diff --git a/.agents/skills/release-changelog/SKILL.md b/.agents/skills/release-changelog/SKILL.md index 55e7b360ea3..022df4ae5b8 100644 --- a/.agents/skills/release-changelog/SKILL.md +++ b/.agents/skills/release-changelog/SKILL.md @@ -168,6 +168,11 @@ Guidelines: - write from the user perspective - keep highlights short and concrete - spell out upgrade actions for breaking changes +- **write at full stable depth from the first pass**: the beta-keyed + draft ships verbatim as the stable's notes, so the previous stable's + file is the density bar the moment the draft is first written — never + leave it at generated-skeleton density for the soak. The skeleton's + nested PR summaries are raw material to rewrite, not a format to keep. - **describe deltas, not repeats**: read the previous stable's notes (`releases/v.md`) before writing. When they already introduced a feature, this release's entry covers only what changed — diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 549595dbe38..a267581aa10 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -194,6 +194,25 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + # PID 1 must be an init that reaps adopted orphans. With node there, the + # orphans agent runs leave behind are never wait()ed and pin as zombies + # until the cgroup pid limit is exhausted and every fork() in the + # container fails. Run against the pushed image rather than a local + # build: the step above is multi-arch with `push: true`, so nothing is + # loaded into the runner's daemon. The cloud variant is FROM production + # and inherits the same ENTRYPOINT, so checking this image covers both. + - name: Verify PID 1 reaps orphaned processes + env: + # Through the environment, not interpolated into the script body, so + # the tag text is data rather than shell. + IMAGE_TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + image="$(printf '%s\n' "$IMAGE_TAGS" | head -n 1)" + test -n "$image" + echo "Verifying orphan reaping in $image" + docker run --rm -i --pull always "$image" sh -s < scripts/assert-orphan-reaping.sh + # The cloud variant carries built bundled plugins for managed deployments # (see the `cloud` stage in the Dockerfile). It runs as its own job with no # `needs:` on the stock publish above, so the two builds run in parallel and diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b1f41389593..e356bb8a096 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -280,6 +280,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify Paperclip Runner + run: pnpm --filter @paperclipai/paperclip-runner check:all + - name: Build run: pnpm build diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 5ec66da8a69..1656d4f16fe 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -38,6 +38,65 @@ on: type: string jobs: + # The Docker smoke below can never exercise the background-service leg of + # onboarding: containers have no service manager, so v2026.824.0 shipped a + # service install that crash-looped on a missing shim while every + # golden-path check stayed green. Run the same published artifact directly + # on the runner VM's systemd and require the installed service to end up + # serving. + smoke_service: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Start a user systemd session + # The hosted runner has no login session for the runner user, so + # `systemctl --user` cannot reach a user manager until lingering + # starts one. Export the session address for the steps below. + run: | + sudo loginctl enable-linger "$(id -un)" + uid="$(id -u)" + for _ in $(seq 1 30); do + [[ -S "/run/user/$uid/bus" ]] && break + sleep 1 + done + [[ -S "/run/user/$uid/bus" ]] + { + echo "XDG_RUNTIME_DIR=/run/user/$uid" + echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus" + } >> "$GITHUB_ENV" + + - name: Onboard with the background service + env: + PAPERCLIPAI_VERSION: ${{ inputs.paperclip_version }} + DATA_DIR: ${{ runner.temp }}/service-smoke-data + SMOKE_CLEANUP: "false" + run: ./scripts/service-onboard-smoke.sh + + - name: Capture service diagnostics + if: always() + run: | + { + systemctl --user --no-pager status paperclipai.service || true + journalctl --user -u paperclipai.service --no-pager || true + } > "$RUNNER_TEMP/paperclipai-service.log" 2>&1 + + - name: Upload service diagnostics + if: always() + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact_name }}-service + path: ${{ runner.temp }}/paperclipai-service.log + retention-days: 14 + smoke: runs-on: ubuntu-latest timeout-minutes: 45 diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index df146e7faac..55d457a24d1 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -182,5 +182,8 @@ jobs: - name: Install dependencies run: pnpm install --no-frozen-lockfile + - name: Verify Paperclip Runner + run: pnpm --filter @paperclipai/paperclip-runner check:all + - name: Build run: pnpm build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29abc996c5c..1f0511e051e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -647,6 +647,9 @@ jobs: timeout-minutes: 10 permissions: contents: write + # gh pr view needs PR read for the skeleton's nested summaries; + # without it the enrichment silently degrades to bare subjects. + pull-requests: read steps: - name: Checkout repository uses: actions/checkout@v7 @@ -661,6 +664,9 @@ jobs: - name: Draft stable notes from the published beta env: + # gh needs a token so the generator can nest each PR's summary + # under its subject line (best-effort thoroughness). + GH_TOKEN: ${{ github.token }} BETA_VERSION: ${{ needs.publish_beta.outputs.beta_version }} SOURCE_SHA: ${{ needs.select_beta.outputs.sha }} run: | diff --git a/AGENTS.md b/AGENTS.md index 3f8581b8084..4547b149c41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,32 @@ When you are creating a plan file in the repository itself, new plan documents b 6. Attach inspectable generated artifacts. When your task produces a user-inspectable deliverable file, follow the Paperclip skill's "Generated Artifacts and Work Products" workflow before final disposition. In this repo, prefer the self-contained skill helper at `skills/paperclip/scripts/paperclip-upload-artifact.sh` so the file is available through the Paperclip API, create/update an artifact work product when the file is the deliverable, link the uploaded artifact in the final issue comment, and then set status. Do not rely on local filesystem paths as the only access path. If an important file intentionally remains workspace-only, create/update a work product with `metadata.resourceRef.kind: "workspace_file"` and a workspace-relative path, then name that work product and path in the final comment. Treat browse/search as a fallback for recovering workspace files, not the preferred deliverable path. See `doc/AGENT-ARTIFACTS.md` for details and `.mp4`/`.webm` examples. +7. Name the three data paths correctly. +This repo has three separate data paths. Do not confuse them. Match a change to a path by its file path, not by the word "observability" or "telemetry" alone. + +- **Telemetry** is the Paperclip first-party event system. It is opt-out and it sends data to a Paperclip endpoint by default. Its paths are: + - `packages/shared/src/telemetry/` + - the generated contract `packages/shared/src/telemetry/generated/paperclip-telemetry.ts` + - each caller of `packages/shared/src/telemetry/events.ts` or `packages/shared/src/telemetry/client.ts` +- **Observability** is the OpenTelemetry trace path. An operator must set an OTLP endpoint. Until an operator sets the endpoint, the tracer is a no-operation. Its paths are: + - `server/src/instrumentation.ts` + - `doc/observability.md` + - `packages/adapter-utils/src/duplex-observability.ts` + - `server/src/services/duplex-observability-recorder.ts` + - the span attributes in `packages/adapter-utils/src/acpx-engine/startup-timing.ts` +- **The run log** holds rows in the local `heartbeat_run_events` table. The data stays in the instance database. Its paths are: + - `doc/run-log-events.md` + - `packages/db/src/schema/heartbeat_run_events.ts` + - the append path `appendRunEvent` in `server/src/services/heartbeat.ts` + +Apply a review level that matches the path: + +- **Telemetry change (strict review).** The author updates the generated contract first. The author updates `packages/shared/src/telemetry/README.md` in the same pull request. The author requests a privacy review. Reason: a Telemetry event goes to a Paperclip endpoint by default, so a mistake sends data immediately. +- **Observability change (lighter review).** The operator endpoint gate stays in place. The no-operation behaviour stays when no endpoint is set. A privacy review is not necessary while the change stays inside the closed span-attribute allowlist. +- **Run-log change (no extra review).** A run-log change needs neither review level above, because the data stays in the instance database. + +**Exclusion.** The word "observability" in a file such as `server/src/services/recovery-observability.ts` names a different concept. Apply this rule by path, not by word match. + ## 6. Database Change Workflow When changing data model: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 152c527f202..a1dab6f5f34 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,7 +104,9 @@ All tests must pass before a PR can be merged. Run them locally first and verify ### Telemetry Changes -If your change adds, removes, or modifies emitted telemetry events, update the [Telemetry Data Contract](packages/shared/src/telemetry/README.md) in the same PR. Keep clients emitting raw dimension values and avoid documenting or relying on private delivery details. +This repo has three separate data paths: Telemetry, Observability, and the run log. See rule 7 in `AGENTS.md` for the full definitions and the review level each path needs. + +If your change adds, removes, or modifies emitted telemetry events, update the [Telemetry Data Contract](packages/shared/src/telemetry/README.md) in the same PR. Keep clients emitting raw dimension values and avoid documenting or relying on private delivery details. If your change adds, removes, or modifies an OpenTelemetry span or span attribute, keep the change inside the closed span-attribute allowlist in `packages/adapter-utils/src/acpx-engine/startup-timing.ts`. If your change adds or modifies a run-log event, update `doc/run-log-events.md` in the same PR. ### Paperclip Gates Must Pass diff --git a/Dockerfile b/Dockerfile index c41520df329..65b64dcd5bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM node:24-trixie-slim AS base ARG USER_UID=1000 ARG USER_GID=1000 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates gosu curl gh git wget ripgrep python3 \ + && apt-get install -y --no-install-recommends ca-certificates gosu curl gh git wget ripgrep python3 tini \ && rm -rf /var/lib/apt/lists/* \ && corepack enable @@ -24,6 +24,7 @@ COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/google-sheets-mcp-server/package.json packages/google-sheets-mcp-server/ COPY packages/kv-demo-mcp-server/package.json packages/kv-demo-mcp-server/ COPY packages/mcp-server/package.json packages/mcp-server/ +COPY packages/paperclip-runner/package.json packages/paperclip-runner/ COPY packages/skills-catalog/package.json packages/skills-catalog/ COPY packages/tailscale-https-broker/package.json packages/tailscale-https-broker/ COPY packages/teams-catalog/package.json packages/teams-catalog/ @@ -118,7 +119,14 @@ ENV NODE_ENV=production \ EXPOSE 3100 -ENTRYPOINT ["docker-entrypoint.sh"] +# tini, not node, is PID 1. The entrypoint ends in `exec`, so without an init +# node inherits PID 1 and never wait()s the orphans the kernel re-parents onto +# it -- agent runs spawn git/claude/esbuild/sh descendants that outlive their +# leader, so they pile up as permanent zombies (~79/h measured) until the +# cgroup pid limit is exhausted and *every* fork() in the container fails. +# tini reaps adopted orphans and forwards signals, so the exec chain below and +# graceful shutdown are unchanged. Mirrors docker/agent-runtime/Dockerfile.base. +ENTRYPOINT ["/usr/bin/tini", "--", "docker-entrypoint.sh"] CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"] # Cloud image variant (build with `--target cloud`): the production image diff --git a/cli/src/__tests__/onboard-service.test.ts b/cli/src/__tests__/onboard-service.test.ts index c4a711f3f31..92697fc675c 100644 --- a/cli/src/__tests__/onboard-service.test.ts +++ b/cli/src/__tests__/onboard-service.test.ts @@ -1,5 +1,30 @@ import { describe, expect, it, vi } from "vitest"; -import { handleOnboardService } from "../onboard-service.js"; +import { + handleOnboardService, + handoffToOnboardedService, + isInstallableReleaseVersion, + resolveOnboardServiceDashboardUrl, + shouldOfferForegroundStart, +} from "../onboard-service.js"; + +function dashboardConfig(overrides: { + host?: string; + port?: number; + baseUrlMode?: "auto" | "explicit"; + publicBaseUrl?: string; +} = {}) { + return { + server: { + host: overrides.host ?? "127.0.0.1", + port: overrides.port ?? 3100, + }, + auth: { + baseUrlMode: overrides.baseUrlMode ?? "auto", + disableSignUp: false, + ...(overrides.publicBaseUrl ? { publicBaseUrl: overrides.publicBaseUrl } : {}), + }, + }; +} function supportedDetection() { return { @@ -24,6 +49,7 @@ function supportedDetection() { pid: 123, })), logs: vi.fn(async () => undefined), + installedExecutablePath: vi.fn(async () => null), }, }; } @@ -48,7 +74,11 @@ describe("onboard service policy", () => { const installed = await handleOnboardService( { yes: true, installService: true }, - { detect: vi.fn(async () => detection), isInteractive: () => false }, + { + detect: vi.fn(async () => detection), + isInteractive: () => false, + ensureServiceShim: vi.fn(async () => ({ ok: true, installedNow: false })), + }, ); expect(installed).toBe(true); @@ -61,7 +91,12 @@ describe("onboard service policy", () => { const installed = await handleOnboardService( {}, - { detect: vi.fn(async () => detection), isInteractive: () => true, confirm }, + { + detect: vi.fn(async () => detection), + isInteractive: () => true, + confirm, + ensureServiceShim: vi.fn(async () => ({ ok: true, installedNow: false })), + }, ); expect(confirm).toHaveBeenCalledOnce(); @@ -81,4 +116,157 @@ describe("onboard service policy", () => { expect(detect).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); }); + + it("materializes the managed shim before installing the service", async () => { + const detection = supportedDetection(); + const success = vi.fn(); + const ensureServiceShim = vi.fn(async () => ({ ok: true, installedNow: true })); + + const installed = await handleOnboardService( + { yes: true, installService: true }, + { detect: vi.fn(async () => detection), isInteractive: () => false, ensureServiceShim, success }, + ); + + expect(installed).toBe(true); + expect(ensureServiceShim).toHaveBeenCalledOnce(); + expect(success).toHaveBeenCalledWith(expect.stringContaining("managed paperclipai payload")); + expect(detection.manager.install).toHaveBeenCalledWith({ startNow: true, startOnLogin: true }); + }); + + it("declines instead of installing a service without a binary", async () => { + const detection = supportedDetection(); + const warn = vi.fn(); + + const installed = await handleOnboardService( + { yes: true, installService: true }, + { + detect: vi.fn(async () => detection), + isInteractive: () => false, + ensureServiceShim: vi.fn(async () => ({ ok: false, installedNow: false, reason: "npm exploded" })), + warn, + }, + ); + + expect(installed).toBe(false); + expect(detection.manager.install).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("npm exploded")); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("paperclipai install")); + }); + +}); + +describe("isInstallableReleaseVersion", () => { + it("accepts calendar releases and rejects placeholders", () => { + expect(isInstallableReleaseVersion("2026.824.1")).toBe(true); + expect(isInstallableReleaseVersion("2026.818.0-beta.1")).toBe(true); + expect(isInstallableReleaseVersion("0.3.1")).toBe(false); + expect(isInstallableReleaseVersion("not-a-version")).toBe(false); + }); +}); + +describe("onboarded service dashboard handoff", () => { + it("resolves a reachable local dashboard URL", () => { + expect(resolveOnboardServiceDashboardUrl(dashboardConfig({ host: "0.0.0.0", port: 4321 }))) + .toBe("http://127.0.0.1:4321"); + expect(resolveOnboardServiceDashboardUrl(dashboardConfig({ host: "::1" }))) + .toBe("http://[::1]:3100"); + }); + + it("uses the configured public URL when auth requires one", () => { + expect(resolveOnboardServiceDashboardUrl(dashboardConfig({ + baseUrlMode: "explicit", + publicBaseUrl: "https://paperclip.example.com/", + }))).toBe("https://paperclip.example.com"); + }); + + it("prints the dashboard URL without opening a browser in non-interactive runs", async () => { + const info = vi.fn(); + const waitUntilReady = vi.fn(async () => ({ + schemaVersion: 1 as const, + instanceId: "default", + pid: 123, + host: "127.0.0.1", + port: 3100, + dashboardUrl: "http://127.0.0.1:3100", + startedAt: "2026-08-25T00:00:00.000Z", + })); + const openDashboard = vi.fn(async () => true); + + await handoffToOnboardedService(dashboardConfig(), { + isInteractive: () => false, + waitUntilReady, + openDashboard, + info, + }); + + expect(info).toHaveBeenCalledWith(expect.stringContaining("http://127.0.0.1:3100")); + expect(waitUntilReady).toHaveBeenCalledOnce(); + expect(openDashboard).not.toHaveBeenCalled(); + }); + + it("uses the ready service runtime port before opening the dashboard", async () => { + const waitUntilReady = vi.fn(async () => ({ + schemaVersion: 1 as const, + instanceId: "default", + pid: 123, + host: "127.0.0.1", + port: 3101, + dashboardUrl: "http://127.0.0.1:3101", + startedAt: "2026-08-25T00:00:00.000Z", + })); + const openDashboard = vi.fn(async () => true); + const success = vi.fn(); + + await handoffToOnboardedService(dashboardConfig(), { + isInteractive: () => true, + waitUntilReady, + openDashboard, + info: vi.fn(), + success, + }); + + expect(waitUntilReady).toHaveBeenCalledOnce(); + expect(openDashboard).toHaveBeenCalledWith("http://127.0.0.1:3101"); + expect(success).toHaveBeenCalledWith(expect.stringContaining("Sent")); + }); + + it("keeps the manual link and warns when service health does not become ready", async () => { + const openDashboard = vi.fn(async () => true); + const warn = vi.fn(); + + await handoffToOnboardedService(dashboardConfig(), { + isInteractive: () => true, + waitUntilReady: vi.fn(async () => null), + openDashboard, + info: vi.fn(), + warn, + }); + + expect(openDashboard).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("paperclipai service logs")); + }); +}); + +describe("shouldOfferForegroundStart", () => { + const base = { serviceInstalled: false, startAlreadyDecided: false, invokedByRun: false, interactive: true }; + + it("offers a foreground start on a plain interactive onboard", () => { + expect(shouldOfferForegroundStart(base)).toBe(true); + }); + + it("never prompts after the service was installed and started", () => { + expect(shouldOfferForegroundStart({ ...base, serviceInstalled: true })).toBe(false); + }); + + it("never prompts when the start decision was already made by flags", () => { + expect(shouldOfferForegroundStart({ ...base, startAlreadyDecided: true })).toBe(false); + }); + + it("never prompts when run itself invoked onboarding", () => { + expect(shouldOfferForegroundStart({ ...base, invokedByRun: true })).toBe(false); + }); + + it("never prompts without an interactive terminal", () => { + expect(shouldOfferForegroundStart({ ...base, interactive: false })).toBe(false); + }); }); diff --git a/cli/src/__tests__/runtime-info.test.ts b/cli/src/__tests__/runtime-info.test.ts new file mode 100644 index 00000000000..eea58f3a050 --- /dev/null +++ b/cli/src/__tests__/runtime-info.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + readRuntimeInfo, + removeRuntimeInfoForPid, + writeRuntimeInfo, + type PaperclipRuntimeInfo, +} from "../runtime-info.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function fixture(): { filePath: string; info: PaperclipRuntimeInfo } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-runtime-info-")); + roots.push(root); + return { + filePath: path.join(root, "runtime-info.json"), + info: { + schemaVersion: 1, + instanceId: "default", + pid: 123, + host: "127.0.0.1", + port: 3101, + dashboardUrl: "http://127.0.0.1:3101", + startedAt: "2026-08-25T00:00:00.000Z", + }, + }; +} + +describe("runtime info", () => { + it("writes and reads the selected runtime endpoint", () => { + const { filePath, info } = fixture(); + writeRuntimeInfo(info, filePath); + expect(readRuntimeInfo("default", filePath)).toEqual(info); + }); + + it("does not remove runtime info owned by a replacement process", () => { + const { filePath, info } = fixture(); + writeRuntimeInfo(info, filePath); + removeRuntimeInfoForPid(999, "default", filePath); + expect(readRuntimeInfo("default", filePath)).toEqual(info); + removeRuntimeInfoForPid(info.pid, "default", filePath); + expect(readRuntimeInfo("default", filePath)).toBeNull(); + }); + + it("rejects malformed runtime info", () => { + const { filePath } = fixture(); + fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, port: 70_000 })); + expect(readRuntimeInfo("default", filePath)).toBeNull(); + }); +}); diff --git a/cli/src/__tests__/service-health-check.test.ts b/cli/src/__tests__/service-health-check.test.ts index d913b2771da..cb4eb298e3c 100644 --- a/cli/src/__tests__/service-health-check.test.ts +++ b/cli/src/__tests__/service-health-check.test.ts @@ -3,6 +3,13 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { serviceHealthChecks } from "../checks/service-health-check.js"; +import { + extractExecutableFromLaunchdPlist, + extractExecutableFromSystemdUnit, + isExecutableFile, + renderLaunchdPlist, + renderSystemdUnit, +} from "../services/service-manager.js"; import { resolveRestartExpectedVersion, withHotRestartLock } from "../commands/service.js"; import type { PaperclipConfig } from "../config/schema.js"; import { buildLocalHealthUrl } from "../utils/health-url.js"; @@ -52,6 +59,7 @@ function managerFixture(active = true) { linger: true, })), logs: vi.fn(async () => undefined), + installedExecutablePath: vi.fn(async () => null), }; } @@ -129,6 +137,7 @@ describe("service health doctor checks", () => { const results = await serviceHealthChecks(config, { detect: vi.fn(async () => ({ supported: true as const, manager })), probe: vi.fn(async () => ({ ok: true, version: "1.2.3" })), + shimPresent: vi.fn(async () => true), }); expect(results).toContainEqual( @@ -140,3 +149,107 @@ describe("service health doctor checks", () => { ); }); }); + +describe("isExecutableFile", () => { + it("accepts only executable regular files", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "shim-check-")); + const executable = path.join(dir, "exec"); + const plain = path.join(dir, "plain"); + fs.writeFileSync(executable, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(plain, "data", { mode: 0o644 }); + + await expect(isExecutableFile(executable)).resolves.toBe(true); + await expect(isExecutableFile(plain)).resolves.toBe(false); + await expect(isExecutableFile(dir)).resolves.toBe(false); + await expect(isExecutableFile(path.join(dir, "missing"))).resolves.toBe(false); + }); +}); + +describe("service runtime shim awareness", () => { + function inactiveManager() { + return { + platform: "launchd" as const, + instanceId: "default", + serviceName: "ing.paperclip.paperclipai", + definitionPath: "/tmp/nonexistent-definition.plist", + renderDefinition: () => "plist", + install: vi.fn(async () => ({ changed: false })), + uninstall: vi.fn(async () => undefined), + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + restart: vi.fn(async () => undefined), + status: vi.fn(async () => ({ + platform: "launchd" as const, + serviceName: "ing.paperclip.paperclipai", + installed: true, + active: false, + enabled: true, + pid: null, + detail: "loaded", + })), + logs: vi.fn(async () => undefined), + installedExecutablePath: vi.fn(async (): Promise => null), + }; + } + + it("blames the missing binary, not a port conflict, when the shim is gone", async () => { + const results = await serviceHealthChecks({} as never, { + detect: vi.fn(async () => ({ supported: true as const, manager: inactiveManager() as never })), + probe: vi.fn(async () => ({ ok: false, version: null, error: "fetch failed" })), + shimPresent: vi.fn(async () => false), + }); + const runtime = results.find((r) => r.name === "Service runtime"); + expect(runtime?.status).toBe("fail"); + expect(runtime?.message).toContain("no executable exists at"); + expect(runtime?.repairHint).toContain("paperclipai install"); + }); + + it("diagnoses against the executable recorded in the definition, not the current env", async () => { + const manager = inactiveManager(); + manager.installedExecutablePath = vi.fn(async () => "/custom/bin/paperclipai"); + const shimPresent = vi.fn(async () => false); + const results = await serviceHealthChecks({} as never, { + detect: vi.fn(async () => ({ supported: true as const, manager: manager as never })), + probe: vi.fn(async () => ({ ok: false, version: null, error: "fetch failed" })), + shimPresent, + }); + const runtime = results.find((r) => r.name === "Service runtime"); + expect(shimPresent).toHaveBeenCalledWith("/custom/bin/paperclipai"); + expect(runtime?.message).toContain("/custom/bin/paperclipai"); + expect(runtime?.repairHint).toContain("/custom/bin/paperclipai"); + expect(runtime?.repairHint).toContain("unset PAPERCLIP_SHIM_PATH"); + expect(runtime?.repairHint).toContain("`paperclipai install` followed by `paperclipai service install`"); + }); + + it("attributes a healthy foreign responder instead of reporting Healthy", async () => { + const results = await serviceHealthChecks({} as never, { + detect: vi.fn(async () => ({ supported: true as const, manager: inactiveManager() as never })), + probe: vi.fn(async () => ({ ok: true, version: "9.9.9" })), + shimPresent: vi.fn(async () => true), + }); + const healthResult = results.find((r) => r.name === "Service health"); + expect(healthResult?.status).toBe("warn"); + expect(healthResult?.message).toContain("but not from ing.paperclip.paperclipai"); + const runtime = results.find((r) => r.name === "Service runtime"); + expect(runtime?.message).toContain("serving another Paperclip process"); + }); +}); + +describe("definition executable extraction", () => { + it("round-trips through both renderers", () => { + const unit = renderSystemdUnit({ instanceId: "default", shimPath: "/custom/bin/paperclipai", homeDir: "/home/x/.paperclip" }); + expect(extractExecutableFromSystemdUnit(unit)).toBe("/custom/bin/paperclipai"); + const plist = renderLaunchdPlist({ instanceId: "default", shimPath: "/custom/bin/paperclipai", homeDir: "/home/x/.paperclip", stdoutPath: "/tmp/o.log", stderrPath: "/tmp/e.log" }); + expect(extractExecutableFromLaunchdPlist(plist)).toBe("/custom/bin/paperclipai"); + expect(extractExecutableFromSystemdUnit("garbage")).toBe(null); + expect(extractExecutableFromLaunchdPlist("garbage")).toBe(null); + }); + + it("round-trips paths the renderers escape", () => { + const hostile = '/tmp/we"ird $pa%th & /paperclipai'; + const unit = renderSystemdUnit({ instanceId: "default", shimPath: hostile, homeDir: "/home/x/.paperclip" }); + expect(extractExecutableFromSystemdUnit(unit)).toBe(hostile); + const plist = renderLaunchdPlist({ instanceId: "default", shimPath: hostile, homeDir: "/home/x/.paperclip", stdoutPath: "/tmp/o.log", stderrPath: "/tmp/e.log" }); + expect(extractExecutableFromLaunchdPlist(plist)).toBe(hostile); + }); +}); diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 3e5d3444d42..42112836f2f 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -1610,7 +1610,13 @@ describe("worktree helpers", () => { fs.rmSync(tempRoot, { recursive: true, force: true }); } }, - 30_000, + // This test starts three separate embedded Postgres lifecycles (the + // source database, the worktree init's internal target database, and a + // third instance opened here to verify the seeded rows), so it needs + // more headroom than the other embedded-Postgres tests in this file. + // It normally finishes in well under 10s; the 60s budget absorbs CI + // runner contention without masking a real hang. + 60_000, ); itEmbeddedPostgres( diff --git a/cli/src/checks/service-health-check.ts b/cli/src/checks/service-health-check.ts index 27f3a3a3ad7..5a4aadd6db8 100644 --- a/cli/src/checks/service-health-check.ts +++ b/cli/src/checks/service-health-check.ts @@ -1,9 +1,12 @@ import fs from "node:fs/promises"; +import path from "node:path"; import type { PaperclipConfig } from "../config/schema.js"; import { resolvePaperclipInstanceId } from "../config/home.js"; -import { readInstallManifest } from "../install-store.js"; +import { readInstallManifest, resolveInstallStorePaths } from "../install-store.js"; import { detectServiceManager, + isExecutableFile, + resolveServiceShimPath, type ServiceManagerDetection, } from "../services/service-manager.js"; import { buildLocalHealthUrl } from "../utils/health-url.js"; @@ -13,6 +16,7 @@ type HealthResult = { ok: boolean; version: string | null; error?: string }; type ServiceCheckDependencies = { detect: (instanceId: string) => Promise; probe: (config: PaperclipConfig) => Promise; + shimPresent: (executablePath: string) => Promise; }; async function probeHealth(config: PaperclipConfig): Promise { @@ -45,6 +49,7 @@ export async function serviceHealthChecks( const deps: ServiceCheckDependencies = { detect: (instanceId) => detectServiceManager({ instanceId }), probe: probeHealth, + shimPresent: (executablePath) => isExecutableFile(executablePath), ...dependencies, }; const instanceId = resolvePaperclipInstanceId(); @@ -84,17 +89,36 @@ export async function serviceHealthChecks( ); const health = await deps.probe(config); + // The installed definition is the truth about what the service executes; + // fall back to the environment-derived path only when it is unreadable. + const serviceExecutable = (await manager.installedExecutablePath()) ?? resolveServiceShimPath(); + const shimPresent = status.active ? true : await deps.shimPresent(serviceExecutable); results.push( status.active ? { name: "Service runtime", status: "pass", message: `${status.serviceName} is active` } - : { - name: "Service runtime", - status: "fail", - message: health.ok - ? `${status.serviceName} is inactive but the configured port is serving another Paperclip process` - : `${status.serviceName} is ${status.detail ?? "inactive"}`, - repairHint: "Run `paperclipai service start`, or stop the conflicting foreground process first", - }, + : !shimPresent + ? { + name: "Service runtime", + status: "fail", + message: `${status.serviceName} cannot start: no executable exists at ${serviceExecutable}`, + repairHint: + path.resolve(serviceExecutable) === path.resolve(resolveInstallStorePaths().shimPath) + ? "Run `paperclipai install` to restore the managed payload and shim, then `paperclipai service start`" + : `Restore the executable at ${serviceExecutable}, or unset PAPERCLIP_SHIM_PATH and run \`paperclipai install\` followed by \`paperclipai service install\` to re-point the service at the managed shim`, + } + : health.ok + ? { + name: "Service runtime", + status: "fail", + message: `${status.serviceName} is inactive but the configured port is serving another Paperclip process`, + repairHint: "Run `paperclipai service start`, or stop the conflicting foreground process first", + } + : { + name: "Service runtime", + status: "fail", + message: `${status.serviceName} is ${status.detail ?? "inactive"}`, + repairHint: "Run `paperclipai service start`; inspect `paperclipai service logs` if it does not stay up", + }, ); let expectedVersion: string | null = null; @@ -116,11 +140,17 @@ export async function serviceHealthChecks( message: `Running ${health.version ?? "unknown"}; managed install is ${expectedVersion}`, repairHint: "Run `paperclipai service restart --expected-version " + expectedVersion + "`", } - : { - name: "Service health", - status: "pass", - message: `Healthy${health.version ? ` at version ${health.version}` : ""}`, - }, + : status.active + ? { + name: "Service health", + status: "pass", + message: `Healthy${health.version ? ` at version ${health.version}` : ""}`, + } + : { + name: "Service health", + status: "warn", + message: `The configured port answers healthy${health.version ? ` (version ${health.version})` : ""}, but not from ${status.serviceName} — the service is inactive`, + }, ); if (status.enabled && status.linger === false) { diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 31850714d73..714741b2581 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -52,7 +52,11 @@ import { trackInstallStarted, trackInstallCompleted, } from "../telemetry.js"; -import { handleOnboardService } from "../onboard-service.js"; +import { + handleOnboardService, + handoffToOnboardedService, + shouldOfferForegroundStart, +} from "../onboard-service.js"; import { readInstallManifest, isManagedExecutable } from "../install-store.js"; type SetupMode = "quickstart" | "advanced"; @@ -457,9 +461,12 @@ export async function onboard(opts: OnboardOptions): Promise { printManagedInstallHint(); const serviceInstalled = await handleOnboardService(opts); + if (serviceInstalled) { + await handoffToOnboardedService(existingConfig); + } let shouldRunNow = !serviceInstalled && (opts.run === true || opts.yes === true); - if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) { + if (shouldOfferForegroundStart({ serviceInstalled, startAlreadyDecided: shouldRunNow, invokedByRun: opts.invokedByRun === true, interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY) })) { const answer = await p.confirm({ message: "Start Paperclip now?", initialValue: true, @@ -723,9 +730,12 @@ export async function onboard(opts: OnboardOptions): Promise { } const serviceInstalled = await handleOnboardService(opts); + if (serviceInstalled) { + await handoffToOnboardedService(config); + } let shouldRunNow = !serviceInstalled && (opts.run === true || opts.yes === true); - if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) { + if (shouldOfferForegroundStart({ serviceInstalled, startAlreadyDecided: shouldRunNow, invokedByRun: opts.invokedByRun === true, interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY) })) { const answer = await p.confirm({ message: "Start Paperclip now?", initialValue: true, diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index e9003e8949d..9269851bbda 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -17,6 +17,7 @@ import { resolvePaperclipInstanceId, } from "../config/home.js"; import { assertForegroundRunAllowed } from "../services/service-manager.js"; +import { removeRuntimeInfoForPid, writeRuntimeInfo } from "../runtime-info.js"; import { printUpdateNotice } from "../update-notice.js"; import { ensureWorktreeSeeded } from "./worktree.js"; @@ -93,6 +94,16 @@ export async function runCommand(opts: RunOptions): Promise { p.log.step("Starting Paperclip server..."); const startedServer = await importServerEntry(); + writeRuntimeInfo({ + schemaVersion: 1, + instanceId, + pid: process.pid, + host: startedServer.host, + port: startedServer.listenPort, + dashboardUrl: startedServer.apiUrl.replace(/\/api\/?$/, ""), + startedAt: new Date().toISOString(), + }); + process.once("exit", () => removeRuntimeInfoForPid(process.pid, instanceId)); if (shouldGenerateBootstrapInviteAfterStart(config)) { p.log.step("Generating bootstrap CEO invite"); diff --git a/cli/src/onboard-service.ts b/cli/src/onboard-service.ts index 50cc8e052f6..52a115953bb 100644 --- a/cli/src/onboard-service.ts +++ b/cli/src/onboard-service.ts @@ -1,18 +1,130 @@ +import path from "node:path"; import * as p from "@clack/prompts"; import pc from "picocolors"; +import type { PaperclipConfig } from "./config/schema.js"; +import { openUrl } from "./client/board-auth.js"; +import { installCommand } from "./commands/install.js"; import { resolvePaperclipInstanceId } from "./config/home.js"; +import { readRuntimeInfo, type PaperclipRuntimeInfo } from "./runtime-info.js"; +import { + readInstallManifest, + resolveInstallStorePaths, + type InstallManifest, +} from "./install-store.js"; import { detectServiceManager, + isExecutableFile, + resolveServiceShimPath, type ServiceManagerDetection, } from "./services/service-manager.js"; +import { buildLocalAppUrl, buildLocalHealthUrl } from "./utils/health-url.js"; +import { packageVersion } from "./version.js"; export type OnboardServiceOptions = { yes?: boolean; installService?: boolean; }; +type EnsureShimResult = { ok: boolean; installedNow: boolean; reason?: string }; +type OnboardServiceDashboardConfig = { + auth: Pick; + server: Pick; +}; + +type OnboardServiceDashboardDependencies = { + isInteractive: () => boolean; + waitUntilReady: () => Promise; + openDashboard: (url: string) => Promise; + info: (message: string) => void; + success: (message: string) => void; + warn: (message: string) => void; +}; + +function envDisablesBrowser(value = process.env.PAPERCLIP_NO_BROWSER): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} + +async function waitUntilDashboardReady(timeoutMs = 60_000): Promise { + const instanceId = resolvePaperclipInstanceId(); + const detection = await detectServiceManager({ instanceId }); + if (!detection.supported) return null; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const info = readRuntimeInfo(instanceId); + if (info) { + const status = await detection.manager.status().catch(() => null); + if (status?.active && status.pid === info.pid) { + try { + const response = await fetch(buildLocalHealthUrl(info.host, info.port), { + signal: AbortSignal.timeout(2_000), + }); + const body = await response.json() as { status?: unknown }; + if (response.ok && body.status === "ok") return info; + } catch {} + } + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return null; +} + +const defaultDashboardDependencies: OnboardServiceDashboardDependencies = { + isInteractive: () => process.stdin.isTTY === true && process.stdout.isTTY === true, + waitUntilReady: waitUntilDashboardReady, + openDashboard: openUrl, + info: (message) => p.log.info(message), + success: (message) => p.log.success(message), + warn: (message) => p.log.warn(message), +}; + +export function resolveOnboardServiceDashboardUrl( + config: OnboardServiceDashboardConfig, + runtime?: Pick | null, +): string { + if (runtime?.dashboardUrl.trim()) return runtime.dashboardUrl.trim().replace(/\/+$/, ""); + if (config.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl?.trim()) { + return config.auth.publicBaseUrl.trim().replace(/\/+$/, ""); + } + return buildLocalAppUrl(config.server.host, config.server.port); +} + +export async function handoffToOnboardedService( + config: OnboardServiceDashboardConfig, + dependencies: Partial = {}, +): Promise { + const deps = { ...defaultDashboardDependencies, ...dependencies }; + const runtime = await deps.waitUntilReady(); + const dashboardUrl = resolveOnboardServiceDashboardUrl(config, runtime); + deps.info(`Paperclip dashboard: ${pc.cyan(dashboardUrl)}`); + + if (!runtime) { + deps.warn( + `The background service started, but the dashboard is not ready yet. ` + + `Open ${dashboardUrl} after checking \`paperclipai service logs\`.`, + ); + return; + } + + if (!deps.isInteractive() || envDisablesBrowser()) return; + + if (await deps.openDashboard(dashboardUrl)) { + deps.success("Sent the Paperclip dashboard to your browser."); + } else { + deps.warn(`Could not open a browser automatically. Open ${dashboardUrl} manually.`); + } +} + +// Source checkouts carry the repository placeholder version; installing +// that as an npm spec would fetch an ancient release (or nothing) instead +// of the running code. Only real calendar versions are installable. +export function isInstallableReleaseVersion(version: string): boolean { + return /^\d{4}\.\d{1,4}\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version); +} + type OnboardServiceDependencies = { detect: (instanceId: string) => Promise; + ensureServiceShim: () => Promise; confirm: () => Promise; confirmLinger: () => Promise; isInteractive: () => boolean; @@ -23,6 +135,60 @@ type OnboardServiceDependencies = { const defaultDependencies: OnboardServiceDependencies = { detect: (instanceId) => detectServiceManager({ instanceId }), + // The service definition targets the managed shim. An ephemeral run (npx) + // never lays it down, so installing the service without this step creates + // a definition that crash-loops on a missing binary. + ensureServiceShim: async () => { + const shimPath = resolveServiceShimPath(); + if (await isExecutableFile(shimPath)) { + return { ok: true, installedNow: false }; + } + const storeShimPath = resolveInstallStorePaths().shimPath; + if (path.resolve(shimPath) !== path.resolve(storeShimPath)) { + return { + ok: false, + installedNow: false, + reason: `no executable exists at ${shimPath} (PAPERCLIP_SHIM_PATH), and it is outside the managed install store`, + }; + } + let manifest: InstallManifest | null = null; + try { + manifest = readInstallManifest(); + } catch {} + try { + if (manifest?.source === "git" && manifest.repo) { + // A managed git payload must be preserved as-is: reinstall the + // exact revision the manifest records, not an npm release. + await installCommand({ repo: manifest.repo, ref: manifest.sha ?? manifest.ref, yes: true }); + } else if (isInstallableReleaseVersion(packageVersion)) { + // packageVersion, not cliVersion: a managed executable's cliVersion + // carries provenance text that is not an installable npm spec. + await installCommand({ version: packageVersion, yes: true }); + } else { + return { + ok: false, + installedNow: false, + reason: + `this build reports version ${packageVersion}, which is not an installable release; ` + + "run `paperclipai install` (or `paperclipai install --repo --ref ` for source builds) first", + }; + } + } catch (error) { + return { + ok: false, + installedNow: false, + reason: error instanceof Error ? error.message : String(error), + }; + } + if (await isExecutableFile(shimPath)) { + return { ok: true, installedNow: true }; + } + return { + ok: false, + installedNow: false, + reason: `the managed install completed but no executable shim appeared at ${shimPath}`, + }; + }, confirm: async () => { const answer = await p.confirm({ message: "Install Paperclip as a background service?", @@ -68,6 +234,22 @@ export async function handleOnboardService( if (!explicitlyRequested && !(await deps.confirm())) return false; + // A definition pointing at a missing binary crash-loops in the platform + // supervisor's penalty box while doctor blames a port conflict. + // Materialize the managed install first, or decline with the repair path + // instead of installing a corpse. + const shim = await deps.ensureServiceShim(); + if (!shim.ok) { + deps.warn( + `Background service not installed: ${shim.reason ?? "the managed install could not be completed"}. ` + + "Run `paperclipai install`, then `paperclipai service install`.", + ); + return false; + } + if (shim.installedNow) { + deps.success("Installed the managed paperclipai payload and command shim for the service."); + } + await detection.manager.install({ startNow: true, startOnLogin: true }); if (!explicitlyRequested && detection.manager.enableLinger && await deps.confirmLinger()) { await detection.manager.enableLinger(); @@ -75,3 +257,20 @@ export async function handleOnboardService( deps.success(`Installed and started ${detection.manager.serviceName}.`); return true; } + +// Onboarding falls back to offering a foreground start when nothing else +// will serve. A just-installed service is already serving, so offering the +// start would only run the user into the already-running instance guard. +export function shouldOfferForegroundStart(options: { + serviceInstalled: boolean; + startAlreadyDecided: boolean; + invokedByRun: boolean; + interactive: boolean; +}): boolean { + return ( + !options.startAlreadyDecided && + !options.serviceInstalled && + !options.invokedByRun && + options.interactive + ); +} diff --git a/cli/src/runtime-info.ts b/cli/src/runtime-info.ts new file mode 100644 index 00000000000..6c2287bdc77 --- /dev/null +++ b/cli/src/runtime-info.ts @@ -0,0 +1,82 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolvePaperclipInstanceRoot } from "./config/home.js"; + +export const PAPERCLIP_RUNTIME_INFO_FILENAME = "runtime-info.json"; + +export type PaperclipRuntimeInfo = { + schemaVersion: 1; + instanceId: string; + pid: number; + host: string; + port: number; + dashboardUrl: string; + startedAt: string; +}; + +export function resolveRuntimeInfoPath(instanceId?: string): string { + return path.join(resolvePaperclipInstanceRoot(instanceId), PAPERCLIP_RUNTIME_INFO_FILENAME); +} + +function parseRuntimeInfo(value: unknown): PaperclipRuntimeInfo | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + if ( + record.schemaVersion !== 1 || + typeof record.instanceId !== "string" || + !Number.isInteger(record.pid) || + (record.pid as number) <= 0 || + typeof record.host !== "string" || + !Number.isInteger(record.port) || + (record.port as number) <= 0 || + (record.port as number) > 65_535 || + typeof record.dashboardUrl !== "string" || + typeof record.startedAt !== "string" + ) { + return null; + } + return record as PaperclipRuntimeInfo; +} + +export function readRuntimeInfo(instanceId?: string, filePath = resolveRuntimeInfoPath(instanceId)): PaperclipRuntimeInfo | null { + try { + const info = parseRuntimeInfo(JSON.parse(fs.readFileSync(filePath, "utf8"))); + if (!info) return null; + if (instanceId && info.instanceId !== instanceId) return null; + return info; + } catch { + return null; + } +} + +export function writeRuntimeInfo( + info: PaperclipRuntimeInfo, + filePath = resolveRuntimeInfoPath(info.instanceId), +): void { + const directoryPath = path.dirname(filePath); + fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 }); + const temporaryPath = path.join( + directoryPath, + `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}`, + ); + try { + fs.writeFileSync(temporaryPath, `${JSON.stringify(info, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + flag: "wx", + }); + fs.renameSync(temporaryPath, filePath); + } finally { + fs.rmSync(temporaryPath, { force: true }); + } +} + +export function removeRuntimeInfoForPid( + pid: number, + instanceId?: string, + filePath = resolveRuntimeInfoPath(instanceId), +): void { + const current = readRuntimeInfo(instanceId, filePath); + if (current?.pid !== pid) return; + fs.rmSync(filePath, { force: true }); +} diff --git a/cli/src/services/service-manager.ts b/cli/src/services/service-manager.ts index f284afa59b4..5267d5e1451 100644 --- a/cli/src/services/service-manager.ts +++ b/cli/src/services/service-manager.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFile } from "node:child_process"; @@ -33,6 +34,7 @@ export interface ServiceManager { restart(): Promise; status(): Promise; logs(follow: boolean, lines: number): Promise; + installedExecutablePath(): Promise; enableLinger?(): Promise; } @@ -75,6 +77,45 @@ export function resolveServiceShimPath(homeDir = os.homedir()): string { return process.env.PAPERCLIP_SHIM_PATH?.trim() || path.join(homeDir, ".local", "bin", "paperclipai"); } +// The installed definition, not the current environment, is the truth +// about what the service executes: PAPERCLIP_SHIM_PATH may have changed +// or been unset since the definition was written. +function unescapeSystemd(value: string): string { + return value.replace(/\\\\|\\"|\$\$|%%/g, (m) => + m === "\\\\" ? "\\" : m === '\\"' ? '"' : m === "$$" ? "$" : "%", + ); +} + +function unescapeXml(value: string): string { + return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => + name === "amp" ? "&" : name === "lt" ? "<" : name === "gt" ? ">" : name === "quot" ? '"' : "'", + ); +} + +export function extractExecutableFromSystemdUnit(content: string): string | null { + const match = content.match(/^ExecStart="((?:\\.|[^"\\])*)"/m); + return match ? unescapeSystemd(match[1]) : null; +} + +export function extractExecutableFromLaunchdPlist(content: string): string | null { + const match = content.match(/ProgramArguments<\/key>\s*\s*([^<]+)<\/string>/); + return match ? unescapeXml(match[1]) : null; +} + +// The service definition executes this path directly: existence is not +// enough — a directory or a non-executable file would satisfy fs.access's +// default mode and still crash the supervisor at spawn. +export async function isExecutableFile(filePath: string): Promise { + try { + const stats = await fs.stat(filePath); + if (!stats.isFile()) return false; + await fs.access(filePath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + export function systemdServiceName(instanceId: string): string { return instanceId === "default" ? "paperclipai.service" : `paperclipai-${instanceId}.service`; } @@ -174,6 +215,14 @@ export class SystemdServiceManager implements ServiceManager { return renderSystemdUnit({ instanceId: this.instanceId, shimPath: this.shimPath, homeDir: this.homeDir }); } + async installedExecutablePath(): Promise { + try { + return extractExecutableFromSystemdUnit(await fs.readFile(this.definitionPath, "utf8")); + } catch { + return null; + } + } + private async ensureCurrent(): Promise { const changed = await writeIfChanged(this.definitionPath, this.renderDefinition()); if (changed) await this.runner("systemctl", ["--user", "daemon-reload"]); @@ -242,6 +291,14 @@ export class LaunchdServiceManager implements ServiceManager { renderDefinition(): string { return renderLaunchdPlist({ instanceId: this.instanceId, shimPath: this.shimPath, homeDir: this.homeDir, stdoutPath: this.stdoutPath, stderrPath: this.stderrPath }); } + async installedExecutablePath(): Promise { + try { + return extractExecutableFromLaunchdPlist(await fs.readFile(this.definitionPath, "utf8")); + } catch { + return null; + } + } + async install(options: ServiceInstallOptions): Promise<{ changed: boolean }> { await fs.mkdir(path.dirname(this.stdoutPath), { recursive: true }); const changed = await writeIfChanged(this.definitionPath, this.renderDefinition()); diff --git a/cli/src/utils/health-url.ts b/cli/src/utils/health-url.ts index 12f20312ffa..ed3d95c15ee 100644 --- a/cli/src/utils/health-url.ts +++ b/cli/src/utils/health-url.ts @@ -1,4 +1,4 @@ -export function buildLocalHealthUrl(host: string | undefined, port: number): string { +export function buildLocalAppUrl(host: string | undefined, port: number): string { const configuredHost = host?.trim(); const reachableHost = !configuredHost || configuredHost === "0.0.0.0" || configuredHost === "::" ? "127.0.0.1" @@ -6,5 +6,9 @@ export function buildLocalHealthUrl(host: string | undefined, port: number): str const urlHost = reachableHost.includes(":") && !reachableHost.startsWith("[") ? `[${reachableHost}]` : reachableHost; - return `http://${urlHost}:${port}/api/health`; + return `http://${urlHost}:${port}`; +} + +export function buildLocalHealthUrl(host: string | undefined, port: number): string { + return `${buildLocalAppUrl(host, port)}/api/health`; } diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 83fa2cbb5dc..e9c81b082ed 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -198,6 +198,39 @@ Triage writes serialize on the company and attention-source identity so concurre `decision_retention` tracks the last observed source `activityAt`, Keep, reversible archive provenance, and monotonic source/archive versions. `decision_archive_notification_outbox` has a unique key over company, source identity, archive version, and immutable origin agent so repeated sweeps cannot enqueue duplicate notifications; delivery claims are retryable and coalesced per agent. +## Native runner persistence + +Native runner state is additive to the existing heartbeat tables. Every existing +`heartbeat_runs` row defaults to `runtime_mode = 'legacy'`; adding these columns +does not select the native runtime or start a runner process. Native execution can +record its resolved runtime profile, provider session, driver, completion +contract, durable event cursor, and finalization phase on the run when a later +rollout explicitly selects it. + +`completion_contracts`, `native_run_results`, `native_run_finalizations`, +`work_assessments`, `status_decisions`, and `status_decision_effects` form the +append-oriented evidence and status-decision chain. Unique fingerprints, +versions, ordinals, and idempotency keys make retries deterministic. Composite +foreign keys bind every contract, result, assessment, decision, effect, and +finalization to one company, issue, and run. The database rejects mixed-owner +evidence even when every referenced ID exists. Native source identities on +`heartbeat_run_events` are nullable so legacy events remain readable without +rewriting historical rows. Per-run native source identifiers are unique, while +the existing legacy sequence behavior remains unchanged. The hidden native +coordinator serializes on its bound `heartbeat_runs` row, allocates +`next_event_seq`, and commits a validated PRP event before the transport sends +its cumulative ACK. Byte-equivalent source retries return the existing cursor; +gaps and conflicting replays fail closed. Accepted structured results enter the +finalization ledger, whose retry time and owner lease are checked under a row +lock. None of these writes selects a runtime or changes a legacy run's execution +path. + +Issue `status_version` advances only when `status` changes. The JavaScript backup +path includes user-defined functions and triggers so a restored database keeps +that invariant. Removing or disabling a future native rollout flag must not +delete these records; persisted experimental runs remain available for recovery +and inspection. + ## Plugin database namespaces The plugin runtime tracks plugin-owned database namespaces and migrations in `plugin_database_namespaces` and `plugin_migrations`. Hosted deployments that separate runtime and migration connections should set `DATABASE_MIGRATION_URL`; plugin namespace migration work uses the migration connection when present. diff --git a/doc/INSTALLING.md b/doc/INSTALLING.md index c817645e2ba..26f09528454 100644 --- a/doc/INSTALLING.md +++ b/doc/INSTALLING.md @@ -138,6 +138,11 @@ paperclipai onboard --yes --install-service # explicit automation opt-in paperclipai onboard --yes --no-install-service ``` +After onboarding installs and starts the service, it waits for the service to +report its selected runtime port and then prints the dashboard URL. Interactive +terminals open that URL in the default browser; headless and non-interactive +runs print the URL without trying to launch a browser. + Service commands are namespaced: ```sh diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 0bad4eecd32..b8620c169a6 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -558,7 +558,7 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and |---|---|---| | Create company | yes | no | | Hire/create agent | yes (direct) | request via approval | -| Pause/resume agent | yes | no | +| Pause/resume agent | yes | pause: no; resume: direct `agents:configure` grant only | | Create/update task | yes | yes | | Force reassign task | yes | limited | | Approve strategy/hire requests | yes | no | @@ -569,6 +569,13 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and | Manage another user's inbox state | yes | saved target-user opt-in or scoped `inbox:manage` grant | | Set work-object visibility (issue/project) | no | no (pro gate) | +Agent resume is the only grant-gated exception in the lifecycle-route group. An +agent actor calling `POST /agents/:agentId/resume` must pass the protected +`agent_config:update` decision with `scope.requiresChangeGrant: true`; self +access does not bypass that decision, and `agents:suggest-changes` alone cannot +apply the lifecycle change. Pause, clear-error, terminate, approval, and +key-management routes remain board-only. + ### 9.3.1 Shared default-open issue writes For standard-trust agents, issue comments, issue field/status updates, child diff --git a/doc/acp-run-lifecycle.md b/doc/acp-run-lifecycle.md index ea68b76d1cc..e4d0c566d0d 100644 --- a/doc/acp-run-lifecycle.md +++ b/doc/acp-run-lifecycle.md @@ -80,13 +80,15 @@ second run blocked on the lease until the first run fully returns, so the second run never re-stages into a workspace the first run still uses. The release runs in a `finally`, so an earlier teardown fault never strands the lease. -## Per-phase telemetry +## Per-phase run-log events -The run emits one telemetry event per named lifecycle phase. Each event carries -only the phase name, the wall-time duration, and the outcome (`ok` or `failed`). -The phase name is one member of a closed allowlist. An event never carries a +The run writes one [run-log event](run-log-events.md) per named lifecycle +phase, to the `heartbeat_run_events` table. This event is not a Paperclip +Telemetry event and not an OpenTelemetry export. Each event carries only the +phase name, the wall-time duration, and the outcome (`ok` or `failed`). The +phase name is one member of a closed allowlist. An event never carries a command, an argument, a path, an environment value, or a raw identifier. A -telemetry failure never fails the run. +run-log write failure never fails the run. ## Known limitations and deferred work @@ -97,5 +99,5 @@ telemetry failure never fails the run. run-scoped credential rebind protocol exists. - **The sandbox staged-files reuse stays enabled.** Its reuse payload carries no credential, so a compatible resume reuses the already-staged runtime. -- **The per-phase telemetry is observability-only.** It records the duration and - the outcome of each phase; it does not change run control flow. +- **The per-phase run-log events record the duration and the outcome only.** + They never change run control flow. diff --git a/doc/architecture/paperclip-runner-compatibility.md b/doc/architecture/paperclip-runner-compatibility.md new file mode 100644 index 00000000000..8630e45ca90 --- /dev/null +++ b/doc/architecture/paperclip-runner-compatibility.md @@ -0,0 +1,206 @@ +# Paperclip Runner compatibility and rollout + +- Status: Proposed +- Date: 2026-08-24 +- Parent decision: [Paperclip Runner architecture](paperclip-runner.md) + +## Purpose + +This document defines compatibility rules for introducing the experimental +Paperclip Runner. These rules are acceptance criteria for each implementation +change. They are not a migration plan for existing adapters. + +## Compatibility invariants + +1. Existing adapter selection is authoritative. A direct adapter stays direct. +2. A non-runner run must not start runnerd or open PRP. +3. A non-runner run must not create native result, finalization, status-decision, + or runner-transport records. +4. A non-runner run must not invoke native status arbitration. +5. Direct adapters keep their current transcript, interaction, cancellation, + result, and finalization behavior. +6. Runner-only UI controls depend on persisted runtime facts and are absent from + direct runs. +7. The rollout flag controls fresh runner selection and fresh runner starts. +8. A flag change does not rewrite an agent profile or a persisted run choice. +9. Persisted native data remains readable after the flag is disabled. +10. Recovery may finish an already persisted native run while fresh native + starts remain blocked. + +## Runtime selection + +The server resolves and persists the runtime once, before provider launch. + +| Persisted runtime | Adapter | Flag | Result | +| --- | --- | --- | --- | +| none | Any direct adapter | off or on | Use the existing direct path. | +| none | `paperclip_runner` with Codex | off | Reject the fresh start with a stable rollout-disabled error. | +| none | `paperclip_runner` with Codex | on | Use PRP v1 and runnerd. | +| none | `paperclip_runner` with another provider | on | Reject the unsupported provider before runnerd starts. | +| direct | Any | changed later | Keep the persisted direct path. | +| native | Any | changed later | Keep the persisted native path for read, cancel, recovery, and finalization. | + +The server must not fall back from a selected `paperclip_runner` start to +`codex_local`. A configuration or rollout error must be visible. Silent fallback +would hide the runtime that executed the task. + +## Direct adapter boundary + +This rule applies to every built-in and plugin direct adapter. It includes: + +- `codex_local`; +- `claude_local`; +- `opencode_local`; +- other local CLI or session adapters; +- process and HTTP adapters; +- gateway adapters; and +- external adapter plugins. + +Adding Paperclip Runner must not add runner imports or runner branches inside a +direct adapter implementation. The heartbeat coordinator may select the +explicit runner adapter at one narrow seam. All other adapters continue through +their existing code. + +For a flag-off `codex_local` heartbeat, compatibility proof must show: + +- one direct invocation; +- the same normalized result and finalization bytes as the approved baseline; +- zero runner processes; +- zero PRP connections; and +- zero native rows. + +## Configuration behavior + +When the rollout flag is off: + +- creation UI does not offer `paperclip_runner`; +- edit UI does not offer switching to `paperclip_runner`; +- server creation and import reject a new `paperclip_runner` selection; +- the server rejects a fresh start for an existing runner-configured agent; +- read and export preserve an existing runner configuration; +- unrelated edits to an existing runner-configured agent do not erase its + configuration; and +- switching that agent to a direct adapter remains allowed. + +When the rollout flag is on: + +- creation, import, and edit accept `paperclip_runner` only with provider + `codex` and valid Codex configuration; +- switching from a direct adapter affects only future unresolved runs; and +- switching away from the runner affects only future unresolved runs. + +Server validation is the authority in both states. Import files and API clients +cannot bypass the flag or provider allowlist. + +## Persisted native runs + +The following data remains readable independent of the current flag: + +- the persisted runtime selection and reason; +- run, runner, session, turn, and provider identity; +- ordered runner and provider events; +- accepted result and evidence assessment; +- finalization coordinator and status decision; +- usage, cost, cancellation, and terminal details; and +- durable interactions and final task-thread reply. + +If the flag is disabled during an in-flight native run, the server may reconnect, +cancel, reconcile, and finalize that same run. It must not use that recovery as +authority to start a new native run. + +Recovery must remain idempotent. Repeating it cannot add a second final reply, +interaction, wake, status decision, or application effect. + +## Task-page compatibility + +The task page uses one provider-neutral thread projection. Runtime facts may add +runner event groups, semantic receipts, usage, and structured questions. They +must not replace classic direct-adapter content. + +Direct-adapter coverage must include: + +- an active run; +- a settled run; +- an empty transcript; +- a pending interaction; +- a resolved interaction; and +- the classic interface state. + +The existing composer remains usable for direct adapters. A direct run does not +show reconnect, runner cancel, semantic receipt, or other runner-only controls. +Final replies continue to use the existing issue-comment behavior. + +## Structured input compatibility + +New structured questions use `paperclip.question_set.v1` and the matching +response contract. Provider-specific question objects remain inside their +drivers. + +Legacy unstructured interaction records remain readable and resolvable. A +structured form fails closed when its required schema, question mode, question +ID, option ID, or response value is invalid. The implementation must not silently +convert malformed structured input to a legacy text prompt. + +## Protocol version compatibility + +PRP wire versions, fixture versions, event schema versions, and typed schema +discriminators are independently versioned. + +- Peers negotiate the highest common PRP version. +- No common required version fails closed before command or provider execution. +- An unknown required fixture or event schema version fails closed. +- An unknown required typed discriminator fails closed. +- Additive optional properties remain compatible only when an old v1 consumer + can ignore them without changing behavior. +- Unknown object properties must survive validation when the owning schema + permits additive fields. + +Breaking meaning requires a new required version. A provider error is not a +protocol negotiation result. + +## Semantic action compatibility + +Catalog generation and production authorization are separate steps. + +- A catalog entry does not authorize production use. +- An operation without a production binding is undiscoverable. +- An operation denied to the run is undiscoverable unless the protocol + explicitly defines a safe denied receipt for that discovery mode. +- A duplicate call with the same idempotency key and canonical input returns the + original safe receipt. +- Reusing the key with different input returns a conflict and performs no second + effect. +- Receipts redact credentials, private provider payloads, and hidden identity. + +## Required compatibility matrix + +Each runner-related pull request updates only rows that it can execute. The +complete first-wave matrix must cover: + +| Area | Required cases | +| --- | --- | +| Runtime selection | Every built-in direct adapter, explicit runner selection, unsupported provider, flag on, and flag off. | +| Direct regression | Flag-off `codex_local` invocation count, byte-stable result/finalization, and zero native rows. | +| Configuration | Enabled and disabled create, import, edit, read, export, and adapter switch. | +| Recovery | Persisted native run after flag disable, reconnect, duplicate event, duplicate command, cancellation, and server restart. | +| Protocol security | Cross-company binding, ticket replay, ticket expiry, malformed frame, unsupported version, and revoked lease. | +| Semantic actions | Discovery denial, unbound action, duplicate call, conflicting retry, redaction, and governed action. | +| Task page | Active, settled, empty transcript, interaction, and classic direct-adapter states. | +| Structured input | Valid response, malformed response, stale response, duplicate response, provider loss, and cancellation. | + +## Pull request acceptance + +Every implementation pull request must: + +- build and work against its declared base; +- keep its changed-file count below 100; +- update manifests and exports only for implemented surfaces; +- run the smallest relevant tests before the repository handoff gate; +- run typecheck, tests, and build for handoff; +- run protocol parity, Cargo, clean-consumer, migration, token, and browser gates + when those areas change; +- contain no unexplained failed, cancelled, or path-skipped verification; and +- resolve actionable review and security findings before it is ready. + +Generated files land with their source and a drift check. `pnpm-lock.yaml` is +owned by CI and is not part of these pull requests. diff --git a/doc/architecture/paperclip-runner.md b/doc/architecture/paperclip-runner.md new file mode 100644 index 00000000000..85b41813ba4 --- /dev/null +++ b/doc/architecture/paperclip-runner.md @@ -0,0 +1,286 @@ +# ADR: Paperclip Runner architecture + +- Status: Proposed +- Date: 2026-08-24 +- Owners: Paperclip control plane and runner maintainers +- Related: [Paperclip Runner compatibility and rollout](paperclip-runner-compatibility.md) + +## Context + +Paperclip is a control plane. It owns companies, agents, issues, budgets, +approvals, and durable workflow state. Agent providers remain execution +services. Existing adapters invoke those services directly from the Paperclip +server. + +Paperclip Runner introduces a separate execution process for provider sessions. +This process needs durable delivery, restart recovery, and governed access to +Paperclip actions. It must not become a second control plane. It must also land +without changing the behavior of existing adapters. + +The initial implementation is intentionally narrow. It supports Codex through +an explicit, experimental adapter. Other providers and developer tools remain +outside this decision. + +## Decision + +Add a standalone package named `@paperclipai/paperclip-runner`. The package owns +the language-neutral Paperclip Runner Protocol (PRP), the Rust runner process, +provider drivers, deterministic replay, and semantic action dispatch contracts. + +Add one explicit adapter named `paperclip_runner`. The adapter is available only +when an instance-level, default-off rollout flag is enabled. Its first supported +provider is Codex. + +Do not route existing adapters through Paperclip Runner. A direct adapter keeps +its current invocation, transcript, interaction, cancellation, and finalization +paths. + +## Goals + +- Keep runner process ownership outside the Paperclip server process. +- Preserve Paperclip as the authority for identity, policy, and workflow state. +- Recover a run after runner or network interruption without duplicate effects. +- Expose only actions that the current run is allowed to use. +- Make protocol behavior deterministic across TypeScript and Rust. +- Keep existing adapter behavior unchanged while the runner is experimental. + +## Non-goals + +- Replace existing direct adapters. +- Move business authorization or issue status policy into Rust. +- Give runnerd a broad Paperclip API credential. +- Support OpenCode, ACPX, Claude Managed, AWS AgentCore, or remote sandboxes in + the first production slice. +- Expose browser SDK, React SDK, eval, lab, or scenario-explorer package entry + points in the initial release. +- Commit recorded screenshots, stress logs, or construction history as product + architecture. + +## Topology + +The initial local topology is: + +```text +Paperclip server + | authenticated PRP v1 WebSocket + v +paperclip-runnerd + | Codex app-server protocol + v +Codex +``` + +The server opens a native run and launches a verified runnerd artifact in the +realized execution environment. Runnerd opens the outbound PRP connection. It +then owns the provider process group and the durable transport state for that +run. + +The browser does not connect to runnerd. It reads projections from the existing +Paperclip APIs and task-thread models. + +## Dependency direction + +The runner package must build and test without importing Paperclip server, UI, +CLI, database, or other private workspace implementation modules. + +```text +JSON Schema and fixtures + | + +------------------+ + v v + TypeScript contracts Rust runner core + | | + +--------+---------+ + v + deterministic parity + +Paperclip server ----implements----> runner public ports +``` + +The dependency points from an implementation to a contract. The Paperclip +server may implement a public runner port. The runner package must not import +the server implementation. + +The initial public package surfaces are: + +- `@paperclipai/paperclip-runner` for runtime contracts and clients. +- `@paperclipai/paperclip-runner/testing` for deterministic fakes and + conformance helpers. + +Every export must have an implementation and a clean-consumer test before it is +published. Later SDK, eval, and lab surfaces require separate decisions. + +## Protocol boundary + +PRP v1 uses a WebSocket at: + +```text +/api/runner/v1/connect/:runId +``` + +JSON Schema is the language-neutral protocol authority. TypeScript and Rust use +the same canonical fixtures and must produce the same replay result. + +Protocol rules are fail closed: + +- Unknown required protocol or schema versions are rejected. +- Unknown required discriminators and enum values are rejected. +- Additive optional object fields may be accepted when v1 consumers can ignore + them safely. +- Frames, headers, durable state, diagnostic tails, and replay windows are + bounded. +- Event identity and ordering remain stable across reconnect and restart. + +Provider-native messages do not cross this boundary. Drivers translate them to +provider-neutral PRP events, results, usage, cancellations, and structured +input. + +## Trust boundary + +The Paperclip server is authoritative for: + +- company, agent, issue, run, session, and user attribution; +- rollout and runtime selection; +- action discovery and authorization; +- approval, budget, secret, revision, and workspace policy; +- durable application records and activity history; +- result acceptance, finalization, and issue status. + +Runnerd is authoritative only for its local responsibilities: + +- provider process supervision; +- provider session and turn transport; +- durable PRP outbox and command receipts; +- stable runner-side event identity; +- bounded process diagnostics. + +Runnerd receives a short-lived, one-use bootstrap ticket. The ticket is bound to +the company, agent, issue, run, runner, session, turn, and verified artifact. The +server exchanges it for a short-lived connection lease. Raw tickets are never +stored. Runnerd never receives a broad Paperclip API key. + +The server rejects expired, replayed, revoked, cross-company, mismatched, +malformed, oversized, or protocol-incompatible connections. Cancellation, +timeout, supersession, and environment-lease loss revoke runner authority. + +## Durable delivery and recovery + +Runnerd persists an event before it sends the event. The server acknowledges a +cumulative source cursor. Runnerd removes acknowledged data only after it +validates that cursor. + +A reconnect reports the last processed command, the next source sequence, the +last acknowledged source sequence, and the unacknowledged range. The server may +then replay one pending command and accept byte-equivalent event retries. + +Command identity is idempotent. Reusing a command ID with the same canonical +input returns the stored result. Reusing the ID with different input fails +closed. A repeated semantic tool call cannot repeat an application effect. + +Recovery is bounded. Exhausted storage, reconnect, command, or time limits end +the run with a classified failure instead of an unbounded loop. + +## Semantic actions + +PRP carries provider-neutral semantic operation IDs. The package may define an +operation catalog, but catalog presence does not grant authority. + +For each run, the server projects only operations that have a production +binding and that the current actor may discover. An unbound or unauthorized +operation is absent from discovery. The server validates inputs, executes the +existing application authority, and returns a redacted receipt. + +This keeps these invariants in one place: + +- company scoping; +- actor and run attribution; +- authorization and approval; +- revision and idempotency checks; +- budget and secret policy; +- activity logging and safe error details. + +Runnerd cannot forge identity through tool input. Identity and scope come from +the authenticated connection binding. + +## Structured input + +Questions use `paperclip.question_set.v1`. Responses use the matching canonical +response contract. Drivers translate between these provider-neutral records and +provider-native input APIs. + +The server validates a response at the untrusted API edge and again against the +persisted question set before delivery. A process loss may materialize a durable +task-thread interaction, but cancellation and an already resolved request must +not create a second interaction or continuation. + +## Persistence and finalization + +Native run records are additive. Existing heartbeat and issue records remain +readable. A runner result is an untrusted claim. It does not directly update an +issue status. + +The server validates the result, classifies durable evidence, applies status +policy, commits workflow effects, and records finalization. Each phase is +idempotent and recoverable. Existing direct adapters keep their existing +finalization paths and do not invoke native status arbitration. + +Disabling the rollout flag blocks fresh runner starts. It does not make an +already persisted native run unreadable or prevent bounded recovery and +finalization of that run. + +## Rollout + +The rollout has three gates: + +1. The instance flag is enabled. +2. The agent explicitly selects `paperclip_runner`. +3. The adapter selects a supported provider. The initial provider is `codex`. + +The adapter is hidden from creation and selection surfaces while the flag is +off. Server validation also rejects a fresh runner selection or start while the +flag is off. UI hiding is not the security boundary. + +Runtime selection is persisted before launch. Later setting changes cannot +silently move an in-flight run between the direct and native execution paths. + +The detailed compatibility rules are in +[Paperclip Runner compatibility and rollout](paperclip-runner-compatibility.md). + +## Observability + +The server records structured runner, provider, semantic action, interaction, +usage, and terminal events. Logs and receipts must not contain bootstrap +tickets, leases, provider credentials, secret values, complete environments, or +private host paths. + +The task page may project these records through existing thread components. A +runner-only control must depend on persisted runtime facts. It must not depend +only on the agent adapter profile. + +## Consequences + +This design adds process, protocol, and recovery complexity. In return, it gives +provider sessions a durable and testable execution boundary without moving +Paperclip governance into the runner. + +The default-off, explicit-adapter rollout duplicates some provider +configuration during the experiment. This is intentional. It keeps comparison +and rollback simple and prevents a global migration of existing agents. + +The standalone package boundary requires generated artifacts, clean-consumer +tests, and cross-language parity gates. These checks add build cost, but they +prevent server implementation details from becoming accidental public API. + +## Required proof before general availability + +- TypeScript and Rust accept and reject the same protocol fixtures. +- Lost acknowledgements, reconnects, runner restarts, and duplicate commands do + not duplicate events or effects. +- Bootstrap replay, binding mismatch, and cross-company access fail closed. +- Only authorized and production-bound actions appear in discovery. +- Cancellation settles provider, runner, transport, and application state. +- Flag-off direct-adapter runs create no native records and start no runner + process. +- Persisted native runs remain readable and recoverable after the flag changes. +- Codex completes the server to PRP to runnerd to provider to server path. +- Existing adapter compatibility tests remain byte stable where specified. diff --git a/doc/observability.md b/doc/observability.md index 15101f47d93..02ddef5f4be 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -1,5 +1,10 @@ # Observability +This document is the Observability contract. It covers the OpenTelemetry +trace path and two local instrumentation contracts; see the +[Telemetry Data Contract](../packages/shared/src/telemetry/README.md) for the +separate first-party event system. + Paperclip ships with **opt-in** OpenTelemetry auto-instrumentation for the server process. When activated it produces **traces only** — no metrics and no logs are exported by this integration. The OTel packages are *optional peer @@ -94,8 +99,332 @@ without tracing — your server stays up. ## Scope -This integration emits **traces only**. Metrics and log exporters are out of -scope and intentionally not configured here. Auto-instrumentations for -`fs`, `dns`, and `net` are disabled by default because they are too chatty +The OpenTelemetry export carries **traces only**. Metrics and log exporters +are out of scope and intentionally not configured here. Auto-instrumentations +for `fs`, `dns`, and `net` are disabled by default because they are too chatty for this workload; everything else from `@opentelemetry/auto-instrumentations-node` is on (HTTP, Express, PG, etc.). + +This document also holds two local instrumentation contracts: the sandbox +startup trace spans, and the sandbox duplex transport instrumentation. Both +sections follow below. + +## Sandbox Startup Trace Spans + +Paperclip opens OpenTelemetry spans on the sandbox start path. These spans are +an Observability surface. They are not Paperclip Telemetry events. The +generated telemetry contract does not cover them, so this section is their +canonical contract. + +The spans are opt-in. Paperclip exports them only when an OTLP endpoint is +configured. With no endpoint the whole span path is a no-op. Paperclip opens the +spans only for a run that targets a remote sandbox. A local run and an SSH run +stay out of these spans. + +Every span attribute uses the closed `paperclip.sandbox.startup.` prefix and +rides a fixed allowlist. A command line, an argument, an environment value, a +file path, program output, or a raw identifier never rides a span. It rides +neither as an attribute nor as an event. The producer bounds each free-form +value: + +- A command basename maps to a small known set. Any other value maps to `other`. +- A region maps to a small known set. Any other value maps to `unknown`. +- An image id, a sandbox id, and a lease id ride only as a non-reversible short + hash. + +Each numeric attribute is finite. Paperclip omits an attribute when its value is +absent, never a misleading `0`. + +### Spans + +| Span | Scope | Parent | +| --- | --- | --- | +| `sandbox.startup` | The one root span for a sandbox bring-up. | none (root) | +| `workspace.resolve` | Workspace resolution step. | `sandbox.startup` | +| `codex-home.seed` | Managed-home seed step. | `sandbox.startup` | +| `skills.reconcile` | Skills reconcile step. | `sandbox.startup` | +| `stage.sync` | Workspace stage-sync step. | `sandbox.startup` | +| `snapshot.git` | Host-side git workspace enumeration inside `stage.sync` (`git status --ignored`, the HEAD diffs, `ls-files`). | `stage.sync` | +| `snapshot.baseline` | Host-side baseline workspace content-hash walk inside `stage.sync`, kept for restore. | `stage.sync` | +| `stage.workspace` | One inbound workspace stage task inside `stage.sync`. It packs and uploads the workspace. | `stage.sync` | +| `stage.asset.` | One inbound asset stage task inside `stage.sync`. It packs and uploads one managed-home asset. The `` segment is the asset key. | `stage.sync` | +| `stage.project.` | One inbound referenced-project stage task inside `stage.sync`. It uploads one referenced project. The `` segment is the project id. | `stage.sync` | +| `pack` | Host-side workspace tarball build inside the `stage.workspace` task. | `stage.workspace` | +| `bridge.paperclip` | Paperclip bridge start step. | `sandbox.startup` | +| `bridge.process-session` | Process-session bridge start step. | `sandbox.startup` | +| `acp.handshake` | ACP session handshake step. | `sandbox.startup` | +| `sandbox.syncBack` | The settlement sync-back that restores the managed home at teardown. | the active run span | +| `restore.workspace` | One outbound workspace restore task at teardown. It reads the sandbox workspace back and merges it into the host workspace. | `sandbox.syncBack` | +| `restore.asset.` | One outbound asset restore task at teardown. It reads one asset back to its host store. The `` segment is the asset key. | `sandbox.syncBack` | +| `sandbox.agentSession.sendInput` | One outbound ACP message to the agent — the socket handler's one `writeTextFile` exec. | the active run span | +| `sandbox.agentSession.pollOutput` | One 100 ms poll tick — `list`, then `read`+`remove` per file found (`1 + 2n` execs). | the active run span | +| `sandbox.callbackBridge.relayRequest` | One Paperclip-API callback request — read the request, write the response, remove it. | the active run span | +| `sandbox.agentProcess` | The persistent streamed agent process the process-session bridge launches; open until the process settles or the bridge tears down, whichever comes first. | the active run span | +| `sandbox.exec` | One host-to-sandbox execution. | the active step or wrapper span | + +A step span name is the step name. The `sandbox.exec` span parents to the step +span that runs the execution, so each execution nests under its step. Within +`stage.sync`, the host-side sub-steps `snapshot.git` and `snapshot.baseline` open +as child spans of the step, so the host work at the head of the step is +attributed rather than showing as a gap. Each inbound sync operation also opens +its own task span under `stage.sync`: `stage.workspace`, one `stage.asset.` +per asset, and one `stage.project.` per referenced project. The `pack` span +nests under `stage.workspace`, because the host builds the tarball inside that +task. Two concurrent tasks produce overlapping spans. + +The settlement `sandbox.syncBack` span runs at teardown and parents to the run +span. It wraps the managed-home restore. Each outbound restore operation opens +its own task span under `sandbox.syncBack`: `restore.workspace` and one +`restore.asset.` per asset. Two concurrent restore tasks produce overlapping +spans. A run-time +`sandbox.exec` span parents instead to the run-time wrapper span that runs it +(`sandbox.agentSession.sendInput`, `sandbox.agentSession.pollOutput`, +`sandbox.callbackBridge.relayRequest`, or `sandbox.agentProcess`). Each run-time +wrapper span parents to the live run span (`agent.turn` during the turn, +`task.run` otherwise). With no active trace context the exec span opens +unparented. + +`sandbox.agentProcess` wraps the persistent streamed agent process. The +process-session bridge launches it during `bridge.process-session`, so it opens +under `task.run` — no turn has started yet. It therefore overlaps the sibling +`agent.turn` rather than nesting under it or dangling off the short-lived bring-up +step. The span ends when the process settles or when the bridge tears down, +whichever comes first. The bridge tears down before the run root span ends, so +the span never outlives `task.run` even when the process lingers past teardown +(the sandbox `execute` has no cancel, so a lingering process cannot be forced to +resolve). + +The root span sets the error status when the bring-up fails. Each step span sets +the error status when its step fails. The `sandbox.exec` span sets the error +status when the exit code is non-zero or the execution throws. + +### Outcome values + +The `paperclip.sandbox.startup.outcome` attribute uses a closed value set: + +- `ok` — the step or the execution settled with a success result. +- `skipped` — a warm cache skipped the step; the step ran no work. +- `failed` — the step or the execution threw, or the exit code was non-zero. + +### Root span attributes + +The `sandbox.startup` root span uses this closed attribute allowlist. + +| Attribute | Type | Optional | Meaning | +| --- | --- | --- | --- | +| `paperclip.sandbox.startup.root.wall_ms` | number | no | The root-span wall time of the whole bring-up. | +| `paperclip.sandbox.startup.root.work_ms` | number | no | The sum of the step wall times. | +| `paperclip.sandbox.startup.root.diff_ms` | number | no | `work_ms − wall_ms`; the overlap the parallel steps saved. | +| `paperclip.sandbox.startup.provider` | string | yes | The normalized provider family. | +| `paperclip.sandbox.startup.cold_start` | boolean | yes | Whether the bring-up is a cold start. | +| `paperclip.sandbox.startup.region` | string | yes | The clamped region label. | +| `paperclip.sandbox.startup.image_id` | string | yes | The hashed image id. | +| `paperclip.sandbox.startup.sandbox_id` | string | yes | The hashed sandbox id. | +| `paperclip.sandbox.startup.lease_id` | string | yes | The hashed lease id. | + +### Step span attributes + +Each bring-up step span uses this closed attribute allowlist. The step name +rides the span name, so no `step` attribute repeats it. + +| Attribute | Type | Optional | Meaning | +| --- | --- | --- | --- | +| `paperclip.sandbox.startup.step.wall_ms` | number | no | The wall time of the step. | +| `paperclip.sandbox.startup.outcome` | string | no | The step outcome (`ok`, `skipped`, or `failed`). | +| `paperclip.sandbox.startup.provider` | string | yes | The normalized provider family. | +| `paperclip.sandbox.startup.batch` | string | yes | A shared tag that marks two parallel steps as one batch. | +| `paperclip.sandbox.startup.handshake.create_runtime.wall_ms` | number | yes | The create-runtime sub-time of the `acp.handshake` step. | +| `paperclip.sandbox.startup.handshake.ensure_session.wall_ms` | number | yes | The ensure-session sub-time of the `acp.handshake` step. | + +The round-trip count and the provider durations no longer ride a step span. The +per-execution `sandbox.exec` child spans carry that detail. + +### `sandbox.exec` span attributes + +The `sandbox.exec` span uses this closed attribute allowlist. Paperclip omits a +numeric attribute when the provider does not report the value. + +| Attribute | Type | Optional | Meaning | +| --- | --- | --- | --- | +| `paperclip.sandbox.startup.provider` | string | no | The normalized provider family. | +| `paperclip.sandbox.startup.exec.command` | string | no | The clamped `argv[0]` command label. | +| `paperclip.sandbox.startup.exec.exit_code` | number | yes | The numeric process exit code. | +| `paperclip.sandbox.startup.exec.wall_ms` | number | no | The host-measured wall time of the execution. | +| `paperclip.sandbox.startup.exec.wait_before_ms` | number | yes | The provider handle-fetch wait before the execution ran. | +| `paperclip.sandbox.startup.exec.sandbox_ms` | number | yes | The in-sandbox run time of the execution. | +| `paperclip.sandbox.startup.exec.network_ms` | number | yes | The transport time the host adds; `wall_ms − wait_before_ms − sandbox_ms`. | +| `paperclip.sandbox.startup.exec.critical_path` | boolean | no | Whether the execution sits on the startup critical path. | +| `paperclip.sandbox.startup.exec.cache_hit` | boolean | yes | Whether the provider served the sandbox handle from its warm cache. | +| `paperclip.sandbox.startup.outcome` | string | no | The execution outcome (`ok` or `failed`). | + +The plugin decides the cache hit at the sandbox-handle lookup. The span no +longer infers a cache hit from `wait_before_ms == 0`. Paperclip omits the +`cache_hit` attribute when the provider does not report the value. + +To add a span attribute, extend the `SANDBOX_STARTUP_SPAN_ATTRS` allowlist in +the code first. Keep the attribute low-cardinality and free of user content. + +### Provider spans + +A sandbox provider plugin also opens spans for its own sync steps. These spans +use the `sandbox.daytona.` name prefix. They share the +`paperclip.sandbox.startup.` attribute prefix and obey the same opt-in and +no-user-content rules as the startup spans above. + +The plugin worker runs in a separate process from the host. So the host treats +every field of a worker-sent span as untrusted input. The host re-clamps the +span name and every attribute at one boundary, the `span.record` host handler, +before it records the span. + +| Span | Scope | Parent | +| --- | --- | --- | +| `sandbox.daytona.pack` | The host-local pack step that builds the upload tarball. It makes no sandbox round trip. | the active startup step span | +| `sandbox.daytona.transfer` | The transfer step: an upload to the sandbox (inbound) or a download from the sandbox (outbound). The `paperclip.sandbox.startup.transfer.direction` attribute records the direction. | the active sync task span (`stage.*` inbound, `restore.*` under `sandbox.syncBack` outbound) | +| `sandbox.daytona.ensureDirectory` | The `mkdir -p` step that ensures a directory exists before a write. | the active startup step span | +| `sandbox.daytona.checkSymlinkEscape` | The re-check step that a path resolves inside the workspace root before use. | the active startup step span | +| `sandbox.daytona.promote` | The atomic move of a staged temp onto its target via a pinned dir handle. | the active startup step span | +| `sandbox.daytona.extractTarball` | The one round trip that re-checks the path, runs `tar -xf`, and removes the scratch tarball. | the active startup step span | +| `sandbox.daytona.postUploadCommand` | One caller-supplied post-upload command. | the active startup step span | +| `sandbox.daytona.session.open` | The create of the one persistent session for a lease, on the first in-run command. | the active run span | +| `sandbox.daytona.session.close` | The delete of that persistent session on lease release. | the active run span | +| `sandbox.daytona.other` | Any span name outside the known set. | the active startup step span | + +The host clamps the span name to the closed set of leaf names above (`pack`, +`transfer`, `ensureDirectory`, `checkSymlinkEscape`, `promote`, `extractTarball`, +`postUploadCommand`, `session.open`, and `session.close`). The host maps a known +name to `sandbox.daytona.`. The host maps any other value to +`sandbox.daytona.other`, so a span name never carries free-form data. Only the +daytona provider emits these spans today, so the segment is the literal +`daytona`. + +The `sandbox.daytona.*` spans use this closed attribute allowlist. The host +drops every other key, so a command, an argument, a path, an id, a standard +output, or a standard error never rides a provider span. The host records only +the attributes that the producer sends for one span. + +| Attribute | Type | Optional | Meaning | +| --- | --- | --- | --- | +| `paperclip.sandbox.startup.provider` | string | no | The normalized provider family. | +| `paperclip.sandbox.startup.outcome` | string | yes | The step outcome (`ok`, `skipped`, or `failed`). | +| `paperclip.sandbox.startup.pack.wall_ms` | number | yes | The host-local wall time of the pack step. It rides the `sandbox.daytona.pack` span. | +| `paperclip.sandbox.startup.transfer.wall_ms` | number | yes | The wall time of the transfer step. It rides the `sandbox.daytona.transfer` span. | +| `paperclip.sandbox.startup.transfer.guard.count` | number | yes | The number of serial guard round trips before one transfer. It rides the `sandbox.daytona.transfer` span. | +| `paperclip.sandbox.startup.transfer.direction` | string | yes | The transfer direction (`inbound` or `outbound`). It rides the `sandbox.daytona.transfer` span. | + +The `span.record` host handler enforces the allowlist. It re-maps `provider` +through the provider-family normalizer. It keeps `outcome` only when the value +is `ok`, `skipped`, or `failed`. It keeps `transfer.direction` only when the +value is `inbound` or `outbound`. It keeps a numeric attribute only when the +value is a finite number. It drops a status message and keeps only the numeric +status code. The handler never throws, because observability must not change the +sync control flow. + +The `span.record` host method needs the `environment.drivers.register` +capability. So only a plugin that registers an environment driver may emit a +provider span. The capability gate rejects a provider span from any other +plugin. + +The host parents each provider span to the active sync task span. An inbound +transfer runs inside a `stage.*` task span, so its provider spans parent there. +An outbound transfer runs inside a `restore.*` task span under `sandbox.syncBack` +at teardown, so its provider spans parent there. The host mints a W3C +`traceparent` from the active task span and passes it to the plugin worker on the +per-call invocation channel. The teardown restore runs inside the run-parented +`sandbox.syncBack` span, so the host mints a `traceparent` for an outbound +provider span the same way it does for an inbound one. The worker tags its span with the +`traceparent` and treats the value as opaque. The worker never derives the +parent from it. The host recovers the `traceparent` from its own invocation +record, so a worker can never forge a parent. The host validates the +`traceparent` and rejects a missing or malformed value. With no active host +trace context the worker sends no span, so the whole provider-span path is a +no-op. + +## Sandbox Duplex Transport Instrumentation + +This section documents one duplex transport with three sinks: an +OpenTelemetry span, a counter in the `tool_runtime_metric_counters` table, and +one run-log event. + +Paperclip opens a fixed observability surface for the sandbox duplex transport. +This instrumentation is separate from Paperclip Telemetry events and from the +sandbox startup trace spans above. The generated telemetry contract does not +cover it, so this section is its canonical contract. The code owner is +`packages/adapter-utils/src/duplex-observability.ts`. That module holds each name and +each enum value as a literal constant, so the surface never drifts. + +The surface is opt-in. The host injects a recorder that binds the span to the +OTel tracer, the counter to the guarded counter store in +`server/src/services/tool-runtime-metrics.ts`, and the event to the run-events +bridge. The default recorder is a no-op, so the whole surface stays inert until +the host binds a real recorder. Every recorder call sits inside an error swallow, +so a telemetry failure never breaks the request path. + +The surface carries no user content. No route, no query, no request body, no +token, and no raw identifier rides a span, a counter, or an event. Each record +carries only the closed dimension keys below and, for the request span, a +latency. The `provider` dimension carries only the allowlisted public value +`daytona`. Any other plugin key maps to `other` before the record reaches a sink, +so a raw plugin key never reaches a span attribute, a counter label, or an event +field. + +### Spans + +| Span | Scope | Latency | +| --- | --- | --- | +| `sandbox.duplex.channel_open` | One duplex channel-open attempt. The `outcome` dimension is `ok` when the channel opened and readiness passed, or `error` when the open or readiness failed. | none | +| `sandbox.duplex.request` | One duplex request the broker forwarded to the host. | The request latency in milliseconds. | + +### Event + +| Event | Scope | +| --- | --- | +| `sandbox.duplex.transport` | The host emits it at each transport boundary: a ready duplex channel, a fallback to the file bridge, and a terminal channel loss. Its dimensions record the boundary. | + +### Counters + +| Counter | Scope | +| --- | --- | +| `sandbox_duplex_channel_open_total` | One successful duplex channel open. | +| `sandbox_duplex_fallback_total` | One fallback to the file bridge. The `fallback_reason` dimension records the cause. | +| `sandbox_duplex_loss_total` | One terminal duplex channel loss. The `loss_class` dimension records the phase. | +| `sandbox_duplex_session_leak_total` | One leaked provider session at teardown. | + +### Aggregate byte ledger metrics + +The host aggregate byte ledger owns one process-scoped gauge and two +process-scoped counters. The ledger bounds the retained bytes across every live +duplex route in one process. It sets the gauge on each reserve and each release. +It increments a counter on a rejected reservation and on an accounting defect. +These records carry no dimension label. The guarded counter store keys each +counter on `(companyId, metric)`, and the gauge reports one process value, so no +dynamic dimension rides them. The code owner is +`packages/adapter-utils/src/duplex-aggregate-byte-ledger.ts`, and the metric +names are literal constants in `duplex-observability.ts`. + +| Metric | Type | Scope | +| --- | --- | --- | +| `sandbox_duplex_aggregate_bytes_in_use` | gauge | The aggregate retained bytes across every live duplex route. The ledger sets it on each reserve and each release. | +| `sandbox_duplex_aggregate_byte_reservation_rejections_total` | counter | One rejected aggregate byte reservation. The ledger increments it when a reservation would pass the aggregate ceiling. | +| `sandbox_duplex_aggregate_byte_accounting_underflow_total` | counter | One aggregate byte accounting defect. The ledger increments it on a double release or on a transfer of a token it does not hold. | + +### Dimension keys + +Counters carry no dimension labels. The guarded counter store keys each counter +on `(companyId, metric)` with no label column, so the `fallback_reason` and +`loss_class` values fold into the counter metric name instead. The full closed +dimension set below rides only the spans and the `sandbox.duplex.transport` +event, which use only these closed keys. A test asserts the exact set, so a new +key never reaches a sink by accident. + +| Key | Type | Optional | Value set | +| --- | --- | --- | --- | +| `provider` | string | no | `daytona`, or `other` for any other plugin key. | +| `transport` | string | no | `duplex`, `http2`, or `file`. `duplex` names the retired bespoke frame protocol; `http2` names the Node HTTP/2 session over the sandbox channel; a fallback record uses `file`. | +| `outcome` | string | yes | `ok` or `error`. | +| `fallback_reason` | string | yes | `gate_off`, `capability_absent`, `route_busy`, `entrypoint_sync_failed`, `broker_construction_failed`, `channel_open_failed`, `ready_invalid`, `ready_nonce_mismatch`, `ready_timeout`, `contaminated`, `aggregate_bytes_exceeded`, or `preface_missing`. It rides only a fallback record. `route_busy` marks the process-scoped route ceiling full. `entrypoint_sync_failed` and `broker_construction_failed` mark the named build step. `channel_open_failed` marks a failed channel open. `aggregate_bytes_exceeded` marks a readiness handshake, or an `http2` post-preface pre-bind buffer, where the host fell back because the process aggregate byte ceiling had no room. `preface_missing` marks a missing or an invalid HTTP/2 client connection preface inside the bounded readiness buffer: the host found no valid preface after the accepted READY line, aborted the `http2` open, and moved the run to the file bridge (`queue_v1`) one time. | +| `loss_class` | string | yes | `pre_dispatch` or `post_dispatch`, relative to the first request dispatch. It rides only a loss record. | +| `loss_reason` | string | yes | `stdin_eof`, `provider_exit`, `heartbeat_timeout`, `rpc_failure`, `write_error`, `transport_closed`, or `other`. The host maps every loss cause to one of these values, so no raw provider text reaches a sink. `write_error` marks a rejected host-to-sandbox write. `transport_closed` marks a reason-less provider transport close with no exit data. It rides only a loss record. | + +To add a name or an enum value, extend the literal constant in +`duplex-observability.ts` first, then update the test that asserts the closed set. +Keep every dimension low-cardinality and free of user content. diff --git a/doc/run-log-events.md b/doc/run-log-events.md new file mode 100644 index 00000000000..ffaa8dce96d --- /dev/null +++ b/doc/run-log-events.md @@ -0,0 +1,96 @@ +# Run-Log Events + +Run-log events write to the `heartbeat_run_events` table +(`packages/db/src/schema/heartbeat_run_events.ts:6-20`). They are not +Paperclip Telemetry events, and they are not OpenTelemetry exports. A run-log +event needs no operator endpoint. + +## Native PRP Run-Log Events + +The hidden native coordinator writes each validated PRP event to the bound +run's existing event stream before it acknowledges the runner. The row keeps +the PRP `eventType`, source instance, source event ID, source sequence, protocol +schema version, and a SHA-256 digest of the canonical source envelope. Its +payload is `{ "prpEvent": }`. + +The writer locks the native `heartbeat_runs` row and allocates the existing +per-run `seq` cursor. A byte-equivalent retry reuses the first row; a changed +retry or source-sequence gap is rejected. Company, issue, agent, run, session, +and runner-source bindings must match the persisted native run. Bootstrap +tickets, reconnect leases, authentication proofs, encryption keys, and raw +credential material are never written to the run log. + +These records remain run-log events. They do not create an OpenTelemetry or +Paperclip Telemetry export, and legacy adapters do not use this writer. + +## Sandbox Startup Run-Log Event + +Paperclip writes one `run.startup.step` event to the run log for each bring-up +step. This event is a run-log record, not a first-party telemetry event. The +generated telemetry contract does not cover it, so this section is its canonical +contract. + +The event payload carries only three fields. + +| Field | Type | Meaning | +| --- | --- | --- | +| `step` | string | The bring-up step name, for example `stage.sync`. | +| `durationMs` | number | The wall time of the step. A skipped step reports `0`. | +| `outcome` | string | The step outcome (`ok`, `skipped`, or `failed`). | + +The event no longer carries the per-step round-trip count or the provider +duration fields. It dropped `roundTrips`, `providerExecMs`, `providerGetMs`, +`createRuntimeMs`, and `ensureSessionMs`. The startup spans in +[`doc/observability.md`](observability.md) carry that detail now. The +`sandbox.exec` child spans hold the round-trip and provider durations. The +`acp.handshake` step span holds the create-runtime and ensure-session +sub-times. + +To read the detailed timing, use the startup spans. The spans need an OTLP +endpoint. A run with no endpoint keeps only the three run-log fields above. + +## Run Phase Timing Run-Log Event + +Paperclip writes one `run.phase.timing` event to the run log for each +run-lifecycle phase. This event is a run-log record, not a first-party telemetry +event. The generated telemetry contract does not cover it, so this section is its +canonical contract. The producer is `emitRunPhaseTiming` in +`packages/adapter-utils/src/acpx-engine/startup-timing.ts`. + +The event payload carries only three fields. + +| Field | Type | Meaning | +| --- | --- | --- | +| `phase` | string | The run-lifecycle phase name from the closed allowlist below. | +| `durationMs` | number | The wall time of the phase. A negative or a non-finite value clamps to `0`. | +| `outcome` | string | The phase outcome (`ok` or `failed`). | + +The `phase` field is one member of a closed, low-cardinality allowlist. The +producer drops any event whose phase name is outside this allowlist, so a +free-form label never reaches the run log. The allowlist has twelve phase names. + +| Phase | Meaning | +| --- | --- | +| `place_workspace` | Place the run workspace. | +| `start_transport` | Start the agent transport. | +| `create_runtime` | Create the agent runtime. | +| `ensure_session` | Ensure the agent session exists. | +| `configure_session` | Configure the agent session. | +| `prepare_turn` | Prepare the turn. | +| `turn` | Run the turn. | +| `end_session` | End the agent session. | +| `settle_reuse` | Settle the session for reuse. | +| `stop_transport` | Stop the agent transport. | +| `sync_back` | Sync the workspace back. | +| `release_staging_lease` | Release the staging lease. | + +The payload never carries a command, an argument, a path, an environment value, +or a raw identifier. The event rides the `ctx.onEvent` run-event bridge and is +run-log-only. It needs no OTLP endpoint. + +## Related instrumentation + +The sandbox duplex transport also writes one run-log event as one of its three +sinks. See the +[Sandbox Duplex Transport Instrumentation](observability.md#sandbox-duplex-transport-instrumentation) +section in the Observability contract. diff --git a/docker/docker-compose.quickstart.yml b/docker/docker-compose.quickstart.yml index 16fa17bccc1..41a674916f3 100644 --- a/docker/docker-compose.quickstart.yml +++ b/docker/docker-compose.quickstart.yml @@ -11,6 +11,11 @@ services: build: context: .. dockerfile: Dockerfile + # Ceiling on processes. The image makes tini PID 1 so orphaned agent + # descendants are reaped instead of piling up as zombies; this is the + # backstop if something leaks anyway — the container hits its own limit and + # dies visibly rather than starving the host of pids. + pids_limit: 2048 ports: - "${PAPERCLIP_PORT:-3100}:3100" environment: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b26b396ab82..dc6dda42383 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -19,6 +19,15 @@ services: build: context: .. dockerfile: Dockerfile + # No `init: true` here on purpose. The image makes tini PID 1 itself + # (Dockerfile ENTRYPOINT), which covers every launch path -- compose, plain + # `docker run`, the quadlet units and the ECS task definition -- instead of + # one lever per orchestrator. Setting it here too would nest docker-init + # around tini and make tini warn that it is not PID 1 on every boot. + # pids_limit is the backstop: if something ever does leak processes again, + # this container hits its own ceiling and dies visibly rather than starving + # the whole host of pids. + pids_limit: 2048 ports: - "3100:3100" environment: diff --git a/package.json b/package.json index 16132aeaae2..51b94466634 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "smoke:mcp-fixtures": "node scripts/smoke/mcp-fixture-harness.mjs", "smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh", "smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs", - "test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs", + "test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs", "storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs", "test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts", "test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack", @@ -72,7 +72,7 @@ "devDependencies": { "@playwright/test": "^1.62.1", "cross-env": "^10.1.0", - "esbuild": "^0.28.1", + "esbuild": "^0.28.2", "typescript": "^5.7.3", "vitest": "^4.1.10" }, diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 6f5ae7dff6d..8e0d712b097 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -36,8 +36,6 @@ import { type AcpxEngineExecutorOptions, } from "./execute.js"; import { runChildProcess } from "../server-utils.js"; -import { createDuplexBridgeBroker } from "../duplex-bridge-broker.js"; -import type { CommandManagedDuplexChannel } from "../command-managed-runtime.js"; import { getActiveStepContext, runWithRuntimeParent, @@ -5734,56 +5732,55 @@ describe("ACPX engine run lifecycle corrections (F3: one teardown error policy)" }); }); -describe("ACPX engine sandbox duplex run-disposition seam (fail-closed)", () => { +describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () => { beforeEach(() => { vi.clearAllMocks(); }); - // A minimal in-memory duplex channel. The test drives a channel exit to latch a - // real loss in a real broker, so the seam reads a real run disposition. - function createFakeDuplexChannel() { - let exitListener: ((exit: { exitCode: number | null }) => void) | null = null; - const channel: CommandManagedDuplexChannel = { - write(): void {}, - onData(): void {}, - onExit(listener: (exit: { exitCode: number | null }) => void): void { - exitListener = listener; - }, - stop(): void {}, - close(): Promise { - return Promise.resolve(); - }, - }; - return { - channel, - emitExit: (exit: { exitCode: number | null }) => exitListener?.(exit), - }; - } - - // Wrap a started real broker in a paperclip bridge handle. The handle exposes - // the same run-disposition surface the sandbox bridge exposes, so the seam runs - // against the real latch and the real orderly-completion mark. - function bridgeOverBroker(fake: ReturnType) { - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async () => ({ status: 200 }), + /** + * A minimal run-disposition latch. It reproduces the same ordering rule the + * bridge transport applies: the first ordered loss or orderly completion + * latches the terminal disposition, and a later call never overrides it. + * The test drives the latch directly, with no channel and no live process. + */ + function createFakeBridgeHandle() { + let lossOrdered = false; + let lossReason: string | null = null; + let completionOrdered = false; + const readDisposition = () => ({ failed: lossOrdered, lossReason }); + const markOrderlyCompletion = vi.fn(() => { + if (completionOrdered || lossOrdered) return; + completionOrdered = true; + }); + const settleRunDisposition = vi.fn(() => { + markOrderlyCompletion(); + return readDisposition(); }); - broker.start(); - const markOrderlyCompletion = vi.fn(() => broker.markOrderlyCompletion()); - const settleRunDisposition = vi.fn(() => broker.settleRunDisposition()); const stop = vi.fn(async () => {}); const handle = { env: { PAPERCLIP_API_URL: "http://127.0.0.1:1", PAPERCLIP_API_KEY: "bridge-token", - PAPERCLIP_API_BRIDGE_MODE: "duplex_v1", + PAPERCLIP_API_BRIDGE_MODE: "http2_v1", }, - readRunDisposition: () => broker.runDisposition, + readRunDisposition: () => readDisposition(), settleRunDisposition, markOrderlyCompletion, stop, }; - return { broker, handle, markOrderlyCompletion, settleRunDisposition }; + return { + handle, + markOrderlyCompletion, + settleRunDisposition, + readDisposition, + // Record the first ordered loss. A loss ordered after a completion, or a + // second loss, is a no-op — the same rule the real transport applies. + emitLoss: (reason: string) => { + if (lossOrdered || completionOrdered) return; + lossOrdered = true; + lossReason = reason; + }, + }; } // A runtime whose one turn completes cleanly. The `beforeResult` hook runs at @@ -5888,14 +5885,13 @@ describe("ACPX engine sandbox duplex run-disposition seam (fail-closed)", () => } as never); } - it("fails a completed run when the duplex channel was lost before the completion", async () => { + it("fails a completed run when the bridge channel was lost before the completion", async () => { const sandbox = await setupRemoteSandbox(); - const fake = createFakeDuplexChannel(); - const { broker, handle, settleRunDisposition } = bridgeOverBroker(fake); + const fake = createFakeBridgeHandle(); // Latch the loss before the ACP terminal resolves. - const runtime = runtimeWithControlledResult(() => fake.emitExit({ exitCode: 1 })); + const runtime = runtimeWithControlledResult(() => fake.emitLoss("provider_exit")); - const result = await runRemote(handle, runtime, sandbox); + const result = await runRemote(fake.handle, runtime, sandbox); // The lost channel overrides the nominally completed terminal to a failure. expect(result.exitCode).not.toBe(0); @@ -5905,65 +5901,62 @@ describe("ACPX engine sandbox duplex run-disposition seam (fail-closed)", () => expect(result.resultJson).toMatchObject({ status: "failed" }); // The seam read the disposition through the atomic settle step, and the // latched loss kept the failure, so no orderly completion ordered. - expect(settleRunDisposition).toHaveBeenCalledTimes(1); - expect(broker.runDisposition.failed).toBe(true); + expect(fake.settleRunDisposition).toHaveBeenCalledTimes(1); + expect(fake.readDisposition().failed).toBe(true); }); it("keeps a completed run a success when the channel stays live, and a later teardown loss is benign", async () => { const sandbox = await setupRemoteSandbox(); - const fake = createFakeDuplexChannel(); - const { broker, handle, settleRunDisposition } = bridgeOverBroker(fake); + const fake = createFakeBridgeHandle(); // No loss before the completion. const runtime = runtimeWithControlledResult(); - const result = await runRemote(handle, runtime, sandbox); + const result = await runRemote(fake.handle, runtime, sandbox); expect(result.exitCode).toBe(0); expect(result.errorCode ?? null).toBeNull(); // The atomic settle step marked the orderly completion for the // success-eligible terminal. - expect(settleRunDisposition).toHaveBeenCalledTimes(1); + expect(fake.settleRunDisposition).toHaveBeenCalledTimes(1); // A teardown loss ordered after the orderly completion is a normal teardown, // so the run disposition stays a success. - fake.emitExit({ exitCode: 0 }); - expect(broker.runDisposition.failed).toBe(false); + fake.emitLoss("provider_exit"); + expect(fake.readDisposition().failed).toBe(false); }); it("does not let a later completion or activity clear the loss latch", async () => { const sandbox = await setupRemoteSandbox(); - const fake = createFakeDuplexChannel(); - const { broker, handle } = bridgeOverBroker(fake); + const fake = createFakeBridgeHandle(); // Latch the loss before the ACP terminal resolves. - const runtime = runtimeWithControlledResult(() => fake.emitExit({ exitCode: 1 })); + const runtime = runtimeWithControlledResult(() => fake.emitLoss("provider_exit")); - const result = await runRemote(handle, runtime, sandbox); + const result = await runRemote(fake.handle, runtime, sandbox); expect(result.exitCode).not.toBe(0); expect(result.errorCode).toBe("duplex_channel_lost"); // A later orderly-completion mark and further channel activity cannot clear // the latched loss. - broker.markOrderlyCompletion(); - fake.emitExit({ exitCode: 0 }); - expect(broker.runDisposition.failed).toBe(true); - expect(broker.runDisposition.lossReason).toBe("provider_exit"); + fake.markOrderlyCompletion(); + fake.emitLoss("provider_exit"); + expect(fake.readDisposition().failed).toBe(true); + expect(fake.readDisposition().lossReason).toBe("provider_exit"); }); it("marks an orderly completion on a failed terminal so the teardown loss emits no false loss", async () => { const sandbox = await setupRemoteSandbox(); - const fake = createFakeDuplexChannel(); - const { broker, handle, markOrderlyCompletion } = bridgeOverBroker(fake); + const fake = createFakeBridgeHandle(); // The turn fails, and no channel loss ordered before the finalization. const runtime = runtimeWithFailedResult(); - const result = await runRemote(handle, runtime, sandbox); + const result = await runRemote(fake.handle, runtime, sandbox); - // The failed terminal stays a failure, but not a duplex loss. + // The failed terminal stays a failure, but not a bridge-channel loss. expect(result.exitCode).not.toBe(0); expect(result.errorCode).not.toBe("duplex_channel_lost"); // The non-success-eligible terminal marked the orderly completion, so the - // teardown channel_exit orders after the mark and does not latch a loss. - expect(markOrderlyCompletion).toHaveBeenCalledTimes(1); - fake.emitExit({ exitCode: 0 }); - expect(broker.runDisposition.failed).toBe(false); + // teardown loss orders after the mark and does not latch a loss. + expect(fake.markOrderlyCompletion).toHaveBeenCalledTimes(1); + fake.emitLoss("provider_exit"); + expect(fake.readDisposition().failed).toBe(false); }); }); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 45a73f34b90..9210025c4db 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -15,7 +15,7 @@ import type { import { adapterExecutionTargetSessionIdentity, describeAdapterExecutionTarget, - adapterExecutionTargetDuplexTelemetryRecorder, + adapterExecutionTargetDuplexObservabilityRecorder, adapterExecutionTargetEnablesSandboxDuplexBridge, formatAdapterExecutionTimeoutErrorMessage, formatAdapterExecutionTimeoutStartLogLine, @@ -33,8 +33,8 @@ import { type PreparedAdapterExecutionTargetRuntime, type SandboxAdditionalSource, } from "@paperclipai/adapter-utils/execution-target"; -import type { DuplexLossReason } from "../duplex-telemetry.js"; -import { DUPLEX_CHANNEL_LOST_ERROR_CODE } from "../duplex-bridge-broker.js"; +import type { DuplexLossReason } from "../duplex-observability.js"; +import { DUPLEX_CHANNEL_LOST_ERROR_CODE } from "../bridge-transport-contract.js"; import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, applyPaperclipWorkspaceEnv, @@ -2052,7 +2052,7 @@ async function buildRuntime(input: { timeoutSec, hostApiToken: env.PAPERCLIP_API_KEY, enableSandboxDuplexBridge: adapterExecutionTargetEnablesSandboxDuplexBridge(remoteTarget), - duplexTelemetryRecorder: adapterExecutionTargetDuplexTelemetryRecorder(remoteTarget), + duplexObservabilityRecorder: adapterExecutionTargetDuplexObservabilityRecorder(remoteTarget), onLog: input.ctx.onLog, getRuntimeParentContext: input.getRuntimeParentContext, runtimeSpan: input.runtimeSpan, @@ -3390,10 +3390,11 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { .onLog("stderr", `[paperclip] ACPX teardown step "${step}" failed: ${reason}\n`) .catch(() => {}); }; - // Emit one per-phase timing telemetry event. It is observability-only: it - // carries the phase name (from the closed allowlist), the wall time, and the - // outcome, and never a command, a path, an environment value, or an - // identifier. Telemetry failure never fails the run. + // Emit one per-phase timing run-log event. It is not an OpenTelemetry + // export and it is not a Telemetry event: it carries the phase name + // (from the closed allowlist), the wall time, and the outcome, and + // never a command, a path, an environment value, or an identifier. A + // failure to emit this event never fails the run. const emitPhase = (phase: string, startMs: number, outcome: "ok" | "failed"): Promise => emitRunPhaseTiming(ctx, phase, now() - startMs, outcome); // Time a settlement step and emit its phase timing on every path. A step diff --git a/packages/adapter-utils/src/acpx-engine/startup-timing.ts b/packages/adapter-utils/src/acpx-engine/startup-timing.ts index 131c7cf6215..ebbd237befb 100644 --- a/packages/adapter-utils/src/acpx-engine/startup-timing.ts +++ b/packages/adapter-utils/src/acpx-engine/startup-timing.ts @@ -43,7 +43,7 @@ export function normalizeProviderFamily(key: string | undefined): string { /** * The common prefix for every sandbox-startup span attribute. One prefix keeps - * the attribute namespace closed and easy to find in the telemetry backend. + * the attribute namespace closed and easy to find in the OpenTelemetry backend. */ export const SANDBOX_STARTUP_SPAN_ATTR_PREFIX = "paperclip.sandbox.startup."; @@ -794,11 +794,12 @@ export async function emitSkippedStartupStep( /** * Structured event emitted once per named run-lifecycle phase, so the duration - * and the outcome of each phase land in the run-events stream. It is - * observability-only and rides the existing `ctx.onEvent` bridge. The payload is - * a closed shape: exactly `phase`, `durationMs`, and `outcome`. The phase name is - * from a closed allowlist, so the event never carries a command, an argument, a - * path, an environment value, or a raw identifier. + * and the outcome of each phase land in the run-events stream. It is a + * run-log event and rides the existing `ctx.onEvent` bridge. It never changes + * startup control flow. The payload is a closed shape: exactly `phase`, + * `durationMs`, and `outcome`. The phase name is from a closed allowlist, so + * the event never carries a command, an argument, a path, an environment + * value, or a raw identifier. */ export const RUN_PHASE_TIMING_EVENT_TYPE = "run.phase.timing"; diff --git a/packages/adapter-utils/src/bridge-transport-contract.ts b/packages/adapter-utils/src/bridge-transport-contract.ts new file mode 100644 index 00000000000..5209ccd318f --- /dev/null +++ b/packages/adapter-utils/src/bridge-transport-contract.ts @@ -0,0 +1,61 @@ +/** + * Shared contract for the sandbox callback bridge transports. + * + * The retired duplex_v1 broker first defined these symbols. The host + * broker is gone, but the http2_v1 transport and the ACPX engine + * run-disposition seam still use them. This leaf module holds the + * survivors, so a caller of the run-disposition seam does not import the + * whole HTTP/2 bridge server module graph to reach one error code. + */ + +import type { DuplexLossReason } from "./duplex-observability.js"; + +/** + * The typed error code the host reports when the bridge control channel + * died before an orderly completion. Both the ACP lane and the CLI lane + * report this one code, so the run disposition is identical across the two + * lanes. + */ +export const DUPLEX_CHANNEL_LOST_ERROR_CODE = "duplex_channel_lost"; + +/** + * The terminal run disposition a bridge transport computes from its ordered + * lifecycle. A `failed` disposition means a terminal loss ordered before an + * orderly completion, so the run must not report success. The typed loss + * reason names the cause; it is `null` for a success. + */ +export interface DuplexBrokerRunDisposition { + /** True when a terminal loss ordered before an orderly completion. */ + failed: boolean; + /** The typed, closed loss reason on a failure; `null` on a success. */ + lossReason: DuplexLossReason | null; +} + +/** The nested timeout budgets. Each inner budget is smaller than its outer budget. */ +export interface DuplexBrokerBudgets { + /** The deadline for one forward call, in milliseconds. */ + forwardTimeoutMs: number; + /** The deadline for the broker to send one response frame, in milliseconds. */ + responseBudgetMs: number; + /** The deadline the in-sandbox gateway waits for the response frame, in milliseconds. */ + gatewayWaitMs: number; +} + +/** The default nested budgets: forward 30 s, response 32 s, gateway wait 35 s. */ +export const DEFAULT_DUPLEX_BROKER_BUDGETS: DuplexBrokerBudgets = { + forwardTimeoutMs: 30_000, + responseBudgetMs: 32_000, + gatewayWaitMs: 35_000, +}; + +/** + * The safe HTTP methods. RFC 7231 section 4.2.1 defines this set. A safe method + * does not change host state, so the host applies no mutation for it. A caller + * can retry a safe method after a forward failure without a double-apply risk. + */ +const SAFE_BRIDGE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]); + +/** Report whether the method is safe, so a forward failure stays retryable. */ +export function isSafeBridgeMethod(method: string): boolean { + return SAFE_BRIDGE_METHODS.has(method.trim().toUpperCase()); +} diff --git a/packages/adapter-utils/src/command-managed-runtime.test.ts b/packages/adapter-utils/src/command-managed-runtime.test.ts index 2666d20ae78..cf240453678 100644 --- a/packages/adapter-utils/src/command-managed-runtime.test.ts +++ b/packages/adapter-utils/src/command-managed-runtime.test.ts @@ -8,11 +8,34 @@ import { afterEach, describe, expect, it } from "vitest"; import { createCommandManagedRuntimeClient, prepareCommandManagedRuntime, + type CommandManagedDuplexChannel, type CommandManagedRuntimeRunner, } from "./command-managed-runtime.js"; import type { SandboxSyncOperation } from "./sandbox-managed-runtime.js"; import type { RunProcessResult } from "./server-utils.js"; +/** + * An in-memory fake `CommandManagedDuplexChannel`. It holds no real process; a + * write echoes straight to the one registered data listener, so a test proves + * the channel contract carries a `Uint8Array` chunk with no string coercion in + * between. The fake never exits on its own; a test calls the listener it + * registers with `onExit` only when it needs one. + */ +function createFakeEchoDuplexChannel(): CommandManagedDuplexChannel { + let dataListener: ((chunk: Uint8Array) => void) | null = null; + return { + write(data: Uint8Array): void { + dataListener?.(data); + }, + onData(listener: (chunk: Uint8Array) => void): void { + dataListener = listener; + }, + onExit(): void {}, + stop(): void {}, + close: async (): Promise => {}, + }; +} + const execFile = promisify(execFileCallback); interface SpawnRunnerHandle { @@ -1057,4 +1080,22 @@ describe("command managed runtime", () => { /stdout: tar: workspace-download\.tar: Cannot open: Permission denied/, ); }); + + it("test_channel_round_trips_all_byte_values", () => { + const channel = createFakeEchoDuplexChannel(); + const allByteValues = Uint8Array.from({ length: 256 }, (_, value) => value); + const received: Uint8Array[] = []; + channel.onData((chunk) => { + received.push(chunk); + }); + + channel.write(allByteValues); + + expect(received).toHaveLength(1); + // A byte value of zero must survive. A UTF-8 string channel loses it: a + // JavaScript string can hold the code point U+0000, but a C-style consumer + // downstream of a string channel often treats it as a terminator. + expect(received[0]).toEqual(allByteValues); + expect(Array.from(received[0] ?? [])).toEqual(Array.from({ length: 256 }, (_, value) => value)); + }); }); diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index 1121d8365d5..b707529f4dc 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -37,9 +37,9 @@ export interface DuplexChannelOpenInput { */ export interface CommandManagedDuplexChannel { /** Writes raw input bytes to the channel. */ - write(data: string): void; - /** Registers the one data listener. The channel streams each raw chunk in order. */ - onData(listener: (chunk: string) => void): void; + write(data: Uint8Array): void; + /** Registers the one data listener. The channel streams each raw byte chunk in order. */ + onData(listener: (chunk: Uint8Array) => void): void; /** * Registers the one exit listener. The channel calls it one time with the exit. * A numeric `exitCode` is a real process exit. `transportClosed` is true when the @@ -117,6 +117,8 @@ export interface CommandManagedRuntimeRunner { * bidirectional channel to a long-lived command in the sandbox. The SSH runner * and every provider without the capability omit the member, so a caller gates * on its presence in the same style as {@link syncIn}/{@link syncOut}. + * + * HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. */ openDuplexChannel?(input: DuplexChannelOpenInput): Promise; } diff --git a/packages/adapter-utils/src/duplex-aggregate-byte-ledger.test.ts b/packages/adapter-utils/src/duplex-aggregate-byte-ledger.test.ts index 790100509e8..6711bf625b8 100644 --- a/packages/adapter-utils/src/duplex-aggregate-byte-ledger.test.ts +++ b/packages/adapter-utils/src/duplex-aggregate-byte-ledger.test.ts @@ -163,6 +163,8 @@ describe("DuplexAggregateByteLedger", () => { "decoder_buffer", "readiness_buffer", "readiness_replay", + "http2_preface_scan", + "http2_preface_replay", "pending_write", "stdin_write", ]); diff --git a/packages/adapter-utils/src/duplex-aggregate-byte-ledger.ts b/packages/adapter-utils/src/duplex-aggregate-byte-ledger.ts index e7f7aec3c16..9924659ea8e 100644 --- a/packages/adapter-utils/src/duplex-aggregate-byte-ledger.ts +++ b/packages/adapter-utils/src/duplex-aggregate-byte-ledger.ts @@ -34,7 +34,7 @@ import { DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL, DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL, DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE, -} from "./duplex-telemetry.js"; +} from "./duplex-observability.js"; /** * The public rejection marker. A retention site that cannot reserve its bytes @@ -80,6 +80,17 @@ export const MIN_HOST_PROCESS_MEMORY_BYTES_FOR_DUPLEX = 2 * 1024 * 1024 * 1024; * frame decoder charges its own retention. This is a separate retention from * `readiness_buffer`: the gate drops the whole pre-READY buffer on READY, then * charges only the retained suffix and each later pre-bind chunk under this owner. + * - `http2_preface_scan`: the raw untrusted bytes the `http2_v1` preface scan + * retains while it searches for the client connection preface, from the + * readiness-replay handoff until the preface match or the scan cap. This + * is a separate retention from `readiness_replay`: it starts only after + * the readiness gate hands its own retained suffix to the preface scan. + * - `http2_preface_replay`: the bytes the `http2_v1` preface scan holds after the + * client connection preface, from the preface match until the HTTP/2 server + * binds its downstream listener. This is a separate retention from + * `http2_preface_scan`: the scan drops its own buffer on the preface match, + * then charges only the retained suffix and each later pre-bind chunk under + * this owner. * - `pending_write`: the raw host-to-worker write payload a pending duplex write * RPC retains, from the enqueue seam until the RPC settles. * - `stdin_write`: the serialized host-to-worker frame the child-stdin transport @@ -99,6 +110,8 @@ export const DUPLEX_AGGREGATE_TOKEN_OWNERS = [ "decoder_buffer", "readiness_buffer", "readiness_replay", + "http2_preface_scan", + "http2_preface_replay", "pending_write", "stdin_write", ] as const; diff --git a/packages/adapter-utils/src/duplex-body-spool.test.ts b/packages/adapter-utils/src/duplex-body-spool.test.ts new file mode 100644 index 00000000000..37307f2e67e --- /dev/null +++ b/packages/adapter-utils/src/duplex-body-spool.test.ts @@ -0,0 +1,306 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { DUPLEX_FRAME_VERSION, type DuplexBodyChunkFrame } from "./duplex-frame-codec.js"; +import { + DuplexBodyReceiver, + type DuplexBodyReceiverConfig, + splitBodyIntoChunkFrames, + sweepStaleSpillDirectories, +} from "./duplex-body-spool.js"; + +// Read the whole reassembled body back, whatever path it took. A memory body +// returns its buffer; a spilled body streams from disk. The test uses this only +// to assert the round-trip content, never on a hot path. +async function readBodyBytes(body: { + spilled: boolean; + createReadStream: () => NodeJS.ReadableStream; +}): Promise { + const chunks: Buffer[] = []; + for await (const chunk of body.createReadStream()) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +let spillRoot: string; + +beforeEach(async () => { + spillRoot = await fs.mkdtemp(path.join(os.tmpdir(), "duplex-spool-test-")); +}); + +afterEach(async () => { + await fs.rm(spillRoot, { recursive: true, force: true }).catch(() => undefined); +}); + +function makeReceiver(overrides: DuplexBodyReceiverConfig = {}): Promise { + return DuplexBodyReceiver.create({ spillRootDir: spillRoot, ...overrides }); +} + +const RAW = 8; + +// Feed a whole body through a receiver as an envelope plus chunk frames. Return +// the settled body handle. The caller pushes the chunks the split produces. +async function transfer( + receiver: DuplexBodyReceiver, + id: string, + body: Buffer, +): ReturnType { + const promise = receiver.begin(id, body.length); + const frames = splitBodyIntoChunkFrames(id, body, DUPLEX_FRAME_VERSION, RAW); + for (const frame of frames) { + const result = receiver.pushChunk(frame); + expect(result.ok).toBe(true); + } + return promise; +} + +describe("duplex body reassembler — round trip", () => { + it("reassembles a small body on the memory path", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); + const source = Buffer.from("hello duplex body"); + const body = await transfer(receiver, "req-mem", source); + expect(body.spilled).toBe(false); + expect(body.byteCount).toBe(source.length); + expect(body.readMemory().equals(source)).toBe(true); + expect((await readBodyBytes(body)).equals(source)).toBe(true); + await body.dispose(); + }); + + it("reassembles a large body on the spill path without holding it in memory", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 16, rawChunkBytes: RAW }); + const source = Buffer.from("abcdefghijklmnopqrstuvwxyz0123456789ABCD"); // 40 bytes + const body = await transfer(receiver, "req-spill", source); + expect(body.spilled).toBe(true); + expect(body.byteCount).toBe(source.length); + // A spilled body never loads the whole body into memory on the receive side. + expect(() => body.readMemory()).toThrow(); + expect(receiver.spillUsedBytes).toBe(source.length); + expect(receiver.spillOpenFiles).toBe(1); + expect((await readBodyBytes(body)).equals(source)).toBe(true); + await body.dispose(); + // The dispose path unlinks the file and releases the reservation. + expect(receiver.spillUsedBytes).toBe(0); + expect(receiver.spillOpenFiles).toBe(0); + const entries = await fs.readdir(receiver.spillDir); + expect(entries).toHaveLength(0); + await receiver.destroy(); + }); + + it("completes a zero-length body at once and accepts no chunk", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 16, rawChunkBytes: RAW }); + const body = await receiver.begin("req-zero", 0); + expect(body.byteCount).toBe(0); + expect(body.readMemory().length).toBe(0); + const stray = receiver.pushChunk({ + version: DUPLEX_FRAME_VERSION, + type: "body_chunk", + id: "req-zero", + seq: 0, + data: Buffer.from("x").toString("base64"), + }); + expect(stray.ok).toBe(false); + if (!stray.ok) expect(stray.error.code).toBe("zero_body_has_chunk"); + await body.dispose(); + }); +}); + +describe("duplex body reassembler — rejection matrix", () => { + function chunk(id: string, seq: number, data: string): DuplexBodyChunkFrame { + return { version: DUPLEX_FRAME_VERSION, type: "body_chunk", id, seq, data }; + } + + it("rejects a duplicate seq", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); + void receiver.begin("d", 16); + expect(receiver.pushChunk(chunk("d", 0, Buffer.alloc(RAW, 1).toString("base64"))).ok).toBe(true); + const dup = receiver.pushChunk(chunk("d", 0, Buffer.alloc(RAW, 2).toString("base64"))); + expect(dup.ok).toBe(false); + if (!dup.ok) expect(dup.error.code).toBe("seq_out_of_order"); + }); + + it("rejects a gap in the seq order", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); + void receiver.begin("g", 24); + expect(receiver.pushChunk(chunk("g", 0, Buffer.alloc(RAW, 1).toString("base64"))).ok).toBe(true); + const gap = receiver.pushChunk(chunk("g", 2, Buffer.alloc(RAW, 2).toString("base64"))); + expect(gap.ok).toBe(false); + if (!gap.ok) expect(gap.error.code).toBe("seq_out_of_order"); + }); + + it("rejects a reordered seq", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); + void receiver.begin("r", 24); + const reorder = receiver.pushChunk(chunk("r", 1, Buffer.alloc(RAW, 1).toString("base64"))); + expect(reorder.ok).toBe(false); + if (!reorder.ok) expect(reorder.error.code).toBe("seq_out_of_order"); + }); + + it("rejects non-canonical base64", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); + const begun = receiver.begin("b", 8); + begun.catch(() => undefined); + // "AB==" carries non-zero bits in the final sextet, so it decodes to one + // byte and re-encodes to "AA==". The round-trip mismatch marks it not + // canonical. + const bad = receiver.pushChunk(chunk("b", 0, "AB==")); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.error.code).toBe("chunk_not_canonical_base64"); + }); + + it("rejects a short non-final chunk (truncation)", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); + void receiver.begin("t", 24); + // A non-final chunk must be exactly RAW bytes. A 4-byte chunk that does not + // complete the 24-byte body is a truncation. + const truncation = receiver.pushChunk(chunk("t", 0, Buffer.alloc(4, 1).toString("base64"))); + expect(truncation.ok).toBe(false); + if (!truncation.ok) expect(truncation.error.code).toBe("chunk_wrong_size"); + }); + + it("rejects an empty non-final chunk", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); + void receiver.begin("e", 16); + const empty = receiver.pushChunk(chunk("e", 0, "")); + expect(empty.ok).toBe(false); + if (!empty.ok) expect(empty.error.code).toBe("empty_chunk"); + }); + + it("rejects an overrun past the declared body size", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); + // A 12-byte body takes one full RAW chunk, then a final chunk of 4 bytes. A + // second full RAW chunk overruns the declared size. + void receiver.begin("o", 12); + expect(receiver.pushChunk(chunk("o", 0, Buffer.alloc(RAW, 1).toString("base64"))).ok).toBe(true); + const overrun = receiver.pushChunk(chunk("o", 1, Buffer.alloc(RAW, 2).toString("base64"))); + expect(overrun.ok).toBe(false); + if (!overrun.ok) expect(overrun.error.code).toBe("body_overrun"); + }); + + it("rejects an over-size final chunk", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); + void receiver.begin("os", 40); + const over = receiver.pushChunk(chunk("os", 0, Buffer.alloc(RAW + 4, 1).toString("base64"))); + expect(over.ok).toBe(false); + if (!over.ok) expect(over.error.code).toBe("chunk_wrong_size"); + }); + + it("rejects a chunk that arrives with no matching envelope", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); + const orphan = receiver.pushChunk(chunk("missing", 0, Buffer.alloc(RAW, 1).toString("base64"))); + expect(orphan.ok).toBe(false); + if (!orphan.ok) expect(orphan.error.code).toBe("chunk_without_envelope"); + }); + + it("rejects a chunk that arrives after completion", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 1024, rawChunkBytes: RAW }); + const promise = receiver.begin("c", RAW); + expect(receiver.pushChunk(chunk("c", 0, Buffer.alloc(RAW, 1).toString("base64"))).ok).toBe(true); + await promise; + const late = receiver.pushChunk(chunk("c", 1, Buffer.alloc(RAW, 2).toString("base64"))); + expect(late.ok).toBe(false); + if (!late.ok) expect(late.error.code).toBe("chunk_after_completion"); + }); +}); + +describe("duplex body reassembler — spill caps fail closed", () => { + it("fails closed when a write exceeds the per-channel byte cap and unlinks the partial file", async () => { + // A 5-byte cap admits the first RAW=8 chunk write? No: reserve 8 > cap 5, so + // the first write fails closed. Use a cap that admits one chunk but not two. + const receiver = await makeReceiver({ + spillThresholdBytes: 4, + rawChunkBytes: RAW, + channelSpillByteCap: RAW + 2, + }); + const promise = receiver.begin("cap", 24); + expect(receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "cap", seq: 0, data: Buffer.alloc(RAW, 1).toString("base64") }).ok).toBe(true); + // The second chunk write reserves past the byte cap. + receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "cap", seq: 1, data: Buffer.alloc(RAW, 2).toString("base64") }); + await expect(promise).rejects.toMatchObject({ code: "spill_cap_exceeded" }); + // The partial file is gone and the reservation released. + expect(receiver.spillUsedBytes).toBe(0); + expect(receiver.spillOpenFiles).toBe(0); + const entries = await fs.readdir(receiver.spillDir); + expect(entries).toHaveLength(0); + await receiver.destroy(); + }); + + it("fails closed when the open-file cap is reached", async () => { + const receiver = await makeReceiver({ + spillThresholdBytes: 4, + rawChunkBytes: RAW, + channelSpillFileCap: 1, + }); + // The first spill body claims the one file slot and stays open. The channel + // dispose rejects this in-flight body, so attach the catch at once. + const first = receiver.begin("f1", 24); + first.catch(() => undefined); + expect(receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "f1", seq: 0, data: Buffer.alloc(RAW, 1).toString("base64") }).ok).toBe(true); + // The second spill body cannot claim a file slot. + await expect(receiver.begin("f2", 24)).rejects.toMatchObject({ code: "spill_cap_exceeded" }); + expect(receiver.spillOpenFiles).toBe(1); + // The dispose of the in-flight first body rejects its begin promise with a + // terminal channel-closed error and frees the file slot. + await receiver.disposeBody("f1"); + await expect(first).rejects.toMatchObject({ code: "channel_closed" }); + expect(receiver.spillOpenFiles).toBe(0); + await receiver.destroy(); + }); +}); + +describe("duplex body reassembler — lifecycle cleanup", () => { + it("releases the reservation and unlinks the file on channel death", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 4, rawChunkBytes: RAW }); + // The channel death rejects this in-flight body, so attach the catch at once. + const dying = receiver.begin("death", 24); + dying.catch(() => undefined); + expect(receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "death", seq: 0, data: Buffer.alloc(RAW, 1).toString("base64") }).ok).toBe(true); + // Wait for the write to land, so the file exists before the channel dies. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(receiver.spillOpenFiles).toBe(1); + await receiver.disposeAll(); + await expect(dying).rejects.toMatchObject({ code: "channel_closed" }); + expect(receiver.spillUsedBytes).toBe(0); + expect(receiver.spillOpenFiles).toBe(0); + const entries = await fs.readdir(receiver.spillDir); + expect(entries).toHaveLength(0); + await receiver.destroy(); + }); +}); + +describe("duplex body reassembler — spill file safety", () => { + it("creates the spill directory with mode 0700 and each spill file with mode 0600", async () => { + const receiver = await makeReceiver({ spillThresholdBytes: 4, rawChunkBytes: RAW }); + const dirStat = await fs.stat(receiver.spillDir); + expect(dirStat.mode & 0o777).toBe(0o700); + // The final destroy rejects this in-flight body, so attach the catch at once. + const pending = receiver.begin("mode", 8); + pending.catch(() => undefined); + receiver.pushChunk({ version: DUPLEX_FRAME_VERSION, type: "body_chunk", id: "mode", seq: 0, data: Buffer.alloc(RAW, 1).toString("base64") }); + await new Promise((resolve) => setTimeout(resolve, 20)); + const entries = await fs.readdir(receiver.spillDir); + expect(entries).toHaveLength(1); + // The file name is random, so no frame id rides the path. + expect(entries[0]).not.toContain("mode"); + const fileStat = await fs.stat(path.join(receiver.spillDir, entries[0])); + expect(fileStat.mode & 0o777).toBe(0o600); + await receiver.destroy(); + }); +}); + +describe("duplex body reassembler — startup sweep", () => { + it("removes a stale spill directory from a dead process but keeps a live one", async () => { + const deadPid = 999999; // A pid that is almost never alive. + const staleDir = path.join(spillRoot, `paperclip-duplex-spill-${deadPid}-stale`); + await fs.mkdir(staleDir); + await fs.writeFile(path.join(staleDir, "leftover.body"), "x"); + const liveDir = path.join(spillRoot, `paperclip-duplex-spill-${process.pid}-live`); + await fs.mkdir(liveDir); + await sweepStaleSpillDirectories(spillRoot); + await expect(fs.stat(staleDir)).rejects.toThrow(); + // The sweep keeps a directory that belongs to a live process. + await expect(fs.stat(liveDir)).resolves.toBeDefined(); + }); +}); diff --git a/packages/adapter-utils/src/duplex-body-spool.ts b/packages/adapter-utils/src/duplex-body-spool.ts new file mode 100644 index 00000000000..576caca5afe --- /dev/null +++ b/packages/adapter-utils/src/duplex-body-spool.ts @@ -0,0 +1,596 @@ +/** + * Receive-side body reassembly and spill for the sandbox duplex channel. + * + * The duplex codec splits one request body or one response body into an envelope + * frame plus a sequence of `body_chunk` frames. This module reassembles those + * chunks back into one body. The receiver reads `bodyByteCount` from the envelope + * first, then chooses one of two paths before it reads the first chunk: + * - memory path: a body at or below the spill threshold stays in a memory + * buffer. + * - spill path: a body above the spill threshold streams to a temporary file, + * so the receiver never holds a whole large body in memory. + * + * The spill path bounds the disk a single channel can consume. A per-channel + * arena caps the bytes on disk and the number of open spill files. The receiver + * reserves a file slot and reserves bytes before it opens a file and before each + * write. A reserve that exceeds a cap fails closed: it rejects the body as a + * resource error and unlinks any partial file. + * + * The reassembler treats every frame as untrusted. It rejects a bad chunk as a + * loud, terminal protocol error: a non-canonical base64 payload, a wrong chunk + * size, an out-of-order `seq`, a total that does not equal `bodyByteCount`, or a + * chunk that arrives for a completed or unknown body. + */ + +import { randomBytes } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { promises as fs } from "node:fs"; +import type { FileHandle } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { Readable } from "node:stream"; + +import { + DUPLEX_BODY_CHUNK_RAW_BYTES, + type DuplexBodyChunkFrame, +} from "./duplex-frame-codec.js"; + +/** + * The default spill threshold, in bytes. A body at or below this size stays in + * memory. A body above it spills to disk. The value is 1 MiB. It stays + * injectable so a test can lower it and exercise the spill path. + */ +export const DEFAULT_DUPLEX_BODY_SPILL_THRESHOLD_BYTES = 1024 * 1024; + +/** + * The default per-channel spill byte cap. The receiver keeps at most this many + * bytes on disk at one time for one channel. The value is 64 MiB. It is about + * 0.64% of the default 10 GiB sandbox disk and stays far below the security + * ceiling of 25% of the disk quota. It stays injectable so a test can lower it. + */ +export const DEFAULT_DUPLEX_CHANNEL_SPILL_BYTE_CAP = 64 * 1024 * 1024; + +/** + * The default per-channel open-file cap. The receiver keeps at most this many + * spill files open at one time for one channel. The value derives from the byte + * cap and the spill threshold: 64 MiB divided by the 1 MiB spill floor gives 64. + * It stays injectable so a test can lower it. + */ +export const DEFAULT_DUPLEX_CHANNEL_SPILL_FILE_CAP = 64; + +/** The prefix of every spill directory name. The startup sweep matches it. */ +const SPILL_DIR_PREFIX = "paperclip-duplex-spill-"; + +/** The config a caller passes to build a {@link DuplexBodyReceiver}. */ +export interface DuplexBodyReceiverConfig { + /** The spill threshold in bytes. Defaults to {@link DEFAULT_DUPLEX_BODY_SPILL_THRESHOLD_BYTES}. */ + spillThresholdBytes?: number; + /** The fixed raw chunk size in bytes. Defaults to {@link DUPLEX_BODY_CHUNK_RAW_BYTES}. */ + rawChunkBytes?: number; + /** The per-channel spill byte cap. Defaults to {@link DEFAULT_DUPLEX_CHANNEL_SPILL_BYTE_CAP}. */ + channelSpillByteCap?: number; + /** The per-channel open-file cap. Defaults to {@link DEFAULT_DUPLEX_CHANNEL_SPILL_FILE_CAP}. */ + channelSpillFileCap?: number; + /** The root directory that holds the spill directory. Defaults to the OS temp dir. */ + spillRootDir?: string; + /** A diagnostic sink for a non-fatal cleanup note. The receiver names no body id here. */ + logger?: (message: string) => void; +} + +/** The reason the reassembler rejected a body. Each reason is a terminal protocol error. */ +export type DuplexBodyErrorCode = + | "chunk_without_envelope" + | "chunk_after_completion" + | "seq_out_of_order" + | "chunk_not_canonical_base64" + | "chunk_wrong_size" + | "empty_chunk" + | "body_overrun" + | "zero_body_has_chunk" + | "spill_cap_exceeded" + | "spill_write_failed" + | "path_escape" + | "channel_closed"; + +/** A reassembly error. The receiver returns it; it never leaks a raw path or a raw provider string. */ +export class DuplexBodyError extends Error { + readonly code: DuplexBodyErrorCode; + constructor(code: DuplexBodyErrorCode, message: string) { + super(message); + this.name = "DuplexBodyError"; + this.code = code; + } +} + +/** + * One reassembled body. The memory path holds the whole body in a buffer. The + * spill path holds a temporary file. A caller reads the memory buffer, or opens + * a fresh read stream, then disposes the body once. + */ +export interface ReassembledBody { + /** The raw byte length of the body. */ + readonly byteCount: number; + /** True when the body spilled to disk. */ + readonly spilled: boolean; + /** + * The whole body as a buffer. The memory path returns it. The spill path + * throws, because the spill path must never load a whole large body into + * memory on the receive side. A caller streams a spilled body instead. + */ + readMemory(): Buffer; + /** A fresh read stream over the body. The memory path streams one buffer; the spill path streams the file. */ + createReadStream(): Readable; + /** Release the body. The spill path unlinks the file and releases the reservation exactly once. */ + dispose(): Promise; +} + +/** + * The per-channel arena that bounds the spill disk use. It tracks the bytes on + * disk and the open files, and it owns the spill directory. It hands out + * reservations and takes them back. The reservation math is synchronous, so a + * concurrent reserve never races past a cap. + */ +class SpillArena { + usedBytes = 0; + openFiles = 0; + constructor( + readonly dir: string, + private readonly byteCap: number, + private readonly fileCap: number, + ) {} + + /** Reserve one open-file slot. Return false when the file cap is already at the limit. */ + reserveFile(): boolean { + if (this.openFiles >= this.fileCap) return false; + this.openFiles += 1; + return true; + } + + /** Release one open-file slot. */ + releaseFile(): void { + if (this.openFiles > 0) this.openFiles -= 1; + } + + /** Reserve `n` bytes of disk. Return false when the byte cap would be exceeded. */ + reserveBytes(n: number): boolean { + if (this.usedBytes + n > this.byteCap) return false; + this.usedBytes += n; + return true; + } + + /** Release `n` bytes of disk. */ + releaseBytes(n: number): void { + this.usedBytes -= n; + if (this.usedBytes < 0) this.usedBytes = 0; + } +} + +/** The internal state of one in-flight body. */ +interface ActiveBody { + bodyByteCount: number; + nextSeq: number; + receivedBytes: number; + spilled: boolean; + /** Memory-path accumulation. Empty for the spill path. */ + memoryChunks: Buffer[]; + /** The spill file path. Null for the memory path. */ + filePath: string | null; + /** The open spill file handle. Null for the memory path or before the first write. */ + handle: FileHandle | null; + /** The bytes this body reserved in the arena. The dispose path releases exactly this many. */ + reservedBytes: number; + /** True when the body holds an arena file slot that the dispose path must release. */ + holdsFileSlot: boolean; + /** True once the dispose path ran, so it runs its release once. */ + disposed: boolean; + /** Resolve the completion promise the caller awaits. */ + resolve: (body: ReassembledBody) => void; + /** Reject the completion promise on a terminal error. */ + reject: (error: DuplexBodyError) => void; + /** Serialize the spill writes, so the chunks land on disk in `seq` order. */ + writeChain: Promise; + /** True once the body settled (resolved or rejected), so a late chunk never double-settles. */ + settled: boolean; +} + +/** The synchronous result of one `pushChunk` call. */ +export type DuplexBodyPushResult = + | { ok: true } + | { ok: false; error: DuplexBodyError }; + +/** + * A per-channel receive-side body reassembler. One instance serves one channel. + * The caller feeds it one envelope per body, then the `body_chunk` frames for + * that body. The reassembler reassembles the body on the memory path or the + * spill path and settles one completion promise per body. + */ +export class DuplexBodyReceiver { + private readonly spillThresholdBytes: number; + private readonly rawChunkBytes: number; + private readonly arena: SpillArena; + private readonly logger?: (message: string) => void; + private readonly active = new Map(); + + private constructor(arena: SpillArena, config: DuplexBodyReceiverConfig) { + this.arena = arena; + this.spillThresholdBytes = + config.spillThresholdBytes ?? DEFAULT_DUPLEX_BODY_SPILL_THRESHOLD_BYTES; + this.rawChunkBytes = config.rawChunkBytes ?? DUPLEX_BODY_CHUNK_RAW_BYTES; + this.logger = config.logger; + } + + /** + * Build a receiver. The receiver owns the spill directory. It creates the + * directory with `mkdtemp` and mode 0700, then sweeps stale spill directories + * from a prior process. The sweep runs and awaits before the receiver serves a + * body, so no caller races a half-swept directory. + */ + static async create(config: DuplexBodyReceiverConfig = {}): Promise { + const root = config.spillRootDir ?? os.tmpdir(); + await sweepStaleSpillDirectories(root, config.logger); + const dir = await fs.mkdtemp(path.join(root, `${SPILL_DIR_PREFIX}${process.pid}-`)); + // `mkdtemp` creates the directory with mode 0700 on a POSIX host by default, + // but a permissive umask can widen it. Set the mode so the owner alone reads, + // writes, and lists the spill directory. + await fs.chmod(dir, 0o700); + const arena = new SpillArena( + dir, + config.channelSpillByteCap ?? DEFAULT_DUPLEX_CHANNEL_SPILL_BYTE_CAP, + config.channelSpillFileCap ?? DEFAULT_DUPLEX_CHANNEL_SPILL_FILE_CAP, + ); + return new DuplexBodyReceiver(arena, config); + } + + /** The spill directory this receiver owns. A test reads it to assert containment and cleanup. */ + get spillDir(): string { + return this.arena.dir; + } + + /** The bytes on disk across all in-flight bodies for this channel. A test asserts the reservation. */ + get spillUsedBytes(): number { + return this.arena.usedBytes; + } + + /** The open spill files for this channel. A test asserts the file-slot reservation. */ + get spillOpenFiles(): number { + return this.arena.openFiles; + } + + /** + * Begin one body. The caller passes the envelope `id` and `bodyByteCount`. The + * receiver chooses the memory path or the spill path from `bodyByteCount` + * before the first chunk arrives. A `bodyByteCount` of 0 completes at once with + * an empty body and accepts no chunk. The returned promise resolves with the + * reassembled body, or rejects with a {@link DuplexBodyError} on a terminal + * protocol or resource error. + */ + begin(id: string, bodyByteCount: number): Promise { + return new Promise((resolve, reject) => { + // A repeated envelope id for an in-flight body is a protocol violation. The + // caller must not begin the same id twice. Reject the new begin and leave + // the in-flight body untouched. + if (this.active.has(id)) { + reject(new DuplexBodyError("chunk_after_completion", "duplicate body envelope id")); + return; + } + const body: ActiveBody = { + bodyByteCount, + nextSeq: 0, + receivedBytes: 0, + spilled: bodyByteCount > this.spillThresholdBytes, + memoryChunks: [], + filePath: null, + handle: null, + reservedBytes: 0, + holdsFileSlot: false, + disposed: false, + resolve, + reject, + writeChain: Promise.resolve(), + settled: false, + }; + this.active.set(id, body); + if (bodyByteCount === 0) { + // The zero-length body completes at once. It accepts no chunk, so the + // receiver keeps the id in a completed state to reject a stray chunk. + this.complete(id, body); + return; + } + if (body.spilled) { + // Reserve one file slot before the receiver opens the spill file. A + // reserve past the file cap fails closed at once. + if (!this.arena.reserveFile()) { + this.settleReject(id, body, new DuplexBodyError("spill_cap_exceeded", "spill file cap reached")); + return; + } + body.holdsFileSlot = true; + // Open the spill file with a random name and mode 0600. The name never + // derives from the frame id, so no id rides a path. Verify the resolved + // path stays inside the spill directory before the receiver writes. + this.openSpillFile(id, body); + } + }); + } + + /** + * Push one `body_chunk` frame. The receiver validates the chunk against the + * in-flight body, then appends it on the memory path or the spill path. The + * return value reports a synchronous validation outcome. A spill write failure + * or a completion rejects the begin promise instead. + */ + pushChunk(frame: DuplexBodyChunkFrame): DuplexBodyPushResult { + const body = this.active.get(frame.id); + if (!body) { + return fail("chunk_without_envelope", "body_chunk arrived with no matching envelope"); + } + if (body.bodyByteCount === 0) { + return fail("zero_body_has_chunk", "a zero-length body accepts no body_chunk"); + } + if (body.settled) { + return fail("chunk_after_completion", "body_chunk arrived after the body completed"); + } + if (frame.seq !== body.nextSeq) { + return fail("seq_out_of_order", "body_chunk seq is a duplicate, a gap, or a reorder"); + } + // Decode the base64 payload and confirm it is canonical. A non-canonical + // payload re-encodes to a different string, so the round-trip catches it. + const decoded = Buffer.from(frame.data, "base64"); + if (decoded.toString("base64") !== frame.data) { + return fail("chunk_not_canonical_base64", "body_chunk data is not canonical base64"); + } + if (decoded.length === 0) { + return fail("empty_chunk", "body_chunk is empty"); + } + const nextReceived = body.receivedBytes + decoded.length; + if (nextReceived > body.bodyByteCount) { + return fail("body_overrun", "body_chunk overruns the declared body size"); + } + const isFinal = nextReceived === body.bodyByteCount; + if (!isFinal && decoded.length !== this.rawChunkBytes) { + // A non-final chunk must be exactly the fixed raw slice size. A short + // non-final chunk is a truncation; an over-size chunk is a framing fault. + return fail("chunk_wrong_size", "a non-final body_chunk is not the fixed slice size"); + } + if (decoded.length > this.rawChunkBytes) { + // The final chunk must also stay within the fixed raw slice size. + return fail("chunk_wrong_size", "a body_chunk exceeds the fixed slice size"); + } + body.nextSeq += 1; + body.receivedBytes = nextReceived; + if (body.spilled) { + this.appendSpill(frame.id, body, decoded, isFinal); + } else { + body.memoryChunks.push(decoded); + if (isFinal) this.complete(frame.id, body); + } + return { ok: true }; + } + + /** + * Dispose one body by id, whatever its state. The dispose path unlinks the + * spill file and releases the arena reservation exactly once. The caller runs + * it on every settle path and on channel death, so a spill file never lingers. + */ + async disposeBody(id: string): Promise { + const body = this.active.get(id); + if (!body) return; + this.active.delete(id); + await this.release(body); + } + + /** + * Dispose every in-flight body. The caller runs it on channel death, so a + * channel that dies mid-body leaves no spill file behind. + */ + async disposeAll(): Promise { + const ids = [...this.active.keys()]; + await Promise.all(ids.map((id) => this.disposeBody(id))); + } + + /** + * Remove the spill directory this receiver owns. The caller runs it after it + * disposes every body, on channel shutdown. + */ + async destroy(): Promise { + await this.disposeAll(); + await fs.rm(this.arena.dir, { recursive: true, force: true }).catch(() => undefined); + } + + private openSpillFile(id: string, body: ActiveBody): void { + const name = `${randomBytes(24).toString("hex")}.body`; + const filePath = path.join(this.arena.dir, name); + // Path containment. The resolved file path must stay inside the spill + // directory. A generated random name never escapes, but the check is defense + // in depth against a future name scheme. + const resolvedDir = path.resolve(this.arena.dir); + const resolvedFile = path.resolve(filePath); + if (resolvedFile !== path.join(resolvedDir, name) || !resolvedFile.startsWith(resolvedDir + path.sep)) { + this.settleReject(id, body, new DuplexBodyError("path_escape", "spill path escapes the spill directory")); + return; + } + body.filePath = filePath; + // Open the file once, exclusive, mode 0600. The write chain appends each + // chunk in order to this one handle. + body.writeChain = body.writeChain.then(async () => { + body.handle = await fs.open(filePath, "wx", 0o600); + }); + body.writeChain.catch((error) => { + this.settleReject(id, body, new DuplexBodyError("spill_write_failed", errorText(error))); + }); + } + + private appendSpill(id: string, body: ActiveBody, decoded: Buffer, isFinal: boolean): void { + // Reserve the bytes before the write. A reserve past the byte cap fails + // closed: reject the body and unlink the partial file. + if (!this.arena.reserveBytes(decoded.length)) { + this.settleReject(id, body, new DuplexBodyError("spill_cap_exceeded", "spill byte cap reached")); + return; + } + body.reservedBytes += decoded.length; + body.writeChain = body.writeChain.then(async () => { + if (body.settled) return; + if (!body.handle) throw new Error("spill file handle is not open"); + await body.handle.write(decoded); + if (isFinal) { + await body.handle.close(); + body.handle = null; + this.complete(id, body); + } + }); + body.writeChain.catch((error) => { + if (error instanceof DuplexBodyError) return; + this.settleReject(id, body, new DuplexBodyError("spill_write_failed", errorText(error))); + }); + } + + private complete(id: string, body: ActiveBody): void { + if (body.settled) return; + body.settled = true; + const receiver = this; + const handle: ReassembledBody = { + byteCount: body.bodyByteCount, + spilled: body.spilled, + readMemory(): Buffer { + if (body.spilled) { + throw new DuplexBodyError("spill_write_failed", "a spilled body has no in-memory buffer"); + } + return Buffer.concat(body.memoryChunks); + }, + createReadStream(): Readable { + if (body.spilled && body.filePath) return createReadStream(body.filePath); + return Readable.from(Buffer.concat(body.memoryChunks)); + }, + async dispose(): Promise { + await receiver.disposeBody(id); + }, + }; + body.resolve(handle); + } + + private settleReject(id: string, body: ActiveBody, error: DuplexBodyError): void { + if (body.settled) return; + body.settled = true; + // Release the reservation and unlink the partial file before the reject, so + // a rejected body never keeps a spill file or a reservation. The reject fires + // after the release, so a caller that awaits the reject sees a clean arena. + void this.disposeBody(id).finally(() => body.reject(error)); + } + + private async release(body: ActiveBody): Promise { + if (body.disposed) return; + body.disposed = true; + // Wait for the pending writes to settle, so the unlink never races an open + // handle. Swallow a write error here; the reject path already reported it. + await body.writeChain.catch(() => undefined); + if (body.handle) { + await body.handle.close().catch(() => undefined); + body.handle = null; + } + if (body.filePath) { + await fs.rm(body.filePath, { force: true }).catch((error) => { + this.logger?.(`Duplex spill unlink failed: ${errorText(error)}`); + }); + } + if (body.reservedBytes > 0) { + this.arena.releaseBytes(body.reservedBytes); + body.reservedBytes = 0; + } + if (body.holdsFileSlot) { + this.arena.releaseFile(); + body.holdsFileSlot = false; + } + // A body that the channel disposes before it settles must not leave its + // begin promise pending. Reject it with a terminal channel-closed error, so + // a caller that awaits the body sees the channel death. A body that already + // settled keeps its outcome: `complete` resolves it, and `settleReject` sets + // `settled` before it calls dispose, so this guard never double-settles. + if (!body.settled) { + body.settled = true; + body.reject( + new DuplexBodyError("channel_closed", "the channel closed before the body completed"), + ); + } + } +} + +function fail(code: DuplexBodyErrorCode, message: string): DuplexBodyPushResult { + return { ok: false, error: new DuplexBodyError(code, message) }; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Split a whole body buffer into `body_chunk` frames. The send side reads the + * source body once, then calls this to build the frames. Each frame carries one + * raw slice of `rawChunkBytes` as base64 text, except the final frame, which + * carries the remaining bytes. A zero-length body yields no frame. + */ +export function splitBodyIntoChunkFrames( + id: string, + body: Buffer, + version: number, + rawChunkBytes: number = DUPLEX_BODY_CHUNK_RAW_BYTES, +): DuplexBodyChunkFrame[] { + const frames: DuplexBodyChunkFrame[] = []; + let seq = 0; + for (let offset = 0; offset < body.length; offset += rawChunkBytes) { + const slice = body.subarray(offset, offset + rawChunkBytes); + frames.push({ + version, + type: "body_chunk", + id, + seq, + data: slice.toString("base64"), + }); + seq += 1; + } + return frames; +} + +/** + * Sweep stale spill directories from a prior process. The receiver runs it at + * startup, before it serves a body. It removes a spill directory whose embedded + * pid is not this process and is not a live process, so a crash that left a + * directory behind does not leak disk. It never removes a directory of a live + * process, so a concurrent channel keeps its own spill directory. + */ +export async function sweepStaleSpillDirectories( + root: string, + logger?: (message: string) => void, +): Promise { + const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []); + await Promise.all( + entries.map(async (entry) => { + if (!entry.isDirectory() || !entry.name.startsWith(SPILL_DIR_PREFIX)) return; + const pid = parsePidFromSpillDir(entry.name); + if (pid !== null && pid !== process.pid && isProcessAlive(pid)) return; + if (pid === process.pid) return; + await fs.rm(path.join(root, entry.name), { recursive: true, force: true }).catch((error) => { + logger?.(`Duplex stale spill sweep failed: ${errorText(error)}`); + }); + }), + ); +} + +/** Read the pid segment from a spill directory name, or null when it is absent. */ +function parsePidFromSpillDir(name: string): number | null { + const rest = name.slice(SPILL_DIR_PREFIX.length); + const dash = rest.indexOf("-"); + const pidText = dash === -1 ? rest : rest.slice(0, dash); + const pid = Number(pidText); + return Number.isInteger(pid) && pid > 0 ? pid : null; +} + +/** Return true when a process with the pid is alive. A signal 0 probes without a real signal. */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM means the process exists but this process cannot signal it. ESRCH + // means no such process. Treat only ESRCH as dead. + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} diff --git a/packages/adapter-utils/src/duplex-bridge-broker-byte-ledger.test.ts b/packages/adapter-utils/src/duplex-bridge-broker-byte-ledger.test.ts deleted file mode 100644 index 78780c9ff59..00000000000 --- a/packages/adapter-utils/src/duplex-bridge-broker-byte-ledger.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; - -import { - createDuplexBridgeBroker, - type DuplexBridgeBroker, - type DuplexBrokerForwardResult, -} from "./duplex-bridge-broker.js"; -import { - DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED, - DuplexAggregateByteLedger, -} from "./duplex-aggregate-byte-ledger.js"; -import { - DUPLEX_FRAME_VERSION, - DuplexFrameDecoder, - encodeDuplexFrame, - type DuplexRequestFrame, - type DuplexResponseFrame, -} from "./duplex-frame-codec.js"; -import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js"; - -/** - * Regression harness for the broker aggregate byte ledger charging. - * - * The harness feeds request frames straight into the broker through an in-memory - * channel, the same as a provider that controls the transport. A test controls - * when each forward settles, so it can assert the ledger charge while the forward - * is still in flight and after it settles. - */ - -/** One pending forward a test settles by hand. */ -interface PendingForward { - id: string; - resolve: (result: DuplexBrokerForwardResult) => void; - reject: (error: Error) => void; - settled: boolean; -} - -/** The in-memory channel plus the levers a test uses to drive the broker. */ -interface FakeChannelHarness { - channel: CommandManagedDuplexChannel; - feed: (frame: DuplexRequestFrame) => void; - exit: () => void; - responses: DuplexResponseFrame[]; - forwards: PendingForward[]; - resolveForward: (id: string, body?: string) => void; -} - -/** Build one valid request frame. */ -function requestFrame(id: string, method = "POST"): DuplexRequestFrame { - return { - version: DUPLEX_FRAME_VERSION, - type: "request", - id, - method, - path: `/api/issues/${id}`, - query: "", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ id }), - }; -} - -function createFakeChannelHarness(): FakeChannelHarness { - const responses: DuplexResponseFrame[] = []; - const forwards: PendingForward[] = []; - const writtenDecoder = new DuplexFrameDecoder(); - let dataListener: ((chunk: string) => void) | null = null; - let exitListener: ((exit: { exitCode: number | null }) => void) | null = null; - - const channel: CommandManagedDuplexChannel = { - write: (data) => { - for (const result of writtenDecoder.push(data)) { - if (result.ok && result.frame.type === "response") responses.push(result.frame); - } - }, - onData: (listener) => { - dataListener = listener; - }, - onExit: (listener) => { - exitListener = listener; - }, - stop: () => undefined, - close: () => Promise.resolve(), - }; - - return { - channel, - feed: (frame) => { - if (!dataListener) throw new Error("The broker did not bind the data listener."); - dataListener(encodeDuplexFrame(frame)); - }, - exit: () => { - if (!exitListener) throw new Error("The broker did not bind the exit listener."); - exitListener({ exitCode: 0 }); - }, - responses, - forwards, - resolveForward: (id, body = JSON.stringify({ ok: true })) => { - const forward = [...forwards].reverse().find((entry) => entry.id === id && !entry.settled); - if (!forward) throw new Error(`No unsettled forward for id ${id}.`); - forward.settled = true; - forward.resolve({ status: 200, headers: { "content-type": "application/json" }, body }); - }, - }; -} - -/** A forward handler that hands each call to the harness and never auto-resolves. */ -function controllableForward(harness: FakeChannelHarness) { - return (request: DuplexRequestFrame): Promise => - new Promise((resolve, reject) => { - harness.forwards.push({ id: request.id, resolve, reject, settled: false }); - }); -} - -/** Wait for the microtask queue to drain, so a settled promise runs its handlers. */ -async function flush(): Promise { - await Promise.resolve(); - await Promise.resolve(); -} - -describe("duplex bridge broker aggregate byte ledger", () => { - const brokers: DuplexBridgeBroker[] = []; - - afterEach(async () => { - while (brokers.length > 0) { - const broker = brokers.pop(); - if (broker) await broker.close(); - } - }); - - it("charges request-frame, request-payload, and seen-id tokens, then releases the request tokens on forward settlement", async () => { - const harness = createFakeChannelHarness(); - const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 }); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - duplexAggregateByteLedger: ledger, - }); - brokers.push(broker); - broker.start(); - - harness.feed(requestFrame("req-1")); - // The dispatch retains three tokens: the raw frame, the normalized payload, - // and the no-replay set entry. - expect(ledger.liveTokenCount).toBe(3); - expect(ledger.bytesInUse).toBeGreaterThan(0); - const chargedInFlight = ledger.bytesInUse; - - harness.resolveForward("req-1"); - await flush(); - - // The forward settled. The finally owner released the request-frame and the - // request-payload tokens. The seen-id token stays charged for the channel - // lifetime, so exactly one token remains. - expect(ledger.liveTokenCount).toBe(1); - expect(ledger.bytesInUse).toBeGreaterThan(0); - expect(ledger.bytesInUse).toBeLessThan(chargedInFlight); - - // A close releases the seen-id token, so the ledger returns to zero. - await broker.close(); - expect(ledger.bytesInUse).toBe(0); - expect(ledger.liveTokenCount).toBe(0); - }); - - it("keeps request tokens charged when the response timer answers before the forward settles, and releases them on settlement", async () => { - const harness = createFakeChannelHarness(); - const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 }); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - // Squeeze the nested budgets so the response timer fires quickly while the - // forward stays unsettled. - budgets: { forwardTimeoutMs: 5, responseBudgetMs: 10, gatewayWaitMs: 20 }, - duplexAggregateByteLedger: ledger, - }); - brokers.push(broker); - broker.start(); - - harness.feed(requestFrame("req-1", "GET")); - expect(ledger.liveTokenCount).toBe(3); - - // Wait past the response budget so the backstop answers the gateway while the - // forward is still in flight. - await new Promise((resolve) => setTimeout(resolve, 40)); - expect(harness.responses.some((frame) => frame.id === "req-1")).toBe(true); - // The gateway got its answer, but the forward has not settled. The request - // tokens and the seen-id token stay charged: nothing released yet. - expect(ledger.liveTokenCount).toBe(3); - - // Settle the orphaned forward. Its finally owner now releases the two request - // tokens; the seen-id token still stays for the channel lifetime. - harness.resolveForward("req-1"); - await flush(); - expect(ledger.liveTokenCount).toBe(1); - - await broker.close(); - expect(ledger.bytesInUse).toBe(0); - }); - - it("refuses a one-byte-over dispatch with the fixed marker and retains nothing", async () => { - const harness = createFakeChannelHarness(); - // Measure the exact per-request charge with a throwaway broker, so the test - // can size a ceiling one byte below it and force the reservation to fail. - const probeHarness = createFakeChannelHarness(); - const measured = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 }); - const measuringBroker = createDuplexBridgeBroker({ - channel: probeHarness.channel, - forwardRequest: controllableForward(probeHarness), - duplexAggregateByteLedger: measured, - }); - measuringBroker.start(); - probeHarness.feed(requestFrame("req-1")); - const perRequestBytes = measured.bytesInUse; - await measuringBroker.close(); - - const ledger = new DuplexAggregateByteLedger({ ceilingBytes: perRequestBytes - 1 }); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - duplexAggregateByteLedger: ledger, - }); - brokers.push(broker); - broker.start(); - - harness.feed(requestFrame("req-1")); - await flush(); - - // The broker retained nothing: no token, no forward, no seen id. - expect(ledger.liveTokenCount).toBe(0); - expect(ledger.bytesInUse).toBe(0); - expect(harness.forwards.length).toBe(0); - // The refusal carries the fixed marker and is a bounded terminal response. - const refusal = harness.responses.find((frame) => frame.id === "req-1"); - expect(refusal).toBeTruthy(); - expect(refusal?.status).toBe(503); - expect(refusal?.body).toContain(DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED); - - // The refusal did not retain the id, so a resend after pressure eases is - // admitted: raise the ceiling and re-feed. - const roomyLedger = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 }); - const roomyHarness = createFakeChannelHarness(); - const roomyBroker = createDuplexBridgeBroker({ - channel: roomyHarness.channel, - forwardRequest: controllableForward(roomyHarness), - duplexAggregateByteLedger: roomyLedger, - }); - brokers.push(roomyBroker); - roomyBroker.start(); - roomyHarness.feed(requestFrame("req-1")); - expect(roomyHarness.forwards.length).toBe(1); - }); - - it("releases every retained token on a terminal channel loss", async () => { - const harness = createFakeChannelHarness(); - const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1_000_000 }); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - duplexAggregateByteLedger: ledger, - }); - brokers.push(broker); - broker.start(); - - harness.feed(requestFrame("req-1")); - harness.feed(requestFrame("req-2")); - expect(ledger.liveTokenCount).toBe(6); - - // The channel exits mid-flight. The broker latches the loss, transfers the - // in-flight forwards to the orphan registry, and releases the seen-id tokens. - harness.exit(); - expect(broker.runDisposition.failed).toBe(true); - // The seen-id tokens released at loss; the two request tokens per forward stay - // charged until each aborted forward settles. - expect(ledger.liveTokenCount).toBe(4); - - // The aborted forwards settle. Each finally owner releases its two request - // tokens, so the ledger returns to zero. - harness.forwards[0]?.reject(new Error("aborted")); - harness.forwards[1]?.reject(new Error("aborted")); - await flush(); - expect(ledger.bytesInUse).toBe(0); - expect(ledger.liveTokenCount).toBe(0); - }); - - it("does not double-release a token when a forward settles after the channel closed", async () => { - const harness = createFakeChannelHarness(); - let underflow = 0; - const ledger = new DuplexAggregateByteLedger({ - ceilingBytes: 1_000_000, - telemetry: { - setBytesInUse() {}, - recordReservationRejection() {}, - recordAccountingUnderflow() { - underflow += 1; - }, - }, - }); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - duplexAggregateByteLedger: ledger, - }); - brokers.push(broker); - broker.start(); - - harness.feed(requestFrame("req-1")); - await broker.close(); - // The close aborted the in-flight forward and orphaned it. The forward now - // settles after the close: its finally owner releases the request tokens once. - harness.forwards[0]?.reject(new Error("aborted")); - await flush(); - - expect(ledger.bytesInUse).toBe(0); - expect(ledger.liveTokenCount).toBe(0); - // No accounting defect: every token released exactly one time. - expect(underflow).toBe(0); - }); -}); diff --git a/packages/adapter-utils/src/duplex-bridge-broker.test.ts b/packages/adapter-utils/src/duplex-bridge-broker.test.ts deleted file mode 100644 index 6c87a1ae877..00000000000 --- a/packages/adapter-utils/src/duplex-bridge-broker.test.ts +++ /dev/null @@ -1,946 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; - -import { - assertDuplexBrokerLimits, - createDuplexBridgeBroker, - type DuplexBridgeBroker, - type DuplexBrokerForwardResult, -} from "./duplex-bridge-broker.js"; -import { - DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES, - DUPLEX_FRAME_VERSION, - DuplexFrameDecoder, - encodeDuplexFrame, - type DuplexRequestFrame, - type DuplexResponseFrame, -} from "./duplex-frame-codec.js"; -import { - createDuplexTelemetry, - DUPLEX_COUNTER_LOSS_TOTAL, - DUPLEX_SPAN_REQUEST, - type DuplexTelemetryCounterRecord, - type DuplexTelemetryEventRecord, - type DuplexTelemetrySpanRecord, -} from "./duplex-telemetry.js"; -import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js"; - -/** - * Unit harness for the host broker limit gate. - * - * The harness drives frames straight into the broker through an in-memory - * channel. It does not spawn the gateway, so a test injects untrusted request - * frames directly, the same as a malicious provider that controls the transport. - * The channel records each frame the broker writes back, so a test asserts the - * exact response the broker returns for a refused request. - */ - -/** One pending forward the test controls. */ -interface PendingForward { - id: string; - resolve: (result: DuplexBrokerForwardResult) => void; - reject: (error: Error) => void; - settled: boolean; -} - -/** The in-memory channel plus the levers a test uses to drive the broker. */ -interface FakeChannelHarness { - channel: CommandManagedDuplexChannel; - /** Push one request frame into the broker read path. */ - feed: (frame: DuplexRequestFrame) => void; - /** The response frames the broker wrote back, in order. */ - responses: DuplexResponseFrame[]; - /** Every forward call the broker made, in order. */ - forwards: PendingForward[]; - /** Resolve the newest unsettled forward for one id with a 200 result. */ - resolveForward: (id: string, body?: string) => void; - /** Resolve every unsettled forward with a 200 result. */ - resolveAll: () => void; -} - -/** Build one valid request frame with distinctive, secret-looking fields. */ -function requestFrame(id: string, method = "POST"): DuplexRequestFrame { - return { - version: DUPLEX_FRAME_VERSION, - type: "request", - id, - method, - path: `/api/issues/${id}`, - query: "?secret-query=leak", - headers: { authorization: "Bearer super-secret-provider-token" }, - body: JSON.stringify({ secret: "secret-request-body" }), - }; -} - -/** - * Build the in-memory channel harness. The channel keeps the broker read - * listener, so `feed` pushes an encoded frame into the broker. The channel - * decodes each written frame, so `responses` holds the response frames only. - */ -function createFakeChannelHarness(): FakeChannelHarness { - const responses: DuplexResponseFrame[] = []; - const forwards: PendingForward[] = []; - const writtenDecoder = new DuplexFrameDecoder(); - let dataListener: ((chunk: string) => void) | null = null; - - const channel: CommandManagedDuplexChannel = { - write: (data) => { - for (const result of writtenDecoder.push(data)) { - if (result.ok && result.frame.type === "response") { - responses.push(result.frame); - } - } - }, - onData: (listener) => { - dataListener = listener; - }, - onExit: () => undefined, - stop: () => undefined, - close: () => Promise.resolve(), - }; - - return { - channel, - feed: (frame) => { - if (!dataListener) throw new Error("The broker did not bind the data listener."); - dataListener(encodeDuplexFrame(frame)); - }, - responses, - forwards, - resolveForward: (id, body = JSON.stringify({ ok: true })) => { - const forward = [...forwards].reverse().find((entry) => entry.id === id && !entry.settled); - if (!forward) throw new Error(`No unsettled forward for id ${id}.`); - forward.settled = true; - forward.resolve({ status: 200, headers: { "content-type": "application/json" }, body }); - }, - resolveAll: () => { - for (const forward of forwards) { - if (forward.settled) continue; - forward.settled = true; - forward.resolve({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true }), - }); - } - }, - }; -} - -/** A forward handler that hands each call to the harness and never auto-resolves. */ -function controllableForward(harness: FakeChannelHarness) { - return (request: DuplexRequestFrame): Promise => - new Promise((resolve, reject) => { - harness.forwards.push({ id: request.id, resolve, reject, settled: false }); - }); -} - -/** The telemetry sink capture. It proves the broker records nothing for a refusal. */ -interface TelemetryCapture { - spans: DuplexTelemetrySpanRecord[]; - counters: DuplexTelemetryCounterRecord[]; - events: DuplexTelemetryEventRecord[]; -} - -/** Build the real telemetry facade over a capturing recorder. */ -function createTelemetryCapture(): { telemetry: ReturnType; capture: TelemetryCapture } { - const capture: TelemetryCapture = { spans: [], counters: [], events: [] }; - const telemetry = createDuplexTelemetry({ - providerKey: "daytona", - recorder: { - recordSpan: (record) => capture.spans.push(record), - incrementCounter: (record) => capture.counters.push(record), - emitEvent: (record) => capture.events.push(record), - }, - }); - return { telemetry, capture }; -} - -describe("duplex bridge broker request limits", () => { - const brokers: DuplexBridgeBroker[] = []; - - afterEach(async () => { - while (brokers.length > 0) { - const broker = brokers.pop(); - if (broker) await broker.close(); - } - }); - - it("rejects a non-positive or non-integer limit at construction", () => { - expect(() => assertDuplexBrokerLimits({ maxInFlightRequests: 0, maxLifetimeRequests: 10 })).toThrow(); - expect(() => assertDuplexBrokerLimits({ maxInFlightRequests: 5, maxLifetimeRequests: 0 })).toThrow(); - expect(() => assertDuplexBrokerLimits({ maxInFlightRequests: 1.5, maxLifetimeRequests: 10 })).toThrow(); - expect(() => - assertDuplexBrokerLimits({ maxInFlightRequests: Number.POSITIVE_INFINITY, maxLifetimeRequests: 10 }), - ).toThrow(); - expect(() => assertDuplexBrokerLimits({ maxInFlightRequests: 8, maxLifetimeRequests: 16 })).not.toThrow(); - }); - - it("bounds the in-flight forwards and refuses the excess with a retryable terminal response", () => { - const harness = createFakeChannelHarness(); - const { telemetry, capture } = createTelemetryCapture(); - const maxInFlightRequests = 4; - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - telemetry, - maxInFlightRequests, - }); - brokers.push(broker); - broker.start(); - - // Inject more than the limit of unique valid frames. Every forward hangs, so - // the broker holds the maximum number of in-flight forwards. - const total = 10; - const ids = Array.from({ length: total }, (_unused, index) => `flight-${index}`); - for (const id of ids) harness.feed(requestFrame(id)); - - // The broker forwarded only up to the limit. It refused the rest. - expect(harness.forwards).toHaveLength(maxInFlightRequests); - expect(harness.responses).toHaveLength(total - maxInFlightRequests); - for (const response of harness.responses) { - expect(response.status).toBe(503); - expect(response.outcome).toBe("unavailable"); - expect(response.headers["x-paperclip-bridge-outcome"]).toBe("unavailable"); - expect(JSON.parse(response.body)).toEqual({ - error: "Duplex broker capacity limit reached.", - outcome: "unavailable", - retryable: true, - }); - } - // The refusal leaks no route, query, body, or token. - const refusalText = harness.responses.map((response) => encodeDuplexFrame(response)).join(""); - expect(refusalText).not.toContain("secret"); - expect(refusalText).not.toContain("super-secret-provider-token"); - expect(refusalText).not.toContain("/api/issues/"); - - // The broker recorded no request span and no counter for a refusal. Only the - // delivered requests below produce a span. - expect(capture.spans).toHaveLength(0); - expect(capture.counters).toHaveLength(0); - - // Resolve the in-flight forwards. The broker delivers one response for each - // and drains the pending bookkeeping. - harness.resolveAll(); - }); - - it("frees capacity after a delivered request and forwards a resent refused id (no-replay preserved)", async () => { - const harness = createFakeChannelHarness(); - const maxInFlightRequests = 2; - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - maxInFlightRequests, - }); - brokers.push(broker); - broker.start(); - - // Saturate the in-flight limit with two hung forwards. - harness.feed(requestFrame("keep-0")); - harness.feed(requestFrame("keep-1")); - expect(harness.forwards).toHaveLength(2); - - // A third unique id is refused, retryable. The broker did not forward it and - // did not retain its id. - harness.feed(requestFrame("resend-me")); - expect(harness.forwards).toHaveLength(2); - expect(harness.responses).toHaveLength(1); - expect(JSON.parse(harness.responses[0].body).retryable).toBe(true); - - // Free one in-flight slot. The delivered request drains the pending record. - harness.resolveForward("keep-0"); - await Promise.resolve(); - - // The gateway resends the refused id. Capacity is free now, so the broker - // forwards it exactly one time. This proves the refusal did not poison the id. - harness.feed(requestFrame("resend-me")); - expect(harness.forwards.filter((forward) => forward.id === "resend-me")).toHaveLength(1); - - // A resend of an already-forwarded id never reaches the forward twice. - const forwardedKeep1 = harness.forwards.filter((forward) => forward.id === "keep-1").length; - harness.feed(requestFrame("keep-1")); - expect(harness.forwards.filter((forward) => forward.id === "keep-1")).toHaveLength(forwardedKeep1); - - harness.resolveAll(); - }); - - it("bounds the retained request ids over the channel lifetime and refuses each new id past the limit", async () => { - const harness = createFakeChannelHarness(); - const { telemetry, capture } = createTelemetryCapture(); - const maxLifetimeRequests = 3; - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - // Resolve every forward at once, so each dispatched id leaves the in-flight - // count but stays in the retained-id set. - forwardRequest: async (request: DuplexRequestFrame): Promise => { - harness.forwards.push({ id: request.id, resolve: () => undefined, reject: () => undefined, settled: true }); - return { status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) }; - }, - telemetry, - maxLifetimeRequests, - }); - brokers.push(broker); - broker.start(); - - // Inject more than the lifetime limit of unique valid frames. - const total = 9; - for (let index = 0; index < total; index += 1) { - harness.feed(requestFrame(`life-${index}`)); - await Promise.resolve(); - } - - // The broker forwarded only up to the lifetime limit. The retained-id set - // stays bounded, because a refused id never joins it. - expect(harness.forwards).toHaveLength(maxLifetimeRequests); - - // The broker delivered a real response for each forwarded request, then a - // bounded terminal refusal for each id past the limit. - const refusals = harness.responses.filter((response) => response.status === 503); - expect(refusals).toHaveLength(total - maxLifetimeRequests); - for (const refusal of refusals) { - expect(refusal.outcome).toBe("unavailable"); - // A lifetime refusal is terminal, so a resend does not help. - expect(JSON.parse(refusal.body).retryable).toBe(false); - } - - // A resend of a refused id stays refused, so the set never grows. - harness.feed(requestFrame("life-8")); - await Promise.resolve(); - expect(harness.forwards).toHaveLength(maxLifetimeRequests); - - // The telemetry surface stayed on the fixed request span for the delivered - // requests only. No refusal produced a span, a counter, or a loss record. - expect(capture.spans).toHaveLength(maxLifetimeRequests); - for (const span of capture.spans) { - expect(span.name).toBe(DUPLEX_SPAN_REQUEST); - expect(span.dimensions.outcome).toBe("ok"); - expect(Object.keys(span.dimensions).sort()).toEqual(["outcome", "provider", "transport"]); - } - expect(capture.counters).toHaveLength(0); - expect(broker.lossRecord).toBeNull(); - expect(broker.state).toBe("open"); - }); - - it("forwards nothing and fails the channel closed under a flood of over-limit ids", () => { - const harness = createFakeChannelHarness(); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - }); - brokers.push(broker); - broker.start(); - - // Send a sustained flood of distinct ids, each one byte over the id bound. The - // codec rejects each frame on the read path, so no over-limit id ever reaches - // the retained-id set or a forward. - const overLimitId = (index: number): string => - `${"a".repeat(DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES)}-${index}`; - for (let index = 0; index < 20; index += 1) { - harness.feed(requestFrame(overLimitId(index))); - } - - // The broker forwarded nothing. The retained-id set never grew, because the - // set only grows on a dispatched forward, and the broker dispatched none. - expect(harness.forwards).toHaveLength(0); - // The first over-limit frame is a protocol failure, so the broker fails the - // whole channel closed. It stays lost for the rest of the flood. - expect(broker.state).toBe("lost"); - expect(broker.lossRecord?.reason).toBe("protocol_failure"); - }); - - it("forwards a maximal-size id exactly once and does not forward a resend (no-replay preserved)", () => { - const harness = createFakeChannelHarness(); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - }); - brokers.push(broker); - broker.start(); - - // An id at the maximum byte size is allowed. The broker forwards it one time. - const maxId = "a".repeat(DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES); - harness.feed(requestFrame(maxId)); - expect(harness.forwards.filter((forward) => forward.id === maxId)).toHaveLength(1); - - // Deliver the response, then resend the same id. The broker retained the id, - // so the resend never reaches the forward a second time. - harness.resolveForward(maxId); - harness.feed(requestFrame(maxId)); - expect(harness.forwards.filter((forward) => forward.id === maxId)).toHaveLength(1); - expect(broker.state).toBe("open"); - }); - - it("maps a forward that resolves with the indeterminate marker to a non-retryable response and retains the id", async () => { - const harness = createFakeChannelHarness(); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - }); - brokers.push(broker); - broker.start(); - - // The forward handler resolves with a 504 that carries the indeterminate - // marker. This is the shape the host forward returns for a response-read - // failure after the host commit. The broker must keep the marker and the - // non-retryable body, so the gateway maps it to a non-retryable status and a - // caller never repeats a possibly-committed mutation. - harness.feed(requestFrame("commit-1")); - const forward = harness.forwards.find((entry) => entry.id === "commit-1" && !entry.settled); - if (!forward) throw new Error("The broker did not forward the request."); - forward.settled = true; - forward.resolve({ - status: 504, - headers: { - "content-type": "application/json", - "x-paperclip-bridge-outcome": "indeterminate", - }, - body: JSON.stringify({ - error: "Bridge response body exceeded the configured size limit of 32 bytes.", - outcome: "indeterminate", - retryable: false, - }), - }); - // The broker answers inside the forward `then` microtask, so let it settle. - await Promise.resolve(); - - expect(harness.responses).toHaveLength(1); - const response = harness.responses[0]!; - expect(response.status).toBe(504); - expect(response.outcome).toBe("indeterminate"); - expect(response.headers["x-paperclip-bridge-outcome"]).toBe("indeterminate"); - expect(JSON.parse(response.body)).toEqual({ - error: "Bridge response body exceeded the configured size limit of 32 bytes.", - outcome: "indeterminate", - retryable: false, - }); - - // The broker delivered a response, so it retained the id. A resend never - // reaches the forward a second time, so a caller that ignores the - // non-retryable status still cannot repeat the mutation through the broker. - harness.feed(requestFrame("commit-1")); - expect(harness.forwards.filter((entry) => entry.id === "commit-1")).toHaveLength(1); - expect(broker.state).toBe("open"); - }); - - it("maps a forward rejection for a mutating method to a non-retryable indeterminate response and retains the id", async () => { - const harness = createFakeChannelHarness(); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - }); - brokers.push(broker); - broker.start(); - - // The forward rejects before the host delivers a response. A fetch can - // reject after the request reaches the host and the host commits, but - // before the response headers arrive. The broker did not abort the - // forward, so a POST must return a non-retryable indeterminate response. - // A retryable status would let a caller repeat a committed mutation. - harness.feed(requestFrame("mutate-1", "POST")); - const forward = harness.forwards.find((entry) => entry.id === "mutate-1" && !entry.settled); - if (!forward) throw new Error("The broker did not forward the request."); - forward.settled = true; - forward.reject(new Error("fetch failed before the response headers arrived")); - // The broker answers inside the forward rejection microtask, so let it settle. - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(harness.responses).toHaveLength(1); - const response = harness.responses[0]!; - expect(response.status).toBe(504); - expect(response.outcome).toBe("indeterminate"); - expect(response.headers["x-paperclip-bridge-outcome"]).toBe("indeterminate"); - expect(JSON.parse(response.body)).toEqual({ - error: "fetch failed before the response headers arrived", - outcome: "indeterminate", - retryable: false, - }); - - // The broker delivered a response, so it retained the id. A resend never - // reaches the forward a second time, so a caller that ignores the - // non-retryable status still cannot repeat the mutation through the broker. - harness.feed(requestFrame("mutate-1", "POST")); - expect(harness.forwards.filter((entry) => entry.id === "mutate-1")).toHaveLength(1); - expect(broker.state).toBe("open"); - }); - - it("substitutes a bounded terminal response for an oversized response and keeps the channel open", async () => { - const harness = createFakeChannelHarness(); - const { telemetry, capture } = createTelemetryCapture(); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - telemetry, - // A small frame bound makes a legitimately large response body exceed the - // bound, so the test exercises the encode guard without a megabyte body. - maxFrameBytes: 1000, - }); - brokers.push(broker); - broker.start(); - - // Two requests are in flight at the same time. One resolves with a normal - // body; the other resolves with a body that pushes the response frame over - // the bound. The host produced both results, so both requests reached the - // host. - harness.feed(requestFrame("small-1", "POST")); - harness.feed(requestFrame("big-1", "POST")); - - const smallForward = harness.forwards.find((entry) => entry.id === "small-1" && !entry.settled); - if (!smallForward) throw new Error("The broker did not forward the small request."); - smallForward.settled = true; - smallForward.resolve({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true }), - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - const bigForward = harness.forwards.find((entry) => entry.id === "big-1" && !entry.settled); - if (!bigForward) throw new Error("The broker did not forward the big request."); - bigForward.settled = true; - bigForward.resolve({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ data: "y".repeat(2000) }), - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // The other in-flight request delivered its full response, unaffected by the - // oversized one. - expect(harness.responses).toHaveLength(2); - const delivered = harness.responses.find((entry) => entry.id === "small-1")!; - expect(delivered.status).toBe(200); - expect(delivered.outcome).toBe("completed"); - expect(JSON.parse(delivered.body)).toEqual({ ok: true }); - - // The oversized response became one bounded terminal response marked - // indeterminate and non-retryable, so a caller does not retry a possible - // mutation. The broker did not write the oversized frame. - const terminal = harness.responses.find((entry) => entry.id === "big-1")!; - expect(terminal.status).toBe(502); - expect(terminal.outcome).toBe("indeterminate"); - expect(terminal.headers["x-paperclip-bridge-outcome"]).toBe("indeterminate"); - expect(JSON.parse(terminal.body)).toEqual({ - error: "upstream response too large to deliver", - outcome: "indeterminate", - retryable: false, - }); - - // The oversized body never crossed the wire. The broker did not truncate it or - // pass it through. - const writtenText = harness.responses.map((entry) => encodeDuplexFrame(entry)).join(""); - expect(writtenText).not.toContain("yyyy"); - - // The broker recorded the oversized request as an error outcome, never a loss. - // The channel stays open and the run does not fail. - const errorSpans = capture.spans.filter((span) => span.dimensions.outcome === "error"); - expect(errorSpans).toHaveLength(1); - expect(errorSpans[0]!.name).toBe(DUPLEX_SPAN_REQUEST); - expect(broker.lossRecord).toBeNull(); - expect(broker.state).toBe("open"); - expect(broker.runDisposition.failed).toBe(false); - }); - - it("sends a minimal terminal response when the frame bound rejects the full replacement", async () => { - const harness = createFakeChannelHarness(); - const { telemetry, capture } = createTelemetryCapture(); - // The bound accepts a compact request (130 bytes) and the minimal terminal - // response (106 bytes), but rejects the full replacement response (288 bytes). - // This proves the broker never drops the channel or strands the request when - // the configured bound is smaller than the full replacement response. - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - telemetry, - maxFrameBytes: 200, - }); - brokers.push(broker); - broker.start(); - - // A compact request frame fits the bound, so the broker forwards it. - harness.feed({ - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "big-1", - method: "POST", - path: "/api/issues/big-1", - query: "", - headers: {}, - body: JSON.stringify({ go: 1 }), - }); - - const forward = harness.forwards.find((entry) => entry.id === "big-1" && !entry.settled); - if (!forward) throw new Error("The broker did not forward the request."); - forward.settled = true; - // The host produced a large response body that pushes the real response frame - // and the full replacement frame over the bound. - forward.resolve({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ data: "y".repeat(2000) }), - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // The broker sent one minimal terminal response, not the oversized real - // response and not the full replacement. The minimal response carries the - // indeterminate outcome, so the gateway maps it to a terminal 409. - expect(harness.responses).toHaveLength(1); - const terminal = harness.responses[0]!; - expect(terminal.id).toBe("big-1"); - expect(terminal.status).toBe(502); - expect(terminal.outcome).toBe("indeterminate"); - expect(terminal.headers).toEqual({}); - expect(terminal.body).toBe(""); - - // The oversized body never crossed the wire. - const writtenText = harness.responses.map((entry) => encodeDuplexFrame(entry)).join(""); - expect(writtenText).not.toContain("yyyy"); - - // The broker recorded the request as an error outcome, never a loss. The - // channel stays open and the run does not fail. - const errorSpans = capture.spans.filter((span) => span.dimensions.outcome === "error"); - expect(errorSpans).toHaveLength(1); - expect(errorSpans[0]!.name).toBe(DUPLEX_SPAN_REQUEST); - expect(broker.lossRecord).toBeNull(); - expect(broker.state).toBe("open"); - expect(broker.runDisposition.failed).toBe(false); - }); - - it("keeps the channel open and logs when the bound rejects even the minimal terminal response", async () => { - const harness = createFakeChannelHarness(); - const { telemetry, capture } = createTelemetryCapture(); - const logs: string[] = []; - // The bound accepts a tiny request (99 bytes) but rejects even the minimal - // terminal response (102 bytes). The broker cannot send any frame for this - // request, so it must keep the channel open and log a clear local error. - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - telemetry, - maxFrameBytes: 100, - logger: (message) => logs.push(message), - }); - brokers.push(broker); - broker.start(); - - harness.feed({ - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "x", - method: "GET", - path: "/", - query: "", - headers: {}, - body: "", - }); - - const forward = harness.forwards.find((entry) => entry.id === "x" && !entry.settled); - if (!forward) throw new Error("The broker did not forward the request."); - forward.settled = true; - forward.resolve({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ data: "y".repeat(200) }), - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // The broker sent no response frame, because no frame fits the bound. - expect(harness.responses).toHaveLength(0); - // The broker recorded the request as an error outcome, never a loss. - const errorSpans = capture.spans.filter((span) => span.dimensions.outcome === "error"); - expect(errorSpans).toHaveLength(1); - // The broker logged a clear local error and kept the channel open. - expect(logs.some((line) => line.includes("could not deliver a terminal response"))).toBe(true); - expect(broker.lossRecord).toBeNull(); - expect(broker.state).toBe("open"); - expect(broker.runDisposition.failed).toBe(false); - }); - - it("completes a request within the bound and delivers the real response", async () => { - const harness = createFakeChannelHarness(); - const { telemetry, capture } = createTelemetryCapture(); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - telemetry, - maxFrameBytes: 1000, - }); - brokers.push(broker); - broker.start(); - - harness.feed(requestFrame("ok-1", "POST")); - harness.resolveForward("ok-1", JSON.stringify({ ok: true })); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // The broker delivered the real response promptly with the completed outcome. - expect(harness.responses).toHaveLength(1); - const delivered = harness.responses[0]!; - expect(delivered.id).toBe("ok-1"); - expect(delivered.status).toBe(200); - expect(delivered.outcome).toBe("completed"); - expect(JSON.parse(delivered.body)).toEqual({ ok: true }); - - // The delivered response records an `ok` span, never an error, and never a loss. - const okSpans = capture.spans.filter((span) => span.dimensions.outcome === "ok"); - expect(okSpans).toHaveLength(1); - const errorSpans = capture.spans.filter((span) => span.dimensions.outcome === "error"); - expect(errorSpans).toHaveLength(0); - expect(broker.lossRecord).toBeNull(); - expect(broker.state).toBe("open"); - expect(broker.runDisposition.failed).toBe(false); - }); - - it("maps a forward rejection for a safe method to a retryable response", async () => { - const harness = createFakeChannelHarness(); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - }); - brokers.push(broker); - broker.start(); - - // A safe method never changes host state, so a retry cannot double-apply a - // mutation. A forward rejection for a GET stays retryable: the broker - // returns a 502 with the completed outcome, so the gateway passes it through. - harness.feed(requestFrame("read-1", "GET")); - const forward = harness.forwards.find((entry) => entry.id === "read-1" && !entry.settled); - if (!forward) throw new Error("The broker did not forward the request."); - forward.settled = true; - forward.reject(new Error("fetch failed before the response headers arrived")); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(harness.responses).toHaveLength(1); - const response = harness.responses[0]!; - expect(response.status).toBe(502); - expect(response.outcome).toBe("completed"); - expect(response.headers["x-paperclip-bridge-outcome"]).toBeUndefined(); - expect(JSON.parse(response.body)).toEqual({ - error: "fetch failed before the response headers arrived", - }); - }); - - it("maps a forward-budget timeout for a safe method to a retryable response", async () => { - const harness = createFakeChannelHarness(); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - // A tiny forward budget aborts the controller before the manual rejection. - budgets: { forwardTimeoutMs: 5 }, - }); - brokers.push(broker); - broker.start(); - - // A safe method never changes host state. A forward timeout for a GET must - // stay retryable: the broker returns a 504 with the completed outcome and no - // indeterminate marker, so the gateway does not map it to a terminal 409. - harness.feed(requestFrame("read-timeout-1", "GET")); - const forward = harness.forwards.find((entry) => entry.id === "read-timeout-1" && !entry.settled); - if (!forward) throw new Error("The broker did not forward the request."); - - // Wait for the forward-budget timer to abort the controller, then reject the - // forward. The rejection handler now sees the aborted signal. - await new Promise((resolve) => setTimeout(resolve, 20)); - forward.settled = true; - forward.reject(new Error("Duplex broker forward budget exceeded.")); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(harness.responses).toHaveLength(1); - const response = harness.responses[0]!; - expect(response.status).toBe(504); - expect(response.outcome).toBe("completed"); - expect(response.headers["x-paperclip-bridge-outcome"]).toBeUndefined(); - expect(JSON.parse(response.body)).toEqual({ - error: "Duplex broker forward budget exceeded.", - retryable: true, - }); - }); - - it("maps a forward-budget timeout for a mutating method to a non-retryable indeterminate response", async () => { - const harness = createFakeChannelHarness(); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - budgets: { forwardTimeoutMs: 5 }, - }); - brokers.push(broker); - broker.start(); - - // A POST may commit before the forward budget aborts the call. The broker - // must keep the terminal contract: a 504 with the indeterminate outcome, so - // the gateway maps it to a non-retryable 409 and no caller double-applies it. - harness.feed(requestFrame("mutate-timeout-1", "POST")); - const forward = harness.forwards.find((entry) => entry.id === "mutate-timeout-1" && !entry.settled); - if (!forward) throw new Error("The broker did not forward the request."); - - await new Promise((resolve) => setTimeout(resolve, 20)); - forward.settled = true; - forward.reject(new Error("Duplex broker forward budget exceeded.")); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(harness.responses).toHaveLength(1); - const response = harness.responses[0]!; - expect(response.status).toBe(504); - expect(response.outcome).toBe("indeterminate"); - expect(response.headers["x-paperclip-bridge-outcome"]).toBe("indeterminate"); - expect(JSON.parse(response.body)).toEqual({ - error: "Duplex broker forward budget exceeded.", - outcome: "indeterminate", - retryable: false, - }); - }); - - it("maps a response-budget backstop for a safe method to a retryable response", async () => { - const harness = createFakeChannelHarness(); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - // The forward budget aborts the call, but the forward promise stays - // pending. A GET receives the response headers, but the body reader stalls - // through the response budget, so the forward promise never settles. The - // response-budget backstop must answer the request. - budgets: { forwardTimeoutMs: 5, responseBudgetMs: 15, gatewayWaitMs: 40 }, - }); - brokers.push(broker); - broker.start(); - - // A safe method never changes host state, so a stalled body reader cannot - // leave a mutation half-applied. The backstop must keep the request - // retryable: a 504 with the completed outcome and no indeterminate marker, - // so the gateway does not map it to a terminal 409. - harness.feed(requestFrame("read-stall-1", "GET")); - const forward = harness.forwards.find((entry) => entry.id === "read-stall-1" && !entry.settled); - if (!forward) throw new Error("The broker did not forward the request."); - - // Never settle the forward. Wait past the response budget so the backstop - // fires on the stalled body reader. - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(harness.responses).toHaveLength(1); - const response = harness.responses[0]!; - expect(response.status).toBe(504); - expect(response.outcome).toBe("completed"); - expect(response.headers["x-paperclip-bridge-outcome"]).toBeUndefined(); - expect(JSON.parse(response.body)).toEqual({ - error: "Duplex broker response budget exceeded.", - retryable: true, - }); - }); - - it("maps a response-budget backstop for a mutating method to a non-retryable indeterminate response", async () => { - const harness = createFakeChannelHarness(); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: controllableForward(harness), - budgets: { forwardTimeoutMs: 5, responseBudgetMs: 15, gatewayWaitMs: 40 }, - }); - brokers.push(broker); - broker.start(); - - // A POST may commit before the response budget passes, and the broker cannot - // prove the host applied no mutation. The backstop must keep the terminal - // contract: a 504 with the indeterminate outcome, so the gateway maps it to a - // non-retryable 409 and no caller double-applies it. - harness.feed(requestFrame("mutate-stall-1", "POST")); - const forward = harness.forwards.find((entry) => entry.id === "mutate-stall-1" && !entry.settled); - if (!forward) throw new Error("The broker did not forward the request."); - - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(harness.responses).toHaveLength(1); - const response = harness.responses[0]!; - expect(response.status).toBe(504); - expect(response.outcome).toBe("indeterminate"); - expect(response.headers["x-paperclip-bridge-outcome"]).toBe("indeterminate"); - expect(JSON.parse(response.body)).toEqual({ - error: "Duplex broker response budget exceeded.", - outcome: "indeterminate", - retryable: false, - }); - }); -}); - - -/** - * A channel harness that captures the broker exit listener. `emitExit` invokes it, - * so a test drives one channel exit into the broker. - */ -function createExitChannelHarness(): { - channel: CommandManagedDuplexChannel; - emitExit: (exit: { exitCode: number | null; transportClosed?: boolean }) => void; -} { - let exitListener: - | ((exit: { exitCode: number | null; transportClosed?: boolean }) => void) - | null = null; - const channel: CommandManagedDuplexChannel = { - write: () => undefined, - onData: () => undefined, - onExit: (listener) => { - exitListener = listener; - }, - stop: () => undefined, - close: () => Promise.resolve(), - }; - return { - channel, - emitExit: (exit) => { - if (!exitListener) throw new Error("The broker did not bind the exit listener."); - exitListener(exit); - }, - }; -} - -describe("duplex bridge broker exit taxonomy", () => { - const brokers: DuplexBridgeBroker[] = []; - - afterEach(async () => { - while (brokers.length > 0) { - const broker = brokers.pop(); - if (broker) await broker.close(); - } - }); - - it("records a transport close as a distinct loss, not a process exit", () => { - const { telemetry, capture } = createTelemetryCapture(); - const harness = createExitChannelHarness(); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: () => - Promise.resolve({ status: 200, headers: {}, body: "" }), - telemetry, - }); - brokers.push(broker); - broker.start(); - - // A reason-less transport close carries the discriminator and no exit code. - harness.emitExit({ exitCode: null, transportClosed: true }); - - expect(broker.lossRecord?.reason).toBe("transport_closed"); - expect(broker.runDisposition).toEqual({ failed: true, lossReason: "transport_closed" }); - const lossCounter = capture.counters.find( - (record) => record.metric === DUPLEX_COUNTER_LOSS_TOTAL, - ); - expect(lossCounter?.dimensions.loss_reason).toBe("transport_closed"); - }); - - it("records a numeric exit as a process exit", () => { - const { telemetry, capture } = createTelemetryCapture(); - const harness = createExitChannelHarness(); - const broker = createDuplexBridgeBroker({ - channel: harness.channel, - forwardRequest: () => - Promise.resolve({ status: 200, headers: {}, body: "" }), - telemetry, - }); - brokers.push(broker); - broker.start(); - - // A numeric exit code is a real process exit, so the broker keeps the existing - // channel_exit -> provider_exit mapping. - harness.emitExit({ exitCode: 0 }); - - expect(broker.lossRecord?.reason).toBe("channel_exit"); - expect(broker.runDisposition).toEqual({ failed: true, lossReason: "provider_exit" }); - const lossCounter = capture.counters.find( - (record) => record.metric === DUPLEX_COUNTER_LOSS_TOTAL, - ); - expect(lossCounter?.dimensions.loss_reason).toBe("provider_exit"); - }); -}); diff --git a/packages/adapter-utils/src/duplex-bridge-broker.ts b/packages/adapter-utils/src/duplex-bridge-broker.ts deleted file mode 100644 index b7a04021a15..00000000000 --- a/packages/adapter-utils/src/duplex-bridge-broker.ts +++ /dev/null @@ -1,1245 +0,0 @@ -/** - * Host broker for the sandbox duplex channel. - * - * The broker owns the host end of one persistent duplex channel to the sandbox - * gateway. It reads request frames from the channel, forwards each one on the - * existing Paperclip API path, and writes one response frame back. It sends a - * heartbeat frame on an interval to prove liveness. - * - * The broker does not hold the route allowlist, the token replacement, or the - * run attribution. The caller passes a forward handler, and the broker calls it - * for each request. The handler applies the real token and the signed run - * identifier, so those rules stay in one place on the existing forward path. - * - * The broker runs a set of nested timeout budgets: - * - forward budget: the deadline for one forward call. - * - response budget: the deadline for the broker to send one response frame. - * - gateway wait budget: the deadline the in-sandbox gateway waits for the - * response frame. - * Each inner budget is smaller than its outer budget, so the broker aborts and - * answers before the gateway gives up. The broker asserts this order at - * construction and fails a configuration that breaks it. - * - * The provider controls the duplex transport directly, so the broker treats each - * request frame as untrusted. The broker bounds the work one channel can force: - * - in-flight limit: the maximum number of pending forwards at one time. It - * bounds the controllers, the timers, and the concurrent authenticated - * forwards. - * - lifetime limit: the maximum number of distinct requests over the channel - * lifetime. It bounds the retained request-id memory, because the broker keeps - * one id per distinct dispatched request for the no-replay guarantee. - * The broker checks each limit before it adds the id to the seen set, allocates - * the pending record, or calls the forward handler. On a limit it answers the - * refused request with one bounded terminal response and forwards nothing. The - * refusal preserves the no-replay and no-double-dispatch rules. - * - * Loss is terminal. The broker detects loss through channel exit, a stream - * write failure, a protocol failure, a heartbeat write failure, and a close - * timeout. On loss the broker stops the heartbeat, aborts every in-flight - * forward, records the loss, and dispatches nothing more. The broker never - * reconnects and never replays a request. The broker records the loss for - * metrics only and sends nothing about the loss to the sandbox. - */ - -import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js"; -import { - DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED, - type DuplexAggregateByteLedger, - type ReservationToken, -} from "./duplex-aggregate-byte-ledger.js"; -import { - DEFAULT_MAX_DUPLEX_FRAME_BYTES, - DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES, - DUPLEX_FRAME_VERSION, - DuplexFrameDecoder, - encodeDuplexFrame, - encodeDuplexFrameChecked, - type DuplexFrame, - type DuplexRequestFrame, - type DuplexResponseFrame, - type DuplexResponseOutcome, -} from "./duplex-frame-codec.js"; -import type { - DuplexLossReason, - DuplexOutcomeValue, - DuplexTelemetry, -} from "./duplex-telemetry.js"; - -/** The lifecycle states of the broker. The broker moves through them in order. */ -export type DuplexBrokerState = "opening" | "open" | "lost" | "closing" | "closed"; - -/** The reason the broker classified a loss. The broker records it for metrics only. */ -export type DuplexBrokerLossReason = - | "channel_exit" - | "transport_closed" - | "stream_failure" - | "protocol_failure" - | "heartbeat_write_failure" - | "close_timeout"; - -/** - * Map one broker loss reason to the closed, typed telemetry loss reason. The - * broker records the internal reason for its own metrics; the telemetry boundary - * carries only the closed enum value. The map is total over the internal reasons, - * so no raw text ever reaches the typed reason. - * - `channel_exit` -> `provider_exit`: the provider channel process exited. - * - `transport_closed` -> `transport_closed`: the provider transport closed with - * no exit data, so the loss is a transport close, not a process exit. - * - `stream_failure` -> `write_error`: a write to the channel failed. - * - `protocol_failure` -> `rpc_failure`: a malformed or mismatched frame. - * - `heartbeat_write_failure` -> `heartbeat_timeout`: the liveness write failed. - * - `close_timeout` -> `other`: an orderly close did not complete in the budget. - */ -const BROKER_LOSS_REASON_TO_TYPED: Readonly> = { - channel_exit: "provider_exit", - transport_closed: "transport_closed", - stream_failure: "write_error", - protocol_failure: "rpc_failure", - heartbeat_write_failure: "heartbeat_timeout", - close_timeout: "other", -}; - -/** - * Map one broker loss reason to the closed, typed telemetry loss reason. The host - * uses it to name the typed reason on a log line without the raw provider text. - */ -export function typedDuplexLossReason(reason: DuplexBrokerLossReason): DuplexLossReason { - return BROKER_LOSS_REASON_TO_TYPED[reason] ?? "other"; -} - -/** - * The typed error code the host reports when the duplex control channel died - * before an orderly completion. Both the ACP lane and the CLI lane report this - * one code, so the run disposition is identical across the two lanes. - */ -export const DUPLEX_CHANNEL_LOST_ERROR_CODE = "duplex_channel_lost"; - -/** - * The terminal run disposition the broker computes from its ordered lifecycle. A - * `failed` disposition means a terminal loss ordered before an orderly completion, - * so the run must not report success. The typed loss reason names the cause; it is - * `null` for a success. - */ -export interface DuplexBrokerRunDisposition { - /** True when a terminal loss ordered before an orderly completion. */ - failed: boolean; - /** The typed, closed loss reason on a failure; `null` on a success. */ - lossReason: DuplexLossReason | null; -} - -/** The nested timeout budgets. Each inner budget is smaller than its outer budget. */ -export interface DuplexBrokerBudgets { - /** The deadline for one forward call, in milliseconds. */ - forwardTimeoutMs: number; - /** The deadline for the broker to send one response frame, in milliseconds. */ - responseBudgetMs: number; - /** The deadline the in-sandbox gateway waits for the response frame, in milliseconds. */ - gatewayWaitMs: number; -} - -/** The default nested budgets: forward 30 s, response 32 s, gateway wait 35 s. */ -export const DEFAULT_DUPLEX_BROKER_BUDGETS: DuplexBrokerBudgets = { - forwardTimeoutMs: 30_000, - responseBudgetMs: 32_000, - gatewayWaitMs: 35_000, -}; - -/** The default interval between two heartbeat frames, in milliseconds. */ -export const DEFAULT_DUPLEX_BROKER_HEARTBEAT_INTERVAL_MS = 5_000; - -/** The default deadline for an orderly channel close, in milliseconds. */ -export const DEFAULT_DUPLEX_BROKER_CLOSE_TIMEOUT_MS = 2_000; - -/** - * The default maximum number of in-flight requests. The broker holds this many - * pending forwards at one time. It refuses a further request until an in-flight - * request completes. The provider controls the transport, so this finite limit - * bounds the controllers, the timers, and the concurrent authenticated forwards - * a provider can force on the host. - */ -export const DEFAULT_DUPLEX_BROKER_MAX_IN_FLIGHT_REQUESTS = 64; - -/** - * The default maximum number of distinct requests over the channel lifetime. The - * broker forwards this many distinct request ids, then refuses each new distinct - * id and forwards nothing more. This limit bounds the retained request-id memory, - * because the broker keeps one id per distinct dispatched request for the no-replay - * guarantee. - * - * The retained id memory has a hard ceiling. The codec bounds each id at - * `DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES` (256 bytes), so the worst-case retained id - * bytes are this count multiplied by that bound: 50,000 * 256 = 12,800,000 bytes - * (about 12.8 MB), plus the fixed per-entry overhead of the Set. This count is - * sized against that id bound to keep the ceiling small. - */ -export const DEFAULT_DUPLEX_BROKER_MAX_LIFETIME_REQUESTS = 50_000; - -/** - * The fixed, documented per-entry allocation the broker charges the aggregate byte - * ledger for one no-replay request-id set entry. The type and the cardinality of - * `seenRequestIds` are bounded: the codec caps each id at - * {@link DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES}, and the lifetime limit caps the - * entry count. The broker charges the exact raw id bytes plus this fixed entry - * overhead, so the retained set never grows uncharged. This constant models the - * fixed per-entry cost of the string key and the Set slot, not the id bytes. - */ -export const DUPLEX_SEEN_REQUEST_ID_SET_ENTRY_BYTES = 64; - -/** The result of one forward call. The broker turns it into one response frame. */ -export interface DuplexBrokerForwardResult { - status: number; - headers?: Record; - body?: string; -} - -/** - * The forward handler the broker calls for each request. The handler applies the - * real token and the run attribution, then forwards the request on the existing - * API path. The broker aborts `options.signal` when the forward budget ends or a - * loss happens, so a handler that threads the signal into its work stops early. - */ -export type DuplexBrokerForwardHandler = ( - request: DuplexRequestFrame, - options: { signal: AbortSignal }, -) => Promise; - -/** One request record. The broker captures the dispatch-start point for metrics only. */ -export interface DuplexBrokerRequestRecord { - id: string; - method: string; - path: string; - /** The point the broker started to dispatch the request, in milliseconds. */ - dispatchStartMs: number; -} - -/** One loss record. The broker reports it for metrics only. */ -export interface DuplexBrokerLossRecord { - reason: DuplexBrokerLossReason; - message: string; - /** The point the broker recorded the loss, in milliseconds. */ - atMs: number; -} - -/** The options for {@link createDuplexBridgeBroker}. */ -export interface DuplexBrokerOptions { - /** The duplex channel to the sandbox gateway. */ - channel: CommandManagedDuplexChannel; - /** The forward handler the broker calls for each request. */ - forwardRequest: DuplexBrokerForwardHandler; - /** The nested timeout budgets. The default is {@link DEFAULT_DUPLEX_BROKER_BUDGETS}. */ - budgets?: Partial; - /** The interval between two heartbeat frames, in milliseconds. */ - heartbeatIntervalMs?: number; - /** The deadline for an orderly channel close, in milliseconds. */ - closeTimeoutMs?: number; - /** The maximum size of one inbound frame, in bytes. Forwarded to the decoder. */ - maxFrameBytes?: number; - /** - * The maximum number of in-flight requests the broker holds at one time. The - * broker refuses a further request past this limit with a bounded terminal - * response and forwards nothing for it. The default is - * {@link DEFAULT_DUPLEX_BROKER_MAX_IN_FLIGHT_REQUESTS}. - */ - maxInFlightRequests?: number; - /** - * The maximum number of distinct requests the broker dispatches over the - * channel lifetime. The broker refuses each new distinct request past this - * limit with a bounded terminal response and forwards nothing more. This limit - * bounds the retained request-id memory. The default is - * {@link DEFAULT_DUPLEX_BROKER_MAX_LIFETIME_REQUESTS}. - */ - maxLifetimeRequests?: number; - /** The clock the broker reads for the metric timestamps. The default is `Date.now`. */ - now?: () => number; - /** The metrics sink for the per-request dispatch record. */ - onRequestRecord?: (record: DuplexBrokerRequestRecord) => void; - /** The metrics sink for the terminal loss record. */ - onLoss?: (record: DuplexBrokerLossRecord) => void; - /** The sink for a state change. The broker reports every transition. */ - onStateChange?: (state: DuplexBrokerState) => void; - /** The sink for a diagnostic message. The broker never writes diagnostics to the channel. */ - logger?: (message: string) => void; - /** - * The fixed observability facade. The broker records one request span per - * delivered request and one loss record per terminal loss. The facade maps each - * record to the fixed names and dimensions, so no route, query, body, token, or - * raw error rides a span or a counter. The default records nothing. - */ - telemetry?: DuplexTelemetry; - /** - * The process-owned aggregate byte ledger. The broker reserves the exact retained - * bytes of each dispatched request against it before it retains the frame: the - * raw request frame, the normalized request payload, and the no-replay set entry. - * A reservation that would pass the ceiling makes the broker retain nothing and - * refuse the request with the fixed marker - * {@link DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED}. When the ledger is absent the - * broker charges nothing and behaves as before, so a non-duplex or a legacy path - * stays unchanged. - */ - duplexAggregateByteLedger?: DuplexAggregateByteLedger | null; -} - -/** The broker handle the factory returns. */ -export interface DuplexBridgeBroker { - /** The current state of the broker. */ - readonly state: DuplexBrokerState; - /** The loss record, or `null` when the broker never lost the channel. */ - readonly lossRecord: DuplexBrokerLossRecord | null; - /** - * The terminal run disposition from the ordered lifecycle. It reports a failure - * when a terminal loss ordered before an orderly completion, and names the typed - * loss reason. It reports a success for a healthy channel or a normal-teardown - * loss. The host reads it at the run-disposition seam. - */ - readonly runDisposition: DuplexBrokerRunDisposition; - /** - * Mark the host-observed orderly completion of the agent turn on the ordered - * lifecycle. A loss ordered before this mark latches a failure; a loss ordered - * after it stays a success. The broker also marks it on a gateway close frame - * and on a host-initiated orderly close. Safe to call more than one time. - */ - markOrderlyCompletion(): void; - /** - * Atomically read the run disposition and mark the host-observed orderly - * completion in one synchronous step. The host calls it at the run-disposition - * seam for a success-eligible terminal. The broker marks the orderly completion - * only while no loss ordered, then returns the disposition, so no caller can - * insert an `await` between the read and the mark. A loss that already latched - * keeps the failure, because the mark no-ops after a latched loss. - */ - settleRunDisposition(): DuplexBrokerRunDisposition; - /** Start the broker. It wires the channel listeners and moves to `open`. */ - start(): void; - /** Close the channel cleanly. It moves through `closing` to `closed`. */ - close(): Promise; - /** Stop the sandbox child process. Safe to call more than one time. */ - stop(): void; -} - -/** - * Assert the nested budget order. Each inner budget must be smaller than its - * outer budget, so the broker answers before the gateway gives up. The function - * throws when the order breaks. - */ -export function assertNestedDuplexBrokerBudgets(budgets: DuplexBrokerBudgets): void { - if (!(budgets.forwardTimeoutMs < budgets.responseBudgetMs)) { - throw new Error( - `Duplex broker forward budget ${budgets.forwardTimeoutMs}ms must be smaller than the response budget ${budgets.responseBudgetMs}ms.`, - ); - } - if (!(budgets.responseBudgetMs < budgets.gatewayWaitMs)) { - throw new Error( - `Duplex broker response budget ${budgets.responseBudgetMs}ms must be smaller than the gateway wait budget ${budgets.gatewayWaitMs}ms.`, - ); - } -} - -/** The resolved request limits the broker enforces. */ -export interface DuplexBrokerLimits { - /** The maximum number of in-flight requests the broker holds at one time. */ - maxInFlightRequests: number; - /** The maximum number of distinct requests the broker dispatches over the channel lifetime. */ - maxLifetimeRequests: number; -} - -/** - * Assert the request limits. Each limit must be a finite positive integer, so the - * broker fails closed on a broken configuration instead of running with an - * unbounded or a zero limit. The function throws when a limit breaks the rule. - */ -export function assertDuplexBrokerLimits(limits: DuplexBrokerLimits): void { - const entries: ReadonlyArray = [ - ["maxInFlightRequests", limits.maxInFlightRequests], - ["maxLifetimeRequests", limits.maxLifetimeRequests], - ]; - for (const [name, value] of entries) { - if (!Number.isInteger(value) || value < 1) { - throw new Error( - `Duplex broker ${name} must be a finite positive integer; got ${String(value)}.`, - ); - } - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -/** - * The safe HTTP methods. RFC 7231 section 4.2.1 defines this set. A safe method - * does not change host state, so the host applies no mutation for it. A caller - * can retry a safe method after a forward failure without a double-apply risk. - */ -const SAFE_BRIDGE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]); - -/** Report whether the method is safe, so a forward failure stays retryable. */ -export function isSafeBridgeMethod(method: string): boolean { - return SAFE_BRIDGE_METHODS.has(method.trim().toUpperCase()); -} - -/** The internal bookkeeping for one in-flight request. */ -interface PendingRequest { - controller: AbortController; - responded: boolean; - forwardTimer: ReturnType; - responseTimer: ReturnType; - /** The point the broker started to dispatch the request. It sets the span latency. */ - dispatchStartMs: number; - /** True once the forward promise settled. The finally owner sets it one time. */ - forwardSettled: boolean; - /** - * Release the request-frame token and the request-payload token exactly one time. - * The single forward-promise finally owner calls it after the forward settles. - * A second call is a no-op, so no token releases twice. - */ - releaseForwardTokens: () => void; -} - -/** - * One orphaned forward. The broker answered the gateway and removed the request - * from `pending`, but the forward promise or its response-body reader had not - * settled. The orphan keeps the request tokens charged and the controller live - * until the forward finally releases them, so the ledger reports nonzero ownership - * until the async work settles. - */ -interface OrphanedForward { - controller: AbortController; - /** Release the request-frame and request-payload tokens exactly one time. */ - releaseForwardTokens: () => void; -} - -/** - * Create the host duplex bridge broker. The factory asserts the budget order and - * returns a handle. Call `start` to wire the channel and open the broker. - */ -export function createDuplexBridgeBroker(options: DuplexBrokerOptions): DuplexBridgeBroker { - const budgets: DuplexBrokerBudgets = { - ...DEFAULT_DUPLEX_BROKER_BUDGETS, - ...options.budgets, - }; - assertNestedDuplexBrokerBudgets(budgets); - - const limits: DuplexBrokerLimits = { - maxInFlightRequests: options.maxInFlightRequests ?? DEFAULT_DUPLEX_BROKER_MAX_IN_FLIGHT_REQUESTS, - maxLifetimeRequests: options.maxLifetimeRequests ?? DEFAULT_DUPLEX_BROKER_MAX_LIFETIME_REQUESTS, - }; - assertDuplexBrokerLimits(limits); - - const channel = options.channel; - const forwardRequest = options.forwardRequest; - const heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_DUPLEX_BROKER_HEARTBEAT_INTERVAL_MS; - const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_DUPLEX_BROKER_CLOSE_TIMEOUT_MS; - const now = options.now ?? (() => Date.now()); - - // The process-owned aggregate byte ledger, or `null` when the caller injected - // none. When `null` the broker charges nothing and behaves as before. - const ledger = options.duplexAggregateByteLedger ?? null; - // The one frame size bound the broker enforces on both sides. The decoder - // rejects an inbound frame over this bound, and the encode guard refuses to - // write an outbound frame over it. Encode and decode share one value, so a - // frame the broker writes always decodes on the peer. - const maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_DUPLEX_FRAME_BYTES; - // The decoder charges its retained partial-frame bytes against the same host - // ledger. It receives the ledger object directly, so one gauge bounds the - // decoder buffer with every other host retention site. - const decoder = new DuplexFrameDecoder({ - maxFrameBytes, - ...(ledger ? { aggregateByteLedger: ledger } : {}), - }); - // Release one token exactly one time. A `null` token or an absent ledger is a - // no-op, so the broker never records a false accounting defect. - const releaseToken = (token: ReservationToken | null): void => { - if (ledger && token) ledger.release(token); - }; - // The byte size of the normalized request payload the broker retains across the - // forward. It counts the exact retained scalar and header bytes, so the charge - // matches the retained bytes and never a parsed object graph. - const requestPayloadBytes = (frame: DuplexRequestFrame): number => { - let bytes = - Buffer.byteLength(frame.id, "utf8") + - Buffer.byteLength(frame.method, "utf8") + - Buffer.byteLength(frame.path, "utf8") + - Buffer.byteLength(frame.query, "utf8") + - Buffer.byteLength(frame.body, "utf8"); - for (const [key, value] of Object.entries(frame.headers)) { - bytes += Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"); - } - return bytes; - }; - - let state: DuplexBrokerState = "opening"; - let stopped = false; - let started = false; - let closePromise: Promise | null = null; - let lossRecord: DuplexBrokerLossRecord | null = null; - let heartbeatTimer: ReturnType | null = null; - - // The host-owned lifecycle sequence. The broker assigns each terminal lifecycle - // event a strictly increasing sequence number at ingress, before any - // asynchronous logging. The order of these numbers, not a wall-clock or a - // provider timestamp, decides the run disposition. - let lifecycleSeq = 0; - // The sequence number of the terminal loss, or `null` when no loss ordered. The - // broker sets it one time. A later event never clears it, so the loss latches. - let lossSeq: number | null = null; - // The typed, closed loss reason for the latched loss, or `null` on a success. - let typedLossReason: DuplexLossReason | null = null; - // The sequence number of the host-observed orderly completion, or `null` when - // none ordered. The broker sets it one time, only while the channel is healthy. - let orderlyCompletionSeq: number | null = null; - const nextLifecycleSeq = (): number => { - lifecycleSeq += 1; - return lifecycleSeq; - }; - - // Mark the host-observed orderly completion of the agent turn. The broker sets - // the sequence one time, and only while no loss has ordered. A loss that already - // latched keeps the failure, so a late completion never clears the latch. - const markOrderlyCompletion = (): void => { - if (orderlyCompletionSeq !== null || lossSeq !== null) return; - orderlyCompletionSeq = nextLifecycleSeq(); - }; - // Atomically read the run disposition and mark the host-observed orderly - // completion. The mark and the read run in one synchronous step, so no caller - // can insert an `await` between them and no teardown loss can slip in. The mark - // no-ops once a loss latched, so a real mid-run loss keeps the failure. - const settleRunDisposition = (): DuplexBrokerRunDisposition => { - markOrderlyCompletion(); - return { failed: lossSeq !== null, lossReason: typedLossReason }; - }; - // The ids the broker already dispatched. The broker forwards one id one time, - // so a repeated frame never reaches the API twice. - const seenRequestIds = new Set(); - // The aggregate-ledger tokens for the no-replay set entries. The broker holds - // one token per live set entry and releases every token one time at terminal - // teardown, so the retained set never leaves bytes charged after the channel - // ends. - const seenRequestIdTokens = new Set(); - const pending = new Map(); - // The forwards that answered the gateway but whose async work has not settled. - // The broker keeps their tokens charged and their controller live until each - // forward finally releases its tokens, so the ledger reports the real ownership. - const orphanedForwards = new Map(); - - // Release every no-replay set-entry token one time and clear the set. The - // ledger release is one way, and the cleared set stops any second release, so - // this helper is safe to call more than one time at terminal teardown. - const releaseSeenRequestIdTokens = (): void => { - for (const token of seenRequestIdTokens) releaseToken(token); - seenRequestIdTokens.clear(); - }; - - const setState = (next: DuplexBrokerState): void => { - if (state === next) return; - state = next; - options.onStateChange?.(next); - }; - - const clearHeartbeat = (): void => { - if (heartbeatTimer !== null) { - clearInterval(heartbeatTimer); - heartbeatTimer = null; - } - }; - - const clearPending = (): void => { - for (const [id, entry] of pending) { - clearTimeout(entry.forwardTimer); - clearTimeout(entry.responseTimer); - entry.controller.abort(new Error("Duplex broker stopped.")); - // A forward that has not settled still owns its request tokens. Transfer it - // to the orphan registry, so its tokens stay charged until the forward - // finally releases them. The abort only asks the forward to stop; it never - // releases a token by itself. - if (!entry.forwardSettled) { - orphanedForwards.set(id, { - controller: entry.controller, - releaseForwardTokens: entry.releaseForwardTokens, - }); - } - } - pending.clear(); - // Ask every already-orphaned forward to stop as well. The forward-promise - // finally owner releases each orphan token when the forward settles. - for (const orphan of orphanedForwards.values()) { - orphan.controller.abort(new Error("Duplex broker stopped.")); - } - }; - - const recordLoss = (reason: DuplexBrokerLossReason, message: string): void => { - // Loss is terminal. Record it one time and stop every activity. - if (stopped) return; - const afterOrderlyCompletion = orderlyCompletionSeq !== null; - // A clean channel end that orders after a host-observed orderly completion is - // a normal teardown, not a loss. A process exit and a reason-less transport - // close both end the channel, so both count as the normal teardown here. Stop - // cleanly, emit no loss event, and leave the run a success. This keeps the - // closed telemetry contract: an orderly close is not a loss and emits no loss - // event. - if (afterOrderlyCompletion && (reason === "channel_exit" || reason === "transport_closed")) { - stopped = true; - clearHeartbeat(); - clearPending(); - releaseSeenRequestIdTokens(); - decoder.dispose(); - if (state !== "closing") setState("closed"); - return; - } - stopped = true; - // Classify the loss relative to the first dispatch. A loss after the broker - // dispatched a request is `post_dispatch`; a loss before any dispatch is - // `pre_dispatch`. The class rides the fixed loss counter, never the raw - // message. - const lossClass = seenRequestIds.size > 0 ? "post_dispatch" : "pre_dispatch"; - // Assign the loss its lifecycle sequence at ingress, before any logging. The - // loss latches the run as a failure only when no orderly completion ordered - // before it. A loss ordered after an orderly completion (for example a failed - // close) is a real channel loss for the telemetry and the leak metric, but it - // does not fail the run, because the run already completed. Once set, `lossSeq` - // never clears, so a later completion, exit, or activity callback cannot flip - // the latch. - const seq = nextLifecycleSeq(); - if (!afterOrderlyCompletion) { - lossSeq = seq; - typedLossReason = BROKER_LOSS_REASON_TO_TYPED[reason] ?? "other"; - } - clearHeartbeat(); - clearPending(); - releaseSeenRequestIdTokens(); - decoder.dispose(); - lossRecord = { reason, message, atMs: now() }; - setState("lost"); - // Log the internal reason only. The broker never writes the raw provider - // message to a log line, so no raw provider text rides a sink here. - options.logger?.(`Duplex broker lost the channel (${reason}).`); - options.onLoss?.(lossRecord); - options.telemetry?.recordLoss(lossClass, BROKER_LOSS_REASON_TO_TYPED[reason] ?? "other"); - }; - - const writeLine = (line: string): boolean => { - try { - channel.write(line); - return true; - } catch (error) { - recordLoss("stream_failure", errorMessage(error)); - return false; - } - }; - - // The result of one bounded send. `sent` means the frame went out; `too_large` - // means the encoded frame exceeds the bound and the broker wrote nothing; - // `lost` means the write failed and the broker recorded the channel loss. - type SendResult = "sent" | "too_large" | "lost"; - - const trySendFrame = (frame: DuplexFrame): SendResult => { - // Enforce the frame size bound on every broker write. Report a size rejection - // as `too_large` without a channel loss; a broker-built frame over the bound - // is a defect, not a transport failure. A caller decides how to answer the - // request, so no size rejection ever drops the channel. - const encoded = encodeDuplexFrameChecked(frame, maxFrameBytes); - if (!encoded.ok) return "too_large"; - return writeLine(encoded.line) ? "sent" : "lost"; - }; - - const writeFrame = (frame: DuplexFrame): boolean => { - // A control frame and the bounded terminal responses are always small, so the - // size guard is a no-op for them. Drop an oversized frame here without a - // channel loss and report the drop to the caller. - const result = trySendFrame(frame); - if (result === "too_large") { - options.logger?.(`Duplex broker dropped an oversized ${frame.type} frame.`); - return false; - } - return result === "sent"; - }; - - const sendTerminalIndeterminate = (id: string): void => { - // Answer one request the broker cannot deliver with the real result. The - // request reached the host and may have changed state, so the response is - // non-retryable and carries the `indeterminate` outcome. The broker tries the - // full replacement first. When the frame bound rejects even the full - // replacement, the broker sends a minimal replacement that carries only the - // `indeterminate` outcome, so the gateway still ends the request and never - // waits for its full wait budget. When the bound rejects even the minimal - // replacement, the broker logs a clear local error and keeps the channel - // open; it records no channel loss. - const fullReplacement: DuplexResponseFrame = { - version: DUPLEX_FRAME_VERSION, - type: "response", - id, - status: 502, - headers: { - "content-type": "application/json", - "x-paperclip-bridge-outcome": "indeterminate", - }, - body: JSON.stringify({ - error: "upstream response too large to deliver", - outcome: "indeterminate", - retryable: false, - }), - outcome: "indeterminate", - }; - if (trySendFrame(fullReplacement) !== "too_large") return; - // The bound rejects the full replacement. Send a minimal terminal response - // with empty headers and an empty body. The `indeterminate` outcome still - // rides the frame, so the gateway maps the request to a terminal 409. - const minimalReplacement: DuplexResponseFrame = { - version: DUPLEX_FRAME_VERSION, - type: "response", - id, - status: 502, - headers: {}, - body: "", - outcome: "indeterminate", - }; - if (trySendFrame(minimalReplacement) !== "too_large") return; - // The bound rejects even the minimal terminal response. The broker cannot - // deliver any frame for this request within the bound. Log a clear local - // error and keep the channel open for every other request. The gateway ends - // its own outstanding request on its wait budget. - options.logger?.( - `Duplex broker could not deliver a terminal response within the ${maxFrameBytes}-byte frame bound.`, - ); - }; - - const respond = ( - id: string, - result: DuplexBrokerForwardResult, - outcome: DuplexResponseOutcome, - telemetryOutcome: DuplexOutcomeValue, - ): void => { - const entry = pending.get(id); - if (!entry || entry.responded) return; - entry.responded = true; - clearTimeout(entry.forwardTimer); - clearTimeout(entry.responseTimer); - pending.delete(id); - // The response timer, an abort, or a broker loss can answer the gateway before - // the forward promise settles. When the forward has not settled, transfer it to - // the orphan registry so its request tokens stay charged until the forward - // finally releases them. A settled forward already released or will release - // through its finally owner, so it needs no orphan record. - if (!entry.forwardSettled) { - orphanedForwards.set(id, { - controller: entry.controller, - releaseForwardTokens: entry.releaseForwardTokens, - }); - } - // Do not write on a lost or closed channel. The gateway answers its own - // outstanding request on loss, so a late write would go to a dead channel. - if (state !== "open") return; - const frame: DuplexResponseFrame = { - version: DUPLEX_FRAME_VERSION, - type: "response", - id, - status: result.status, - headers: result.headers ?? {}, - body: result.body ?? "", - outcome, - }; - const encoded = encodeDuplexFrameChecked(frame, maxFrameBytes); - if (!encoded.ok) { - // The host produced a result, but the response frame exceeds the size - // bound. Do not write the oversized frame and do not record a channel loss. - // The request reached the host and may have changed state, so the caller - // must not retry a possible mutation. Send a bounded, non-retryable terminal - // response marked `indeterminate` in its place. Record the request outcome - // as an error, never a loss. The channel stays open for every other request. - options.telemetry?.recordRequest({ - latencyMs: now() - entry.dispatchStartMs, - outcome: "error", - }); - sendTerminalIndeterminate(id); - return; - } - // Record the request span for the delivered request. The span carries the - // latency and the outcome only; no route, query, body, or token rides it. - options.telemetry?.recordRequest({ - latencyMs: now() - entry.dispatchStartMs, - outcome: telemetryOutcome, - }); - writeLine(encoded.line); - }; - - const respondSaturated = (id: string, retryable: boolean): void => { - // Answer a refused request with a bounded terminal response. The broker made - // no controller, no timer, and no forward for this id, so the host API stays - // untouched. The response carries no route, no query, no body, and no token; - // it holds only the fixed error shape. The `unavailable` outcome tells the - // gateway this is not a delivered host response, so it never counts as one. - if (state !== "open") return; - const frame: DuplexResponseFrame = { - version: DUPLEX_FRAME_VERSION, - type: "response", - id, - status: 503, - headers: { - "content-type": "application/json", - "x-paperclip-bridge-outcome": "unavailable", - }, - body: JSON.stringify({ - error: "Duplex broker capacity limit reached.", - outcome: "unavailable", - retryable, - }), - outcome: "unavailable", - }; - writeFrame(frame); - }; - - const respondAggregateExceeded = (id: string): void => { - // Answer a request the aggregate byte ledger refused with a bounded terminal - // response. The broker reserved nothing, added no id to the seen set, and made - // no controller, timer, or forward, so the host API stays untouched. The - // response carries the fixed marker only; it holds no route, query, body, or - // token. The `unavailable` outcome tells the gateway this is not a delivered - // host response. The refusal is retryable, because the broker did not retain - // the id: the aggregate pressure can ease, and a resend can then get through. - if (state !== "open") return; - const frame: DuplexResponseFrame = { - version: DUPLEX_FRAME_VERSION, - type: "response", - id, - status: 503, - headers: { - "content-type": "application/json", - "x-paperclip-bridge-outcome": "unavailable", - }, - body: JSON.stringify({ - error: DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED, - outcome: "unavailable", - retryable: true, - }), - outcome: "unavailable", - }; - writeFrame(frame); - }; - - const dispatch = (frame: DuplexRequestFrame): void => { - // Dispatch only while open. After loss or close the broker forwards nothing. - if (state !== "open") return; - // Bound the id byte size before any retention or work. The codec already - // rejects an over-limit id on the read path, so this guard is defense in depth - // for a frame that reaches dispatch by another path. The broker never adds the - // id to the seen set, never allocates a controller or a timer, and never - // forwards. It answers with the bounded terminal refusal, which carries no - // route, query, body, or token, and records no telemetry. The refusal is not - // retryable, because a resend of the same over-limit id never gets past this - // bound. - if (Buffer.byteLength(frame.id, "utf8") > DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES) { - respondSaturated(frame.id, false); - return; - } - // Forward one id one time. A repeated id never reaches the API twice. - if (seenRequestIds.has(frame.id)) return; - // Bound the retained request-id memory. The broker keeps one id per distinct - // dispatched request for the no-replay guarantee, so the set can only grow. - // Once the broker reaches the lifetime limit, it refuses each new distinct id - // and forwards nothing more. The refusal is not retryable, because a resend - // never gets past the limit. This check runs before the id joins the set, so - // the set never grows past the limit. - if (seenRequestIds.size >= limits.maxLifetimeRequests) { - respondSaturated(frame.id, false); - return; - } - // Bound the in-flight request count. Once the broker holds the maximum number - // of pending forwards, it refuses a further request and forwards nothing for - // it. The broker does not add the id to the seen set, so the gateway can - // resend the request after an in-flight request completes. The refusal is - // retryable for that reason. This check bounds the controllers, the timers, - // and the concurrent forwards a provider can force. - if (pending.size >= limits.maxInFlightRequests) { - respondSaturated(frame.id, true); - return; - } - // Reserve the exact retained bytes against the aggregate ledger before the - // broker retains anything. It reserves three tokens in order: the raw request - // frame, the normalized request payload, and the no-replay set entry. A - // reservation that would pass the ceiling makes the broker retain nothing, - // release the tokens it already took, and refuse the request with the fixed - // marker. The broker adds no id to the seen set and makes no controller, timer, - // or forward, so the host API stays untouched. When the ledger is absent all - // tokens stay `null` and the broker behaves as before. - let requestFrameToken: ReservationToken | null = null; - let requestPayloadToken: ReservationToken | null = null; - let seenRequestIdToken: ReservationToken | null = null; - if (ledger) { - const rawFrameBytes = Buffer.byteLength(encodeDuplexFrame(frame), "utf8"); - requestFrameToken = ledger.reserve("request_frame", rawFrameBytes); - if (!requestFrameToken) { - respondAggregateExceeded(frame.id); - return; - } - requestPayloadToken = ledger.reserve("request_payload", requestPayloadBytes(frame)); - if (!requestPayloadToken) { - ledger.release(requestFrameToken); - respondAggregateExceeded(frame.id); - return; - } - const seenEntryBytes = - Buffer.byteLength(frame.id, "utf8") + DUPLEX_SEEN_REQUEST_ID_SET_ENTRY_BYTES; - seenRequestIdToken = ledger.reserve("seen_request_id", seenEntryBytes); - if (!seenRequestIdToken) { - ledger.release(requestFrameToken); - ledger.release(requestPayloadToken); - respondAggregateExceeded(frame.id); - return; - } - } - seenRequestIds.add(frame.id); - if (seenRequestIdToken) seenRequestIdTokens.add(seenRequestIdToken); - - // Release the request-frame and the request-payload tokens exactly one time. - // The single forward-promise finally owner calls it after the forward settles. - // The seen-id token stays charged for the channel lifetime, so it is not part - // of this release; the terminal teardown releases it. - let forwardTokensReleased = false; - const releaseForwardTokens = (): void => { - if (forwardTokensReleased) return; - forwardTokensReleased = true; - releaseToken(requestFrameToken); - releaseToken(requestPayloadToken); - }; - - const record: DuplexBrokerRequestRecord = { - id: frame.id, - method: frame.method, - path: frame.path, - dispatchStartMs: now(), - }; - options.onRequestRecord?.(record); - - const controller = new AbortController(); - const forwardTimer = setTimeout(() => { - controller.abort(new Error("Duplex broker forward budget exceeded.")); - }, budgets.forwardTimeoutMs); - const responseTimer = setTimeout(() => { - // Response-budget backstop. The forward rejection normally answers first, - // well before this deadline. This backstop answers a request whose forward - // rejection handling itself stalls, so the request never strands. One stall - // path is a response whose headers arrive but whose body reader stays - // pending through the budget, so the forward promise never settles. - if (isSafeBridgeMethod(frame.method)) { - // A safe method never changes host state, so a stalled body reader - // cannot leave a mutation half-applied. Keep the request retryable: - // return a 504 with the completed outcome and no indeterminate marker, - // so the gateway passes it through as a retryable status and never maps - // it to a terminal 409. - respond( - frame.id, - { - status: 504, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - error: "Duplex broker response budget exceeded.", - retryable: true, - }), - }, - "completed", - "error", - ); - return; - } - // The method may mutate host state, and the broker cannot prove the host - // applied no mutation once the response budget passes. Return a - // non-retryable 504 and mark the outcome indeterminate, so the gateway - // maps it to a terminal 409 and no caller double-applies the mutation. - respond( - frame.id, - { - status: 504, - headers: { - "content-type": "application/json", - "x-paperclip-bridge-outcome": "indeterminate", - }, - body: JSON.stringify({ - error: "Duplex broker response budget exceeded.", - outcome: "indeterminate", - retryable: false, - }), - }, - "indeterminate", - "error", - ); - }, budgets.responseBudgetMs); - forwardTimer.unref?.(); - responseTimer.unref?.(); - pending.set(frame.id, { - controller, - responded: false, - forwardTimer, - responseTimer, - dispatchStartMs: record.dispatchStartMs, - forwardSettled: false, - releaseForwardTokens, - }); - - // The forward promise has one cleanup owner. The two settle handlers mark the - // forward settled and answer the gateway. The `finally` then releases the - // request tokens exactly one time and removes the request from both the pending - // map and the orphan map. The forward resolves only after the forward and its - // response-body reader both settle, so this owner waits for both. - const markForwardSettled = (): void => { - // Mark the pending entry settled before `respond` runs, so `respond` never - // moves a just-settled forward to the orphan registry. An already-orphaned - // forward needs no flag; its finally owner releases and drains it. - const entry = pending.get(frame.id); - if (entry) entry.forwardSettled = true; - }; - forwardRequest(frame, { signal: controller.signal }) - .then( - (result) => { - markForwardSettled(); - // Keep the outcome classification consistent with the file path. A - // possibly-committed mutation carries the indeterminate marker header, so - // map it to the indeterminate outcome. Any other result is completed. - const outcome: DuplexResponseOutcome = - result.headers?.["x-paperclip-bridge-outcome"] === "indeterminate" - ? "indeterminate" - : "completed"; - // The host delivered a real response, so the request span outcome is `ok`. - // A host application status (200, a 4xx, a 5xx) is still a delivered - // response; only a broker-synthesized failure below is `error`. - respond(frame.id, result, outcome, "ok"); - }, - (error) => { - markForwardSettled(); - return handleForwardRejection(error); - }, - ) - .finally(() => { - pending.delete(frame.id); - orphanedForwards.delete(frame.id); - releaseForwardTokens(); - }); - - // The forward rejection handler. It classifies the rejection by method safety - // and answers the gateway. The `markForwardSettled` call above runs first, so - // the response never re-orphans a settled forward. - function handleForwardRejection(error: unknown): void { - if (controller.signal.aborted) { - // The forward budget aborted the call. A safe method never changes - // host state, so a forward timeout stays retryable for it. Return a - // 504 with the completed outcome and no indeterminate marker, so the - // gateway passes it through as a retryable status. - if (isSafeBridgeMethod(frame.method)) { - respond( - frame.id, - { - status: 504, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - error: errorMessage(error), - retryable: true, - }), - }, - "completed", - "error", - ); - return; - } - // The method may mutate host state, and the forward budget aborted the - // call after the request may have committed. Return a non-retryable 504 - // and mark the outcome indeterminate, so a caller does not retry a - // committed mutation. - respond( - frame.id, - { - status: 504, - headers: { - "content-type": "application/json", - "x-paperclip-bridge-outcome": "indeterminate", - }, - body: JSON.stringify({ - error: errorMessage(error), - outcome: "indeterminate", - retryable: false, - }), - }, - "indeterminate", - "error", - ); - return; - } - // The forward rejected before the host delivered a response. This - // rejection does not prove that the host applied no mutation. A fetch - // can reject after the request bytes reach the host and the host - // commits, but before the response headers arrive. A safe method never - // changes host state, so a retry stays safe for it. For any other - // method the host may have committed, so the outcome is indeterminate. - if (isSafeBridgeMethod(frame.method)) { - // The method is safe, so a retry cannot double-apply a mutation. - // Return a 502 with the completed outcome, so the gateway passes it - // through as a retryable status. - respond( - frame.id, - { - status: 502, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ error: errorMessage(error) }), - }, - "completed", - "error", - ); - return; - } - // The method may mutate host state, so a retry with a new request id - // could apply the mutation twice. Return a non-retryable 504 and mark - // the outcome indeterminate, so the gateway maps it to a terminal 409. - respond( - frame.id, - { - status: 504, - headers: { - "content-type": "application/json", - "x-paperclip-bridge-outcome": "indeterminate", - }, - body: JSON.stringify({ - error: errorMessage(error), - outcome: "indeterminate", - retryable: false, - }), - }, - "indeterminate", - "error", - ); - } - }; - - const handleFrame = (frame: DuplexFrame): void => { - switch (frame.type) { - case "request": - dispatch(frame); - return; - case "close": - // The gateway asked for an orderly close. The gateway sends this frame on - // the agent's orderly completion, so mark the completion on the ordered - // lifecycle before the close, then close the channel. - markOrderlyCompletion(); - void close(); - return; - case "ready": - case "heartbeat": - // Liveness frames. The broker reads them and dispatches nothing. - return; - case "response": - case "error": - // The host never expects these on the read path. Ignore them. - return; - default: - return; - } - }; - - const onData = (chunk: string): void => { - if (stopped) return; - const results = decoder.push(chunk); - for (const result of results) { - if (stopped) return; - if (!result.ok) { - recordLoss("protocol_failure", result.error.message); - return; - } - handleFrame(result.frame); - } - }; - - const onExit = (exit: { exitCode: number | null; transportClosed?: boolean }): void => { - // A reason-less transport close is not a process exit. Record it as a distinct - // loss, so a transport close stays legible in the loss taxonomy. A real process - // exit stays `channel_exit` -> `provider_exit`. - if (exit.transportClosed === true) { - recordLoss("transport_closed", "The sandbox channel transport closed with no exit."); - return; - } - recordLoss("channel_exit", "The sandbox channel process exited."); - }; - - const sendHeartbeat = (): void => { - if (state !== "open") return; - try { - channel.write(encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" })); - } catch (error) { - recordLoss("heartbeat_write_failure", errorMessage(error)); - } - }; - - const close = (): Promise => { - if (closePromise) return closePromise; - if (stopped) return Promise.resolve(); - // A host-initiated orderly close is a host-observed orderly completion. The - // host tears the channel down on its own terms, so a channel end during the - // close is a normal teardown, not a mid-run loss. `markOrderlyCompletion` - // no-ops when a loss already latched, so a lost channel stays a failure. - markOrderlyCompletion(); - closePromise = (async () => { - setState("closing"); - clearHeartbeat(); - clearPending(); - releaseSeenRequestIdTokens(); - decoder.dispose(); - // Send an orderly close frame. Ignore a write failure here; the broker is - // already closing, so a dead channel needs no loss record. - try { - channel.write(encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "close" })); - } catch (error) { - options.logger?.(`Duplex broker could not send the close frame: ${errorMessage(error)}`); - } - let closeTimer: ReturnType | undefined; - const timeout = new Promise((_resolve, reject) => { - closeTimer = setTimeout(() => { - reject(new Error("Duplex broker channel close timed out.")); - }, closeTimeoutMs); - closeTimer.unref?.(); - }); - try { - await Promise.race([channel.close(), timeout]); - if (closeTimer !== undefined) clearTimeout(closeTimer); - stopped = true; - setState("closed"); - } catch (error) { - if (closeTimer !== undefined) clearTimeout(closeTimer); - recordLoss("close_timeout", errorMessage(error)); - } - })(); - return closePromise; - }; - - const start = (): void => { - if (started) return; - started = true; - channel.onData(onData); - channel.onExit(onExit); - heartbeatTimer = setInterval(sendHeartbeat, heartbeatIntervalMs); - heartbeatTimer.unref?.(); - setState("open"); - }; - - const stop = (): void => { - try { - channel.stop(); - } catch (error) { - options.logger?.(`Duplex broker could not stop the channel: ${errorMessage(error)}`); - } - }; - - return { - get state() { - return state; - }, - get lossRecord() { - return lossRecord; - }, - get runDisposition(): DuplexBrokerRunDisposition { - // A latched loss ordered before any orderly completion is a failure. Every - // other state — a healthy channel, or a loss ordered after an orderly - // completion — is a success. - return { failed: lossSeq !== null, lossReason: typedLossReason }; - }, - markOrderlyCompletion, - settleRunDisposition, - start, - close, - stop, - }; -} diff --git a/packages/adapter-utils/src/duplex-bridge-local-e2e.test.ts b/packages/adapter-utils/src/duplex-bridge-local-e2e.test.ts deleted file mode 100644 index a4e2942c974..00000000000 --- a/packages/adapter-utils/src/duplex-bridge-local-e2e.test.ts +++ /dev/null @@ -1,646 +0,0 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import net from "node:net"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { StringDecoder } from "node:string_decoder"; -import { afterEach, describe, expect, it } from "vitest"; - -import { - authorizeSandboxCallbackBridgeRequestWithRoutes, - getSandboxCallbackBridgeServerSource, - SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE, -} from "./sandbox-callback-bridge.js"; -import { - DuplexFrameDecoder, - type DuplexFrame, - type DuplexReadyFrame, - type DuplexRequestFrame, -} from "./duplex-frame-codec.js"; -import { - createDuplexBridgeBroker, - type DuplexBridgeBroker, - type DuplexBrokerForwardResult, -} from "./duplex-bridge-broker.js"; -import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js"; - -/** - * Local real-process end-to-end harness for the composed duplex path. - * - * The harness spawns the real generated gateway with plain `node` from a - * temporary file, attaches the real host broker to the child stdin and stdout, - * and forwards each request to a local fake API server on a real HTTP call. It - * uses no provider credentials. - * - * The child stdio pipes are kernel pipes. They give partial reads, split a - * multi-byte UTF-8 sequence across two chunks, apply backpressure, and give a - * true end of file when the child dies. The harness proves the composed path - * handles each of these real conditions. - */ - -/** One recorded call the fake API server received. */ -interface FakeApiRequest { - method: string; - url: string; - auth: string | null; - runId: string | null; - body: string; -} - -/** The responder the fake API server calls for each request. */ -type FakeApiResponder = (req: IncomingMessage, res: ServerResponse, body: string) => void; - -/** The handle for one fake API server. */ -interface FakeApiServer { - origin: string; - requests: FakeApiRequest[]; - setResponder: (responder: FakeApiResponder) => void; - /** The count of sockets the server holds open right now. */ - openSocketCount: () => number; - /** True while the server still accepts connections. */ - listening: () => boolean; - close: () => Promise; -} - -/** - * Start a local fake API server. The forward handler targets it, so one real - * HTTP call proves the composed path end to end. The server tracks each open - * socket, so a test asserts teardown leaves no open socket handle. - */ -async function startFakeApiServer(): Promise { - const requests: FakeApiRequest[] = []; - const sockets = new Set(); - let responder: FakeApiResponder = (req, res) => { - const url = new URL(req.url ?? "/", "http://127.0.0.1"); - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ ok: true, path: url.pathname })); - }; - - const server = createServer((req, res) => { - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => chunks.push(chunk)); - req.on("end", () => { - const body = Buffer.concat(chunks).toString("utf8"); - requests.push({ - method: req.method ?? "GET", - url: req.url ?? "/", - auth: req.headers.authorization ?? null, - runId: - typeof req.headers["x-paperclip-run-id"] === "string" - ? req.headers["x-paperclip-run-id"] - : null, - body, - }); - responder(req, res, body); - }); - }); - server.on("connection", (socket) => { - sockets.add(socket); - socket.on("close", () => sockets.delete(socket)); - }); - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("The fake API server did not expose a TCP port."); - } - - return { - origin: `http://127.0.0.1:${address.port}`, - requests, - setResponder: (next) => { - responder = next; - }, - openSocketCount: () => sockets.size, - listening: () => server.listening, - close: () => - new Promise((resolve) => { - // Destroy each open socket first. A keep-alive client socket keeps the - // server open, so `server.close` alone could stall. The destroy makes the - // close callback fire and drives the open socket count to zero. - for (const socket of sockets) socket.destroy(); - server.close(() => resolve()); - }), - }; -} - -/** The channel view over the spawned child, plus the frames the child sent host-ward. */ -interface ChildDuplexChannel { - channel: CommandManagedDuplexChannel; - observedFrames: DuplexFrame[]; - stderr: () => string; -} - -/** - * Wrap the spawned child as a {@link CommandManagedDuplexChannel}. The broker - * writes to the child stdin, reads the child stdout, and learns of the child - * exit through this channel. - * - * The host end reads a byte stream from the stdout pipe. A pipe read can split - * one multi-byte UTF-8 character across two chunks. The `StringDecoder` holds - * the bytes of an incomplete character until the next chunk, so the broker only - * ever reads whole characters. The channel keeps a second decoder for - * observation only; it lets the harness assert the READY frame and the request - * frames the child produced, and it never feeds the broker. - */ -function attachChildDuplexChannel(child: ChildProcessWithoutNullStreams): ChildDuplexChannel { - const observed = new DuplexFrameDecoder(); - const observedFrames: DuplexFrame[] = []; - const stdoutDecoder = new StringDecoder("utf8"); - let dataListener: ((chunk: string) => void) | null = null; - let exitListener: ((exit: { exitCode: number | null }) => void) | null = null; - let pendingText = ""; - let pendingExit: { exitCode: number | null } | null = null; - let stderrText = ""; - - child.stdout.on("data", (buffer: Buffer) => { - for (const result of observed.push(buffer)) { - if (result.ok) observedFrames.push(result.frame); - } - const text = stdoutDecoder.write(buffer); - if (text.length === 0) return; - if (dataListener) dataListener(text); - else pendingText += text; - }); - child.stderr.on("data", (buffer: Buffer) => { - stderrText += buffer.toString("utf8"); - }); - child.on("exit", (code) => { - const exit = { exitCode: code }; - if (exitListener) exitListener(exit); - else pendingExit = exit; - }); - // Swallow a stdin EPIPE. The broker can write one more frame while the child - // exits; the write fails and the broker records the loss on its own path. - child.stdin.on("error", () => undefined); - - const channel: CommandManagedDuplexChannel = { - write: (data) => { - child.stdin.write(data); - }, - onData: (listener) => { - dataListener = listener; - if (pendingText.length > 0) { - const replay = pendingText; - pendingText = ""; - listener(replay); - } - }, - onExit: (listener) => { - exitListener = listener; - if (pendingExit) { - const exit = pendingExit; - pendingExit = null; - listener(exit); - } - }, - stop: () => { - child.kill("SIGKILL"); - }, - close: () => - new Promise((resolve) => { - // Close the write side. The child stdin reaches end of file, so the - // gateway sees a real EOF. - child.stdin.end(() => resolve()); - }), - }; - - return { channel, observedFrames, stderr: () => stderrText }; -} - -/** The forward-handler mode. `proxy` calls the fake API; `hang` blocks until abort. */ -type ForwardMode = "proxy" | "hang"; - -/** The options for one harness. */ -interface HarnessOptions { - hostApiToken?: string; - runId?: string; - maxBodyBytes?: number; - lossExitGraceMs?: number; -} - -/** The full harness handle for one composed duplex path. */ -interface DuplexE2EHarness { - baseUrl: string; - bridgeToken: string; - hostApiToken: string; - runId: string; - nonce: string; - broker: DuplexBridgeBroker; - api: FakeApiServer; - child: ChildProcessWithoutNullStreams; - observedFrames: DuplexFrame[]; - forwardedRequests: DuplexRequestFrame[]; - setForwardMode: (mode: ForwardMode) => void; - stderr: () => string; - killChild: () => Promise; - waitFor: (predicate: () => boolean, message: string, timeoutMs?: number) => Promise; - teardown: () => Promise; -} - -/** Reserve one free loopback port. The host assigns it to the gateway. */ -async function reserveLoopbackPort(): Promise { - return new Promise((resolve, reject) => { - const probe = net.createServer(); - probe.once("error", reject); - probe.listen(0, "127.0.0.1", () => { - const address = probe.address(); - if (!address || typeof address === "string") { - probe.close(() => reject(new Error("Could not reserve a loopback port."))); - return; - } - const reserved = address.port; - probe.close(() => resolve(reserved)); - }); - }); -} - -/** - * Build the composed duplex path: a spawned gateway child, the real broker on - * the child stdio, and a local fake API server as the forward target. - */ -async function createHarness(options: HarnessOptions = {}): Promise { - const hostApiToken = options.hostApiToken ?? "real-run-jwt"; - const runId = options.runId ?? "run-e2e"; - const maxBodyBytes = options.maxBodyBytes ?? 1_000_000; - const bridgeToken = "duplex-e2e-bridge-token"; - const nonce = "e2e112233445566778899aabbccddeeff"; - let forwardMode: ForwardMode = "proxy"; - const forwardedRequests: DuplexRequestFrame[] = []; - - const api = await startFakeApiServer(); - const assignedPort = await reserveLoopbackPort(); - - const tmpDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-e2e-")); - const entrypoint = path.join(tmpDir, "gateway.mjs"); - await writeFile(entrypoint, getSandboxCallbackBridgeServerSource(), "utf8"); - - const child = spawn(process.execPath, [entrypoint], { - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - PAPERCLIP_API_BRIDGE_MODE: SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE, - PAPERCLIP_BRIDGE_HOST: "127.0.0.1", - PAPERCLIP_BRIDGE_PORT: String(assignedPort), - PAPERCLIP_BRIDGE_NONCE: nonce, - PAPERCLIP_BRIDGE_TOKEN: bridgeToken, - PAPERCLIP_BRIDGE_MAX_BODY_BYTES: String(maxBodyBytes), - ...(options.lossExitGraceMs != null - ? { PAPERCLIP_BRIDGE_LOSS_EXIT_GRACE_MS: String(options.lossExitGraceMs) } - : {}), - }, - }) as ChildProcessWithoutNullStreams; - - const { channel, observedFrames, stderr } = attachChildDuplexChannel(child); - - const forwardRequest = async ( - request: DuplexRequestFrame, - opts: { signal: AbortSignal }, - ): Promise => { - forwardedRequests.push(request); - // Keep the real route allowlist on the forward seam. The broker forwards - // only an allowed route; it denies any other route with a 403. - const denial = authorizeSandboxCallbackBridgeRequestWithRoutes(request); - if (denial) { - return { - status: 403, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ error: denial }), - }; - } - if (forwardMode === "hang") { - // Block until the broker aborts the forward. This lets a test hold an - // outstanding request open while it forces a loss. - await new Promise((_resolve, reject) => { - const onAbort = () => reject(new Error("The forward call was aborted.")); - if (opts.signal.aborted) { - onAbort(); - return; - } - opts.signal.addEventListener("abort", onAbort, { once: true }); - }); - } - const method = request.method.trim().toUpperCase() || "GET"; - const headers = new Headers(); - for (const [key, value] of Object.entries(request.headers)) { - if (value.trim().length === 0) continue; - headers.set(key, value); - } - // Apply the real host token and the run id, the same as the file bridge path. - headers.set("authorization", `Bearer ${hostApiToken}`); - headers.set("x-paperclip-run-id", runId); - const target = new URL(`${request.path}${request.query ?? ""}`, api.origin); - const response = await fetch(target, { - method, - headers, - ...(method === "GET" || method === "HEAD" ? {} : { body: request.body }), - signal: opts.signal, - }); - const body = await response.text(); - const outHeaders: Record = {}; - response.headers.forEach((value, key) => { - if (key.toLowerCase() === "content-length") return; - outHeaders[key] = value; - }); - return { status: response.status, headers: outHeaders, body }; - }; - - const broker = createDuplexBridgeBroker({ - channel, - forwardRequest, - logger: () => undefined, - }); - broker.start(); - - const waitFor = async ( - predicate: () => boolean, - message: string, - timeoutMs = 5000, - ): Promise => { - const deadline = Date.now() + timeoutMs; - for (;;) { - if (predicate()) return; - if (Date.now() > deadline) { - throw new Error(`Timed out waiting for ${message}. stderr: ${stderr()}`); - } - await new Promise((resolve) => { - const timer = setTimeout(resolve, 20); - timer.unref?.(); - }); - } - }; - - const waitForExit = (): Promise => - new Promise((resolve) => { - if (child.exitCode !== null || child.signalCode !== null) { - resolve(); - return; - } - child.once("exit", () => resolve()); - }); - - const killChild = async (): Promise => { - child.kill("SIGKILL"); - await waitForExit(); - }; - - let torndown = false; - const teardown = async (): Promise => { - if (torndown) return; - torndown = true; - child.kill("SIGKILL"); - await waitForExit(); - child.stdin.destroy(); - child.stdout.destroy(); - child.stderr.destroy(); - await api.close(); - await rm(tmpDir, { recursive: true, force: true }); - }; - - return { - baseUrl: `http://127.0.0.1:${assignedPort}`, - bridgeToken, - hostApiToken, - runId, - nonce, - broker, - api, - child, - observedFrames, - forwardedRequests, - setForwardMode: (mode) => { - forwardMode = mode; - }, - stderr, - killChild, - waitFor, - teardown, - }; -} - -/** - * Build a large body of multi-byte UTF-8 characters. "€" uses three UTF-8 bytes - * and "😀" uses four. A pipe read boundary at a 65536-byte multiple lands inside - * a "€" sequence, because 65536 is not a multiple of three. This guarantees a - * multi-byte character split across two pipe chunks. - */ -function buildMultiByteBody(targetBytes: number): string { - const marker = "😀-start-😀"; - const filler = "€".repeat(Math.ceil(targetBytes / 3)); - return `${marker}${filler}${marker}`; -} - -describe("duplex bridge local end-to-end harness", () => { - const harnesses: DuplexE2EHarness[] = []; - - afterEach(async () => { - while (harnesses.length > 0) { - const harness = harnesses.pop(); - if (harness) await harness.teardown(); - } - }); - - const readyPredicate = (harness: DuplexE2EHarness) => () => - harness.observedFrames.some((frame) => frame.type === "ready"); - - it("delivers a valid READY frame from the spawned gateway child to the attached broker", async () => { - const harness = await createHarness(); - harnesses.push(harness); - - await harness.waitFor(readyPredicate(harness), "a READY frame from the gateway child"); - - const ready = harness.observedFrames.find( - (frame) => frame.type === "ready", - ) as DuplexReadyFrame; - expect(ready.version).toBe(1); - expect(ready.nonce).toBe(harness.nonce); - // READY is liveness only. It carries no address data. - expect((ready as unknown as Record).address).toBeUndefined(); - expect((ready as unknown as Record).port).toBeUndefined(); - // The broker read the READY frame and stayed open with no loss. - expect(harness.broker.state).toBe("open"); - expect(harness.broker.lossRecord).toBeNull(); - }, 20000); - - it("carries one real HTTP request through the child and the broker to the fake API unchanged", async () => { - const harness = await createHarness(); - harnesses.push(harness); - await harness.waitFor(readyPredicate(harness), "the gateway to become ready"); - - harness.api.setResponder((req, res) => { - const url = new URL(req.url ?? "/", "http://127.0.0.1"); - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ ok: true, echoedPath: url.pathname })); - }); - - const response = await fetch(`${harness.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${harness.bridgeToken}` }, - }); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ ok: true, echoedPath: "/api/agents/me" }); - - // The fake API saw one request with the real host token and the run id. The - // broker replaced the bridge token, so the fake API never saw it. - expect(harness.api.requests).toHaveLength(1); - expect(harness.api.requests[0]).toMatchObject({ - method: "GET", - url: "/api/agents/me", - auth: `Bearer ${harness.hostApiToken}`, - runId: harness.runId, - }); - }, 20000); - - it("reassembles a large response that spans many pipe chunks", async () => { - const harness = await createHarness(); - harnesses.push(harness); - await harness.waitFor(readyPredicate(harness), "the gateway to become ready"); - - // The body is larger than the pipe buffer, so the response frame crosses the - // child stdin pipe in many chunks. An ASCII body isolates the chunk-span - // behavior from the multi-byte behavior the next test covers. - const largeBody = "x".repeat(256 * 1024); - expect(Buffer.byteLength(largeBody, "utf8")).toBeGreaterThan(200 * 1024); - harness.api.setResponder((_req, res) => { - res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); - res.end(largeBody); - }); - - const response = await fetch(`${harness.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${harness.bridgeToken}` }, - }); - expect(response.status).toBe(200); - const received = await response.text(); - // The whole body returns complete after it crossed the pipe in many chunks. - expect(received.length).toBe(largeBody.length); - expect(received).toBe(largeBody); - }, 20000); - - it("decodes a multi-byte UTF-8 sequence split across chunk borders", async () => { - const harness = await createHarness(); - harnesses.push(harness); - await harness.waitFor(readyPredicate(harness), "the gateway to become ready"); - - const multiByteBody = buildMultiByteBody(256 * 1024); - expect(Buffer.byteLength(multiByteBody, "utf8")).toBeGreaterThan(200 * 1024); - harness.api.setResponder((_req, res) => { - res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); - res.end(multiByteBody); - }); - - const response = await fetch(`${harness.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${harness.bridgeToken}` }, - }); - expect(response.status).toBe(200); - const received = await response.text(); - // A multi-byte character split across a pipe chunk border decodes to the - // same character, so the whole body returns unchanged. - expect(received.length).toBe(multiByteBody.length); - expect(received).toBe(multiByteBody); - }, 20000); - - it("matches each concurrent request through one child to its own response", async () => { - const harness = await createHarness(); - harnesses.push(harness); - await harness.waitFor(readyPredicate(harness), "the gateway to become ready"); - - harness.api.setResponder((req, res) => { - const url = new URL(req.url ?? "/", "http://127.0.0.1"); - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ path: url.pathname })); - }); - - const ids = Array.from({ length: 8 }, (_unused, index) => `issue-${index}`); - const responses = await Promise.all( - ids.map((id) => - fetch(`${harness.baseUrl}/api/issues/${id}`, { - headers: { authorization: `Bearer ${harness.bridgeToken}` }, - }).then(async (response) => ({ status: response.status, body: await response.json() })), - ), - ); - - responses.forEach((result, index) => { - expect(result.status).toBe(200); - expect(result.body).toEqual({ path: `/api/issues/${ids[index]}` }); - }); - expect(harness.api.requests).toHaveLength(8); - }, 20000); - - it("answers 409 to outstanding and 503 to new requests when the broker closes its write side", async () => { - const harness = await createHarness({ lossExitGraceMs: 3000 }); - harnesses.push(harness); - await harness.waitFor(readyPredicate(harness), "the gateway to become ready"); - - // Hold the forward open, so the broker sends no response frame. The gateway - // keeps the request outstanding until the loss. - harness.setForwardMode("hang"); - const outstanding = fetch(`${harness.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${harness.bridgeToken}` }, - }); - void outstanding.catch(() => undefined); - await harness.waitFor( - () => harness.forwardedRequests.length >= 1, - "the broker to receive the forwarded request", - ); - - // The broker closes its write side, so the child stdin reaches a real EOF. - await harness.broker.close(); - - const lossResponse = await outstanding; - expect(lossResponse.status).toBe(409); - expect(lossResponse.headers.get("x-paperclip-bridge-outcome")).toBe("indeterminate"); - await expect(lossResponse.json()).resolves.toEqual({ error: "outcome_indeterminate" }); - - const afterLoss = await fetch(`${harness.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${harness.bridgeToken}` }, - }); - expect(afterLoss.status).toBe(503); - await expect(afterLoss.json()).resolves.toEqual({ error: "bridge_unavailable" }); - }, 20000); - - it("moves the broker to lost on a child kill, dispatches nothing more, and leaks no handle after teardown", async () => { - const harness = await createHarness(); - harnesses.push(harness); - await harness.waitFor(readyPredicate(harness), "the gateway to become ready"); - - // Hold one forward open, so a request is in flight when the child dies. - harness.setForwardMode("hang"); - const outstanding = fetch(`${harness.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${harness.bridgeToken}` }, - }); - void outstanding.catch(() => undefined); - await harness.waitFor( - () => harness.forwardedRequests.length >= 1, - "the broker to receive the forwarded request", - ); - const forwardedBeforeKill = harness.forwardedRequests.length; - - // A real process kill closes the stdout pipe and ends the child. - await harness.killChild(); - await harness.waitFor( - () => harness.broker.state === "lost", - "the broker to record the channel loss", - ); - expect(harness.broker.lossRecord?.reason).toBe("channel_exit"); - - // The broker dispatches nothing more after the loss. - await new Promise((resolve) => { - const timer = setTimeout(resolve, 100); - timer.unref?.(); - }); - expect(harness.forwardedRequests.length).toBe(forwardedBeforeKill); - // The outstanding fetch fails because the child died before it answered. - await expect(outstanding).rejects.toThrow(); - - await harness.teardown(); - // Teardown left no open pipe or socket handle. - expect(harness.child.stdin.destroyed).toBe(true); - expect(harness.child.stdout.destroyed).toBe(true); - expect(harness.child.stderr.destroyed).toBe(true); - expect(harness.api.listening()).toBe(false); - expect(harness.api.openSocketCount()).toBe(0); - }, 20000); -}); diff --git a/packages/adapter-utils/src/duplex-frame-codec.test.ts b/packages/adapter-utils/src/duplex-frame-codec.test.ts index c19008fb558..0fddd5e3cc2 100644 --- a/packages/adapter-utils/src/duplex-frame-codec.test.ts +++ b/packages/adapter-utils/src/duplex-frame-codec.test.ts @@ -126,7 +126,7 @@ describe("round-trip", () => { ), ), ); - for (const type of ["request", "response", "ready", "heartbeat", "close", "error"]) { + for (const type of ["request", "response", "body_chunk", "ready", "heartbeat", "close", "error"]) { expect(types).toContain(type); } }); @@ -212,8 +212,8 @@ describe("streaming decoder behavior", () => { method: "POST", path: "/x", query: "", - headers: {}, - body: "😀", + headers: { "x-note": "😀" }, + bodyByteCount: 0, }; const bytes = Buffer.from(encodeDuplexFrame(frame), "utf8"); // Split inside the four-byte emoji, right before a continuation byte. @@ -275,7 +275,7 @@ describe("request id byte bound", () => { path: "/", query: "", headers: {}, - body: "", + bodyByteCount: 0, }); } @@ -314,7 +314,7 @@ describe("request id byte bound", () => { id, status: 200, headers: {}, - body: "", + bodyByteCount: 0, outcome: "completed", }), ); @@ -461,8 +461,8 @@ describe("size-checked encode", () => { type: "response", id: "big", status: 200, - headers: {}, - body: "x".repeat(2_000), + headers: { "x-pad": "x".repeat(2_000) }, + bodyByteCount: 0, outcome: "completed", }; expect(() => encodeDuplexFrameChecked(frame, 1_000)).not.toThrow(); @@ -475,22 +475,25 @@ describe("size-checked encode", () => { // Build a frame whose encoded JSON is exactly N bytes. The guard measures the // JSON without the newline, so N bytes encodes and N+1 bytes fails. This is the // same boundary the decoder applies, so encode and decode agree exactly. - const base: DuplexFrame = { + // Pad a header value to grow the encoded JSON by an exact byte count. The + // "x-pad" value adds one byte per character, so the frame lands on an exact + // size the guard measures without the trailing newline. + const pad = (n: number): DuplexFrame => ({ version: DUPLEX_FRAME_VERSION, type: "response", id: "boundary", status: 200, - headers: {}, - body: "", + headers: { "x-pad": "x".repeat(n) }, + bodyByteCount: 0, outcome: "completed", - }; - const baseBytes = Buffer.byteLength(JSON.stringify(base), "utf8"); + }); + const baseBytes = Buffer.byteLength(JSON.stringify(pad(0)), "utf8"); const limit = baseBytes + 10; - const atLimit: DuplexFrame = { ...base, body: "x".repeat(10) }; + const atLimit = pad(10); expect(Buffer.byteLength(JSON.stringify(atLimit), "utf8")).toBe(limit); const okResult = encodeDuplexFrameChecked(atLimit, limit); expect(okResult.ok).toBe(true); - const overResult = encodeDuplexFrameChecked({ ...base, body: "x".repeat(11) }, limit); + const overResult = encodeDuplexFrameChecked(pad(11), limit); expect(overResult.ok).toBe(false); if (!overResult.ok) expect(overResult.error.code).toBe("frame_too_large"); }); diff --git a/packages/adapter-utils/src/duplex-frame-codec.ts b/packages/adapter-utils/src/duplex-frame-codec.ts index c1723ad763c..0a658a440dc 100644 --- a/packages/adapter-utils/src/duplex-frame-codec.ts +++ b/packages/adapter-utils/src/duplex-frame-codec.ts @@ -15,6 +15,13 @@ * The decoder never throws on the read path. A malformed, oversized, or * version-mismatch frame becomes a protocol-error result, not an exception. This * keeps one bad frame from crashing the read loop. + * + * HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. + * The `http2_v1` host readiness gate imports {@link decodeDuplexLine} from this + * file to read the one READY line every gateway sends, so this file's READY + * frame path stays live for both transports. `duplex-body-spool.ts` still + * imports the request, response, and body-chunk frame types from this file, so + * this file keeps every frame type here. */ import { @@ -24,7 +31,15 @@ import { } from "./duplex-aggregate-byte-ledger.js"; /** The wire version this codec reads and writes. */ -export const DUPLEX_FRAME_VERSION = 1; +export const DUPLEX_FRAME_VERSION = 2; + +/** + * The fixed raw byte size of one body slice. The sender splits a body into + * `body_chunk` frames of this raw size, except the final chunk, which holds the + * remaining bytes. The base64 text of one full slice is about 349 KiB, so each + * encoded `body_chunk` frame stays well under {@link DEFAULT_MAX_DUPLEX_FRAME_BYTES}. + */ +export const DUPLEX_BODY_CHUNK_RAW_BYTES = 256 * 1024; /** * The default maximum size of one frame, in bytes. The decoder rejects a longer @@ -54,6 +69,7 @@ const EMPTY = Buffer.alloc(0); export const DUPLEX_FRAME_TYPES = { request: "request", response: "response", + body_chunk: "body_chunk", ready: "ready", heartbeat: "heartbeat", close: "close", @@ -63,7 +79,12 @@ export const DUPLEX_FRAME_TYPES = { /** The outcome of a response frame. A loss response carries a non-completed outcome. */ export type DuplexResponseOutcome = "completed" | "indeterminate" | "unavailable"; -/** A request frame. The gateway forwards it to the host API path. */ +/** + * A request envelope frame. The gateway forwards it to the host API path. The + * envelope carries `bodyByteCount`, the raw byte length of the request body. The + * body itself rides one or more `body_chunk` frames that share the same `id`. A + * `bodyByteCount` of 0 means an empty body and no `body_chunk` frame follows. + */ export interface DuplexRequestFrame { version: number; type: "request"; @@ -72,20 +93,43 @@ export interface DuplexRequestFrame { path: string; query: string; headers: Record; - body: string; + bodyByteCount: number; } -/** A response frame. The host returns it for one request id. */ +/** + * A response envelope frame. The host returns it for one request id. The + * envelope carries `bodyByteCount`, the raw byte length of the response body. + * The body itself rides one or more `body_chunk` frames that share the same + * `id`. A `bodyByteCount` of 0 means an empty body and no `body_chunk` frame + * follows. + */ export interface DuplexResponseFrame { version: number; type: "response"; id: string; status: number; headers: Record; - body: string; + bodyByteCount: number; outcome: DuplexResponseOutcome; } +/** + * A body-chunk frame. One frame carries one raw body slice as base64 text in + * `data`. The frames for one body share the `id` of the envelope. `seq` starts + * at 0 and increases by one for each next chunk, with no gap and no repeat. Each + * raw slice is {@link DUPLEX_BODY_CHUNK_RAW_BYTES} bytes, except the final slice, + * which holds the remaining bytes. The receiver reassembles the slices in `seq` + * order and stops when the total raw byte count equals the envelope + * `bodyByteCount`. + */ +export interface DuplexBodyChunkFrame { + version: number; + type: "body_chunk"; + id: string; + seq: number; + data: string; +} + /** * The READY control frame. The gateway sends it one time after it binds the * host-assigned listener port. READY is a liveness signal, not an address @@ -129,6 +173,7 @@ export interface DuplexErrorFrame { export type DuplexFrame = | DuplexRequestFrame | DuplexResponseFrame + | DuplexBodyChunkFrame | DuplexReadyFrame | DuplexHeartbeatFrame | DuplexCloseFrame @@ -194,6 +239,11 @@ function idWithinLimit(id: string): boolean { return Buffer.byteLength(id, "utf8") <= DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES; } +/** Return true when the value is a safe integer that is zero or positive. */ +function isSafeNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + /** * Encode one frame to a single line of JSON with a trailing newline. `JSON.stringify` * escapes any newline inside a string value, so the returned line holds no @@ -261,6 +311,8 @@ function validateFrame(frame: Record): DuplexDecodeResult { return validateRequest(frame); case "response": return validateResponse(frame); + case "body_chunk": + return validateBodyChunk(frame); case "ready": return validateReady(frame); case "heartbeat": @@ -280,7 +332,7 @@ function validateRequest(frame: Record): DuplexDecodeResult { typeof frame.method !== "string" || typeof frame.path !== "string" || typeof frame.query !== "string" || - typeof frame.body !== "string" || + !isSafeNonNegativeInteger(frame.bodyByteCount) || !isStringRecord(frame.headers) ) { return fail("malformed_frame", "request frame has a missing or wrong-typed field"); @@ -298,7 +350,7 @@ function validateResponse(frame: Record): DuplexDecodeResult { if ( typeof frame.id !== "string" || typeof frame.status !== "number" || - typeof frame.body !== "string" || + !isSafeNonNegativeInteger(frame.bodyByteCount) || !isStringRecord(frame.headers) || typeof frame.outcome !== "string" || !RESPONSE_OUTCOMES.has(frame.outcome) @@ -313,6 +365,29 @@ function validateResponse(frame: Record): DuplexDecodeResult { return ok(frame as unknown as DuplexResponseFrame); } +/** + * Validate a `body_chunk` frame at the structural level. The codec is stateless, + * so it checks only the per-frame shape here: + * - `id` is a string within {@link DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES}. + * - `seq` is a safe integer that is zero or positive. + * - `data` is a string. + * The receive-side reassembler owns the stateful checks: the base64 canonical + * form, the fixed slice size, the `seq` order, and the total against + * `bodyByteCount`. The codec never decodes `data` here. + */ +function validateBodyChunk(frame: Record): DuplexDecodeResult { + if (typeof frame.id !== "string" || typeof frame.data !== "string") { + return fail("malformed_frame", "body_chunk frame has a missing or wrong-typed field"); + } + if (!isSafeNonNegativeInteger(frame.seq)) { + return fail("malformed_frame", "body_chunk frame has a missing or wrong-typed seq"); + } + if (!idWithinLimit(frame.id)) { + return fail("id_too_large", "body_chunk frame id exceeds the maximum size"); + } + return ok(frame as unknown as DuplexBodyChunkFrame); +} + function validateReady(frame: Record): DuplexDecodeResult { // READY carries a liveness nonce, not an address. The schema is strict: a valid // READY frame holds exactly `version`, `type`, and `nonce`. The decoder rejects diff --git a/packages/adapter-utils/src/duplex-frame-vectors.json b/packages/adapter-utils/src/duplex-frame-vectors.json index 5bedc93081e..62d43d1c15f 100644 --- a/packages/adapter-utils/src/duplex-frame-vectors.json +++ b/packages/adapter-utils/src/duplex-frame-vectors.json @@ -1,17 +1,17 @@ { - "frameVersion": 1, + "frameVersion": 2, "defaultMaxFrameBytes": 1000000, "description": "Shared wire-compatibility vectors for the duplex frame codec. Every codec copy decodes the same bytes. bytes is a UTF-8 byte stream; splitByteOffsets cuts it into chunks; expected lists the ordered decode results (frame or protocol-error code).", "vectors": [ { "name": "valid-request", "category": "valid", - "bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n", + "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n", "roundTrip": true, "expected": [ { "frame": { - "version": 1, + "version": 2, "type": "request", "id": "req-1", "method": "GET", @@ -21,7 +21,30 @@ "accept": "application/json", "content-type": "application/json" }, - "body": "" + "bodyByteCount": 0 + } + } + ] + }, + { + "name": "valid-request-with-body-count", + "category": "valid", + "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-body\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":1048576}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 2, + "type": "request", + "id": "req-body", + "method": "GET", + "path": "/api/issues/PAP-1", + "query": "expand=comments", + "headers": { + "accept": "application/json", + "content-type": "application/json" + }, + "bodyByteCount": 1048576 } } ] @@ -29,19 +52,19 @@ { "name": "valid-response-completed", "category": "valid", - "bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"ok\\\":true}\",\"outcome\":\"completed\"}\n", + "bytes": "{\"version\":2,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"bodyByteCount\":11,\"outcome\":\"completed\"}\n", "roundTrip": true, "expected": [ { "frame": { - "version": 1, + "version": 2, "type": "response", "id": "req-1", "status": 200, "headers": { "content-type": "application/json" }, - "body": "{\"ok\":true}", + "bodyByteCount": 11, "outcome": "completed" } } @@ -50,19 +73,19 @@ { "name": "valid-response-indeterminate", "category": "valid", - "bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-2\",\"status\":409,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"error\\\":\\\"outcome_indeterminate\\\"}\",\"outcome\":\"indeterminate\"}\n", + "bytes": "{\"version\":2,\"type\":\"response\",\"id\":\"req-2\",\"status\":409,\"headers\":{\"content-type\":\"application/json\"},\"bodyByteCount\":33,\"outcome\":\"indeterminate\"}\n", "roundTrip": true, "expected": [ { "frame": { - "version": 1, + "version": 2, "type": "response", "id": "req-2", "status": 409, "headers": { "content-type": "application/json" }, - "body": "{\"error\":\"outcome_indeterminate\"}", + "bodyByteCount": 33, "outcome": "indeterminate" } } @@ -71,43 +94,121 @@ { "name": "valid-response-unavailable", "category": "valid", - "bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-3\",\"status\":503,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"error\\\":\\\"bridge_unavailable\\\"}\",\"outcome\":\"unavailable\"}\n", + "bytes": "{\"version\":2,\"type\":\"response\",\"id\":\"req-3\",\"status\":503,\"headers\":{\"content-type\":\"application/json\"},\"bodyByteCount\":30,\"outcome\":\"unavailable\"}\n", "roundTrip": true, "expected": [ { "frame": { - "version": 1, + "version": 2, "type": "response", "id": "req-3", "status": 503, "headers": { "content-type": "application/json" }, - "body": "{\"error\":\"bridge_unavailable\"}", + "bodyByteCount": 30, "outcome": "unavailable" } } ] }, + { + "name": "valid-body-chunk", + "category": "valid", + "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":0,\"data\":\"aGVsbG8gYm9keSBjaHVuaw==\"}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 2, + "type": "body_chunk", + "id": "req-1", + "seq": 0, + "data": "aGVsbG8gYm9keSBjaHVuaw==" + } + } + ] + }, + { + "name": "valid-body-chunk-final", + "category": "valid", + "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":7,\"data\":\"dGFpbA==\"}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 2, + "type": "body_chunk", + "id": "req-1", + "seq": 7, + "data": "dGFpbA==" + } + } + ] + }, { "name": "valid-ready", "category": "valid", - "bytes": "{\"version\":1,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\"}\n", + "bytes": "{\"version\":2,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\"}\n", "roundTrip": true, "expected": [ { "frame": { - "version": 1, + "version": 2, "type": "ready", "nonce": "9f8e7d6c5b4a39281706f5e4d3c2b1a0" } } ] }, + { + "name": "valid-heartbeat", + "category": "valid", + "bytes": "{\"version\":2,\"type\":\"heartbeat\"}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 2, + "type": "heartbeat" + } + } + ] + }, + { + "name": "valid-close", + "category": "valid", + "bytes": "{\"version\":2,\"type\":\"close\"}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 2, + "type": "close" + } + } + ] + }, + { + "name": "valid-error", + "category": "valid", + "bytes": "{\"version\":2,\"type\":\"error\",\"code\":\"malformed_frame\",\"message\":\"peer reported a malformed frame\"}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 2, + "type": "error", + "code": "malformed_frame", + "message": "peer reported a malformed frame" + } + } + ] + }, { "name": "invalid-ready-missing-nonce", "category": "invalid", - "bytes": "{\"version\":1,\"type\":\"ready\"}\n", + "bytes": "{\"version\":2,\"type\":\"ready\"}\n", "expected": [ { "error": "malformed_frame" @@ -117,7 +218,7 @@ { "name": "invalid-ready-with-address", "category": "invalid", - "bytes": "{\"version\":1,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\",\"address\":\"http://127.0.0.1:47215\"}\n", + "bytes": "{\"version\":2,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\",\"address\":\"http://127.0.0.1:47215\"}\n", "expected": [ { "error": "malformed_frame" @@ -127,57 +228,13 @@ { "name": "invalid-ready-with-port", "category": "invalid", - "bytes": "{\"version\":1,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\",\"port\":47215}\n", + "bytes": "{\"version\":2,\"type\":\"ready\",\"nonce\":\"9f8e7d6c5b4a39281706f5e4d3c2b1a0\",\"port\":47215}\n", "expected": [ { "error": "malformed_frame" } ] }, - { - "name": "valid-heartbeat", - "category": "valid", - "bytes": "{\"version\":1,\"type\":\"heartbeat\"}\n", - "roundTrip": true, - "expected": [ - { - "frame": { - "version": 1, - "type": "heartbeat" - } - } - ] - }, - { - "name": "valid-close", - "category": "valid", - "bytes": "{\"version\":1,\"type\":\"close\"}\n", - "roundTrip": true, - "expected": [ - { - "frame": { - "version": 1, - "type": "close" - } - } - ] - }, - { - "name": "valid-error", - "category": "valid", - "bytes": "{\"version\":1,\"type\":\"error\",\"code\":\"malformed_frame\",\"message\":\"peer reported a malformed frame\"}\n", - "roundTrip": true, - "expected": [ - { - "frame": { - "version": 1, - "type": "error", - "code": "malformed_frame", - "message": "peer reported a malformed frame" - } - } - ] - }, { "name": "invalid-not-json", "category": "invalid", @@ -211,7 +268,7 @@ { "name": "invalid-unknown-type", "category": "invalid", - "bytes": "{\"version\":1,\"type\":\"teleport\"}\n", + "bytes": "{\"version\":2,\"type\":\"teleport\"}\n", "expected": [ { "error": "unknown_type" @@ -221,7 +278,37 @@ { "name": "invalid-missing-id", "category": "invalid", - "bytes": "{\"version\":1,\"type\":\"request\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"body\":\"\"}\n", + "bytes": "{\"version\":2,\"type\":\"request\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":0}\n", + "expected": [ + { + "error": "malformed_frame" + } + ] + }, + { + "name": "invalid-request-body-count-string", + "category": "invalid", + "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":\"10\"}\n", + "expected": [ + { + "error": "malformed_frame" + } + ] + }, + { + "name": "invalid-request-body-count-negative", + "category": "invalid", + "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":-1}\n", + "expected": [ + { + "error": "malformed_frame" + } + ] + }, + { + "name": "invalid-request-body-count-fractional", + "category": "invalid", + "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":1.5}\n", "expected": [ { "error": "malformed_frame" @@ -231,7 +318,47 @@ { "name": "invalid-bad-headers", "category": "invalid", - "bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"accept\":7},\"body\":\"{\\\"ok\\\":true}\",\"outcome\":\"completed\"}\n", + "bytes": "{\"version\":2,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"accept\":7},\"bodyByteCount\":10,\"outcome\":\"completed\"}\n", + "expected": [ + { + "error": "malformed_frame" + } + ] + }, + { + "name": "invalid-body-chunk-negative-seq", + "category": "invalid", + "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":-1,\"data\":\"eA==\"}\n", + "expected": [ + { + "error": "malformed_frame" + } + ] + }, + { + "name": "invalid-body-chunk-fractional-seq", + "category": "invalid", + "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":2.5,\"data\":\"eA==\"}\n", + "expected": [ + { + "error": "malformed_frame" + } + ] + }, + { + "name": "invalid-body-chunk-missing-data", + "category": "invalid", + "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":0}\n", + "expected": [ + { + "error": "malformed_frame" + } + ] + }, + { + "name": "invalid-body-chunk-nonstring-data", + "category": "invalid", + "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"req-1\",\"seq\":0,\"data\":42}\n", "expected": [ { "error": "malformed_frame" @@ -241,14 +368,14 @@ { "name": "partial-request-split", "category": "partial", - "bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n", + "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n", "splitByteOffsets": [ - 98 + 102 ], "expected": [ { "frame": { - "version": 1, + "version": 2, "type": "request", "id": "req-1", "method": "GET", @@ -258,7 +385,7 @@ "accept": "application/json", "content-type": "application/json" }, - "body": "" + "bodyByteCount": 0 } } ] @@ -266,23 +393,24 @@ { "name": "partial-utf8-split", "category": "partial", - "bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-utf8\",\"method\":\"POST\",\"path\":\"/api/issues/PAP-1/comments\",\"query\":\"\",\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"text\\\":\\\"héllo 😀 wörld\\\"}\"}\n", + "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-utf8\",\"method\":\"POST\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"content-type\":\"application/json\",\"x-note\":\"héllo 😀 wörld\"},\"bodyByteCount\":0}\n", "splitByteOffsets": [ - 177 + 172 ], "expected": [ { "frame": { - "version": 1, + "version": 2, "type": "request", "id": "req-utf8", "method": "POST", - "path": "/api/issues/PAP-1/comments", - "query": "", + "path": "/api/issues/PAP-1", + "query": "expand=comments", "headers": { - "content-type": "application/json" + "content-type": "application/json", + "x-note": "héllo 😀 wörld" }, - "body": "{\"text\":\"héllo 😀 wörld\"}" + "bodyByteCount": 0 } } ] @@ -290,16 +418,16 @@ { "name": "partial-two-frames", "category": "partial", - "bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n{\"version\":1,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"ok\\\":true}\",\"outcome\":\"completed\"}\n", + "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n{\"version\":2,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"bodyByteCount\":11,\"outcome\":\"completed\"}\n", "splitByteOffsets": [ 7, - 199, - 343 + 174, + 344 ], "expected": [ { "frame": { - "version": 1, + "version": 2, "type": "request", "id": "req-1", "method": "GET", @@ -309,19 +437,19 @@ "accept": "application/json", "content-type": "application/json" }, - "body": "" + "bodyByteCount": 0 } }, { "frame": { - "version": 1, + "version": 2, "type": "response", "id": "req-1", "status": 200, "headers": { "content-type": "application/json" }, - "body": "{\"ok\":true}", + "bodyByteCount": 11, "outcome": "completed" } } @@ -330,14 +458,14 @@ { "name": "partial-no-trailing-newline", "category": "partial", - "bytes": "{\"version\":1,\"type\":\"heartbeat\"}", + "bytes": "{\"version\":2,\"type\":\"heartbeat\"}", "expected": [] }, { "name": "oversized-complete-line", "category": "oversized", "maxFrameBytes": 64, - "bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}\n", + "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"q=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n", "expected": [ { "error": "frame_too_large" @@ -351,23 +479,33 @@ "splitByteOffsets": [ 40 ], - "bytes": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy\n{\"version\":1,\"type\":\"heartbeat\"}\n", + "bytes": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy\n{\"version\":2,\"type\":\"heartbeat\"}\n", "expected": [ { "error": "frame_too_large" }, { "frame": { - "version": 1, + "version": 2, "type": "heartbeat" } } ] }, + { + "name": "version-mismatch-lower", + "category": "versionMismatch", + "bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n", + "expected": [ + { + "error": "version_mismatch" + } + ] + }, { "name": "version-mismatch-higher", "category": "versionMismatch", - "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n", + "bytes": "{\"version\":3,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"bodyByteCount\":0}\n", "expected": [ { "error": "version_mismatch" @@ -387,19 +525,19 @@ { "name": "request-id-at-max-bytes", "category": "idBoundValid", - "bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"body\":\"\"}\n", + "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":0}\n", "roundTrip": true, "expected": [ { "frame": { - "version": 1, + "version": 2, "type": "request", "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "method": "GET", "path": "/", "query": "", "headers": {}, - "body": "" + "bodyByteCount": 0 } } ] @@ -407,7 +545,17 @@ { "name": "request-id-over-max-bytes", "category": "idBoundInvalid", - "bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"body\":\"\"}\n", + "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"bodyByteCount\":0}\n", + "expected": [ + { + "error": "id_too_large" + } + ] + }, + { + "name": "body-chunk-id-over-max-bytes", + "category": "idBoundInvalid", + "bytes": "{\"version\":2,\"type\":\"body_chunk\",\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"seq\":0,\"data\":\"eA==\"}\n", "expected": [ { "error": "id_too_large" @@ -421,14 +569,28 @@ "name": "encode-within-bound", "maxFrameBytes": 200, "frame": { - "version": 1, + "version": 2, "type": "request", "id": "req-enc-1", "method": "GET", "path": "/", "query": "", "headers": {}, - "body": "small" + "bodyByteCount": 5 + }, + "expected": { + "ok": true + } + }, + { + "name": "encode-body-chunk-within-bound", + "maxFrameBytes": 200, + "frame": { + "version": 2, + "type": "body_chunk", + "id": "req-enc-3", + "seq": 0, + "data": "Y2h1bmsgcGF5bG9hZA==" }, "expected": { "ok": true @@ -438,7 +600,7 @@ "name": "encode-control-frame-never-rejected", "maxFrameBytes": 200, "frame": { - "version": 1, + "version": 2, "type": "heartbeat" }, "expected": { @@ -449,14 +611,15 @@ "name": "encode-over-bound", "maxFrameBytes": 200, "frame": { - "version": 1, + "version": 2, "type": "response", "id": "req-enc-2", "status": 200, "headers": { - "content-type": "application/json" + "content-type": "application/json", + "x-pad": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" }, - "body": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "bodyByteCount": 0, "outcome": "completed" }, "expected": { diff --git a/packages/adapter-utils/src/duplex-observability.test.ts b/packages/adapter-utils/src/duplex-observability.test.ts new file mode 100644 index 00000000000..095b64b04db --- /dev/null +++ b/packages/adapter-utils/src/duplex-observability.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { + DUPLEX_LOSS_REASONS, + HTTP2_TELEMETRY_EVENT_NAMES, + mapHttp2EventToDuplexLossReason, + normalizeDuplexLossReason, + normalizeDuplexProvider, + type DuplexFallbackReason, + type DuplexTransportValue, +} from "./duplex-observability.js"; + +describe("duplex observability: HTTP/2 event mapping (accepted security fix 7)", () => { + it("test_http2_events_map_to_the_loss_taxonomy", () => { + // Every closed HTTP/2 event name maps to a value from the existing, + // closed DuplexLossReason set. The map reuses one taxonomy across every + // transport, so no separate HTTP/2-only reason list exists. + for (const event of HTTP2_TELEMETRY_EVENT_NAMES) { + const reason = mapHttp2EventToDuplexLossReason(event); + expect(DUPLEX_LOSS_REASONS).toContain(reason); + } + expect(mapHttp2EventToDuplexLossReason("session_error")).toBe("rpc_failure"); + expect(mapHttp2EventToDuplexLossReason("session_goaway")).toBe("transport_closed"); + expect(mapHttp2EventToDuplexLossReason("session_stall")).toBe("heartbeat_timeout"); + expect(mapHttp2EventToDuplexLossReason("write_error")).toBe("write_error"); + expect(mapHttp2EventToDuplexLossReason("transport_closed")).toBe("transport_closed"); + expect(mapHttp2EventToDuplexLossReason("channel_exit")).toBe("provider_exit"); + }); + + it("test_an_unknown_telemetry_input_maps_to_other", () => { + // An unknown event name, a raw provider error string, and a missing value + // all map to `other`. No raw text ever reaches a sink through this map. + expect(mapHttp2EventToDuplexLossReason("ECONNRESET: read failed at socket.js:42")).toBe("other"); + expect(mapHttp2EventToDuplexLossReason("some_future_event")).toBe("other"); + expect(mapHttp2EventToDuplexLossReason(null)).toBe("other"); + expect(mapHttp2EventToDuplexLossReason(undefined)).toBe("other"); + expect(mapHttp2EventToDuplexLossReason("")).toBe("other"); + }); + + it("keeps the closed HTTP/2 event-name set to the seven named signals", () => { + expect([...HTTP2_TELEMETRY_EVENT_NAMES].sort()).toEqual( + [ + "session_error", + "session_goaway", + "session_stall", + "write_error", + "transport_closed", + "channel_exit", + ].sort(), + ); + }); +}); + +describe("duplex observability: closed transport and fallback-reason values", () => { + it("accepts the http2 transport value and the preface_missing fallback reason", () => { + // A type-level check: these string literals must widen to the exported + // union types with no cast, so a drift in either union breaks the build. + const transport: DuplexTransportValue = "http2"; + const fallbackReason: DuplexFallbackReason = "preface_missing"; + expect(transport).toBe("http2"); + expect(fallbackReason).toBe("preface_missing"); + }); +}); + +describe("duplex observability: existing normalization stays intact", () => { + it("still maps an unknown loss cause and an unknown provider key to their closed defaults", () => { + expect(normalizeDuplexLossReason("not_a_real_reason")).toBe("other"); + expect(normalizeDuplexProvider("some-unlisted-plugin")).toBe("other"); + }); +}); diff --git a/packages/adapter-utils/src/duplex-telemetry.ts b/packages/adapter-utils/src/duplex-observability.ts similarity index 72% rename from packages/adapter-utils/src/duplex-telemetry.ts rename to packages/adapter-utils/src/duplex-observability.ts index b31c6559183..1d144fa60fe 100644 --- a/packages/adapter-utils/src/duplex-telemetry.ts +++ b/packages/adapter-utils/src/duplex-observability.ts @@ -12,7 +12,7 @@ * never reaches a span attribute, a counter label, or an event field. * * The module stays free of `@opentelemetry/api` and of the database. The host - * injects a {@link DuplexTelemetryRecorder}; the default is a no-op recorder, so + * injects a {@link DuplexObservabilityRecorder}; the default is a no-op recorder, so * the whole surface stays inert until the host binds a real recorder. Every * recorder call sits inside an error swallow, so a telemetry failure never breaks * the request path. @@ -59,9 +59,9 @@ export const DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL = /** * The closed set of aggregate byte ledger metric names. A test pins this exact * set, so a new ledger metric name needs an explicit review. Each record uses - * only closed constant dimensions and no dynamic label. The telemetry contract - * documents these metrics under "Aggregate byte ledger metrics" in - * `packages/shared/src/telemetry/README.md`. + * only closed constant dimensions and no dynamic label. The Observability + * contract documents these metrics under "Aggregate byte ledger metrics" in + * `doc/observability.md`. */ export const DUPLEX_AGGREGATE_BYTE_LEDGER_METRIC_NAMES = [ DUPLEX_GAUGE_AGGREGATE_BYTES_IN_USE, @@ -86,8 +86,12 @@ export const DUPLEX_DIMENSION_KEYS = [ /** One dimension key from the closed set. */ export type DuplexDimensionKey = (typeof DUPLEX_DIMENSION_KEYS)[number]; -/** The transport a record is about. */ -export type DuplexTransportValue = "duplex" | "file"; +/** + * The transport a record is about. `duplex` names the retired bespoke frame + * protocol; `http2` names the Node HTTP/2 session over the sandbox channel; + * `file` names the queue-file bridge. + */ +export type DuplexTransportValue = "duplex" | "http2" | "file"; /** The outcome of a record. */ export type DuplexOutcomeValue = "ok" | "error"; @@ -98,9 +102,13 @@ export type DuplexOutcomeValue = "ok" | "error"; * stage: the process-scoped route ceiling was full (`route_busy`), the entrypoint * sync failed (`entrypoint_sync_failed`), the broker construction failed * (`broker_construction_failed`), or the channel open failed (`channel_open_failed`). - * The `aggregate_bytes_exceeded` reason names a readiness handshake the host fell - * back because the process aggregate byte ceiling had no room for the readiness - * buffer. + * The `aggregate_bytes_exceeded` reason names a readiness handshake, or an + * `http2` post-preface pre-bind buffer, the host fell back because the + * process aggregate byte ceiling had no room. The `preface_missing` reason + * names a missing or an invalid HTTP/2 client connection preface inside the + * bounded readiness buffer: the host found no valid preface after the + * accepted READY line, aborted the HTTP/2 open, and moved the run to + * `queue_v1` one time. */ export type DuplexFallbackReason = | "gate_off" @@ -113,7 +121,8 @@ export type DuplexFallbackReason = | "ready_nonce_mismatch" | "ready_timeout" | "contaminated" - | "aggregate_bytes_exceeded"; + | "aggregate_bytes_exceeded" + | "preface_missing"; /** The class of a terminal loss, relative to the first request dispatch. */ export type DuplexLossClass = "pre_dispatch" | "post_dispatch"; @@ -155,6 +164,58 @@ export function normalizeDuplexLossReason(value: string | null | undefined): Dup : "other"; } +/** + * The closed set of HTTP/2 session and stream event names the host maps to a + * loss reason. Each name spells one distinct signal the `http2_v1` transport + * can observe: a session-level protocol fault, a peer GOAWAY, a stalled PING + * watchdog, a rejected host-to-sandbox write, a reason-less transport close, + * or the pseudo-terminal channel process exit. + */ +export const HTTP2_TELEMETRY_EVENT_NAMES = [ + "session_error", + "session_goaway", + "session_stall", + "write_error", + "transport_closed", + "channel_exit", +] as const; + +/** One event name from the closed HTTP/2 event set. */ +export type Http2TelemetryEventName = (typeof HTTP2_TELEMETRY_EVENT_NAMES)[number]; + +/** The host-owned closed HTTP/2 event-name set. It backs {@link mapHttp2EventToDuplexLossReason}. */ +const HTTP2_EVENT_NAMES: ReadonlySet = new Set(HTTP2_TELEMETRY_EVENT_NAMES); + +/** + * The map from one closed HTTP/2 event name to the existing, closed + * {@link DuplexLossReason} taxonomy. The map reuses that one taxonomy instead + * of a second, HTTP/2-only reason list, so the closed-set pattern stays one + * set of values across every transport. + */ +const HTTP2_EVENT_TO_LOSS_REASON: Readonly> = { + session_error: "rpc_failure", + session_goaway: "transport_closed", + session_stall: "heartbeat_timeout", + write_error: "write_error", + transport_closed: "transport_closed", + channel_exit: "provider_exit", +}; + +/** + * Map one HTTP/2 session or stream event name to the closed + * {@link DuplexLossReason} taxonomy (accepted security fix 7). Return the + * mapped reason when the closed event-name set holds the value. Return + * `other` for any other value or a missing value, so an unknown event name or + * a raw provider string never reaches a sink. + */ +export function mapHttp2EventToDuplexLossReason( + event: string | null | undefined, +): DuplexLossReason { + return typeof event === "string" && HTTP2_EVENT_NAMES.has(event) + ? HTTP2_EVENT_TO_LOSS_REASON[event as Http2TelemetryEventName] + : "other"; +} + /** The one approved public provider value. */ export const DUPLEX_APPROVED_PROVIDER = "daytona"; /** The constant for any provider key outside the allowlist. */ @@ -182,7 +243,7 @@ export function normalizeDuplexProvider(key: string | null | undefined): DuplexP * are present only when the record defines them, so a span or a counter never * carries an empty dimension. */ -export interface DuplexTelemetryDimensions { +export interface DuplexObservabilityDimensions { provider: DuplexProviderValue; transport: DuplexTransportValue; outcome?: DuplexOutcomeValue; @@ -192,23 +253,23 @@ export interface DuplexTelemetryDimensions { } /** One span record the host records. The request span carries a latency. */ -export interface DuplexTelemetrySpanRecord { +export interface DuplexObservabilitySpanRecord { name: string; - dimensions: DuplexTelemetryDimensions; + dimensions: DuplexObservabilityDimensions; /** The request latency in milliseconds. Only the request span sets it. */ latencyMs?: number; } /** One counter increment the host records. */ -export interface DuplexTelemetryCounterRecord { +export interface DuplexObservabilityCounterRecord { metric: string; - dimensions: DuplexTelemetryDimensions; + dimensions: DuplexObservabilityDimensions; } /** One event the host emits. */ -export interface DuplexTelemetryEventRecord { +export interface DuplexObservabilityEventRecord { name: string; - dimensions: DuplexTelemetryDimensions; + dimensions: DuplexObservabilityDimensions; } /** @@ -218,14 +279,14 @@ export interface DuplexTelemetryEventRecord { * The recorder receives only already-mapped dimensions, so the raw provider key * never reaches it. */ -export interface DuplexTelemetryRecorder { - recordSpan(record: DuplexTelemetrySpanRecord): void; - incrementCounter(record: DuplexTelemetryCounterRecord): void; - emitEvent(record: DuplexTelemetryEventRecord): void; +export interface DuplexObservabilityRecorder { + recordSpan(record: DuplexObservabilitySpanRecord): void; + incrementCounter(record: DuplexObservabilityCounterRecord): void; + emitEvent(record: DuplexObservabilityEventRecord): void; } /** A no-op recorder. Every method does nothing, so the surface stays inert. */ -export const NOOP_DUPLEX_TELEMETRY_RECORDER: DuplexTelemetryRecorder = { +export const NOOP_DUPLEX_OBSERVABILITY_RECORDER: DuplexObservabilityRecorder = { recordSpan() {}, incrementCounter() {}, emitEvent() {}, @@ -252,7 +313,7 @@ export interface DuplexChannelOpenAttempt { * provider, so a call site never passes a raw key. It maps each semantic event to * the fixed names and dimensions, then calls the recorder inside an error swallow. */ -export interface DuplexTelemetry { +export interface DuplexObservability { /** Begin a channel-open attempt. The caller reports `ready` or `fallback`. */ startChannelOpen(): DuplexChannelOpenAttempt; /** @@ -272,12 +333,19 @@ export interface DuplexTelemetry { recordSessionLeak(): void; } -/** The options for {@link createDuplexTelemetry}. */ -export interface DuplexTelemetryOptions { +/** The options for {@link createDuplexObservability}. */ +export interface DuplexObservabilityOptions { /** The injected recorder. The default is the no-op recorder. */ - recorder?: DuplexTelemetryRecorder | null; + recorder?: DuplexObservabilityRecorder | null; /** The raw provider key. The facade maps it through the allowlist one time. */ providerKey?: string | null; + /** + * The transport value the facade stamps on every non-file record (a channel + * open, a request, a loss, a session leak). The default is `duplex`, so an + * existing caller that names no transport sees no change. The host passes + * `http2` for the `http2_v1` path. + */ + transport?: DuplexTransportValue; } /** @@ -286,25 +354,26 @@ export interface DuplexTelemetryOptions { * inside a `try/catch`, so a throwing recorder never breaks the request path. A * missing recorder yields a facade whose methods do nothing. */ -export function createDuplexTelemetry(options: DuplexTelemetryOptions = {}): DuplexTelemetry { - const recorder = options.recorder ?? NOOP_DUPLEX_TELEMETRY_RECORDER; +export function createDuplexObservability(options: DuplexObservabilityOptions = {}): DuplexObservability { + const recorder = options.recorder ?? NOOP_DUPLEX_OBSERVABILITY_RECORDER; const provider = normalizeDuplexProvider(options.providerKey); + const transport: DuplexTransportValue = options.transport ?? "duplex"; - const safeSpan = (record: DuplexTelemetrySpanRecord): void => { + const safeSpan = (record: DuplexObservabilitySpanRecord): void => { try { recorder.recordSpan(record); } catch { // Observability must not break the request path. } }; - const safeCounter = (record: DuplexTelemetryCounterRecord): void => { + const safeCounter = (record: DuplexObservabilityCounterRecord): void => { try { recorder.incrementCounter(record); } catch { // Observability must not break the request path. } }; - const safeEvent = (record: DuplexTelemetryEventRecord): void => { + const safeEvent = (record: DuplexObservabilityEventRecord): void => { try { recorder.emitEvent(record); } catch { @@ -313,7 +382,7 @@ export function createDuplexTelemetry(options: DuplexTelemetryOptions = {}): Dup }; const recordFallback = (reason: DuplexFallbackReason): void => { - const dimensions: DuplexTelemetryDimensions = { + const dimensions: DuplexObservabilityDimensions = { provider, transport: "file", outcome: "error", @@ -330,9 +399,9 @@ export function createDuplexTelemetry(options: DuplexTelemetryOptions = {}): Dup ready(): void { if (settled) return; settled = true; - const dimensions: DuplexTelemetryDimensions = { + const dimensions: DuplexObservabilityDimensions = { provider, - transport: "duplex", + transport, outcome: "ok", }; safeSpan({ name: DUPLEX_SPAN_CHANNEL_OPEN, dimensions }); @@ -349,7 +418,7 @@ export function createDuplexTelemetry(options: DuplexTelemetryOptions = {}): Dup // closed dimension key, so no new key reaches a sink. safeSpan({ name: DUPLEX_SPAN_CHANNEL_OPEN, - dimensions: { provider, transport: "duplex", outcome: "error", fallback_reason: reason }, + dimensions: { provider, transport, outcome: "error", fallback_reason: reason }, }); recordFallback(reason); }, @@ -359,14 +428,14 @@ export function createDuplexTelemetry(options: DuplexTelemetryOptions = {}): Dup recordRequest(record: { latencyMs: number; outcome: DuplexOutcomeValue }): void { safeSpan({ name: DUPLEX_SPAN_REQUEST, - dimensions: { provider, transport: "duplex", outcome: record.outcome }, + dimensions: { provider, transport, outcome: record.outcome }, latencyMs: record.latencyMs, }); }, recordLoss(lossClass: DuplexLossClass, lossReason: DuplexLossReason): void { - const dimensions: DuplexTelemetryDimensions = { + const dimensions: DuplexObservabilityDimensions = { provider, - transport: "duplex", + transport, outcome: "error", loss_class: lossClass, loss_reason: lossReason, @@ -377,7 +446,7 @@ export function createDuplexTelemetry(options: DuplexTelemetryOptions = {}): Dup recordSessionLeak(): void { safeCounter({ metric: DUPLEX_COUNTER_SESSION_LEAK_TOTAL, - dimensions: { provider, transport: "duplex", outcome: "error" }, + dimensions: { provider, transport, outcome: "error" }, }); }, }; diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index 0ecf167102c..e89a07d8c7f 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -1,5 +1,7 @@ import { createServer } from "node:http"; +import http2 from "node:http2"; import net from "node:net"; +import { duplexPair, type Duplex } from "node:stream"; import { execFile, spawn } from "node:child_process"; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; @@ -8,16 +10,13 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - getSandboxCallbackBridgeServerSource, - getSandboxDuplexGatewayCodecSource, -} from "./sandbox-callback-bridge.js"; +import { getSandboxDuplexGatewayCodecSource } from "./sandbox-callback-bridge.js"; import { __duplexReadinessTesting, - buildDuplexGatewayLaunchArgv, + __http2PrefaceScanTesting, DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, - adapterExecutionTargetDuplexTelemetryRecorder, + adapterExecutionTargetDuplexObservabilityRecorder, adapterExecutionTargetEnablesSandboxDuplexBridge, adapterExecutionTargetSessionIdentity, adapterExecutionTargetToRemoteSpec, @@ -34,6 +33,7 @@ import { startAdapterExecutionTargetProcessSessionBridge, startAdapterExecutionTargetPaperclipBridge, type AdapterSandboxExecutionTarget, + type EffectiveExecutionCapabilities, type EffectiveSandboxCapabilities, } from "./execution-target.js"; import { @@ -58,20 +58,10 @@ import { DUPLEX_FRAME_VERSION, decodeDuplexLine, encodeDuplexFrame, - type DuplexRequestFrame, type DuplexResponseFrame, } from "./duplex-frame-codec.js"; +import { DUPLEX_CHANNEL_LOST_ERROR_CODE } from "./bridge-transport-contract.js"; import { - assertNestedDuplexBrokerBudgets, - createDuplexBridgeBroker, - DUPLEX_CHANNEL_LOST_ERROR_CODE, - type DuplexBrokerForwardResult, - type DuplexBrokerLossRecord, - type DuplexBrokerRequestRecord, - type DuplexBrokerState, -} from "./duplex-bridge-broker.js"; -import { - createDuplexTelemetry, DUPLEX_AGGREGATE_BYTE_LEDGER_METRIC_NAMES, DUPLEX_COUNTER_AGGREGATE_BYTE_ACCOUNTING_UNDERFLOW_TOTAL, DUPLEX_COUNTER_AGGREGATE_BYTE_RESERVATION_REJECTIONS_TOTAL, @@ -83,12 +73,13 @@ import { DUPLEX_SPAN_CHANNEL_OPEN, DUPLEX_SPAN_REQUEST, DUPLEX_TRANSPORT_EVENT, - type DuplexTelemetryCounterRecord, - type DuplexTelemetryDimensions, - type DuplexTelemetryEventRecord, - type DuplexTelemetryRecorder, - type DuplexTelemetrySpanRecord, -} from "./duplex-telemetry.js"; + type DuplexLossReason, + type DuplexObservabilityCounterRecord, + type DuplexObservabilityDimensions, + type DuplexObservabilityEventRecord, + type DuplexObservabilityRecorder, + type DuplexObservabilitySpanRecord, +} from "./duplex-observability.js"; const execFileAsync = promisify(execFile); @@ -3056,7 +3047,7 @@ describe("sandbox adapter execution targets", () => { // The full effective-capability snapshot with one flag set. The two strict // gates read `duplexCommandStream`; the other flags stay false. - function duplexCapabilities(duplexCommandStream: boolean): EffectiveSandboxCapabilities { + function duplexCapabilities(duplexCommandStream: boolean): EffectiveExecutionCapabilities { return { reusableLeases: false, nativeSyncIn: false, @@ -3117,30 +3108,30 @@ describe("sandbox adapter execution targets", () => { const joined = openInput.command.join(" "); const nonce = /PAPERCLIP_BRIDGE_NONCE='([^']*)'/.exec(joined)?.[1] ?? ""; const port = /PAPERCLIP_BRIDGE_PORT='([^']*)'/.exec(joined)?.[1] ?? ""; - let dataListener: ((chunk: string) => void) | null = null; + let dataListener: ((chunk: Uint8Array) => void) | null = null; let exitListener: ((exit: { exitCode: number | null }) => void) | null = null; const channel: CommandManagedDuplexChannel = { - write(data: string): void { - const decoded = decodeDuplexLine(data.replace(/\n$/, "")); + write(data: Uint8Array): void { + const decoded = decodeDuplexLine(Buffer.from(data).toString("utf8").replace(/\n$/, "")); if (decoded.ok) { control.writtenTypes.push(decoded.frame.type); if (decoded.frame.type === "response") control.written.push(decoded.frame); } }, - onData(listener: (chunk: string) => void): void { + onData(listener: (chunk: Uint8Array) => void): void { dataListener = listener; - control.emitData = (chunk) => dataListener?.(chunk); + control.emitData = (chunk) => dataListener?.(new TextEncoder().encode(chunk)); // Drive the readiness emission on the next tick, after the gate also // registers its exit listener. setImmediate(() => { - const emitRaw = (text: string) => dataListener?.(text); + const emitRaw = (text: string) => dataListener?.(new TextEncoder().encode(text)); const emitFrame = (frame: Record) => - dataListener?.(`${JSON.stringify(frame)}\n`); + dataListener?.(new TextEncoder().encode(`${JSON.stringify(frame)}\n`)); const emitExit = (exit: { exitCode: number | null }) => exitListener?.(exit); if (onOpen) { onOpen({ nonce, port, emitRaw, emitFrame, emitExit }); } else { - emitFrame({ version: 1, type: "ready", nonce }); + emitFrame({ version: 2, type: "ready", nonce }); } }); }, @@ -3160,6 +3151,153 @@ describe("sandbox adapter execution targets", () => { return { runner: { ...base, openDuplexChannel }, control }; } + // The HTTP/2 client connection preface, 24 octets (RFC 9113, Section 3.4). + // A test writes this literal to script a preface look-alike; it does not + // import the production constant, so the test proves the real wire bytes + // match, not only that the two source files agree on a name. + const HTTP2_TEST_CLIENT_PREFACE = Buffer.from("505249202a20485454502f322e300d0a0d0a534d0d0a0d0a", "hex"); + + interface Http2SelectionControl { + openCount: number; + stopCount: number; + closeCount: number; + } + + // The hook a test supplies to script the bytes a fake sandbox gateway sends + // after the host binds the readiness gate, and to open a real HTTP/2 client + // session on the same channel. The default hook sends one valid READY line, + // then opens the client session and leaves it idle — the happy path needs + // no hook. + interface Http2OpenContext { + nonce: string; + port: string; + /** The real per-run bridge token the host generated for this open. A + * test attaches it as the `authorization` header on every real HTTP/2 + * request it dispatches, the same way the sandbox gateway does. */ + bridgeToken: string; + /** Write raw bytes onto the channel, ahead of or instead of a READY line + * or the client preface. */ + emitRaw: (bytes: Buffer | string) => void; + /** Write one READY line. Echoes the launch nonce by default. */ + emitReady: (nonce?: string) => void; + /** Open a real HTTP/2 client session on the channel and return it, the + * same session type the generated sandbox gateway opens in production. */ + connectHttp2: () => http2.ClientHttp2Session; + /** End the channel from the sandbox side, simulating a provider process exit. */ + emitExit: () => void; + } + + // Build a runner whose fake `openDuplexChannel` returns one side of a real + // paired in-memory `Duplex` (`node:stream`'s `duplexPair`). A test drives + // the other side directly, including opening a real `http2.connect()` + // client session on it, so the host's HTTP/2 server under test speaks one + // real, wire-compatible HTTP/2 session — the same proof + // `http2-bridge-server.test.ts` uses for the server and the gateway in + // isolation, exercised here through the full transport-selection path. + function makeHttp2SelectionRunner(onOpen?: (ctx: Http2OpenContext) => void): { + runner: ReturnType & { + openDuplexChannel: (openInput: { command: readonly string[] }) => Promise; + }; + control: Http2SelectionControl; + } { + const base = createLocalSandboxRunner(); + const control: Http2SelectionControl = { openCount: 0, stopCount: 0, closeCount: 0 }; + const openDuplexChannel = async (openInput: { + command: readonly string[]; + }): Promise => { + control.openCount += 1; + const joined = openInput.command.join(" "); + const nonce = /PAPERCLIP_BRIDGE_NONCE='([^']*)'/.exec(joined)?.[1] ?? ""; + const port = /PAPERCLIP_BRIDGE_PORT='([^']*)'/.exec(joined)?.[1] ?? ""; + const bridgeToken = /PAPERCLIP_BRIDGE_TOKEN='([^']*)'/.exec(joined)?.[1] ?? ""; + const [hostSide, sandboxSide] = duplexPair(); + const dataListeners: Array<(chunk: Uint8Array) => void> = []; + const exitListeners: Array<(exit: { exitCode: number | null }) => void> = []; + hostSide.on("data", (chunk: Buffer) => { + for (const listener of dataListeners) listener(chunk); + }); + hostSide.on("end", () => { + for (const listener of exitListeners) listener({ exitCode: 0 }); + }); + const channel: CommandManagedDuplexChannel = { + write: (data: Uint8Array) => { + hostSide.write(Buffer.from(data)); + }, + onData: (listener: (chunk: Uint8Array) => void) => { + dataListeners.push(listener); + }, + onExit: (listener: (exit: { exitCode: number | null }) => void) => { + exitListeners.push(listener); + }, + stop: () => { + control.stopCount += 1; + hostSide.destroy(); + }, + close: async () => { + control.closeCount += 1; + hostSide.end(); + }, + }; + setImmediate(() => { + const ctx: Http2OpenContext = { + nonce, + port, + bridgeToken, + emitRaw: (bytes) => sandboxSide.write(typeof bytes === "string" ? Buffer.from(bytes) : bytes), + emitReady: (readyNonce = nonce) => + sandboxSide.write( + encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "ready", nonce: readyNonce }), + ), + connectHttp2: () => http2.connect("http://bridge.internal", { createConnection: () => sandboxSide }), + emitExit: () => sandboxSide.end(), + }; + if (onOpen) { + onOpen(ctx); + } else { + ctx.emitReady(); + ctx.connectHttp2(); + } + }); + return channel; + }; + return { runner: { ...base, openDuplexChannel }, control }; + } + + /** + * Forward one request over a real HTTP/2 client session and resolve with + * the response the host sends back. Mirrors the shape of the sandbox + * gateway's own request forward, so a test drives the transport exactly + * like production does. + */ + function http2TestRequest( + session: http2.ClientHttp2Session, + request: { method: string; path: string; headers?: Record; body?: string }, + ): Promise<{ status: number; headers: Record; body: string }> { + return new Promise((resolve, reject) => { + const body = Buffer.from(request.body ?? "", "utf8"); + const stream = session.request( + { ":method": request.method, ":path": request.path, ...request.headers }, + { endStream: body.length === 0 }, + ); + const chunks: Buffer[] = []; + let status = 502; + let headers: Record = {}; + stream.on("response", (h) => { + status = Number(h[":status"]) || 502; + headers = {}; + for (const [key, value] of Object.entries(h)) { + if (key.startsWith(":") || value == null) continue; + headers[key] = Array.isArray(value) ? value.join(", ") : String(value); + } + }); + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.once("end", () => resolve({ status, headers, body: Buffer.concat(chunks).toString("utf8") })); + stream.once("error", (error) => reject(error)); + if (body.length > 0) stream.end(body); + else if (!stream.writableEnded) stream.end(); + }); + } + // Start a host API server that records each forwarded request, so a test can // assert the real token and the run id reach the host, or that a rejected // request never forwards. @@ -3211,17 +3349,72 @@ describe("sandbox adapter execution targets", () => { }; } - it("selects the duplex transport when both strict gates pass and readiness completes", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-select-")); + it("test_startup_failure_falls_back_to_queue_v1", async () => { + // The channel open itself fails (a provider startup fault, before any + // READY line or preface is possible). The host must fall back to the + // file bridge and record the typed open-failure reason, never hang. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-startup-fail-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + const base = createLocalSandboxRunner(); + let openCount = 0; + const openDuplexChannel = async (): Promise => { + openCount += 1; + throw new Error("provider could not start the sandbox process"); + }; + const runner = { ...base, openDuplexChannel }; + const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", - providerKey: "e2b", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-startup-fail", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + duplexObservabilityRecorder: recorder, + }); + try { + expect(bridge).not.toBeNull(); + expect(openCount).toBe(1); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); + const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); + expect(fallback?.dimensions.fallback_reason).toBe("channel_open_failed"); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_daytona_selects_http2_v1", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-select-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner, control } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", environmentId: "env-1", leaseId: "lease-1", remoteCwd, @@ -3231,7 +3424,7 @@ describe("sandbox adapter execution targets", () => { }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ - runId: "run-duplex", + runId: "run-http2", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", @@ -3242,45 +3435,30 @@ describe("sandbox adapter execution targets", () => { try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); // The host builds the origin from the port it assigned, never from a frame. expect(bridge?.env.PAPERCLIP_API_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); expect(bridge?.env.PAPERCLIP_API_KEY).not.toBe("real-run-jwt"); - // The gateway forwards one agent request as a request frame. The broker - // forwards it on the host path with the real token and the run id, then - // writes one response frame back. - control.emitData( - encodeDuplexFrame({ - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-1", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { authorization: "Bearer bridge-token" }, - body: "", - }), - ); - await waitForCondition( - () => control.written.length >= 1, - "the broker to write a duplex response frame", - 4000, - ); - expect(control.written[0]).toMatchObject({ - type: "response", - id: "req-1", - status: 200, - outcome: "completed", + // The sandbox gateway forwards one agent request as one real HTTP/2 + // stream, over the one session that runs directly on the sandbox + // channel. The host serves it with the real token and the run id. + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); + const response = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, }); + expect(response.status).toBe(200); expect(api.requests).toHaveLength(1); expect(api.requests[0]).toMatchObject({ method: "GET", url: "/api/agents/me", auth: "Bearer real-run-jwt", - runId: "run-duplex", + runId: "run-http2", }); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } @@ -3304,7 +3482,7 @@ describe("sandbox adapter execution targets", () => { // The flood is larger than the ceiling; the READY frame is far smaller. const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 4096 }); const { runner, control } = makeDuplexSelectionRunner((ctx) => { - ctx.emitFrame({ version: 1, type: "ready", nonce: ctx.nonce }); + ctx.emitFrame({ version: 2, type: "ready", nonce: ctx.nonce }); ctx.emitRaw("x".repeat(64 * 1024)); }); const { recorder, counters } = createRecordingDuplexRecorder(); @@ -3327,7 +3505,7 @@ describe("sandbox adapter execution targets", () => { hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { // The file bridge serves, not the duplex transport. @@ -3349,13 +3527,13 @@ describe("sandbox adapter execution targets", () => { } }, 20000); - it("streams run logs on the duplex path under the same gate and log line as the file path", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-runlog-")); + it("streams run logs on the http2 path under the same gate and log line as the file path", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-runlog-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner } = makeDuplexSelectionRunner(); + const { runner } = makeHttp2SelectionRunner(); const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = []; const target: AdapterSandboxExecutionTarget = { kind: "remote", @@ -3381,9 +3559,9 @@ describe("sandbox adapter execution targets", () => { }, }); try { - // The duplex transport served, and it still streams run logs with the same + // The http2 transport served, and it still streams run logs with the same // gate and the same log line as the file path. - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); expect(bridge?.runLogTail).toBeTruthy(); expect(combinedStream(logs, "stdout")).toContain("Sandbox run log streaming enabled"); const wrapped = bridge!.runLogTail!.create().wrapCommand("agent-cli", ["--message", "hello world"]); @@ -3395,13 +3573,13 @@ describe("sandbox adapter execution targets", () => { } }, 20000); - it("returns no run-log tail on the duplex path when streaming is opted out", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-runlog-off-")); + it("returns no run-log tail on the http2 path when streaming is opted out", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-runlog-off-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner } = makeDuplexSelectionRunner(); + const { runner } = makeHttp2SelectionRunner(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", @@ -3414,7 +3592,7 @@ describe("sandbox adapter execution targets", () => { }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ - runId: "run-duplex-log-off", + runId: "run-http2-log-off", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", @@ -3423,7 +3601,7 @@ describe("sandbox adapter execution targets", () => { enableSandboxDuplexBridge: true, }); try { - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); expect(bridge?.runLogTail ?? null).toBeNull(); } finally { await bridge?.stop(); @@ -3431,15 +3609,15 @@ describe("sandbox adapter execution targets", () => { } }, 20000); - it("routes duplex channel-open and fallback records to a recorder attached on the server seam", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-recorder-")); + it("routes http2 channel-open and fallback records to a recorder attached on the server seam", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-recorder-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const counters: DuplexTelemetryCounterRecord[] = []; - const recorder: DuplexTelemetryRecorder = { + const counters: DuplexObservabilityCounterRecord[] = []; + const recorder: DuplexObservabilityRecorder = { recordSpan() {}, incrementCounter(record) { counters.push(record); @@ -3447,7 +3625,7 @@ describe("sandbox adapter execution targets", () => { emitEvent() {}, }; - // A channel open reaches the recorder on the duplex success path. The host + // A channel open reaches the recorder on the http2 success path. The host // attaches the recorder to the sandbox target on the same seam as the // runner; the caller reads it with the accessor and passes it to the bridge. const openTarget: AdapterSandboxExecutionTarget = { @@ -3456,24 +3634,24 @@ describe("sandbox adapter execution targets", () => { providerKey: "daytona", remoteCwd, timeoutMs: 30_000, - runner: makeDuplexSelectionRunner().runner, + runner: makeHttp2SelectionRunner().runner, effectiveCapabilities: duplexCapabilities(true), - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }; const openBridge = await startAdapterExecutionTargetPaperclipBridge({ - runId: "run-duplex-open", + runId: "run-http2-open", target: openTarget, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, - duplexTelemetryRecorder: adapterExecutionTargetDuplexTelemetryRecorder(openTarget), + duplexObservabilityRecorder: adapterExecutionTargetDuplexObservabilityRecorder(openTarget), }); try { - expect(openBridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(openBridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); const open = counters.find((record) => record.metric === DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL); - expect(open?.dimensions.transport).toBe("duplex"); + expect(open?.dimensions.transport).toBe("http2"); expect(open?.dimensions.provider).toBe("daytona"); } finally { await openBridge?.stop(); @@ -3490,7 +3668,7 @@ describe("sandbox adapter execution targets", () => { timeoutMs: 30_000, runner: makeDuplexSelectionRunner().runner, effectiveCapabilities: duplexCapabilities(true), - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }; const fallbackBridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-duplex-fallback", @@ -3500,7 +3678,7 @@ describe("sandbox adapter execution targets", () => { hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: false, - duplexTelemetryRecorder: adapterExecutionTargetDuplexTelemetryRecorder(fallbackTarget), + duplexObservabilityRecorder: adapterExecutionTargetDuplexObservabilityRecorder(fallbackTarget), }); try { expect(fallbackBridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); @@ -3557,15 +3735,15 @@ describe("sandbox adapter execution targets", () => { { name: "a mismatched nonce", onOpen: (ctx: DuplexOpenContext) => - ctx.emitFrame({ version: 1, type: "ready", nonce: "00000000000000000000000000000000" }), + ctx.emitFrame({ version: 2, type: "ready", nonce: "00000000000000000000000000000000" }), }, { name: "an incomplete READY frame", - onOpen: (ctx: DuplexOpenContext) => ctx.emitRaw('{"version":1,"type":"ready"}\n'), + onOpen: (ctx: DuplexOpenContext) => ctx.emitRaw('{"version":2,"type":"ready"}\n'), }, { name: "protocol contamination before READY", - onOpen: (ctx: DuplexOpenContext) => ctx.emitFrame({ version: 1, type: "heartbeat" }), + onOpen: (ctx: DuplexOpenContext) => ctx.emitFrame({ version: 2, type: "heartbeat" }), }, { name: "a gateway bind failure with no READY frame", @@ -3619,12 +3797,12 @@ describe("sandbox adapter execution targets", () => { { name: "an attacker-owned numeric local port", buildReady: (nonce: string, attackerPort: number) => - `{"version":1,"type":"ready","nonce":"${nonce}","port":${attackerPort}}\n`, + `{"version":2,"type":"ready","nonce":"${nonce}","port":${attackerPort}}\n`, }, { name: "a channel-supplied host URL", buildReady: (nonce: string, attackerPort: number) => - `{"version":1,"type":"ready","nonce":"${nonce}","address":"http://127.0.0.1:${attackerPort}"}\n`, + `{"version":2,"type":"ready","nonce":"${nonce}","address":"http://127.0.0.1:${attackerPort}"}\n`, }, ])( "rejects a READY frame that carries $name and never sends the bridge token there", @@ -3696,17 +3874,23 @@ describe("sandbox adapter execution targets", () => { 20000, ); - it("answers an unlisted route 403 over the duplex path without forwarding it", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-403-")); + it("test_route_allowlist_header_cleanup_and_token_replacement_hold_on_http2", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-403-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", - providerKey: "e2b", + providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, @@ -3714,7 +3898,7 @@ describe("sandbox adapter execution targets", () => { }; const bridge = await startAdapterExecutionTargetPaperclipBridge({ - runId: "run-403", + runId: "run-http2-403", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", @@ -3723,28 +3907,39 @@ describe("sandbox adapter execution targets", () => { enableSandboxDuplexBridge: true, }); try { - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); - control.emitData( - encodeDuplexFrame({ - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-forbidden", - method: "POST", - path: "/api/secret-admin-route", - query: "", - headers: { authorization: "Bearer bridge-token", "content-type": "application/json" }, - body: JSON.stringify({ escalate: true }), - }), - ); - await waitForCondition( - () => control.written.length >= 1, - "the broker to write a 403 response frame", - 4000, - ); - expect(control.written[0]).toMatchObject({ type: "response", id: "req-forbidden", status: 403 }); - // The route allowlist rejected the request, so it never forwarded. + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); + + // An unlisted route answers 403 over the real HTTP/2 stream and never + // reaches the host API — the route allowlist holds on the http2 path. + const forbidden = await http2TestRequest(sessionRef.current!, { + method: "POST", + path: "/api/secret-admin-route", + headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" }, + body: JSON.stringify({ escalate: true }), + }); + expect(forbidden.status).toBe(403); expect(api.requests).toHaveLength(0); + + // An allowed route forwards with the real host token and the run id — + // the token replacement and the run-id injection hold on the http2 path. + // A header outside the allowlist (`x-not-allowed`) never reaches the host. + const allowed = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}`, "x-not-allowed": "should-be-dropped" }, + }); + expect(allowed.status).toBe(200); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]).toMatchObject({ + method: "GET", + url: "/api/agents/me", + auth: "Bearer real-run-jwt", + runId: "run-http2-403", + }); + expect(api.requests[0].headers["x-not-allowed"]).toBeUndefined(); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } @@ -3755,15 +3950,15 @@ describe("sandbox adapter execution targets", () => { // dimensions, and values. An optional `failEvery` flag makes every method throw, // so a test proves a telemetry failure never breaks the request path. function createRecordingDuplexRecorder(options: { failEvery?: boolean } = {}): { - recorder: DuplexTelemetryRecorder; - spans: DuplexTelemetrySpanRecord[]; - counters: DuplexTelemetryCounterRecord[]; - events: DuplexTelemetryEventRecord[]; + recorder: DuplexObservabilityRecorder; + spans: DuplexObservabilitySpanRecord[]; + counters: DuplexObservabilityCounterRecord[]; + events: DuplexObservabilityEventRecord[]; } { - const spans: DuplexTelemetrySpanRecord[] = []; - const counters: DuplexTelemetryCounterRecord[] = []; - const events: DuplexTelemetryEventRecord[] = []; - const recorder: DuplexTelemetryRecorder = { + const spans: DuplexObservabilitySpanRecord[] = []; + const counters: DuplexObservabilityCounterRecord[] = []; + const events: DuplexObservabilityEventRecord[] = []; + const recorder: DuplexObservabilityRecorder = { recordSpan(record) { if (options.failEvery) throw new Error("telemetry sink down"); spans.push(record); @@ -3782,7 +3977,7 @@ describe("sandbox adapter execution targets", () => { // Every dimension key a record carries must be one of the fixed keys. The set is // closed, so a new key never reaches a sink by accident. - function assertOnlyFixedDimensionKeys(dimensions: DuplexTelemetryDimensions | undefined): void { + function assertOnlyFixedDimensionKeys(dimensions: DuplexObservabilityDimensions | undefined): void { expect(dimensions).toBeDefined(); for (const key of Object.keys(dimensions ?? {})) { expect(DUPLEX_DIMENSION_KEYS).toContain(key as (typeof DUPLEX_DIMENSION_KEYS)[number]); @@ -3820,13 +4015,19 @@ describe("sandbox adapter execution targets", () => { ); }); - it("records a duplex request span with latency and the fixed dimension keys", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-obs-")); + it("records an http2 request span with latency and the fixed dimension keys", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-obs-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); const { recorder, spans, counters, events } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", @@ -3846,38 +4047,28 @@ describe("sandbox adapter execution targets", () => { hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); - control.emitData( - encodeDuplexFrame({ - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-obs", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { authorization: "Bearer bridge-token" }, - body: "", - }), - ); - await waitForCondition( - () => control.written.length >= 1, - "the broker to write a duplex response frame", - 4000, - ); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); + const response = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(200); // The channel-open surface: the span, the counter, and the transport event. const openSpan = spans.find((span) => span.name === DUPLEX_SPAN_CHANNEL_OPEN); expect(openSpan).toBeDefined(); - expect(openSpan?.dimensions).toMatchObject({ provider: "daytona", transport: "duplex", outcome: "ok" }); + expect(openSpan?.dimensions).toMatchObject({ provider: "daytona", transport: "http2", outcome: "ok" }); expect(counters.some((c) => c.metric === DUPLEX_COUNTER_CHANNEL_OPEN_TOTAL)).toBe(true); expect( events.some( (e) => e.name === DUPLEX_TRANSPORT_EVENT && - e.dimensions.transport === "duplex" && + e.dimensions.transport === "http2" && e.dimensions.outcome === "ok", ), ).toBe(true); @@ -3887,9 +4078,10 @@ describe("sandbox adapter execution targets", () => { expect(requestSpan).toBeDefined(); expect(typeof requestSpan?.latencyMs).toBe("number"); expect(requestSpan?.latencyMs).toBeGreaterThanOrEqual(0); - expect(requestSpan?.dimensions).toMatchObject({ provider: "daytona", transport: "duplex", outcome: "ok" }); + expect(requestSpan?.dimensions).toMatchObject({ provider: "daytona", transport: "http2", outcome: "ok" }); assertOnlyFixedDimensionKeys(requestSpan?.dimensions); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } @@ -3921,7 +4113,7 @@ describe("sandbox adapter execution targets", () => { hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); @@ -4000,7 +4192,7 @@ describe("sandbox adapter execution targets", () => { hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { // The channel never opened, so the host serves the file bridge. @@ -4030,12 +4222,20 @@ describe("sandbox adapter execution targets", () => { { name: "before any dispatch", dispatchFirst: false, expectedClass: "pre_dispatch" }, { name: "after a dispatch", dispatchFirst: true, expectedClass: "post_dispatch" }, ])("increments the loss counter with the loss class $name", async ({ dispatchFirst, expectedClass }) => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-loss-")); + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-loss-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + let emitExit: (() => void) | null = null; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + emitExit = ctx.emitExit; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", @@ -4055,53 +4255,50 @@ describe("sandbox adapter execution targets", () => { hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); if (dispatchFirst) { - control.emitData( - encodeDuplexFrame({ - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-loss", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { authorization: "Bearer bridge-token" }, - body: "", - }), - ); - await waitForCondition( - () => control.written.length >= 1, - "the broker to write a duplex response frame", - 4000, - ); + const response = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(200); } - // A malformed frame is a protocol failure. The broker records a terminal loss. - control.emitData("@@@ not a duplex frame @@@\n"); + // The pseudo-terminal channel exits. The host records a terminal loss. + emitExit!(); await waitForCondition( () => counters.some((c) => c.metric === DUPLEX_COUNTER_LOSS_TOTAL), - "the broker to record a loss counter", + "the host to record a loss counter", 4000, ); const loss = counters.find((c) => c.metric === DUPLEX_COUNTER_LOSS_TOTAL); expect(loss?.dimensions.loss_class).toBe(expectedClass); - expect(loss?.dimensions).toMatchObject({ transport: "duplex", outcome: "error" }); + expect(loss?.dimensions).toMatchObject({ transport: "http2", outcome: "error", loss_reason: "provider_exit" }); assertOnlyFixedDimensionKeys(loss?.dimensions); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("keeps serving the request path when the telemetry recorder throws", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-guard-")); + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-guard-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); const { recorder } = createRecordingDuplexRecorder({ failEvery: true }); const target: AdapterSandboxExecutionTarget = { kind: "remote", @@ -4121,39 +4318,36 @@ describe("sandbox adapter execution targets", () => { hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { - // The throwing recorder never blocked the duplex selection. - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); - control.emitData( - encodeDuplexFrame({ - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-guard", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { authorization: "Bearer bridge-token" }, - body: "", - }), - ); - await waitForCondition( - () => control.written.length >= 1, - "the broker to write a duplex response frame", - 4000, - ); + // The throwing recorder never blocked the http2 selection. + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); + const response = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); // The request path still delivered a real host response. - expect(control.written[0]).toMatchObject({ type: "response", id: "req-guard", status: 200 }); + expect(response.status).toBe(200); expect(api.requests).toHaveLength(1); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); - it("keeps sentinel route, query, body, tokens, and provider errors off the duplex telemetry and logs", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-redact-")); + it("keeps sentinel route, query, body, and tokens off the http2 telemetry and logs", async () => { + // `recordHttp2Loss` (execution-target.ts) never accepts a raw error + // message: it maps every host-observed HTTP/2 event to one fixed, closed + // `DuplexLossReason` value before any sink reads it (accepted security + // fix 7, `duplex-observability.test.ts` pins the closed map). A raw provider + // error string has no code path into a sink on the http2_v1 transport, so + // this test proves the property that does need a live run: the route, + // the query, the body, and both tokens never ride a sink either. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-redact-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); @@ -4162,47 +4356,16 @@ describe("sandbox adapter execution targets", () => { const ROUTE_SENTINEL = "sentinelroute8f21"; const QUERY_SENTINEL = "sentinelquery3d90"; const BODY_SENTINEL = "sentinelbodya17c"; - const BRIDGE_TOKEN_SENTINEL = "sentinelbridgetok55e2"; const AGENT_TOKEN_SENTINEL = "sentinelagenttoke91b4"; - const PROVIDER_ERROR_SENTINEL = "sentinelprovidererr7a3d"; - const sentinels = [ - ROUTE_SENTINEL, - QUERY_SENTINEL, - BODY_SENTINEL, - BRIDGE_TOKEN_SENTINEL, - AGENT_TOKEN_SENTINEL, - PROVIDER_ERROR_SENTINEL, - ]; + const sentinels = [ROUTE_SENTINEL, QUERY_SENTINEL, BODY_SENTINEL, AGENT_TOKEN_SENTINEL]; - // A runner whose channel throws a provider error on the response write, so the - // broker records a stream-failure loss carrying the sentinel message. - const base = createLocalSandboxRunner(); - const control = { emitData: (_chunk: string) => {} }; - const openDuplexChannel = async (openInput: { - command: readonly string[]; - }): Promise => { - const joined = openInput.command.join(" "); - const nonce = /PAPERCLIP_BRIDGE_NONCE='([^']*)'/.exec(joined)?.[1] ?? ""; - let dataListener: ((chunk: string) => void) | null = null; - const channel: CommandManagedDuplexChannel = { - write(_data: string): void { - // Every response write fails with a provider error carrying the sentinel. - throw new Error(`provider write failed: ${PROVIDER_ERROR_SENTINEL}`); - }, - onData(listener: (chunk: string) => void): void { - dataListener = listener; - control.emitData = (chunk) => dataListener?.(chunk); - setImmediate(() => dataListener?.(`${JSON.stringify({ version: 1, type: "ready", nonce })}\n`)); - }, - onExit(_listener: (exit: { exitCode: number | null }) => void): void {}, - stop(): void {}, - close(): Promise { - return Promise.resolve(); - }, - }; - return channel; - }; - const runner = { ...base, openDuplexChannel }; + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); const logLines: string[] = []; const { recorder, spans, counters, events } = createRecordingDuplexRecorder(); @@ -4228,57 +4391,64 @@ describe("sandbox adapter execution targets", () => { hostApiToken: AGENT_TOKEN_SENTINEL, hostApiUrl: api.origin, enableSandboxDuplexBridge: true, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, onLog: async (_stream, chunk) => { logLines.push(chunk); }, }); - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); - // A request that carries the sentinel route, query, body, and bridge token. - // The response write then fails with the sentinel provider error. - control.emitData( - encodeDuplexFrame({ - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-redact", - method: "GET", - path: `/api/${ROUTE_SENTINEL}`, - query: `secret=${QUERY_SENTINEL}`, - headers: { authorization: `Bearer ${BRIDGE_TOKEN_SENTINEL}` }, - body: BODY_SENTINEL, - }), - ); - // Give the forward and the failing response write time to run and record a loss. + // Dispatch one real HTTP/2 stream that carries the sentinel route, + // query, and body. The route allowlist accepts an arbitrary issue id + // segment, so the sentinel rides an allowed route. The real agent + // token (also a sentinel) never leaves the host. + const response = await http2TestRequest(sessionRef.current!, { + method: "POST", + path: `/api/issues/${ROUTE_SENTINEL}/comments?secret=${QUERY_SENTINEL}`, + headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" }, + body: JSON.stringify({ body: BODY_SENTINEL }), + }); + expect(response.status).toBe(200); await waitForCondition( - () => counters.some((c) => c.metric === DUPLEX_COUNTER_LOSS_TOTAL), - "the broker to record a loss counter after the failed write", + () => spans.some((s) => s.name === DUPLEX_SPAN_REQUEST), + "the host to record a request span", 4000, ); // Serialize every telemetry record and every log line, then assert that no - // sentinel reaches any of them on the duplex path. + // sentinel reaches any of them on the http2 path. const telemetryDump = JSON.stringify({ spans, counters, events }); const logDump = logLines.join(""); for (const sentinel of sentinels) { expect(telemetryDump).not.toContain(sentinel); expect(logDump).not.toContain(sentinel); } + // The real agent token replaced the bridge token on the forward; the + // recording API server saw only the real token, never the bridge token. + expect(api.requests[0]?.auth).toBe(`Bearer ${AGENT_TOKEN_SENTINEL}`); } finally { if (previousDebug === undefined) delete process.env.PAPERCLIP_BRIDGE_DEBUG; else process.env.PAPERCLIP_BRIDGE_DEBUG = previousDebug; + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); it("maps a sentinel provider key to the constant other across every sink", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-prov-")); + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-prov-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); const { recorder, spans, counters, events } = createRecordingDuplexRecorder(); const PROVIDER_SENTINEL = "sentinel-plugin-provider-key-9c2a"; const target: AdapterSandboxExecutionTarget = { @@ -4299,25 +4469,20 @@ describe("sandbox adapter execution targets", () => { hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); - control.emitData( - encodeDuplexFrame({ - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-prov", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { authorization: "Bearer bridge-token" }, - body: "", - }), - ); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); + const response = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(200); await waitForCondition( () => spans.some((s) => s.name === DUPLEX_SPAN_REQUEST), - "the broker to record a request span", + "the host to record a request span", 4000, ); @@ -4335,6 +4500,7 @@ describe("sandbox adapter execution targets", () => { const telemetryDump = JSON.stringify({ spans, counters, events }); expect(telemetryDump).not.toContain(PROVIDER_SENTINEL); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } @@ -4377,7 +4543,7 @@ describe("sandbox adapter execution targets", () => { // A long readiness timeout, so the buffer cap, not the timeout, drives the // failure. duplexReadinessTimeoutMs: 5_000, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); @@ -4435,7 +4601,7 @@ describe("sandbox adapter execution targets", () => { // A long readiness timeout, so the buffer cap, not the timeout, drives the // failure. duplexReadinessTimeoutMs: 5_000, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); @@ -4491,7 +4657,7 @@ describe("sandbox adapter execution targets", () => { // A long readiness timeout, so the aggregate ledger, not the timeout, drives // the failure. duplexReadinessTimeoutMs: 5_000, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); @@ -4512,22 +4678,23 @@ describe("sandbox adapter execution targets", () => { }, 20000); it("charges the pre-READY buffer against the injected host ledger and releases it when readiness passes", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-ready-ledger-ok-")); + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-ready-ledger-ok-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // The host stamps one process-owned aggregate byte ledger on the sandbox // target at a generous ceiling. The fake gateway sends one pre-READY noise - // line, then the valid READY frame. The gate charges the noise bytes against - // the injected ledger, passes readiness, and releases the pre-READY tokens. - // The broker's frame decoder re-charges any post-READY bytes, so the ledger - // returns to zero after the handshake. + // line, then the valid READY frame, then a real HTTP/2 client preface. The + // gate charges the noise bytes against the injected ledger, passes + // readiness, and releases the pre-READY tokens. The ledger returns to zero + // once the post-READY replay hands off to the bound HTTP/2 channel. const ledger = new DuplexAggregateByteLedger({ ceilingBytes: 1024 * 1024 }); const reserveSpy = vi.spyOn(ledger, "reserve"); - const { runner, control } = makeDuplexSelectionRunner((ctx) => { + const { runner, control } = makeHttp2SelectionRunner((ctx) => { ctx.emitRaw("pty-echo-noise"); - ctx.emitFrame({ version: 1, type: "ready", nonce: ctx.nonce }); + ctx.emitReady(); + ctx.connectHttp2(); }); const target: AdapterSandboxExecutionTarget = { kind: "remote", @@ -4552,8 +4719,8 @@ describe("sandbox adapter execution targets", () => { }); try { expect(bridge).not.toBeNull(); - // Readiness passed, so the duplex transport serves. - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + // Readiness passed, so the http2 transport serves. + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); // The gate charged the pre-READY noise against the exact injected ledger, so // the identity holds at this seam. expect(reserveSpy).toHaveBeenCalledWith("readiness_buffer", expect.any(Number)); @@ -4613,7 +4780,7 @@ describe("sandbox adapter execution targets", () => { hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 5_000, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); @@ -4629,6 +4796,64 @@ describe("sandbox adapter execution targets", () => { } }, 20000); + it("bounds the pre-READY buffer growth-copy work by the bytes received", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-growth-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + // An adversarial provider sends many small newline-less fragments before the + // cap fires. A one-copy-per-fragment append copies the whole retained buffer + // on every fragment, so the total copy work is quadratic in the number of + // fragments. The gate must instead grow its backing storage by doubling, so + // the total copy work stays linear in the bytes received. + const readinessBufferCapBytes = DEFAULT_MAX_DUPLEX_FRAME_BYTES + 4_096; + const smallChunk = "x".repeat(64); + const chunkCount = Math.ceil(readinessBufferCapBytes / smallChunk.length) + 1; + const totalBytes = chunkCount * smallChunk.length; + const { runner, control } = makeDuplexSelectionRunner((ctx) => { + for (let i = 0; i < chunkCount; i += 1) { + ctx.emitRaw(smallChunk); + } + }); + const { recorder } = createRecordingDuplexRecorder(); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + __duplexReadinessTesting.resetBufferGrowthCopyUnits(); + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-growth-bound", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + duplexReadinessTimeoutMs: 5_000, + duplexObservabilityRecorder: recorder, + }); + try { + expect(bridge).not.toBeNull(); + expect(control.openCount).toBe(1); + const copyUnits = __duplexReadinessTesting.readBufferGrowthCopyUnits(); + // Doubling growth copies a logarithmic number of times, each at most the + // current buffer length, so the total stays within a small multiple of + // totalBytes. A per-fragment full-buffer copy is quadratic (about + // totalBytes^2 / (2 * chunkSize)), far above this bound. + expect(copyUnits).toBeLessThanOrEqual(4 * totalBytes); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + it("bounds the pre-READY skip scan work by the bytes received", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-blank-")); cleanupDirs.push(rootDir); @@ -4642,11 +4867,12 @@ describe("sandbox adapter execution targets", () => { // work quadratic. The READY frame then settles the gate ready. const blankLineCount = 15_000; const noisePrefix = "\n".repeat(blankLineCount) + "a non-frame echo line\n"; - const { runner, control } = makeDuplexSelectionRunner((ctx) => { + const { runner, control } = makeHttp2SelectionRunner((ctx) => { ctx.emitRaw(noisePrefix); - ctx.emitFrame({ version: 1, type: "ready", nonce: ctx.nonce }); + ctx.emitReady(); + ctx.connectHttp2(); }); - const readyLine = '{"version":1,"type":"ready","nonce":""}\n'; + const readyLine = '{"version":2,"type":"ready","nonce":""}\n'; const totalBytes = noisePrefix.length + readyLine.length; const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { @@ -4669,7 +4895,7 @@ describe("sandbox adapter execution targets", () => { hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 5_000, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); @@ -4679,9 +4905,9 @@ describe("sandbox adapter execution targets", () => { // near totalBytes. A per-line full rescan is quadratic (about // blankLineCount^2 / 2), far above this bound. expect(scanUnits).toBeLessThanOrEqual(4 * totalBytes); - // The gate skipped the noise and accepted the READY frame, so the duplex + // The gate skipped the noise and accepted the READY frame, so the http2 // transport serves and no fallback fired. - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback).toBeUndefined(); } finally { @@ -4690,19 +4916,24 @@ describe("sandbox adapter execution targets", () => { } }, 20000); - it("skips pre-READY noise lines, then accepts a valid READY frame and serves the duplex transport", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-noise-")); + it("test_a_prologue_of_any_length_before_the_ready_line_is_discarded", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-noise-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); // A PTY channel echoes the launch wrapper line before it sets raw mode, so the // first line the host reads is a non-frame echo, not the READY frame. The gate - // must skip the echo line and a partial-JSON line, then accept the READY frame. - const { runner, control } = makeDuplexSelectionRunner((ctx) => { + // must skip the echo line and a partial-JSON line, then accept the READY + // frame — no matter how long the prologue is, and with no length held anywhere + // in the code. The preface scan then starts only on the bytes the gate + // retained after that accepted line. + const { runner, control } = makeHttp2SelectionRunner((ctx) => { ctx.emitRaw("sh -c exec env PAPERCLIP_BRIDGE_NONCE=... node gateway.mjs\n"); - ctx.emitRaw('{"version":1,"type":"ready"}\n'); - ctx.emitFrame({ version: 1, type: "ready", nonce: ctx.nonce }); + ctx.emitRaw('{"version":2,"type":"ready"}\n'); + ctx.emitRaw("x".repeat(50_000)); // an arbitrarily long prologue, no fixed length + ctx.emitReady(); + ctx.connectHttp2(); }); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { @@ -4724,14 +4955,15 @@ describe("sandbox adapter execution targets", () => { hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 5_000, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); - // The gate skipped the echo and the partial frame, then accepted the READY - // frame, so the duplex transport serves and no fallback fired. - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); + // The gate skipped the echo, the partial frame, and the long prologue, + // then accepted the READY frame, so the http2 transport serves and no + // fallback fired. + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); expect(fallback).toBeUndefined(); } finally { @@ -4752,7 +4984,7 @@ describe("sandbox adapter execution targets", () => { // the `ready_nonce_mismatch` reason. const { runner, control } = makeDuplexSelectionRunner((ctx) => { ctx.emitRaw("a non-frame echo line\n"); - ctx.emitFrame({ version: 1, type: "ready", nonce: "00000000000000000000000000000000" }); + ctx.emitFrame({ version: 2, type: "ready", nonce: "00000000000000000000000000000000" }); }); const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { @@ -4774,7 +5006,7 @@ describe("sandbox adapter execution targets", () => { hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 5_000, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); @@ -4808,7 +5040,7 @@ describe("sandbox adapter execution targets", () => { // per-skip cap check stops the bypass. const readinessBufferCapBytes = DEFAULT_MAX_DUPLEX_FRAME_BYTES + 4_096; const { runner, control } = makeDuplexSelectionRunner((ctx) => { - const readyLine = `${JSON.stringify({ version: 1, type: "ready", nonce: ctx.nonce })}\n`; + const readyLine = `${JSON.stringify({ version: 2, type: "ready", nonce: ctx.nonce })}\n`; ctx.emitRaw("\n".repeat(readinessBufferCapBytes + 1) + readyLine); }); const { recorder, counters } = createRecordingDuplexRecorder(); @@ -4832,7 +5064,7 @@ describe("sandbox adapter execution targets", () => { enableSandboxDuplexBridge: true, // A long readiness timeout, so the cap, not the timeout, drives the failure. duplexReadinessTimeoutMs: 5_000, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); @@ -4860,7 +5092,7 @@ describe("sandbox adapter execution targets", () => { // carries the closed `fallback_reason` dimension, so a reader can group the // failed opens by reason. const { runner } = makeDuplexSelectionRunner((ctx) => - ctx.emitFrame({ version: 1, type: "ready", nonce: "00000000000000000000000000000000" }), + ctx.emitFrame({ version: 2, type: "ready", nonce: "00000000000000000000000000000000" }), ); const { recorder, spans } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { @@ -4882,7 +5114,7 @@ describe("sandbox adapter execution targets", () => { hostApiUrl: api.origin, enableSandboxDuplexBridge: true, duplexReadinessTimeoutMs: 5_000, - duplexTelemetryRecorder: recorder, + duplexObservabilityRecorder: recorder, }); try { expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); @@ -4890,7 +5122,7 @@ describe("sandbox adapter execution targets", () => { expect(openSpan).toBeDefined(); expect(openSpan?.dimensions).toMatchObject({ provider: "daytona", - transport: "duplex", + transport: "http2", outcome: "error", fallback_reason: "ready_nonce_mismatch", }); @@ -4902,17 +5134,23 @@ describe("sandbox adapter execution targets", () => { } }, 20000); - it("drops a frame header outside the allowlist on the host duplex forward path", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-hdr-")); + it("drops a header outside the allowlist on the host http2 forward path", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-hdr-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", - providerKey: "e2b", + providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, @@ -4929,31 +5167,21 @@ describe("sandbox adapter execution targets", () => { enableSandboxDuplexBridge: true, }); try { - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); - control.emitData( - encodeDuplexFrame({ - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-hdr", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { - // An allowlisted header the host must keep. - accept: "application/json", - // A header outside the allowlist the host must drop. - "x-injected-header": "attacker", - // A sandbox-supplied auth header the host must replace with the real token. - authorization: "Bearer bridge-token", - }, - body: "", - }), - ); - await waitForCondition( - () => api.requests.length >= 1, - "the broker to forward the duplex request", - 4000, - ); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); + const response = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { + authorization: `Bearer ${bridgeToken}`, + // An allowlisted header the host must keep. + accept: "application/json", + // A header outside the allowlist the host must drop. + "x-injected-header": "attacker", + }, + }); + expect(response.status).toBe(200); + await waitForCondition(() => api.requests.length >= 1, "the host to forward the http2 request", 4000); const forwarded = api.requests[0]; // The allowlisted header reaches the host. expect(forwarded.headers.accept).toBe("application/json"); @@ -4963,30 +5191,39 @@ describe("sandbox adapter execution targets", () => { expect(forwarded.auth).toBe("Bearer real-run-jwt"); expect(forwarded.runId).toBe("run-hdr"); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); - it("selects the duplex transport for a large forward budget and starts the broker", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-budget-")); + it("selects the http2 transport for a large forward budget", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-budget-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", - providerKey: "e2b", + providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; - // A forward budget past the default response budget (32 s). Before the budget - // derivation, this made the nested budget assertion throw and leak the channel. + // A forward budget past the default response budget (32 s). The http2 path + // holds no nested-budget derivation (that budget set belonged to the retired + // duplex_v1 broker only), so a large forward budget must still select and + // serve normally. const bridge = await startAdapterExecutionTargetPaperclipBridge({ runId: "run-budget", target, @@ -4998,67 +5235,67 @@ describe("sandbox adapter execution targets", () => { forwardTimeoutMs: 60_000, }); try { - // The broker started with derived nested budgets, so the duplex transport serves. - expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("duplex_v1"); - control.emitData( - encodeDuplexFrame({ - version: DUPLEX_FRAME_VERSION, - type: "request", - id: "req-budget", - method: "GET", - path: "/api/agents/me", - query: "", - headers: { authorization: "Bearer bridge-token" }, - body: "", - }), - ); - await waitForCondition( - () => control.written.length >= 1, - "the broker to write a duplex response frame", - 4000, - ); - expect(control.written[0]).toMatchObject({ type: "response", id: "req-budget", status: 200 }); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); + const response = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(200); } finally { + sessionRef.current?.close(); await bridge?.stop(); await api.close(); } }, 20000); - it("falls back to the file bridge when the broker construction throws", async () => { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-ctor-")); + it("test_a_missing_preface_aborts_the_open_and_falls_back_to_queue_v1", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-no-preface-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(); + // The gateway sends a valid READY line, but no HTTP/2 client preface ever + // follows (a suppressed or a stalled sandbox client). Readiness passes, + // then the bounded preface scan finds nothing before its bound; the host + // must abort the open and select the file bridge instead of hanging or + // leaking the channel. + const { runner, control } = makeHttp2SelectionRunner((ctx) => { + ctx.emitReady(); + // No connectHttp2() call: the client preface never arrives. + }); + const { recorder, counters } = createRecordingDuplexRecorder(); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", - providerKey: "e2b", + providerKey: "daytona", remoteCwd, timeoutMs: 30_000, runner, effectiveCapabilities: duplexCapabilities(true), }; - // A non-finite forward budget makes the nested budget assertion throw at - // broker construction. Readiness passes first, so the guarded region must - // close the channel and select the file bridge instead of leaking it. const bridge = await startAdapterExecutionTargetPaperclipBridge({ - runId: "run-ctor", + runId: "run-no-preface", target, runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), adapterKey: "codex", hostApiToken: "real-run-jwt", hostApiUrl: api.origin, enableSandboxDuplexBridge: true, - forwardTimeoutMs: Number.NaN, + // A short readiness timeout, so the preface-missing bound (which reapplies + // this same value) fires quickly in the test. + duplexReadinessTimeoutMs: 500, + duplexObservabilityRecorder: recorder, }); try { expect(bridge).not.toBeNull(); expect(control.openCount).toBe(1); - // Readiness passed, then the broker construction threw; the file bridge serves. + // The missing preface aborted the open; the file bridge serves. expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); + const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); + expect(fallback?.dimensions.fallback_reason).toBe("preface_missing"); // The bounded cleanup left no live provider session. expect(control.closeCount + control.stopCount).toBeGreaterThanOrEqual(1); } finally { @@ -5067,6 +5304,398 @@ describe("sandbox adapter execution targets", () => { } }, 20000); + it("test_disabled_flag_selects_queue_v1", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-disabled-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const { runner, control } = makeHttp2SelectionRunner(); + const { recorder, counters } = createRecordingDuplexRecorder(); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + // The kill switch is off. The host must never open the channel at all. + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-disabled", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: false, + duplexObservabilityRecorder: recorder, + }); + try { + expect(bridge).not.toBeNull(); + expect(control.openCount).toBe(0); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); + const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); + expect(fallback?.dimensions.fallback_reason).toBe("gate_off"); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_fallback_to_queue_v1_happens_at_most_once_per_run", async () => { + // The open attempt runs once per run, with no retry loop: a preface + // failure falls through to the file bridge exactly one time, and the + // host never re-attempts http2_v1 afterward in the same run. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-one-way-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const { runner, control } = makeHttp2SelectionRunner((ctx) => { + ctx.emitReady(); + // No connectHttp2() call: the preface never arrives, so the open fails. + }); + const { recorder, counters } = createRecordingDuplexRecorder(); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-one-way", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + duplexReadinessTimeoutMs: 500, + duplexObservabilityRecorder: recorder, + }); + try { + expect(bridge).not.toBeNull(); + // The host opened the channel exactly once for the whole run — no retry + // loop re-attempted http2_v1 after the fallback. + expect(control.openCount).toBe(1); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); + // Exactly one fallback record — the transition never repeats. + const fallbacks = counters.filter((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL); + expect(fallbacks).toHaveLength(1); + expect(fallbacks[0]?.dimensions.fallback_reason).toBe("preface_missing"); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_the_host_writes_no_byte_before_the_ready_line_is_accepted", async () => { + // The launch wrapper gives the gateway no start acknowledgment (accepted + // fact from PAP-5065): a host byte written before the READY line is + // accepted can reach the shell instead of the child. This test proves + // the host writes nothing to the channel until well after it accepted + // READY: it holds the channel open, with no client preface arriving + // (so the http2 server, if bound, would try to write its own SETTINGS + // frame), and asserts zero bytes crossed the channel the whole time. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-no-early-write-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const base = createLocalSandboxRunner(); + const hostWrites: Buffer[] = []; + let readyLineSent = false; + const readySentAtMs = { value: 0 }; + const openDuplexChannel = async (openInput: { + command: readonly string[]; + }): Promise => { + const joined = openInput.command.join(" "); + const nonce = /PAPERCLIP_BRIDGE_NONCE='([^']*)'/.exec(joined)?.[1] ?? ""; + let dataListener: ((chunk: Uint8Array) => void) | null = null; + const channel: CommandManagedDuplexChannel = { + write: (data: Uint8Array) => { + // Any host write before READY is accepted is exactly the fault + // this test guards against. + hostWrites.push(Buffer.from(data)); + }, + onData: (listener: (chunk: Uint8Array) => void) => { + dataListener = listener; + setImmediate(() => { + readySentAtMs.value = Date.now(); + readyLineSent = true; + dataListener?.( + new TextEncoder().encode(`${JSON.stringify({ version: 2, type: "ready", nonce })}\n`), + ); + // No client preface follows: the host must still write nothing + // while it waits out the preface bound, proving the READY-accept + // gate — not a timer — is what would unlock a host write. + }); + }, + onExit: (_listener: (exit: { exitCode: number | null }) => void) => {}, + stop: () => {}, + close: async () => Promise.resolve(), + }; + return channel; + }; + const runner = { ...base, openDuplexChannel }; + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-no-early-write", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + duplexReadinessTimeoutMs: 300, + }); + try { + expect(readyLineSent).toBe(true); + // The preface never arrived, so the open fell back to the file bridge — + // and across the whole open attempt, including the wait after READY, + // the host wrote zero bytes to the channel. + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); + expect(hostWrites).toHaveLength(0); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_invalid_ready_line_bytes_do_not_reach_a_log_line", async () => { + // Fix 5: decode only the bounded READY line as strict protocol text, and + // never log raw channel bytes. This test sends a pre-READY line whose + // bytes are deliberately sentinel-marked and syntactically invalid (not + // valid UTF-8 JSON), then a valid READY frame. No log line — on any + // stream — may contain the sentinel bytes. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-invalid-ready-bytes-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const INVALID_LINE_SENTINEL = "sentinelinvalidreadyline62fa"; + const logLines: string[] = []; + const { runner } = makeHttp2SelectionRunner((ctx) => { + // Invalid bytes: not valid UTF-8 (a lone continuation byte), immediately + // followed by a sentinel and a newline — an invalid candidate line. + ctx.emitRaw(Buffer.concat([Buffer.from([0x80]), Buffer.from(INVALID_LINE_SENTINEL), Buffer.from("\n")])); + ctx.emitReady(); + ctx.connectHttp2(); + }); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-invalid-ready-bytes", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + onLog: async (_stream, chunk) => { + logLines.push(chunk); + }, + }); + try { + // The invalid line was skipped as noise, and READY still passed. + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + const logDump = logLines.join(""); + expect(logDump).not.toContain(INVALID_LINE_SENTINEL); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_close_after_orderly_completion_keeps_the_run_result", async () => { + // A loss ordered after a host-observed orderly completion is a normal + // teardown, not a failure: the run already completed. The disposition + // latch must keep the success and emit no loss event for it. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-orderly-close-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + let emitExit: (() => void) | null = null; + const { runner } = makeHttp2SelectionRunner((ctx) => { + emitExit = ctx.emitExit; + ctx.emitReady(); + ctx.connectHttp2(); + }); + const { recorder, events, counters } = createRecordingDuplexRecorder(); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-orderly-close", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + duplexObservabilityRecorder: recorder, + }); + try { + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + // The agent turn completes cleanly before the channel ends. + expect(bridge?.settleRunDisposition?.()).toEqual({ failed: false, lossReason: null }); + emitExit!(); + await new Promise((resolve) => setTimeout(resolve, 100)); + // The disposition still reports success after the teardown loss. + expect(bridge?.readRunDisposition?.()).toEqual({ failed: false, lossReason: null }); + // A normal teardown after completion is not a loss: no loss event, no + // loss counter. + expect(events.some((e) => e.dimensions.loss_reason !== undefined)).toBe(false); + expect(counters.some((c) => c.metric === DUPLEX_COUNTER_LOSS_TOTAL)).toBe(false); + } finally { + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_safe_request_during_channel_loss_stays_retryable", async () => { + // A GET never changes host state, so a response-body read failure stays + // retryable: the host answers 502 with no indeterminate marker, the same + // rule `forwardBridgeRequest` already applies on every transport. This + // proves the http2_v1 forward handler reuses that one function unchanged. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-safe-retry-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-safe-retry", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + // A ceiling far under any real response, so every response-body read + // fails the size check deterministically. + maxBodyBytes: 1, + }); + try { + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); + const response = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(502); + expect(response.headers["x-paperclip-bridge-outcome"]).toBeUndefined(); + } finally { + sessionRef.current?.close(); + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_unsafe_request_during_channel_loss_is_indeterminate", async () => { + // A POST may have committed on the host before the response-body read + // failed, so a retry could double-apply it. The host answers a + // non-retryable 504 with the indeterminate marker instead — the same + // rule `forwardBridgeRequest` already applies on every transport. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-unsafe-indeterminate-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-unsafe-indeterminate", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + maxBodyBytes: 1, + }); + try { + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); + const response = await http2TestRequest(sessionRef.current!, { + method: "POST", + path: "/api/issues/issue-1/comments", + headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" }, + body: JSON.stringify({ body: "hello" }), + }); + expect(response.status).toBe(504); + expect(response.headers["x-paperclip-bridge-outcome"]).toBe("indeterminate"); + } finally { + sessionRef.current?.close(); + await bridge?.stop(); + await api.close(); + } + }, 20000); + // --------------------------------------------------------------------------- // Real-PTY replay. // @@ -5077,13 +5706,13 @@ describe("sandbox adapter execution targets", () => { // rather than against synthetic frames. // --------------------------------------------------------------------------- - async function runReadinessReplay(emit: (ctx: DuplexOpenContext) => void) { + async function runReadinessReplay(emit: (ctx: Http2OpenContext) => void) { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pty-replay-")); cleanupDirs.push(rootDir); const remoteCwd = path.join(rootDir, "workspace"); await mkdir(remoteCwd, { recursive: true }); const api = await startRecordingApiServer(); - const { runner, control } = makeDuplexSelectionRunner(emit); + const { runner, control } = makeHttp2SelectionRunner(emit); const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", @@ -5120,9 +5749,10 @@ describe("sandbox adapter execution targets", () => { "daytona@212487a7f3c9:~$ exec 2>'/tmp/paperclip-duplex-x.log'; stty raw -echo; " + "exec 'bash' '-c' 'exec env PAPERCLIP_BRIDGE_NONCE=" + ctx.nonce + " node gateway.mjs'\r\n", ); - ctx.emitRaw('{"version":1,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); + ctx.emitRaw('{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); + ctx.connectHttp2(); }); - expect(mode).toBe("duplex_v1"); + expect(mode).toBe("http2_v1"); }, 20000); // Case 2: captured from a local pty.fork() + bash on a host whose bash enables @@ -5137,20 +5767,22 @@ describe("sandbox adapter execution targets", () => { "stty raw -echo; exec 'bash' '-c' 'exec env node gateway.mjs'\r\n", ); // No newline between the escape sequence and the frame: same line. - ctx.emitRaw('\x1b[?2004l\r{"version":1,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); + ctx.emitRaw('\x1b[?2004l\r{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); + ctx.connectHttp2(); }); - expect(mode).toBe("duplex_v1"); + expect(mode).toBe("http2_v1"); }, 20000); // Case 3: the READY frame split across two chunk deliveries. it("PTY replay: accepts READY split across chunk boundaries", async () => { const { mode } = await runReadinessReplay((ctx) => { ctx.emitRaw("prompt$ wrapper-line\r\n"); - const frame = '{"version":1,"type":"ready","nonce":"' + ctx.nonce + '"}\n'; + const frame = '{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'; ctx.emitRaw(frame.slice(0, 12)); ctx.emitRaw(frame.slice(12)); + ctx.connectHttp2(); }); - expect(mode).toBe("duplex_v1"); + expect(mode).toBe("http2_v1"); }, 20000); // Case 4: a multibyte character split across two chunks in the pre-READY noise. @@ -5159,9 +5791,122 @@ describe("sandbox adapter execution targets", () => { const noise = Buffer.from("prompt ✓ done\r\n", "utf8"); ctx.emitRaw(noise.slice(0, 8).toString("utf8")); ctx.emitRaw(noise.slice(8).toString("utf8")); - ctx.emitRaw('{"version":1,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); + ctx.emitRaw('{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); + ctx.connectHttp2(); }); - expect(mode).toBe("duplex_v1"); + expect(mode).toBe("http2_v1"); + }, 20000); + + it("test_the_session_starts_at_the_client_preface_after_the_ready_line", async () => { + // The gate retains every byte after the accepted READY line, and the host + // starts the HTTP/2 session at the client preface offset inside that + // retained suffix, inclusive — no byte before the preface reaches the + // HTTP/2 server. A pre-preface byte would make the server report a + // `PROTOCOL_ERROR`; this test proves the real session opens cleanly + // instead, which only holds when the offset is exact. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-preface-offset-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + // The READY line and the client preface arrive in the same chunk, back + // to back, with no gap — the exact production shape. + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-preface-offset", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + }); + try { + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); + const response = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(200); + } finally { + sessionRef.current?.close(); + await bridge?.stop(); + await api.close(); + } + }, 20000); + + it("test_a_preface_pattern_before_the_ready_line_does_not_start_a_session", async () => { + // The scan window opens only after the gate accepts the READY line. A + // banner byte can carry the same 24 octets before that line; a scan that + // started earlier could match those bytes and start a session against + // the shell. This test embeds the exact preface bytes in the pre-READY + // noise, then proves a session still starts only at the REAL preface + // that follows READY. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-preface-lookalike-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + await mkdir(remoteCwd, { recursive: true }); + const api = await startRecordingApiServer(); + const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null }; + let bridgeToken = ""; + const { runner, control } = makeHttp2SelectionRunner((ctx) => { + bridgeToken = ctx.bridgeToken; + // A pre-READY banner that happens to carry the literal preface bytes. + ctx.emitRaw(Buffer.concat([Buffer.from("prompt$ "), HTTP2_TEST_CLIENT_PREFACE, Buffer.from("\r\n")])); + ctx.emitReady(); + sessionRef.current = ctx.connectHttp2(); + }); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "daytona", + remoteCwd, + timeoutMs: 30_000, + runner, + effectiveCapabilities: duplexCapabilities(true), + }; + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-preface-lookalike", + target, + runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"), + adapterKey: "codex", + hostApiToken: "real-run-jwt", + hostApiUrl: api.origin, + enableSandboxDuplexBridge: true, + }); + try { + expect(bridge).not.toBeNull(); + expect(control.openCount).toBe(1); + expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1"); + await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000); + const response = await http2TestRequest(sessionRef.current!, { + method: "GET", + path: "/api/agents/me", + headers: { authorization: `Bearer ${bridgeToken}` }, + }); + expect(response.status).toBe(200); + } finally { + sessionRef.current?.close(); + await bridge?.stop(); + await api.close(); + } }, 20000); // Case 5: a flood of short noise lines must fail closed at the cap rather than @@ -5170,28 +5915,13 @@ describe("sandbox adapter execution targets", () => { const { mode } = await runReadinessReplay((ctx) => { const line = "x".repeat(64) + "\n"; for (let i = 0; i < 80_000; i += 1) ctx.emitRaw(line); - ctx.emitRaw('{"version":1,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); + ctx.emitRaw('{"version":2,"type":"ready","nonce":"' + ctx.nonce + '"}\n'); }); expect(mode).toBe("queue_v1"); }, 30000); }); -// One decoded stdout frame from the generated duplex gateway. The gateway writes -// newline-delimited JSON frames to stdout, so the test parses each line. -interface DecodedGatewayFrame { - version?: number; - type?: string; - id?: string; - method?: string; - path?: string; - query?: string; - headers?: Record; - body?: string; - nonce?: string; - __unparsed?: string; -} - // One decode result from the embedded codec. The shape mirrors the host codec: // a valid frame or a protocol error with a code. interface EmbeddedDecodeResult { @@ -5248,220 +5978,12 @@ interface DuplexFrameFixture { encodeVectors: DuplexEncodeVector[]; } -describe("sandbox duplex gateway", () => { - const duplexCleanupDirs: string[] = []; - const duplexChildren: Array> = []; - - afterEach(async () => { - while (duplexChildren.length > 0) { - const child = duplexChildren.pop(); - if (!child) continue; - child.kill("SIGKILL"); - } - while (duplexCleanupDirs.length > 0) { - const dir = duplexCleanupDirs.pop(); - if (!dir) continue; - await rm(dir, { recursive: true, force: true }).catch(() => undefined); - } - }); - - it("launches the gateway with `exec env`, so a POSIX shell runs the environment-assignment prefix", async () => { - // A POSIX shell accepts an environment-assignment prefix only on a plain - // command, never on `exec`. The form `exec NAME=value command` exits with - // status 127, so the gateway never starts. The launch argv must use - // `exec env NAME=value command`. This test runs the generated script in a real - // `sh` against a stub entrypoint that echoes one launch env var, so it fails on - // the old `exec NAME=value` form and passes on the `exec env` form. - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-launch-")); - duplexCleanupDirs.push(rootDir); - const stub = path.join(rootDir, "stub-entrypoint.sh"); - // The stub stands in for the node gateway. It prints a READY frame that echoes - // the launch nonce, so the test proves the environment assignment reached the - // process the launch replaced the shell with. - await writeFile( - stub, - '#!/bin/sh\nprintf \'{"version":1,"type":"ready","nonce":"%s"}\\n\' "$PAPERCLIP_BRIDGE_NONCE"\n', - "utf8", - ); - const argv = buildDuplexGatewayLaunchArgv({ - shellCommand: "sh", - remoteEntrypoint: stub, - // Run the stub with `sh`, so the test needs no node runtime. The launch form - // is `exec env NAME=value 'sh' ''`, which exercises the exec-env fix. - nodeCommand: "sh", - env: { PAPERCLIP_BRIDGE_NONCE: "abc123def456", PAPERCLIP_BRIDGE_PORT: "40404" }, - }); - const [shell, ...shellArgs] = argv; - const { stdout } = await execFileAsync(shell, shellArgs, { encoding: "utf8" }); - const decoded = decodeDuplexLine(stdout.trim()); - expect(decoded.ok).toBe(true); - if (decoded.ok) { - expect(decoded.frame.type).toBe("ready"); - if (decoded.frame.type === "ready") { - expect(decoded.frame.nonce).toBe("abc123def456"); - } - } - }); - - interface DuplexGatewayHandle { - baseUrl: string; - frames: DecodedGatewayFrame[]; - stderr: () => string; - waitForFrame: ( - predicate: (frame: DecodedGatewayFrame) => boolean, - timeoutMs?: number, - ) => Promise; - sendFrame: (frame: Record) => void; - sendRaw: (text: string) => void; - endStdin: () => void; - exited: Promise; - stop: () => Promise; - } - - // Reserve a free loopback port. The host assigns a positive port to the duplex - // gateway; the gateway binds exactly that port. The test opens an ephemeral - // listener, reads its port, then closes it, so the number is very likely free - // when the gateway binds it a moment later. - async function reserveLoopbackPort(): Promise { - return new Promise((resolve, reject) => { - const probe = net.createServer(); - probe.once("error", reject); - probe.listen(0, "127.0.0.1", () => { - const address = probe.address(); - if (!address || typeof address === "string") { - probe.close(() => reject(new Error("Could not reserve a loopback port."))); - return; - } - const reserved = address.port; - probe.close(() => resolve(reserved)); - }); - }); - } - - // Start the generated gateway `.mjs` in duplex mode as a real child process. - // The test writes response frames to the child stdin and reads request frames - // from the child stdout, so it stands in for the host side of the channel. - // The host assigns the port and the nonce; the test builds the base URL from - // the assigned port, never from the READY frame. - async function startDuplexGateway( - env: Record, - options: { port?: number; nonce?: string } = {}, - ): Promise { - const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-duplex-gateway-")); - duplexCleanupDirs.push(rootDir); - const entrypoint = path.join(rootDir, "gateway.mjs"); - await writeFile(entrypoint, getSandboxCallbackBridgeServerSource(), "utf8"); - - const assignedPort = options.port ?? (await reserveLoopbackPort()); - const nonce = options.nonce ?? "d0c1b2a3e4f5061728394a5b6c7d8e9f"; - const child = spawn(process.execPath, [entrypoint], { - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - PAPERCLIP_API_BRIDGE_MODE: "duplex_v1", - PAPERCLIP_BRIDGE_HOST: "127.0.0.1", - PAPERCLIP_BRIDGE_PORT: String(assignedPort), - PAPERCLIP_BRIDGE_NONCE: nonce, - ...env, - }, - }); - duplexChildren.push(child); - - const frames: DecodedGatewayFrame[] = []; - const waiters: Array<{ - predicate: (frame: DecodedGatewayFrame) => boolean; - resolve: (frame: DecodedGatewayFrame) => void; - }> = []; - let stdoutBuffer = ""; - let stderrText = ""; - - child.stdout?.setEncoding("utf8"); - child.stdout?.on("data", (chunk: string) => { - stdoutBuffer += chunk; - let newlineIndex = stdoutBuffer.indexOf("\n"); - while (newlineIndex !== -1) { - const line = stdoutBuffer.slice(0, newlineIndex); - stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); - if (line.length > 0) { - let frame: DecodedGatewayFrame; - try { - frame = JSON.parse(line) as DecodedGatewayFrame; - } catch { - frame = { __unparsed: line }; - } - frames.push(frame); - for (const waiter of [...waiters]) { - if (waiter.predicate(frame)) { - waiters.splice(waiters.indexOf(waiter), 1); - waiter.resolve(frame); - } - } - } - newlineIndex = stdoutBuffer.indexOf("\n"); - } - }); - child.stderr?.setEncoding("utf8"); - child.stderr?.on("data", (chunk: string) => { - stderrText += chunk; - }); - - const exited = new Promise((resolve) => { - child.on("exit", (code) => resolve(code)); - }); - - const waitForFrame = ( - predicate: (frame: DecodedGatewayFrame) => boolean, - timeoutMs = 5000, - ): Promise => { - const existing = frames.find(predicate); - if (existing) return Promise.resolve(existing); - return new Promise((resolve, reject) => { - const waiter = { - predicate, - resolve: (frame: DecodedGatewayFrame) => { - clearTimeout(timer); - resolve(frame); - }, - }; - const timer = setTimeout(() => { - const index = waiters.indexOf(waiter); - if (index !== -1) waiters.splice(index, 1); - reject(new Error(`Timed out waiting for a gateway frame. stderr: ${stderrText}`)); - }, timeoutMs); - waiters.push(waiter); - }); - }; - - const handle: DuplexGatewayHandle = { - baseUrl: "", - frames, - stderr: () => stderrText, - waitForFrame, - sendFrame: (frame) => { - child.stdin?.write(`${JSON.stringify(frame)}\n`); - }, - sendRaw: (text) => { - child.stdin?.write(text); - }, - endStdin: () => { - child.stdin?.end(); - }, - exited, - stop: async () => { - child.kill("SIGKILL"); - await exited.catch(() => null); - }, - }; - - const ready = await waitForFrame((frame) => frame.type === "ready"); - // READY carries the echoed nonce and no address data. The host builds the - // origin from the port it assigned, never from the frame. - expect(ready.nonce).toBe(nonce); - expect((ready as Record).address).toBeUndefined(); - handle.baseUrl = `http://127.0.0.1:${assignedPort}`; - return handle; - } - +// This describe block covers the zero-dependency codec every generated +// gateway embeds (`DUPLEX_GATEWAY_CODEC_SOURCE`), and the sandbox-process +// decoder byte cap. The `http2_v1` gateway embeds the same codec source to +// send its one READY line, so this coverage stays live for the active +// transport. +describe("embedded sandbox gateway codec", () => { it("embedded gateway codec passes every vector in the shared fixture", async () => { const codecFactory = new Function( `${getSandboxDuplexGatewayCodecSource()}\nreturn { encodeDuplexFrame, encodeDuplexFrameChecked, decodeDuplexLine, DuplexFrameDecoder, DUPLEX_FRAME_VERSION, DEFAULT_MAX_DUPLEX_FRAME_BYTES, DUPLEX_DECODER_SCOPE, DEFAULT_MAX_DUPLEX_DECODER_BYTES };`, @@ -5618,708 +6140,12 @@ describe("sandbox duplex gateway", () => { expect(ledger.bytesInUse).toBe(0); expect(ledger.liveTokenCount).toBe(0); }); - - it("returns the same HTTP response as the file gateway for a forwarded request", async () => { - const token = "duplex-token-forward"; - const gateway = await startDuplexGateway({ PAPERCLIP_BRIDGE_TOKEN: token }); - - const responsePromise = fetch(`${gateway.baseUrl}/api/agents/me?view=compact`, { - headers: { - authorization: `Bearer ${token}`, - accept: "application/json", - "if-none-match": '"cache-key"', - "x-bridge-debug": "drop-me", - }, - }); - const requestFrame = await gateway.waitForFrame((frame) => frame.type === "request"); - expect(requestFrame.method).toBe("GET"); - expect(requestFrame.path).toBe("/api/agents/me"); - expect(requestFrame.query).toBe("?view=compact"); - // Only allowlisted headers forward; the bearer and the debug header drop. - expect(requestFrame.headers).toEqual({ - accept: "application/json", - "if-none-match": '"cache-key"', - }); - - gateway.sendFrame({ - version: 1, - type: "response", - id: requestFrame.id, - status: 200, - headers: { "content-type": "application/json", etag: '"rev-1"', "content-length": "999" }, - body: JSON.stringify({ ok: true }), - outcome: "completed", - }); - const response = await responsePromise; - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("application/json"); - expect(response.headers.get("etag")).toBe('"rev-1"'); - await expect(response.json()).resolves.toEqual({ ok: true }); - - // An indeterminate outcome maps to a non-retryable 409, the same contract the - // file gateway applies through the outcome header. - const indeterminatePromise = fetch(`${gateway.baseUrl}/api/issues/issue-1`, { - method: "PATCH", - headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, - body: JSON.stringify({ status: "in_progress" }), - }); - const patchFrame = await gateway.waitForFrame( - (frame) => frame.type === "request" && frame.id !== requestFrame.id, - ); - gateway.sendFrame({ - version: 1, - type: "response", - id: patchFrame.id, - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ error: "outcome_indeterminate" }), - outcome: "indeterminate", - }); - const indeterminate = await indeterminatePromise; - expect(indeterminate.status).toBe(409); - - await gateway.stop(); - }, 20000); - - it("fails a request that exceeds the frame size bound with a local 413 and keeps the channel open", async () => { - const token = "duplex-token-oversize-request"; - // Raise the body limit above the frame bound, so `readBody` accepts the body - // and the encode guard is the only limit the request meets. The default frame - // bound is 1,000,000 bytes. - const gateway = await startDuplexGateway({ - PAPERCLIP_BRIDGE_TOKEN: token, - PAPERCLIP_BRIDGE_MAX_BODY_BYTES: "3000000", - }); - - // A body over the frame bound makes the request frame exceed the bound. The - // gateway must fail this one local request with a clean 413 and forward no - // frame. - const oversizeBody = JSON.stringify({ body: "x".repeat(1_100_000) }); - const tooLarge = await fetch(`${gateway.baseUrl}/api/issues/issue-1/comments`, { - method: "POST", - headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, - body: oversizeBody, - }); - expect(tooLarge.status).toBe(413); - await expect(tooLarge.json()).resolves.toEqual({ error: "request_too_large" }); - - // The oversized request forwarded no frame. It never left the gateway. - expect(gateway.frames.filter((frame) => frame.type === "request")).toHaveLength(0); - // The gateway did not lose the channel: no close frame and the process runs. - expect(gateway.frames.some((frame) => frame.type === "close")).toBe(false); - - // The channel stays open, so a normal request after the oversized one still - // forwards and completes. - const okPromise = fetch(`${gateway.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${token}` }, - }); - const requestFrame = await gateway.waitForFrame((frame) => frame.type === "request"); - gateway.sendFrame({ - version: 1, - type: "response", - id: requestFrame.id, - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true }), - outcome: "completed", - }); - const okResponse = await okPromise; - expect(okResponse.status).toBe(200); - await expect(okResponse.json()).resolves.toEqual({ ok: true }); - - await gateway.stop(); - }, 20000); - - it("enforces the bearer check, JSON-only rule, body limit, and depth limit", async () => { - const token = "duplex-token-contract"; - const gateway = await startDuplexGateway({ - PAPERCLIP_BRIDGE_TOKEN: token, - PAPERCLIP_BRIDGE_MAX_QUEUE_DEPTH: "1", - PAPERCLIP_BRIDGE_MAX_BODY_BYTES: "16", - }); - - const badAuth = await fetch(`${gateway.baseUrl}/api/agents/me`, { - headers: { authorization: "Bearer wrong-token" }, - }); - expect(badAuth.status).toBe(401); - await expect(badAuth.json()).resolves.toEqual({ error: "Invalid bridge token." }); - - const nonJson = await fetch(`${gateway.baseUrl}/api/issues/issue-1/comments`, { - method: "POST", - headers: { authorization: `Bearer ${token}`, "content-type": "text/plain" }, - body: "not json", - }); - expect(nonJson.status).toBe(415); - await expect(nonJson.json()).resolves.toEqual({ - error: "Bridge only accepts JSON request bodies.", - }); - - const oversizeBody = await fetch(`${gateway.baseUrl}/api/issues/issue-1/comments`, { - method: "POST", - headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, - body: JSON.stringify({ body: "x".repeat(64) }), - }); - expect(oversizeBody.status).toBe(502); - await expect(oversizeBody.json()).resolves.toEqual({ - error: "Bridge request body exceeded the configured size limit.", - }); - - // No request frame forwarded so far: the guards rejected before forwarding. - const framesBeforeDepth = gateway.frames.filter((frame) => frame.type === "request").length; - expect(framesBeforeDepth).toBe(0); - - // One outstanding request fills the single depth slot; the host never - // responds, so the slot stays used. - const outstanding = fetch(`${gateway.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${token}` }, - }); - void outstanding.catch(() => undefined); - await gateway.waitForFrame((frame) => frame.type === "request"); - - const queueFull = await fetch(`${gateway.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${token}` }, - }); - expect(queueFull.status).toBe(503); - await expect(queueFull.json()).resolves.toEqual({ error: "Bridge request queue is full." }); - - // Only the outstanding request forwarded a frame; the queue-full request did - // not. - const framesAfterDepth = gateway.frames.filter((frame) => frame.type === "request").length; - expect(framesAfterDepth).toBe(1); - - await gateway.stop(); - }, 20000); - - it("answers outstanding requests 409 and new requests 503 on stdin EOF, then exits", async () => { - const token = "duplex-token-eof"; - const gateway = await startDuplexGateway({ - PAPERCLIP_BRIDGE_TOKEN: token, - PAPERCLIP_BRIDGE_LOSS_EXIT_GRACE_MS: "3000", - }); - - const outstanding = fetch(`${gateway.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${token}` }, - }); - await gateway.waitForFrame((frame) => frame.type === "request"); - gateway.endStdin(); - - const lossResponse = await outstanding; - expect(lossResponse.status).toBe(409); - expect(lossResponse.headers.get("x-paperclip-bridge-outcome")).toBe("indeterminate"); - await expect(lossResponse.json()).resolves.toEqual({ error: "outcome_indeterminate" }); - - const afterLoss = await fetch(`${gateway.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${token}` }, - }); - expect(afterLoss.status).toBe(503); - await expect(afterLoss.json()).resolves.toEqual({ error: "bridge_unavailable" }); - - const exitCode = await gateway.exited; - expect(exitCode).toBe(0); - }, 20000); - - it("applies the same loss behavior on a heartbeat timeout", async () => { - const token = "duplex-token-heartbeat"; - const gateway = await startDuplexGateway({ - PAPERCLIP_BRIDGE_TOKEN: token, - PAPERCLIP_BRIDGE_HEARTBEAT_TIMEOUT_MS: "400", - PAPERCLIP_BRIDGE_LOSS_EXIT_GRACE_MS: "3000", - PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS: "30000", - }); - - // Never send an inbound frame: inbound silence trips the heartbeat timeout. - const outstanding = fetch(`${gateway.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${token}` }, - }); - await gateway.waitForFrame((frame) => frame.type === "request"); - - const lossResponse = await outstanding; - expect(lossResponse.status).toBe(409); - await expect(lossResponse.json()).resolves.toEqual({ error: "outcome_indeterminate" }); - - const afterLoss = await fetch(`${gateway.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${token}` }, - }); - expect(afterLoss.status).toBe(503); - await expect(afterLoss.json()).resolves.toEqual({ error: "bridge_unavailable" }); - - const exitCode = await gateway.exited; - expect(exitCode).toBe(0); - }, 20000); - - it("keeps diagnostics off stdout; stdout carries only frames", async () => { - const token = "duplex-token-stdout"; - const gateway = await startDuplexGateway({ PAPERCLIP_BRIDGE_TOKEN: token }); - - const responsePromise = fetch(`${gateway.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${token}` }, - }); - const requestFrame = await gateway.waitForFrame((frame) => frame.type === "request"); - - // Feed a malformed inbound line and an unknown response id. Both force a - // diagnostic path; none of it may reach stdout. - gateway.sendRaw("this is not a frame\n"); - gateway.sendFrame({ - version: 1, - type: "response", - id: "unknown-id", - status: 200, - headers: {}, - body: "", - outcome: "completed", - }); - gateway.sendFrame({ - version: 1, - type: "response", - id: requestFrame.id, - status: 200, - headers: { "content-type": "application/json" }, - body: "{}", - outcome: "completed", - }); - - const response = await responsePromise; - expect(response.status).toBe(200); - - // Every stdout line parsed as a frame; none was diagnostic text. - expect(gateway.frames.some((frame) => frame.__unparsed !== undefined)).toBe(false); - expect( - gateway.frames.every( - (frame) => typeof frame.version === "number" && typeof frame.type === "string", - ), - ).toBe(true); - const allowedTypes = new Set(["ready", "heartbeat", "request"]); - expect(gateway.frames.every((frame) => allowedTypes.has(String(frame.type)))).toBe(true); - - // The malformed inbound line produced a stderr diagnostic, not a stdout one. - expect(gateway.stderr()).toContain("dropped an inbound frame"); - - await gateway.stop(); - }, 20000); - - it("defaults the wait budget to 35 s and honors the environment key override", async () => { - // The generated source carries the 35 s default. - expect(getSandboxCallbackBridgeServerSource()).toContain("35000"); - - const token = "duplex-token-budget"; - const gateway = await startDuplexGateway({ - PAPERCLIP_BRIDGE_TOKEN: token, - PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS: "150", - PAPERCLIP_BRIDGE_HEARTBEAT_TIMEOUT_MS: "30000", - }); - - const started = Date.now(); - const response = await fetch(`${gateway.baseUrl}/api/agents/me`, { - headers: { authorization: `Bearer ${token}` }, - }); - expect(response.status).toBe(502); - await expect(response.json()).resolves.toEqual({ - error: "Timed out waiting for host bridge response.", - }); - expect(Date.now() - started).toBeLessThan(4000); - - await gateway.stop(); - }, 20000); }); -/** - * A scripted fake duplex channel. The test drives the read path with - * `emitData`/`emitExit`, and reads the frames the broker wrote through `written`. - * The `writeError` and `closeBehavior` hooks let a test force a stream failure - * and a close timeout. - */ -function createFakeDuplexChannel(): { - channel: CommandManagedDuplexChannel; - emitData: (chunk: string) => void; - emitExit: (exit: { exitCode: number | null }) => void; - written: DuplexResponseFrame[]; - writtenTypes: string[]; - stopped: () => number; - setWriteError: (error: Error | null) => void; - setCloseBehavior: (behavior: "resolve" | "hang" | "reject") => void; -} { - let dataListener: ((chunk: string) => void) | null = null; - let exitListener: ((exit: { exitCode: number | null }) => void) | null = null; - let writeError: Error | null = null; - let closeBehavior: "resolve" | "hang" | "reject" = "resolve"; - let stopCount = 0; - const written: DuplexResponseFrame[] = []; - const writtenTypes: string[] = []; - - const channel: CommandManagedDuplexChannel = { - write(data: string): void { - if (writeError) throw writeError; - const decoded = decodeDuplexLine(data.replace(/\n$/, "")); - if (decoded.ok) { - writtenTypes.push(decoded.frame.type); - if (decoded.frame.type === "response") written.push(decoded.frame); - } - }, - onData(listener: (chunk: string) => void): void { - dataListener = listener; - }, - onExit(listener: (exit: { exitCode: number | null }) => void): void { - exitListener = listener; - }, - stop(): void { - stopCount += 1; - }, - close(): Promise { - if (closeBehavior === "resolve") return Promise.resolve(); - if (closeBehavior === "reject") return Promise.reject(new Error("close rejected")); - return new Promise(() => {}); - }, - }; - - return { - channel, - emitData: (chunk: string) => dataListener?.(chunk), - emitExit: (exit: { exitCode: number | null }) => exitListener?.(exit), - written, - writtenTypes, - stopped: () => stopCount, - setWriteError: (error: Error | null) => { - writeError = error; - }, - setCloseBehavior: (behavior: "resolve" | "hang" | "reject") => { - closeBehavior = behavior; - }, - }; -} - -/** Build one request frame line the fake channel can emit. */ -function requestFrameLine(overrides: Partial & { id: string }): string { - const frame: DuplexRequestFrame = { - version: DUPLEX_FRAME_VERSION, - type: "request", - id: overrides.id, - method: overrides.method ?? "POST", - path: overrides.path ?? "/api/issues/PAP-1/comments", - query: overrides.query ?? "", - headers: overrides.headers ?? { authorization: "Bearer bridge-token" }, - body: overrides.body ?? JSON.stringify({ body: "hello" }), - }; - return encodeDuplexFrame(frame); -} - /** Wait for the pending microtasks and macrotasks to settle. */ function flushMacrotasks(): Promise { return new Promise((resolve) => setImmediate(resolve)); } - -describe("createDuplexBridgeBroker", () => { - it("forwards a decoded request to the handler and writes the handler response back", async () => { - const fake = createFakeDuplexChannel(); - const received: DuplexRequestFrame[] = []; - // The forward handler owns the token replacement and the run attribution. The - // broker passes the decoded request straight through, so the sandbox request - // still carries only the bridge token here, and the handler applies the real - // token and the signed run identifier on the existing forward path. - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async (request): Promise => { - received.push(request); - expect(request.headers.authorization).toBe("Bearer bridge-token"); - expect(request.headers["x-paperclip-run-id"]).toBeUndefined(); - return { - status: 201, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true }), - }; - }, - }); - - broker.start(); - fake.emitData(requestFrameLine({ id: "req-1" })); - await flushMacrotasks(); - - expect(received).toHaveLength(1); - expect(received[0].id).toBe("req-1"); - expect(fake.written).toHaveLength(1); - expect(fake.written[0]).toMatchObject({ - type: "response", - id: "req-1", - status: 201, - outcome: "completed", - }); - expect(JSON.parse(fake.written[0].body)).toEqual({ ok: true }); - - await broker.close(); - }); - - it("moves through opening, open, closing, closed in order", async () => { - const fake = createFakeDuplexChannel(); - const states: DuplexBrokerState[] = []; - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async () => ({ status: 200 }), - onStateChange: (state) => states.push(state), - }); - - expect(broker.state).toBe("opening"); - broker.start(); - expect(broker.state).toBe("open"); - await broker.close(); - expect(broker.state).toBe("closed"); - expect(states).toEqual(["open", "closing", "closed"]); - expect(fake.writtenTypes).toContain("close"); - }); - - it.each([ - { - name: "channel exit", - reason: "channel_exit" as const, - trigger: (fake: ReturnType) => - fake.emitExit({ exitCode: 1 }), - }, - { - name: "protocol failure", - reason: "protocol_failure" as const, - trigger: (fake: ReturnType) => - fake.emitData("this is not json\n"), - }, - { - name: "stream failure", - reason: "stream_failure" as const, - trigger: (fake: ReturnType) => { - fake.setWriteError(new Error("broken pipe")); - fake.emitData(requestFrameLine({ id: "req-stream" })); - }, - }, - ])("enters lost on $name", async ({ reason, trigger }) => { - const fake = createFakeDuplexChannel(); - const losses: DuplexBrokerLossRecord[] = []; - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async () => ({ status: 200 }), - onLoss: (record) => losses.push(record), - }); - broker.start(); - - trigger(fake); - await flushMacrotasks(); - - expect(broker.state).toBe("lost"); - expect(broker.lossRecord?.reason).toBe(reason); - expect(losses).toHaveLength(1); - }); - - it("enters lost on a close timeout", async () => { - const fake = createFakeDuplexChannel(); - fake.setCloseBehavior("hang"); - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async () => ({ status: 200 }), - closeTimeoutMs: 20, - }); - broker.start(); - - await broker.close(); - - expect(broker.state).toBe("lost"); - expect(broker.lossRecord?.reason).toBe("close_timeout"); - }); - - it("stops the heartbeat, marks the run bridge ended, and dispatches nothing after loss", async () => { - const fake = createFakeDuplexChannel(); - const forwarded: string[] = []; - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async (request) => { - forwarded.push(request.id); - return { status: 200 }; - }, - }); - broker.start(); - - fake.emitExit({ exitCode: 1 }); - await flushMacrotasks(); - expect(broker.state).toBe("lost"); - expect(broker.lossRecord).not.toBeNull(); - - // A request that arrives after loss reaches nothing. The broker never - // reconnects and never replays a request. - fake.emitData(requestFrameLine({ id: "after-loss" })); - await flushMacrotasks(); - expect(forwarded).toEqual([]); - }); - - it("forwards one request id one time, so a repeated frame never reaches the API twice", async () => { - const fake = createFakeDuplexChannel(); - const forwarded: string[] = []; - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async (request) => { - forwarded.push(request.id); - return { status: 200, body: "ok" }; - }, - }); - broker.start(); - - fake.emitData(requestFrameLine({ id: "dup" })); - await flushMacrotasks(); - fake.emitData(requestFrameLine({ id: "dup" })); - await flushMacrotasks(); - - expect(forwarded).toEqual(["dup"]); - expect(fake.written).toHaveLength(1); - - await broker.close(); - }); - - it("captures the dispatch-start point per request for metrics only", async () => { - const fake = createFakeDuplexChannel(); - const records: DuplexBrokerRequestRecord[] = []; - let clock = 1000; - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async () => ({ status: 200 }), - now: () => clock, - onRequestRecord: (record) => records.push(record), - }); - broker.start(); - - clock = 2500; - fake.emitData(requestFrameLine({ id: "metric-1", method: "GET", path: "/api/agents/me" })); - await flushMacrotasks(); - - expect(records).toEqual([ - { id: "metric-1", method: "GET", path: "/api/agents/me", dispatchStartMs: 2500 }, - ]); - // The record never reaches the channel. The broker writes only a response. - expect(fake.writtenTypes).not.toContain("request"); - - await broker.close(); - }); - - it("latches a failure when a loss orders before an orderly completion and names the typed reason", async () => { - const fake = createFakeDuplexChannel(); - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async () => ({ status: 200 }), - }); - broker.start(); - - // The channel dies mid-turn, before any orderly completion. - fake.emitExit({ exitCode: 1 }); - await flushMacrotasks(); - expect(broker.runDisposition).toEqual({ failed: true, lossReason: "provider_exit" }); - - // A later orderly completion cannot clear the latch. A delayed activity - // callback cannot clear the latch either, because the broker dispatches - // nothing after loss. - broker.markOrderlyCompletion(); - fake.emitData(requestFrameLine({ id: "after-loss" })); - await flushMacrotasks(); - expect(broker.runDisposition).toEqual({ failed: true, lossReason: "provider_exit" }); - }); - - it("keeps a success when a loss orders after a host-observed orderly completion, and emits no loss event", async () => { - const events: DuplexTelemetryEventRecord[] = []; - const counters: DuplexTelemetryCounterRecord[] = []; - const recorder: DuplexTelemetryRecorder = { - recordSpan() {}, - incrementCounter(record) { - counters.push(record); - }, - emitEvent(record) { - events.push(record); - }, - }; - const fake = createFakeDuplexChannel(); - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async () => ({ status: 200 }), - telemetry: createDuplexTelemetry({ recorder, providerKey: "daytona" }), - }); - broker.start(); - - // The agent completes its turn, then the channel ends during the teardown. - broker.markOrderlyCompletion(); - fake.emitExit({ exitCode: 0 }); - await flushMacrotasks(); - - expect(broker.runDisposition).toEqual({ failed: false, lossReason: null }); - // A normal teardown is not a loss: no loss event and no loss counter. - expect(events.some((e) => e.dimensions.loss_reason !== undefined)).toBe(false); - expect(counters.some((c) => c.metric === DUPLEX_COUNTER_LOSS_TOTAL)).toBe(false); - }); - - it("carries the typed loss_reason on the transport loss event and keeps a sentinel message off every sink", async () => { - const spans: DuplexTelemetrySpanRecord[] = []; - const counters: DuplexTelemetryCounterRecord[] = []; - const events: DuplexTelemetryEventRecord[] = []; - const recorder: DuplexTelemetryRecorder = { - recordSpan(record) { - spans.push(record); - }, - incrementCounter(record) { - counters.push(record); - }, - emitEvent(record) { - events.push(record); - }, - }; - const logLines: string[] = []; - const fake = createFakeDuplexChannel(); - const sentinel = "SENTINEL-PROVIDER-TEXT-1a2b3c"; - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async () => ({ status: 200 }), - telemetry: createDuplexTelemetry({ recorder, providerKey: "daytona" }), - logger: (message) => logLines.push(message), - }); - broker.start(); - - // A stream write failure carries a raw provider message. It maps to the typed - // `write_error`; the raw message must reach no sink. - fake.setWriteError(new Error(sentinel)); - fake.emitData(requestFrameLine({ id: "req-sentinel" })); - await flushMacrotasks(); - - const lossEvent = events.find((e) => e.dimensions.loss_reason !== undefined); - expect(lossEvent?.dimensions.loss_reason).toBe("write_error"); - expect(lossEvent?.dimensions).toMatchObject({ transport: "duplex", outcome: "error" }); - - // The sentinel provider text reaches no telemetry sink and no log line. - const serializedSinks = JSON.stringify({ spans, counters, events }); - expect(serializedSinks).not.toContain(sentinel); - expect(logLines.join("\n")).not.toContain(sentinel); - }); - - it("rejects a configuration where an inner budget is not smaller than its outer budget", () => { - expect(() => - assertNestedDuplexBrokerBudgets({ - forwardTimeoutMs: 32_000, - responseBudgetMs: 32_000, - gatewayWaitMs: 35_000, - }), - ).toThrow(/forward budget/); - expect(() => - assertNestedDuplexBrokerBudgets({ - forwardTimeoutMs: 30_000, - responseBudgetMs: 35_000, - gatewayWaitMs: 35_000, - }), - ).toThrow(/response budget/); - expect(() => - createDuplexBridgeBroker({ - channel: createFakeDuplexChannel().channel, - forwardRequest: async () => ({ status: 200 }), - budgets: { forwardTimeoutMs: 40_000 }, - }), - ).toThrow(/forward budget/); - // The default budget set holds the nested order. - expect(() => - assertNestedDuplexBrokerBudgets({ - forwardTimeoutMs: 30_000, - responseBudgetMs: 32_000, - gatewayWaitMs: 35_000, - }), - ).not.toThrow(); - }); -}); - describe("sandbox target spec parse: enableSandboxDuplexBridge", () => { // The minimal serialized sandbox target the host stamps and the adapter parses. // A test overrides one field per case to prove the fail-closed parse. @@ -6381,33 +6207,56 @@ describe("sandbox target spec parse: enableSandboxDuplexBridge", () => { }); }); +/** + * A minimal run-disposition latch for the seam tests below. It reproduces the + * same ordering rule the real bridge transport applies: the first ordered + * loss or orderly completion latches the terminal disposition, and a later + * call never overrides it. `emitExit` stands in for a transport-level channel + * exit — the real transport treats every channel exit as a loss candidate, + * mapped to the typed `provider_exit` reason. + */ +function createFakeBridgeTransport() { + let lossOrdered = false; + let lossReason: DuplexLossReason | null = null; + let completionOrdered = false; + const markOrderlyCompletion = (): void => { + if (completionOrdered || lossOrdered) return; + completionOrdered = true; + }; + return { + get runDisposition() { + return { failed: lossOrdered, lossReason }; + }, + settleRunDisposition() { + markOrderlyCompletion(); + return { failed: lossOrdered, lossReason }; + }, + markOrderlyCompletion, + emitExit(): void { + if (lossOrdered || completionOrdered) return; + lossOrdered = true; + lossReason = "provider_exit"; + }, + }; +} + describe("settleRunDisposition atomic read and mark", () => { it("marks the orderly completion and reports a success for a healthy channel", async () => { - const fake = createFakeDuplexChannel(); - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async () => ({ status: 200 }), - }); - broker.start(); + const broker = createFakeBridgeTransport(); // The one atomic step marks the orderly completion and reads the success. expect(broker.settleRunDisposition()).toEqual({ failed: false, lossReason: null }); // A later teardown loss orders after the mark, so it stays a normal teardown. - fake.emitExit({ exitCode: 0 }); + broker.emitExit(); await flushMacrotasks(); expect(broker.runDisposition).toEqual({ failed: false, lossReason: null }); }); it("reports the failure and does not mark for a latched loss", async () => { - const fake = createFakeDuplexChannel(); - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async () => ({ status: 200 }), - }); - broker.start(); + const broker = createFakeBridgeTransport(); // A loss ordered before any orderly completion latches the failure. - fake.emitExit({ exitCode: 1 }); + broker.emitExit(); await flushMacrotasks(); // The atomic step reads the failure and no-ops the mark, so a later // completion cannot clear the latch. @@ -6443,20 +6292,10 @@ describe("CLI-lane run-disposition seam", () => { } as AdapterSandboxExecutionTarget; } - function startBroker(fake: ReturnType) { - const broker = createDuplexBridgeBroker({ - channel: fake.channel, - forwardRequest: async () => ({ status: 200 }), - }); - broker.start(); - return broker; - } - - it("fails a clean CLI completion closed when the duplex channel was lost mid-turn", async () => { - const fake = createFakeDuplexChannel(); - const broker = startBroker(fake); + it("fails a clean CLI completion closed when the bridge channel was lost mid-turn", async () => { + const broker = createFakeBridgeTransport(); // The control channel dies mid-turn, before the CLI process exits. - fake.emitExit({ exitCode: 1 }); + broker.emitExit(); await flushMacrotasks(); const runner = mockRunner({ ...CLEAN_RESULT }); @@ -6477,8 +6316,7 @@ describe("CLI-lane run-disposition seam", () => { }); it("keeps a clean CLI completion a success when the channel stays healthy, and a teardown loss stays benign", async () => { - const fake = createFakeDuplexChannel(); - const broker = startBroker(fake); + const broker = createFakeBridgeTransport(); const runner = mockRunner({ ...CLEAN_RESULT }); const result = await runAdapterExecutionTargetProcess("run-cli-ok", sandboxTarget(runner), "agent-cli", [], { @@ -6495,17 +6333,16 @@ describe("CLI-lane run-disposition seam", () => { // The seam's atomic settle marked the orderly completion at agent // completion. A teardown loss ordered after it is a normal teardown, so the // run stays a success without any manual mark here. - fake.emitExit({ exitCode: 0 }); + broker.emitExit(); await flushMacrotasks(); expect(broker.runDisposition.failed).toBe(false); }); it("keeps a clean CLI completion a success when the gateway exits during the run-log tail finish", async () => { - const fake = createFakeDuplexChannel(); - const broker = startBroker(fake); + const broker = createFakeBridgeTransport(); // A run-log tail whose finish emits a gateway exit. This reproduces the - // race where the duplex gateway dies after the clean process completion but + // race where the bridge gateway dies after the clean process completion but // before the host reads the disposition. The seam settles the disposition // synchronously before this finish await, so the mark orders first and the // teardown exit stays benign. @@ -6514,7 +6351,7 @@ describe("CLI-lane run-disposition seam", () => { wrapCommand: (command, args) => ({ command, args }), start: () => {}, finish: async () => { - fake.emitExit({ exitCode: 1 }); + broker.emitExit(); await flushMacrotasks(); }, abort: async () => {}, @@ -6541,10 +6378,9 @@ describe("CLI-lane run-disposition seam", () => { }); it("cannot clear the loss latch with a later completion", async () => { - const fake = createFakeDuplexChannel(); - const broker = startBroker(fake); + const broker = createFakeBridgeTransport(); // The loss latches before the CLI process exits. - fake.emitExit({ exitCode: 1 }); + broker.emitExit(); await flushMacrotasks(); // A later orderly completion cannot clear the latch. broker.markOrderlyCompletion(); @@ -6564,10 +6400,9 @@ describe("CLI-lane run-disposition seam", () => { }); it("leaves an already-failed CLI result unchanged and never settles the disposition", async () => { - const fake = createFakeDuplexChannel(); - const broker = startBroker(fake); + const broker = createFakeBridgeTransport(); // The control channel is lost, but the process itself also exited non-zero. - fake.emitExit({ exitCode: 1 }); + broker.emitExit(); await flushMacrotasks(); let settleCalls = 0; @@ -6609,20 +6444,20 @@ describe("duplex readiness gate replay-buffer reservation", () => { emitExit: (exit: { exitCode: number | null }) => void; }; } { - let dataListener: ((chunk: string) => void) | null = null; + let dataListener: ((chunk: Uint8Array) => void) | null = null; let exitListener: ((exit: { exitCode: number | null }) => void) | null = null; const control = { stopCount: 0, closeCount: 0, written: [] as string[], - emitData: (chunk: string): void => dataListener?.(chunk), + emitData: (chunk: string): void => dataListener?.(new TextEncoder().encode(chunk)), emitExit: (exit: { exitCode: number | null }): void => exitListener?.(exit), }; const channel: CommandManagedDuplexChannel = { - write(data: string): void { - control.written.push(data); + write(data: Uint8Array): void { + control.written.push(Buffer.from(data).toString("utf8")); }, - onData(listener: (chunk: string) => void): void { + onData(listener: (chunk: Uint8Array) => void): void { dataListener = listener; }, onExit(listener: (exit: { exitCode: number | null }) => void): void { @@ -6662,7 +6497,7 @@ describe("duplex readiness gate replay-buffer reservation", () => { } function readyLine(): string { - return `${JSON.stringify({ version: 1, type: "ready", nonce: READY_NONCE })}\n`; + return `${JSON.stringify({ version: 2, type: "ready", nonce: READY_NONCE })}\n`; } it("charges the post-READY suffix and releases it after the broker handoff", async () => { @@ -6686,7 +6521,7 @@ describe("duplex readiness gate replay-buffer reservation", () => { // The broker binds and replays the suffix; the gate releases the token after // the synchronous handoff. const replayed: string[] = []; - gate.brokerChannel.onData((chunk) => replayed.push(chunk)); + gate.brokerChannel.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); expect(replayed).toEqual([suffix]); expect(ledger.bytesInUse).toBe(0); expect(ledger.liveTokenCount).toBe(0); @@ -6735,7 +6570,7 @@ describe("duplex readiness gate replay-buffer reservation", () => { // reservation. const replayed: string[] = []; const exits: Array<{ exitCode: number | null }> = []; - gate.brokerChannel.onData((chunk) => replayed.push(chunk)); + gate.brokerChannel.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); gate.brokerChannel.onExit((exit) => exits.push(exit)); expect(replayed).toEqual([suffix]); expect(exits).toEqual([{ exitCode: 0 }]); @@ -6768,7 +6603,7 @@ describe("duplex readiness gate replay-buffer reservation", () => { expect(counts.underflows).toBe(0); // Binding the broker replays nothing, because the gate dropped the buffer. const replayed: string[] = []; - gate.brokerChannel.onData((chunk) => replayed.push(chunk)); + gate.brokerChannel.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); expect(replayed).toEqual([]); }); @@ -6795,10 +6630,406 @@ describe("duplex readiness gate replay-buffer reservation", () => { expect(ledger.liveTokenCount).toBe(1); // The broker binds, replays the suffix, and the gate releases the token. const replayed: string[] = []; - gate.brokerChannel.onData((chunk) => replayed.push(chunk)); + gate.brokerChannel.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); expect(replayed).toEqual([suffix]); expect(ledger.bytesInUse).toBe(0); expect(gate.retainedReadinessBufferLength()).toBe(0); expect(counts.underflows).toBe(0); }); + + it("releases the readiness_replay token before the downstream listener runs, so a same-size downstream reservation for the same bytes does not double-book", async () => { + const { channel, control } = makeFakeReadinessChannel(); + const suffix = "s".repeat(512); + const suffixBytes = Buffer.byteLength(suffix, "utf8"); + // The ceiling admits the READY line plus one reservation of the suffix + // size, but not a second, separate reservation of the suffix on top of + // that. A downstream listener that reserves the same bytes under its own + // owner — the real shape of the HTTP/2 preface scanner — proves the gate + // releases its own `readiness_replay` token first: the downstream + // reservation must still fit. + const { ledger, counts } = makeCountingLedger(Buffer.byteLength(readyLine(), "utf8") + suffixBytes); + const gate = __duplexReadinessTesting.createReadinessGate(channel, { + nonce: READY_NONCE, + timeoutMs: 5_000, + ledger, + }); + control.emitData(`${readyLine()}${suffix}`); + expect((await gate.ready).ok).toBe(true); + expect(ledger.bytesInUse).toBe(suffixBytes); + + let peakBytesInUseDuringHandoff = -1; + let downstreamToken: ReturnType = null; + gate.brokerChannel.onData((chunk) => { + // The downstream listener reserves the same bytes under its own owner, + // the same way the preface scanner charges `http2_preface_scan` for the + // replayed chunk. The gate must have released its own token before this + // call runs, so this reservation fits under the tight ceiling. + downstreamToken = ledger.reserve("http2_preface_scan", chunk.byteLength); + peakBytesInUseDuringHandoff = ledger.bytesInUse; + }); + + // The downstream reservation succeeded: the gate's release ran first, so + // only one reservation for these bytes was ever live at once. + expect(downstreamToken).not.toBeNull(); + expect(peakBytesInUseDuringHandoff).toBe(suffixBytes); + expect(counts.rejections).toBe(0); + expect(counts.underflows).toBe(0); + // The gate's own token is gone; only the downstream token remains live. + expect(ledger.liveTokenCount).toBe(1); + expect(ledger.bytesInUse).toBe(suffixBytes); + }); +}); + +describe("http2 preface scan post-preface replay buffer", () => { + // The HTTP/2 client connection preface, 24 octets (RFC 9113, Section 3.4). + // A test writes this literal, not the production constant, so the test + // proves the real wire bytes match, not only that the two source files + // agree on a name. + const PREFACE = Buffer.from("505249202a20485454502f322e300d0a0d0a534d0d0a0d0a", "hex"); + + // A fake duplex channel the test drives directly. `control.emitData` + // re-enters the data listener the scan bound at construction. The fake + // records the stop call, so a test proves an overflow fails closed. + function makeFakeChannel(): { + channel: CommandManagedDuplexChannel; + control: { stopCount: number; emitData: (chunk: Buffer) => void }; + } { + let dataListener: ((chunk: Uint8Array) => void) | null = null; + const control = { + stopCount: 0, + emitData: (chunk: Buffer): void => dataListener?.(chunk), + }; + const channel: CommandManagedDuplexChannel = { + write(): void {}, + onData(listener: (chunk: Uint8Array) => void): void { + dataListener = listener; + }, + onExit(): void {}, + stop(): void { + control.stopCount += 1; + }, + close(): Promise { + return Promise.resolve(); + }, + }; + return { channel, control }; + } + + function makeCountingLedger(ceilingBytes: number): { + ledger: DuplexAggregateByteLedger; + counts: { rejections: number }; + } { + const counts = { rejections: 0 }; + const ledger = new DuplexAggregateByteLedger({ + ceilingBytes, + telemetry: { + setBytesInUse(): void {}, + recordReservationRejection(): void { + counts.rejections += 1; + }, + recordAccountingUnderflow(): void {}, + }, + }); + return { ledger, counts }; + } + + it("charges the pre-preface scan buffer while it searches, across many chunks", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger, counts } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 4_096, + timeoutMs: 5_000, + ledger, + }); + // Noise, then the preface, arrive as two separate chunks: the scan + // charges each chunk against the ledger as it grows the buffer, not + // only once the preface is found. + const noise = "not-the-preface-yet"; + control.emitData(Buffer.from(noise)); + expect(ledger.bytesInUse).toBe(Buffer.byteLength(noise, "utf8")); + expect(ledger.liveTokenCount).toBe(1); + control.emitData(PREFACE); + expect(await scan.settled).toBe("found"); + // The preface match releases the whole scan buffer (the noise prefix, + // dropped, plus the preface) and re-charges only the retained preface + // under the replay owner. + expect(ledger.bytesInUse).toBe(PREFACE.byteLength); + expect(ledger.liveTokenCount).toBe(1); + expect(counts.rejections).toBe(0); + }); + + it("fails closed when the aggregate byte ledger refuses the pre-preface scan reservation", async () => { + const { channel, control } = makeFakeChannel(); + // The ceiling admits nothing: even the 24-octet preface itself cannot + // reserve, so the scan fails closed before it ever finds a preface. + const { ledger, counts } = makeCountingLedger(4); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 4_096, + timeoutMs: 5_000, + ledger, + }); + control.emitData(Buffer.concat([PREFACE, Buffer.from("12345678")])); + expect(await scan.settled).toBe("missing"); + expect(scan.replayOverflowed()).toBe(false); + expect(ledger.bytesInUse).toBe(0); + expect(counts.rejections).toBe(1); + }); + + it("rejects a single pre-preface chunk over the cap without reserving or concatenating it", async () => { + const { channel, control } = makeFakeChannel(); + // The ceiling is far larger than the cap, so a ledger refusal cannot + // explain a rejection here: only the cap check on the chunk's + // prospective length can. + const { ledger, counts } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 64, + timeoutMs: 5_000, + ledger, + }); + // One chunk, larger than the cap on its own, with no preface inside it. + // The scan must reject it on its prospective length before the + // `Buffer.concat` allocation and before the ledger reservation, so + // nothing here ever charges the ledger. + control.emitData(Buffer.from("x".repeat(128))); + expect(await scan.settled).toBe("missing"); + expect(scan.replayOverflowed()).toBe(false); + expect(ledger.bytesInUse).toBe(0); + expect(ledger.liveTokenCount).toBe(0); + expect(counts.rejections).toBe(0); + }); + + it("rejects a pre-preface chunk that tips an already-buffered scan past the cap", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger, counts } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 64, + timeoutMs: 5_000, + ledger, + }); + // The first chunk stays under the cap on its own, so the scan buffers + // and charges it while it keeps searching. + const firstChunk = "n".repeat(40); + control.emitData(Buffer.from(firstChunk)); + expect(ledger.bytesInUse).toBe(Buffer.byteLength(firstChunk, "utf8")); + // The second chunk, added to the first, passes the cap. The scan must + // reject it before the concat that would grow the buffer past the cap, + // and it must drop the already-buffered first chunk too. + control.emitData(Buffer.from("n".repeat(40))); + expect(await scan.settled).toBe("missing"); + expect(scan.replayOverflowed()).toBe(false); + expect(ledger.bytesInUse).toBe(0); + expect(ledger.liveTokenCount).toBe(0); + expect(counts.rejections).toBe(0); + }); + + it("charges the post-preface bytes and releases them after the downstream bind", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 4_096, + timeoutMs: 5_000, + ledger, + }); + const suffix = "one-http2-frame"; + // The preface and the trailing suffix arrive in one chunk. The scan + // delivers every byte from the preface onward, inclusive, so the charged + // and replayed bytes carry the preface itself plus the suffix. + const fromPreface = Buffer.concat([PREFACE, Buffer.from(suffix)]).toString("utf8"); + control.emitData(Buffer.concat([PREFACE, Buffer.from(suffix)])); + expect(await scan.settled).toBe("found"); + expect(scan.replayOverflowed()).toBe(false); + expect(ledger.bytesInUse).toBe(Buffer.byteLength(fromPreface, "utf8")); + expect(ledger.liveTokenCount).toBe(1); + // The HTTP/2 server binds and replays the bytes; the scan releases the + // token after the synchronous handoff. + const replayed: string[] = []; + scan.scanned.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); + expect(replayed).toEqual([fromPreface]); + expect(ledger.bytesInUse).toBe(0); + expect(ledger.liveTokenCount).toBe(0); + }); + + it("fails closed and stops the channel when the post-preface buffer floods past the cap", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 64, + timeoutMs: 5_000, + ledger, + }); + // The preface arrives alone, so the pending buffer starts empty. + control.emitData(PREFACE); + expect(await scan.settled).toBe("found"); + expect(scan.replayOverflowed()).toBe(false); + // A post-preface chunk larger than the cap floods the buffer before the + // HTTP/2 server binds. The scan drops the buffer, stops the channel, and + // fails closed — the same shape a missing preface fails closed. + control.emitData(Buffer.from("x".repeat(128))); + expect(scan.replayOverflowed()).toBe(true); + expect(control.stopCount).toBe(1); + expect(ledger.bytesInUse).toBe(0); + expect(ledger.liveTokenCount).toBe(0); + // Binding the downstream listener replays nothing: the scan dropped the + // buffer on the overflow. + const replayed: string[] = []; + scan.scanned.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); + expect(replayed).toEqual([]); + }); + + it("fails closed when the aggregate byte ledger refuses the post-preface replay reservation", async () => { + const { channel, control } = makeFakeChannel(); + // The ceiling admits the 24-octet preface itself, but not an 8-byte + // suffix on top of it. + const { ledger, counts } = makeCountingLedger(30); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 4_096, + timeoutMs: 5_000, + ledger, + }); + // The preface arrives alone, so it is the only bytes charged so far. + control.emitData(PREFACE); + expect(await scan.settled).toBe("found"); + expect(scan.replayOverflowed()).toBe(false); + expect(ledger.bytesInUse).toBe(PREFACE.byteLength); + // A post-preface suffix, on top of the retained preface, passes the + // ceiling. The scan drops the buffer, stops the channel, and fails + // closed — the same shape a missing preface fails closed. + control.emitData(Buffer.from("12345678")); + expect(scan.replayOverflowed()).toBe(true); + expect(control.stopCount).toBe(1); + expect(ledger.bytesInUse).toBe(0); + expect(counts.rejections).toBe(1); + }); + + it("releases the scan-buffer tokens when the readiness timeout elapses with no preface found", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 4_096, + // A short bound, so the test does not wait out a production-sized one. + timeoutMs: 20, + ledger, + }); + // Partial, non-matching data arrives and stays charged while the scan + // keeps searching. No preface ever completes, so nothing else in the + // scan releases this charge — only the timeout path can. + const partial = "not-a-preface-and-never-will-be"; + control.emitData(Buffer.from(partial)); + expect(ledger.bytesInUse).toBe(Buffer.byteLength(partial, "utf8")); + expect(ledger.liveTokenCount).toBe(1); + // The bound readiness timeout elapses before a preface ever arrives. The + // scan must release its held tokens here — the one terminal path that + // has no cap or ledger refusal of its own to trigger a release. + expect(await scan.settled).toBe("missing"); + expect(ledger.bytesInUse).toBe(0); + expect(ledger.liveTokenCount).toBe(0); + }); + + it("finds a preface fragmented into many one-byte chunks", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger } = makeCountingLedger(1024 * 1024); + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes: 4_096, + timeoutMs: 5_000, + ledger, + }); + // A slow sandbox socket can deliver the preface one byte at a time. The + // scan must still find it and deliver the exact octets, the same as it + // does for a preface that arrives in one chunk. + for (const byte of PREFACE) { + control.emitData(Buffer.from([byte])); + } + expect(await scan.settled).toBe("found"); + expect(scan.replayOverflowed()).toBe(false); + const replayed: string[] = []; + scan.scanned.onData((chunk) => replayed.push(Buffer.from(chunk).toString("utf8"))); + expect(replayed).toEqual([PREFACE.toString("utf8")]); + }); + + it("bounds the pre-preface scan search and growth-copy work by the bytes received, across many one-byte fragments", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger } = makeCountingLedger(1024 * 1024); + __http2PrefaceScanTesting.resetScanSearchUnits(); + __http2PrefaceScanTesting.resetScanBufferGrowthCopyUnits(); + // An adversarial sandbox sends many one-byte fragments, none of them the + // preface, right up to the cap boundary. A search that always restarts + // from the beginning of the retained buffer re-examines the whole + // buffer on every fragment; a one-copy-per-fragment append copies the + // whole retained buffer on every fragment. Both are quadratic in the + // fragment count. + const capBytes = 2_048; + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes, + timeoutMs: 5_000, + ledger, + }); + for (let i = 0; i < capBytes; i += 1) { + control.emitData(Buffer.from([0x2e])); // '.', never part of the preface + } + // One more one-byte fragment tips the retained buffer past the cap, so + // the scan settles and this test can read the final work counts. + control.emitData(Buffer.from([0x2e])); + expect(await scan.settled).toBe("missing"); + const searchUnits = __http2PrefaceScanTesting.readScanSearchUnits(); + const growthCopyUnits = __http2PrefaceScanTesting.readScanBufferGrowthCopyUnits(); + // Each one-byte fragment can re-examine at most the preface's 24 octets + // of overlap (RFC 9113, Section 3.4), so the search work stays within a + // small multiple of capBytes. Doubling growth copies a logarithmic + // number of times, each at most the current buffer length, so the + // growth-copy work stays within a small multiple of capBytes too. A + // per-fragment full rescan or full copy is quadratic (about + // capBytes^2 / 2), far above either bound. + expect(searchUnits).toBeLessThanOrEqual(50 * capBytes); + expect(growthCopyUnits).toBeLessThanOrEqual(4 * capBytes); + }); + + it("bounds the post-preface replay buffer growth-copy work by the bytes received, across many one-byte fragments, and still enforces the cap", async () => { + const { channel, control } = makeFakeChannel(); + const { ledger } = makeCountingLedger(1024 * 1024); + __http2PrefaceScanTesting.resetReplayBufferGrowthCopyUnits(); + const capBytes = 2_048; + const scan = __http2PrefaceScanTesting.scanForHttp2ClientPreface(channel, { + capBytes, + timeoutMs: 5_000, + ledger, + }); + control.emitData(PREFACE); + expect(await scan.settled).toBe("found"); + // Many one-byte post-preface fragments arrive before the HTTP/2 server + // binds, right up to the cap boundary. A one-copy-per-fragment append + // copies the whole retained buffer on every fragment; the fix must + // instead grow by doubling. + const postPrefaceBytes = capBytes - PREFACE.byteLength; + for (let i = 0; i < postPrefaceBytes; i += 1) { + control.emitData(Buffer.from([0x2e])); + } + const growthCopyUnits = __http2PrefaceScanTesting.readReplayBufferGrowthCopyUnits(); + expect(growthCopyUnits).toBeLessThanOrEqual(4 * capBytes); + // One more one-byte fragment tips the retained buffer past the cap. The + // scan drops the buffer, stops the channel, and fails closed — the same + // shape a missing preface fails closed. + control.emitData(Buffer.from([0x2e])); + expect(scan.replayOverflowed()).toBe(true); + expect(control.stopCount).toBe(1); + }); +}); + +describe("EffectiveSandboxCapabilities deprecated alias", () => { + it("still type-checks as EffectiveExecutionCapabilities", () => { + // A type-level check, not a runtime one: this assignment fails to compile + // if the alias drifts from the renamed interface. Keep it here so a later + // removal of the alias is a deliberate act, not an accident. + const snapshot: EffectiveExecutionCapabilities = { + reusableLeases: false, + nativeSyncIn: false, + nativeSyncOut: false, + persistentProcessSessions: false, + independentControlCommands: false, + incrementalSessionOutput: false, + concurrentSyncOperations: false, + duplexCommandStream: false, + }; + const aliased: EffectiveSandboxCapabilities = snapshot; + expect(aliased).toEqual(snapshot); + }); }); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 065a7f8c5b7..28423e1a4e4 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import net from "node:net"; import os from "node:os"; import path from "node:path"; +import { Readable } from "node:stream"; import { randomBytes, randomUUID } from "node:crypto"; import type { SshRemoteExecutionSpec } from "./ssh.js"; import { @@ -24,46 +25,43 @@ export type { SandboxAdditionalSource, } from "./sandbox-managed-runtime.js"; import { - authorizeSandboxCallbackBridgeRequestWithRoutes, createCommandManagedSandboxCallbackBridgeQueueClient, createSandboxCallbackBridgeAsset, createSandboxCallbackBridgeToken, DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES, DEFAULT_SANDBOX_DUPLEX_DECODER_MAX_BYTES, - SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT, + SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE, sandboxCallbackBridgeDirectories, - sanitizeSandboxCallbackBridgeHeaders, startSandboxCallbackBridgeServer, startSandboxCallbackBridgeWorker, syncRemoteTextFileWithHashSkip, syncSandboxCallbackBridgeEntrypoint, } from "./sandbox-callback-bridge.js"; +import { + createHttp2BridgeServer, + type Http2BridgeForwardHandler, +} from "./http2-bridge-server.js"; import { createSandboxRunLogTailFactory, type SandboxRunLogTailFactory, } from "./sandbox-run-log-stream.js"; import { - createDuplexBridgeBroker, DEFAULT_DUPLEX_BROKER_BUDGETS, DUPLEX_CHANNEL_LOST_ERROR_CODE, isSafeBridgeMethod, - typedDuplexLossReason, - type DuplexBridgeBroker, - type DuplexBrokerBudgets, - type DuplexBrokerForwardResult, type DuplexBrokerRunDisposition, -} from "./duplex-bridge-broker.js"; +} from "./bridge-transport-contract.js"; +import { decodeDuplexLine, DEFAULT_MAX_DUPLEX_FRAME_BYTES } from "./duplex-frame-codec.js"; +import type { ReassembledBody } from "./duplex-body-spool.js"; import { - decodeDuplexLine, - DEFAULT_MAX_DUPLEX_FRAME_BYTES, - type DuplexRequestFrame, -} from "./duplex-frame-codec.js"; -import { - createDuplexTelemetry, + createDuplexObservability, + mapHttp2EventToDuplexLossReason, type DuplexFallbackReason, - type DuplexTelemetryRecorder, -} from "./duplex-telemetry.js"; + type DuplexLossReason, + type DuplexObservabilityRecorder, + type Http2TelemetryEventName, +} from "./duplex-observability.js"; import { DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED, type DuplexAggregateByteLedger, @@ -135,13 +133,14 @@ export interface AdapterSshExecutionTarget extends AdapterExecutionTargetWorkspa } /** - * Read-only snapshot of the effective sandbox capabilities for one execution - * target. Each flag is the resolved result of the provider's declaration, the - * live worker's verified methods, and any narrowing from the config or lease. - * The host computes it once and attaches it to the target; a consumer reads it - * but never changes it, so every field is `readonly`. + * Read-only snapshot of the effective execution capabilities for one + * execution target — local, ssh, sandbox, or plugin. Each flag is the + * resolved result of the provider's declaration, the live worker's verified + * methods, and any narrowing from the config or lease. The host computes it + * once and attaches it to the target; a consumer reads it but never changes + * it, so every field is `readonly`. */ -export interface EffectiveSandboxCapabilities { +export interface EffectiveExecutionCapabilities { readonly reusableLeases: boolean; readonly nativeSyncIn: boolean; readonly nativeSyncOut: boolean; @@ -152,6 +151,12 @@ export interface EffectiveSandboxCapabilities { readonly duplexCommandStream: boolean; } +/** + * @deprecated Renamed to `EffectiveExecutionCapabilities`. This alias will + * be removed in a later major release. + */ +export interface EffectiveSandboxCapabilities extends EffectiveExecutionCapabilities {} + export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWorkspaceMetadata { kind: "remote"; transport: "sandbox"; @@ -161,7 +166,7 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor * resolves it from the provider declaration ∩ the verified worker methods ∩ * narrowing, then attaches it here. Absent when no snapshot was resolved. */ - readonly effectiveCapabilities?: EffectiveSandboxCapabilities | null; + readonly effectiveCapabilities?: EffectiveExecutionCapabilities | null; /** * Per-run duplex bridge kill switch. The host stamps it on the same seam as * `effectiveCapabilities`. `true` selects the duplex transport only when the @@ -184,12 +189,12 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor */ streamRunLogs?: boolean | null; /** - * The injected duplex telemetry recorder for this run. The host attaches it on - * the same seam as `runner`, so this live object stays on the host and never - * enters the sandbox environment. The bridge binds it to the fixed duplex - * observability surface. Absent means the safe no-op default. + * The injected duplex observability recorder for this run. The host attaches + * it on the same seam as `runner`, so this live object stays on the host and + * never enters the sandbox environment. The bridge binds it to the fixed + * duplex observability surface. Absent means the safe no-op default. */ - duplexTelemetryRecorder?: DuplexTelemetryRecorder | null; + duplexObservabilityRecorder?: DuplexObservabilityRecorder | null; /** * The process-owned aggregate byte ledger for the sandbox duplex channel. The * host stamps this same object on every sandbox target on the same seam as @@ -343,7 +348,7 @@ function readString(value: unknown): string | null { // missing or non-boolean field reads as `false`, so a round-tripped target // never grants a capability that the snapshot did not carry. Returns null when // there is no object to read. -function parseEffectiveSandboxCapabilities(value: unknown): EffectiveSandboxCapabilities | null { +function parseEffectiveExecutionCapabilities(value: unknown): EffectiveExecutionCapabilities | null { const parsed = parseObject(value); if (Object.keys(parsed).length === 0) return null; return { @@ -432,15 +437,15 @@ export function adapterExecutionTargetEnablesSandboxDuplexBridge( } /** - * Read the injected duplex telemetry recorder off a target. Only a sandbox - * target with a recorder attached returns it. Every other target returns null, - * so the bridge falls back to the safe no-op recorder. + * Read the injected duplex observability recorder off a target. Only a + * sandbox target with a recorder attached returns it. Every other target + * returns null, so the bridge falls back to the safe no-op recorder. */ -export function adapterExecutionTargetDuplexTelemetryRecorder( +export function adapterExecutionTargetDuplexObservabilityRecorder( target: AdapterExecutionTarget | null | undefined, -): DuplexTelemetryRecorder | null { +): DuplexObservabilityRecorder | null { return target?.kind === "remote" && target.transport === "sandbox" - ? target.duplexTelemetryRecorder ?? null + ? target.duplexObservabilityRecorder ?? null : null; } @@ -1309,7 +1314,7 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar if (kind === "remote" && readStringMeta(parsed, "transport") === "sandbox") { const remoteCwd = readStringMeta(parsed, "remoteCwd"); if (!remoteCwd) return null; - const effectiveCapabilities = parseEffectiveSandboxCapabilities(parsed.effectiveCapabilities); + const effectiveCapabilities = parseEffectiveExecutionCapabilities(parsed.effectiveCapabilities); return { kind: "remote", transport: "sandbox", @@ -2533,21 +2538,486 @@ function isDuplexRouteBusyError(error: unknown): boolean { */ const DUPLEX_READINESS_BUFFER_CAP_BYTES = DEFAULT_MAX_DUPLEX_FRAME_BYTES + 4_096; +// --------------------------------------------------------------------------- +// http2_v1: the client connection preface scan and the run disposition latch. +// --------------------------------------------------------------------------- + /** - * Derive the nested broker budgets from one forward budget. The response budget - * and the gateway wait budget each add a fixed margin, so the nested budget - * order holds for any finite forward budget. The default forward budget (30 s) - * yields the historical 32 s response budget and 35 s gateway wait budget, so a - * caller that sets no forward budget sees no change. + * The HTTP/2 client connection preface: 24 octets, `PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n` + * (RFC 9113, Section 3.4). A Node `http2.performServerHandshake` call needs a + * `Duplex` whose readable side starts at this exact sequence; one extra + * leading byte makes the preface invalid, and the server reports a + * `PROTOCOL_ERROR`. The server does not skip a leading byte and does not + * search for the sequence, so the host finds the offset itself before it + * hands the channel to the server. */ -function deriveNestedDuplexBrokerBudgets(forwardTimeoutMs: number): DuplexBrokerBudgets { - const responseMargin = - DEFAULT_DUPLEX_BROKER_BUDGETS.responseBudgetMs - DEFAULT_DUPLEX_BROKER_BUDGETS.forwardTimeoutMs; - const gatewayMargin = - DEFAULT_DUPLEX_BROKER_BUDGETS.gatewayWaitMs - DEFAULT_DUPLEX_BROKER_BUDGETS.responseBudgetMs; - const responseBudgetMs = forwardTimeoutMs + responseMargin; - const gatewayWaitMs = responseBudgetMs + gatewayMargin; - return { forwardTimeoutMs, responseBudgetMs, gatewayWaitMs }; +const HTTP2_CLIENT_CONNECTION_PREFACE = Buffer.from( + "505249202a20485454502f322e300d0a0d0a534d0d0a0d0a", + "hex", +); + +/** The one shared empty buffer. The preface scan starts and resets its two + * retained buffers to it. */ +const HTTP2_PREFACE_EMPTY_BUFFER = Buffer.alloc(0); + +// The count of bytes the preface scan's substring search examines, in bytes. +// A search that always starts from the beginning of the retained buffer +// re-examines the whole buffer on every fragment, so this count grows +// quadratically in the number of fragments. `findPrefaceFrom` instead +// resumes from where the prior search left off, so this count stays linear +// in the bytes received. A test reads this count to prove the search work +// does not regress to the quadratic shape. Production code never reads this +// count. +let http2PrefaceScanSearchUnits = 0; + +// The count of bytes the pre-preface scan buffer copies while it grows its +// backing storage. A one-copy-per-fragment append copies the whole retained +// buffer on every fragment, so this count grows quadratically in the number +// of fragments. The doubling-growth approach copies only on a reallocation, +// so this count stays linear in the bytes received. A test reads this count +// to prove the growth work does not regress to the quadratic shape. +// Production code never reads this count. +let http2PrefaceScanBufferGrowthCopyUnits = 0; + +// The same count as `http2PrefaceScanBufferGrowthCopyUnits`, for the +// post-preface replay buffer instead of the pre-preface scan buffer. +let http2PrefaceReplayBufferGrowthCopyUnits = 0; + +/** + * A byte buffer that grows its backing storage by doubling its capacity, + * instead of copying the whole retained buffer on every appended fragment. A + * sender that trickles input in many small fragments would otherwise force + * one full copy of the whole retained buffer per fragment: with the buffer + * growing toward its cap, that is quadratic work in the number of fragments. + * Doubling the backing storage's capacity only when the current capacity + * runs out reallocates and copies a logarithmic number of times, so the + * total copy work stays linear in the bytes received. `countGrowthCopy` + * receives the number of bytes each reallocation copies, so a test can add + * these up and prove the growth work stays linear. + */ +function createGrowableByteBuffer(countGrowthCopy: (copiedBytes: number) => void): { + view: () => Buffer; + length: () => number; + append: (chunk: Uint8Array) => void; + reset: () => void; +} { + let used: Buffer = HTTP2_PREFACE_EMPTY_BUFFER; + let storage: Buffer = HTTP2_PREFACE_EMPTY_BUFFER; + return { + view: () => used, + length: () => used.length, + append: (chunk: Uint8Array): void => { + const usedLength = used.length; + const neededLength = usedLength + chunk.byteLength; + if (neededLength > storage.length) { + let nextCapacity = storage.length === 0 ? chunk.byteLength : storage.length * 2; + while (nextCapacity < neededLength) { + nextCapacity *= 2; + } + const grown = Buffer.allocUnsafe(nextCapacity); + storage.copy(grown, 0, 0, usedLength); + countGrowthCopy(usedLength); + storage = grown; + } + storage.set(chunk, usedLength); + used = storage.subarray(0, neededLength); + }, + reset: (): void => { + used = HTTP2_PREFACE_EMPTY_BUFFER; + storage = HTTP2_PREFACE_EMPTY_BUFFER; + }, + }; +} + +/** + * Find the client connection preface in `buffer` at or after index `from` + * and return its offset, or -1. This counts the real scan distance for a + * test: from `from` up to the found preface's end, or to the end of the + * buffer when it finds none. The count stays linear in the bytes received + * when the caller advances `from` to just short of the buffer's end on every + * miss, instead of always searching from the start of the buffer. + */ +function findPrefaceFrom(buffer: Buffer, from: number): number { + const offset = buffer.indexOf(HTTP2_CLIENT_CONNECTION_PREFACE, from); + const scannedTo = + offset === -1 ? buffer.length : offset + HTTP2_CLIENT_CONNECTION_PREFACE.length; + http2PrefaceScanSearchUnits += Math.max(scannedTo - from, 0); + return offset; +} + +/** + * Wrap the readiness gate's broker channel so its `onData` delivers no byte + * until the client connection preface appears, then delivers every byte from + * the preface onward. + * + * The wrapped channel opens its scan window only on the bytes the readiness + * gate already retained after it accepted the READY line (constraint: the + * scan window opens only after the gate accepts the nonce). Nothing before + * that line ever reaches this scan, because the gate itself discards the + * whole pre-READY buffer on acceptance. The scan buffers at most + * {@link DUPLEX_READINESS_BUFFER_CAP_BYTES}; past that bound with no preface + * found, it calls `onMissing` exactly one time and stops buffering, so the + * caller can abort the open and fall back to `queue_v1`. The function holds + * no prologue byte count: it always scans for the fixed 24-octet sequence, + * never a length. The scan buffer holds untrusted, sandbox-controlled bytes + * on the same footing as the readiness gate's own pre-READY buffer, so, when + * the caller supplies a ledger, it charges each received chunk against the + * process aggregate byte ledger under the `http2_preface_scan` owner before + * the chunk grows the buffer. A refusal fails closed the same way the cap + * does: the function drops the buffer and calls `onMissing`. + * + * The bytes that follow the found preface, before the HTTP/2 server binds a + * downstream listener, land in `pendingAfterPreface`. This buffer holds + * untrusted bytes on the same footing as the scan buffer, so it carries the + * same {@link DUPLEX_READINESS_BUFFER_CAP_BYTES} cap and, when the caller + * supplies a ledger, charges each retained chunk against it under the + * `http2_preface_replay` owner. A chunk that would pass the cap, or that the + * ledger refuses, fails closed: the function + * drops the buffer, releases its ledger tokens, and stops the channel. The + * caller reads {@link replayOverflowed} after the preface settles and, on + * `true`, treats the open the same as a missing preface. + */ +function createHttp2PrefaceScanningChannel( + channel: CommandManagedDuplexChannel, + options: { + capBytes: number; + onFound: () => void; + onMissing: () => void; + ledger?: DuplexAggregateByteLedger | null; + }, +): { + channel: CommandManagedDuplexChannel; + replayOverflowed: () => boolean; + disposeScanBuffer: () => void; +} { + const ledger = options.ledger ?? null; + // The pre-preface scan buffer. `scanBuf.append` grows its backing storage + // by doubling, instead of copying the whole retained buffer on every + // fragment — see {@link createGrowableByteBuffer}. `scanSearchFrom` is the + // first index the next search must examine: `findPrefaceFrom` advances it + // to just short of the buffer's end on every miss, so a fragmented preface + // is found without a full rescan of the retained buffer on every fragment. + const scanBuf = createGrowableByteBuffer((copiedBytes) => { + http2PrefaceScanBufferGrowthCopyUnits += copiedBytes; + }); + let scanSearchFrom = 0; + let sawPreface = false; + let failed = false; + let downstream: ((chunk: Uint8Array) => void) | null = null; + // Bytes found after the preface before a downstream listener attaches. The + // wrapped channel replays them on attach, the same pattern the readiness + // gate itself uses for its own post-READY replay buffer. This also grows + // by doubling, for the same reason as `scanBuf`: fragmented post-preface + // input must not force a full copy of the retained buffer per fragment. + const pendingAfterPreface = createGrowableByteBuffer((copiedBytes) => { + http2PrefaceReplayBufferGrowthCopyUnits += copiedBytes; + }); + // Every `http2_preface_replay` reservation token this buffer holds. The + // function releases each token exactly once, on the downstream handoff or + // on an overflow. + const replayTokens: ReservationToken[] = []; + // Every `http2_preface_scan` reservation token the pre-preface scan buffer + // holds. The scan is untrusted, sandbox-controlled input on the same + // footing as the readiness gate's own pre-READY buffer, so it charges the + // ledger the same way: one reservation per received chunk, released in + // full on the terminal scan outcome — the preface found, the cap passed + // with no match, or a ledger refusal. + const scanTokens: ReservationToken[] = []; + let replayOverflow = false; + + function releaseReplayTokens(): void { + if (!ledger) return; + for (const token of replayTokens) { + ledger.release(token); + } + replayTokens.length = 0; + } + + function releaseScanTokens(): void { + if (!ledger) return; + for (const token of scanTokens) { + ledger.release(token); + } + scanTokens.length = 0; + } + + // Drop the pending buffer, release its tokens, and stop the channel. The + // caller reads `replayOverflowed()` after the preface settles and falls + // back the same way it does for a missing preface. + function overflowAndStop(): void { + replayOverflow = true; + pendingAfterPreface.reset(); + releaseReplayTokens(); + channel.stop(); + } + + function deliver(chunk: Buffer): void { + if (downstream) { + downstream(chunk); + return; + } + if (replayOverflow) return; + if (pendingAfterPreface.length() + chunk.byteLength > options.capBytes) { + overflowAndStop(); + return; + } + if (ledger) { + const token = ledger.reserve("http2_preface_replay", chunk.byteLength); + if (!token) { + overflowAndStop(); + return; + } + replayTokens.push(token); + } + pendingAfterPreface.append(chunk); + } + + channel.onData((chunk) => { + if (failed || replayOverflow) return; + if (sawPreface) { + deliver(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return; + } + const rawChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + // Reject the chunk on its prospective length before it grows the scan + // buffer, the same way `deliver` bounds `pendingAfterPreface`. A single + // oversized chunk — or a chunk that tips an already-large buffer past + // the cap — must fail closed here, before `scanBuf.append` performs the + // allocation. Checking the cap only after the append still bounds the + // retained buffer, but it lets one untrusted chunk force an allocation + // as large as the chunk itself, unbounded by `capBytes`. + if (scanBuf.length() + rawChunk.byteLength > options.capBytes) { + failed = true; + scanBuf.reset(); + scanSearchFrom = 0; + releaseScanTokens(); + options.onMissing(); + return; + } + // Charge this chunk against the aggregate ledger before it grows the + // scan buffer. A refusal fails closed the same way the cap does: drop + // the buffer and report a missing preface. + if (ledger) { + const token = ledger.reserve("http2_preface_scan", rawChunk.byteLength); + if (!token) { + failed = true; + scanBuf.reset(); + scanSearchFrom = 0; + releaseScanTokens(); + options.onMissing(); + return; + } + scanTokens.push(token); + } + scanBuf.append(rawChunk); + const scanBuffer = scanBuf.view(); + const offset = findPrefaceFrom(scanBuffer, scanSearchFrom); + if (offset === -1) { + // No match yet. Resume the next search just short of the buffer's + // end, keeping back an overlap of one octet less than the preface + // length, so a preface split across this fragment and the next one is + // still found. Each byte enters that overlap window a bounded number + // of times, so the total search work stays linear in the bytes + // received, not quadratic in the number of fragments. + scanSearchFrom = Math.max( + 0, + scanBuffer.length - (HTTP2_CLIENT_CONNECTION_PREFACE.length - 1), + ); + return; + } + sawPreface = true; + options.onFound(); + const fromPreface = Buffer.from(scanBuffer.subarray(offset)); + scanBuf.reset(); + scanSearchFrom = 0; + // Release the scan tokens before `deliver` charges the same bytes under + // `http2_preface_replay`. The two calls run inside one synchronous + // callback with no `await` between them, so no other reservation can + // observe the released state in between; releasing first keeps the + // ledger's momentary peak at the real retained bytes, not double them. + releaseScanTokens(); + deliver(fromPreface); + }); + + return { + channel: { + write: (data: Uint8Array) => channel.write(data), + onData: (listener: (chunk: Uint8Array) => void) => { + downstream = listener; + if (pendingAfterPreface.length() > 0) { + // Copy the exact retained bytes instead of handing the listener + // the growable buffer's backing view. That backing storage can + // run ahead of the bytes in use (the doubling growth in + // `pendingAfterPreface.append` over-provisions it), so a raw view + // would keep the whole over-provisioned allocation alive for as + // long as the listener holds its reference. + const replay = Buffer.from(pendingAfterPreface.view()); + pendingAfterPreface.reset(); + listener(replay); + } + // Release every replay token exactly once, after the synchronous + // handoff to the downstream listener. Order is safe here: the + // downstream listener is the bound HTTP/2 server duplex, which holds + // no aggregate-ledger reservation of its own for these bytes, so + // this release cannot overlap a second reservation for the same + // bytes. Contrast the readiness gate's own `readiness_replay` + // handoff, which releases first because its downstream listener (the + // preface scanner) does take its own reservation for the same bytes. + releaseReplayTokens(); + }, + onExit: (listener: (exit: { exitCode: number | null }) => void) => channel.onExit(listener), + stop: () => channel.stop(), + close: () => channel.close(), + }, + replayOverflowed: () => replayOverflow, + /** + * Release every held `http2_preface_scan` token and drop the scan + * buffer, for a caller-side terminal path this function itself never + * reaches — the bound readiness timeout elapsing while the scan is + * still searching, with no preface found and no cap or ledger refusal + * of its own. A call after the preface already matched, or after the + * cap or the ledger already failed the scan closed, is a no-op: both + * paths already released the scan tokens themselves. + */ + disposeScanBuffer: (): void => { + if (sawPreface || failed) return; + failed = true; + scanBuf.reset(); + scanSearchFrom = 0; + releaseScanTokens(); + }, + }; +} + +/** The terminal outcome of the preface scan: either the client preface + * appeared inside the bounded readiness buffer, or it did not. */ +type Http2PrefaceScanResult = "found" | "missing"; + +/** + * Wait for {@link createHttp2PrefaceScanningChannel} to settle: either the + * preface appears, or the scan passes the bound with no match, or the bound + * readiness timeout elapses first. Reapplying the readiness timeout here + * keeps one configured value for both the READY-line wait and this + * immediately-following preface wait; the task adds no new timeout setting. + * Returns the scanning channel alongside the settled result, so the caller + * binds the HTTP/2 server to it only on a `found` result. On a `found` + * result, the caller must still read `replayOverflowed()`: the post-preface + * buffer can overflow its cap or its ledger reservation after the preface + * settles as `found` and before the caller binds a downstream listener. + */ +function scanForHttp2ClientPreface( + channel: CommandManagedDuplexChannel, + options: { capBytes: number; timeoutMs: number; ledger?: DuplexAggregateByteLedger | null }, +): { + scanned: CommandManagedDuplexChannel; + settled: Promise; + replayOverflowed: () => boolean; +} { + let resolveSettled!: (result: Http2PrefaceScanResult) => void; + let settledOnce = false; + const settled = new Promise((resolve) => { + resolveSettled = resolve; + }); + // Reassigned to the real function once `createHttp2PrefaceScanningChannel` + // returns, below. `settle` can only actually run after that point (the + // timer fires later, and `onMissing`/`onFound` fire from inside the + // channel's own `onData`, which registers after this call), so the + // placeholder never runs for real. + let disposeScanBuffer: () => void = () => {}; + const settle = (result: Http2PrefaceScanResult): void => { + if (settledOnce) return; + settledOnce = true; + clearTimeout(timer); + // The bound readiness timeout can elapse while the scan still searches, + // with no preface found and no cap or ledger refusal of its own. That + // path holds no other cleanup, so release its `http2_preface_scan` + // tokens here. A `found` result, or a `missing` result the scan itself + // already failed closed, is a no-op inside `disposeScanBuffer`. + if (result === "missing") disposeScanBuffer(); + resolveSettled(result); + }; + const timer = setTimeout(() => settle("missing"), options.timeoutMs); + timer.unref?.(); + const scan = createHttp2PrefaceScanningChannel(channel, { + capBytes: options.capBytes, + onFound: () => settle("found"), + onMissing: () => settle("missing"), + ledger: options.ledger, + }); + disposeScanBuffer = scan.disposeScanBuffer; + return { scanned: scan.channel, settled, replayOverflowed: scan.replayOverflowed }; +} + +/** + * Test-only surface for {@link scanForHttp2ClientPreface}. A test drives the + * post-preface replay cap and ledger charge across every terminal path + * without the whole bridge. Production code never reads this export. + */ +export const __http2PrefaceScanTesting = { + scanForHttp2ClientPreface: ( + channel: CommandManagedDuplexChannel, + options: { capBytes: number; timeoutMs: number; ledger?: DuplexAggregateByteLedger | null }, + ) => scanForHttp2ClientPreface(channel, options), + readScanSearchUnits: (): number => http2PrefaceScanSearchUnits, + resetScanSearchUnits: (): void => { + http2PrefaceScanSearchUnits = 0; + }, + readScanBufferGrowthCopyUnits: (): number => http2PrefaceScanBufferGrowthCopyUnits, + resetScanBufferGrowthCopyUnits: (): void => { + http2PrefaceScanBufferGrowthCopyUnits = 0; + }, + readReplayBufferGrowthCopyUnits: (): number => http2PrefaceReplayBufferGrowthCopyUnits, + resetReplayBufferGrowthCopyUnits: (): void => { + http2PrefaceReplayBufferGrowthCopyUnits = 0; + }, +}; + +/** + * The terminal run disposition for the `http2_v1` path, in the same shape as + * {@link DuplexBrokerRunDisposition}. A `failed` disposition means a terminal + * loss ordered before an orderly completion, so the run must not report + * success. + */ +interface Http2RunDispositionLatch { + readonly disposition: DuplexBrokerRunDisposition; + /** + * Record a terminal loss. Returns `true` when the call flipped the + * disposition to failed; returns `false` when a loss or an orderly + * completion already latched, so the run already has its terminal result. + * The first recorded loss latches — a later call never overrides it. + */ + recordLoss(reason: DuplexLossReason): boolean; + /** Mark the host-observed orderly completion of the agent turn. A loss that + * already latched keeps the failure. */ + markOrderlyCompletion(): void; + /** Atomically mark the orderly completion and read the disposition. */ + settleRunDisposition(): DuplexBrokerRunDisposition; +} + +function createHttp2RunDispositionLatch(): Http2RunDispositionLatch { + let lossOrdered = false; + let lossReason: DuplexLossReason | null = null; + let completionOrdered = false; + const markOrderlyCompletion = (): void => { + if (completionOrdered || lossOrdered) return; + completionOrdered = true; + }; + return { + get disposition(): DuplexBrokerRunDisposition { + return { failed: lossOrdered, lossReason }; + }, + recordLoss(reason: DuplexLossReason): boolean { + if (lossOrdered || completionOrdered) return false; + lossOrdered = true; + lossReason = reason; + return true; + }, + markOrderlyCompletion, + settleRunDisposition(): DuplexBrokerRunDisposition { + markOrderlyCompletion(); + return { failed: lossOrdered, lossReason }; + }, + }; } /** @@ -2600,21 +3070,36 @@ async function ensureSandboxRunLogDirectory(input: { * and before it decodes a candidate line, so an over-cap prefix never reaches READY * acceptance. The cap check has priority over READY acceptance on every path. */ -// The count of the pre-READY newline-scan work, in UTF-16 code units. Each -// search adds the number of code units it can read. A test reads this count to -// prove the scan work stays linear in the bytes received. Production code never -// reads this count. +// The count of the pre-READY newline-scan work, in bytes. Each search adds the +// number of bytes it can read. A test reads this count to prove the scan work +// stays linear in the bytes received. Production code never reads this count. let duplexReadinessNewlineScanUnits = 0; +// The count of bytes `appendReadinessBytes` copies while it grows the pre-READY +// buffer's backing storage. A one-copy-per-fragment approach copies the whole +// retained buffer on every fragment, so this count grows quadratically in the +// number of fragments. The doubling-growth approach copies only on a +// reallocation, so this count stays linear in the bytes received. A test reads +// this count to prove the growth work does not regress to the quadratic shape. +// Production code never reads this count. +let duplexReadinessBufferGrowthCopyUnits = 0; + +/** The newline byte. The readiness buffer is a byte buffer, not a string. */ +const READINESS_NEWLINE_BYTE = 0x0a; +/** The opening-brace byte. The bracketed-paste retry scans for it, not a string. */ +const READINESS_OPEN_BRACE_BYTE = 0x7b; +/** The one shared empty buffer. The gate starts and resets `buffer` to it. */ +const READINESS_EMPTY_BUFFER = Buffer.alloc(0); + /** * Find the first newline in `buffer` at or after index `from` and return its - * index, or -1. `String#indexOf` reads the code units from `from` up to the - * newline it finds, or to the end of the buffer when it finds none. This helper - * counts that real scan distance for a test. The count stays linear in the bytes + * index, or -1. `Buffer#indexOf` reads the bytes from `from` up to the newline + * it finds, or to the end of the buffer when it finds none. This helper counts + * that real scan distance for a test. The count stays linear in the bytes * received when the caller advances `from` past each newline it consumes. */ -function findNewlineFrom(buffer: string, from: number): number { - const newlineIndex = buffer.indexOf("\n", from); +function findNewlineFrom(buffer: Buffer, from: number): number { + const newlineIndex = buffer.indexOf(READINESS_NEWLINE_BYTE, from); const scanned = newlineIndex === -1 ? buffer.length - from : newlineIndex - from + 1; duplexReadinessNewlineScanUnits += scanned; return newlineIndex; @@ -2630,6 +3115,10 @@ export const __duplexReadinessTesting = { resetNewlineScanUnits: (): void => { duplexReadinessNewlineScanUnits = 0; }, + readBufferGrowthCopyUnits: (): number => duplexReadinessBufferGrowthCopyUnits, + resetBufferGrowthCopyUnits: (): void => { + duplexReadinessBufferGrowthCopyUnits = 0; + }, // Build one readiness gate over a supplied channel, so a test can drive the // readiness-replay reservation lifecycle across every terminal path without the // whole bridge. Production code never reads this factory. @@ -2666,8 +3155,8 @@ interface DuplexReadinessGate { */ disposePendingReplay(): void; /** - * Test-only. Report the length of the retained pre-READY buffer, in UTF-16 code - * units. A test reads this to prove the gate drops the pre-READY buffer on READY + * Test-only. Report the length of the retained pre-READY buffer, in bytes. A + * test reads this to prove the gate drops the pre-READY buffer on READY * acceptance, so the process does not retain the sandbox-controlled prefix. * Production code does not read this. */ @@ -2725,20 +3214,56 @@ function createDuplexReadinessGate( } replayTokens.length = 0; } - // The raw bytes the host reads before the READY frame completes. The buffer is - // append-only, so the O(1) cap check on `buffer.length` stays valid. - let buffer = ""; + // The raw bytes the host reads before the READY frame completes. `buffer` is + // append-only and always a zero-copy view over the used prefix of `storage`, + // so the O(1) cap check on `buffer.length` stays valid. + let buffer: Buffer = READINESS_EMPTY_BUFFER; + // The backing storage for `buffer`. `appendReadinessBytes` grows this by + // doubling its capacity, instead of copying the whole retained buffer on + // every fragment. See `appendReadinessBytes` for why this bounds the total + // copy work. + let storage: Buffer = READINESS_EMPTY_BUFFER; // The start index of the current line in `buffer`. A leading blank line // advances this cursor past its newline without a buffer copy. let lineStart = 0; // The next index to search for a newline. The gate scans from here, so each - // code unit is read at most one time for the newline search. + // byte is read at most one time for the newline search. let scanFrom = 0; + + /** + * Append `chunk` to the pre-READY buffer without copying the bytes already + * retained. A sender that trickles the handshake in many small fragments + * (a slow socket, a byte-at-a-time PTY echo) would otherwise force one full + * copy of the whole retained buffer per fragment: with `buffer` growing + * toward the {@link DUPLEX_READINESS_BUFFER_CAP_BYTES} cap, that is + * quadratic work in the number of fragments. This instead grows `storage` + * by doubling its capacity only when the current capacity runs out, so the + * backing store reallocates and copies a logarithmic number of times, and + * each append copies only the incoming chunk. The used length still reads + * in O(1) through `buffer.length`, so every cap check and slice below stays + * unchanged. + */ + function appendReadinessBytes(chunk: Uint8Array): void { + const usedLength = buffer.length; + const neededLength = usedLength + chunk.byteLength; + if (neededLength > storage.length) { + let nextCapacity = storage.length === 0 ? chunk.byteLength : storage.length * 2; + while (nextCapacity < neededLength) { + nextCapacity *= 2; + } + const grown = Buffer.allocUnsafe(nextCapacity); + storage.copy(grown, 0, 0, usedLength); + duplexReadinessBufferGrowthCopyUnits += usedLength; + storage = grown; + } + storage.set(chunk, usedLength); + buffer = storage.subarray(0, neededLength); + } // The bytes that followed the READY frame, held until the broker binds. - let pending = ""; + let pending: Uint8Array = READINESS_EMPTY_BUFFER; // The exit that arrived after READY but before the broker bound, if any. let pendingExit: { exitCode: number | null } | null = null; - let dataSink: ((chunk: string) => void) | null = null; + let dataSink: ((chunk: Uint8Array) => void) | null = null; let exitSink: ((exit: { exitCode: number | null }) => void) | null = null; let resolveReady!: (result: DuplexReadinessResult) => void; const ready = new Promise((resolve) => { @@ -2775,17 +3300,20 @@ function createDuplexReadinessGate( // with the aggregate marker, because `ready` already resolved before this // synchronous post-READY chunk arrived. if (ledger) { - const token = ledger.reserve("readiness_replay", Buffer.byteLength(chunk, "utf8")); + const token = ledger.reserve("readiness_replay", chunk.byteLength); if (!token) { replayOverflow = true; - pending = ""; + pending = READINESS_EMPTY_BUFFER; releaseReplayTokens(); channel.stop(); return; } replayTokens.push(token); } - pending += chunk; + // Copy a first chunk instead of aliasing the caller's `Uint8Array`, so a + // channel that reuses its delivered buffer across calls cannot corrupt the + // bytes this gate holds for the broker replay. + pending = pending.length === 0 ? Buffer.from(chunk) : Buffer.concat([pending, chunk]); return; } if (settled) { @@ -2794,12 +3322,12 @@ function createDuplexReadinessGate( // never grows the buffer after the gate settles. return; } - // Reserve the exact UTF-8 bytes of this chunk against the aggregate ledger - // before the gate retains it. The pre-READY buffer holds untrusted bytes, so - // a flood counts toward the process aggregate ceiling. A rejection fails + // Reserve the exact bytes of this chunk against the aggregate ledger before + // the gate retains it. The pre-READY buffer holds untrusted bytes, so a + // flood counts toward the process aggregate ceiling. A rejection fails // closed: the gate retains nothing more and falls back to the file bridge. if (ledger) { - const token = ledger.reserve("readiness_buffer", Buffer.byteLength(chunk, "utf8")); + const token = ledger.reserve("readiness_buffer", chunk.byteLength); if (!token) { finish({ ok: false, reason: "aggregate_bytes_exceeded" }); return; @@ -2807,9 +3335,11 @@ function createDuplexReadinessGate( retainedTokens.push(token); } // Append the new bytes and continue the newline search from `scanFrom`, the - // first index not yet examined. Each code unit is read at most one time for - // the search, so the total scan work stays linear in the bytes received. - buffer += chunk; + // first index not yet examined. Each byte is read at most one time for the + // search, and `appendReadinessBytes` copies at most the incoming chunk, so + // the total work stays linear in the bytes received, not in the number of + // fragments they arrive in. + appendReadinessBytes(chunk); for (;;) { const newlineIndex = findNewlineFrom(buffer, scanFrom); if (newlineIndex === -1) { @@ -2820,13 +3350,8 @@ function createDuplexReadinessGate( // READY frame, so finish with protocol contamination. The retained // skipped lines count against the cap; that is acceptable and fail-closed. // - // Gate on buffer.length, the UTF-16 code-unit count, which is O(1). - // Buffer.byteLength is O(n), so a byte check on every newline-less chunk - // makes the pre-READY window quadratic in the bytes received. The UTF-8 - // byte length is greater than or equal to the UTF-16 code-unit count for - // every string, so this check never fires before the byte cap is truly - // exceeded. It can fire late by at most a factor of 3, so peak buffer - // memory stays bounded near 3 MB. + // `buffer` is a byte buffer, so `buffer.length` is the exact byte count, + // read in O(1). if (buffer.length > DUPLEX_READINESS_BUFFER_CAP_BYTES) { finish({ ok: false, reason: "protocol_contamination" }); } @@ -2867,7 +3392,7 @@ function createDuplexReadinessGate( // strict decode over the remainder of the line, and readiness still // authenticates on the nonce below, so a prefix cannot forge a frame or // smuggle a second one — it can only be discarded. - const braceIndex = line.indexOf("{"); + const braceIndex = line.indexOf(READINESS_OPEN_BRACE_BYTE); if (braceIndex > 0) { decoded = decodeDuplexLine(line.slice(braceIndex)); } @@ -2885,18 +3410,27 @@ function createDuplexReadinessGate( // keeps the transient charge equal to the suffix, not the sum of the dropped // prefix and the retained suffix. The two steps run in one synchronous // section, so no other route can take the freed bytes in between. - const suffix = buffer.slice(newlineIndex + 1); - // Drop the original pre-READY buffer now. The gate keeps only the - // retained suffix as `pending`, and it charges that suffix under - // `readiness_replay` below. If the gate keeps the buffer, the process - // retains the full sandbox-controlled string while the ledger counts - // only the suffix, so aggregate retention passes the ceiling. This clear - // also covers the broker handoff and the replay disposal. Both run later - // and read no buffer bytes. - buffer = ""; + // + // Copy the suffix instead of slicing it off `buffer`. `buffer` is a view + // over `storage`, and `storage`'s capacity can run ahead of the bytes in + // use (the doubling growth in `appendReadinessBytes` over-provisions it). + // A slice would keep that whole over-provisioned allocation alive, so the + // process would retain more physical bytes than the ledger charges under + // `readiness_replay`. The copy is exactly `suffix.length` bytes, one time, + // not a per-fragment cost. + const suffix = Buffer.from(buffer.subarray(newlineIndex + 1)); + // Drop the original pre-READY buffer and its backing storage now. The + // gate keeps only the retained suffix as `pending`, and it charges that + // suffix under `readiness_replay` below. If the gate keeps `storage`, the + // process retains the full sandbox-controlled bytes while the ledger + // counts only the suffix, so aggregate retention passes the ceiling. This + // clear also covers the broker handoff and the replay disposal. Both run + // later and read no buffer bytes. + buffer = READINESS_EMPTY_BUFFER; + storage = READINESS_EMPTY_BUFFER; releaseReadinessBufferTokens(); if (ledger && suffix.length > 0) { - const token = ledger.reserve("readiness_replay", Buffer.byteLength(suffix, "utf8")); + const token = ledger.reserve("readiness_replay", suffix.byteLength); if (!token) { // The retained suffix passes the aggregate ceiling. Fail closed: drop // the suffix and fall back to the file bridge with the aggregate marker. @@ -2937,18 +3471,24 @@ function createDuplexReadinessGate( }); const brokerChannel: CommandManagedDuplexChannel = { - write: (data: string) => channel.write(data), - onData: (listener: (chunk: string) => void) => { + write: (data: Uint8Array) => channel.write(data), + onData: (listener: (chunk: Uint8Array) => void) => { dataSink = listener; if (pending.length > 0) { const replay = pending; - pending = ""; + pending = READINESS_EMPTY_BUFFER; + // Release every readiness-replay token before the synchronous handoff + // to the broker, not after. The broker (the HTTP/2 preface scanner) + // charges its own reservation for these same bytes inside + // `listener(replay)` below, under a different owner. Releasing first + // keeps the ledger's momentary peak at the real retained bytes, not + // double them: the release and the broker's reserve both run inside + // this one synchronous call, with no `await` between them, so no + // other route can claim the freed capacity in between. + releaseReplayTokens(); listener(replay); + return; } - // Release every readiness-replay token exactly once, after the synchronous - // handoff to the broker. The broker charges its own decode retention inside - // the `listener(replay)` call above, so the release here never opens an - // admission gap for the same retained bytes. releaseReplayTokens(); }, onExit: (listener: (exit: { exitCode: number | null }) => void) => { @@ -2968,7 +3508,7 @@ function createDuplexReadinessGate( brokerChannel, replayOverflowed: () => replayOverflow, disposePendingReplay: () => { - pending = ""; + pending = READINESS_EMPTY_BUFFER; releaseReplayTokens(); }, retainedReadinessBufferLength: () => buffer.length, @@ -3023,6 +3563,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { // `duplexCommandStream` is exactly `true`. Any other value of either gate // selects the file bridge. The caller reads this from the experimental instance // setting `enableSandboxDuplexBridge`. The default is the file bridge. + // HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. enableSandboxDuplexBridge?: boolean | null; // The deadline for the duplex readiness handshake, in milliseconds. On a // timeout the host closes the partial channel and selects the file bridge. The @@ -3043,7 +3584,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { // span, the request span, the guarded counters, and the transport event. The // default is a no-op recorder, so the surface stays inert until the host injects // a real recorder. - duplexTelemetryRecorder?: DuplexTelemetryRecorder | null; + duplexObservabilityRecorder?: DuplexObservabilityRecorder | null; }): Promise { if (!adapterExecutionTargetUsesPaperclipBridge(input.target)) { return null; @@ -3114,9 +3655,12 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { // is a no-op, so the facade is inert until the host injects a real recorder. const duplexProviderKey = "providerKey" in target ? target.providerKey ?? undefined : undefined; - const duplexTelemetry = createDuplexTelemetry({ - recorder: input.duplexTelemetryRecorder ?? undefined, + const duplexObservability = createDuplexObservability({ + recorder: input.duplexObservabilityRecorder ?? undefined, providerKey: duplexProviderKey, + // http2_v1 is the one active non-file transport now; every non-file + // record this facade produces stamps the `http2` transport value. + transport: "http2", }); // PAPERCLIP_BRIDGE_DEBUG opts into verbose stdout logs of every bridge proxy @@ -3132,9 +3676,24 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { // file bridge and the duplex broker. The sandbox request carries only the // bridge token; the real agent token never leaves the host. const forwardBridgeRequest = async ( - request: { method: string; path: string; query: string; headers: Record; body: string }, + request: { + method: string; + path: string; + query: string; + headers: Record; + /** The file bridge passes the whole request body here as one string. */ + body?: string; + }, signal?: AbortSignal, - options?: { suppressDebugLog?: boolean }, + options?: { + suppressDebugLog?: boolean; + /** + * The duplex broker passes the reassembled request body here. The forward + * streams it to the host, so a spilled body never loads into memory on the + * receive side. The forward never disposes it; the broker owns its lifecycle. + */ + reassembledBody?: ReassembledBody; + }, ): Promise<{ status: number; headers: Record; body: string }> => { const method = request.method.trim().toUpperCase() || "GET"; // The per-request debug log prints the method, the path, and the query. The @@ -3159,12 +3718,26 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { // the forward budget here, whichever comes first. const timeoutSignal = AbortSignal.timeout(forwardTimeoutMs); const forwardSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; - const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), { + // Build the request-body init. A GET or a HEAD carries no body. The duplex + // broker passes the reassembled body: stream it, so a body that spilled to + // disk never loads into memory here. A streamed body needs `duplex: "half"`. + // The file bridge passes the whole body as one string. + const forwardInit: RequestInit & { duplex?: "half" } = { method, headers, - ...(method === "GET" || method === "HEAD" ? {} : { body: request.body }), signal: forwardSignal, - }); + }; + if (method !== "GET" && method !== "HEAD") { + if (options?.reassembledBody) { + forwardInit.body = Readable.toWeb( + options.reassembledBody.createReadStream(), + ) as unknown as ReadableStream; + forwardInit.duplex = "half"; + } else if (typeof request.body === "string") { + forwardInit.body = request.body; + } + } + const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), forwardInit); if (emitDebugLog) { await onLog( "stdout", @@ -3229,6 +3802,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { // experimental setting is exactly `true`, the resolved capability // `duplexCommandStream` is exactly `true`, and the runner exposes the duplex // channel. Any other value of either gate selects the file bridge below. + // HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. const duplexRequested = input.enableSandboxDuplexBridge === true; const capabilityGranted = "effectiveCapabilities" in target && @@ -3239,14 +3813,14 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { // capability or the runner method absent. A later channel-open failure records // its own fallback through the channel-open attempt below. if (!duplexRequested) { - duplexTelemetry.recordFallback("gate_off"); + duplexObservability.recordFallback("gate_off"); } else if (!capabilityGranted || typeof openDuplexChannel !== "function") { - duplexTelemetry.recordFallback("capability_absent"); + duplexObservability.recordFallback("capability_absent"); } if (duplexRequested && capabilityGranted && typeof openDuplexChannel === "function") { // Begin the channel-open attempt. The block reports exactly one terminal: // `ready` on success, or `fallback(reason)` on an open or a readiness failure. - const duplexChannelOpen = duplexTelemetry.startChannelOpen(); + const duplexChannelOpen = duplexObservability.startChannelOpen(); const readinessTimeoutMs = typeof input.duplexReadinessTimeoutMs === "number" && Number.isFinite(input.duplexReadinessTimeoutMs) && @@ -3289,7 +3863,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { shellCommand, }); const gatewayEnv: Record = { - PAPERCLIP_API_BRIDGE_MODE: SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE, + PAPERCLIP_API_BRIDGE_MODE: SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE, PAPERCLIP_BRIDGE_TOKEN: bridgeToken, PAPERCLIP_BRIDGE_HOST: "127.0.0.1", PAPERCLIP_BRIDGE_PORT: String(assignedPort), @@ -3367,91 +3941,144 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { "[paperclip] Sandbox duplex readiness replay exceeded the aggregate byte ceiling (aggregate_bytes_exceeded). Using the file bridge.\n", ); } else { - // Readiness passed. Construct the broker inside the guarded region, so a - // construction throw closes the channel within the cleanup budget and - // selects the file bridge. The broker enforces the same route allowlist - // as the file bridge, then forwards each request with the real token and - // the run id. The agent environment below receives the bridge URL and - // token only now, after readiness passed. - let broker: DuplexBridgeBroker | null = null; - try { - broker = createDuplexBridgeBroker({ - channel: gate.brokerChannel, - // Derive the nested budgets from the forward budget, so any forward - // budget keeps the nested order the broker asserts at construction. - budgets: deriveNestedDuplexBrokerBudgets(forwardTimeoutMs), - forwardRequest: async ( - request: DuplexRequestFrame, - options: { signal: AbortSignal }, - ): Promise => { - const denialReason = authorizeSandboxCallbackBridgeRequestWithRoutes(request); - if (denialReason) { - return { - status: 403, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ error: denialReason }), - }; - } - // Apply the header allowlist on the host, next to the route check. - // The host is the trust boundary: the sandbox controls the frame - // headers, so the host drops every header outside the allowlist - // before the authenticated forward. The forward then applies the - // real token and the run id. - const sanitizedRequest = { - ...request, - headers: sanitizeSandboxCallbackBridgeHeaders(request.headers), - }; - // Suppress the per-request debug log on the duplex path, so no route - // or query rides a log line here. - return forwardBridgeRequest(sanitizedRequest, options.signal, { - suppressDebugLog: true, + // Readiness passed. The gate retained every byte that followed the + // accepted READY line. Scan those retained bytes for the HTTP/2 + // client connection preface — the scan window opens only now, after + // the gate accepted the nonce — and start the HTTP/2 session at that + // offset, inclusive. A missing preface inside the bounded readiness + // buffer aborts the open; the run falls back to `queue_v1` exactly + // one time (accepted security fix 6). + const openedChannel = channel; + const prefaceScan = scanForHttp2ClientPreface(gate.brokerChannel, { + capBytes: DUPLEX_READINESS_BUFFER_CAP_BYTES, + timeoutMs: readinessTimeoutMs, + // Inject the same host-process aggregate byte ledger the readiness + // gate charges, so the post-preface pre-bind buffer counts toward + // the same aggregate ceiling. + ledger: duplexAggregateByteLedger, + }); + const prefaceResult = await prefaceScan.settled; + if (prefaceResult === "missing" || prefaceScan.replayOverflowed()) { + // Fail closed, the same shape as a readiness failure: close the + // partial channel inside the cleanup budget, then select the file + // bridge. No HTTP/2 server ever bound to this channel, so no + // request reached it or any endpoint. + gate.disposePendingReplay(); + await closeDuplexChannelWithinBudget(openedChannel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS); + if (prefaceResult === "missing") { + duplexChannelOpen.fallback("preface_missing"); + await onLog( + "stderr", + "[paperclip] Sandbox HTTP/2 client preface did not appear inside the bounded readiness buffer (preface_missing). Using the file bridge.\n", + ); + } else { + duplexChannelOpen.fallback("aggregate_bytes_exceeded"); + await onLog( + "stderr", + "[paperclip] Sandbox HTTP/2 post-preface buffer exceeded the aggregate byte ceiling (aggregate_bytes_exceeded). Using the file bridge.\n", + ); + } + } else { + // The run disposition latch for the http2_v1 path, in the same + // shape the retired duplex_v1 broker exposed. A loss ordered before + // an orderly completion reports a failure with the typed loss + // reason; every other state reports a success. + const dispositionLatch = createHttp2RunDispositionLatch(); + // Set before the first await inside the forward handler, so a loss + // that lands mid-request still classifies as `post_dispatch`. + let anyStreamDispatched = false; + + const recordHttp2Loss = (event: Http2TelemetryEventName): void => { + const reason = mapHttp2EventToDuplexLossReason(event); + const disposedNow = dispositionLatch.recordLoss(reason); + if (!disposedNow && (reason === "provider_exit" || reason === "transport_closed")) { + // A clean channel end that orders after a host-observed orderly + // completion is a normal teardown, not a loss: the run already + // completed. Emit no loss telemetry and no log line for it, the + // same policy the retired duplex_v1 broker applied. + return; + } + const lossClass = anyStreamDispatched ? "post_dispatch" : "pre_dispatch"; + duplexObservability.recordLoss(lossClass, reason); + void onLog("stderr", `[paperclip] Sandbox HTTP/2 channel lost (${reason}). The run fails.\n`); + }; + + // The forward handler applies the real host token and the run id + // through the existing `forwardBridgeRequest` — the same function + // the file bridge uses. The route allowlist, the header allowlist, + // and the per-request debug-log suppression already ran inside + // `createHttp2BridgeServer`'s own stream handler before this call. + // It records the request span with the same latency-and-outcome + // shape the retired duplex_v1 broker recorded: `ok` for any + // delivered host response (any status), `error` only when the + // forward call itself throws. + const http2ForwardRequest: Http2BridgeForwardHandler = async (request) => { + anyStreamDispatched = true; + const dispatchStartMs = Date.now(); + try { + const result = await forwardBridgeRequest( + { + method: request.method, + path: request.pathname, + query: request.query, + headers: request.headers, + body: request.body.toString("utf8"), + }, + undefined, + { suppressDebugLog: true }, + ); + duplexObservability.recordRequest({ latencyMs: Date.now() - dispatchStartMs, outcome: "ok" }); + return { status: result.status, headers: result.headers, body: result.body }; + } catch (error) { + duplexObservability.recordRequest({ latencyMs: Date.now() - dispatchStartMs, outcome: "error" }); + throw error; + } + }; + + const http2Server = createHttp2BridgeServer({ + bridgeToken, + forwardRequest: http2ForwardRequest, + onGoaway: () => recordHttp2Loss("session_goaway"), + onSessionError: () => recordHttp2Loss("session_error"), + }); + // Combine the loss hook with the forward-to-Duplex handoff inside + // one `onExit` registration: the channel primitive holds exactly + // one listener slot, and `bindChannel` below registers the one that + // ends the wrapped `Duplex`. + // + // This object's `stop()` is also the real sandbox-side effect of + // the post-bind read backpressure bound `bindChannel` applies + // (`wrapDuplexChannelAsNodeDuplex` in `http2-bridge-server.ts`): + // this raw provider channel exposes no pause, so once the bounded + // read queue there overflows, it calls `stop()` through this exact + // chain, down to `prefaceScan.scanned.stop()` and on to the real + // channel, instead of letting sandbox-controlled bytes grow host + // memory with no bound. + const channelForHttp2Server: CommandManagedDuplexChannel = { + write: (data: Uint8Array) => prefaceScan.scanned.write(data), + onData: (listener: (chunk: Uint8Array) => void) => prefaceScan.scanned.onData(listener), + onExit: (listener: (exit: { exitCode: number | null }) => void) => { + prefaceScan.scanned.onExit((exit) => { + recordHttp2Loss("channel_exit"); + listener(exit); }); }, - // The duplex path emits only the fixed transport telemetry. It passes no - // free-form logger, so no raw provider error rides a log line here. - telemetry: duplexTelemetry, - // Surface a terminal channel loss on the run log. The broker latches - // the failure on its ordered lifecycle; the host names only the typed, - // closed loss reason here, never the raw provider message. The caller - // reads the latched disposition through `readRunDisposition` at the - // run-disposition seam. - onLoss: (record) => { - void onLog( - "stderr", - `[paperclip] Sandbox duplex channel lost (${typedDuplexLossReason(record.reason)}). The run fails.\n`, - ); - }, - // Inject the one host-process aggregate byte ledger. The broker - // reserves the retained request-frame, request-payload, and no-replay - // set-entry bytes against it, so the aggregate retained bytes across all - // live routes stay under the ceiling. It is the same object the - // response-body reader reads off the stamped target above. - duplexAggregateByteLedger, - }); - } catch { - // The broker construction failed, so no broker owns the channel. The - // broker never bound, so it never released the pending replay reservation; - // release it here. Then close the channel within the cleanup budget and - // select the file bridge. The log line names no raw error, so no raw error - // rides a log line on the duplex path. - gate.disposePendingReplay(); - await closeDuplexChannelWithinBudget(channel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS); - duplexChannelOpen.fallback("broker_construction_failed"); - await onLog( - "stderr", - "[paperclip] Could not start the sandbox duplex broker. Using the file bridge.\n", - ); - } - if (broker) { - const activeBroker = broker; - activeBroker.start(); + stop: () => prefaceScan.scanned.stop(), + close: () => prefaceScan.scanned.close(), + }; + const boundDuplex = http2Server.bindChannel(channelForHttp2Server); + // Also catches the `Duplex` this wrapper destroys when the bounded + // read backpressure queue overflows post-bind, so that loss still + // reaches `recordHttp2Loss` the same way any other write fault does. + boundDuplex.on("error", () => recordHttp2Loss("write_error")); + duplexChannelOpen.ready(); await onLog( "stdout", - "[paperclip] Sandbox duplex transport ready; serving the host-assigned origin.\n", + "[paperclip] Sandbox HTTP/2 transport ready; serving the host-assigned origin.\n", ); - // Stream run logs on the duplex path with the same gate and the same - // log line as the file path. The duplex path starts no file-bridge + // Stream run logs on the http2 path with the same gate and the same + // log line as the file path. The http2 path starts no file-bridge // worker, so create the log directory before the tail starts. let duplexRunLogTail: SandboxRunLogTailFactory | null = null; if (target.transport === "sandbox" && target.streamRunLogs !== false) { @@ -3475,32 +4102,21 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { env: { PAPERCLIP_API_URL: sandboxOrigin, PAPERCLIP_API_KEY: bridgeToken, - PAPERCLIP_API_BRIDGE_MODE: SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE, + PAPERCLIP_API_BRIDGE_MODE: SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE, }, runLogTail: duplexRunLogTail, - // Read the broker's ordered lifecycle latch at the run-disposition - // seam. A loss ordered before an orderly completion reports a failure - // with the typed loss reason; every other state reports a success. - readRunDisposition: (): DuplexBrokerRunDisposition => activeBroker.runDisposition, + readRunDisposition: (): DuplexBrokerRunDisposition => dispositionLatch.disposition, // Atomically read the latch and mark the orderly completion for the // ACP success-eligible terminal, so no await separates the read from // the mark and a teardown loss cannot slip in between. - settleRunDisposition: (): DuplexBrokerRunDisposition => activeBroker.settleRunDisposition(), - // Surface the broker's orderly-completion mark to the run-disposition - // seam. The seam marks the completion for a success-eligible terminal, - // so a teardown loss after the completion stays a normal teardown. - markOrderlyCompletion: (): void => activeBroker.markOrderlyCompletion(), + settleRunDisposition: (): DuplexBrokerRunDisposition => dispositionLatch.settleRunDisposition(), + markOrderlyCompletion: (): void => dispositionLatch.markOrderlyCompletion(), stop: async () => { - // Close the channel before lease release. The broker sends an orderly - // close and releases the route, then stops the child, so no live - // provider session remains when the caller releases the lease. - await activeBroker.close(); - activeBroker.stop(); - // A channel that did not reach the `closed` state may leave a live - // provider session, so record one session leak. - if (activeBroker.state !== "closed") { - duplexTelemetry.recordSessionLeak(); - } + // Close the HTTP/2 server's sessions, then the channel, before + // lease release, so no live provider session remains when the + // caller releases the lease. + await http2Server.close(); + await closeDuplexChannelWithinBudget(openedChannel, DEFAULT_DUPLEX_CLEANUP_BUDGET_MS); await bridgeAsset.cleanup(); }, }; diff --git a/packages/adapter-utils/src/http2-bridge-server.test.ts b/packages/adapter-utils/src/http2-bridge-server.test.ts new file mode 100644 index 00000000000..113aa212e49 --- /dev/null +++ b/packages/adapter-utils/src/http2-bridge-server.test.ts @@ -0,0 +1,1075 @@ +import { duplexPair } from "node:stream"; +import type { Duplex } from "node:stream"; +import http2 from "node:http2"; + +import { describe, expect, it } from "vitest"; + +import { + buildHttp2BridgeForwardUrl, + classifyStreamAgainstGoaway, + createHttp2BridgeServer, + parseCanonicalBridgeRequestPath, + wrapDuplexChannelAsNodeDuplex, + DEFAULT_HTTP2_BRIDGE_PING_INTERVAL_MS, + DEFAULT_HTTP2_BRIDGE_PING_STALL_MS, + HTTP2_BRIDGE_ENABLE_PUSH, + HTTP2_BRIDGE_HEADER_TABLE_SIZE, + HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS, + HTTP2_BRIDGE_MAX_DEFLATE_DYNAMIC_TABLE_SIZE, + HTTP2_BRIDGE_MAX_HEADER_LIST_PAIRS, + HTTP2_BRIDGE_MAX_HEADER_LIST_SIZE, + HTTP2_BRIDGE_MAX_SESSION_INVALID_FRAMES, + HTTP2_BRIDGE_MAX_SESSION_MEMORY, + HTTP2_BRIDGE_MAX_SESSION_REJECTED_STREAMS, + HTTP2_BRIDGE_SERVER_OPTIONS, + HTTP2_BRIDGE_STREAM_RESET_BURST, + HTTP2_BRIDGE_STREAM_RESET_RATE, + type Http2BridgeForwardRequest, + type Http2BridgeForwardResult, + type Http2BridgeGoawayRecord, +} from "./http2-bridge-server.js"; +import { createSandboxHttp2BridgeGateway } from "./sandbox-callback-bridge.js"; +import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js"; + +/** + * Unit harness for the host HTTP/2 server and the sandbox HTTP/2 client + * gateway. Every test connects the pair over one paired in-memory `Duplex` + * (`node:stream`'s `duplexPair`) — no real TCP socket and no spawned sandbox + * process. This proves the two halves speak one wire-compatible HTTP/2 + * session with no network in between. + */ + +const BRIDGE_TOKEN = "test-bridge-token-fixed-length-32"; + +/** Wrap one side of a paired `Duplex` as a minimal fake `CommandManagedDuplexChannel`. */ +function fakeChannelFromDuplex(duplex: Duplex): CommandManagedDuplexChannel { + const dataListeners: Array<(chunk: Uint8Array) => void> = []; + const exitListeners: Array<(exit: { exitCode: number | null; transportClosed?: boolean }) => void> = []; + duplex.on("data", (chunk: Buffer) => { + for (const listener of dataListeners) listener(chunk); + }); + duplex.on("end", () => { + for (const listener of exitListeners) listener({ exitCode: null, transportClosed: true }); + }); + return { + write: (data) => { + duplex.write(Buffer.from(data)); + }, + onData: (listener) => { + dataListeners.push(listener); + }, + onExit: (listener) => { + exitListeners.push(listener); + }, + stop: () => { + duplex.destroy(); + }, + close: async () => { + duplex.end(); + }, + }; +} + +interface TestPairOptions { + forwardRequest?: (request: Http2BridgeForwardRequest) => Promise; + bridgeToken?: string; + pingIntervalMs?: number; + pingStallMs?: number; + requestBodyTimeoutMs?: number; + requestBodyLifetimeCeilingMs?: number; + closeGraceMs?: number; + onGoaway?: (record: Http2BridgeGoawayRecord) => void; + onSessionError?: (error: Error) => void; + onSession?: (session: http2.ServerHttp2Session) => void; +} + +/** Bind the host server to one side of a fresh paired in-memory `Duplex`. + * Returns the other side, unbound, for a caller to connect a client to. */ +function bindTestServer(options: TestPairOptions = {}) { + const bridgeToken = options.bridgeToken ?? BRIDGE_TOKEN; + const [serverSide, clientSide] = duplexPair(); + const forwardRequest = + options.forwardRequest ?? + (async (request: Http2BridgeForwardRequest) => ({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ echoedMethod: request.method, echoedPath: request.pathname }), + })); + const handle = createHttp2BridgeServer({ + bridgeToken, + forwardRequest, + pingIntervalMs: options.pingIntervalMs, + pingStallMs: options.pingStallMs, + requestBodyTimeoutMs: options.requestBodyTimeoutMs, + requestBodyLifetimeCeilingMs: options.requestBodyLifetimeCeilingMs, + closeGraceMs: options.closeGraceMs, + onGoaway: options.onGoaway, + onSessionError: options.onSessionError, + onSession: options.onSession, + }); + const channel = fakeChannelFromDuplex(serverSide); + handle.bindChannel(channel); + return { handle, bridgeToken, clientSide, serverSide }; +} + +/** Bind the server, then connect the sandbox HTTP/2 client gateway to the + * other side of the same paired in-memory `Duplex`. */ +function createTestPair(options: TestPairOptions = {}) { + const { handle, bridgeToken, clientSide, serverSide } = bindTestServer(options); + const gateway = createSandboxHttp2BridgeGateway({ + bridgeToken, + createConnection: () => clientSide, + }); + return { handle, gateway, bridgeToken, clientSide, serverSide }; +} + +/** Open a raw HTTP/2 client session directly against one side of the pair, + * bypassing the sandbox gateway. Some tests need direct stream control (an + * explicit RST_STREAM, an explicit GOAWAY) the gateway's `forwardRequest` + * abstraction does not expose. */ +function connectRawClient(clientSide: Duplex): http2.ClientHttp2Session { + return http2.connect("http://bridge.internal", { createConnection: () => clientSide }); +} + +/** Track whether `forwardRequest` ran, so a test can prove a denied or + * destroyed stream never reached it. Call `markCalled()` from inside + * `forwardRequest`, then assert on `.called`. */ +function createForwarderCallTracker(): { called: boolean; markCalled: () => void } { + const tracker = { + called: false, + markCalled(): void { + tracker.called = true; + }, + }; + return tracker; +} + +/** Send one more request over `rawClient` and wait for it to complete, so a + * test can prove the session survived a prior faulted, stalled, or denied + * stream. */ +async function expectSessionStillServesARequest( + rawClient: http2.ClientHttp2Session, + request: { method: string; path: string; token: string }, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = rawClient.request({ + ":method": request.method, + ":path": request.path, + authorization: `Bearer ${request.token}`, + }); + let status = 0; + let body = ""; + req.setEncoding("utf8"); + req.on("response", (headers) => { + status = Number(headers[":status"]) || 0; + }); + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => resolve({ status, body })); + req.on("error", reject); + req.end(); + }); +} + +describe("createHttp2BridgeServer + createSandboxHttp2BridgeGateway", () => { + it("test_one_session_over_a_fake_channel_forwards_a_request", async () => { + const { handle, gateway } = createTestPair(); + try { + const response = await gateway.forwardRequest({ + method: "GET", + path: "/api/agents/me", + query: "", + headers: {}, + body: Buffer.alloc(0), + receivedToken: BRIDGE_TOKEN, + }); + expect(response.status).toBe(200); + expect(JSON.parse(response.body.toString("utf8"))).toEqual({ + echoedMethod: "GET", + echoedPath: "/api/agents/me", + }); + } finally { + await gateway.close(); + await handle.close(); + } + }); + + it("test_sixty_four_concurrent_streams_all_complete", async () => { + const seenPaths = new Set(); + const { handle, gateway } = createTestPair({ + forwardRequest: async (request) => { + // Force real overlap: every forward call waits one macrotask before it + // resolves, so 64 concurrent streams are genuinely in flight together. + await new Promise((resolve) => setTimeout(resolve, 5)); + seenPaths.add(request.pathname); + return { + status: 200, + headers: {}, + body: JSON.stringify({ echoedPath: request.pathname }), + }; + }, + }); + try { + const requests = Array.from({ length: 64 }, (_, index) => + gateway.forwardRequest({ + method: "GET", + path: `/api/issues/${index}`, + query: "", + headers: {}, + body: Buffer.alloc(0), + receivedToken: BRIDGE_TOKEN, + }), + ); + const responses = await Promise.all(requests); + expect(responses).toHaveLength(64); + for (const [index, response] of responses.entries()) { + expect(response.status).toBe(200); + expect(JSON.parse(response.body.toString("utf8"))).toEqual({ + echoedPath: `/api/issues/${index}`, + }); + } + expect(seenPaths.size).toBe(64); + } finally { + await gateway.close(); + await handle.close(); + } + }); + + it("test_ping_detects_a_silent_stall_within_twenty_seconds", async () => { + // The production defaults name the twenty-second bound this test name + // promises. The mechanism test below exercises the same code path with + // small overrides, so the suite stays fast. + expect(DEFAULT_HTTP2_BRIDGE_PING_STALL_MS).toBe(20_000); + expect(DEFAULT_HTTP2_BRIDGE_PING_INTERVAL_MS).toBeLessThan(DEFAULT_HTTP2_BRIDGE_PING_STALL_MS); + + const [serverSide] = duplexPair(); + // Nothing consumes the other side of the pair, so every PING frame the + // server sends goes unacknowledged: a silent stall. + const stallErrors: Error[] = []; + const handle = createHttp2BridgeServer({ + bridgeToken: BRIDGE_TOKEN, + forwardRequest: async () => ({ status: 200 }), + pingIntervalMs: 15, + pingStallMs: 40, + onSessionError: (error) => stallErrors.push(error), + }); + handle.bindChannel(fakeChannelFromDuplex(serverSide)); + + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(stallErrors).toHaveLength(1); + expect(stallErrors[0]?.message).toMatch(/stall/i); + await handle.close(); + }); + + it("test_goaway_reports_the_last_processed_stream_identifier", async () => { + // GOAWAY's Last-Stream-ID names the highest stream the SENDER processed + // for the peer's own initiated streams. Every request stream in this + // transport originates from the sandbox, so the meaningful, valid + // direction is the host naming the last client stream it processed — not + // the reverse (a client-sent GOAWAY only ever carries 0 here, since + // server push is disabled). The sandbox gateway observes it and can + // classify its own dispatched stream IDs with `classifyStreamAgainstGoaway`. + let hostSession: http2.ServerHttp2Session | undefined; + const goawayRecords: Array<{ lastStreamId: number; errorCode: number }> = []; + const { handle, bridgeToken, clientSide } = bindTestServer({ + onSession: (session) => { + hostSession = session; + }, + }); + const gateway = createSandboxHttp2BridgeGateway({ + bridgeToken, + createConnection: () => clientSide, + onGoaway: (record) => goawayRecords.push(record), + }); + try { + const response = await gateway.forwardRequest({ + method: "GET", + path: "/api/agents/me", + query: "", + headers: {}, + body: Buffer.alloc(0), + receivedToken: bridgeToken, + }); + expect(response.status).toBe(200); + + await new Promise((resolve) => { + hostSession?.goaway(http2.constants.NGHTTP2_NO_ERROR, 1); + setTimeout(resolve, 50); + }); + expect(goawayRecords).toHaveLength(1); + expect(goawayRecords[0]?.lastStreamId).toBe(1); + expect(classifyStreamAgainstGoaway(1, goawayRecords[0]!.lastStreamId)).toBe("accepted"); + expect(classifyStreamAgainstGoaway(3, goawayRecords[0]!.lastStreamId)).toBe("not_accepted"); + } finally { + await gateway.close(); + await handle.close(); + } + }); + + it("test_rst_stream_fails_one_request_and_keeps_the_session", async () => { + const { handle, bridgeToken, clientSide } = bindTestServer({ + forwardRequest: async (request) => { + if (request.pathname === "/api/agents/me") { + // Hold this one open long enough for the test to RST it. + await new Promise((resolve) => setTimeout(resolve, 200)); + } + return { status: 200, headers: {}, body: JSON.stringify({ path: request.pathname }) }; + }, + }); + const rawClient = connectRawClient(clientSide); + try { + const abortedStream = rawClient.request({ + ":method": "GET", + ":path": "/api/agents/me", + authorization: `Bearer ${bridgeToken}`, + }); + const abortedOutcome = new Promise<"aborted" | "closed">((resolve) => { + abortedStream.on("error", () => resolve("aborted")); + abortedStream.on("close", () => resolve("closed")); + }); + abortedStream.end(); + // Give the request time to reach the server before the reset. + await new Promise((resolve) => setTimeout(resolve, 20)); + abortedStream.close(http2.constants.NGHTTP2_CANCEL); + await abortedOutcome; + + // The session survives: a second request completes normally. + const survivingResponse = await expectSessionStillServesARequest(rawClient, { + method: "GET", + path: "/api/companies/co1", + token: bridgeToken, + }); + expect(survivingResponse.status).toBe(200); + expect(JSON.parse(survivingResponse.body)).toEqual({ path: "/api/companies/co1" }); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_a_stalled_request_body_settles_instead_of_hanging_forever", async () => { + const forwarderTracker = createForwarderCallTracker(); + const { handle, bridgeToken, clientSide } = bindTestServer({ + requestBodyTimeoutMs: 30, + forwardRequest: async () => { + forwarderTracker.markCalled(); + return { status: 200 }; + }, + }); + const rawClient = connectRawClient(clientSide); + try { + const startMs = Date.now(); + // A partial, never-ended body stalls the read. The timeout destroys the + // stream, so the request settles (through an `error` or a `close`, not + // a clean response) well inside the bound, instead of hanging forever. + await new Promise((resolve) => { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + req.on("error", () => resolve()); + req.on("close", () => resolve()); + req.write("partial-body"); + }); + expect(Date.now() - startMs).toBeLessThan(5_000); + expect(forwarderTracker.called).toBe(false); + + // The session survives the timed-out stream: a second, complete request + // still succeeds. + const survivingResponse = await expectSessionStillServesARequest(rawClient, { + method: "POST", + path: "/api/issues/abc/comments", + token: bridgeToken, + }); + expect(survivingResponse.status).toBe(200); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_a_slow_but_progressing_request_body_completes_instead_of_timing_out", async () => { + // The idle bound is well under the total time the whole body takes, so a + // one-shot bound over the full read would trip. Each chunk resets the + // bound, so the request still completes. + const { handle, bridgeToken, clientSide } = bindTestServer({ + requestBodyTimeoutMs: 80, + forwardRequest: async (request) => ({ + status: 200, + body: JSON.stringify({ bodyLength: request.body.byteLength }), + }), + }); + const rawClient = connectRawClient(clientSide); + try { + const response = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + let status = 0; + let body = ""; + req.setEncoding("utf8"); + req.on("response", (headers) => { + status = Number(headers[":status"]) || 0; + }); + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => resolve({ status, body })); + req.on("error", reject); + // Five chunks, each inside the idle bound, summing past it. + let sent = 0; + const sendNext = () => { + if (sent >= 5) { + req.end(); + return; + } + sent += 1; + req.write("chunk"); + setTimeout(sendNext, 40); + }; + sendNext(); + }); + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toEqual({ bodyLength: "chunk".length * 5 }); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_a_slow_trickle_that_never_ends_stops_at_the_one_shot_ceiling", async () => { + // The idle bound is generous, so a chunk every 40ms never lets it + // expire. The ceiling (150ms) arms once, when the read starts, and + // never renews on a chunk: this proves a peer that keeps every chunk + // gap under the idle bound, but never finishes the body, still loses + // the stream once the read's total age passes the ceiling. This is the + // failure mode an authenticated sandbox could otherwise use to hold one + // of the concurrent-stream slots open indefinitely: keep sending just + // enough to dodge the idle bound, and never finish the body. + // + // Every write below lands well before the ceiling, and the test makes + // no further call on the stream after the last one: nothing races the + // server's own reset of it once the ceiling passes, so this proves the + // bound from the server's own, deterministic effect (the forward + // handler never runs) instead of from a raw client-stream event whose + // timing Node does not guarantee here. + const forwarderTracker = createForwarderCallTracker(); + const { handle, bridgeToken, clientSide } = bindTestServer({ + requestBodyTimeoutMs: 5_000, + requestBodyLifetimeCeilingMs: 150, + forwardRequest: async () => { + forwarderTracker.markCalled(); + return { status: 200 }; + }, + }); + const rawClient = connectRawClient(clientSide); + try { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + req.on("error", () => undefined); + // Four chunks, 40ms apart, well inside the idle bound. The total send + // time, about 160ms, passes the 150ms ceiling. The body never ends: + // this request never calls `.end()`. + for (let chunkIndex = 0; chunkIndex < 4; chunkIndex += 1) { + req.write("chunk"); + await new Promise((resolve) => setTimeout(resolve, 40)); + } + // Wait past the ceiling with no further write, so the server has time + // to destroy the stalled stream on its own. + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(forwarderTracker.called).toBe(false); + + // The session survives the stopped stream: a second, complete request + // still succeeds. + const survivingResponse = await expectSessionStillServesARequest(rawClient, { + method: "POST", + path: "/api/issues/abc/comments", + token: bridgeToken, + }); + expect(survivingResponse.status).toBe(200); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_a_request_body_within_the_lifetime_bound_still_completes", async () => { + // A generous ceiling must not interfere with an ordinary request that + // finishes well inside it. + const { handle, bridgeToken, clientSide } = bindTestServer({ + requestBodyTimeoutMs: 5_000, + requestBodyLifetimeCeilingMs: 5_000, + forwardRequest: async (request) => ({ + status: 200, + body: JSON.stringify({ bodyLength: request.body.byteLength }), + }), + }); + const rawClient = connectRawClient(clientSide); + try { + const response = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + let status = 0; + let body = ""; + req.setEncoding("utf8"); + req.on("response", (headers) => { + status = Number(headers[":status"]) || 0; + }); + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => resolve({ status, body })); + req.on("error", reject); + req.write("chunk"); + req.end(); + }); + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toEqual({ bodyLength: "chunk".length }); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_a_slow_but_progressing_upload_completes_within_the_ceiling", async () => { + // The idle bound and the ceiling are both generous, so a slow but + // genuinely progressing upload completes normally, instead of losing + // its stream mid-upload. + const forwarderTracker = createForwarderCallTracker(); + const { handle, bridgeToken, clientSide } = bindTestServer({ + requestBodyTimeoutMs: 5_000, + requestBodyLifetimeCeilingMs: 5_000, + forwardRequest: async (request) => { + forwarderTracker.markCalled(); + return { status: 200, body: JSON.stringify({ bodyLength: request.body.byteLength }) }; + }, + }); + const rawClient = connectRawClient(clientSide); + try { + const response = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + let status = 0; + let body = ""; + req.setEncoding("utf8"); + req.on("response", (headers) => { + status = Number(headers[":status"]) || 0; + }); + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => resolve({ status, body })); + req.on("error", reject); + void (async () => { + // Six chunks, 40ms apart (well inside the idle bound), each real + // progress. + for (let sent = 0; sent < 6; sent += 1) { + req.write("chunk"); + await new Promise((r) => setTimeout(r, 40)); + } + req.end(); + })(); + }); + expect(forwarderTracker.called).toBe(true); + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toEqual({ bodyLength: "chunk".length * 6 }); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_close_force_destroys_a_session_with_a_stalled_stream_instead_of_waiting_forever", async () => { + const { handle, bridgeToken, clientSide } = bindTestServer({ + // A body timeout far longer than the close grace, so `close()` is the + // only thing that bounds the wait in this test. + requestBodyTimeoutMs: 60_000, + closeGraceMs: 30, + }); + const rawClient = connectRawClient(clientSide); + try { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + authorization: `Bearer ${bridgeToken}`, + }); + req.write("partial-body"); + // Give the request time to reach the server before close() runs. + await new Promise((resolve) => setTimeout(resolve, 20)); + + const closeStart = Date.now(); + await handle.close(); + const closeElapsedMs = Date.now() - closeStart; + // `close()` force-destroyed the stalled session at the grace bound, + // instead of waiting on `session.close()` forever. + expect(closeElapsedMs).toBeLessThan(5_000); + } finally { + rawClient.close(); + } + }); + + it("test_the_server_sets_every_bounded_option", async () => { + expect(HTTP2_BRIDGE_SERVER_OPTIONS).toEqual({ + settings: { + enablePush: HTTP2_BRIDGE_ENABLE_PUSH, + maxConcurrentStreams: HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS, + maxHeaderListSize: HTTP2_BRIDGE_MAX_HEADER_LIST_SIZE, + headerTableSize: HTTP2_BRIDGE_HEADER_TABLE_SIZE, + }, + maxSessionMemory: HTTP2_BRIDGE_MAX_SESSION_MEMORY, + maxHeaderListPairs: HTTP2_BRIDGE_MAX_HEADER_LIST_PAIRS, + maxDeflateDynamicTableSize: HTTP2_BRIDGE_MAX_DEFLATE_DYNAMIC_TABLE_SIZE, + maxSessionInvalidFrames: HTTP2_BRIDGE_MAX_SESSION_INVALID_FRAMES, + maxSessionRejectedStreams: HTTP2_BRIDGE_MAX_SESSION_REJECTED_STREAMS, + streamResetRate: HTTP2_BRIDGE_STREAM_RESET_RATE, + streamResetBurst: HTTP2_BRIDGE_STREAM_RESET_BURST, + }); + expect(HTTP2_BRIDGE_ENABLE_PUSH).toBe(false); + expect(HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS).toBe(64); + expect(HTTP2_BRIDGE_MAX_HEADER_LIST_SIZE).toBe(16384); + expect(HTTP2_BRIDGE_HEADER_TABLE_SIZE).toBe(4096); + expect(HTTP2_BRIDGE_MAX_SESSION_MEMORY).toBe(16); + expect(HTTP2_BRIDGE_MAX_HEADER_LIST_PAIRS).toBe(128); + expect(HTTP2_BRIDGE_MAX_DEFLATE_DYNAMIC_TABLE_SIZE).toBe(4096); + expect(HTTP2_BRIDGE_MAX_SESSION_INVALID_FRAMES).toBe(100); + expect(HTTP2_BRIDGE_MAX_SESSION_REJECTED_STREAMS).toBe(100); + expect(HTTP2_BRIDGE_STREAM_RESET_RATE).toBe(10); + expect(HTTP2_BRIDGE_STREAM_RESET_BURST).toBe(100); + + // The running server actually carries the bound, not only the constant: + // the four `settings` values ride the server's outbound SETTINGS frame, + // so the connected client's `remoteSettings` reflects them after the + // handshake completes. + const { handle, clientSide } = bindTestServer(); + const rawClient = connectRawClient(clientSide); + try { + const remoteSettings = await new Promise((resolve) => { + rawClient.once("remoteSettings", resolve); + }); + expect(remoteSettings.enablePush).toBe(HTTP2_BRIDGE_ENABLE_PUSH); + expect(remoteSettings.maxConcurrentStreams).toBe(HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS); + expect(remoteSettings.maxHeaderListSize).toBe(HTTP2_BRIDGE_MAX_HEADER_LIST_SIZE); + expect(remoteSettings.headerTableSize).toBe(HTTP2_BRIDGE_HEADER_TABLE_SIZE); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + describe("parseCanonicalBridgeRequestPath", () => { + it("parses an origin-form path with a query exactly one time", () => { + const result = parseCanonicalBridgeRequestPath({ ":path": "/api/issues/abc?foo=bar" }); + expect(result).toEqual({ ok: true, value: { pathname: "/api/issues/abc", query: "?foo=bar" } }); + }); + + it("test_a_non_origin_form_path_is_rejected", () => { + expect(parseCanonicalBridgeRequestPath({ ":path": "http://evil.example/api" })).toEqual({ + ok: false, + reason: "non_origin_form", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "//evil.example/api" })).toEqual({ + ok: false, + reason: "non_origin_form", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "*" })).toEqual({ + ok: false, + reason: "non_origin_form", + }); + }); + + it("test_an_encoded_separator_or_a_dot_segment_is_rejected", () => { + expect(parseCanonicalBridgeRequestPath({ ":path": "/api%2fissues/abc" })).toEqual({ + ok: false, + reason: "encoded_slash", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "/api\\issues/abc" })).toEqual({ + ok: false, + reason: "backslash", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "/api/issues\0/abc" })).toEqual({ + ok: false, + reason: "nul_byte", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "/api/%00/abc" })).toEqual({ + ok: false, + reason: "nul_byte", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "/api/../secrets" })).toEqual({ + ok: false, + reason: "dot_segment", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "/api/%2e%2e/secrets" })).toEqual({ + ok: false, + reason: "dot_segment", + }); + expect(parseCanonicalBridgeRequestPath({ ":path": "/api/./issues" })).toEqual({ + ok: false, + reason: "dot_segment", + }); + }); + + it("rejects a duplicate pseudo-header", () => { + expect( + parseCanonicalBridgeRequestPath({ ":path": ["/api/agents/me", "/api/agents/other"] } as never), + ).toEqual({ ok: false, reason: "duplicate_pseudo_header" }); + }); + + it("rejects a missing path", () => { + expect(parseCanonicalBridgeRequestPath({})).toEqual({ ok: false, reason: "missing_path" }); + }); + }); + + it("buildHttp2BridgeForwardUrl resolves the parsed pathname and query against the base URL", () => { + const url = buildHttp2BridgeForwardUrl("http://127.0.0.1:4000", { + pathname: "/api/issues/abc", + query: "?foo=bar", + }); + expect(url.toString()).toBe("http://127.0.0.1:4000/api/issues/abc?foo=bar"); + }); + + it("classifyStreamAgainstGoaway classifies by the last processed stream id", () => { + expect(classifyStreamAgainstGoaway(1, 3)).toBe("accepted"); + expect(classifyStreamAgainstGoaway(3, 3)).toBe("accepted"); + expect(classifyStreamAgainstGoaway(5, 3)).toBe("not_accepted"); + }); + + it("test_a_stream_without_the_bridge_token_never_reaches_the_forwarder", async () => { + const forwarderTracker = createForwarderCallTracker(); + const { handle, clientSide } = bindTestServer({ + forwardRequest: async (request) => { + forwarderTracker.markCalled(); + return { status: 200, body: JSON.stringify({ path: request.pathname }) }; + }, + }); + const rawClient = connectRawClient(clientSide); + try { + const response = await new Promise<{ status: number; body: string }>((resolve, reject) => { + // No `authorization` header at all: the stream never carries a token. + const req = rawClient.request({ ":method": "GET", ":path": "/api/agents/me" }); + let status = 0; + let body = ""; + req.on("response", (headers) => { + status = Number(headers[":status"]) || 0; + }); + req.setEncoding("utf8"); + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => resolve({ status, body })); + req.on("error", reject); + req.end(); + }); + expect(response.status).toBe(401); + expect(forwarderTracker.called).toBe(false); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("test_a_denied_stream_with_an_unfinished_body_still_frees_its_stream_slot", async () => { + // The server answers a denied stream (an invalid token, here) at once, + // before it ever reads the request body. A peer that leaves that body + // unfinished must not hold the stream open past the same idle bound an + // authenticated request gets — otherwise, up to + // `HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS` denied streams could each retain + // a slot forever and block every legitimate callback. + const forwarderTracker = createForwarderCallTracker(); + const { handle, clientSide } = bindTestServer({ + requestBodyTimeoutMs: 30, + forwardRequest: async () => { + forwarderTracker.markCalled(); + return { status: 200 }; + }, + }); + const rawClient = connectRawClient(clientSide); + try { + const startMs = Date.now(); + const closed = new Promise((resolve) => { + const req = rawClient.request({ + ":method": "POST", + ":path": "/api/issues/abc/comments", + // No `authorization` header: the token check denies this stream + // before the body is ever read. + }); + req.on("error", () => undefined); + req.on("close", () => resolve()); + // Drain the response so its readable side reaches `end`: nothing in + // this test reads the response body otherwise, and an unread + // response can itself hold the client stream open past `close`. + req.resume(); + // A partial body the request never ends: without its own bound, the + // inbound half of this stream would stay open indefinitely. + req.write("partial-body"); + }); + await closed; + expect(Date.now() - startMs).toBeLessThan(5_000); + expect(forwarderTracker.called).toBe(false); + + // The session survives the freed stream: a second, complete request + // still succeeds, proving the session itself stayed open and healthy. + const survivingResponse = await expectSessionStillServesARequest(rawClient, { + method: "GET", + path: "/api/agents/me", + token: BRIDGE_TOKEN, + }); + expect(survivingResponse.status).toBe(200); + } finally { + rawClient.close(); + await handle.close(); + } + }); + + it("rejects a route the allowlist does not carry, before the forwarder runs", async () => { + const forwarderTracker = createForwarderCallTracker(); + const { gateway, handle } = createTestPair({ + forwardRequest: async () => { + forwarderTracker.markCalled(); + return { status: 200 }; + }, + }); + try { + const response = await gateway.forwardRequest({ + method: "DELETE", + path: "/api/agents/me", + query: "", + headers: {}, + body: Buffer.alloc(0), + receivedToken: BRIDGE_TOKEN, + }); + expect(response.status).toBe(403); + expect(forwarderTracker.called).toBe(false); + } finally { + await gateway.close(); + await handle.close(); + } + }); + + it("the sandbox gateway keeps its own token check before it opens a stream", async () => { + const forwarderTracker = createForwarderCallTracker(); + const { gateway, handle } = createTestPair({ + forwardRequest: async () => { + forwarderTracker.markCalled(); + return { status: 200 }; + }, + }); + try { + await expect( + gateway.forwardRequest({ + method: "GET", + path: "/api/agents/me", + query: "", + headers: {}, + body: Buffer.alloc(0), + receivedToken: "wrong-token", + }), + ).rejects.toThrow(/invalid bridge token/i); + expect(forwarderTracker.called).toBe(false); + } finally { + await gateway.close(); + await handle.close(); + } + }); + + it("wrapDuplexChannelAsNodeDuplex relays writes and pushes through onData", async () => { + const written: Buffer[] = []; + let dataListener: ((chunk: Uint8Array) => void) | undefined; + let exitListener: ((exit: { exitCode: number | null }) => void) | undefined; + const channel: CommandManagedDuplexChannel = { + write: (data) => { + written.push(Buffer.from(data)); + }, + onData: (listener) => { + dataListener = listener; + }, + onExit: (listener) => { + exitListener = listener; + }, + stop: () => undefined, + close: async () => undefined, + }; + const duplex = wrapDuplexChannelAsNodeDuplex(channel); + const received: Buffer[] = []; + duplex.on("data", (chunk: Buffer) => received.push(chunk)); + + duplex.write(Buffer.from("outbound")); + await new Promise((resolve) => setImmediate(resolve)); + expect(Buffer.concat(written).toString("utf8")).toBe("outbound"); + + dataListener?.(Buffer.from("inbound")); + await new Promise((resolve) => setImmediate(resolve)); + expect(Buffer.concat(received).toString("utf8")).toBe("inbound"); + + const ended = new Promise((resolve) => duplex.on("end", resolve)); + exitListener?.({ exitCode: 0 }); + await ended; + }); + + it("wrapDuplexChannelAsNodeDuplex queues chunks past a full readable side and drains them once the consumer reads again", async () => { + let dataListener: ((chunk: Uint8Array) => void) | undefined; + const channel: CommandManagedDuplexChannel = { + write: () => undefined, + onData: (listener) => { + dataListener = listener; + }, + onExit: () => undefined, + stop: () => undefined, + close: async () => undefined, + }; + const duplex = wrapDuplexChannelAsNodeDuplex(channel, { maxBufferedReadBytes: 1_000_000 }); + const received: Buffer[] = []; + // The first chunk alone passes the readable side's default 64 KiB + // high-water mark, so `push()` reports the readable side full. The two + // chunks that follow queue in the wrapper instead of pushing past that + // signal — this is the bounded flow-control path this fix adds. + dataListener?.(Buffer.alloc(70_000, "a")); + dataListener?.(Buffer.from("-second-")); + dataListener?.(Buffer.from("-third-")); + + // Attaching a "data" listener now starts flowing mode, which drives the + // wrapper's own `read()` until every queued chunk drains, proving no + // queued chunk was dropped and the arrival order held. + const drainedAll = new Promise((resolve) => { + duplex.on("data", (chunk: Buffer) => { + received.push(chunk); + if (Buffer.concat(received).includes("-third-")) resolve(); + }); + }); + await drainedAll; + + expect(Buffer.concat(received).toString("utf8").endsWith("-second--third-")).toBe(true); + }); + + it("wrapDuplexChannelAsNodeDuplex stops the channel and destroys the duplex once the bounded read backpressure buffer overflows", async () => { + let dataListener: ((chunk: Uint8Array) => void) | undefined; + let stopped = false; + const channel: CommandManagedDuplexChannel = { + write: () => undefined, + onData: (listener) => { + dataListener = listener; + }, + onExit: () => undefined, + stop: () => { + stopped = true; + }, + close: async () => undefined, + }; + // The cap must clear the default 65,536-byte readable high-water mark, + // so the first chunk below still passes the direct-push check and + // forces `push()` to report the readable side full; the overflow this + // test proves comes from the queue that follows, not from that first + // chunk on its own. + const duplex = wrapDuplexChannelAsNodeDuplex(channel, { maxBufferedReadBytes: 80_000 }); + // No consumer ever attaches, so the readable side never drains: every + // chunk past the first, which alone passes the default high-water mark, + // fills the bounded queue instead of the unbounded internal buffer a + // caller ignoring `push()`'s return would grow. + const errored = new Promise((resolve) => duplex.on("error", resolve)); + + dataListener?.(Buffer.alloc(70_000, "a")); // passes the high-water mark and the cap; still pushed directly. + dataListener?.(Buffer.alloc(30_000, "b")); // queues: 30,000 of the 80,000-byte cap. + dataListener?.(Buffer.alloc(30_000, "b")); // queues: 60,000 of the 80,000-byte cap. + dataListener?.(Buffer.alloc(30_000, "b")); // 90,000 queued bytes passes the cap. + + const error = await errored; + expect(error.message).toMatch(/backpressure/i); + expect(stopped).toBe(true); + }); + + it("wrapDuplexChannelAsNodeDuplex fails closed on one inbound chunk larger than the bounded read backpressure buffer, before the queue holds anything to compare it against", async () => { + let dataListener: ((chunk: Uint8Array) => void) | undefined; + let stopped = false; + const channel: CommandManagedDuplexChannel = { + write: () => undefined, + onData: (listener) => { + dataListener = listener; + }, + onExit: () => undefined, + stop: () => { + stopped = true; + }, + close: async () => undefined, + }; + // No consumer ever attaches, and the queue is empty when this chunk + // arrives, so a check that only bounds the queue's cumulative size (and + // not one chunk's own size) would let this chunk reach `push()` unbound. + const duplex = wrapDuplexChannelAsNodeDuplex(channel, { maxBufferedReadBytes: 5_000 }); + const errored = new Promise((resolve) => duplex.on("error", resolve)); + + dataListener?.(Buffer.alloc(10_000, "a")); // exceeds the 5,000-byte cap on its own, on the very first chunk. + + const error = await errored; + expect(error.message).toMatch(/backpressure/i); + expect(stopped).toBe(true); + }); + + it("wrapDuplexChannelAsNodeDuplex fails closed once the read backpressure queue stalls with no drain, even though it stays under the byte cap", async () => { + let dataListener: ((chunk: Uint8Array) => void) | undefined; + let stopped = false; + const channel: CommandManagedDuplexChannel = { + write: () => undefined, + onData: (listener) => { + dataListener = listener; + }, + onExit: () => undefined, + stop: () => { + stopped = true; + }, + close: async () => undefined, + }; + // A generous byte cap, so the byte-cap check above never fires: this + // test proves the independent time bound catches a stuck consumer the + // byte cap alone would miss. + const duplex = wrapDuplexChannelAsNodeDuplex(channel, { + maxBufferedReadBytes: 10_000_000, + readBackpressureStallMs: 40, + }); + const errored = new Promise((resolve) => duplex.on("error", resolve)); + + dataListener?.(Buffer.alloc(70_000, "a")); // passes the high-water mark; pushed directly, no consumer drains it. + dataListener?.(Buffer.from("queued-and-never-drained")); // queues; no "data" listener ever attaches to drain it. + + const error = await errored; + expect(error.message).toMatch(/stall/i); + expect(stopped).toBe(true); + }); + + it("wrapDuplexChannelAsNodeDuplex clears the stall bound once the queue fully drains, instead of firing later on an idle channel", async () => { + let dataListener: ((chunk: Uint8Array) => void) | undefined; + const channel: CommandManagedDuplexChannel = { + write: () => undefined, + onData: (listener) => { + dataListener = listener; + }, + onExit: () => undefined, + stop: () => undefined, + close: async () => undefined, + }; + // A short stall bound, so this test proves the drain below clears the + // timer instead of merely finishing before a long one would have fired. + const duplex = wrapDuplexChannelAsNodeDuplex(channel, { + maxBufferedReadBytes: 10_000_000, + readBackpressureStallMs: 20, + }); + const received: Buffer[] = []; + const drainedAll = new Promise((resolve) => { + duplex.on("data", (chunk: Buffer) => { + received.push(chunk); + if (Buffer.concat(received).includes("-third-")) resolve(); + }); + }); + + dataListener?.(Buffer.alloc(70_000, "a")); // passes the high-water mark; queues the chunks that follow. + dataListener?.(Buffer.from("-second-")); + dataListener?.(Buffer.from("-third-")); + await drainedAll; + + // Wait past the stall bound with the channel now idle and the queue + // empty. A timer the full drain above did not clear would fire here. + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(duplex.destroyed).toBe(false); + }); +}); diff --git a/packages/adapter-utils/src/http2-bridge-server.ts b/packages/adapter-utils/src/http2-bridge-server.ts new file mode 100644 index 00000000000..a42afa17031 --- /dev/null +++ b/packages/adapter-utils/src/http2-bridge-server.ts @@ -0,0 +1,901 @@ +/** + * Host HTTP/2 server for the sandbox callback bridge transport. + * + * The server wraps one {@link CommandManagedDuplexChannel} as a Node `Duplex` + * and runs one plaintext HTTP/2 session on it. It maps every stream on that + * session to one call of the caller-supplied `forwardRequest` handler, then + * writes the result back as the stream response. The handler applies the real + * host token and the run attribution, so those rules stay in one place, next + * to the existing file-bridge and duplex-bridge forward path. + * + * This file does not select the transport for a run. It builds and tests the + * host half of the pair in isolation; a later phase wires the pair into the + * transport-selection path. + * + * Requests flow from the sandbox to the host only: the host never opens a + * stream to the sandbox. The server enforces three checks, in this order, for + * every stream: + * 1. a constant-time compare of the bridge token against the per-run token + * (accepted security fix 4), before any other processing; + * 2. one canonical parse of the `:path` pseudo-header (accepted security fix + * 3), whose result feeds both the route allowlist and the forward URL; + * 3. the route allowlist and the header allowlist, reused unchanged from + * `sandbox-callback-bridge.ts`. + * The server also bounds ten `http2.createServer` options (accepted security + * fix 1), so Node enforces the session, header, and stream-reset limits on + * every connection with no new component. + */ + +import { Duplex } from "node:stream"; +import http2 from "node:http2"; + +import type { CommandManagedDuplexChannel } from "./command-managed-runtime.js"; +import { + authorizeSandboxCallbackBridgeRequestWithRoutes, + compareBridgeTokensConstantTime, + sanitizeSandboxCallbackBridgeHeaders, + DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST, + DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES, + DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST, + type SandboxCallbackBridgeRouteRule, +} from "./sandbox-callback-bridge.js"; + +// --------------------------------------------------------------------------- +// Bounded server options (accepted security fix 1). Node enforces each value, +// so naming them adds configuration and no new component. Every value and +// every name below matches the board-approved table exactly. +// --------------------------------------------------------------------------- + +/** Server push. The transport never needs it. */ +export const HTTP2_BRIDGE_ENABLE_PUSH = false; +/** Open streams. This matches the current broker limit ({@link DEFAULT_DUPLEX_BROKER_MAX_IN_FLIGHT_REQUESTS} in `duplex-bridge-broker.ts`). */ +export const HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS = 64; +/** One decompressed header list. The Node default is 65535. */ +export const HTTP2_BRIDGE_MAX_HEADER_LIST_SIZE = 16384; +/** The header-compression table. This keeps the Node default. */ +export const HTTP2_BRIDGE_HEADER_TABLE_SIZE = 4096; +/** Session memory in mebibytes. The Node default is 10. */ +export const HTTP2_BRIDGE_MAX_SESSION_MEMORY = 16; +/** Header pairs per request. This names the Node default. */ +export const HTTP2_BRIDGE_MAX_HEADER_LIST_PAIRS = 128; +/** The outbound compression table. */ +export const HTTP2_BRIDGE_MAX_DEFLATE_DYNAMIC_TABLE_SIZE = 4096; +/** Invalid frames before Node closes the session. */ +export const HTTP2_BRIDGE_MAX_SESSION_INVALID_FRAMES = 100; +/** Rejected streams before Node closes the session. */ +export const HTTP2_BRIDGE_MAX_SESSION_REJECTED_STREAMS = 100; +/** The stream-reset budget (frames per interval). Node sends GOAWAY past the budget. */ +export const HTTP2_BRIDGE_STREAM_RESET_RATE = 10; +/** The stream-reset budget (burst allowance). Node sends GOAWAY past the budget. */ +export const HTTP2_BRIDGE_STREAM_RESET_BURST = 100; + +/** + * The full bounded options object. The server passes this object, unchanged, + * to `http2.createServer`. A test asserts every value on this object, so it + * proves the running server actually carries the bound, not only that the + * named constant exists. + */ +export const HTTP2_BRIDGE_SERVER_OPTIONS: http2.ServerOptions = { + settings: { + enablePush: HTTP2_BRIDGE_ENABLE_PUSH, + maxConcurrentStreams: HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS, + maxHeaderListSize: HTTP2_BRIDGE_MAX_HEADER_LIST_SIZE, + headerTableSize: HTTP2_BRIDGE_HEADER_TABLE_SIZE, + }, + maxSessionMemory: HTTP2_BRIDGE_MAX_SESSION_MEMORY, + maxHeaderListPairs: HTTP2_BRIDGE_MAX_HEADER_LIST_PAIRS, + maxDeflateDynamicTableSize: HTTP2_BRIDGE_MAX_DEFLATE_DYNAMIC_TABLE_SIZE, + maxSessionInvalidFrames: HTTP2_BRIDGE_MAX_SESSION_INVALID_FRAMES, + maxSessionRejectedStreams: HTTP2_BRIDGE_MAX_SESSION_REJECTED_STREAMS, + streamResetRate: HTTP2_BRIDGE_STREAM_RESET_RATE, + streamResetBurst: HTTP2_BRIDGE_STREAM_RESET_BURST, +}; + +// --------------------------------------------------------------------------- +// Duplex channel adapter +// --------------------------------------------------------------------------- + +/** The default cap, in bytes, on the read-side queue {@link wrapDuplexChannelAsNodeDuplex} + * holds once `Duplex.push()` reports the readable side is full (a `false` + * return). A sandbox-controlled channel has no upstream pause: `onData` below + * keeps delivering bytes whether or not the HTTP/2 session keeps up with + * them. Past this cap the wrapper treats the channel as stuck, not merely + * slow, and fails closed: it stops the channel and destroys the `Duplex`, so + * a producer that keeps outpacing its reader cannot grow host memory without + * bound. This cap also bounds one single chunk: the wrapper checks a chunk's + * own size against it before `push()` ever runs, so one oversized chunk + * cannot cross the cap on its first delivery, before the queue holds + * anything to compare it against. */ +export const DEFAULT_HTTP2_BRIDGE_MAX_BUFFERED_READ_BYTES = HTTP2_BRIDGE_MAX_SESSION_MEMORY * 1024 * 1024; +/** The default bound, in milliseconds, on how long the read-side queue + * {@link wrapDuplexChannelAsNodeDuplex} holds can stay non-empty with no + * chunk draining from it. The byte cap above bounds how much memory a stuck + * reader can hold; it does not bound how long the reader can stay stuck. A + * consumer that never resumes reading would otherwise hold the channel open, + * backpressured, for as long as the queue stays under the byte cap. Each + * drained chunk renews this bound, so a consumer that keeps making real + * progress never trips it; only a consumer that stops resuming entirely + * does. */ +export const DEFAULT_HTTP2_BRIDGE_READ_BACKPRESSURE_STALL_MS = 30_000; + +/** + * Wrap a {@link CommandManagedDuplexChannel} as a Node `Duplex`, so an + * `Http2Server` can run one session directly on it (`server.emit("connection", + * duplex)`). The wrapper never buffers more than one write in flight: it calls + * the stream write callback only after the channel's own write call settles + * (the backpressure constraint), never as a delivery signal. The provider + * accepts many megabytes in milliseconds and holds them in its own buffer, so + * this direction stays governed by the channel's own write-settle timing. + * + * The read direction needs its own bound. The channel exposes no pause: once + * `onData` below is registered, the channel keeps calling it for every byte + * the sandbox sends, with no way for this wrapper to slow it down. Node's + * `Duplex.push()` reports back-pressure through its boolean return, not by + * refusing the call, so a caller that ignores a `false` return and keeps + * pushing grows the readable side's internal buffer with no limit. This + * wrapper honors that signal instead: while `push()` reports room, it pushes + * directly; once `push()` reports the readable side is full, it queues each + * later chunk instead of pushing past that signal, and drains the queue from + * `read()`, which Node calls again only once the consumer wants more. Every + * chunk, on either path, first checks against + * {@link DEFAULT_HTTP2_BRIDGE_MAX_BUFFERED_READ_BYTES} (or the caller's + * `maxBufferedReadBytes`) on its own size, and the queue checks against the + * same cap on its cumulative size: past either check the wrapper fails + * closed instead of buffering further, because the channel has no pause to + * fall back on. A second, independent bound — + * {@link DEFAULT_HTTP2_BRIDGE_READ_BACKPRESSURE_STALL_MS} (or the caller's + * `readBackpressureStallMs`) — covers the case the byte cap does not: a + * consumer that stops reading entirely, so the queue never grows past the + * byte cap but also never drains. This bound renews on every chunk the + * queue drains, so a consumer that keeps making real progress never trips + * it. + */ +export function wrapDuplexChannelAsNodeDuplex( + channel: CommandManagedDuplexChannel, + options: { maxBufferedReadBytes?: number; readBackpressureStallMs?: number } = {}, +): Duplex { + const maxBufferedReadBytes = options.maxBufferedReadBytes ?? DEFAULT_HTTP2_BRIDGE_MAX_BUFFERED_READ_BYTES; + const readBackpressureStallMs = + options.readBackpressureStallMs ?? DEFAULT_HTTP2_BRIDGE_READ_BACKPRESSURE_STALL_MS; + // Chunks `onData` already delivered that `push()` has not yet accepted, + // in arrival order. `read()` drains this queue before it lets Node pull + // any new bytes, so the delivery order the channel used stays intact. + const pendingReads: Buffer[] = []; + let pendingReadBytes = 0; + // True once `push()` last reported room for more, or before the first + // push call. `onData` pushes directly while this holds; once a `push()` + // call reports no room, later chunks queue in `pendingReads` instead. + let canPushMore = true; + // True once the channel exited. `endReadableIfDrained` pushes `null` only + // after the queue this wrapper still holds fully drains, so a chunk that + // arrived before the exit is never dropped. + let channelExited = false; + // Arms while the queue holds at least one chunk; clears once it fully + // drains. Fires `readBackpressureStallMs` after the queue's last drain (or + // its first chunk, if it never drained at all) with no further drain, so a + // consumer that stops resuming does not hold the channel open forever + // under the byte cap. + let backpressureStallTimer: ReturnType | undefined; + + function clearBackpressureStallTimer(): void { + if (backpressureStallTimer === undefined) return; + clearTimeout(backpressureStallTimer); + backpressureStallTimer = undefined; + } + + function armBackpressureStallTimer(): void { + clearBackpressureStallTimer(); + backpressureStallTimer = setTimeout(() => { + failClosed( + "Sandbox HTTP/2 channel's read backpressure queue did not drain within the stall bound; the reader appears stuck.", + ); + }, readBackpressureStallMs); + backpressureStallTimer.unref?.(); + } + + function failClosed(message: string): void { + clearBackpressureStallTimer(); + channel.stop(); + duplex.destroy(new Error(message)); + } + + function endReadableIfDrained(): void { + if (!channelExited || pendingReads.length > 0 || duplex.destroyed) return; + duplex.push(null); + } + + const duplex: Duplex = new Duplex({ + read() { + canPushMore = true; + let drainedAChunk = false; + while (canPushMore && pendingReads.length > 0) { + const next = pendingReads.shift(); + if (next === undefined) break; + pendingReadBytes -= next.byteLength; + drainedAChunk = true; + canPushMore = duplex.push(next); + } + if (pendingReads.length === 0) { + clearBackpressureStallTimer(); + } else if (drainedAChunk) { + // The queue still holds chunks, but at least one drained just now: + // real progress, so the stall bound renews instead of expiring under + // a consumer that is still reading, only slowly. + armBackpressureStallTimer(); + } + endReadableIfDrained(); + }, + write(chunk: unknown, _encoding, callback) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBufferLike); + let settleResult: unknown; + try { + settleResult = channel.write(bytes); + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error))); + return; + } + if ( + settleResult != null && + typeof (settleResult as Promise).then === "function" + ) { + (settleResult as Promise).then( + () => callback(), + (error) => callback(error instanceof Error ? error : new Error(String(error))), + ); + } else { + callback(); + } + }, + final(callback) { + channel + .close() + .then(() => callback(), (error) => callback(error instanceof Error ? error : new Error(String(error)))); + }, + }); + channel.onData((chunk) => { + if (duplex.destroyed) return; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + // Check one chunk's own size against the cap before either path below + // runs. `push()` never refuses a call on its size, so a single chunk + // larger than the whole cap would otherwise reach Node's internal + // buffer unbounded on the direct-push path, before the queue this + // wrapper owns ever holds anything to compare a later chunk against. + if (bytes.byteLength > maxBufferedReadBytes) { + failClosed( + "Sandbox HTTP/2 channel delivered one chunk larger than the bounded read backpressure buffer.", + ); + return; + } + if (canPushMore && pendingReads.length === 0) { + canPushMore = duplex.push(bytes); + return; + } + // The readable side already reported it is full, and the channel has no + // pause to slow the sandbox side down: queue this chunk instead of + // pushing past that signal. Bound the queue, so a producer that keeps + // outpacing its reader cannot grow it without limit. + if (pendingReads.length === 0) { + // The queue was empty until this chunk: arm the stall bound, so a + // consumer that never resumes reading still ends the channel, even + // though this chunk alone stays under the byte cap. + armBackpressureStallTimer(); + } + pendingReadBytes += bytes.byteLength; + if (pendingReadBytes > maxBufferedReadBytes) { + failClosed( + "Sandbox HTTP/2 channel exceeded the bounded read backpressure buffer; the reader could not keep up.", + ); + return; + } + pendingReads.push(bytes); + }); + channel.onExit(() => { + channelExited = true; + endReadableIfDrained(); + }); + // The channel exited, or `read()`/`onData` above failed the `Duplex` + // closed: either way, no further chunk will ever drain, so the stall + // timer serves no purpose and only holds a stray handle open. + duplex.once("close", clearBackpressureStallTimer); + return duplex; +} + +// --------------------------------------------------------------------------- +// Canonical `:path` parsing (accepted security fix 3) +// --------------------------------------------------------------------------- + +/** The reason {@link parseCanonicalBridgeRequestPath} rejected one request. */ +export type CanonicalBridgeRequestPathRejection = + | "missing_path" + | "duplicate_pseudo_header" + | "non_origin_form" + | "encoded_slash" + | "backslash" + | "nul_byte" + | "dot_segment"; + +/** The one parsed pathname and query. Both the route allowlist and the forward + * URL builder read this same value; the host never parses `:path` twice. */ +export interface CanonicalBridgeRequestPath { + pathname: string; + /** The query string, in `URL.search` form: empty, or a leading `?`. */ + query: string; +} + +export type CanonicalBridgeRequestPathResult = + | { ok: true; value: CanonicalBridgeRequestPath } + | { ok: false; reason: CanonicalBridgeRequestPathRejection }; + +/** The request pseudo-headers HTTP/2 allows exactly one of, per request. */ +const REQUEST_PSEUDO_HEADER_NAMES = [":method", ":scheme", ":authority", ":path"] as const; + +/** + * Parse the `:path` pseudo-header exactly one time. The caller passes the + * returned pathname and query to both the route allowlist and the forward URL + * builder — never a second, independent parse of the raw header. + * + * The parser rejects a request that carries any of: a duplicate pseudo-header, + * a missing or empty `:path`, a non-origin-form path, an encoded slash, a + * backslash, a NUL byte, or a dot segment (checked before URL normalization + * would silently remove it, and after percent-decoding each segment, so an + * encoded dot segment cannot slip through). + */ +export function parseCanonicalBridgeRequestPath( + headers: http2.IncomingHttpHeaders, +): CanonicalBridgeRequestPathResult { + for (const name of REQUEST_PSEUDO_HEADER_NAMES) { + if (Array.isArray((headers as Record)[name])) { + return { ok: false, reason: "duplicate_pseudo_header" }; + } + } + const rawPath = headers[":path"]; + if (typeof rawPath !== "string" || rawPath.length === 0) { + return { ok: false, reason: "missing_path" }; + } + // Origin-form only: a single leading "/", never "//" (network-path form) and + // never an absolute-form URI ("scheme://..."). + if (!rawPath.startsWith("/") || rawPath.startsWith("//") || rawPath.includes("://")) { + return { ok: false, reason: "non_origin_form" }; + } + if (/%2f/i.test(rawPath)) { + return { ok: false, reason: "encoded_slash" }; + } + if (rawPath.includes("\\")) { + return { ok: false, reason: "backslash" }; + } + if (rawPath.includes("\0") || /%00/i.test(rawPath)) { + return { ok: false, reason: "nul_byte" }; + } + const queryIndex = rawPath.indexOf("?"); + const rawPathname = queryIndex === -1 ? rawPath : rawPath.slice(0, queryIndex); + for (const segment of rawPathname.split("/")) { + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + return { ok: false, reason: "non_origin_form" }; + } + if (decoded === "." || decoded === "..") { + return { ok: false, reason: "dot_segment" }; + } + } + // Every raw-string check above passed, and the path carries no dot segment, + // so `URL` normalization here changes nothing but percent-encoding; it stays + // safe to build the canonical pathname and query from it. + let url: URL; + try { + url = new URL(rawPath, "http://bridge.internal"); + } catch { + return { ok: false, reason: "non_origin_form" }; + } + return { ok: true, value: { pathname: url.pathname, query: url.search } }; +} + +/** + * Build the forward URL from the one canonical parse. This mirrors + * `buildBridgeForwardUrl` in `execution-target.ts`, which the file bridge and + * the duplex bridge use today; a later phase wires the HTTP/2 host handler to + * that same forward path and can consolidate the two into one export. + */ +export function buildHttp2BridgeForwardUrl( + baseUrl: string, + request: CanonicalBridgeRequestPath, +): URL { + const url = new URL(request.pathname, baseUrl); + const query = request.query.trim(); + url.search = query.startsWith("?") ? query.slice(1) : query; + return url; +} + +// --------------------------------------------------------------------------- +// PING stall detection +// --------------------------------------------------------------------------- + +/** The default interval between two liveness PING frames, in milliseconds. */ +export const DEFAULT_HTTP2_BRIDGE_PING_INTERVAL_MS = 5_000; +/** The default bound a sent PING waits for its ack before the session counts as stalled. */ +export const DEFAULT_HTTP2_BRIDGE_PING_STALL_MS = 20_000; +/** The default idle bound on a request body read: the maximum gap between + * two received chunks (or between the token check and the first chunk) + * before the server treats the stream as stalled. Each received chunk resets + * this bound, so a slow peer that keeps making real progress completes; only + * a peer that stops sending trips it. */ +export const DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_TIMEOUT_MS = 30_000; +/** The default hard ceiling on a request body read's total lifetime: an + * absolute bound armed once, at the start of the read, and never renewed by + * later progress. This bound is independent of + * {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_TIMEOUT_MS}: the idle bound + * resets on every chunk to catch a peer that stops sending; this ceiling + * catches a peer that never stops sending but also never finishes, so a + * peer cannot use a steady trickle of small chunks to hold a + * {@link HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS} stream slot open forever. Set + * well above {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_TIMEOUT_MS} so a + * legitimate upload that makes real but slow progress — a chunk every few + * seconds, well inside the idle bound — still has room to finish. */ +export const DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_LIFETIME_CEILING_MS = 480_000; +/** The default bound {@link Http2BridgeServerHandle.close} waits for an + * active session to close on its own before it force-destroys the session. A + * session that carries a stalled stream would otherwise hold `close()` open + * forever, because `session.close()` waits for every open stream to end. */ +export const DEFAULT_HTTP2_BRIDGE_CLOSE_GRACE_MS = 5_000; + +function startHttp2BridgePingWatchdog( + session: http2.ServerHttp2Session, + input: { intervalMs: number; stallMs: number; onStall: (error: Error) => void }, +): () => void { + let stopped = false; + let pingTimer: ReturnType | undefined; + let stallTimer: ReturnType | undefined; + + function sendOnePing(): void { + if (stopped) return; + stallTimer = setTimeout(() => { + if (stopped) return; + stopped = true; + input.onStall(new Error("HTTP/2 bridge session stalled: no PING ack within the stall bound.")); + }, input.stallMs); + stallTimer.unref?.(); + try { + session.ping((error) => { + if (stopped) return; + if (stallTimer) clearTimeout(stallTimer); + if (error) { + stopped = true; + input.onStall(error instanceof Error ? error : new Error(String(error))); + return; + } + pingTimer = setTimeout(sendOnePing, input.intervalMs); + pingTimer.unref?.(); + }); + } catch (error) { + if (stallTimer) clearTimeout(stallTimer); + stopped = true; + input.onStall(error instanceof Error ? error : new Error(String(error))); + } + } + + pingTimer = setTimeout(sendOnePing, input.intervalMs); + pingTimer.unref?.(); + + return () => { + stopped = true; + if (pingTimer) clearTimeout(pingTimer); + if (stallTimer) clearTimeout(stallTimer); + }; +} + +// --------------------------------------------------------------------------- +// The server +// --------------------------------------------------------------------------- + +/** The result of one forward call. The server turns it into one stream response. */ +export interface Http2BridgeForwardResult { + status: number; + headers?: Record; + body?: Buffer | string; +} + +/** + * The one canonically-parsed, route-authorized, header-sanitized request the + * server hands to the forward handler. + */ +export interface Http2BridgeForwardRequest { + method: string; + pathname: string; + query: string; + headers: Record; + body: Buffer; +} + +export type Http2BridgeForwardHandler = ( + request: Http2BridgeForwardRequest, +) => Promise; + +/** The GOAWAY the server observed, naming the last stream ID the peer processed. */ +export interface Http2BridgeGoawayRecord { + lastStreamId: number; + errorCode: number; +} + +/** Classify one stream ID against an observed GOAWAY's last processed stream ID. */ +export function classifyStreamAgainstGoaway( + streamId: number, + lastStreamId: number, +): "accepted" | "not_accepted" { + return streamId <= lastStreamId ? "accepted" : "not_accepted"; +} + +export interface CreateHttp2BridgeServerOptions { + /** The per-run bridge token. The server compares it, constant-time, against + * the token on every stream before route or header processing. */ + bridgeToken: string; + /** The forward handler the server calls for each authorized request. */ + forwardRequest: Http2BridgeForwardHandler; + /** The route allowlist. The default is {@link DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST}. */ + routes?: readonly SandboxCallbackBridgeRouteRule[]; + /** The header allowlist. The default is {@link DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST}. */ + headerAllowlist?: readonly string[]; + /** The maximum request body size, in bytes. The default is {@link DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES}. */ + maxBodyBytes?: number; + /** The interval between two liveness PING frames, in milliseconds. */ + pingIntervalMs?: number; + /** The bound a sent PING waits for its ack before the server closes the session. */ + pingStallMs?: number; + /** The idle bound on a request body read: the maximum gap between two + * received chunks. The default is + * {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_TIMEOUT_MS}. */ + requestBodyTimeoutMs?: number; + /** The hard ceiling on a request body read's total lifetime, armed once + * and never renewed by progress. See + * {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_LIFETIME_CEILING_MS} for the + * default and the reasoning behind it. */ + requestBodyLifetimeCeilingMs?: number; + /** The bound {@link Http2BridgeServerHandle.close} waits for an active + * session to close on its own before it force-destroys the session. The + * default is {@link DEFAULT_HTTP2_BRIDGE_CLOSE_GRACE_MS}. */ + closeGraceMs?: number; + /** The cap, in bytes, on data this server holds once a bound `Duplex` + * reports its readable side is full (`push()` returns `false`). Past this + * cap the server treats the channel as stuck, not merely slow: see + * {@link wrapDuplexChannelAsNodeDuplex}. The default is + * {@link DEFAULT_HTTP2_BRIDGE_MAX_BUFFERED_READ_BYTES}. */ + maxBufferedReadBytes?: number; + /** The bound, in milliseconds, on how long the read backpressure queue + * {@link wrapDuplexChannelAsNodeDuplex} holds can stay non-empty with no + * chunk draining from it. The default is + * {@link DEFAULT_HTTP2_BRIDGE_READ_BACKPRESSURE_STALL_MS}. */ + readBackpressureStallMs?: number; + /** The sink for a GOAWAY the server observed on a session (one the sandbox + * side sent to the host). */ + onGoaway?: (record: Http2BridgeGoawayRecord) => void; + /** The sink for a session-level fault (a stall, a protocol fault). */ + onSessionError?: (error: Error) => void; + /** + * Fires once for each new session. A caller uses the live + * `ServerHttp2Session` to send its own GOAWAY (accepted security fix's + * GOAWAY-classification behavior is meaningful only from the side that + * names the last stream it processed — the host, since every stream + * originates from the sandbox). A test uses this hook to drive the + * GOAWAY test deterministically. + */ + onSession?: (session: http2.ServerHttp2Session) => void; +} + +/** The handle {@link createHttp2BridgeServer} returns. */ +export interface Http2BridgeServerHandle { + /** The underlying `Http2Server`. It is never `listen()`-ed; every session + * binds through {@link Http2BridgeServerHandle.bindChannel}. */ + readonly server: http2.Http2Server; + /** Wrap the channel as a `Duplex` and run one HTTP/2 session on it. Returns + * the wrapped `Duplex`, so a caller can also drive it directly (a test uses + * this to bind one side of a paired in-memory `Duplex`). */ + bindChannel(channel: CommandManagedDuplexChannel): Duplex; + /** Close every active session. Safe to call more than one time. */ + close(): Promise; +} + +function normalizeStreamMethod(value: string | string[] | undefined): string { + return typeof value === "string" && value.trim().length > 0 ? value.trim().toUpperCase() : "GET"; +} + +function readBridgeTokenHeader(headers: http2.IncomingHttpHeaders): string | undefined { + const raw = headers.authorization; + if (typeof raw !== "string" || !raw.startsWith("Bearer ")) return undefined; + return raw.slice("Bearer ".length); +} + +function toOutboundHeaderRecord(headers: http2.IncomingHttpHeaders): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (key.startsWith(":") || value == null) continue; + out[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : String(value); + } + return out; +} + +/** The size and time bounds a request body read enforces. */ +export interface Http2BridgeBodyBounds { + /** The maximum request body size, in bytes. */ + maxBodyBytes: number; + /** The idle bound: see {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_TIMEOUT_MS}. */ + idleTimeoutMs: number; + /** The lifetime ceiling: see {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_LIFETIME_CEILING_MS}. */ + lifetimeCeilingMs: number; +} + +/** + * Read one request body, bounded on size and on two independent time + * bounds: an idle bound and a total-lifetime ceiling. See + * {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_TIMEOUT_MS} and + * {@link DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_LIFETIME_CEILING_MS} for why the + * server enforces each one. + * + * The `close` listener is the settle-of-last-resort: it fires whenever the + * stream ends for any reason at all — a normal end, an error, a timeout- or + * shutdown-triggered `destroy()`, or a peer reset — so the promise always + * settles and the caller never awaits a stream that already went away. + */ +function readHttp2StreamBody( + stream: http2.ServerHttp2Stream, + bounds: Http2BridgeBodyBounds, +): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + let settled = false; + let idleTimer: ReturnType; + let lifetimeCeilingTimer: ReturnType; + const settle = (run: () => void) => { + if (settled) return; + settled = true; + clearTimeout(idleTimer); + clearTimeout(lifetimeCeilingTimer); + run(); + }; + const armIdleTimer = () => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + settle(() => reject(new Error("Bridge request body stalled before it completed."))); + stream.destroy(); + }, bounds.idleTimeoutMs); + idleTimer.unref?.(); + }; + // Arms one time, when the read starts, and never rearms on a chunk: see + // DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_LIFETIME_CEILING_MS for why the + // ceiling must stay independent of progress. + lifetimeCeilingTimer = setTimeout(() => { + settle(() => reject(new Error("Bridge request body passed the total lifetime ceiling."))); + stream.destroy(); + }, bounds.lifetimeCeilingMs); + lifetimeCeilingTimer.unref?.(); + armIdleTimer(); + stream.on("data", (chunk: Buffer) => { + totalBytes += chunk.byteLength; + if (totalBytes > bounds.maxBodyBytes) { + settle(() => reject(new Error("Bridge request body exceeded the configured size limit."))); + stream.destroy(); + return; + } + chunks.push(chunk); + // The chunk is real progress, so the peer is not stalled: reset the + // idle bound. The lifetime ceiling timer above does not reset here. + armIdleTimer(); + }); + stream.once("end", () => settle(() => resolve(Buffer.concat(chunks)))); + stream.once("error", (error) => settle(() => reject(error instanceof Error ? error : new Error(String(error))))); + stream.once("aborted", () => settle(() => reject(new Error("Bridge request stream aborted.")))); + stream.once("close", () => settle(() => reject(new Error("Bridge request stream closed before it completed.")))); + }); +} + +function respondJson(stream: http2.ServerHttp2Stream, status: number, body: unknown): void { + if (stream.destroyed || stream.closed) return; + try { + stream.respond({ ":status": status, "content-type": "application/json" }); + stream.end(JSON.stringify(body)); + } catch { + // The peer reset the stream (RST_STREAM) before the server could answer. + // That fault stays local to this one stream; every other stream and the + // session itself stay unaffected. + } +} + +/** + * Answer a denied stream, then consume and discard its request body under + * the same bounds ({@link Http2BridgeBodyBounds}) an authenticated request + * gets. A denied request's body content never reaches the forward handler, + * but the inbound half of the stream still needs a bound: without one, a + * peer that leaves the body unfinished keeps the stream open, holding one + * of the {@link HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS} slots for as long as + * it chooses. Nothing awaits the discard; the caller has already answered + * the request and moves on to the next stream. + */ +function denyRequest( + stream: http2.ServerHttp2Stream, + status: number, + body: unknown, + bounds: Http2BridgeBodyBounds, +): void { + respondJson(stream, status, body); + if (stream.destroyed || stream.closed) return; + readHttp2StreamBody(stream, bounds).catch(() => { + // The idle or lifetime bound above already destroyed the stream, or the + // peer reset it first. Either way the slot is free; the discarded body + // content is irrelevant to a denial. + }); +} + +/** + * Create the host HTTP/2 bridge server. The server runs no listener of its + * own: a caller wraps one duplex channel through {@link Http2BridgeServerHandle.bindChannel} + * per sandbox session. + */ +export function createHttp2BridgeServer(options: CreateHttp2BridgeServerOptions): Http2BridgeServerHandle { + const routes = options.routes ?? DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST; + const headerAllowlist = options.headerAllowlist ?? DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST; + const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES; + const pingIntervalMs = options.pingIntervalMs ?? DEFAULT_HTTP2_BRIDGE_PING_INTERVAL_MS; + const pingStallMs = options.pingStallMs ?? DEFAULT_HTTP2_BRIDGE_PING_STALL_MS; + const requestBodyTimeoutMs = options.requestBodyTimeoutMs ?? DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_TIMEOUT_MS; + const requestBodyLifetimeCeilingMs = + options.requestBodyLifetimeCeilingMs ?? DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_LIFETIME_CEILING_MS; + const closeGraceMs = options.closeGraceMs ?? DEFAULT_HTTP2_BRIDGE_CLOSE_GRACE_MS; + const maxBufferedReadBytes = options.maxBufferedReadBytes ?? DEFAULT_HTTP2_BRIDGE_MAX_BUFFERED_READ_BYTES; + const readBackpressureStallMs = + options.readBackpressureStallMs ?? DEFAULT_HTTP2_BRIDGE_READ_BACKPRESSURE_STALL_MS; + // Built one time and passed to every readHttp2StreamBody() and + // denyRequest() call below, so every stream on this server enforces the + // same bounds. + const bodyBounds: Http2BridgeBodyBounds = { + maxBodyBytes, + idleTimeoutMs: requestBodyTimeoutMs, + lifetimeCeilingMs: requestBodyLifetimeCeilingMs, + }; + + const server = http2.createServer(HTTP2_BRIDGE_SERVER_OPTIONS); + const activeSessions = new Set(); + + async function handleStream( + stream: http2.ServerHttp2Stream, + headers: http2.IncomingHttpHeaders, + ): Promise { + // Accepted security fix 4: the constant-time bridge-token compare runs + // before route processing and before header processing. This host check + // is independent of the gateway's own token check on the sandbox side. + if (!compareBridgeTokensConstantTime(options.bridgeToken, readBridgeTokenHeader(headers))) { + denyRequest(stream, 401, { error: "Invalid bridge token." }, bodyBounds); + return; + } + + // Accepted security fix 3: parse `:path` exactly one time; both the route + // allowlist and the forward request below read this one result. + const parsedPath = parseCanonicalBridgeRequestPath(headers); + if (!parsedPath.ok) { + denyRequest(stream, 400, { error: `Invalid request path: ${parsedPath.reason}` }, bodyBounds); + return; + } + const method = normalizeStreamMethod(headers[":method"]); + + const denialReason = authorizeSandboxCallbackBridgeRequestWithRoutes( + { method, path: parsedPath.value.pathname }, + routes, + ); + if (denialReason) { + denyRequest(stream, 403, { error: denialReason }, bodyBounds); + return; + } + + const sanitizedHeaders = sanitizeSandboxCallbackBridgeHeaders( + toOutboundHeaderRecord(headers), + headerAllowlist, + ); + + let body: Buffer; + try { + body = await readHttp2StreamBody(stream, bodyBounds); + } catch (error) { + respondJson(stream, 413, { error: error instanceof Error ? error.message : String(error) }); + return; + } + + let result: Http2BridgeForwardResult; + try { + result = await options.forwardRequest({ + method, + pathname: parsedPath.value.pathname, + query: parsedPath.value.query, + headers: sanitizedHeaders, + body, + }); + } catch (error) { + respondJson(stream, 502, { error: error instanceof Error ? error.message : String(error) }); + return; + } + + if (stream.destroyed || stream.closed) return; + const responseHeaders: http2.OutgoingHttpHeaders = { ":status": result.status }; + for (const [key, value] of Object.entries(result.headers ?? {})) { + if (key.toLowerCase() === "content-length") continue; + responseHeaders[key] = value; + } + try { + stream.respond(responseHeaders); + stream.end(result.body); + } catch { + // The peer reset the stream (RST_STREAM) between dispatch and response. + // One stream's write fault stays local to that stream. + } + } + + server.on("session", (session) => { + activeSessions.add(session); + options.onSession?.(session); + const stopWatchdog = startHttp2BridgePingWatchdog(session, { + intervalMs: pingIntervalMs, + stallMs: pingStallMs, + // Destroying the session with an error routes back through this same + // session's own `error` listener below, which reports it exactly once. + onStall: (error) => { + if (!session.destroyed) session.destroy(error); + }, + }); + session.on("goaway", (errorCode: number, lastStreamId: number) => { + options.onGoaway?.({ lastStreamId, errorCode }); + }); + session.on("close", () => { + stopWatchdog(); + activeSessions.delete(session); + }); + session.on("error", (error) => { + stopWatchdog(); + options.onSessionError?.(error instanceof Error ? error : new Error(String(error))); + }); + }); + + server.on("stream", (stream, headers) => { + void handleStream(stream, headers).catch((error) => { + // A fault inside `handleStream` itself (not a forward-handler or + // stream-body rejection, both already caught above) is a defensive + // last resort. Destroy only this stream; the session stays open. + if (!stream.destroyed) { + stream.destroy(error instanceof Error ? error : new Error(String(error))); + } + }); + }); + + return { + server, + bindChannel(channel: CommandManagedDuplexChannel): Duplex { + const duplex = wrapDuplexChannelAsNodeDuplex(channel, { maxBufferedReadBytes, readBackpressureStallMs }); + server.emit("connection", duplex); + return duplex; + }, + async close(): Promise { + // `session.close()` sends GOAWAY and waits for every open stream to end + // on its own; a stalled stream (its body never completes, and its own + // timeout has not yet fired) would hold this wait open forever. The + // grace timer bounds it: past `closeGraceMs`, the server force-destroys + // the session, which ends its streams at once and settles their body + // reads through the `close` backstop in `readHttp2StreamBody`. + await Promise.all( + [...activeSessions].map( + (session) => + new Promise((resolve) => { + if (session.closed || session.destroyed) { + resolve(); + return; + } + const forceDestroyTimer = setTimeout(() => { + if (!session.destroyed) session.destroy(); + }, closeGraceMs); + forceDestroyTimer.unref?.(); + session.close(() => { + clearTimeout(forceDestroyTimer); + resolve(); + }); + }), + ), + ); + }, + }; +} diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index aa7bb230299..283097815d4 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -86,7 +86,6 @@ export type { export { inferOpenAiCompatibleBiller } from "./billing.js"; export { ADAPTER_LOGIN_PANEL_MODES, - ADAPTER_LOGIN_SANDBOX_TRANSPORTS, ADAPTER_LOGIN_TIMEOUT_POLICIES, ADAPTER_LOGIN_COMPLETION_CLAIMS, assertValidAdapterLoginCapability, @@ -94,7 +93,6 @@ export { } from "./login-capability.js"; export type { AdapterLoginPanelMode, - AdapterLoginSandboxTransport, AdapterLoginTimeoutPolicy, AdapterLoginCompletionClaim, AdapterLoginPrompt, diff --git a/packages/adapter-utils/src/login-capability.test.ts b/packages/adapter-utils/src/login-capability.test.ts index a79a4e2dfa1..617103defce 100644 --- a/packages/adapter-utils/src/login-capability.test.ts +++ b/packages/adapter-utils/src/login-capability.test.ts @@ -10,7 +10,6 @@ import { function validCapability(): AdapterLoginCapability { return { panelMode: "displayed_code", - sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded", getCommand: () => "vendor login", parsePrompt: () => null, @@ -25,7 +24,6 @@ describe("assertValidAdapterLoginCapability", () => { it("accepts a well-formed capability with every optional member set", () => { const capability: AdapterLoginCapability = { panelMode: "submitted_browser_code", - sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed", getCommand: () => "vendor setup-token", parsePrompt: () => null, @@ -48,11 +46,6 @@ describe("assertValidAdapterLoginCapability", () => { expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/panelMode/); }); - it("rejects an unknown sandboxTransport", () => { - const bad = { ...validCapability(), sandboxTransport: "http" }; - expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/sandboxTransport/); - }); - it("rejects an unknown timeoutPolicy", () => { const bad = { ...validCapability(), timeoutPolicy: "unbounded" }; expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/timeoutPolicy/); diff --git a/packages/adapter-utils/src/login-capability.ts b/packages/adapter-utils/src/login-capability.ts index 986dc8a6b6e..9eac8e13466 100644 --- a/packages/adapter-utils/src/login-capability.ts +++ b/packages/adapter-utils/src/login-capability.ts @@ -18,14 +18,6 @@ export const ADAPTER_LOGIN_PANEL_MODES = ["displayed_code", "submitted_browser_code"] as const; export type AdapterLoginPanelMode = (typeof ADAPTER_LOGIN_PANEL_MODES)[number]; -/** - * The sandbox transport that the login command needs. `streamed_exec` runs the - * command over the streamed exec channel. `pseudo_terminal` runs the command on - * a real pseudo-terminal, because a pipe emits no login prompt. - */ -export const ADAPTER_LOGIN_SANDBOX_TRANSPORTS = ["streamed_exec", "pseudo_terminal"] as const; -export type AdapterLoginSandboxTransport = (typeof ADAPTER_LOGIN_SANDBOX_TRANSPORTS)[number]; - /** * The host-side timeout policy. `caller_bounded` lets the caller set the * timeout. `fixed` binds the timeout to a fixed adapter value. @@ -68,8 +60,6 @@ export interface AdapterLoginCompletionContext { export interface AdapterLoginCapability { /** The login panel mode. */ panelMode: AdapterLoginPanelMode; - /** The sandbox transport that the login command needs. */ - sandboxTransport: AdapterLoginSandboxTransport; /** The host-side timeout policy. */ timeoutPolicy: AdapterLoginTimeoutPolicy; /** Returns the fixed, non-secret login command. */ @@ -122,11 +112,6 @@ export function assertValidAdapterLoginCapability( `${prefix}: "panelMode" must be one of ${ADAPTER_LOGIN_PANEL_MODES.join(", ")}.`, ); } - if (!isOneOf(ADAPTER_LOGIN_SANDBOX_TRANSPORTS, cap.sandboxTransport)) { - throw new Error( - `${prefix}: "sandboxTransport" must be one of ${ADAPTER_LOGIN_SANDBOX_TRANSPORTS.join(", ")}.`, - ); - } if (!isOneOf(ADAPTER_LOGIN_TIMEOUT_POLICIES, cap.timeoutPolicy)) { throw new Error( `${prefix}: "timeoutPolicy" must be one of ${ADAPTER_LOGIN_TIMEOUT_POLICIES.join(", ")}.`, diff --git a/packages/adapter-utils/src/setup-token-transport.test.ts b/packages/adapter-utils/src/login-pty-transport.test.ts similarity index 88% rename from packages/adapter-utils/src/setup-token-transport.test.ts rename to packages/adapter-utils/src/login-pty-transport.test.ts index 3644e35581e..d73bddcb166 100644 --- a/packages/adapter-utils/src/setup-token-transport.test.ts +++ b/packages/adapter-utils/src/login-pty-transport.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { - createSetupTokenPtyTransport, - type SetupTokenPtySession, -} from "./setup-token-transport.js"; + createLoginPtyTransport, + type LoginPtySession, +} from "./login-pty-transport.js"; // The Enter byte the terminal login UI reads to submit the browser code. The // login runner appends this byte to the code. The transport forwards the bytes @@ -14,7 +14,7 @@ const ENTER = "\r"; * stream on demand, and records the stop and the close. The tests use it in * place of a real pseudo-terminal, so the transport runs with no sandbox. */ -function createFakePtySession(): SetupTokenPtySession & { +function createFakePtySession(): LoginPtySession & { writes: string[]; emit: (chunk: string) => void; finish: (exitCode: number | null) => void; @@ -74,10 +74,10 @@ function createFakePtySession(): SetupTokenPtySession & { }; } -describe("createSetupTokenPtyTransport", () => { +describe("createLoginPtyTransport", () => { it("delivers delayed input to the process", async () => { const session = createFakePtySession(); - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); const started = transport.start("claude setup-token", () => {}); await session.ready; @@ -94,7 +94,7 @@ describe("createSetupTokenPtyTransport", () => { it("returns incremental terminal output to the runner", async () => { const session = createFakePtySession(); const received: string[] = []; - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); const started = transport.start("claude setup-token", (chunk) => { received.push(chunk); @@ -114,7 +114,7 @@ describe("createSetupTokenPtyTransport", () => { it("delivers the Enter byte after the browser code", async () => { const session = createFakePtySession(); - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); const started = transport.start("claude setup-token", () => {}); await session.ready; @@ -133,7 +133,7 @@ describe("createSetupTokenPtyTransport", () => { it("resolves start with the child exit code", async () => { const session = createFakePtySession(); - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); const started = transport.start("claude setup-token", () => {}); session.finish(7); @@ -143,7 +143,7 @@ describe("createSetupTokenPtyTransport", () => { it("stops the child with a direct kill", async () => { const session = createFakePtySession(); - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); const started = transport.start("claude setup-token", () => {}); await session.ready; @@ -156,7 +156,7 @@ describe("createSetupTokenPtyTransport", () => { it("disposes the session resources", async () => { const session = createFakePtySession(); - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); const started = transport.start("claude setup-token", () => {}); session.finish(0); @@ -168,7 +168,7 @@ describe("createSetupTokenPtyTransport", () => { it("stays safe when stop and dispose run before start", async () => { const session = createFakePtySession(); - const transport = createSetupTokenPtyTransport(async () => session); + const transport = createLoginPtyTransport(async () => session); // The runner may stop or dispose before it starts the child. The transport // must not throw, and it must not open a session for a run it already stopped. @@ -185,7 +185,7 @@ describe("createSetupTokenPtyTransport", () => { const openGate = new Promise((resolve) => { resolveOpen = resolve; }); - const transport = createSetupTokenPtyTransport(async () => { + const transport = createLoginPtyTransport(async () => { await openGate; return session; }); @@ -207,7 +207,7 @@ describe("createSetupTokenPtyTransport", () => { const openGate = new Promise((resolve) => { resolveOpen = resolve; }); - const transport = createSetupTokenPtyTransport(async () => { + const transport = createLoginPtyTransport(async () => { await openGate; return session; }); diff --git a/packages/adapter-utils/src/setup-token-transport.ts b/packages/adapter-utils/src/login-pty-transport.ts similarity index 89% rename from packages/adapter-utils/src/setup-token-transport.ts rename to packages/adapter-utils/src/login-pty-transport.ts index af8b0c856cc..48369bdbe49 100644 --- a/packages/adapter-utils/src/setup-token-transport.ts +++ b/packages/adapter-utils/src/login-pty-transport.ts @@ -1,4 +1,4 @@ -// The setup-token pseudo-terminal transport. It gives the Claude `setup-token` +// The login pseudo-terminal (PTY) transport. It gives the Claude `setup-token` // login runner a child that runs on a real pseudo-terminal (PTY). The command // needs a PTY: pipe stdio emits no login prompt. The transport starts the // command on a PTY, streams the incremental terminal output, delivers delayed @@ -7,7 +7,7 @@ // This module is provider-agnostic and pure. It holds no Node built-in import, // so the browser-safe root entry can re-export it. A sandbox provider (for // example the Daytona plugin) opens the concrete pseudo-terminal through a -// {@link SetupTokenPtySessionOpener}. A unit test opens a fake pseudo-terminal +// {@link LoginPtySessionOpener}. A unit test opens a fake pseudo-terminal // through the same opener. // // Boundary (ANSI and OSC 8): the transport forwards the raw terminal bytes @@ -20,7 +20,7 @@ * raw terminal output, accepts delayed input, and stops the child. The session * forwards the raw terminal bytes; it runs no ANSI or OSC 8 handling. */ -export interface SetupTokenPtySession { +export interface LoginPtySession { /** * Registers the one output listener. The session streams each raw terminal * output chunk to `listener`, in order, as the pseudo-terminal emits it. @@ -49,7 +49,7 @@ export interface SetupTokenPtySession { * Opens a pseudo-terminal session for `command`. A provider binds this to its * sandbox. The transport calls it one time, on start. */ -export type SetupTokenPtySessionOpener = (command: string) => Promise; +export type LoginPtySessionOpener = (command: string) => Promise; /** * The child side of the setup-token run, in the shape the login runner needs. @@ -60,7 +60,7 @@ export type SetupTokenPtySessionOpener = (command: string) => Promise { if (started) { - throw new Error("setup-token PTY transport already started."); + throw new Error("login PTY transport already started."); } started = true; const opened = await open(command); diff --git a/packages/adapter-utils/src/published-exports.test.ts b/packages/adapter-utils/src/published-exports.test.ts new file mode 100644 index 00000000000..fa23e206f05 --- /dev/null +++ b/packages/adapter-utils/src/published-exports.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +// This test pins the published subpath surface of `package.json`. +// +// `@paperclipai/server` is itself a published npm package. Its published +// build imports `@paperclipai/adapter-utils/duplex-observability` as a real +// npm dependency, not as a workspace link. Node resolves that import against +// `publishConfig.exports`, so the subpath must stay published there. An +// earlier revision of this file denied the subpath with an explicit `null` +// entry; that denial broke module resolution for the published server. The +// subpath now falls through to the wildcard entry, the same as every other +// file in the package. + +interface PackageManifest { + exports: Record; + publishConfig: { + exports: Record; + }; +} + +const manifestPath = fileURLToPath(new URL("../package.json", import.meta.url)); +const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as PackageManifest; + +describe("publishConfig publishes the duplex observability subpath", () => { + it("does not deny the subpath in publishConfig.exports", () => { + expect(manifest.publishConfig.exports).not.toHaveProperty("./duplex-observability"); + }); + + it("keeps the wildcard entry in publishConfig.exports so the subpath resolves through it", () => { + expect(manifest.publishConfig.exports["./*"]).toBeDefined(); + }); + + it("keeps the top-level wildcard export unchanged", () => { + expect(manifest.exports["./*"]).toBe("./src/*.ts"); + }); +}); diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index b0300d6d7d1..07e24ec155d 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -1,5 +1,6 @@ -import { execFile as execFileCallback } from "node:child_process"; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { execFile as execFileCallback, spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -475,6 +476,68 @@ describe("sandbox callback bridge", () => { } }); + it("recovers from transient queue polling failures and keeps relaying requests", async () => { + // A single reset or slow sandbox exec used to unwind the poll loop into its + // terminal catch, killing the relay for the rest of the run. The loop must + // instead back off, retry, and still deliver requests queued after the + // failure window. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-transient-")); + cleanupDirs.push(rootDir); + + const queueDir = path.posix.join(rootDir, "queue"); + const directories = sandboxCallbackBridgeDirectories(queueDir); + const baseClient = createFileSystemSandboxCallbackBridgeQueueClient(); + let listCalls = 0; + const client: SandboxCallbackBridgeQueueClient = { + ...baseClient, + listJsonFiles: async (dirPath: string) => { + listCalls += 1; + if (listCalls <= 3) { + throw new Error("list requests failed: kex_exchange_identification: read: Connection reset by peer"); + } + return baseClient.listJsonFiles(dirPath); + }, + }; + + const seenPaths: string[] = []; + const worker = await startSandboxCallbackBridgeWorker({ + client, + queueDir, + authorizeRequest: async () => null, + handleRequest: async (request) => { + seenPaths.push(request.path); + return { + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ok: true }), + }; + }, + }); + cleanupFns.push(async () => { + await worker.stop(); + }); + + const requestId = "transient-recovery-1"; + await writeFile( + path.join(directories.requestsDir, `${requestId}.json`), + JSON.stringify({ + id: requestId, + method: "GET", + path: "/api/agents/me", + query: "", + headers: {}, + body: "", + }), + "utf8", + ); + + const responseFile = await waitForJsonFile(directories.responsesDir, 10_000); + const raw = await readFile(path.join(directories.responsesDir, responseFile), "utf8"); + expect(JSON.parse(raw)).toMatchObject({ id: requestId, status: 200 }); + expect(seenPaths).toEqual(["/api/agents/me"]); + expect(listCalls).toBeGreaterThan(3); + }); + it("keeps the queue-directory setup on the startup step but resets the poll loop store", async () => { // The worker starts inside the measured `bridge.paperclip` step. Its awaited // queue-directory setup is startup work, so a `makeDir` `sandbox.exec` span @@ -1510,7 +1573,7 @@ describe("sandbox callback bridge", () => { })}\n`; } - it("times out a stalled poll, writes a 503, and surfaces a run-level error", async () => { + it("times out a stalled poll, surfaces a run-level error, and recovers to deliver the request", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-hang-")); cleanupDirs.push(rootDir); @@ -1526,9 +1589,11 @@ describe("sandbox callback bridge", () => { const client: SandboxCallbackBridgeQueueClient = { ...base, // The first poll never resolves — a silently unresponsive sandbox channel. - // The per-iteration timeout must convert the hang into a caught error. The - // request never reaches the handler, so the recovery path can safely 503 - // it. Later calls (the recovery's failPendingRequests) resolve. + // The per-iteration timeout must convert the hang into a caught error, and + // the loop must then back off and retry rather than die: the request never + // reached the handler, so the retry delivers the real response. A + // sustained outage is the watchdog's job (proven separately below), not a + // reason to fail a request one transient hang could still serve. listJsonFiles: async (dir) => { listCalls += 1; if (listCalls === 1) { @@ -1550,7 +1615,7 @@ describe("sandbox callback bridge", () => { const responseFile = await waitForJsonFile(directories.responsesDir, 3_000); const responseBody = await readFile(path.posix.join(directories.responsesDir, responseFile), "utf8"); - expect(JSON.parse(responseBody).status).toBe(503); + expect(JSON.parse(responseBody).status).toBe(200); expect(workerErrors.length).toBeGreaterThan(0); expect(workerErrors[0]).toContain("timed out"); @@ -2261,10 +2326,13 @@ describe("sandbox callback bridge", () => { }); it("retries a recovery 503 write that fails transiently, delivers the 503, and removes the request", async () => { - // The poll times out, so the recovery path aborts the queued request with a - // 503. The first 503 write fails, so the recovery must retry it inside the - // same pass. It then delivers the 503 and removes the request. The request is - // unclaimed, so its host mutation never ran; the 503 stays retry-safe. + // A request attempt times out (its first read hangs), so the loop's request + // catch runs the recovery pass, which aborts the queued request with a 503. + // The first 503 write fails, so the recovery must retry it inside the same + // pass. It then delivers the 503 and removes the request. The request is + // unclaimed, so its host mutation never ran; the 503 stays retry-safe. (A + // hung poll no longer triggers this pass — the loop backs off and retries + // the poll instead, and a sustained hang is the watchdog's job.) const waitFor = async (predicate: () => boolean, timeoutMs: number) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -2286,27 +2354,26 @@ describe("sandbox callback bridge", () => { requestBodies.set(requestPath, bridgeRequestJson("req-503-retry")); const responseWrites: Array<{ path: string; status: number; body: string }> = []; const requestRemovals: string[] = []; - let listCalls = 0; + let readCalls = 0; let writeAttempts = 0; const client: SandboxCallbackBridgeQueueClient = { makeDir: async () => {}, makeDirs: async () => {}, - // The first poll never resolves — a silently unresponsive sandbox channel. - // The per-iteration timeout converts the hang into a caught error, so the - // loop `catch` runs the recovery path. Later listings resolve, so the - // recovery enumerates and aborts the request. - listJsonFiles: async (dir) => { - if (dir !== directories.requestsDir) { - return []; - } - listCalls += 1; - if (listCalls === 1) { - return await new Promise(() => {}); - } - return [...requestBodies.keys()].map((entry) => path.posix.basename(entry)).sort(); - }, + listJsonFiles: async (dir) => + dir === directories.requestsDir + ? [...requestBodies.keys()].map((entry) => path.posix.basename(entry)).sort() + : [], + // The first read never resolves — a silently unresponsive sandbox channel + // hit mid-request, before the handler claim. The per-iteration timeout + // converts the hang into a caught error, so the loop's request catch runs + // the recovery pass. The recovery's own read resolves, so it can build and + // deliver the 503. readTextFile: async (remotePath) => { + readCalls += 1; + if (readCalls === 1) { + return await new Promise(() => {}); + } const body = requestBodies.get(remotePath); if (body === undefined) { throw new Error(`missing request ${remotePath}`); @@ -2640,4 +2707,377 @@ describe("sandbox callback bridge", () => { await worker.stop({ drainTimeoutMs: 50 }); }); + + async function prepareGatewayFixture(prefix: string) { + const rootDir = await mkdtemp(path.join(os.tmpdir(), prefix)); + cleanupDirs.push(rootDir); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + await mkdir(localWorkspaceDir, { recursive: true }); + await mkdir(remoteWorkspaceDir, { recursive: true }); + await writeFile(path.join(localWorkspaceDir, "README.md"), "bridge test\n", "utf8"); + + const runner = createExecRunner(); + const bridgeAsset = await createSandboxCallbackBridgeAsset(); + cleanupFns.push(bridgeAsset.cleanup); + const prepared = await prepareCommandManagedRuntime({ + runner, + spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 }, + adapterKey: "codex", + workspaceLocalDir: localWorkspaceDir, + assets: [{ key: "bridge", localDir: bridgeAsset.localDir }], + }); + const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge"); + return { + runner, + remoteWorkspaceDir, + assetRemoteDir: prepared.assetDirs.bridge, + queueDir, + directories: sandboxCallbackBridgeDirectories(queueDir), + bridgeToken: createSandboxCallbackBridgeToken(), + }; + } + + it("cleans up a timed-out request file and keeps serving after the host recovers", async () => { + const fixture = await prepareGatewayFixture("paperclip-bridge-timeout-clean-"); + + const bridge = await startSandboxCallbackBridgeServer({ + runner: fixture.runner, + remoteCwd: fixture.remoteWorkspaceDir, + assetRemoteDir: fixture.assetRemoteDir, + queueDir: fixture.queueDir, + bridgeToken: fixture.bridgeToken, + timeoutMs: 30_000, + responseTimeoutMs: 600, + pollIntervalMs: 50, + }); + cleanupFns.push(async () => { + await bridge.stop(); + }); + + // No worker runs, so the request times out at the gateway. + const timedOut = await fetch(`${bridge.baseUrl}/api/agents/me`, { + headers: { authorization: `Bearer ${fixture.bridgeToken}` }, + }); + expect(timedOut.status).toBe(502); + await expect(timedOut.json()).resolves.toMatchObject({ + error: expect.stringContaining("Timed out"), + }); + + // The gateway cleaned its own request file, so nothing counts toward the + // queue-depth cap after the caller gave up. + const leftover = (await readdir(fixture.directories.requestsDir).catch(() => [])).filter((name) => + name.endsWith(".json"), + ); + expect(leftover).toEqual([]); + + // A worker that comes up afterwards serves the next request normally — + // the timeout neither wedged nor killed the gateway. + const worker = await startSandboxCallbackBridgeWorker({ + client: createFileSystemSandboxCallbackBridgeQueueClient(), + queueDir: fixture.queueDir, + authorizeRequest: async () => null, + handleRequest: async () => ({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ok: true }), + }), + }); + cleanupFns.push(async () => { + await worker.stop(); + }); + + const recovered = await fetch(`${bridge.baseUrl}/api/agents/me`, { + headers: { authorization: `Bearer ${fixture.bridgeToken}` }, + }); + expect(recovered.status).toBe(200); + await expect(recovered.json()).resolves.toMatchObject({ ok: true }); + }, 30_000); + + it("sweeps stale request files before rejecting at the queue-depth cap", async () => { + const fixture = await prepareGatewayFixture("paperclip-bridge-stale-sweep-"); + + const bridge = await startSandboxCallbackBridgeServer({ + runner: fixture.runner, + remoteCwd: fixture.remoteWorkspaceDir, + assetRemoteDir: fixture.assetRemoteDir, + queueDir: fixture.queueDir, + bridgeToken: fixture.bridgeToken, + timeoutMs: 30_000, + responseTimeoutMs: 500, + pollIntervalMs: 50, + maxQueueDepth: 1, + }); + cleanupFns.push(async () => { + await bridge.stop(); + }); + + // Plant an orphaned request file (a killed caller, or a previous gateway + // process's leftover) and backdate it beyond the response deadline. + const orphanPath = path.join(fixture.directories.requestsDir, "orphan.json"); + await writeFile(orphanPath, bridgeRequestJson("orphan"), "utf8"); + const staleTime = new Date(Date.now() - 60_000); + await utimes(orphanPath, staleTime, staleTime); + + // The queue sits at the cap, but the only entry is stale: the gateway must + // sweep it and admit the request instead of answering 503. With no worker + // the admitted request then times out (502) — proof it entered the queue. + const response = await fetch(`${bridge.baseUrl}/api/agents/me`, { + headers: { authorization: `Bearer ${fixture.bridgeToken}` }, + }); + expect(response.status).toBe(502); + const body = (await response.json()) as { error?: string }; + expect(body.error ?? "").not.toContain("queue is full"); + + const leftover = (await readdir(fixture.directories.requestsDir).catch(() => [])).filter((name) => + name.endsWith(".json"), + ); + expect(leftover).toEqual([]); + }, 30_000); + + it("skips a request file that vanished before the read instead of escalating", async () => { + // The gateway deletes a request file when its caller stops waiting. The + // worker's read then races the deletion; a vanished file must be a quiet + // skip, not a worker failure with a recovery pass. + const queueDir = "/virtual-bridge/vanished"; + let listCalls = 0; + const handled: string[] = []; + const responseStatuses: number[] = []; + const { runtimeSpan, workerErrors } = createWorkerErrorCapture(); + + const client: SandboxCallbackBridgeQueueClient = { + makeDir: async () => {}, + makeDirs: async () => {}, + listJsonFiles: async () => { + listCalls += 1; + return listCalls === 1 ? ["ghost.json"] : []; + }, + readTextFile: async () => { + throw new Error("cat: ghost.json: No such file or directory"); + }, + writeTextFile: async () => {}, + writeResponseFile: async (_remotePath, body) => { + responseStatuses.push((JSON.parse(body.trim()) as { status: number }).status); + return { wrote: true }; + }, + rename: async () => {}, + remove: async () => {}, + }; + + const worker = await startSandboxCallbackBridgeWorker({ + client, + queueDir, + runtimeSpan, + authorizeRequest: async () => null, + handleRequest: async (request) => { + handled.push(request.id); + return { status: 200, body: "ok" }; + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 250)); + await worker.stop({ drainTimeoutMs: 50 }); + + expect(handled).toEqual([]); + expect(responseStatuses).toEqual([]); + expect(workerErrors).toEqual([]); + }); + + it("embeds crash handlers and queue hygiene in the generated gateway source", () => { + // A crashed gateway is a dead loopback port for the rest of the run, so + // the generated source must keep its crash handlers and its stale-queue + // sweep. The readiness gate matters too: survival applies only after the + // gateway is adoptable, so a startup fault still fails fast. This pins + // their presence; the behavior is proven above and below. + const source = getSandboxCallbackBridgeServerSource(); + expect(source).toContain('process.on("uncaughtException"'); + expect(source).toContain('process.on("unhandledRejection"'); + expect(source).toContain("gatewayReady"); + expect(source).toContain("sweepStaleRequests"); + }); + + it("exits fast when the gateway cannot bind its port instead of lingering un-ready", async () => { + // Before readiness, the crash handlers must not keep the process alive: a + // failed bind means the gateway can never serve, and surviving would only + // leave an un-ready zombie while the host waits out its readiness poll. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-bind-fail-")); + cleanupDirs.push(rootDir); + const entrypoint = path.join(rootDir, "paperclip-bridge-server.mjs"); + await writeFile(entrypoint, getSandboxCallbackBridgeServerSource(), "utf8"); + const queueDir = path.join(rootDir, "queue"); + await mkdir(queueDir, { recursive: true }); + + const blocker = createServer(); + await new Promise((resolve) => { + blocker.listen(0, "127.0.0.1", resolve); + }); + cleanupFns.push( + () => + new Promise((resolve) => { + blocker.close(() => resolve()); + }), + ); + const blockedPort = (blocker.address() as { port: number }).port; + + const child = spawn(process.execPath, [entrypoint], { + env: { + ...process.env, + PAPERCLIP_BRIDGE_QUEUE_DIR: queueDir, + PAPERCLIP_BRIDGE_TOKEN: "test-token", + PAPERCLIP_BRIDGE_PORT: String(blockedPort), + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const exitCode = await new Promise((resolve) => { + child.on("close", resolve); + }); + + expect(exitCode).toBe(1); + expect(stderr).toContain("[paperclip-bridge] server error"); + expect(stderr).toContain("EADDRINUSE"); + }, 15_000); + + it("exits nonzero for the retired duplex_v1 mode instead of starting the queue gateway", async () => { + // The closed mode allowlist rejects `duplex_v1` before the queue-directory + // check, so a stale `duplex_v1` launch environment fails startup instead + // of silently falling through to the queue gateway. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-mode-duplex-")); + cleanupDirs.push(rootDir); + const entrypoint = path.join(rootDir, "paperclip-bridge-server.mjs"); + await writeFile(entrypoint, getSandboxCallbackBridgeServerSource(), "utf8"); + const queueDir = path.join(rootDir, "queue"); + await mkdir(queueDir, { recursive: true }); + + const child = spawn(process.execPath, [entrypoint], { + env: { + ...process.env, + PAPERCLIP_API_BRIDGE_MODE: "duplex_v1", + PAPERCLIP_BRIDGE_QUEUE_DIR: queueDir, + PAPERCLIP_BRIDGE_TOKEN: "test-token", + PAPERCLIP_BRIDGE_PORT: "0", + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const exitCode = await new Promise((resolve) => { + child.on("close", resolve); + }); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Unsupported PAPERCLIP_API_BRIDGE_MODE: duplex_v1"); + }, 15_000); + + it("exits nonzero for an unknown bridge mode instead of starting the queue gateway", async () => { + // The closed mode allowlist rejects every value it does not name, not + // only the retired duplex transport. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-mode-unknown-")); + cleanupDirs.push(rootDir); + const entrypoint = path.join(rootDir, "paperclip-bridge-server.mjs"); + await writeFile(entrypoint, getSandboxCallbackBridgeServerSource(), "utf8"); + const queueDir = path.join(rootDir, "queue"); + await mkdir(queueDir, { recursive: true }); + + const child = spawn(process.execPath, [entrypoint], { + env: { + ...process.env, + PAPERCLIP_API_BRIDGE_MODE: "totally_unknown_mode", + PAPERCLIP_BRIDGE_QUEUE_DIR: queueDir, + PAPERCLIP_BRIDGE_TOKEN: "test-token", + PAPERCLIP_BRIDGE_PORT: "0", + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const exitCode = await new Promise((resolve) => { + child.on("close", resolve); + }); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Unsupported PAPERCLIP_API_BRIDGE_MODE: totally_unknown_mode"); + }, 15_000); + + it("test_http2_gateway_writes_no_frame_between_ready_and_the_preface", async () => { + // Spawn the real generated gateway in http2_v1 mode and read its raw + // stdout bytes. The only frame-codec write on this path is the READY + // line; the very next bytes must be the HTTP/2 client connection preface + // with nothing in between, because the gateway hands stdout to the + // HTTP/2 client immediately after it writes READY and starts no + // heartbeat timer and writes no envelope frame on this path. + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-http2-gateway-")); + cleanupDirs.push(rootDir); + const entrypoint = path.join(rootDir, "paperclip-bridge-server.mjs"); + await writeFile(entrypoint, getSandboxCallbackBridgeServerSource(), "utf8"); + + const probe = createServer(); + const assignedPort = await new Promise((resolve, reject) => { + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address(); + if (!address || typeof address === "string") { + reject(new Error("Could not reserve a loopback port for the test.")); + return; + } + probe.close(() => resolve(address.port)); + }); + }); + + const nonce = "test-nonce-http2"; + const child = spawn(process.execPath, [entrypoint], { + env: { + ...process.env, + PAPERCLIP_API_BRIDGE_MODE: "http2_v1", + PAPERCLIP_BRIDGE_TOKEN: "test-token", + PAPERCLIP_BRIDGE_PORT: String(assignedPort), + PAPERCLIP_BRIDGE_NONCE: nonce, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + cleanupFns.push(async () => { + child.kill(); + }); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + + const chunks: Buffer[] = []; + const preface = Buffer.from("505249202a20485454502f322e300d0a0d0a534d0d0a0d0a", "hex"); + const firstBytes = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Timed out waiting for the http2 gateway stdout. stderr: " + stderr)), + 5000, + ); + child.stdout.on("data", (chunk: Buffer) => { + chunks.push(chunk); + const total = Buffer.concat(chunks); + const newlineIndex = total.indexOf(0x0a); + if (newlineIndex !== -1 && total.length >= newlineIndex + 1 + preface.length) { + clearTimeout(timer); + resolve(total); + } + }); + child.once("error", reject); + child.once("exit", (code) => { + clearTimeout(timer); + reject(new Error("The http2 gateway exited early with code " + String(code) + ". stderr: " + stderr)); + }); + }); + + const newlineIndex = firstBytes.indexOf(0x0a); + expect(newlineIndex).toBeGreaterThan(0); + const readyLine = firstBytes.subarray(0, newlineIndex).toString("utf8"); + expect(JSON.parse(readyLine)).toMatchObject({ type: "ready", nonce }); + const afterReady = firstBytes.subarray(newlineIndex + 1, newlineIndex + 1 + preface.length); + expect(afterReady).toEqual(preface); + }, 15_000); }); diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index 9c3d073919f..cee3ce0b1d9 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -1,7 +1,9 @@ -import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { promises as fs } from "node:fs"; +import http2 from "node:http2"; import os from "node:os"; import path from "node:path"; +import type { Duplex } from "node:stream"; import { runWithoutActiveStep, @@ -34,7 +36,8 @@ const DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES = 8 * 1024 * 1024; // (PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS), so the host loop fails fast and writes // 503 responses before the in-sandbox client gives up. A silently unresponsive // sandbox channel makes a client call hang with no reject; this timeout turns -// that hang into a caught error, so the loop `catch` runs `failPendingRequests`. +// that hang into a caught error, so the poll loop can back off and retry while +// the watchdog below decides when to fail the queued requests. const DEFAULT_BRIDGE_ITERATION_TIMEOUT_MS = 10_000; // Watchdog backstop for a hang that the per-iteration timeout does not catch // (for example many slow-but-under-timeout calls, or a stall outside the awaited @@ -58,32 +61,27 @@ const MAX_BACKSTOP_WRITE_ATTEMPTS = 3; // The delay between two 504 backstop write attempts. It is short, so all retries // finish well under the in-sandbox 30s response deadline. const BACKSTOP_WRITE_RETRY_MS = 50; +// Backoff cap between poll-loop retries after a transient iteration failure. +// The cap keeps a recovering loop probing often enough to resume before the +// in-sandbox 30s response deadline strands queued callers, while the +// exponential ramp below it keeps a hard-down channel from burning an exec +// call every poll interval. +const MAX_TRANSIENT_ITERATION_BACKOFF_MS = 5_000; const REMOTE_WRITE_BASE64_CHUNK_SIZE = 32 * 1024; export const SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT = "paperclip-bridge-server.mjs"; const SANDBOX_EXEC_CHANNEL_ENV = "PAPERCLIP_SANDBOX_EXEC_CHANNEL"; const SANDBOX_EXEC_CHANNEL_BRIDGE = "bridge"; -// The two bridge modes the generated gateway supports. The file mode polls a -// request/response queue on disk. The duplex mode forwards one request frame to -// stdout and resolves one response frame from stdin. The generated `.mjs` -// selects the mode from `PAPERCLIP_API_BRIDGE_MODE`. +// The bridge modes the generated gateway supports. The file mode polls a +// request/response queue on disk. The http2 mode runs one Node HTTP/2 client +// session directly on stdin/stdout, after it sends the one READY line the +// host readiness gate expects. The generated `.mjs` selects the mode from +// `PAPERCLIP_API_BRIDGE_MODE`. The generated gateway rejects every other +// value with a fixed startup error, including the retired duplex transport. +// HTTP/2 is the preferred transport. `queue_v1` is the soft-deprecated fallback. const SANDBOX_CALLBACK_BRIDGE_FILE_MODE = "queue_v1"; -export const SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE = "duplex_v1"; - -// The duplex gateway HTTP wait budget default. The gateway waits this long for a -// response frame before it answers the local caller with a 502 timeout. The -// value stays configurable through `PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS`, the -// same environment key the file mode reads. -const DEFAULT_DUPLEX_GATEWAY_WAIT_BUDGET_MS = 35_000; -// How often the duplex gateway writes a heartbeat frame to stdout. -const DEFAULT_DUPLEX_GATEWAY_HEARTBEAT_INTERVAL_MS = 5_000; -// The duplex gateway treats the channel as lost when no inbound frame arrives on -// stdin within this window. -const DEFAULT_DUPLEX_GATEWAY_HEARTBEAT_TIMEOUT_MS = 20_000; -// After a loss, the duplex gateway answers each new local request with a 503, -// then exits when this grace ends. The grace gives the local caller time to read -// the 409 for an outstanding request and a 503 for a new request. -const DEFAULT_DUPLEX_GATEWAY_LOSS_EXIT_GRACE_MS = 1_000; +/** The active non-file transport mode. */ +export const SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE = "http2_v1"; /** Span name that wraps one Paperclip-API callback request — read the request, * write the response, and remove the request file. */ @@ -976,7 +974,22 @@ export async function startSandboxCallbackBridgeWorker(input: { await writeAbortedHandlerBackstop(fileName, guard, lastWriteError); }; try { - const raw = await input.client.readTextFile(requestPath); + let raw: string; + try { + raw = await input.client.readTextFile(requestPath); + } catch (error) { + // The gateway deletes a request file when its caller stops waiting + // (client-side timeout cleanup). A read that fails because the file is + // gone is that benign race, not a channel fault: confirm the file + // vanished and skip quietly instead of escalating into a recovery + // pass. A file that is still listed rethrows, so a real read fault + // keeps its existing handling. + const remaining = await input.client.listJsonFiles(directories.requestsDir).catch(() => null); + if (remaining !== null && !remaining.includes(fileName)) { + return; + } + throw error; + } let request: SandboxCallbackBridgeRequest; try { request = JSON.parse(raw) as SandboxCallbackBridgeRequest; @@ -1380,13 +1393,54 @@ export async function startSandboxCallbackBridgeWorker(input: { watchdogTimer.unref(); } try { + // Consecutive transient poll failures. A single failed list call — one + // reset or slow sandbox exec — must not end the relay for the rest of the + // run: the in-sandbox gateway keeps queueing requests, so a dead loop + // strands every later API call from the agent (its status writes then look + // like connection failures and the issue loses its disposition). Back off + // and retry the poll instead. The watchdog stays the escalation path for a + // sustained outage — it fires after `watchdogTimeoutMs` without a + // successful iteration and fails the queued requests fast, while this loop + // keeps probing for recovery. + let consecutivePollFailures = 0; while (true) { - const fileNames = await withTimeout( - input.client.listJsonFiles(directories.requestsDir), - iterationTimeoutMs, - "Sandbox callback bridge list requests", - ); - if (fileNames.length === 0) { + let fileNames: string[]; + try { + fileNames = await withTimeout( + input.client.listJsonFiles(directories.requestsDir), + iterationTimeoutMs, + "Sandbox callback bridge list requests", + ); + consecutivePollFailures = 0; + } catch (error) { + if (stopping) { + break; + } + consecutivePollFailures += 1; + const message = `${buildWorkerFailureMessage(error)} (transient poll failure ${consecutivePollFailures}; retrying)`; + if (consecutivePollFailures === 1) { + // Put the first failure of a streak on the run trace; later repeats + // only warn, so a flapping channel does not spam failed spans. + await surfaceRunError(new Error(message)); + } else { + console.warn(`[paperclip] ${message}`); + } + const backoffMs = Math.min( + pollIntervalMs * 2 ** consecutivePollFailures, + MAX_TRANSIENT_ITERATION_BACKOFF_MS, + ); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + continue; + } + // A file whose attempt is still in flight (or waiting on its 504 + // backstop) is not actionable: `processRequestFile` would skip it via + // the guard map. Treat an all-guarded listing like an empty one and + // sleep a poll interval. Re-listing immediately would spin the loop — + // an exec storm against a real sandbox channel, and with an in-memory + // client a pure-microtask loop that starves every timer in the process + // (including the guard's own backstop and abort timers). + const actionableFileNames = fileNames.filter((fileName) => !inFlightRequestGuards.has(fileName)); + if (actionableFileNames.length === 0) { lastSuccessfulIterationAt = Date.now(); if (stopping) { break; @@ -1394,7 +1448,7 @@ export async function startSandboxCallbackBridgeWorker(input: { await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); continue; } - for (const fileName of fileNames) { + for (const fileName of actionableFileNames) { if (stopping && Date.now() >= stopDeadline) break; inFlight += 1; try { @@ -1406,7 +1460,7 @@ export async function startSandboxCallbackBridgeWorker(input: { // `task.run` after it. Without a runner, the request runs under the // run parent with no wrapper span, exactly like the earlier behavior. // The per-iteration timeout wraps the whole request, so a hung - // request rejects and the loop `catch` runs `failPendingRequests`. + // request rejects and the catch below runs the recovery pass. await withTimeout( input.runtimeSpan ? input.runtimeSpan(CALLBACK_BRIDGE_RELAY_REQUEST_SPAN, () => @@ -1419,6 +1473,23 @@ export async function startSandboxCallbackBridgeWorker(input: { `Sandbox callback bridge process request ${fileName}`, ); lastSuccessfulIterationAt = Date.now(); + } catch (error) { + // A single request attempt failed or hung. Run the same recovery + // pass the loop previously died on — abort the in-flight handler + // (its 504 backstop keeps the caller from stranding) and 503 the + // unclaimed queued requests — but keep the loop alive afterward. A + // caller that sees the retry-safe 503 re-queues, and the recovered + // loop serves the retry; the old terminal catch left every later + // request to strand instead. + const message = buildWorkerFailureMessage(error); + await surfaceRunError(new Error(message)); + try { + await failPendingRequests(message, { abandonInFlight: true }); + } catch (failPendingError) { + console.warn( + `[paperclip] sandbox callback bridge failed to abort queued requests after a request failure: ${failPendingError instanceof Error ? failPendingError.message : String(failPendingError)}`, + ); + } } finally { inFlight -= 1; } @@ -1752,6 +1823,208 @@ export async function startSandboxCallbackBridgeServer(input: { }; } +// --------------------------------------------------------------------------- +// Sandbox HTTP/2 client gateway +// +// This gateway turns each local loopback request into one HTTP/2 stream to +// the host server in `http2-bridge-server.ts`. It sits beside the file-mode +// gateway above; it changes neither of them. The generated in-sandbox +// entrypoint and the transport-selection path already select it. +// --------------------------------------------------------------------------- + +/** + * Constant-time bridge-token compare. Both this sandbox gateway and the host + * server in `http2-bridge-server.ts` import this one helper, so the gateway + * check and the independent host check (accepted security fix 4) apply the + * exact same comparison rule. A length mismatch returns `false` without a + * `timingSafeEqual` call, because `timingSafeEqual` throws on unequal buffer + * lengths; both operands are bridge tokens of near-fixed length, so this one + * length branch leaks no useful timing signal. + */ +export function compareBridgeTokensConstantTime( + expected: string, + received: string | null | undefined, +): boolean { + const expectedBytes = Buffer.from(expected, "utf8"); + const receivedBytes = Buffer.from(typeof received === "string" ? received : "", "utf8"); + if (expectedBytes.length !== receivedBytes.length) return false; + return timingSafeEqual(expectedBytes, receivedBytes); +} + +const SANDBOX_HTTP2_GATEWAY_DEFAULT_AUTHORITY = "bridge.internal"; + +/** One local request the gateway forwards as one HTTP/2 stream. */ +export interface SandboxHttp2BridgeGatewayRequest { + method: string; + path: string; + query: string; + headers: Record; + body: Buffer; + /** + * The token the local caller presented. The gateway check (accepted + * security fix 4 keeps this alongside the independent host check) compares + * it against the per-run bridge token before it opens a stream. + */ + receivedToken: string | null | undefined; +} + +/** The response one forwarded HTTP/2 stream carried back. */ +export interface SandboxHttp2BridgeGatewayResponse { + status: number; + headers: Record; + body: Buffer; +} + +export interface SandboxHttp2BridgeGateway { + /** Forward one local request as one HTTP/2 stream over the client session. */ + forwardRequest( + request: SandboxHttp2BridgeGatewayRequest, + ): Promise; + /** Close the HTTP/2 client session. Safe to call more than one time. */ + close(): Promise; +} + +export interface CreateSandboxHttp2BridgeGatewayOptions { + /** + * The per-run bridge token. The gateway checks every request against it, + * then attaches it to the outbound HTTP/2 stream as the `authorization` + * header, so the host can run its own independent check. + */ + bridgeToken: string; + /** + * Open the transport the HTTP/2 client session runs on. Returns a `Duplex` + * already connected to the host — the sandbox process's own channel in + * production, or one side of a paired in-memory `Duplex` in a test. + */ + createConnection: () => Duplex; + /** The `:authority` pseudo-header value. The channel carries no real network + * address, so this is a fixed label. The default is `bridge.internal`. */ + authority?: string; + /** The header allowlist applied to every outbound request. The default is + * {@link DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST}. */ + headerAllowlist?: readonly string[]; + /** + * The sink for a GOAWAY the host sends. The host names the last client + * stream ID it processed; a caller classifies each of its own dispatched + * stream IDs against it with `classifyStreamAgainstGoaway` in + * `http2-bridge-server.ts` to know which requests need a retry elsewhere. + */ + onGoaway?: (record: { lastStreamId: number; errorCode: number }) => void; +} + +function forwardOneHttp2Request( + session: http2.ClientHttp2Session, + request: { + bridgeToken: string; + method: string; + path: string; + query: string; + headers: Record; + body: Buffer; + }, +): Promise { + return new Promise((resolve, reject) => { + const query = request.query.trim(); + const pathWithQuery = + query.length === 0 ? request.path : `${request.path}${query.startsWith("?") ? query : `?${query}`}`; + const requestHeaders: http2.OutgoingHttpHeaders = { + ":method": request.method, + ":path": pathWithQuery, + authorization: `Bearer ${request.bridgeToken}`, + ...request.headers, + }; + let stream: http2.ClientHttp2Stream; + try { + stream = session.request(requestHeaders, { endStream: request.body.length === 0 }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + const chunks: Buffer[] = []; + let responseHeaders: Record = {}; + let status = 502; + let settled = false; + const settle = (run: () => void) => { + if (settled) return; + settled = true; + run(); + }; + stream.on("response", (headers) => { + const rawStatus = headers[":status"]; + status = typeof rawStatus === "number" ? rawStatus : Number(rawStatus) || 502; + responseHeaders = {}; + for (const [key, value] of Object.entries(headers)) { + if (key.startsWith(":") || value == null) continue; + responseHeaders[key] = Array.isArray(value) ? value.join(", ") : String(value); + } + }); + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.once("end", () => settle(() => resolve({ status, headers: responseHeaders, body: Buffer.concat(chunks) }))); + stream.once("error", (error) => + settle(() => reject(error instanceof Error ? error : new Error(String(error)))), + ); + stream.once("aborted", () => settle(() => reject(new Error("Bridge HTTP/2 stream aborted.")))); + if (request.body.length > 0) { + stream.end(request.body); + } else if (!stream.writableEnded) { + stream.end(); + } + }); +} + +/** + * Create the sandbox HTTP/2 client gateway. It opens one HTTP/2 client + * session on the transport `createConnection` returns, and forwards each + * local request the caller hands it (already checked against the bridge + * token — see {@link SandboxHttp2BridgeGatewayRequest.receivedToken}) as one + * HTTP/2 stream. It keeps the header allowlist on the sandbox side, exactly + * as the file-mode gateway does. + */ +export function createSandboxHttp2BridgeGateway( + options: CreateSandboxHttp2BridgeGatewayOptions, +): SandboxHttp2BridgeGateway { + const authority = options.authority?.trim() || SANDBOX_HTTP2_GATEWAY_DEFAULT_AUTHORITY; + const headerAllowlist = options.headerAllowlist ?? DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST; + const session = http2.connect(`http://${authority}`, { + createConnection: options.createConnection, + }); + // A session-level fault fails every in-flight `forwardRequest` call through + // that stream's own `error`/`aborted` handler. This listener only stops + // Node from raising an unhandled `error` event for the session itself. + session.on("error", () => undefined); + session.on("goaway", (errorCode: number, lastStreamId: number) => { + options.onGoaway?.({ lastStreamId, errorCode }); + }); + + return { + forwardRequest(request: SandboxHttp2BridgeGatewayRequest): Promise { + // The gateway check (accepted security fix 4 keeps this as well as the + // independent host-side check): a request whose token does not match + // the per-run bridge token never opens a stream. + if (!compareBridgeTokensConstantTime(options.bridgeToken, request.receivedToken)) { + return Promise.reject(new Error("Invalid bridge token.")); + } + return forwardOneHttp2Request(session, { + bridgeToken: options.bridgeToken, + method: request.method, + path: request.path, + query: request.query, + headers: sanitizeSandboxCallbackBridgeHeaders(request.headers, headerAllowlist), + body: request.body, + }); + }, + close(): Promise { + return new Promise((resolve) => { + if (session.closed || session.destroyed) { + resolve(); + return; + } + session.close(() => resolve()); + }); + }, + }; +} + /** * The zero-dependency codec the generated duplex gateway embeds. It is a plain * JavaScript copy of the host codec in `duplex-frame-codec.ts`. It uses only the @@ -1763,7 +2036,8 @@ export async function startSandboxCallbackBridgeServer(input: { * compatible with the host codec. {@link getSandboxDuplexGatewayCodecSource} * returns this exact source so a test can run every fixture vector against it. */ -const DUPLEX_GATEWAY_CODEC_SOURCE = `const DUPLEX_FRAME_VERSION = 1; +const DUPLEX_GATEWAY_CODEC_SOURCE = `const DUPLEX_FRAME_VERSION = 2; +const DUPLEX_BODY_CHUNK_RAW_BYTES = 256 * 1024; const DEFAULT_MAX_DUPLEX_FRAME_BYTES = 1000000; const DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES = 256; const DEFAULT_MAX_DUPLEX_DECODER_BYTES = ${DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES}; @@ -1792,6 +2066,10 @@ function duplexIsStringRecord(value) { return true; } +function duplexIsSafeNonNegativeInteger(value) { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + function encodeDuplexFrame(frame) { return JSON.stringify(frame) + "\\n"; } @@ -1828,6 +2106,8 @@ function duplexValidateFrame(frame) { return duplexValidateRequest(frame); case "response": return duplexValidateResponse(frame); + case "body_chunk": + return duplexValidateBodyChunk(frame); case "ready": return duplexValidateReady(frame); case "heartbeat": @@ -1847,7 +2127,7 @@ function duplexValidateRequest(frame) { typeof frame.method !== "string" || typeof frame.path !== "string" || typeof frame.query !== "string" || - typeof frame.body !== "string" || + !duplexIsSafeNonNegativeInteger(frame.bodyByteCount) || !duplexIsStringRecord(frame.headers) ) { return duplexFail("malformed_frame", "request frame has a missing or wrong-typed field"); @@ -1862,7 +2142,7 @@ function duplexValidateResponse(frame) { if ( typeof frame.id !== "string" || typeof frame.status !== "number" || - typeof frame.body !== "string" || + !duplexIsSafeNonNegativeInteger(frame.bodyByteCount) || !duplexIsStringRecord(frame.headers) || typeof frame.outcome !== "string" || !DUPLEX_RESPONSE_OUTCOMES.has(frame.outcome) @@ -1875,6 +2155,19 @@ function duplexValidateResponse(frame) { return duplexOk(frame); } +function duplexValidateBodyChunk(frame) { + if (typeof frame.id !== "string" || typeof frame.data !== "string") { + return duplexFail("malformed_frame", "body_chunk frame has a missing or wrong-typed field"); + } + if (!duplexIsSafeNonNegativeInteger(frame.seq)) { + return duplexFail("malformed_frame", "body_chunk frame has a missing or wrong-typed seq"); + } + if (Buffer.byteLength(frame.id, "utf8") > DEFAULT_MAX_DUPLEX_REQUEST_ID_BYTES) { + return duplexFail("id_too_large", "body_chunk frame id exceeds the maximum size"); + } + return duplexOk(frame); +} + function duplexValidateReady(frame) { if (typeof frame.nonce !== "string") { return duplexFail("malformed_frame", "ready frame has a missing or wrong-typed nonce"); @@ -1984,6 +2277,8 @@ export function getSandboxCallbackBridgeServerSource(): string { import { createServer } from "node:http"; import { promises as fs } from "node:fs"; import path from "node:path"; +import http2 from "node:http2"; +import { Duplex } from "node:stream"; const bridgeMode = process.env.PAPERCLIP_API_BRIDGE_MODE || "${SANDBOX_CALLBACK_BRIDGE_FILE_MODE}"; const queueDir = process.env.PAPERCLIP_BRIDGE_QUEUE_DIR; @@ -1991,36 +2286,18 @@ const bridgeToken = process.env.PAPERCLIP_BRIDGE_TOKEN; const host = process.env.PAPERCLIP_BRIDGE_HOST || "127.0.0.1"; const port = Number(process.env.PAPERCLIP_BRIDGE_PORT || "0"); // The host assigns the loopback port and passes it through the launch -// environment. The duplex gateway binds exactly this port; it never selects a +// environment. The gateway binds exactly this port; it never selects a // different one. The host also passes one random per-open nonce here. The // gateway echoes it in the READY frame so the host correlates READY with this // channel open. The nonce is a liveness signal, not authentication. const bridgeNonce = process.env.PAPERCLIP_BRIDGE_NONCE || ""; const pollIntervalMs = Number(process.env.PAPERCLIP_BRIDGE_POLL_INTERVAL_MS || "100"); const responseTimeoutMs = Number( - process.env.PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS || - (bridgeMode === "${SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE}" - ? "${DEFAULT_DUPLEX_GATEWAY_WAIT_BUDGET_MS}" - : "${DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS}"), + process.env.PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS || "${DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS}", ); const maxQueueDepth = Number(process.env.PAPERCLIP_BRIDGE_MAX_QUEUE_DEPTH || "${DEFAULT_BRIDGE_MAX_QUEUE_DEPTH}"); const maxBodyBytes = Number(process.env.PAPERCLIP_BRIDGE_MAX_BODY_BYTES || "${DEFAULT_BRIDGE_MAX_BODY_BYTES}"); -// The host passes the separate sandbox-process raw-decoder cap here. The in-sandbox -// decoder enforces it locally under the "sandbox_process" scope; it never shares the -// host aggregate byte ledger. -const maxDuplexDecoderBytes = Number( - process.env.PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES || "${DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES}", -); -const heartbeatIntervalMs = Number( - process.env.PAPERCLIP_BRIDGE_HEARTBEAT_INTERVAL_MS || "${DEFAULT_DUPLEX_GATEWAY_HEARTBEAT_INTERVAL_MS}", -); -const heartbeatTimeoutMs = Number( - process.env.PAPERCLIP_BRIDGE_HEARTBEAT_TIMEOUT_MS || "${DEFAULT_DUPLEX_GATEWAY_HEARTBEAT_TIMEOUT_MS}", -); -const lossExitGraceMs = Number( - process.env.PAPERCLIP_BRIDGE_LOSS_EXIT_GRACE_MS || "${DEFAULT_DUPLEX_GATEWAY_LOSS_EXIT_GRACE_MS}", -); -// The header allowlist. Both the file gateway and the duplex gateway strip an +// The header allowlist. Both the file gateway and the http2 gateway strip an // inbound request to these headers before they forward it. One copy serves both // modes. The route allowlist stays on the host: both modes forward a request to // the host, and the host enforces the same route allowlist for each. @@ -2029,10 +2306,48 @@ const allowedHeaders = new Set(${JSON.stringify([...DEFAULT_SANDBOX_CALLBACK_BRI if (!bridgeToken) { throw new Error("PAPERCLIP_BRIDGE_TOKEN is required."); } -if (bridgeMode !== "${SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE}" && !queueDir) { +// Closed allowlist for the bridge mode. The generated gateway supports exactly +// two transports: http2 and the file-mode queue. Every other value, including +// the retired duplex transport, fails startup at once instead of falling +// through to a mode that never ran. This check runs before the queue-directory +// check below, so an unsupported mode never reaches a state where a missing +// queue directory masks the real problem. +if ( + bridgeMode !== "${SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE}" && + bridgeMode !== "${SANDBOX_CALLBACK_BRIDGE_FILE_MODE}" +) { + throw new Error("Unsupported PAPERCLIP_API_BRIDGE_MODE: " + bridgeMode); +} +if (bridgeMode !== "${SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE}" && !queueDir) { throw new Error("PAPERCLIP_BRIDGE_QUEUE_DIR and PAPERCLIP_BRIDGE_TOKEN are required."); } +// A crashed gateway is a dead loopback port for the rest of the run: nothing +// inside the sandbox respawns this process, and every later agent API call +// then fails at the connection level. Once the gateway is ready, log an +// uncaught fault to stderr (the host redirects it into logs/bridge.log) and +// keep serving — the relay holds no state a fault can corrupt beyond the one +// request it interrupted. Before readiness the same fault means the gateway +// can never become usable (a failed bind, a failed readiness write), so exit +// instead: surviving there only leaves an un-ready zombie behind while the +// host waits out its readiness poll. +let gatewayReady = false; +process.on("uncaughtException", (error) => { + process.stderr.write( + "[paperclip-bridge] uncaught exception: " + (error && error.stack ? error.stack : String(error)) + "\\n", + ); + if (!gatewayReady) { + process.exit(1); + } +}); +process.on("unhandledRejection", (reason) => { + const detail = reason && typeof reason === "object" && "stack" in reason ? reason.stack : String(reason); + process.stderr.write("[paperclip-bridge] unhandled rejection: " + detail + "\\n"); + if (!gatewayReady) { + process.exit(1); + } +}); + // The embedded zero-dependency frame codec. The duplex gateway uses it; the file // gateway ignores it. ${DUPLEX_GATEWAY_CODEC_SOURCE} @@ -2092,6 +2407,24 @@ async function runFileGateway() { return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).length; } + // Delete request files older than the response deadline. Every live caller + // cleans its own request file when it times out, so a file this old is an + // orphan: its writer was killed mid-wait, or a previous gateway process died + // and left its queue behind. Orphans otherwise count toward the queue-depth + // cap forever and wedge the gateway at a permanent 503. + async function sweepStaleRequests() { + const staleBefore = Date.now() - responseTimeoutMs - 2000; + const entries = await fs.readdir(requestsDir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const filePath = path.posix.join(requestsDir, entry.name); + const stats = await fs.stat(filePath).catch(() => null); + if (stats && stats.mtimeMs < staleBefore) { + await fs.rm(filePath, { force: true }).catch(() => undefined); + } + } + } + async function waitForResponse(requestId) { const responsePath = path.posix.join(responsesDir, \`\${requestId}.json\`); const deadline = Date.now() + responseTimeoutMs; @@ -2116,8 +2449,13 @@ async function runFileGateway() { } if (await queueDepth() >= maxQueueDepth) { - writeJsonResponse(res, 503, { error: "Bridge request queue is full." }); - return; + // Reclaim orphaned request files before rejecting; only a queue that + // is genuinely full of live requests gets the 503. + await sweepStaleRequests(); + if (await queueDepth() >= maxQueueDepth) { + writeJsonResponse(res, 503, { error: "Bridge request queue is full." }); + return; + } } const url = new URL(req.url || "/", "http://127.0.0.1"); @@ -2142,7 +2480,19 @@ async function runFileGateway() { await fs.writeFile(tempPath, \`\${JSON.stringify(payload)}\\n\`, "utf8"); await fs.rename(tempPath, requestPath); - const response = await waitForResponse(requestId); + let response; + try { + response = await waitForResponse(requestId); + } catch (error) { + // The host never delivered a response inside the deadline. Remove this + // request's file so it cannot pile up toward the queue-depth cap. The + // host's normal response write is guarded on the request file, so the + // removal also tells the host that no caller waits anymore. Without + // this cleanup a stalled host wedges the gateway at the cap and every + // later request gets an immediate 503 until run end. + await fs.rm(requestPath, { force: true }).catch(() => undefined); + throw error; + } const responseHeaders = response.headers || {}; // The host marks a possibly-committed mutation with an indeterminate outcome. // The host cannot cancel a host operation that is in flight, so the mutation @@ -2180,7 +2530,24 @@ async function runFileGateway() { await fs.mkdir(responsesDir, { recursive: true }); await fs.mkdir(logsDir, { recursive: true }); + // Newer Node runtimes do not reliably surface a failed bind through + // uncaughtException here: with nothing else keeping the event loop alive, + // the process can drain and exit 0 before the error event is delivered + // (observed on Node 24/25; Node 22 delivered it). Attach an explicit error + // listener and pin the loop with a keepalive until the bind settles, so a + // startup failure exits 1 with the fault on stderr on every runtime. + const bindKeepalive = setInterval(() => {}, 1000); + server.once("error", (error) => { + clearInterval(bindKeepalive); + process.stderr.write( + "[paperclip-bridge] server error: " + (error && error.stack ? error.stack : String(error)) + "\\n", + ); + if (!gatewayReady) { + process.exit(1); + } + }); server.listen(port, host, async () => { + clearInterval(bindKeepalive); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Bridge server did not expose a TCP address."); @@ -2195,107 +2562,136 @@ async function runFileGateway() { const tempReadyFile = \`\${readyFile}.tmp\`; await fs.writeFile(tempReadyFile, JSON.stringify(ready), "utf8"); await fs.rename(tempReadyFile, readyFile); + // The readiness file is on disk, so the host will adopt this process. + // From here on an uncaught fault must not kill the listener. + gatewayReady = true; }); } -function runDuplexGateway() { - // One outstanding local request per id. Each entry holds the HTTP resolver and - // the wait-budget timer. - const pending = new Map(); - let unavailable = false; - let lossTriggered = false; - let lastInboundAt = Date.now(); +// --------------------------------------------------------------------------- +// http2_v1: run one Node HTTP/2 client session directly on stdin/stdout. +// +// This gateway writes exactly one frame-codec line before it hands stdin and +// stdout to the HTTP/2 client: the READY line, so the host readiness gate +// accepts the same handshake it already accepts for every mode. After that +// one write, only the HTTP/2 client touches stdout: this function calls +// writeFrame no more, and it starts no heartbeat timer, so no non-HTTP/2 +// writer can put a byte on stdout between the READY line and the client +// connection preface. +// --------------------------------------------------------------------------- +function createStdioDuplex() { + const duplex = new Duplex({ + read() { + // process.stdin pushes bytes through the "data" listener below; there + // is nothing to pull on demand here. + }, + write(chunk, _encoding, callback) { + const flushed = process.stdout.write(chunk); + if (flushed) { + callback(); + } else { + process.stdout.once("drain", () => callback()); + } + }, + }); + process.stdin.on("data", (chunk) => { + duplex.push(chunk); + }); + process.stdin.on("end", () => { + duplex.push(null); + }); + process.stdin.on("error", (error) => { + duplex.destroy(error instanceof Error ? error : new Error(String(error))); + }); + return duplex; +} + +function runHttp2Gateway() { function diag(message) { - // Diagnostics go to stderr only. Stdout carries frames. + // Diagnostics go to stderr only, the same as every other mode. process.stderr.write("[paperclip-bridge] " + message + "\\n"); } - function writeFrame(frame) { process.stdout.write(encodeDuplexFrame(frame)); } - function stopTimers() { - clearInterval(heartbeatSendTimer); - clearInterval(heartbeatWatchTimer); + const authority = "bridge.internal"; + let session = null; + let unavailable = false; + + function openSession() { + if (session) return session; + const stdio = createStdioDuplex(); + session = http2.connect("http://" + authority, { + createConnection: () => stdio, + }); + session.on("error", () => { + unavailable = true; + }); + session.on("close", () => { + unavailable = true; + }); + session.on("goaway", (errorCode, lastStreamId) => { + diag("host sent GOAWAY (errorCode=" + errorCode + ", lastStreamId=" + lastStreamId + ")"); + }); + return session; } - // Loss behavior. On stdin end, a close frame, or a heartbeat timeout, answer - // every outstanding request with a non-retryable 409 (the request may have - // reached the host), answer every new request with a retryable 503 (the - // request never left the gateway), then exit. The gateway never replays a - // request. - function triggerLoss(reason) { - if (lossTriggered) return; - lossTriggered = true; - unavailable = true; - diag("duplex channel lost: " + reason); - stopTimers(); - for (const entry of pending.values()) { - clearTimeout(entry.timer); - entry.resolve({ - status: 409, - headers: { - "content-type": "application/json", - "x-paperclip-bridge-outcome": "indeterminate", + function forwardOverHttp2(request) { + return new Promise((resolve, reject) => { + const activeSession = openSession(); + const query = request.query || ""; + const pathWithQuery = + query.length === 0 ? request.path : request.path + (query.charAt(0) === "?" ? query : "?" + query); + const outboundHeaders = Object.assign( + { + ":method": request.method, + ":path": pathWithQuery, + authorization: "Bearer " + bridgeToken, }, - body: JSON.stringify({ error: "outcome_indeterminate" }), - }); - } - pending.clear(); - const exitTimer = setTimeout(() => process.exit(0), lossExitGraceMs); - if (typeof exitTimer.unref === "function") exitTimer.unref(); - } - - function handleInboundFrame(frame) { - if (frame.type === "response") { - const entry = pending.get(frame.id); - if (!entry) return; - pending.delete(frame.id); - clearTimeout(entry.timer); - // Map an indeterminate outcome to a non-retryable 409, the same contract - // the file gateway applies through the outcome header. - const statusCode = - frame.outcome === "indeterminate" ? 409 : typeof frame.status === "number" ? frame.status : 200; - entry.resolve({ - status: statusCode, - headers: frame.headers || {}, - body: typeof frame.body === "string" ? frame.body : "", - }); - return; - } - if (frame.type === "close") { - triggerLoss("host sent a close frame"); - return; - } - // A heartbeat, ready, error, or request frame proves liveness but needs no - // local action here. - } - - const decoder = new DuplexFrameDecoder({ maxAggregateBytes: maxDuplexDecoderBytes }); - process.stdin.on("data", (chunk) => { - lastInboundAt = Date.now(); - for (const result of decoder.push(chunk)) { - if (!result.ok) { - diag("dropped an inbound frame: " + result.error.code); - continue; + request.headers, + ); + let stream; + try { + stream = activeSession.request(outboundHeaders, { endStream: request.body.length === 0 }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; } - handleInboundFrame(result.frame); - } - }); - process.stdin.on("end", () => triggerLoss("stdin reached end of file")); - process.stdin.on("error", (error) => - triggerLoss("stdin error: " + (error && error.message ? error.message : String(error))), - ); - - const heartbeatSendTimer = setInterval(() => { - writeFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" }); - }, heartbeatIntervalMs); - const heartbeatWatchTimer = setInterval(() => { - if (Date.now() - lastInboundAt > heartbeatTimeoutMs) { - triggerLoss("no inbound frame within the heartbeat timeout"); - } - }, Math.max(200, Math.floor(heartbeatTimeoutMs / 4))); + const chunks = []; + let status = 502; + let responseHeaders = {}; + let settled = false; + const settle = (run) => { + if (settled) return; + settled = true; + run(); + }; + stream.on("response", (headers) => { + const raw = headers[":status"]; + status = typeof raw === "number" ? raw : Number(raw) || 502; + responseHeaders = {}; + for (const [key, value] of Object.entries(headers)) { + if (key.charAt(0) === ":" || value == null) continue; + responseHeaders[key] = Array.isArray(value) ? value.join(", ") : String(value); + } + }); + stream.on("data", (chunk) => chunks.push(chunk)); + stream.once("end", () => + settle(() => resolve({ status: status, headers: responseHeaders, body: Buffer.concat(chunks) })), + ); + stream.once("error", (error) => + settle(() => reject(error instanceof Error ? error : new Error(String(error)))), + ); + stream.once("aborted", () => settle(() => reject(new Error("Bridge HTTP/2 stream aborted.")))); + if (request.body.length > 0) { + stream.end(request.body); + } else if (!stream.writableEnded) { + stream.end(); + } + }); + } const server = createServer(async (req, res) => { try { @@ -2309,61 +2705,32 @@ function runDuplexGateway() { writeJsonResponse(res, 503, { error: "bridge_unavailable" }); return; } - if (pending.size >= maxQueueDepth) { - writeJsonResponse(res, 503, { error: "Bridge request queue is full." }); - return; - } const url = new URL(req.url || "/", "http://127.0.0.1"); const contentType = typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : ""; if (req.method && req.method !== "GET" && req.method !== "HEAD" && !/json/i.test(contentType)) { writeJsonResponse(res, 415, { error: "Bridge only accepts JSON request bodies." }); return; } - const requestId = randomUUID(); - const requestBody = await readBody(req); - if (unavailable) { - writeJsonResponse(res, 503, { error: "bridge_unavailable" }); + const requestBodyBuffer = Buffer.from(await readBody(req), "utf8"); + let response; + try { + response = await forwardOverHttp2({ + method: req.method || "GET", + path: url.pathname, + query: url.search, + headers: normalizeHeaders(req.headers), + body: requestBodyBuffer, + }); + } catch (error) { + writeJsonResponse(res, 502, { error: error instanceof Error ? error.message : String(error) }); return; } - const requestFrame = { - version: DUPLEX_FRAME_VERSION, - type: "request", - id: requestId, - method: req.method || "GET", - path: url.pathname, - query: url.search, - headers: normalizeHeaders(req.headers), - body: requestBody, - }; - // Enforce the frame size bound on encode. When the request body makes the - // frame exceed the bound, fail this one local request with a clean 413. Do - // not write the frame and do not trigger loss. The frame never leaves the - // gateway, so no other in-flight request is affected and the channel stays - // open. - const encodedRequest = encodeDuplexFrameChecked(requestFrame); - if (!encodedRequest.ok) { - writeJsonResponse(res, 413, { error: "request_too_large" }); - return; - } - const response = await new Promise((resolve) => { - const timer = setTimeout(() => { - if (pending.delete(requestId)) { - resolve({ - status: 502, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ error: "Timed out waiting for host bridge response." }), - }); - } - }, responseTimeoutMs); - pending.set(requestId, { resolve: resolve, timer: timer }); - process.stdout.write(encodedRequest.line); - }); res.statusCode = typeof response.status === "number" ? response.status : 200; for (const [key, value] of Object.entries(response.headers || {})) { if (typeof value !== "string" || key.toLowerCase() === "content-length") continue; res.setHeader(key, value); } - res.end(typeof response.body === "string" ? response.body : ""); + res.end(response.body); } catch (error) { writeJsonResponse(res, 502, { error: error instanceof Error ? error.message : String(error) }); } @@ -2386,40 +2753,47 @@ function runDuplexGateway() { process.exit(0); }); - // Bind-or-exit. The host assigns a positive loopback port. The gateway binds - // exactly that port. On a non-positive assigned port or a bind failure it - // writes a diagnostic and exits with a nonzero code. It never selects a - // different port, so no untrusted workload can steer the endpoint. + // Bind-or-exit, the same rule every mode applies: the host assigns a + // positive loopback port, and the gateway binds exactly that port or exits + // nonzero. It never selects a different port. if (!Number.isInteger(port) || port <= 0) { - diag("duplex gateway requires a positive assigned PAPERCLIP_BRIDGE_PORT; got " + String(port)); + diag("http2 gateway requires a positive assigned PAPERCLIP_BRIDGE_PORT; got " + String(port)); process.exit(1); } server.on("error", (error) => { - diag("duplex gateway could not bind port " + String(port) + ": " + (error && error.message ? error.message : String(error))); + diag( + "http2 gateway could not bind port " + + String(port) + + ": " + + (error && error.message ? error.message : String(error)), + ); process.exit(1); }); server.listen(port, host, () => { const address = server.address(); if (!address || typeof address === "string") { - diag("duplex gateway did not expose a TCP address"); + diag("http2 gateway did not expose a TCP address"); process.exit(1); return; } - // The gateway sends READY only after the listener binds. READY is a liveness - // signal: it carries the frame version and the echoed nonce, and no address - // data. The host builds the endpoint from its own stored port. Stdout carries - // only frames. - writeFrame({ - version: DUPLEX_FRAME_VERSION, - type: "ready", - nonce: bridgeNonce, - }); + // Send the one frame-codec line on this path: the READY line the host + // readiness gate expects. Open the HTTP/2 client session on the very next + // statement, so the client connection preface is the next byte the host + // sees after READY, with no other writer in between. + writeFrame({ version: DUPLEX_FRAME_VERSION, type: "ready", nonce: bridgeNonce }); + gatewayReady = true; + openSession(); }); } -if (bridgeMode === "${SANDBOX_CALLBACK_BRIDGE_DUPLEX_MODE}") { - runDuplexGateway(); -} else { +// The startup check above already rejected every value except http2 and +// queue, so this dispatch names both modes explicitly and never falls +// through to the queue gateway for an unsupported mode. +if (bridgeMode === "${SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE}") { + runHttp2Gateway(); +} else if (bridgeMode === "${SANDBOX_CALLBACK_BRIDGE_FILE_MODE}") { await runFileGateway(); +} else { + throw new Error("Unsupported PAPERCLIP_API_BRIDGE_MODE: " + bridgeMode); }`; } diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 526fe367892..3b497239e81 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -880,7 +880,12 @@ describe("renderPaperclipWakePrompt", () => { expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Use child issues"); expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("instead of polling agents, sessions, or processes"); expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Create child issues directly when you know what needs to be done"); - expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("POST /api/issues/{issueId}/interactions"); + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("POST /api/issues/$PAPERCLIP_TASK_ID/interactions"); + // URL paths in prompt text carry real ids or env vars, never brace + // placeholders: agents paste these lines verbatim, and a literal {issueId} + // reaches the server as /api/issues/%7BissueId%7D and 404s. + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).not.toContain("/api/issues/{issueId}"); + expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).not.toContain("/api/issues/{id}"); expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("kind suggest_tasks, ask_user_questions, or request_confirmation"); expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain( "Use continuationPolicy wake_assignee when you need to resume after a response (it wakes on acceptance and rejection alike; only expiry does not wake); use wake_assignee_on_accept when you want to resume only after acceptance", diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index c9f291387e3..a013ada663b 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -176,9 +176,9 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [ "- If woken by a human comment on a dependency-blocked issue, respond or triage the comment without treating the blocked deliverable work as unblocked.", "- Create child issues directly when you know what needs to be done; use issue-thread interactions when the board/user must choose suggested tasks, answer structured questions, or confirm a proposal.", "- Use `PAPERCLIP_SCRATCH_DIR` / `PAPERCLIP_RUN_SCRATCH_DIR` for temporary scratch files instead of ad hoc `/tmp` paths; Paperclip removes that run-owned directory after the run ends.", - "- To ask for that input, create an interaction on the current issue with POST /api/issues/{issueId}/interactions using kind suggest_tasks, ask_user_questions, or request_confirmation. Use continuationPolicy wake_assignee when you need to resume after a response (it wakes on acceptance and rejection alike; only expiry does not wake); use wake_assignee_on_accept when you want to resume only after acceptance.", + "- To ask for that input, create an interaction on the current issue with POST /api/issues/$PAPERCLIP_TASK_ID/interactions using kind suggest_tasks, ask_user_questions, or request_confirmation. Use continuationPolicy wake_assignee when you need to resume after a response (it wakes on acceptance and rejection alike; only expiry does not wake); use wake_assignee_on_accept when you want to resume only after acceptance.", "- Never create probe or throwaway issue-thread interactions to discover the interactions API shape or your permissions; schema discovery goes through the OpenAPI spec and explicit validation errors, not placeholder cards. Every ask_user_questions, suggest_tasks, or request_confirmation you post must carry a real, answerable prompt; withdraw one you no longer need instead of leaving it pending.", - "- When you intentionally restart follow-up work on a completed assigned issue, include structured `resume: true` with the POST /api/issues/{issueId}/comments or PATCH /api/issues/{issueId} comment payload. Generic agent comments on closed issues are inert by default.", + "- When you intentionally restart follow-up work on a completed assigned issue, include structured `resume: true` with the POST /api/issues/$PAPERCLIP_TASK_ID/comments or PATCH /api/issues/$PAPERCLIP_TASK_ID comment payload (substitute that issue's real id when it is not the current task). Generic agent comments on closed issues are inert by default.", "- For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}. Wait for acceptance before creating implementation subtasks, and create a fresh confirmation after superseding board/user comments if approval is still needed.", "- If blocked, mark the issue blocked and name the unblock owner and action.", "- Respect budget, pause/cancel, approval gates, and company boundaries.", @@ -2010,7 +2010,7 @@ export function renderPaperclipWakePrompt( lines.push( "", "The harness already checked out this issue for the current run.", - "Do not call `/api/issues/{id}/checkout` again unless you intentionally switch to a different task.", + "Do not call `POST /api/issues/$PAPERCLIP_TASK_ID/checkout` again unless you intentionally switch to a different task.", "", ); } diff --git a/packages/adapter-utils/src/workspace-restore-merge.ts b/packages/adapter-utils/src/workspace-restore-merge.ts index dd4760dcc99..16782973c3e 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.ts @@ -106,29 +106,38 @@ function entriesMatch(left: SnapshotEntry | null | undefined, right: SnapshotEnt return false; } -async function isHolderAlive(lockDir: string): Promise { +const LOCK_STALE_MS = 30_000; + +async function isLockStale(lockDir: string): Promise { try { const raw = await fs.readFile(path.join(lockDir, "owner.json"), "utf8"); const owner = JSON.parse(raw) as { pid?: unknown }; const pid = typeof owner.pid === "number" && Number.isFinite(owner.pid) && owner.pid > 0 ? owner.pid : null; if (pid === null) { // Owner record is unparseable / missing pid — treat as stale. - return false; + return true; } try { process.kill(pid, 0); - return true; - } catch { return false; + } catch { + return true; } } catch { - // owner.json missing or unreadable — treat as stale. - return false; + // owner.json is missing or unreadable. A live holder also passes through + // this exact state, briefly, between its own `fs.mkdir(lockDir)` and its + // `fs.writeFile(owner.json)` below. Reading "missing" as "stale" here would + // let a concurrent acquirer delete a live holder's lock directory during + // that window. Mirror the materializePaperclipSkillCopy lock pattern: fall + // back to the lock directory's own mtime, and only call it stale once the + // directory itself has outlived the stale threshold. + const stat = await fs.stat(lockDir).catch(() => null); + return !stat || Date.now() - stat.mtimeMs > LOCK_STALE_MS; } } async function acquireDirectoryMergeLock(lockDir: string): Promise<() => Promise> { - const deadline = Date.now() + 30_000; + const deadline = Date.now() + LOCK_STALE_MS; while (true) { try { await fs.mkdir(lockDir); @@ -146,7 +155,7 @@ async function acquireDirectoryMergeLock(lockDir: string): Promise<() => Promise // Stale-lock detection: if the owner PID is dead (SIGKILL / OOM / crash), // the lockDir would otherwise persist forever and stall restores. Mirror // the materializePaperclipSkillCopy lock pattern — remove and retry. - if (!(await isHolderAlive(lockDir))) { + if (await isLockStale(lockDir)) { await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined); continue; } diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index e03130bc2e5..fdcbe8fe2ca 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -53,7 +53,7 @@ "probe:quota:raw": "pnpm exec tsx src/cli/quota-probe.ts --json --raw-cli" }, "dependencies": { - "@agentclientprotocol/claude-agent-acp": "^0.69.0", + "@agentclientprotocol/claude-agent-acp": "^0.70.0", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/shared": "workspace:*", "picocolors": "^1.1.1" diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index f8532e8a503..7d2ca4bdb26 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -15,7 +15,7 @@ import { ensureAdapterExecutionTargetCommandResolvable, ensureAdapterExecutionTargetRuntimeCommandInstalled, prepareAdapterExecutionTargetRuntime, - adapterExecutionTargetDuplexTelemetryRecorder, + adapterExecutionTargetDuplexObservabilityRecorder, adapterExecutionTargetEnablesSandboxDuplexBridge, readAdapterExecutionTarget, resolveAdapterExecutionTargetTimeoutSec, @@ -684,7 +684,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(result?.code).toBe("ABCD-EFGHJ"); }); + it("parse_returns_url_and_code_from_carriage_return_line_endings", () => { + // A pseudo-terminal ends each line with a carriage return and a line feed. + // The carriage return is whitespace, so the URL token stops before it, and the + // code line trims it. The parser reads the tokens the same as line-feed output. + const text = [ + "1. Open this link in your browser and sign in to your account", + EXACT_URL, + "2. Enter this one-time code (expires in 15 minutes)", + "ABCD-EFGHJ", + ].join("\r\n"); + const result = parseDeviceLoginPrompt(text); + expect(result).not.toBeNull(); + expect(result?.url).toBe(EXACT_URL); + expect(result?.code).toBe("ABCD-EFGHJ"); + }); + + it("parse_returns_url_and_code_with_a_terminal_title_osc_sequence", () => { + // A pseudo-terminal can emit an Operating System Command (OSC) title sequence + // in the stream. The device-auth prompt still prints the URL and the code as + // plain standalone tokens on their own lines. The OSC title sequence carries no + // URL token, so it does not shift the URL or the code, and the parser reads + // both tokens. + const osc = "\x1b]0;Codex\x07"; + const text = [ + `${osc}1. Open this link in your browser and sign in to your account`, + EXACT_URL, + "2. Enter this one-time code (expires in 15 minutes)", + "ABCD-EFGHJ", + ].join("\n"); + const result = parseDeviceLoginPrompt(text); + expect(result).not.toBeNull(); + expect(result?.url).toBe(EXACT_URL); + expect(result?.code).toBe("ABCD-EFGHJ"); + }); + + it("parse_returns_url_and_code_from_a_merged_raw_pseudo_terminal_frame", () => { + // One buffer holds the whole prompt as a real pseudo-terminal emits it: a + // leading title Operating System Command (OSC) sequence, color Control + // Sequence Introducer (CSI) sequences around the URL and the code, and + // carriage-return line endings. The parser removes the color sequences and + // reads the plain tokens. + const title = "\x1b]0;Codex login\x07"; + const cyan = "\x1b[36m"; + const bold = "\x1b[1m"; + const reset = "\x1b[0m"; + const text = + `${title}1. Open this link in your browser and sign in to your account\r\n` + + `${cyan}${EXACT_URL}${reset}\r\n` + + `2. Enter this one-time code (expires in 15 minutes)\r\n` + + `${bold}ABCD-EFGHJ${reset}\r\n`; + const result = parseDeviceLoginPrompt(text); + expect(result).not.toBeNull(); + expect(result?.url).toBe(EXACT_URL); + expect(result?.code).toBe("ABCD-EFGHJ"); + }); + it("parse_returns_null_when_prompt_absent", () => { const text = "Some unrelated log line\nNothing to see here\n"; expect(parseDeviceLoginPrompt(text)).toBeNull(); diff --git a/packages/adapters/codex-local/src/server/device-login-runner.test.ts b/packages/adapters/codex-local/src/server/device-login-runner.test.ts index 7a149581499..b9393e8bbad 100644 --- a/packages/adapters/codex-local/src/server/device-login-runner.test.ts +++ b/packages/adapters/codex-local/src/server/device-login-runner.test.ts @@ -24,10 +24,10 @@ interface FakeDriverOptions { function createFakeDriver(options: FakeDriverOptions = {}) { const disposeCalls = { count: 0 }; const driver: SandboxLoginDriver = { - async execStreaming(_command, onStdout) { + async start(_command, onData) { if (options.execError) throw options.execError; for (const chunk of options.chunks ?? []) { - onStdout(chunk); + onData(chunk); } if (options.hang) { // Never resolve. The runner must fall back to its timeout or signal. diff --git a/packages/adapters/codex-local/src/server/device-login-runner.ts b/packages/adapters/codex-local/src/server/device-login-runner.ts index 4c66a702c0e..968031e1130 100644 --- a/packages/adapters/codex-local/src/server/device-login-runner.ts +++ b/packages/adapters/codex-local/src/server/device-login-runner.ts @@ -38,18 +38,33 @@ const MAX_PARSE_BUFFER_CHARS = 64 * 1024; /** * The sandbox side of the device-login run. The runner never calls Daytona - * directly; a caller injects a concrete driver. A production driver binds these - * three methods to a non-persisting Daytona exec path, a file read, and a - * sandbox delete. + * directly; a caller injects a concrete driver. A production driver binds `start` + * to the shared login pseudo-terminal (PTY) transport, binds `readFile` to a + * descriptor-bound credential read, and binds `dispose` to the transport dispose. + * + * The `start` shape matches the shared login pseudo-terminal transport start + * method, so the driver runs the login command on a real pseudo-terminal. The + * login command + * needs a pseudo-terminal: pipe stdio emits no login prompt. The runner never + * calls `stop` or `write` on the driver; the device-login flow needs no delayed + * input, and the service owns the sandbox delete. The runner disposes the driver + * one time on every terminal state. */ export interface SandboxLoginDriver extends LoginRunnerDisposable { /** - * Runs `command` in the sandbox and streams standard output to `onStdout` in - * memory. Resolves with the command exit code when the command ends. A driver - * must not persist the raw output to any durable log. + * Starts `command` on a pseudo-terminal and streams the terminal output to + * `onData` in memory, in order, as the pseudo-terminal emits it. Resolves with + * the command exit code when the command ends. A driver must not persist the raw + * output to any durable log. + */ + start(command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }>; + /** + * Reads the credential bytes with one descriptor-bound read. The read is + * separate from the pseudo-terminal session; the session has no file-read + * method. A driver runs one fixed, server-controlled operation that opens the + * verified session home and the credential file with no symlink follow, checks + * the opened descriptor, and reads only from that same descriptor. */ - execStreaming(command: string, onStdout: (chunk: string) => void): Promise<{ exitCode: number | null }>; - /** Reads the bytes of one file from the sandbox. */ readFile(path: string): Promise; } @@ -123,8 +138,8 @@ export async function runDeviceLogin( return { outcome: "cancelled", exitCode: null, promptSurfaced }; } - const exec = driver.execStreaming(command, onStdout); - const raced = await raceLoginRunnerExit(exec, timeoutMs, signal); + const started = driver.start(command, onStdout); + const raced = await raceLoginRunnerExit(started, timeoutMs, signal); if (raced.kind === "timeout") { log("[paperclip] Device login timed out; disposing the sandbox."); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 572bcc9a75c..aa1e958729d 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -21,7 +21,7 @@ import { ensureAdapterExecutionTargetCommandResolvable, ensureAdapterExecutionTargetRuntimeCommandInstalled, prepareAdapterExecutionTargetRuntime, - adapterExecutionTargetDuplexTelemetryRecorder, + adapterExecutionTargetDuplexObservabilityRecorder, adapterExecutionTargetEnablesSandboxDuplexBridge, readAdapterExecutionTarget, resolveAdapterExecutionTargetTimeoutSec, @@ -954,7 +954,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise): string { "GET example:", ` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" \\"$PAPERCLIP_API_URL/api/agents/me\\"" })`, "POST/PATCH example:", - ` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\\" -d '{...}' \\"$PAPERCLIP_API_URL/api/issues/{id}/checkout\\"" })`, + ` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\\" -d '{...}' \\"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/checkout\\"" })`, + "When PAPERCLIP_TASK_ID is not set, substitute a real issue id from the current context; never send a placeholder like {id} in the URL.", "", "", ].join("\n"); @@ -461,7 +462,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { ); }); + it("does not warn when the default-sentinel model is absent from the real model list", async () => { + // Real grok never lists the "grok-build" sentinel; execute.ts never sends + // it. The probe must treat the default as valid (info), not warn. + runProcessMock + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + "You are logged in with grok.com.", + "", + "Default model: grok-4.20-0309-non-reasoning", + "", + "Available models:", + " * grok-4.20-0309-non-reasoning (default)", + " * grok-4", + ].join("\n"), + stderr: "", + }) + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "text", data: "hello" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "s", requestId: "r" }), + ].join("\n"), + stderr: "", + }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "grok_local", + config: { command: "grok", cwd: "/tmp/project" }, // no model → default sentinel + }); + + const codes = result.checks.map((check: { code: string }) => check.code); + expect(codes).toContain("grok_model_configured"); + expect(codes).not.toContain("grok_model_not_found"); + expect(result.status).toBe("pass"); + }); + + it("warns when an explicitly configured real model is not available", async () => { + runProcessMock + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + "You are logged in with grok.com.", + "", + "Default model: grok-4", + "", + "Available models:", + " * grok-4", + ].join("\n"), + stderr: "", + }) + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "text", data: "hello" }), + JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "s", requestId: "r" }), + ].join("\n"), + stderr: "", + }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "grok_local", + config: { command: "grok", cwd: "/tmp/project", model: "grok-nonexistent" }, + }); + + expect(result.checks.map((check: { code: string }) => check.code)).toContain("grok_model_not_found"); + }); + it("downgrades auth failures to warnings", async () => { runProcessMock .mockResolvedValueOnce({ diff --git a/packages/adapters/grok-local/src/server/test.ts b/packages/adapters/grok-local/src/server/test.ts index b69ad7ff6cc..f166d3c6af9 100644 --- a/packages/adapters/grok-local/src/server/test.ts +++ b/packages/adapters/grok-local/src/server/test.ts @@ -226,15 +226,22 @@ export async function testEnvironment( }); } if (configuredModel) { + // The default model id is a sentinel meaning "let the Grok CLI pick its + // own default" — execute.ts only passes `--model` when the value differs + // from it, so the sentinel is never sent to grok and must not be checked + // against the discovered list (real grok never lists "grok-build", which + // otherwise produced a spurious "not found" warning on every probe). + const usesCliDefault = configuredModel === DEFAULT_GROK_LOCAL_MODEL; + const available = usesCliDefault || parsedModels.models.includes(configuredModel); checks.push({ - code: parsedModels.models.includes(configuredModel) ? "grok_model_configured" : "grok_model_not_found", - level: parsedModels.models.includes(configuredModel) ? "info" : "warn", - message: parsedModels.models.includes(configuredModel) - ? `Configured model: ${configuredModel}` - : `Configured model "${configuredModel}" not found in available models.`, - hint: parsedModels.models.includes(configuredModel) - ? undefined - : "Run `grok models` and choose an available model id.", + code: available ? "grok_model_configured" : "grok_model_not_found", + level: available ? "info" : "warn", + message: usesCliDefault + ? `Using the Grok CLI's default model${parsedModels.defaultModel ? ` (${parsedModels.defaultModel})` : ""}.` + : available + ? `Configured model: ${configuredModel}` + : `Configured model "${configuredModel}" not found in available models.`, + hint: available ? undefined : "Run `grok models` and choose an available model id.", }); } } diff --git a/packages/adapters/kimi-local/tsconfig.json b/packages/adapters/kimi-local/tsconfig.json index 2f355cfeebf..e1b71318a68 100644 --- a/packages/adapters/kimi-local/tsconfig.json +++ b/packages/adapters/kimi-local/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 7771254de4d..fb193c2261a 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -423,6 +423,7 @@ function buildWakeText( "Workflow:", "1) GET /api/agents/me", `2) Determine issueId: PAPERCLIP_TASK_ID if present, otherwise issue_id (${issueIdHint}).`, + ' Replace {issueId} in every endpoint below with that determined id. Never send the literal text "{issueId}" in a URL.', "3) If issueId exists:", " - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$PAPERCLIP_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\",\"in_review\"]}", " - GET /api/issues/{issueId}", diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 3272a951d13..4283f57d27f 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -15,7 +15,7 @@ import { ensureAdapterExecutionTargetCommandResolvable, ensureAdapterExecutionTargetRuntimeCommandInstalled, prepareAdapterExecutionTargetRuntime, - adapterExecutionTargetDuplexTelemetryRecorder, + adapterExecutionTargetDuplexObservabilityRecorder, adapterExecutionTargetEnablesSandboxDuplexBridge, readAdapterExecutionTarget, readAdapterExecutionTargetHomeDir, @@ -463,7 +463,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { "created_at" timestamptz NOT NULL DEFAULT now() ); `); + await sourceSql.unsafe(` + CREATE FUNCTION "public"."backup_test_mark_done"() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + NEW."state" := 'done'; + RETURN NEW; + END; + $$; + CREATE TRIGGER "backup_test_mark_done_trigger" + BEFORE UPDATE OF "title" ON "public"."backup_test_records" + FOR EACH ROW + EXECUTE FUNCTION "public"."backup_test_mark_done"(); + `); const payload = "x".repeat(8192); for (let index = 0; index < 160; index += 1) { @@ -213,6 +228,18 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { metadata: { index: 159, even: false }, }, ]); + + await restoreSql.unsafe(` + UPDATE "public"."backup_test_records" + SET "title" = 'triggered' + WHERE "title" = 'row-0' + `); + const triggeredRows = await restoreSql.unsafe<{ state: string }[]>(` + SELECT "state"::text AS "state" + FROM "public"."backup_test_records" + WHERE "title" = 'triggered' + `); + expect(triggeredRows).toEqual([{ state: "done" }]); } finally { await sourceSql.end(); await restoreSql.end(); diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 9e3760c7588..30ef52a397b 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -591,6 +591,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emitStatement("BEGIN;"); emitStatement("SET LOCAL session_replication_role = replica;"); emitStatement("SET LOCAL client_min_messages = warning;"); + emitStatement("SET LOCAL check_function_bodies = false;"); emit(""); const allTables = await sql` @@ -809,7 +810,9 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emit(""); } - // Foreign keys (after all tables and referenced unique constraints are created) + // Collect foreign keys now. Emit them after routines and standalone indexes + // because PostgreSQL permits a non-constraint unique index to be the target + // of a foreign key. const allForeignKeys = await sql<{ constraint_name: string; source_schema: string; @@ -849,14 +852,29 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise && includedTableNames.has(tableKey(fk.target_schema, fk.target_table)), ); - if (fks.length > 0) { - emit("-- Foreign keys"); - for (const fk of fks) { - const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", "); - const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", "); - emitStatement( - `ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`, - ); + // JavaScript backups are used when a worktree seed filters or transforms + // table data. Preserve user-defined routines before indexes because an + // expression index may depend on a user-defined function. + const routines = await sql<{ definition: string }[]>` + SELECT pg_get_functiondef(p.oid) AS definition + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE ${sql.unsafe(nonSystemSchemaPredicate("n.nspname"))} + AND p.prokind IN ('f', 'p') + AND NOT EXISTS ( + SELECT 1 + FROM pg_depend d + WHERE d.classid = 'pg_proc'::regclass + AND d.objid = p.oid + AND d.deptype = 'e' + ) + ORDER BY n.nspname, p.proname, pg_get_function_identity_arguments(p.oid) + `; + if (routines.length > 0) { + emit("-- Functions and procedures"); + for (const routine of routines) { + const definition = routine.definition.trimEnd(); + emitStatement(definition.endsWith(";") ? definition : `${definition};`); } emit(""); } @@ -883,6 +901,18 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emit(""); } + if (fks.length > 0) { + emit("-- Foreign keys"); + for (const fk of fks) { + const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", "); + const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", "); + emitStatement( + `ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`, + ); + } + emit(""); + } + // Dump data for each table for (const { schema_name, tablename } of tables) { const currentTableKey = tableKey(schema_name, tablename); @@ -938,6 +968,33 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise emit(""); } + const allTriggers = await sql<{ + schema_name: string; + tablename: string; + definition: string; + }[]>` + SELECT + n.nspname AS schema_name, + c.relname AS tablename, + pg_get_triggerdef(t.oid, true) AS definition + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE NOT t.tgisinternal + AND ${sql.unsafe(nonSystemSchemaPredicate("n.nspname"))} + ORDER BY n.nspname, c.relname, t.tgname + `; + const triggers = allTriggers.filter((entry) => ( + includedTableNames.has(tableKey(entry.schema_name, entry.tablename)) + )); + if (triggers.length > 0) { + emit("-- Triggers"); + for (const trigger of triggers) { + emitStatement(`${trigger.definition};`); + } + emit(""); + } + // Sequence values if (sequences.length > 0) { emit("-- Sequence values"); diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index 2308deb6cfe..a41a3d889ec 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -1405,4 +1405,466 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { }, 20_000, ); + + it( + "preserves legacy runs while adding native persistence and replay-safe status versioning", + async () => { + const connectionString = await createTempDatabase(); + await applyPendingMigrations(connectionString); + + const nativePersistenceHash = await migrationHash("0227_modern_pandemic.sql"); + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); + const companyId = "10000000-0000-4000-8000-000000000227"; + const agentId = "20000000-0000-4000-8000-000000000227"; + const runId = "30000000-0000-4000-8000-000000000227"; + const issueId = "40000000-0000-4000-8000-000000000227"; + const contractId = "50000000-0000-4000-8000-000000000227"; + const resultId = "60000000-0000-4000-8000-000000000227"; + const assessmentId = "70000000-0000-4000-8000-000000000227"; + const decisionId = "80000000-0000-4000-8000-000000000227"; + const otherCompanyId = "11000000-0000-4000-8000-000000000227"; + const otherAgentId = "21000000-0000-4000-8000-000000000227"; + const otherRunId = "31000000-0000-4000-8000-000000000227"; + const otherIssueId = "41000000-0000-4000-8000-000000000227"; + const otherContractId = "51000000-0000-4000-8000-000000000227"; + const otherResultId = "61000000-0000-4000-8000-000000000227"; + const otherAssessmentId = "71000000-0000-4000-8000-000000000227"; + const otherDecisionId = "81000000-0000-4000-8000-000000000227"; + + try { + await sql.unsafe(` + DROP TABLE IF EXISTS status_decision_effects, status_decisions, work_assessments, + native_run_finalizations, native_run_results, completion_contracts CASCADE; + DROP TRIGGER IF EXISTS paperclip_issue_status_version_trigger ON issues; + DROP FUNCTION IF EXISTS paperclip_bump_issue_status_version(); + DROP INDEX IF EXISTS issues_company_id_uq; + DROP INDEX IF EXISTS heartbeat_run_events_run_source_event_uq; + DROP INDEX IF EXISTS heartbeat_run_events_run_source_seq_uq; + ALTER TABLE heartbeat_run_events + DROP COLUMN IF EXISTS source_instance_id, + DROP COLUMN IF EXISTS source_event_id, + DROP COLUMN IF EXISTS source_seq, + DROP COLUMN IF EXISTS source_payload_sha256, + DROP COLUMN IF EXISTS protocol_schema_version; + ALTER TABLE heartbeat_run_events ALTER COLUMN seq TYPE integer; + ALTER TABLE heartbeat_runs + DROP COLUMN IF EXISTS runtime_mode, + DROP COLUMN IF EXISTS runtime_mode_resolver_version, + DROP COLUMN IF EXISTS runtime_mode_reason, + DROP COLUMN IF EXISTS runtime_mode_resolved_at, + DROP COLUMN IF EXISTS runner_profile_json, + DROP COLUMN IF EXISTS runner_instance_id, + DROP COLUMN IF EXISTS native_session_id, + DROP COLUMN IF EXISTS native_issue_id, + DROP COLUMN IF EXISTS driver_kind, + DROP COLUMN IF EXISTS driver_version, + DROP COLUMN IF EXISTS completion_contract_id, + DROP COLUMN IF EXISTS completion_contract_sha256, + DROP COLUMN IF EXISTS next_event_seq, + DROP COLUMN IF EXISTS native_phase, + DROP COLUMN IF EXISTS native_phase_updated_at; + ALTER TABLE issues + DROP COLUMN IF EXISTS status_version, + DROP COLUMN IF EXISTS last_status_decision_id; + `); + await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${nativePersistenceHash}`; + await sql` + INSERT INTO companies (id, name, issue_prefix) + VALUES (${companyId}, 'Native persistence fixture', 'NPF') + `; + await sql` + INSERT INTO agents (id, company_id, name) + VALUES (${agentId}, ${companyId}, 'Legacy migration agent') + `; + await sql` + INSERT INTO heartbeat_runs (id, company_id, agent_id, status) + VALUES (${runId}, ${companyId}, ${agentId}, 'succeeded') + `; + await sql` + INSERT INTO issues (id, company_id, title, status) + VALUES (${issueId}, ${companyId}, 'Legacy migration issue', 'in_progress') + `; + await sql.unsafe(` + INSERT INTO heartbeat_run_events + (company_id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at) + VALUES + ('${companyId}', '${runId}', '${agentId}', 1, 'legacy.start', 'system', 'info', 'one', '{"bytes":"alpha-1"}'::jsonb, '2026-08-01T00:00:01.000Z'), + ('${companyId}', '${runId}', '${agentId}', 5, 'legacy.log', 'stdout', 'info', 'first-five', '{"bytes":"beta-5a"}'::jsonb, '2026-08-01T00:00:02.000Z'), + ('${companyId}', '${runId}', '${agentId}', 5, 'legacy.log', 'stderr', 'warn', 'duplicate-five', '{"bytes":"gamma-5b"}'::jsonb, '2026-08-01T00:00:03.000Z'), + ('${companyId}', '${runId}', '${agentId}', 9, 'legacy.end', 'system', 'info', 'nine', '{"bytes":"delta-9"}'::jsonb, '2026-08-01T00:00:04.000Z') + `); + } finally { + await sql.end(); + } + + await applyPendingMigrations(connectionString); + + const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const events = await verifySql.unsafe<{ + seq: string; + event_type: string; + stream: string; + level: string; + message: string; + payload: { bytes: string }; + created_at: Date; + }[]>(` + SELECT seq, event_type, stream, level, message, payload, created_at + FROM heartbeat_run_events + WHERE run_id = '${runId}' + ORDER BY id + `); + expect(events.map((event) => Number(event.seq))).toEqual([1, 5, 5, 9]); + expect(events.map(({ seq: _seq, ...event }) => ({ + ...event, + created_at: event.created_at.toISOString(), + }))).toEqual([ + { + event_type: "legacy.start", + stream: "system", + level: "info", + message: "one", + payload: { bytes: "alpha-1" }, + created_at: "2026-08-01T00:00:01.000Z", + }, + { + event_type: "legacy.log", + stream: "stdout", + level: "info", + message: "first-five", + payload: { bytes: "beta-5a" }, + created_at: "2026-08-01T00:00:02.000Z", + }, + { + event_type: "legacy.log", + stream: "stderr", + level: "warn", + message: "duplicate-five", + payload: { bytes: "gamma-5b" }, + created_at: "2026-08-01T00:00:03.000Z", + }, + { + event_type: "legacy.end", + stream: "system", + level: "info", + message: "nine", + payload: { bytes: "delta-9" }, + created_at: "2026-08-01T00:00:04.000Z", + }, + ]); + + const runs = await verifySql.unsafe<{ runtime_mode: string; next_event_seq: string }[]>(` + SELECT runtime_mode, next_event_seq + FROM heartbeat_runs + WHERE id = '${runId}' + `); + expect(runs.map((run) => ({ + runtimeMode: run.runtime_mode, + nextEventSeq: Number(run.next_event_seq), + }))).toEqual([{ runtimeMode: "legacy", nextEventSeq: 10 }]); + + const nativeRowsBefore = await verifySql.unsafe<{ table_name: string; row_count: number }[]>(` + SELECT 'completion_contracts' AS table_name, count(*)::int AS row_count FROM completion_contracts + UNION ALL SELECT 'native_run_results', count(*)::int FROM native_run_results + UNION ALL SELECT 'native_run_finalizations', count(*)::int FROM native_run_finalizations + UNION ALL SELECT 'work_assessments', count(*)::int FROM work_assessments + UNION ALL SELECT 'status_decisions', count(*)::int FROM status_decisions + UNION ALL SELECT 'status_decision_effects', count(*)::int FROM status_decision_effects + ORDER BY table_name + `); + expect(nativeRowsBefore.every((row) => row.row_count === 0)).toBe(true); + + await verifySql` + INSERT INTO companies (id, name, issue_prefix) + VALUES (${otherCompanyId}, 'Other native persistence fixture', 'ONP') + `; + await verifySql` + INSERT INTO agents (id, company_id, name) + VALUES (${otherAgentId}, ${otherCompanyId}, 'Other native migration agent') + `; + await verifySql` + INSERT INTO heartbeat_runs ( + id, company_id, agent_id, status, native_issue_id, completion_contract_id + ) VALUES ( + ${otherRunId}, ${otherCompanyId}, ${otherAgentId}, 'succeeded', + ${otherIssueId}, ${otherContractId} + ) + `; + await verifySql` + INSERT INTO issues (id, company_id, title, status) + VALUES (${otherIssueId}, ${otherCompanyId}, 'Other native issue', 'in_progress') + `; + await verifySql` + UPDATE heartbeat_runs + SET native_issue_id = ${issueId}, completion_contract_id = ${contractId} + WHERE id = ${runId} + `; + + await expect(verifySql` + INSERT INTO completion_contracts ( + company_id, issue_id, revision, schema_version, policy_version, + risk, completion_authority, incomplete_criteria_policy, contract_json, + canonical_sha256, created_by_actor_type, created_by_actor_id + ) VALUES ( + ${companyId}, ${otherIssueId}, 1, 'paperclip.completion-contract.v1', + 'policy-v1', 'low', 'server', 'review', ${JSON.stringify({ criteria: [] })}::jsonb, + 'cross-company-contract-sha', 'system', 'migration-test' + ) + `).rejects.toThrow(/completion_contracts_issue_company_fk/); + + await verifySql` + INSERT INTO completion_contracts ( + id, company_id, issue_id, revision, schema_version, policy_version, + risk, completion_authority, incomplete_criteria_policy, contract_json, + canonical_sha256, created_by_actor_type, created_by_actor_id + ) VALUES ( + ${contractId}, ${companyId}, ${issueId}, 1, 'paperclip.completion-contract.v1', + 'policy-v1', 'low', 'server', 'review', ${JSON.stringify({ criteria: [] })}::jsonb, + 'contract-sha', 'system', 'migration-test' + ) + `; + await verifySql` + INSERT INTO completion_contracts ( + id, company_id, issue_id, revision, schema_version, policy_version, + risk, completion_authority, incomplete_criteria_policy, contract_json, + canonical_sha256, created_by_actor_type, created_by_actor_id + ) VALUES ( + ${otherContractId}, ${otherCompanyId}, ${otherIssueId}, 1, + 'paperclip.completion-contract.v1', 'policy-v1', 'low', 'server', 'review', + ${JSON.stringify({ criteria: [] })}::jsonb, 'other-contract-sha', + 'system', 'migration-test' + ) + `; + + await expect(verifySql` + INSERT INTO native_run_results ( + company_id, issue_id, run_id, completion_contract_id, + server_fingerprint, schema_status, result_json, canonical_sha256 + ) VALUES ( + ${companyId}, ${issueId}, ${otherRunId}, ${contractId}, + 'cross-company-run', 'valid', ${JSON.stringify({ summary: "invalid" })}::jsonb, + 'cross-company-run-sha' + ) + `).rejects.toThrow(/native_run_results_run_contract_owner_fk/); + + await expect(verifySql` + INSERT INTO native_run_results ( + company_id, issue_id, run_id, completion_contract_id, + server_fingerprint, schema_status, result_json, canonical_sha256 + ) VALUES ( + ${companyId}, ${issueId}, ${runId}, ${otherContractId}, + 'cross-company-contract', 'valid', ${JSON.stringify({ summary: "invalid" })}::jsonb, + 'cross-company-contract-sha' + ) + `).rejects.toThrow(/native_run_results_run_contract_owner_fk/); + + await verifySql` + INSERT INTO native_run_results ( + id, company_id, issue_id, run_id, completion_contract_id, + server_fingerprint, schema_status, result_json, canonical_sha256 + ) VALUES ( + ${resultId}, ${companyId}, ${issueId}, ${runId}, ${contractId}, + 'result-fingerprint', 'valid', ${JSON.stringify({ summary: "done" })}::jsonb, 'result-sha' + ) + `; + await verifySql` + INSERT INTO native_run_results ( + id, company_id, issue_id, run_id, completion_contract_id, + server_fingerprint, schema_status, result_json, canonical_sha256 + ) VALUES ( + ${otherResultId}, ${otherCompanyId}, ${otherIssueId}, ${otherRunId}, + ${otherContractId}, 'other-result-fingerprint', 'valid', + ${JSON.stringify({ summary: "other" })}::jsonb, 'other-result-sha' + ) + `; + + await expect(verifySql` + INSERT INTO work_assessments ( + company_id, issue_id, run_id, contract_id, result_id, + trigger_kind, trigger_actor_company_id, prior_issue_status, + prior_status_version, policy_version, assessment_json, input_digest + ) VALUES ( + ${companyId}, ${issueId}, ${runId}, ${contractId}, ${otherResultId}, + 'run_terminal', ${companyId}, 'in_progress', 0, 'policy-v1', + ${JSON.stringify({ disposition: "invalid" })}::jsonb, + 'cross-company-assessment-input-sha' + ) + `).rejects.toThrow(/work_assessments_result_owner_fk/); + + await expect(verifySql` + INSERT INTO work_assessments ( + company_id, issue_id, run_id, contract_id, result_id, + trigger_kind, trigger_actor_company_id, prior_issue_status, + prior_status_version, policy_version, assessment_json, input_digest + ) VALUES ( + ${companyId}, ${issueId}, ${runId}, ${contractId}, ${resultId}, + 'run_terminal', ${otherCompanyId}, 'in_progress', 0, 'policy-v1', + ${JSON.stringify({ disposition: "invalid" })}::jsonb, + 'cross-company-trigger-input-sha' + ) + `).rejects.toThrow(/work_assessments_trigger_actor_company_check/); + + await verifySql` + INSERT INTO work_assessments ( + id, company_id, issue_id, run_id, contract_id, result_id, + trigger_kind, trigger_actor_company_id, prior_issue_status, + prior_status_version, policy_version, assessment_json, input_digest + ) VALUES ( + ${assessmentId}, ${companyId}, ${issueId}, ${runId}, ${contractId}, ${resultId}, + 'run_terminal', ${companyId}, 'in_progress', 0, 'policy-v1', + ${JSON.stringify({ disposition: "done" })}::jsonb, 'assessment-input-sha' + ) + `; + await verifySql` + INSERT INTO work_assessments ( + id, company_id, issue_id, run_id, contract_id, result_id, + trigger_kind, trigger_actor_company_id, prior_issue_status, + prior_status_version, policy_version, assessment_json, input_digest + ) VALUES ( + ${otherAssessmentId}, ${otherCompanyId}, ${otherIssueId}, ${otherRunId}, + ${otherContractId}, ${otherResultId}, 'run_terminal', ${otherCompanyId}, + 'in_progress', 0, 'policy-v1', + ${JSON.stringify({ disposition: "done" })}::jsonb, 'other-assessment-input-sha' + ) + `; + + await expect(verifySql` + INSERT INTO status_decisions ( + company_id, issue_id, run_id, assessment_id, decision_version, + policy_version, from_status, to_status, reason_code, + decision_json, decision_digest + ) VALUES ( + ${companyId}, ${issueId}, ${runId}, ${otherAssessmentId}, 1, + 'policy-v1', 'in_progress', 'done', 'native_result_accepted', + ${JSON.stringify({ toStatus: "done" })}::jsonb, 'cross-company-decision-sha' + ) + `).rejects.toThrow(/status_decisions_assessment_owner_fk/); + + await verifySql` + INSERT INTO status_decisions ( + id, company_id, issue_id, run_id, assessment_id, decision_version, + policy_version, from_status, to_status, reason_code, + decision_json, decision_digest + ) VALUES ( + ${decisionId}, ${companyId}, ${issueId}, ${runId}, ${assessmentId}, 1, + 'policy-v1', 'in_progress', 'done', 'native_result_accepted', + ${JSON.stringify({ toStatus: "done" })}::jsonb, 'decision-sha' + ) + `; + await verifySql` + INSERT INTO status_decisions ( + id, company_id, issue_id, run_id, assessment_id, decision_version, + policy_version, from_status, to_status, reason_code, + decision_json, decision_digest + ) VALUES ( + ${otherDecisionId}, ${otherCompanyId}, ${otherIssueId}, ${otherRunId}, + ${otherAssessmentId}, 1, 'policy-v1', 'in_progress', 'done', + 'native_result_accepted', ${JSON.stringify({ toStatus: "done" })}::jsonb, + 'other-decision-sha' + ) + `; + + await expect(verifySql` + INSERT INTO status_decision_effects ( + company_id, issue_id, decision_id, ordinal, effect_kind, + target_type, idempotency_key, payload + ) VALUES ( + ${companyId}, ${issueId}, ${otherDecisionId}, 0, 'update_issue_status', + 'issue', 'cross-company-decision-effect', ${JSON.stringify({ status: "done" })}::jsonb + ) + `).rejects.toThrow(/status_decision_effects_decision_owner_fk/); + + await verifySql` + INSERT INTO status_decision_effects ( + company_id, issue_id, decision_id, ordinal, effect_kind, + target_type, idempotency_key, payload + ) VALUES ( + ${companyId}, ${issueId}, ${decisionId}, 0, 'update_issue_status', + 'issue', 'decision-effect-1', ${JSON.stringify({ status: "done" })}::jsonb + ) + `; + + await expect(verifySql` + INSERT INTO native_run_finalizations ( + run_id, company_id, issue_id, phase, result_id, assessment_id, decision_id + ) VALUES ( + ${runId}, ${companyId}, ${issueId}, 'committed', ${resultId}, + ${assessmentId}, ${otherDecisionId} + ) + `).rejects.toThrow(/native_run_finalizations_decision_owner_fk/); + + await verifySql` + INSERT INTO native_run_finalizations ( + run_id, company_id, issue_id, phase, result_id, assessment_id, decision_id + ) VALUES ( + ${runId}, ${companyId}, ${issueId}, 'committed', ${resultId}, ${assessmentId}, ${decisionId} + ) + `; + + await verifySql`UPDATE issues SET title = 'Renamed legacy issue' WHERE id = ${issueId}`; + await verifySql`UPDATE issues SET status = 'done' WHERE id = ${issueId}`; + const issues = await verifySql.unsafe<{ status: string; status_version: string }[]>(` + SELECT status, status_version + FROM issues + WHERE id = '${issueId}' + `); + expect(issues.map((issue) => ({ + status: issue.status, + statusVersion: Number(issue.status_version), + }))).toEqual([{ status: "done", statusVersion: 1 }]); + + await verifySql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${nativePersistenceHash}`; + } finally { + await verifySql.end(); + } + + await expect(applyPendingMigrations(connectionString)).resolves.toBeUndefined(); + await expect(inspectMigrations(connectionString)).resolves.toMatchObject({ + status: "upToDate", + }); + + const replaySql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const replayed = await replaySql.unsafe<{ + status_version: string; + next_event_seq: string; + trigger_count: number; + finalization_count: number; + }[]>(` + SELECT + issue.status_version, + run.next_event_seq, + ( + SELECT count(*)::int + FROM pg_trigger + WHERE tgname = 'paperclip_issue_status_version_trigger' + AND NOT tgisinternal + ) AS trigger_count, + ( + SELECT count(*)::int + FROM native_run_finalizations + WHERE run_id = '${runId}' + ) AS finalization_count + FROM issues issue + CROSS JOIN heartbeat_runs run + WHERE issue.id = '${issueId}' AND run.id = '${runId}' + `); + expect(replayed.map((row) => ({ + statusVersion: Number(row.status_version), + nextEventSeq: Number(row.next_event_seq), + triggerCount: row.trigger_count, + finalizationCount: row.finalization_count, + }))).toEqual([{ + statusVersion: 1, + nextEventSeq: 10, + triggerCount: 1, + finalizationCount: 1, + }]); + } finally { + await replaySql.end(); + } + }, + 60_000, + ); }); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index c0337620491..1a96d3183d4 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -411,6 +411,25 @@ async function columnExists( return rows[0]?.exists ?? false; } +async function columnHasDataType( + sql: ReturnType, + tableName: string, + columnName: string, + dataType: string, +): Promise { + const rows = await sql<{ dataType: string; udtName: string }[]>` + SELECT data_type AS "dataType", udt_name AS "udtName" + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${tableName} + AND column_name = ${columnName} + `; + const expected = dataType.toLowerCase(); + return rows.some((row) => ( + row.dataType.toLowerCase() === expected || row.udtName.toLowerCase() === expected + )); +} + async function indexExists( sql: ReturnType, indexName: string, @@ -444,11 +463,65 @@ async function constraintExists( return rows[0]?.exists ?? false; } +async function functionExists( + sql: ReturnType, + functionName: string, +): Promise { + const rows = await sql<{ exists: boolean }[]>` + SELECT EXISTS ( + SELECT 1 + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.proname = ${functionName} + ) AS exists + `; + return rows[0]?.exists ?? false; +} + +async function triggerExists( + sql: ReturnType, + triggerName: string, +): Promise { + const rows = await sql<{ exists: boolean }[]>` + SELECT EXISTS ( + SELECT 1 + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND t.tgname = ${triggerName} + AND NOT t.tgisinternal + ) AS exists + `; + return rows[0]?.exists ?? false; +} + +async function heartbeatNextEventSequencesAreCurrent( + sql: ReturnType, +): Promise { + const rows = await sql<{ current: boolean }[]>` + SELECT NOT EXISTS ( + SELECT 1 + FROM heartbeat_runs run + WHERE run.next_event_seq IS DISTINCT FROM COALESCE(( + SELECT max(event.seq) + 1 + FROM heartbeat_run_events event + WHERE event.run_id = run.id + ), 1) + ) AS current + `; + return rows[0]?.current ?? false; +} + async function migrationStatementAlreadyApplied( sql: ReturnType, statement: string, ): Promise { - const normalized = statement.replace(/\s+/g, " ").trim(); + const normalized = statement + .replace(/^\s*--.*$/gm, "") + .replace(/\s+/g, " ") + .trim(); const createTableMatch = normalized.match(/^CREATE TABLE(?: IF NOT EXISTS)? "([^"]+)"/i); if (createTableMatch) { @@ -462,6 +535,18 @@ async function migrationStatementAlreadyApplied( return columnExists(sql, addColumnMatch[1], addColumnMatch[2]); } + const alterColumnTypeMatch = normalized.match( + /^ALTER TABLE "([^"]+)" ALTER COLUMN "([^"]+)" SET DATA TYPE ([A-Za-z0-9_]+)/i, + ); + if (alterColumnTypeMatch) { + return columnHasDataType( + sql, + alterColumnTypeMatch[1], + alterColumnTypeMatch[2], + alterColumnTypeMatch[3], + ); + } + const createIndexMatch = normalized.match(/^CREATE (?:UNIQUE )?INDEX(?: IF NOT EXISTS)? "([^"]+)"/i); if (createIndexMatch) { return indexExists(sql, createIndexMatch[1]); @@ -472,6 +557,30 @@ async function migrationStatementAlreadyApplied( return constraintExists(sql, addConstraintMatch[2]); } + const createFunctionMatch = normalized.match( + /^CREATE OR REPLACE FUNCTION "?([A-Za-z_][A-Za-z0-9_]*)"?\s*\(/i, + ); + if (createFunctionMatch) { + return functionExists(sql, createFunctionMatch[1]); + } + + const createTriggerMatch = normalized.match( + /^CREATE TRIGGER "?([A-Za-z_][A-Za-z0-9_]*)"?/i, + ); + if (createTriggerMatch) { + return triggerExists(sql, createTriggerMatch[1]); + } + + // This native-runner cursor backfill has a persistent postcondition. Verify it + // instead of replaying it when a restored database is missing only the + // migration-history row. + if ( + normalized.startsWith('UPDATE "heartbeat_runs" AS run') + && normalized.includes('SET "next_event_seq" = COALESCE') + ) { + return heartbeatNextEventSequencesAreCurrent(sql); + } + // If we cannot reason about a statement safely, require manual migration. return false; } diff --git a/packages/db/src/migrations/0227_modern_pandemic.sql b/packages/db/src/migrations/0227_modern_pandemic.sql new file mode 100644 index 00000000000..bef73bac91a --- /dev/null +++ b/packages/db/src/migrations/0227_modern_pandemic.sql @@ -0,0 +1,410 @@ +CREATE TABLE IF NOT EXISTS "completion_contracts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "revision" integer NOT NULL, + "schema_version" text NOT NULL, + "policy_version" text NOT NULL, + "risk" text NOT NULL, + "completion_authority" text NOT NULL, + "incomplete_criteria_policy" text NOT NULL, + "contract_json" jsonb NOT NULL, + "canonical_sha256" text NOT NULL, + "created_by_actor_type" text NOT NULL, + "created_by_actor_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "supersedes_contract_id" uuid +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "native_run_finalizations" ( + "run_id" uuid PRIMARY KEY NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "phase" text NOT NULL, + "attempt" integer DEFAULT 0 NOT NULL, + "lease_owner" text, + "lease_expires_at" timestamp with time zone, + "result_id" uuid, + "assessment_id" uuid, + "decision_id" uuid, + "failure_code" text, + "failure_detail" jsonb, + "next_attempt_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "native_run_results" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "run_id" uuid NOT NULL, + "turn_id" text, + "completion_contract_id" uuid NOT NULL, + "caller_result_id" text, + "caller_dedupe_key" text, + "server_fingerprint" text NOT NULL, + "schema_status" text NOT NULL, + "rejection_code" text, + "result_json" jsonb NOT NULL, + "canonical_sha256" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "status_decision_effects" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "decision_id" uuid NOT NULL, + "ordinal" integer NOT NULL, + "effect_kind" text NOT NULL, + "target_type" text NOT NULL, + "target_id" text, + "idempotency_key" text NOT NULL, + "payload" jsonb NOT NULL, + "delivery_state" text DEFAULT 'pending' NOT NULL, + "attempt_count" integer DEFAULT 0 NOT NULL, + "next_attempt_at" timestamp with time zone, + "last_error" text, + "delivered_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "status_decisions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "run_id" uuid NOT NULL, + "assessment_id" uuid NOT NULL, + "decision_version" bigint NOT NULL, + "policy_version" text NOT NULL, + "from_status" text NOT NULL, + "to_status" text NOT NULL, + "reason_code" text NOT NULL, + "decision_json" jsonb NOT NULL, + "decision_digest" text NOT NULL, + "application_state" text DEFAULT 'proposed' NOT NULL, + "supersedes_decision_id" uuid, + "applied_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "work_assessments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "run_id" uuid NOT NULL, + "turn_id" text, + "contract_id" uuid NOT NULL, + "result_id" uuid NOT NULL, + "trigger_kind" text NOT NULL, + "trigger_ref" text, + "trigger_capability" text, + "trigger_actor_company_id" uuid NOT NULL, + "prior_issue_status" text NOT NULL, + "prior_status_version" bigint NOT NULL, + "prior_decision_id" uuid, + "policy_version" text NOT NULL, + "assessment_json" jsonb NOT NULL, + "input_digest" text NOT NULL, + "supersedes_assessment_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "heartbeat_run_events" ALTER COLUMN "seq" SET DATA TYPE bigint;--> statement-breakpoint +ALTER TABLE "heartbeat_run_events" ADD COLUMN IF NOT EXISTS "source_instance_id" text;--> statement-breakpoint +ALTER TABLE "heartbeat_run_events" ADD COLUMN IF NOT EXISTS "source_event_id" text;--> statement-breakpoint +ALTER TABLE "heartbeat_run_events" ADD COLUMN IF NOT EXISTS "source_seq" bigint;--> statement-breakpoint +ALTER TABLE "heartbeat_run_events" ADD COLUMN IF NOT EXISTS "source_payload_sha256" text;--> statement-breakpoint +ALTER TABLE "heartbeat_run_events" ADD COLUMN IF NOT EXISTS "protocol_schema_version" integer;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "runtime_mode" text DEFAULT 'legacy' NOT NULL;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "runtime_mode_resolver_version" text;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "runtime_mode_reason" text;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "runtime_mode_resolved_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "runner_profile_json" jsonb;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "runner_instance_id" uuid;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "native_session_id" uuid;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "native_issue_id" uuid;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "driver_kind" text;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "driver_version" text;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "completion_contract_id" uuid;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "completion_contract_sha256" text;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "next_event_seq" bigint DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "native_phase" text;--> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "native_phase_updated_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "status_decisions" ADD COLUMN IF NOT EXISTS "run_id" uuid;--> statement-breakpoint +ALTER TABLE "status_decisions" ALTER COLUMN "run_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "status_version" bigint DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "last_status_decision_id" uuid;--> statement-breakpoint +UPDATE "heartbeat_runs" AS run +SET "next_event_seq" = COALESCE(( + SELECT max(event."seq") + 1 + FROM "heartbeat_run_events" AS event + WHERE event."run_id" = run."id" +), 1);--> statement-breakpoint +CREATE OR REPLACE FUNCTION paperclip_bump_issue_status_version() +RETURNS trigger AS $$ +BEGIN + IF NEW."status" IS DISTINCT FROM OLD."status" THEN + NEW."status_version" := OLD."status_version" + 1; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql;--> statement-breakpoint +DROP TRIGGER IF EXISTS paperclip_issue_status_version_trigger ON "issues";--> statement-breakpoint +CREATE TRIGGER paperclip_issue_status_version_trigger +BEFORE UPDATE OF "status" ON "issues" +FOR EACH ROW EXECUTE FUNCTION paperclip_bump_issue_status_version();--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "completion_contracts" ADD CONSTRAINT "completion_contracts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "completion_contracts" ADD CONSTRAINT "completion_contracts_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_result_id_native_run_results_id_fk" FOREIGN KEY ("result_id") REFERENCES "public"."native_run_results"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_completion_contract_id_completion_contracts_id_fk" FOREIGN KEY ("completion_contract_id") REFERENCES "public"."completion_contracts"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "status_decision_effects" ADD CONSTRAINT "status_decision_effects_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "status_decision_effects" ADD CONSTRAINT "status_decision_effects_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "status_decision_effects" ADD CONSTRAINT "status_decision_effects_decision_id_status_decisions_id_fk" FOREIGN KEY ("decision_id") REFERENCES "public"."status_decisions"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_assessment_id_work_assessments_id_fk" FOREIGN KEY ("assessment_id") REFERENCES "public"."work_assessments"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_contract_id_completion_contracts_id_fk" FOREIGN KEY ("contract_id") REFERENCES "public"."completion_contracts"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_result_id_native_run_results_id_fk" FOREIGN KEY ("result_id") REFERENCES "public"."native_run_results"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_trigger_actor_company_id_companies_id_fk" FOREIGN KEY ("trigger_actor_company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "completion_contracts_issue_revision_uq" ON "completion_contracts" USING btree ("issue_id","revision");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "completion_contracts_issue_hash_uq" ON "completion_contracts" USING btree ("issue_id","canonical_sha256");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "native_run_results_run_fingerprint_uq" ON "native_run_results" USING btree ("run_id","server_fingerprint");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "native_run_results_run_caller_result_uq" ON "native_run_results" USING btree ("run_id","caller_result_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "native_run_results_run_caller_dedupe_uq" ON "native_run_results" USING btree ("run_id","caller_dedupe_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "status_decision_effects_decision_ordinal_uq" ON "status_decision_effects" USING btree ("decision_id","ordinal");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "status_decision_effects_company_idempotency_uq" ON "status_decision_effects" USING btree ("company_id","idempotency_key");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "status_decisions_company_issue_version_uq" ON "status_decisions" USING btree ("company_id","issue_id","decision_version");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "status_decisions_company_assessment_uq" ON "status_decisions" USING btree ("company_id","assessment_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "status_decisions_company_issue_digest_uq" ON "status_decisions" USING btree ("company_id","issue_id","decision_digest");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "work_assessments_company_issue_input_uq" ON "work_assessments" USING btree ("company_id","issue_id","input_digest");--> statement-breakpoint +-- paperclip:migration-safety-ignore large-create-index-not-concurrently: nullable native source IDs mean historical rows need no backfill and the invariant must commit atomically with the new columns. +CREATE UNIQUE INDEX IF NOT EXISTS "heartbeat_run_events_run_source_event_uq" ON "heartbeat_run_events" USING btree ("run_id","source_event_id") WHERE "heartbeat_run_events"."source_event_id" is not null;--> statement-breakpoint +-- paperclip:migration-safety-ignore large-create-index-not-concurrently: nullable native source sequence fields mean historical rows need no backfill and the invariant must commit atomically with the new columns. +CREATE UNIQUE INDEX IF NOT EXISTS "heartbeat_run_events_run_source_seq_uq" ON "heartbeat_run_events" USING btree ("run_id","source_instance_id","source_seq") WHERE "heartbeat_run_events"."source_instance_id" is not null and "heartbeat_run_events"."source_seq" is not null; +--> statement-breakpoint +-- paperclip:migration-safety-ignore large-create-index-not-concurrently: the primary key already makes this ownership tuple unique; this supporting index only enables composite foreign keys. +CREATE UNIQUE INDEX IF NOT EXISTS "issues_company_id_uq" ON "issues" USING btree ("company_id","id");--> statement-breakpoint +-- paperclip:migration-safety-ignore large-create-index-not-concurrently: the primary key already makes this ownership tuple unique; this supporting index only enables composite foreign keys. +CREATE UNIQUE INDEX IF NOT EXISTS "heartbeat_runs_company_native_issue_id_uq" ON "heartbeat_runs" USING btree ("company_id","native_issue_id","id");--> statement-breakpoint +-- paperclip:migration-safety-ignore large-create-index-not-concurrently: the primary key already makes this ownership tuple unique; this supporting index only enables composite foreign keys. +CREATE UNIQUE INDEX IF NOT EXISTS "heartbeat_runs_company_native_issue_contract_id_uq" ON "heartbeat_runs" USING btree ("company_id","native_issue_id","id","completion_contract_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "completion_contracts_company_issue_id_uq" ON "completion_contracts" USING btree ("company_id","issue_id","id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "native_run_results_company_issue_run_id_uq" ON "native_run_results" USING btree ("company_id","issue_id","run_id","id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "work_assessments_company_issue_run_id_uq" ON "work_assessments" USING btree ("company_id","issue_id","run_id","id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "status_decisions_company_issue_id_uq" ON "status_decisions" USING btree ("company_id","issue_id","id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "status_decisions_company_issue_run_assessment_id_uq" ON "status_decisions" USING btree ("company_id","issue_id","run_id","assessment_id","id");--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "completion_contracts" ADD CONSTRAINT "completion_contracts_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "completion_contracts" ADD CONSTRAINT "completion_contracts_supersedes_owner_fk" FOREIGN KEY ("company_id","issue_id","supersedes_contract_id") REFERENCES "public"."completion_contracts"("company_id","issue_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_run_contract_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","completion_contract_id") REFERENCES "public"."heartbeat_runs"("company_id","native_issue_id","id","completion_contract_id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_results" ADD CONSTRAINT "native_run_results_completion_contract_owner_fk" FOREIGN KEY ("company_id","issue_id","completion_contract_id") REFERENCES "public"."completion_contracts"("company_id","issue_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_run_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","native_issue_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_contract_owner_fk" FOREIGN KEY ("company_id","issue_id","contract_id") REFERENCES "public"."completion_contracts"("company_id","issue_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_result_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","result_id") REFERENCES "public"."native_run_results"("company_id","issue_id","run_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_supersedes_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","supersedes_assessment_id") REFERENCES "public"."work_assessments"("company_id","issue_id","run_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_assessment_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","assessment_id") REFERENCES "public"."work_assessments"("company_id","issue_id","run_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "status_decisions" ADD CONSTRAINT "status_decisions_supersedes_owner_fk" FOREIGN KEY ("company_id","issue_id","supersedes_decision_id") REFERENCES "public"."status_decisions"("company_id","issue_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "status_decision_effects" ADD CONSTRAINT "status_decision_effects_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "status_decision_effects" ADD CONSTRAINT "status_decision_effects_decision_owner_fk" FOREIGN KEY ("company_id","issue_id","decision_id") REFERENCES "public"."status_decisions"("company_id","issue_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_run_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id") REFERENCES "public"."heartbeat_runs"("company_id","native_issue_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_result_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","result_id") REFERENCES "public"."native_run_results"("company_id","issue_id","run_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_assessment_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","assessment_id") REFERENCES "public"."work_assessments"("company_id","issue_id","run_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_decision_owner_fk" FOREIGN KEY ("company_id","issue_id","run_id","assessment_id","decision_id") REFERENCES "public"."status_decisions"("company_id","issue_id","run_id","assessment_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_assessment_requires_result_check" CHECK ("assessment_id" is null or "result_id" is not null); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_run_finalizations" ADD CONSTRAINT "native_run_finalizations_decision_requires_assessment_check" CHECK ("decision_id" is null or "assessment_id" is not null); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "work_assessments" ADD CONSTRAINT "work_assessments_trigger_actor_company_check" CHECK ("trigger_actor_company_id" = "company_id"); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; diff --git a/packages/db/src/migrations/0227_zippy_blockbuster.sql b/packages/db/src/migrations/0228_zippy_blockbuster.sql similarity index 100% rename from packages/db/src/migrations/0227_zippy_blockbuster.sql rename to packages/db/src/migrations/0228_zippy_blockbuster.sql diff --git a/packages/db/src/migrations/meta/0227_snapshot.json b/packages/db/src/migrations/meta/0227_snapshot.json index 4f018186400..eba06327584 100644 --- a/packages/db/src/migrations/meta/0227_snapshot.json +++ b/packages/db/src/migrations/meta/0227_snapshot.json @@ -1,5 +1,5 @@ { - "id": "24bac3a4-a335-4f26-8a3a-54700d19a9f1", + "id": "11d55aa5-6b96-4b7b-bca2-52d827ed8f61", "prevId": "682c69c4-2439-4cd4-b312-49ce9de1d711", "version": "7", "dialect": "postgresql", @@ -8846,6 +8846,210 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.completion_contracts": { + "name": "completion_contracts", + "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 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy_version": { + "name": "policy_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completion_authority": { + "name": "completion_authority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incomplete_criteria_policy": { + "name": "incomplete_criteria_policy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contract_json": { + "name": "contract_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "canonical_sha256": { + "name": "canonical_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "supersedes_contract_id": { + "name": "supersedes_contract_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "completion_contracts_issue_revision_uq": { + "name": "completion_contracts_issue_revision_uq", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "completion_contracts_issue_hash_uq": { + "name": "completion_contracts_issue_hash_uq", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "canonical_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "completion_contracts_company_id_companies_id_fk": { + "name": "completion_contracts_company_id_companies_id_fk", + "tableFrom": "completion_contracts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "completion_contracts_issue_company_fk": { + "name": "completion_contracts_issue_company_fk", + "tableFrom": "completion_contracts", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "completion_contracts_supersedes_owner_fk": { + "name": "completion_contracts_supersedes_owner_fk", + "tableFrom": "completion_contracts", + "tableTo": "completion_contracts", + "columnsFrom": [ + "company_id", + "issue_id", + "supersedes_contract_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "completion_contracts_company_issue_id_uq": { + "name": "completion_contracts_company_issue_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "issue_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.cost_events": { "name": "cost_events", "schema": "", @@ -16843,7 +17047,7 @@ }, "seq": { "name": "seq", - "type": "integer", + "type": "bigint", "primaryKey": false, "notNull": true }, @@ -16883,6 +17087,36 @@ "primaryKey": false, "notNull": false }, + "source_instance_id": { + "name": "source_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_event_id": { + "name": "source_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_seq": { + "name": "source_seq", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source_payload_sha256": { + "name": "source_payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protocol_schema_version": { + "name": "protocol_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -16913,6 +17147,56 @@ "method": "btree", "with": {} }, + "heartbeat_run_events_run_source_event_uq": { + "name": "heartbeat_run_events_run_source_event_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"heartbeat_run_events\".\"source_event_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_run_source_seq_uq": { + "name": "heartbeat_run_events_run_source_seq_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"heartbeat_run_events\".\"source_instance_id\" is not null and \"heartbeat_run_events\".\"source_seq\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, "heartbeat_run_events_company_run_idx": { "name": "heartbeat_run_events_company_run_idx", "columns": [ @@ -17302,6 +17586,98 @@ "primaryKey": false, "notNull": false }, + "runtime_mode": { + "name": "runtime_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'legacy'" + }, + "runtime_mode_resolver_version": { + "name": "runtime_mode_resolver_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_mode_reason": { + "name": "runtime_mode_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_mode_resolved_at": { + "name": "runtime_mode_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "runner_profile_json": { + "name": "runner_profile_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "runner_instance_id": { + "name": "runner_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "native_session_id": { + "name": "native_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "native_issue_id": { + "name": "native_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "driver_kind": { + "name": "driver_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "driver_version": { + "name": "driver_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completion_contract_id": { + "name": "completion_contract_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "completion_contract_sha256": { + "name": "completion_contract_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_event_seq": { + "name": "next_event_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "native_phase": { + "name": "native_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "native_phase_updated_at": { + "name": "native_phase_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, "session_id_before": { "name": "session_id_before", "type": "text", @@ -17809,7 +18185,27 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "heartbeat_runs_company_native_issue_id_uq": { + "name": "heartbeat_runs_company_native_issue_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "native_issue_id", + "id" + ] + }, + "heartbeat_runs_company_native_issue_contract_id_uq": { + "name": "heartbeat_runs_company_native_issue_contract_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "native_issue_id", + "id", + "completion_contract_id" + ] + } + }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false @@ -23379,6 +23775,19 @@ "notNull": true, "default": "'backlog'" }, + "status_version": { + "name": "status_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_status_decision_id": { + "name": "last_status_decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "work_mode": { "name": "work_mode", "type": "text", @@ -23497,12 +23906,6 @@ "notNull": true, "default": "'default'" }, - "superseded_by_issue_id": { - "name": "superseded_by_issue_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, "request_depth": { "name": "request_depth", "type": "integer", @@ -23839,21 +24242,6 @@ "method": "btree", "with": {} }, - "issues_superseded_by_issue_idx": { - "name": "issues_superseded_by_issue_idx", - "columns": [ - { - "expression": "superseded_by_issue_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, "issues_company_project_workspace_idx": { "name": "issues_company_project_workspace_idx", "columns": [ @@ -24414,19 +24802,6 @@ "onDelete": "no action", "onUpdate": "no action" }, - "issues_superseded_by_issue_id_issues_id_fk": { - "name": "issues_superseded_by_issue_id_issues_id_fk", - "tableFrom": "issues", - "tableTo": "issues", - "columnsFrom": [ - "superseded_by_issue_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, "issues_execution_workspace_id_execution_workspaces_id_fk": { "name": "issues_execution_workspace_id_execution_workspaces_id_fk", "tableFrom": "issues", @@ -24442,7 +24817,16 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "issues_company_id_uq": { + "name": "issues_company_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "id" + ] + } + }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false @@ -24832,6 +25216,465 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.native_run_finalizations": { + "name": "native_run_finalizations", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "result_id": { + "name": "result_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assessment_id": { + "name": "assessment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "decision_id": { + "name": "decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_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": {}, + "foreignKeys": { + "native_run_finalizations_company_id_companies_id_fk": { + "name": "native_run_finalizations_company_id_companies_id_fk", + "tableFrom": "native_run_finalizations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_finalizations_issue_company_fk": { + "name": "native_run_finalizations_issue_company_fk", + "tableFrom": "native_run_finalizations", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_finalizations_run_owner_fk": { + "name": "native_run_finalizations_run_owner_fk", + "tableFrom": "native_run_finalizations", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id" + ], + "columnsTo": [ + "company_id", + "native_issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_finalizations_result_owner_fk": { + "name": "native_run_finalizations_result_owner_fk", + "tableFrom": "native_run_finalizations", + "tableTo": "native_run_results", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "result_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "run_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_finalizations_assessment_owner_fk": { + "name": "native_run_finalizations_assessment_owner_fk", + "tableFrom": "native_run_finalizations", + "tableTo": "work_assessments", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "assessment_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "run_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_finalizations_decision_owner_fk": { + "name": "native_run_finalizations_decision_owner_fk", + "tableFrom": "native_run_finalizations", + "tableTo": "status_decisions", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "assessment_id", + "decision_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "run_id", + "assessment_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "native_run_finalizations_assessment_requires_result_check": { + "name": "native_run_finalizations_assessment_requires_result_check", + "value": "\"native_run_finalizations\".\"assessment_id\" is null or \"native_run_finalizations\".\"result_id\" is not null" + }, + "native_run_finalizations_decision_requires_assessment_check": { + "name": "native_run_finalizations_decision_requires_assessment_check", + "value": "\"native_run_finalizations\".\"decision_id\" is null or \"native_run_finalizations\".\"assessment_id\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.native_run_results": { + "name": "native_run_results", + "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 + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completion_contract_id": { + "name": "completion_contract_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "caller_result_id": { + "name": "caller_result_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caller_dedupe_key": { + "name": "caller_dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "server_fingerprint": { + "name": "server_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_status": { + "name": "schema_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rejection_code": { + "name": "rejection_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "canonical_sha256": { + "name": "canonical_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "native_run_results_run_fingerprint_uq": { + "name": "native_run_results_run_fingerprint_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "native_run_results_run_caller_result_uq": { + "name": "native_run_results_run_caller_result_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "caller_result_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "native_run_results_run_caller_dedupe_uq": { + "name": "native_run_results_run_caller_dedupe_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "caller_dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "native_run_results_company_id_companies_id_fk": { + "name": "native_run_results_company_id_companies_id_fk", + "tableFrom": "native_run_results", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_results_issue_company_fk": { + "name": "native_run_results_issue_company_fk", + "tableFrom": "native_run_results", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_results_run_contract_owner_fk": { + "name": "native_run_results_run_contract_owner_fk", + "tableFrom": "native_run_results", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "completion_contract_id" + ], + "columnsTo": [ + "company_id", + "native_issue_id", + "id", + "completion_contract_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_results_completion_contract_owner_fk": { + "name": "native_run_results_completion_contract_owner_fk", + "tableFrom": "native_run_results", + "tableTo": "completion_contracts", + "columnsFrom": [ + "company_id", + "issue_id", + "completion_contract_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "native_run_results_company_issue_run_id_uq": { + "name": "native_run_results_company_issue_run_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "issue_id", + "run_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.pipeline_automation_executions": { "name": "pipeline_automation_executions", "schema": "", @@ -30138,12 +30981,6 @@ "primaryKey": false, "notNull": false }, - "superseded_by_run_id": { - "name": "superseded_by_run_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, "failure_reason": { "name": "failure_reason", "type": "text", @@ -30298,21 +31135,6 @@ "method": "btree", "with": {} }, - "routine_runs_superseded_by_run_idx": { - "name": "routine_runs_superseded_by_run_idx", - "columns": [ - { - "expression": "superseded_by_run_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, "routine_runs_trigger_idempotency_idx": { "name": "routine_runs_trigger_idempotency_idx", "columns": [ @@ -30400,19 +31222,6 @@ ], "onDelete": "set null", "onUpdate": "no action" - }, - "routine_runs_superseded_by_run_id_routine_runs_id_fk": { - "name": "routine_runs_superseded_by_run_id_routine_runs_id_fk", - "tableFrom": "routine_runs", - "tableTo": "routine_runs", - "columnsFrom": [ - "superseded_by_run_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -30804,13 +31613,6 @@ "notNull": true, "default": "'coalesce_if_active'" }, - "lifecycle_policy": { - "name": "lifecycle_policy", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'independent'" - }, "catch_up_policy": { "name": "catch_up_policy", "type": "text", @@ -32363,6 +33165,489 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.status_decision_effects": { + "name": "status_decision_effects", + "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 + }, + "decision_id": { + "name": "decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "effect_kind": { + "name": "effect_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "delivery_state": { + "name": "delivery_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_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": { + "status_decision_effects_decision_ordinal_uq": { + "name": "status_decision_effects_decision_ordinal_uq", + "columns": [ + { + "expression": "decision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "status_decision_effects_company_idempotency_uq": { + "name": "status_decision_effects_company_idempotency_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "status_decision_effects_company_id_companies_id_fk": { + "name": "status_decision_effects_company_id_companies_id_fk", + "tableFrom": "status_decision_effects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_decision_effects_issue_company_fk": { + "name": "status_decision_effects_issue_company_fk", + "tableFrom": "status_decision_effects", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_decision_effects_decision_owner_fk": { + "name": "status_decision_effects_decision_owner_fk", + "tableFrom": "status_decision_effects", + "tableTo": "status_decisions", + "columnsFrom": [ + "company_id", + "issue_id", + "decision_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.status_decisions": { + "name": "status_decisions", + "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 + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assessment_id": { + "name": "assessment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "decision_version": { + "name": "decision_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "policy_version": { + "name": "policy_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision_json": { + "name": "decision_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decision_digest": { + "name": "decision_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "application_state": { + "name": "application_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'proposed'" + }, + "supersedes_decision_id": { + "name": "supersedes_decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_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": { + "status_decisions_company_issue_version_uq": { + "name": "status_decisions_company_issue_version_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "decision_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "status_decisions_company_assessment_uq": { + "name": "status_decisions_company_assessment_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assessment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "status_decisions_company_issue_digest_uq": { + "name": "status_decisions_company_issue_digest_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "decision_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "status_decisions_company_id_companies_id_fk": { + "name": "status_decisions_company_id_companies_id_fk", + "tableFrom": "status_decisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_decisions_issue_company_fk": { + "name": "status_decisions_issue_company_fk", + "tableFrom": "status_decisions", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_decisions_assessment_owner_fk": { + "name": "status_decisions_assessment_owner_fk", + "tableFrom": "status_decisions", + "tableTo": "work_assessments", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "assessment_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "run_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_decisions_supersedes_owner_fk": { + "name": "status_decisions_supersedes_owner_fk", + "tableFrom": "status_decisions", + "tableTo": "status_decisions", + "columnsFrom": [ + "company_id", + "issue_id", + "supersedes_decision_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "status_decisions_company_issue_id_uq": { + "name": "status_decisions_company_issue_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "issue_id", + "id" + ] + }, + "status_decisions_company_issue_run_assessment_id_uq": { + "name": "status_decisions_company_issue_run_assessment_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "issue_id", + "run_id", + "assessment_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.summary_slots": { "name": "summary_slots", "schema": "", @@ -38982,6 +40267,293 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.work_assessments": { + "name": "work_assessments", + "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 + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contract_id": { + "name": "contract_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "result_id": { + "name": "result_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger_kind": { + "name": "trigger_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_ref": { + "name": "trigger_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_capability": { + "name": "trigger_capability", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_actor_company_id": { + "name": "trigger_actor_company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prior_issue_status": { + "name": "prior_issue_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prior_status_version": { + "name": "prior_status_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "prior_decision_id": { + "name": "prior_decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "policy_version": { + "name": "policy_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assessment_json": { + "name": "assessment_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "input_digest": { + "name": "input_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "supersedes_assessment_id": { + "name": "supersedes_assessment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_assessments_company_issue_input_uq": { + "name": "work_assessments_company_issue_input_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "input_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "work_assessments_company_id_companies_id_fk": { + "name": "work_assessments_company_id_companies_id_fk", + "tableFrom": "work_assessments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_assessments_trigger_actor_company_id_companies_id_fk": { + "name": "work_assessments_trigger_actor_company_id_companies_id_fk", + "tableFrom": "work_assessments", + "tableTo": "companies", + "columnsFrom": [ + "trigger_actor_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_assessments_issue_company_fk": { + "name": "work_assessments_issue_company_fk", + "tableFrom": "work_assessments", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_assessments_run_owner_fk": { + "name": "work_assessments_run_owner_fk", + "tableFrom": "work_assessments", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id" + ], + "columnsTo": [ + "company_id", + "native_issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_assessments_contract_owner_fk": { + "name": "work_assessments_contract_owner_fk", + "tableFrom": "work_assessments", + "tableTo": "completion_contracts", + "columnsFrom": [ + "company_id", + "issue_id", + "contract_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_assessments_result_owner_fk": { + "name": "work_assessments_result_owner_fk", + "tableFrom": "work_assessments", + "tableTo": "native_run_results", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "result_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "run_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_assessments_supersedes_owner_fk": { + "name": "work_assessments_supersedes_owner_fk", + "tableFrom": "work_assessments", + "tableTo": "work_assessments", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "supersedes_assessment_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "run_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "work_assessments_company_issue_run_id_uq": { + "name": "work_assessments_company_issue_run_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "issue_id", + "run_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "work_assessments_trigger_actor_company_check": { + "name": "work_assessments_trigger_actor_company_check", + "value": "\"work_assessments\".\"trigger_actor_company_id\" = \"work_assessments\".\"company_id\"" + } + }, + "isRLSEnabled": false + }, "public.workspace_operations": { "name": "workspace_operations", "schema": "", @@ -39694,4 +41266,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/packages/db/src/migrations/meta/0228_snapshot.json b/packages/db/src/migrations/meta/0228_snapshot.json new file mode 100644 index 00000000000..e16c85669d1 --- /dev/null +++ b/packages/db/src/migrations/meta/0228_snapshot.json @@ -0,0 +1,41344 @@ +{ + "id": "d5c905e0-8ef9-4ae1-b332-5bef58b504d8", + "prevId": "11d55aa5-6b96-4b7b-bca2-52d827ed8f61", + "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 + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "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_company_agent_created_idx": { + "name": "activity_log_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": {} + }, + "activity_log_company_responsible_user_created_idx": { + "name": "activity_log_company_responsible_user_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "responsible_user_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.adapter_auth_sessions": { + "name": "adapter_auth_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 + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_session_id": { + "name": "public_session_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "provider_lease_id": { + "name": "provider_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'starting'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "promotion_expires_at": { + "name": "promotion_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bound_at": { + "name": "bound_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "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": { + "adapter_auth_sessions_company_status_idx": { + "name": "adapter_auth_sessions_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": {} + }, + "adapter_auth_sessions_company_owner_adapter_active_uq": { + "name": "adapter_auth_sessions_company_owner_adapter_active_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"adapter_auth_sessions\".\"status\" IN ('starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "adapter_auth_sessions_public_session_id_uq": { + "name": "adapter_auth_sessions_public_session_id_uq", + "columns": [ + { + "expression": "public_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "adapter_auth_sessions_environment_idx": { + "name": "adapter_auth_sessions_environment_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "adapter_auth_sessions_expires_idx": { + "name": "adapter_auth_sessions_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "adapter_auth_sessions_provider_lease_idx": { + "name": "adapter_auth_sessions_provider_lease_idx", + "columns": [ + { + "expression": "provider_lease_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "adapter_auth_sessions_company_id_companies_id_fk": { + "name": "adapter_auth_sessions_company_id_companies_id_fk", + "tableFrom": "adapter_auth_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "adapter_auth_sessions_environment_id_environments_id_fk": { + "name": "adapter_auth_sessions_environment_id_environments_id_fk", + "tableFrom": "adapter_auth_sessions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope_config": { + "name": "scope_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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_memberships": { + "name": "agent_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 + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'joined'" + }, + "starred_at": { + "name": "starred_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": { + "agent_memberships_company_user_idx": { + "name": "agent_memberships_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": {} + }, + "agent_memberships_company_user_starred_idx": { + "name": "agent_memberships_company_user_starred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_memberships_agent_idx": { + "name": "agent_memberships_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_memberships_company_user_agent_uq": { + "name": "agent_memberships_company_user_agent_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_memberships_company_id_companies_id_fk": { + "name": "agent_memberships_company_id_companies_id_fk", + "tableFrom": "agent_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_memberships_agent_id_agents_id_fk": { + "name": "agent_memberships_agent_id_agents_id_fk", + "tableFrom": "agent_memberships", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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": {} + }, + "agent_wakeup_requests_review_path_recovery_idempotency_uq": { + "name": "agent_wakeup_requests_review_path_recovery_idempotency_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'issue_review_path_lost:%' AND \"agent_wakeup_requests\".\"status\" <> 'skipped'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_disposition_repair_idempotency_uq": { + "name": "agent_wakeup_requests_disposition_repair_idempotency_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'issue_disposition_repair:%' AND \"agent_wakeup_requests\".\"status\" <> 'skipped'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_company_payload_issue_idx": { + "name": "agent_wakeup_requests_company_payload_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"payload\" ->> 'issueId')", + "asc": true, + "isExpression": 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" + }, + "default_environment_id": { + "name": "default_environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_reason": { + "name": "error_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "agents_company_default_environment_idx": { + "name": "agents_company_default_environment_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "default_environment_id", + "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" + }, + "agents_default_environment_id_environments_id_fk": { + "name": "agents_default_environment_id_environments_id_fk", + "tableFrom": "agents", + "tableTo": "environments", + "columnsFrom": [ + "default_environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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.board_api_keys": { + "name": "board_api_keys", + "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 + }, + "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 + }, + "expires_at": { + "name": "expires_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": { + "board_api_keys_key_hash_idx": { + "name": "board_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_api_keys_user_idx": { + "name": "board_api_keys_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "board_api_keys_user_id_user_id_fk": { + "name": "board_api_keys_user_id_user_id_fk", + "tableFrom": "board_api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_incidents": { + "name": "budget_incidents", + "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 + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_kind": { + "name": "window_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "threshold_type": { + "name": "threshold_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_limit": { + "name": "amount_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_observed": { + "name": "amount_observed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "budget_incidents_company_status_idx": { + "name": "budget_incidents_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": {} + }, + "budget_incidents_company_scope_idx": { + "name": "budget_incidents_company_scope_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_incidents_policy_window_threshold_idx": { + "name": "budget_incidents_policy_window_threshold_idx", + "columns": [ + { + "expression": "policy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "threshold_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"budget_incidents\".\"status\" <> 'dismissed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_incidents_company_id_companies_id_fk": { + "name": "budget_incidents_company_id_companies_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budget_incidents_policy_id_budget_policies_id_fk": { + "name": "budget_incidents_policy_id_budget_policies_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "budget_policies", + "columnsFrom": [ + "policy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budget_incidents_approval_id_approvals_id_fk": { + "name": "budget_incidents_approval_id_approvals_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_policies": { + "name": "budget_policies", + "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 + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'billed_cents'" + }, + "window_kind": { + "name": "window_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "warn_percent": { + "name": "warn_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 80 + }, + "hard_stop_enabled": { + "name": "hard_stop_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_enabled": { + "name": "notify_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_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": { + "budget_policies_company_scope_active_idx": { + "name": "budget_policies_company_scope_active_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_policies_company_window_idx": { + "name": "budget_policies_company_window_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_policies_company_scope_metric_unique_idx": { + "name": "budget_policies_company_scope_metric_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_policies_company_id_companies_id_fk": { + "name": "budget_policies_company_id_companies_id_fk", + "tableFrom": "budget_policies", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.built_in_managed_resources": { + "name": "built_in_managed_resources", + "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 + }, + "bundle_key": { + "name": "bundle_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_kind": { + "name": "resource_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stock_version": { + "name": "stock_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stock_hash": { + "name": "stock_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "defaults_json": { + "name": "defaults_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "built_in_managed_resources_company_idx": { + "name": "built_in_managed_resources_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "built_in_managed_resources_resource_idx": { + "name": "built_in_managed_resources_resource_idx", + "columns": [ + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "built_in_managed_resources_company_bundle_resource_uq": { + "name": "built_in_managed_resources_company_bundle_resource_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bundle_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "built_in_managed_resources_company_id_companies_id_fk": { + "name": "built_in_managed_resources_company_id_companies_id_fk", + "tableFrom": "built_in_managed_resources", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.case_attachments": { + "name": "case_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 + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_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": { + "case_attachments_company_case_idx": { + "name": "case_attachments_company_case_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "case_attachments_asset_uq": { + "name": "case_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "case_attachments_company_id_companies_id_fk": { + "name": "case_attachments_company_id_companies_id_fk", + "tableFrom": "case_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "case_attachments_case_id_cases_id_fk": { + "name": "case_attachments_case_id_cases_id_fk", + "tableFrom": "case_attachments", + "tableTo": "cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "case_attachments_asset_id_assets_id_fk": { + "name": "case_attachments_asset_id_assets_id_fk", + "tableFrom": "case_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.case_documents": { + "name": "case_documents", + "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 + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "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": { + "case_documents_company_case_key_uq": { + "name": "case_documents_company_case_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "case_documents_document_uq": { + "name": "case_documents_document_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "case_documents_company_case_updated_idx": { + "name": "case_documents_company_case_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "case_documents_company_id_companies_id_fk": { + "name": "case_documents_company_id_companies_id_fk", + "tableFrom": "case_documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "case_documents_case_id_cases_id_fk": { + "name": "case_documents_case_id_cases_id_fk", + "tableFrom": "case_documents", + "tableTo": "cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "case_documents_document_id_documents_id_fk": { + "name": "case_documents_document_id_documents_id_fk", + "tableFrom": "case_documents", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.case_events": { + "name": "case_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 + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_agent_id": { + "name": "actor_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "case_events_case_created_idx": { + "name": "case_events_case_created_idx", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "case_events_company_case_idx": { + "name": "case_events_company_case_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "case_events_company_id_companies_id_fk": { + "name": "case_events_company_id_companies_id_fk", + "tableFrom": "case_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "case_events_case_id_cases_id_fk": { + "name": "case_events_case_id_cases_id_fk", + "tableFrom": "case_events", + "tableTo": "cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "case_events_actor_agent_id_agents_id_fk": { + "name": "case_events_actor_agent_id_agents_id_fk", + "tableFrom": "case_events", + "tableTo": "agents", + "columnsFrom": [ + "actor_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "case_events_kind_check": { + "name": "case_events_kind_check", + "value": "\"case_events\".\"kind\" in (\n 'created',\n 'updated',\n 'fields_changed',\n 'status_changed',\n 'issue_linked',\n 'issue_unlinked',\n 'document_revised',\n 'child_linked',\n 'attachment_added',\n 'label_added',\n 'label_removed'\n )" + }, + "case_events_actor_type_check": { + "name": "case_events_actor_type_check", + "value": "\"case_events\".\"actor_type\" in ('user', 'agent', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.case_issue_links": { + "name": "case_issue_links", + "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 + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_run_id": { + "name": "created_by_run_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": { + "case_issue_links_case_issue_uq": { + "name": "case_issue_links_case_issue_uq", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "case_issue_links_company_case_idx": { + "name": "case_issue_links_company_case_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "case_issue_links_issue_idx": { + "name": "case_issue_links_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "case_issue_links_company_id_companies_id_fk": { + "name": "case_issue_links_company_id_companies_id_fk", + "tableFrom": "case_issue_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "case_issue_links_case_id_cases_id_fk": { + "name": "case_issue_links_case_id_cases_id_fk", + "tableFrom": "case_issue_links", + "tableTo": "cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "case_issue_links_issue_id_issues_id_fk": { + "name": "case_issue_links_issue_id_issues_id_fk", + "tableFrom": "case_issue_links", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "case_issue_links_role_check": { + "name": "case_issue_links_role_check", + "value": "\"case_issue_links\".\"role\" in ('origin', 'work', 'reference')" + } + }, + "isRLSEnabled": false + }, + "public.case_labels": { + "name": "case_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 + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_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": { + "case_labels_case_label_uq": { + "name": "case_labels_case_label_uq", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "case_labels_company_case_idx": { + "name": "case_labels_company_case_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "case_labels_label_idx": { + "name": "case_labels_label_idx", + "columns": [ + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "case_labels_company_id_companies_id_fk": { + "name": "case_labels_company_id_companies_id_fk", + "tableFrom": "case_labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "case_labels_case_id_cases_id_fk": { + "name": "case_labels_case_id_cases_id_fk", + "tableFrom": "case_labels", + "tableTo": "cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "case_labels_label_id_labels_id_fk": { + "name": "case_labels_label_id_labels_id_fk", + "tableFrom": "case_labels", + "tableTo": "labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cases": { + "name": "cases", + "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 + }, + "case_number": { + "name": "case_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "case_type": { + "name": "case_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "parent_case_id": { + "name": "parent_case_id", + "type": "uuid", + "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 + }, + "completed_at": { + "name": "completed_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": { + "cases_company_case_number_uq": { + "name": "cases_company_case_number_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cases_identifier_uq": { + "name": "cases_identifier_uq", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cases_company_type_key_uq": { + "name": "cases_company_type_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cases_company_status_idx": { + "name": "cases_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": {} + }, + "cases_company_type_idx": { + "name": "cases_company_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cases_company_project_idx": { + "name": "cases_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": {} + }, + "cases_parent_idx": { + "name": "cases_parent_idx", + "columns": [ + { + "expression": "parent_case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cases_title_search_idx": { + "name": "cases_title_search_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "cases_identifier_search_idx": { + "name": "cases_identifier_search_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "cases_summary_search_idx": { + "name": "cases_summary_search_idx", + "columns": [ + { + "expression": "summary", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "cases_company_id_companies_id_fk": { + "name": "cases_company_id_companies_id_fk", + "tableFrom": "cases", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cases_project_id_projects_id_fk": { + "name": "cases_project_id_projects_id_fk", + "tableFrom": "cases", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cases_parent_case_id_cases_id_fk": { + "name": "cases_parent_case_id_cases_id_fk", + "tableFrom": "cases", + "tableTo": "cases", + "columnsFrom": [ + "parent_case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cases_created_by_agent_id_agents_id_fk": { + "name": "cases_created_by_agent_id_agents_id_fk", + "tableFrom": "cases", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cases_status_check": { + "name": "cases_status_check", + "value": "\"cases\".\"status\" in ('draft', 'in_progress', 'in_review', 'approved', 'done', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.cli_auth_challenges": { + "name": "cli_auth_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_access": { + "name": "requested_access", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'board'" + }, + "requested_company_id": { + "name": "requested_company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pending_key_hash": { + "name": "pending_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pending_key_name": { + "name": "pending_key_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "board_api_key_id": { + "name": "board_api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cli_auth_challenges_secret_hash_idx": { + "name": "cli_auth_challenges_secret_hash_idx", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cli_auth_challenges_approved_by_idx": { + "name": "cli_auth_challenges_approved_by_idx", + "columns": [ + { + "expression": "approved_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cli_auth_challenges_requested_company_idx": { + "name": "cli_auth_challenges_requested_company_idx", + "columns": [ + { + "expression": "requested_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cli_auth_challenges_requested_company_id_companies_id_fk": { + "name": "cli_auth_challenges_requested_company_id_companies_id_fk", + "tableFrom": "cli_auth_challenges", + "tableTo": "companies", + "columnsFrom": [ + "requested_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_auth_challenges_approved_by_user_id_user_id_fk": { + "name": "cli_auth_challenges_approved_by_user_id_user_id_fk", + "tableFrom": "cli_auth_challenges", + "tableTo": "user", + "columnsFrom": [ + "approved_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk": { + "name": "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk", + "tableFrom": "cli_auth_challenges", + "tableTo": "board_api_keys", + "columnsFrom": [ + "board_api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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'" + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "attachment_max_bytes": { + "name": "attachment_max_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10485760 + }, + "default_responsible_user_id": { + "name": "default_responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "interaction_resolver_governance": { + "name": "interaction_resolver_governance", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "feedback_data_sharing_enabled": { + "name": "feedback_data_sharing_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feedback_data_sharing_consent_at": { + "name": "feedback_data_sharing_consent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "feedback_data_sharing_consent_by_user_id": { + "name": "feedback_data_sharing_consent_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feedback_data_sharing_terms_version": { + "name": "feedback_data_sharing_terms_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_logos": { + "name": "company_logos", + "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 + }, + "asset_id": { + "name": "asset_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": { + "company_logos_company_uq": { + "name": "company_logos_company_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_logos_asset_uq": { + "name": "company_logos_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_logos_company_id_companies_id_fk": { + "name": "company_logos_company_id_companies_id_fk", + "tableFrom": "company_logos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_logos_asset_id_assets_id_fk": { + "name": "company_logos_asset_id_assets_id_fk", + "tableFrom": "company_logos", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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_onboarding_seeds": { + "name": "company_onboarding_seeds", + "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 + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mission": { + "name": "mission", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_role": { + "name": "agent_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_task_title": { + "name": "first_task_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_task_details": { + "name": "first_task_details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_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": { + "company_onboarding_seeds_company_uq": { + "name": "company_onboarding_seeds_company_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_onboarding_seeds_company_id_companies_id_fk": { + "name": "company_onboarding_seeds_company_id_companies_id_fk", + "tableFrom": "company_onboarding_seeds", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_onboarding_seeds_goal_id_goals_id_fk": { + "name": "company_onboarding_seeds_goal_id_goals_id_fk", + "tableFrom": "company_onboarding_seeds", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_onboarding_seeds_agent_id_agents_id_fk": { + "name": "company_onboarding_seeds_agent_id_agents_id_fk", + "tableFrom": "company_onboarding_seeds", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_onboarding_seeds_issue_id_issues_id_fk": { + "name": "company_onboarding_seeds_issue_id_issues_id_fk", + "tableFrom": "company_onboarding_seeds", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_bindings": { + "name": "company_secret_bindings", + "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 + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_path": { + "name": "config_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_selector": { + "name": "version_selector", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'latest'" + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "projection_class": { + "name": "projection_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unclassified'" + }, + "projection_allowlist_key": { + "name": "projection_allowlist_key", + "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_secret_bindings_company_idx": { + "name": "company_secret_bindings_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_bindings_secret_idx": { + "name": "company_secret_bindings_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_bindings_target_idx": { + "name": "company_secret_bindings_target_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_bindings_target_path_uq": { + "name": "company_secret_bindings_target_path_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "config_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_bindings_company_id_companies_id_fk": { + "name": "company_secret_bindings_company_id_companies_id_fk", + "tableFrom": "company_secret_bindings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secret_bindings_secret_id_company_secrets_id_fk": { + "name": "company_secret_bindings_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_bindings", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_proposals": { + "name": "company_secret_proposals", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "proposed_name": { + "name": "proposed_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proposed_key": { + "name": "proposed_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proposed_description": { + "name": "proposed_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "justification": { + "name": "justification", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_ciphertext": { + "name": "value_ciphertext", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "value_fingerprint_sha256": { + "name": "value_fingerprint_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value_length": { + "name": "value_length", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "secret_proposal_id": { + "name": "secret_proposal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "config_path": { + "name": "config_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "projection_class": { + "name": "projection_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unclassified'" + }, + "binding_target_policy_snapshot": { + "name": "binding_target_policy_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proposer_ancestor_ids_snapshot": { + "name": "proposer_ancestor_ids_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target_ancestor_ids_snapshot": { + "name": "target_ancestor_ids_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proposed_by_agent_id": { + "name": "proposed_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "origin_issue_id": { + "name": "origin_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "interaction_id": { + "name": "interaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_user_id": { + "name": "resolved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_reason": { + "name": "resolution_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_secret_id": { + "name": "created_secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "applied_binding_config_path": { + "name": "applied_binding_config_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ciphertext_scrubbed_at": { + "name": "ciphertext_scrubbed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_secret_proposals_company_status_idx": { + "name": "company_secret_proposals_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": {} + }, + "company_secret_proposals_proposer_status_idx": { + "name": "company_secret_proposals_proposer_status_idx", + "columns": [ + { + "expression": "proposed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_proposals_expiry_idx": { + "name": "company_secret_proposals_expiry_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_proposals_secret_proposal_idx": { + "name": "company_secret_proposals_secret_proposal_idx", + "columns": [ + { + "expression": "secret_proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_proposals_interaction_idx": { + "name": "company_secret_proposals_interaction_idx", + "columns": [ + { + "expression": "interaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_proposals_company_id_companies_id_fk": { + "name": "company_secret_proposals_company_id_companies_id_fk", + "tableFrom": "company_secret_proposals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secret_proposals_secret_id_company_secrets_id_fk": { + "name": "company_secret_proposals_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_proposals", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_secret_proposals_secret_proposal_id_company_secret_proposals_id_fk": { + "name": "company_secret_proposals_secret_proposal_id_company_secret_proposals_id_fk", + "tableFrom": "company_secret_proposals", + "tableTo": "company_secret_proposals", + "columnsFrom": [ + "secret_proposal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_proposals_target_id_agents_id_fk": { + "name": "company_secret_proposals_target_id_agents_id_fk", + "tableFrom": "company_secret_proposals", + "tableTo": "agents", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_proposals_proposed_by_agent_id_agents_id_fk": { + "name": "company_secret_proposals_proposed_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_proposals", + "tableTo": "agents", + "columnsFrom": [ + "proposed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_proposals_origin_issue_id_issues_id_fk": { + "name": "company_secret_proposals_origin_issue_id_issues_id_fk", + "tableFrom": "company_secret_proposals", + "tableTo": "issues", + "columnsFrom": [ + "origin_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_secret_proposals_origin_run_id_heartbeat_runs_id_fk": { + "name": "company_secret_proposals_origin_run_id_heartbeat_runs_id_fk", + "tableFrom": "company_secret_proposals", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "origin_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_proposals_interaction_id_issue_thread_interactions_id_fk": { + "name": "company_secret_proposals_interaction_id_issue_thread_interactions_id_fk", + "tableFrom": "company_secret_proposals", + "tableTo": "issue_thread_interactions", + "columnsFrom": [ + "interaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_secret_proposals_created_secret_id_company_secrets_id_fk": { + "name": "company_secret_proposals_created_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_proposals", + "tableTo": "company_secrets", + "columnsFrom": [ + "created_secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "company_secret_proposals_kind_check": { + "name": "company_secret_proposals_kind_check", + "value": "\"company_secret_proposals\".\"kind\" in ('secret', 'binding')" + }, + "company_secret_proposals_status_check": { + "name": "company_secret_proposals_status_check", + "value": "\"company_secret_proposals\".\"status\" in ('pending', 'approved', 'rejected', 'withdrawn', 'expired')" + }, + "company_secret_proposals_projection_check": { + "name": "company_secret_proposals_projection_check", + "value": "\"company_secret_proposals\".\"projection_class\" = 'unclassified'" + }, + "company_secret_proposals_shape_check": { + "name": "company_secret_proposals_shape_check", + "value": "(\n \"company_secret_proposals\".\"kind\" = 'secret'\n and \"company_secret_proposals\".\"proposed_name\" is not null\n and \"company_secret_proposals\".\"proposed_key\" is not null\n and \"company_secret_proposals\".\"secret_id\" is null\n and \"company_secret_proposals\".\"secret_proposal_id\" is null\n and \"company_secret_proposals\".\"target_type\" is null\n and \"company_secret_proposals\".\"target_id\" is null\n and \"company_secret_proposals\".\"config_path\" is null\n ) or (\n \"company_secret_proposals\".\"kind\" = 'binding'\n and ((\"company_secret_proposals\".\"secret_id\" is not null)::int + (\"company_secret_proposals\".\"secret_proposal_id\" is not null)::int) = 1\n and \"company_secret_proposals\".\"target_type\" = 'agent'\n and \"company_secret_proposals\".\"target_id\" is not null\n and \"company_secret_proposals\".\"config_path\" is not null\n )" + } + }, + "isRLSEnabled": false + }, + "public.company_secret_provider_configs": { + "name": "company_secret_provider_configs", + "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 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "health_checked_at": { + "name": "health_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "health_message": { + "name": "health_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "health_details": { + "name": "health_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_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 + }, + "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_secret_provider_configs_company_idx": { + "name": "company_secret_provider_configs_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_provider_configs_company_provider_idx": { + "name": "company_secret_provider_configs_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_secret_provider_configs_default_uq": { + "name": "company_secret_provider_configs_default_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"company_secret_provider_configs\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_provider_configs_company_id_companies_id_fk": { + "name": "company_secret_provider_configs_company_id_companies_id_fk", + "tableFrom": "company_secret_provider_configs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_provider_configs_created_by_agent_id_agents_id_fk": { + "name": "company_secret_provider_configs_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_provider_configs", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + }, + "provider_version_ref": { + "name": "provider_version_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'current'" + }, + "fingerprint_sha256": { + "name": "fingerprint_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rotation_job_id": { + "name": "rotation_job_id", + "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()" + }, + "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_fingerprint_idx": { + "name": "company_secret_versions_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint_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 + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company'" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_secret_definition_id": { + "name": "user_secret_definition_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "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'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "managed_mode": { + "name": "managed_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip_managed'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config_id": { + "name": "provider_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "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 + }, + "last_resolved_at": { + "name": "last_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_rotated_at": { + "name": "last_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_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 + }, + "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_scope_idx": { + "name": "company_secrets_company_scope_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_owner_idx": { + "name": "company_secrets_company_owner_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_user_definition_owner_idx": { + "name": "company_secrets_user_definition_owner_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_secret_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_user_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_provider_config_idx": { + "name": "company_secrets_provider_config_idx", + "columns": [ + { + "expression": "provider_config_id", + "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, + "where": "\"company_secrets\".\"scope\" = 'company' and \"company_secrets\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_key_uq": { + "name": "company_secrets_company_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"company_secrets\".\"scope\" = 'company' and \"company_secrets\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_user_definition_owner_uq": { + "name": "company_secrets_user_definition_owner_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_secret_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"company_secrets\".\"scope\" = 'user' and \"company_secrets\".\"deleted_at\" is null", + "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_user_secret_definition_id_user_secret_definitions_id_fk": { + "name": "company_secrets_user_secret_definition_id_user_secret_definitions_id_fk", + "tableFrom": "company_secrets", + "tableTo": "user_secret_definitions", + "columnsFrom": [ + "user_secret_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_secrets_provider_config_id_company_secret_provider_configs_id_fk": { + "name": "company_secrets_provider_config_id_company_secret_provider_configs_id_fk", + "tableFrom": "company_secrets", + "tableTo": "company_secret_provider_configs", + "columnsFrom": [ + "provider_config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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": { + "company_secrets_scope_shape_check": { + "name": "company_secrets_scope_shape_check", + "value": "(\n \"company_secrets\".\"scope\" = 'company'\n and \"company_secrets\".\"owner_user_id\" is null\n and \"company_secrets\".\"user_secret_definition_id\" is null\n ) or (\n \"company_secrets\".\"scope\" = 'user'\n and \"company_secrets\".\"owner_user_id\" is not null\n and \"company_secrets\".\"user_secret_definition_id\" is not null\n )" + } + }, + "isRLSEnabled": false + }, + "public.company_skill_policies": { + "name": "company_skill_policies", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "default_effect": { + "name": "default_effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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": {}, + "foreignKeys": { + "company_skill_policies_company_id_companies_id_fk": { + "name": "company_skill_policies_company_id_companies_id_fk", + "tableFrom": "company_skill_policies", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skill_comments": { + "name": "company_skill_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 + }, + "company_skill_id": { + "name": "company_skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "deleted_at": { + "name": "deleted_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": { + "company_skill_comments_company_skill_created_idx": { + "name": "company_skill_comments_company_skill_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_comments_parent_idx": { + "name": "company_skill_comments_parent_idx", + "columns": [ + { + "expression": "parent_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_comments_company_id_companies_id_fk": { + "name": "company_skill_comments_company_id_companies_id_fk", + "tableFrom": "company_skill_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_comments_company_skill_id_company_skills_id_fk": { + "name": "company_skill_comments_company_skill_id_company_skills_id_fk", + "tableFrom": "company_skill_comments", + "tableTo": "company_skills", + "columnsFrom": [ + "company_skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_comments_parent_comment_id_company_skill_comments_id_fk": { + "name": "company_skill_comments_parent_comment_id_company_skill_comments_id_fk", + "tableFrom": "company_skill_comments", + "tableTo": "company_skill_comments", + "columnsFrom": [ + "parent_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_skill_comments_author_agent_id_agents_id_fk": { + "name": "company_skill_comments_author_agent_id_agents_id_fk", + "tableFrom": "company_skill_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skill_stars": { + "name": "company_skill_stars", + "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 + }, + "company_skill_id": { + "name": "company_skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "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": { + "company_skill_stars_skill_agent_idx": { + "name": "company_skill_stars_skill_agent_idx", + "columns": [ + { + "expression": "company_skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_stars_skill_user_idx": { + "name": "company_skill_stars_skill_user_idx", + "columns": [ + { + "expression": "company_skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_stars_company_skill_created_idx": { + "name": "company_skill_stars_company_skill_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_stars_company_id_companies_id_fk": { + "name": "company_skill_stars_company_id_companies_id_fk", + "tableFrom": "company_skill_stars", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_stars_company_skill_id_company_skills_id_fk": { + "name": "company_skill_stars_company_skill_id_company_skills_id_fk", + "tableFrom": "company_skill_stars", + "tableTo": "company_skills", + "columnsFrom": [ + "company_skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_stars_agent_id_agents_id_fk": { + "name": "company_skill_stars_agent_id_agents_id_fk", + "tableFrom": "company_skill_stars", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skill_test_inputs": { + "name": "company_skill_test_inputs", + "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 + }, + "skill_id": { + "name": "skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_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": { + "company_skill_test_inputs_company_skill_name_idx": { + "name": "company_skill_test_inputs_company_skill_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_test_inputs_company_skill_active_idx": { + "name": "company_skill_test_inputs_company_skill_active_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_test_inputs_company_id_companies_id_fk": { + "name": "company_skill_test_inputs_company_id_companies_id_fk", + "tableFrom": "company_skill_test_inputs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_test_inputs_skill_id_company_skills_id_fk": { + "name": "company_skill_test_inputs_skill_id_company_skills_id_fk", + "tableFrom": "company_skill_test_inputs", + "tableTo": "company_skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skill_test_run_templates": { + "name": "company_skill_test_run_templates", + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "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 + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_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": { + "company_skill_test_run_templates_company_active_idx": { + "name": "company_skill_test_run_templates_company_active_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_test_run_templates_company_id_companies_id_fk": { + "name": "company_skill_test_run_templates_company_id_companies_id_fk", + "tableFrom": "company_skill_test_run_templates", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_test_run_templates_created_by_agent_id_agents_id_fk": { + "name": "company_skill_test_run_templates_created_by_agent_id_agents_id_fk", + "tableFrom": "company_skill_test_run_templates", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_skill_test_run_templates_updated_by_agent_id_agents_id_fk": { + "name": "company_skill_test_run_templates_updated_by_agent_id_agents_id_fk", + "tableFrom": "company_skill_test_run_templates", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skill_test_runs": { + "name": "company_skill_test_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 + }, + "skill_id": { + "name": "skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "input_id": { + "name": "input_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "input_snapshot": { + "name": "input_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "skill_version_id": { + "name": "skill_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_config_snapshot": { + "name": "agent_config_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "template_name": { + "name": "template_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "template_body": { + "name": "template_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rendered_template_body": { + "name": "rendered_template_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness_issue_description": { + "name": "harness_issue_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "output_document_key": { + "name": "output_document_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'output'" + }, + "output_snapshot": { + "name": "output_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "harness_issue_expires_at": { + "name": "harness_issue_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "harness_issue_deleted_at": { + "name": "harness_issue_deleted_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": { + "company_skill_test_runs_company_skill_created_idx": { + "name": "company_skill_test_runs_company_skill_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_test_runs_company_issue_idx": { + "name": "company_skill_test_runs_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_test_runs_company_input_created_idx": { + "name": "company_skill_test_runs_company_input_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "input_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_test_runs_company_status_idx": { + "name": "company_skill_test_runs_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": {} + }, + "company_skill_test_runs_company_harness_expires_idx": { + "name": "company_skill_test_runs_company_harness_expires_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "harness_issue_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_test_runs_company_id_companies_id_fk": { + "name": "company_skill_test_runs_company_id_companies_id_fk", + "tableFrom": "company_skill_test_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_test_runs_skill_id_company_skills_id_fk": { + "name": "company_skill_test_runs_skill_id_company_skills_id_fk", + "tableFrom": "company_skill_test_runs", + "tableTo": "company_skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_test_runs_input_id_company_skill_test_inputs_id_fk": { + "name": "company_skill_test_runs_input_id_company_skill_test_inputs_id_fk", + "tableFrom": "company_skill_test_runs", + "tableTo": "company_skill_test_inputs", + "columnsFrom": [ + "input_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_skill_test_runs_skill_version_id_company_skill_versions_id_fk": { + "name": "company_skill_test_runs_skill_version_id_company_skill_versions_id_fk", + "tableFrom": "company_skill_test_runs", + "tableTo": "company_skill_versions", + "columnsFrom": [ + "skill_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "company_skill_test_runs_agent_id_agents_id_fk": { + "name": "company_skill_test_runs_agent_id_agents_id_fk", + "tableFrom": "company_skill_test_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "company_skill_test_runs_issue_id_issues_id_fk": { + "name": "company_skill_test_runs_issue_id_issues_id_fk", + "tableFrom": "company_skill_test_runs", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skill_versions": { + "name": "company_skill_versions", + "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 + }, + "company_skill_id": { + "name": "company_skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_name": { + "name": "release_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "file_inventory": { + "name": "file_inventory", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_skill_versions_skill_revision_idx": { + "name": "company_skill_versions_skill_revision_idx", + "columns": [ + { + "expression": "company_skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_versions_skill_release_idx": { + "name": "company_skill_versions_skill_release_idx", + "columns": [ + { + "expression": "company_skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "release_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"company_skill_versions\".\"release_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_versions_company_skill_created_idx": { + "name": "company_skill_versions_company_skill_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_versions_company_id_companies_id_fk": { + "name": "company_skill_versions_company_id_companies_id_fk", + "tableFrom": "company_skill_versions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_versions_company_skill_id_company_skills_id_fk": { + "name": "company_skill_versions_company_skill_id_company_skills_id_fk", + "tableFrom": "company_skill_versions", + "tableTo": "company_skills", + "columnsFrom": [ + "company_skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_versions_author_agent_id_agents_id_fk": { + "name": "company_skill_versions_author_agent_id_agents_id_fk", + "tableFrom": "company_skill_versions", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skills": { + "name": "company_skills", + "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 + }, + "folder_id": { + "name": "folder_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_path'" + }, + "source_locator": { + "name": "source_locator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trust_level": { + "name": "trust_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown_only'" + }, + "compatibility": { + "name": "compatibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'compatible'" + }, + "file_inventory": { + "name": "file_inventory", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "homepage_url": { + "name": "homepage_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "sharing_scope": { + "name": "sharing_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company'" + }, + "public_share_token": { + "name": "public_share_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_skill_id": { + "name": "forked_from_skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "forked_from_company_id": { + "name": "forked_from_company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "star_count": { + "name": "star_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "install_count": { + "name": "install_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fork_count": { + "name": "fork_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "current_version_id": { + "name": "current_version_id", + "type": "uuid", + "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": { + "company_skills_company_key_idx": { + "name": "company_skills_company_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skills_company_name_idx": { + "name": "company_skills_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skills_company_folder_idx": { + "name": "company_skills_company_folder_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skills_company_categories_idx": { + "name": "company_skills_company_categories_idx", + "columns": [ + { + "expression": "categories", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "company_skills_company_sharing_scope_idx": { + "name": "company_skills_company_sharing_scope_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sharing_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skills_company_current_version_idx": { + "name": "company_skills_company_current_version_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "current_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skills_company_forked_from_idx": { + "name": "company_skills_company_forked_from_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "forked_from_skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skills_company_id_companies_id_fk": { + "name": "company_skills_company_id_companies_id_fk", + "tableFrom": "company_skills", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_skills_folder_id_folders_id_fk": { + "name": "company_skills_folder_id_folders_id_fk", + "tableFrom": "company_skills", + "tableTo": "folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_skills_forked_from_skill_id_company_skills_id_fk": { + "name": "company_skills_forked_from_skill_id_company_skills_id_fk", + "tableFrom": "company_skills", + "tableTo": "company_skills", + "columnsFrom": [ + "forked_from_skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_skills_forked_from_company_id_companies_id_fk": { + "name": "company_skills_forked_from_company_id_companies_id_fk", + "tableFrom": "company_skills", + "tableTo": "companies", + "columnsFrom": [ + "forked_from_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_skills_current_version_id_company_skill_versions_id_fk": { + "name": "company_skills_current_version_id_company_skill_versions_id_fk", + "tableFrom": "company_skills", + "tableTo": "company_skill_versions", + "columnsFrom": [ + "current_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_transfer_runs": { + "name": "company_transfer_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": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "actor_key": { + "name": "actor_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_ref": { + "name": "container_ref", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest_sha256": { + "name": "manifest_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "blob_count": { + "name": "blob_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_parts": { + "name": "completed_parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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_transfer_runs_company_idx": { + "name": "company_transfer_runs_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_transfer_runs_idempotency_direction_idx": { + "name": "company_transfer_runs_idempotency_direction_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "direction", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_transfer_runs_actor_status_idx": { + "name": "company_transfer_runs_actor_status_idx", + "columns": [ + { + "expression": "actor_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_transfer_runs_company_id_companies_id_fk": { + "name": "company_transfer_runs_company_id_companies_id_fk", + "tableFrom": "company_transfer_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_user_sidebar_preferences": { + "name": "company_user_sidebar_preferences", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_order": { + "name": "project_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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_user_sidebar_preferences_company_idx": { + "name": "company_user_sidebar_preferences_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_user_idx": { + "name": "company_user_sidebar_preferences_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_user_sidebar_preferences_company_user_uq": { + "name": "company_user_sidebar_preferences_company_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_user_sidebar_preferences_company_id_companies_id_fk": { + "name": "company_user_sidebar_preferences_company_id_companies_id_fk", + "tableFrom": "company_user_sidebar_preferences", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.completion_contracts": { + "name": "completion_contracts", + "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 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy_version": { + "name": "policy_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completion_authority": { + "name": "completion_authority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incomplete_criteria_policy": { + "name": "incomplete_criteria_policy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contract_json": { + "name": "contract_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "canonical_sha256": { + "name": "canonical_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "supersedes_contract_id": { + "name": "supersedes_contract_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "completion_contracts_issue_revision_uq": { + "name": "completion_contracts_issue_revision_uq", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "completion_contracts_issue_hash_uq": { + "name": "completion_contracts_issue_hash_uq", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "canonical_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "completion_contracts_company_id_companies_id_fk": { + "name": "completion_contracts_company_id_companies_id_fk", + "tableFrom": "completion_contracts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "completion_contracts_issue_company_fk": { + "name": "completion_contracts_issue_company_fk", + "tableFrom": "completion_contracts", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "completion_contracts_supersedes_owner_fk": { + "name": "completion_contracts_supersedes_owner_fk", + "tableFrom": "completion_contracts", + "tableTo": "completion_contracts", + "columnsFrom": [ + "company_id", + "issue_id", + "supersedes_contract_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "completion_contracts_company_issue_id_uq": { + "name": "completion_contracts_company_issue_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "issue_id", + "id" + ] + } + }, + "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 + }, + "heartbeat_run_id": { + "name": "heartbeat_run_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 + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "cost_status": { + "name": "cost_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reported'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_input_tokens": { + "name": "cached_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": {} + }, + "cost_events_company_provider_occurred_idx": { + "name": "cost_events_company_provider_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "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_biller_occurred_idx": { + "name": "cost_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "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_heartbeat_run_idx": { + "name": "cost_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "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": "set null", + "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" + }, + "cost_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "cost_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "cost_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.decision_archive_notification_outbox": { + "name": "decision_archive_notification_outbox", + "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 + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archive_version": { + "name": "archive_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "origin_agent_id": { + "name": "origin_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "origin_issue_id": { + "name": "origin_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_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": { + "decision_archive_notification_outbox_uq": { + "name": "decision_archive_notification_outbox_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archive_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decision_archive_notification_outbox_pending_idx": { + "name": "decision_archive_notification_outbox_pending_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "decision_archive_notification_outbox_company_id_companies_id_fk": { + "name": "decision_archive_notification_outbox_company_id_companies_id_fk", + "tableFrom": "decision_archive_notification_outbox", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "decision_archive_notification_outbox_origin_agent_id_agents_id_fk": { + "name": "decision_archive_notification_outbox_origin_agent_id_agents_id_fk", + "tableFrom": "decision_archive_notification_outbox", + "tableTo": "agents", + "columnsFrom": [ + "origin_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "decision_archive_notification_outbox_status_check": { + "name": "decision_archive_notification_outbox_status_check", + "value": "\"decision_archive_notification_outbox\".\"status\" IN ('pending', 'delivering', 'delivered')" + } + }, + "isRLSEnabled": false + }, + "public.decision_queue_items": { + "name": "decision_queue_items", + "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 + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_type": { + "name": "added_by_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_agent_id": { + "name": "added_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "added_by_user_id": { + "name": "added_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by_run_id": { + "name": "added_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "added_by_agent_api_key_id": { + "name": "added_by_agent_api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "responsible_user_id": { + "name": "responsible_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": { + "decision_queue_items_queue_source_uq": { + "name": "decision_queue_items_queue_source_uq", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decision_queue_items_company_source_idx": { + "name": "decision_queue_items_company_source_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "decision_queue_items_company_id_companies_id_fk": { + "name": "decision_queue_items_company_id_companies_id_fk", + "tableFrom": "decision_queue_items", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "decision_queue_items_added_by_agent_id_agents_id_fk": { + "name": "decision_queue_items_added_by_agent_id_agents_id_fk", + "tableFrom": "decision_queue_items", + "tableTo": "agents", + "columnsFrom": [ + "added_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_queue_items_added_by_run_id_heartbeat_runs_id_fk": { + "name": "decision_queue_items_added_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "decision_queue_items", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "added_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_queue_items_added_by_agent_api_key_id_agent_api_keys_id_fk": { + "name": "decision_queue_items_added_by_agent_api_key_id_agent_api_keys_id_fk", + "tableFrom": "decision_queue_items", + "tableTo": "agent_api_keys", + "columnsFrom": [ + "added_by_agent_api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_queue_items_queue_company_fk": { + "name": "decision_queue_items_queue_company_fk", + "tableFrom": "decision_queue_items", + "tableTo": "decision_queues", + "columnsFrom": [ + "queue_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "decision_queue_items_actor_check": { + "name": "decision_queue_items_actor_check", + "value": "(\n (\"decision_queue_items\".\"added_by_type\" = 'agent' AND \"decision_queue_items\".\"added_by_agent_id\" IS NOT NULL AND \"decision_queue_items\".\"added_by_user_id\" IS NULL)\n OR (\"decision_queue_items\".\"added_by_type\" = 'user' AND \"decision_queue_items\".\"added_by_agent_id\" IS NULL AND \"decision_queue_items\".\"added_by_user_id\" IS NOT NULL)\n OR (\"decision_queue_items\".\"added_by_type\" = 'system' AND \"decision_queue_items\".\"added_by_agent_id\" IS NULL AND \"decision_queue_items\".\"added_by_user_id\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.decision_queues": { + "name": "decision_queues", + "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 + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_type": { + "name": "created_by_type", + "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_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_api_key_id": { + "name": "created_by_agent_api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "retention_days": { + "name": "retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seed_rules": { + "name": "seed_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "seed_rules_enabled": { + "name": "seed_rules_enabled", + "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": { + "decision_queues_company_key_uq": { + "name": "decision_queues_company_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decision_queues_company_updated_idx": { + "name": "decision_queues_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": { + "decision_queues_company_id_companies_id_fk": { + "name": "decision_queues_company_id_companies_id_fk", + "tableFrom": "decision_queues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "decision_queues_created_by_agent_id_agents_id_fk": { + "name": "decision_queues_created_by_agent_id_agents_id_fk", + "tableFrom": "decision_queues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_queues_created_by_run_id_heartbeat_runs_id_fk": { + "name": "decision_queues_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "decision_queues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_queues_created_by_agent_api_key_id_agent_api_keys_id_fk": { + "name": "decision_queues_created_by_agent_api_key_id_agent_api_keys_id_fk", + "tableFrom": "decision_queues", + "tableTo": "agent_api_keys", + "columnsFrom": [ + "created_by_agent_api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "decision_queues_id_company_uq": { + "name": "decision_queues_id_company_uq", + "nullsNotDistinct": false, + "columns": [ + "id", + "company_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "decision_queues_creator_check": { + "name": "decision_queues_creator_check", + "value": "(\n (\"decision_queues\".\"created_by_type\" = 'agent' AND \"decision_queues\".\"created_by_agent_id\" IS NOT NULL AND \"decision_queues\".\"created_by_user_id\" IS NULL)\n OR (\"decision_queues\".\"created_by_type\" = 'user' AND \"decision_queues\".\"created_by_agent_id\" IS NULL AND \"decision_queues\".\"created_by_user_id\" IS NOT NULL)\n OR (\"decision_queues\".\"created_by_type\" = 'system' AND \"decision_queues\".\"created_by_agent_id\" IS NULL AND \"decision_queues\".\"created_by_user_id\" IS NULL)\n )" + }, + "decision_queues_retention_days_check": { + "name": "decision_queues_retention_days_check", + "value": "\"decision_queues\".\"retention_days\" IS NULL OR (\"decision_queues\".\"retention_days\" >= 1 AND \"decision_queues\".\"retention_days\" <= 3650)" + } + }, + "isRLSEnabled": false + }, + "public.decision_retention": { + "name": "decision_retention", + "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 + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_activity_at": { + "name": "source_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "keep": { + "name": "keep", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_reason": { + "name": "archived_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_by_type": { + "name": "archived_by_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_by_agent_id": { + "name": "archived_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "archived_by_user_id": { + "name": "archived_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_by_run_id": { + "name": "archived_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "archive_version": { + "name": "archive_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "decision_retention_company_source_uq": { + "name": "decision_retention_company_source_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decision_retention_company_archived_idx": { + "name": "decision_retention_company_archived_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "decision_retention_company_id_companies_id_fk": { + "name": "decision_retention_company_id_companies_id_fk", + "tableFrom": "decision_retention", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "decision_retention_archived_by_agent_id_agents_id_fk": { + "name": "decision_retention_archived_by_agent_id_agents_id_fk", + "tableFrom": "decision_retention", + "tableTo": "agents", + "columnsFrom": [ + "archived_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_retention_archived_by_run_id_heartbeat_runs_id_fk": { + "name": "decision_retention_archived_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "decision_retention", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "archived_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "decision_retention_archive_actor_check": { + "name": "decision_retention_archive_actor_check", + "value": "(\n (\"decision_retention\".\"archived_at\" IS NULL AND \"decision_retention\".\"archived_by_type\" IS NULL AND \"decision_retention\".\"archived_by_agent_id\" IS NULL AND \"decision_retention\".\"archived_by_user_id\" IS NULL)\n OR (\"decision_retention\".\"archived_at\" IS NOT NULL AND \"decision_retention\".\"archived_by_type\" = 'system' AND \"decision_retention\".\"archived_by_agent_id\" IS NULL AND \"decision_retention\".\"archived_by_user_id\" IS NULL)\n OR (\"decision_retention\".\"archived_at\" IS NOT NULL AND \"decision_retention\".\"archived_by_type\" = 'agent' AND \"decision_retention\".\"archived_by_agent_id\" IS NOT NULL AND \"decision_retention\".\"archived_by_user_id\" IS NULL)\n OR (\"decision_retention\".\"archived_at\" IS NOT NULL AND \"decision_retention\".\"archived_by_type\" = 'user' AND \"decision_retention\".\"archived_by_agent_id\" IS NULL AND \"decision_retention\".\"archived_by_user_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.decision_triage": { + "name": "decision_triage", + "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 + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decide_by": { + "name": "decide_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decide_by_date": { + "name": "decide_by_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "snoozed_until": { + "name": "snoozed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "set_by_type": { + "name": "set_by_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "set_by_agent_id": { + "name": "set_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "set_by_user_id": { + "name": "set_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "set_by_run_id": { + "name": "set_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "set_by_agent_api_key_id": { + "name": "set_by_agent_api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": { + "decision_triage_company_source_uq": { + "name": "decision_triage_company_source_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decision_triage_company_decide_by_idx": { + "name": "decision_triage_company_decide_by_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "decide_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "decision_triage_company_id_companies_id_fk": { + "name": "decision_triage_company_id_companies_id_fk", + "tableFrom": "decision_triage", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "decision_triage_set_by_agent_id_agents_id_fk": { + "name": "decision_triage_set_by_agent_id_agents_id_fk", + "tableFrom": "decision_triage", + "tableTo": "agents", + "columnsFrom": [ + "set_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_triage_set_by_run_id_heartbeat_runs_id_fk": { + "name": "decision_triage_set_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "decision_triage", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "set_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_triage_set_by_agent_api_key_id_agent_api_keys_id_fk": { + "name": "decision_triage_set_by_agent_api_key_id_agent_api_keys_id_fk", + "tableFrom": "decision_triage", + "tableTo": "agent_api_keys", + "columnsFrom": [ + "set_by_agent_api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "decision_triage_actor_check": { + "name": "decision_triage_actor_check", + "value": "(\n (\"decision_triage\".\"set_by_type\" = 'agent' AND \"decision_triage\".\"set_by_agent_id\" IS NOT NULL AND \"decision_triage\".\"set_by_user_id\" IS NULL)\n OR (\"decision_triage\".\"set_by_type\" = 'user' AND \"decision_triage\".\"set_by_agent_id\" IS NULL AND \"decision_triage\".\"set_by_user_id\" IS NOT NULL)\n )" + }, + "decision_triage_decide_by_check": { + "name": "decision_triage_decide_by_check", + "value": "(\n (\"decision_triage\".\"decide_by\" IS NULL AND \"decision_triage\".\"decide_by_date\" IS NULL)\n OR (\"decision_triage\".\"decide_by\" IN ('today', 'this_week', 'whenever') AND \"decision_triage\".\"decide_by_date\" IS NULL)\n OR (\"decision_triage\".\"decide_by\" = 'date' AND \"decision_triage\".\"decide_by_date\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.decision_triage_events": { + "name": "decision_triage_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 + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_agent_id": { + "name": "actor_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_run_id": { + "name": "actor_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_api_key_id": { + "name": "agent_api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "decision_triage_events_company_source_created_idx": { + "name": "decision_triage_events_company_source_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decision_triage_events_queue_created_idx": { + "name": "decision_triage_events_queue_created_idx", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "decision_triage_events_company_id_companies_id_fk": { + "name": "decision_triage_events_company_id_companies_id_fk", + "tableFrom": "decision_triage_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "decision_triage_events_queue_id_decision_queues_id_fk": { + "name": "decision_triage_events_queue_id_decision_queues_id_fk", + "tableFrom": "decision_triage_events", + "tableTo": "decision_queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_triage_events_actor_agent_id_agents_id_fk": { + "name": "decision_triage_events_actor_agent_id_agents_id_fk", + "tableFrom": "decision_triage_events", + "tableTo": "agents", + "columnsFrom": [ + "actor_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_triage_events_actor_run_id_heartbeat_runs_id_fk": { + "name": "decision_triage_events_actor_run_id_heartbeat_runs_id_fk", + "tableFrom": "decision_triage_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "actor_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_triage_events_agent_api_key_id_agent_api_keys_id_fk": { + "name": "decision_triage_events_agent_api_key_id_agent_api_keys_id_fk", + "tableFrom": "decision_triage_events", + "tableTo": "agent_api_keys", + "columnsFrom": [ + "agent_api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "decision_triage_events_actor_check": { + "name": "decision_triage_events_actor_check", + "value": "(\n (\"decision_triage_events\".\"actor_type\" = 'agent' AND \"decision_triage_events\".\"actor_agent_id\" IS NOT NULL AND \"decision_triage_events\".\"actor_user_id\" IS NULL)\n OR (\"decision_triage_events\".\"actor_type\" = 'user' AND \"decision_triage_events\".\"actor_agent_id\" IS NULL AND \"decision_triage_events\".\"actor_user_id\" IS NOT NULL)\n OR (\"decision_triage_events\".\"actor_type\" = 'system' AND \"decision_triage_events\".\"actor_agent_id\" IS NULL AND \"decision_triage_events\".\"actor_user_id\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.decision_training_examples": { + "name": "decision_training_examples", + "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 + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cutoff_at": { + "name": "cutoff_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "notes_history": { + "name": "notes_history", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "decision_outcome": { + "name": "decision_outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_policy": { + "name": "retention_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scrub_deleted_comments_v1'" + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "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": { + "decision_training_examples_company_created_at_idx": { + "name": "decision_training_examples_company_created_at_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": {} + }, + "decision_training_examples_issue_idx": { + "name": "decision_training_examples_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decision_training_examples_source_author_uq": { + "name": "decision_training_examples_source_author_uq", + "columns": [ + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "decision_training_examples_company_id_companies_id_fk": { + "name": "decision_training_examples_company_id_companies_id_fk", + "tableFrom": "decision_training_examples", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "decision_training_examples_issue_id_issues_id_fk": { + "name": "decision_training_examples_issue_id_issues_id_fk", + "tableFrom": "decision_training_examples", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.decision_bundles": { + "name": "decision_bundles", + "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 + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_agent_id": { + "name": "origin_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "origin_issue_id": { + "name": "origin_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "decision_bundles_company_created_at_idx": { + "name": "decision_bundles_company_created_at_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": { + "decision_bundles_company_id_companies_id_fk": { + "name": "decision_bundles_company_id_companies_id_fk", + "tableFrom": "decision_bundles", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_bundles_origin_agent_id_agents_id_fk": { + "name": "decision_bundles_origin_agent_id_agents_id_fk", + "tableFrom": "decision_bundles", + "tableTo": "agents", + "columnsFrom": [ + "origin_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_bundles_origin_issue_id_issues_id_fk": { + "name": "decision_bundles_origin_issue_id_issues_id_fk", + "tableFrom": "decision_bundles", + "tableTo": "issues", + "columnsFrom": [ + "origin_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_bundles_origin_run_id_heartbeat_runs_id_fk": { + "name": "decision_bundles_origin_run_id_heartbeat_runs_id_fk", + "tableFrom": "decision_bundles", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "origin_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.decision_effect_executions": { + "name": "decision_effect_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "decision_id": { + "name": "decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effect_index": { + "name": "effect_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "effect_type": { + "name": "effect_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_issue_id": { + "name": "target_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claimed'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activity_log_id": { + "name": "activity_log_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "decision_effect_executions_decision_effect_uq": { + "name": "decision_effect_executions_decision_effect_uq", + "columns": [ + { + "expression": "decision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effect_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decision_effect_executions_target_issue_idx": { + "name": "decision_effect_executions_target_issue_idx", + "columns": [ + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "decision_effect_executions_decision_id_decisions_id_fk": { + "name": "decision_effect_executions_decision_id_decisions_id_fk", + "tableFrom": "decision_effect_executions", + "tableTo": "decisions", + "columnsFrom": [ + "decision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "decision_effect_executions_target_issue_id_issues_id_fk": { + "name": "decision_effect_executions_target_issue_id_issues_id_fk", + "tableFrom": "decision_effect_executions", + "tableTo": "issues", + "columnsFrom": [ + "target_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decision_effect_executions_activity_log_id_activity_log_id_fk": { + "name": "decision_effect_executions_activity_log_id_activity_log_id_fk", + "tableFrom": "decision_effect_executions", + "tableTo": "activity_log", + "columnsFrom": [ + "activity_log_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.decision_target_issues": { + "name": "decision_target_issues", + "schema": "", + "columns": { + "decision_id": { + "name": "decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "decision_target_issues_decision_idx": { + "name": "decision_target_issues_decision_idx", + "columns": [ + { + "expression": "decision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decision_target_issues_issue_idx": { + "name": "decision_target_issues_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "decision_target_issues_decision_id_decisions_id_fk": { + "name": "decision_target_issues_decision_id_decisions_id_fk", + "tableFrom": "decision_target_issues", + "tableTo": "decisions", + "columnsFrom": [ + "decision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "decision_target_issues_issue_id_issues_id_fk": { + "name": "decision_target_issues_issue_id_issues_id_fk", + "tableFrom": "decision_target_issues", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "decision_target_issues_company_id_companies_id_fk": { + "name": "decision_target_issues_company_id_companies_id_fk", + "tableFrom": "decision_target_issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "decision_target_issues_decision_id_issue_id_pk": { + "name": "decision_target_issues_decision_id_issue_id_pk", + "columns": [ + "decision_id", + "issue_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.decisions": { + "name": "decisions", + "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 + }, + "bundle_id": { + "name": "bundle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "origin_agent_id": { + "name": "origin_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "origin_issue_id": { + "name": "origin_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rule_key": { + "name": "rule_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chosen_option_id": { + "name": "chosen_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_values": { + "name": "input_values", + "type": "jsonb", + "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 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signed_spec": { + "name": "signed_spec", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_snapshots": { + "name": "target_snapshots", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "continuation_policy": { + "name": "continuation_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "decisions_company_status_expires_at_idx": { + "name": "decisions_company_status_expires_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decisions_bundle_idx": { + "name": "decisions_bundle_idx", + "columns": [ + { + "expression": "bundle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decisions_origin_issue_idx": { + "name": "decisions_origin_issue_idx", + "columns": [ + { + "expression": "origin_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decisions_company_idempotency_uq": { + "name": "decisions_company_idempotency_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"decisions\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "decisions_company_id_companies_id_fk": { + "name": "decisions_company_id_companies_id_fk", + "tableFrom": "decisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decisions_bundle_id_decision_bundles_id_fk": { + "name": "decisions_bundle_id_decision_bundles_id_fk", + "tableFrom": "decisions", + "tableTo": "decision_bundles", + "columnsFrom": [ + "bundle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "decisions_origin_agent_id_agents_id_fk": { + "name": "decisions_origin_agent_id_agents_id_fk", + "tableFrom": "decisions", + "tableTo": "agents", + "columnsFrom": [ + "origin_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decisions_origin_issue_id_issues_id_fk": { + "name": "decisions_origin_issue_id_issues_id_fk", + "tableFrom": "decisions", + "tableTo": "issues", + "columnsFrom": [ + "origin_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "decisions_origin_run_id_heartbeat_runs_id_fk": { + "name": "decisions_origin_run_id_heartbeat_runs_id_fk", + "tableFrom": "decisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "origin_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_annotation_anchor_snapshots": { + "name": "document_annotation_anchor_snapshots", + "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 + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_revision_id": { + "name": "from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "from_revision_number": { + "name": "from_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "to_revision_id": { + "name": "to_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "to_revision_number": { + "name": "to_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_anchor": { + "name": "previous_anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "next_anchor": { + "name": "next_anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "anchor_state": { + "name": "anchor_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_confidence": { + "name": "anchor_confidence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_annotation_anchor_snapshots_company_thread_created_at_idx": { + "name": "document_annotation_anchor_snapshots_company_thread_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_annotation_anchor_snapshots_company_document_revision_idx": { + "name": "document_annotation_anchor_snapshots_company_document_revision_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_revision_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_annotation_anchor_snapshots_company_id_companies_id_fk": { + "name": "document_annotation_anchor_snapshots_company_id_companies_id_fk", + "tableFrom": "document_annotation_anchor_snapshots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk": { + "name": "document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk", + "tableFrom": "document_annotation_anchor_snapshots", + "tableTo": "document_annotation_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_annotation_anchor_snapshots_document_id_documents_id_fk": { + "name": "document_annotation_anchor_snapshots_document_id_documents_id_fk", + "tableFrom": "document_annotation_anchor_snapshots", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk": { + "name": "document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk", + "tableFrom": "document_annotation_anchor_snapshots", + "tableTo": "document_revisions", + "columnsFrom": [ + "from_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk": { + "name": "document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk", + "tableFrom": "document_annotation_anchor_snapshots", + "tableTo": "document_revisions", + "columnsFrom": [ + "to_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_annotation_comments": { + "name": "document_annotation_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 + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_type": { + "name": "author_type", + "type": "text", + "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 + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_trust": { + "name": "source_trust", + "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": { + "document_annotation_comments_company_thread_created_at_idx": { + "name": "document_annotation_comments_company_thread_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_annotation_comments_company_issue_created_at_idx": { + "name": "document_annotation_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": {} + }, + "document_annotation_comments_company_routine_created_at_idx": { + "name": "document_annotation_comments_company_routine_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_annotation_comments_company_case_created_at_idx": { + "name": "document_annotation_comments_company_case_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_annotation_comments_company_document_created_at_idx": { + "name": "document_annotation_comments_company_document_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_annotation_comments_issue_comment_idx": { + "name": "document_annotation_comments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_annotation_comments_body_search_idx": { + "name": "document_annotation_comments_body_search_idx", + "columns": [ + { + "expression": "body", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "document_annotation_comments_company_id_companies_id_fk": { + "name": "document_annotation_comments_company_id_companies_id_fk", + "tableFrom": "document_annotation_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_annotation_comments_thread_id_document_annotation_threads_id_fk": { + "name": "document_annotation_comments_thread_id_document_annotation_threads_id_fk", + "tableFrom": "document_annotation_comments", + "tableTo": "document_annotation_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_annotation_comments_issue_id_issues_id_fk": { + "name": "document_annotation_comments_issue_id_issues_id_fk", + "tableFrom": "document_annotation_comments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_annotation_comments_routine_id_routines_id_fk": { + "name": "document_annotation_comments_routine_id_routines_id_fk", + "tableFrom": "document_annotation_comments", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_annotation_comments_case_id_cases_id_fk": { + "name": "document_annotation_comments_case_id_cases_id_fk", + "tableFrom": "document_annotation_comments", + "tableTo": "cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_annotation_comments_document_id_documents_id_fk": { + "name": "document_annotation_comments_document_id_documents_id_fk", + "tableFrom": "document_annotation_comments", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_annotation_comments_author_agent_id_agents_id_fk": { + "name": "document_annotation_comments_author_agent_id_agents_id_fk", + "tableFrom": "document_annotation_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk": { + "name": "document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "document_annotation_comments", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_annotation_comments_issue_comment_id_issue_comments_id_fk": { + "name": "document_annotation_comments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "document_annotation_comments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_annotation_comments_exactly_one_owner_chk": { + "name": "document_annotation_comments_exactly_one_owner_chk", + "value": "num_nonnulls(\"document_annotation_comments\".\"issue_id\", \"document_annotation_comments\".\"routine_id\", \"document_annotation_comments\".\"case_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.document_annotation_threads": { + "name": "document_annotation_threads", + "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": false + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_key": { + "name": "document_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "anchor_state": { + "name": "anchor_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "original_revision_id": { + "name": "original_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_revision_number": { + "name": "original_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "current_revision_id": { + "name": "current_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "current_revision_number": { + "name": "current_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "selected_text": { + "name": "selected_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix_text": { + "name": "prefix_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "suffix_text": { + "name": "suffix_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "normalized_start": { + "name": "normalized_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "normalized_end": { + "name": "normalized_end", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "markdown_start": { + "name": "markdown_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "markdown_end": { + "name": "markdown_end", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "anchor_confidence": { + "name": "anchor_confidence", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'exact'" + }, + "anchor_selector": { + "name": "anchor_selector", + "type": "jsonb", + "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 + }, + "resolved_by_agent_id": { + "name": "resolved_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_user_id": { + "name": "resolved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "document_annotation_threads_company_document_status_idx": { + "name": "document_annotation_threads_company_document_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_annotation_threads_company_issue_status_idx": { + "name": "document_annotation_threads_company_issue_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_annotation_threads_company_routine_status_idx": { + "name": "document_annotation_threads_company_routine_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_annotation_threads_company_case_status_idx": { + "name": "document_annotation_threads_company_case_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_annotation_threads_company_current_revision_open_idx": { + "name": "document_annotation_threads_company_current_revision_open_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "current_revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_annotation_threads_company_anchor_state_idx": { + "name": "document_annotation_threads_company_anchor_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "anchor_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_annotation_threads_company_id_companies_id_fk": { + "name": "document_annotation_threads_company_id_companies_id_fk", + "tableFrom": "document_annotation_threads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_annotation_threads_issue_id_issues_id_fk": { + "name": "document_annotation_threads_issue_id_issues_id_fk", + "tableFrom": "document_annotation_threads", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_annotation_threads_routine_id_routines_id_fk": { + "name": "document_annotation_threads_routine_id_routines_id_fk", + "tableFrom": "document_annotation_threads", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_annotation_threads_case_id_cases_id_fk": { + "name": "document_annotation_threads_case_id_cases_id_fk", + "tableFrom": "document_annotation_threads", + "tableTo": "cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_annotation_threads_document_id_documents_id_fk": { + "name": "document_annotation_threads_document_id_documents_id_fk", + "tableFrom": "document_annotation_threads", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_annotation_threads_original_revision_id_document_revisions_id_fk": { + "name": "document_annotation_threads_original_revision_id_document_revisions_id_fk", + "tableFrom": "document_annotation_threads", + "tableTo": "document_revisions", + "columnsFrom": [ + "original_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_annotation_threads_current_revision_id_document_revisions_id_fk": { + "name": "document_annotation_threads_current_revision_id_document_revisions_id_fk", + "tableFrom": "document_annotation_threads", + "tableTo": "document_revisions", + "columnsFrom": [ + "current_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_annotation_threads_created_by_agent_id_agents_id_fk": { + "name": "document_annotation_threads_created_by_agent_id_agents_id_fk", + "tableFrom": "document_annotation_threads", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_annotation_threads_resolved_by_agent_id_agents_id_fk": { + "name": "document_annotation_threads_resolved_by_agent_id_agents_id_fk", + "tableFrom": "document_annotation_threads", + "tableTo": "agents", + "columnsFrom": [ + "resolved_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_annotation_threads_exactly_one_owner_chk": { + "name": "document_annotation_threads_exactly_one_owner_chk", + "value": "num_nonnulls(\"document_annotation_threads\".\"issue_id\", \"document_annotation_threads\".\"routine_id\", \"document_annotation_threads\".\"case_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.document_memberships": { + "name": "document_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 + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_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": { + "document_memberships_company_user_starred_idx": { + "name": "document_memberships_company_user_starred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_memberships_company_user_document_uq": { + "name": "document_memberships_company_user_document_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_memberships_company_id_companies_id_fk": { + "name": "document_memberships_company_id_companies_id_fk", + "tableFrom": "document_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_memberships_document_id_documents_id_fk": { + "name": "document_memberships_document_id_documents_id_fk", + "tableFrom": "document_memberships", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_revisions": { + "name": "document_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 + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown'" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "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_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_revisions_document_revision_uq": { + "name": "document_revisions_document_revision_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_revisions_company_document_created_idx": { + "name": "document_revisions_company_document_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_revisions_company_id_companies_id_fk": { + "name": "document_revisions_company_id_companies_id_fk", + "tableFrom": "document_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_revisions_document_id_documents_id_fk": { + "name": "document_revisions_document_id_documents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_revisions_created_by_agent_id_agents_id_fk": { + "name": "document_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_revisions_created_by_run_id_heartbeat_runs_id_fk": { + "name": "document_revisions_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "document_revisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "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": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown'" + }, + "latest_body": { + "name": "latest_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest_revision_id": { + "name": "latest_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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 + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by_agent_id": { + "name": "locked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "locked_by_user_id": { + "name": "locked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_trust": { + "name": "source_trust", + "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": { + "documents_company_updated_idx": { + "name": "documents_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": {} + }, + "documents_company_created_idx": { + "name": "documents_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": {} + }, + "documents_title_search_idx": { + "name": "documents_title_search_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "documents_latest_body_search_idx": { + "name": "documents_latest_body_search_idx", + "columns": [ + { + "expression": "latest_body", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "documents_company_id_companies_id_fk": { + "name": "documents_company_id_companies_id_fk", + "tableFrom": "documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "documents_created_by_agent_id_agents_id_fk": { + "name": "documents_created_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "documents_updated_by_agent_id_agents_id_fk": { + "name": "documents_updated_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "documents_locked_by_agent_id_agents_id_fk": { + "name": "documents_locked_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "locked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_custom_image_setup_sessions": { + "name": "environment_custom_image_setup_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "promoted_template_id": { + "name": "promoted_template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_lease_id": { + "name": "provider_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_lease_id": { + "name": "environment_lease_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'starting'" + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_by_agent_id": { + "name": "started_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "base_template_ref": { + "name": "base_template_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_summary": { + "name": "connection_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "connection_secret_ref": { + "name": "connection_secret_ref", + "type": "text", + "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": { + "environment_custom_image_setup_sessions_environment_status_idx": { + "name": "environment_custom_image_setup_sessions_environment_status_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_custom_image_setup_sessions_environment_active_uq": { + "name": "environment_custom_image_setup_sessions_environment_active_uq", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"environment_custom_image_setup_sessions\".\"status\" IN ('starting', 'waiting_for_user', 'capturing')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_custom_image_setup_sessions_template_idx": { + "name": "environment_custom_image_setup_sessions_template_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_custom_image_setup_sessions_promoted_template_idx": { + "name": "environment_custom_image_setup_sessions_promoted_template_idx", + "columns": [ + { + "expression": "promoted_template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_custom_image_setup_sessions_expires_idx": { + "name": "environment_custom_image_setup_sessions_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_custom_image_setup_sessions_provider_lease_idx": { + "name": "environment_custom_image_setup_sessions_provider_lease_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_lease_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_custom_image_setup_sessions_environment_id_environments_id_fk": { + "name": "environment_custom_image_setup_sessions_environment_id_environments_id_fk", + "tableFrom": "environment_custom_image_setup_sessions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_custom_image_setup_sessions_template_id_environment_custom_image_templates_id_fk": { + "name": "environment_custom_image_setup_sessions_template_id_environment_custom_image_templates_id_fk", + "tableFrom": "environment_custom_image_setup_sessions", + "tableTo": "environment_custom_image_templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "environment_custom_image_setup_sessions_promoted_template_id_environment_custom_image_templates_id_fk": { + "name": "environment_custom_image_setup_sessions_promoted_template_id_environment_custom_image_templates_id_fk", + "tableFrom": "environment_custom_image_setup_sessions", + "tableTo": "environment_custom_image_templates", + "columnsFrom": [ + "promoted_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "environment_custom_image_setup_sessions_environment_lease_id_environment_leases_id_fk": { + "name": "environment_custom_image_setup_sessions_environment_lease_id_environment_leases_id_fk", + "tableFrom": "environment_custom_image_setup_sessions", + "tableTo": "environment_leases", + "columnsFrom": [ + "environment_lease_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "environment_custom_image_setup_sessions_started_by_agent_id_agents_id_fk": { + "name": "environment_custom_image_setup_sessions_started_by_agent_id_agents_id_fk", + "tableFrom": "environment_custom_image_setup_sessions", + "tableTo": "agents", + "columnsFrom": [ + "started_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_custom_image_templates": { + "name": "environment_custom_image_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_kind": { + "name": "template_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "template_ref": { + "name": "template_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_template_ref": { + "name": "source_template_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_environment_config_fingerprint": { + "name": "source_environment_config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "superseded_by_template_id": { + "name": "superseded_by_template_id", + "type": "uuid", + "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": { + "environment_custom_image_templates_environment_status_idx": { + "name": "environment_custom_image_templates_environment_status_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_custom_image_templates_environment_provider_status_idx": { + "name": "environment_custom_image_templates_environment_provider_status_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_custom_image_templates_environment_active_uq": { + "name": "environment_custom_image_templates_environment_active_uq", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"environment_custom_image_templates\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_custom_image_templates_superseded_by_idx": { + "name": "environment_custom_image_templates_superseded_by_idx", + "columns": [ + { + "expression": "superseded_by_template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_custom_image_templates_last_used_idx": { + "name": "environment_custom_image_templates_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_custom_image_templates_environment_id_environments_id_fk": { + "name": "environment_custom_image_templates_environment_id_environments_id_fk", + "tableFrom": "environment_custom_image_templates", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_custom_image_templates_created_by_agent_id_agents_id_fk": { + "name": "environment_custom_image_templates_created_by_agent_id_agents_id_fk", + "tableFrom": "environment_custom_image_templates", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "environment_custom_image_templates_superseded_by_template_id_environment_custom_image_templates_id_fk": { + "name": "environment_custom_image_templates_superseded_by_template_id_environment_custom_image_templates_id_fk", + "tableFrom": "environment_custom_image_templates", + "tableTo": "environment_custom_image_templates", + "columnsFrom": [ + "superseded_by_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_leases": { + "name": "environment_leases", + "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 + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "lease_policy": { + "name": "lease_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ephemeral'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_lease_id": { + "name": "provider_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cleanup_status": { + "name": "cleanup_status", + "type": "text", + "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": { + "environment_leases_company_environment_status_idx": { + "name": "environment_leases_company_environment_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_company_execution_workspace_idx": { + "name": "environment_leases_company_execution_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_company_issue_idx": { + "name": "environment_leases_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": {} + }, + "environment_leases_heartbeat_run_idx": { + "name": "environment_leases_heartbeat_run_idx", + "columns": [ + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_company_last_used_idx": { + "name": "environment_leases_company_last_used_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_provider_lease_idx": { + "name": "environment_leases_provider_lease_idx", + "columns": [ + { + "expression": "provider_lease_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_leases_company_id_companies_id_fk": { + "name": "environment_leases_company_id_companies_id_fk", + "tableFrom": "environment_leases", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_leases_environment_id_environments_id_fk": { + "name": "environment_leases_environment_id_environments_id_fk", + "tableFrom": "environment_leases", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "environment_leases_execution_workspace_id_execution_workspaces_id_fk": { + "name": "environment_leases_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "environment_leases", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "environment_leases_issue_id_issues_id_fk": { + "name": "environment_leases_issue_id_issues_id_fk", + "tableFrom": "environment_leases", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "environment_leases_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "environment_leases_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "environment_leases", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environments": { + "name": "environments", + "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 + }, + "driver": { + "name": "driver", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "env_vars": { + "name": "env_vars", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "environments_status_idx": { + "name": "environments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_local_driver_idx": { + "name": "environments_local_driver_idx", + "columns": [ + { + "expression": "driver", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"environments\".\"driver\" = 'local'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_managed_sandbox_idx": { + "name": "environments_managed_sandbox_idx", + "columns": [ + { + "expression": "driver", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"environments\".\"driver\" = 'sandbox' AND (\"environments\".\"metadata\" ->> 'managedByPaperclip')::boolean = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_name_idx": { + "name": "environments_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_workspace_runtime_leases": { + "name": "execution_workspace_runtime_leases", + "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 + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_key": { + "name": "owner_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_issue_id": { + "name": "owner_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_run_id": { + "name": "owner_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_action": { + "name": "last_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "renewed_at": { + "name": "renewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "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": { + "execution_workspace_runtime_leases_company_workspace_idx": { + "name": "execution_workspace_runtime_leases_company_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspace_runtime_leases_company_owner_idx": { + "name": "execution_workspace_runtime_leases_company_owner_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspace_runtime_leases_expires_at_idx": { + "name": "execution_workspace_runtime_leases_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_workspace_runtime_leases_company_id_companies_id_fk": { + "name": "execution_workspace_runtime_leases_company_id_companies_id_fk", + "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_workspace_runtime_leases_execution_workspace_id_execution_workspaces_id_fk": { + "name": "execution_workspace_runtime_leases_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_workspace_runtime_leases_owner_issue_id_issues_id_fk": { + "name": "execution_workspace_runtime_leases_owner_issue_id_issues_id_fk", + "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "issues", + "columnsFrom": [ + "owner_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspace_runtime_leases_owner_run_id_heartbeat_runs_id_fk": { + "name": "execution_workspace_runtime_leases_owner_run_id_heartbeat_runs_id_fk", + "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "owner_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspace_runtime_leases_owner_agent_id_agents_id_fk": { + "name": "execution_workspace_runtime_leases_owner_agent_id_agents_id_fk", + "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "execution_workspace_runtime_leases_execution_workspace_id_unique": { + "name": "execution_workspace_runtime_leases_execution_workspace_id_unique", + "nullsNotDistinct": false, + "columns": [ + "execution_workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_workspaces": { + "name": "execution_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 + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_issue_id": { + "name": "source_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy_type": { + "name": "strategy_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_fs'" + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "derived_from_execution_workspace_id": { + "name": "derived_from_execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_eligible_at": { + "name": "cleanup_eligible_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_reason": { + "name": "cleanup_reason", + "type": "text", + "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": { + "execution_workspaces_company_project_status_idx": { + "name": "execution_workspaces_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_project_workspace_status_idx": { + "name": "execution_workspaces_company_project_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_source_issue_idx": { + "name": "execution_workspaces_company_source_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_last_used_idx": { + "name": "execution_workspaces_company_last_used_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_branch_idx": { + "name": "execution_workspaces_company_branch_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_workspaces_company_id_companies_id_fk": { + "name": "execution_workspaces_company_id_companies_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_workspaces_project_id_projects_id_fk": { + "name": "execution_workspaces_project_id_projects_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_workspaces_project_workspace_id_project_workspaces_id_fk": { + "name": "execution_workspaces_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspaces_source_issue_id_issues_id_fk": { + "name": "execution_workspaces_source_issue_id_issues_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "issues", + "columnsFrom": [ + "source_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk": { + "name": "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "derived_from_execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.external_object_mentions": { + "name": "external_object_mentions", + "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 + }, + "source_issue_id": { + "name": "source_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_record_id": { + "name": "source_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "document_key": { + "name": "document_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_key": { + "name": "property_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "matched_text_redacted": { + "name": "matched_text_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sanitized_display_url": { + "name": "sanitized_display_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_identity_hash": { + "name": "canonical_identity_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_identity": { + "name": "canonical_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_key": { + "name": "provider_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detector_key": { + "name": "detector_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "object_type": { + "name": "object_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'exact'" + }, + "created_by_plugin_id": { + "name": "created_by_plugin_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": { + "external_object_mentions_company_source_issue_idx": { + "name": "external_object_mentions_company_source_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_object_mentions_company_object_idx": { + "name": "external_object_mentions_company_object_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_object_mentions_company_provider_idx": { + "name": "external_object_mentions_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_object_mentions_company_source_record_uq": { + "name": "external_object_mentions_company_source_record_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "canonical_identity_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"external_object_mentions\".\"source_record_id\" is not null and \"external_object_mentions\".\"canonical_identity_hash\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_object_mentions_company_source_null_record_uq": { + "name": "external_object_mentions_company_source_null_record_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "canonical_identity_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"external_object_mentions\".\"source_record_id\" is null and \"external_object_mentions\".\"canonical_identity_hash\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "external_object_mentions_company_id_companies_id_fk": { + "name": "external_object_mentions_company_id_companies_id_fk", + "tableFrom": "external_object_mentions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_object_mentions_source_issue_id_issues_id_fk": { + "name": "external_object_mentions_source_issue_id_issues_id_fk", + "tableFrom": "external_object_mentions", + "tableTo": "issues", + "columnsFrom": [ + "source_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_object_mentions_object_id_external_objects_id_fk": { + "name": "external_object_mentions_object_id_external_objects_id_fk", + "tableFrom": "external_object_mentions", + "tableTo": "external_objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_object_mentions_created_by_plugin_id_plugins_id_fk": { + "name": "external_object_mentions_created_by_plugin_id_plugins_id_fk", + "tableFrom": "external_object_mentions", + "tableTo": "plugins", + "columnsFrom": [ + "created_by_plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.external_objects": { + "name": "external_objects", + "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_key": { + "name": "provider_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "object_type": { + "name": "object_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sanitized_canonical_url": { + "name": "sanitized_canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_identity_hash": { + "name": "canonical_identity_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_key": { + "name": "display_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_key": { + "name": "icon_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_title": { + "name": "display_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_key": { + "name": "status_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_label": { + "name": "status_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_icon_key": { + "name": "status_icon_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_category": { + "name": "status_category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "status_tone": { + "name": "status_tone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'neutral'" + }, + "liveness": { + "name": "liveness", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "is_terminal": { + "name": "is_terminal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "remote_version": { + "name": "remote_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_resolved_at": { + "name": "last_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_changed_at": { + "name": "last_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_refresh_at": { + "name": "next_refresh_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_started_at": { + "name": "refresh_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "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": { + "external_objects_company_provider_object_idx": { + "name": "external_objects_company_provider_object_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_objects_company_provider_status_idx": { + "name": "external_objects_company_provider_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_objects_company_refresh_idx": { + "name": "external_objects_company_refresh_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_refresh_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_objects_company_external_id_uq": { + "name": "external_objects_company_external_id_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_objects_company_identity_uq": { + "name": "external_objects_company_identity_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "canonical_identity_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "external_objects_company_id_companies_id_fk": { + "name": "external_objects_company_id_companies_id_fk", + "tableFrom": "external_objects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_objects_plugin_id_plugins_id_fk": { + "name": "external_objects_plugin_id_plugins_id_fk", + "tableFrom": "external_objects", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_exports": { + "name": "feedback_exports", + "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 + }, + "feedback_vote_id": { + "name": "feedback_vote_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_only'" + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "export_id": { + "name": "export_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-envelope-v2'" + }, + "bundle_version": { + "name": "bundle_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-bundle-v2'" + }, + "payload_version": { + "name": "payload_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-v1'" + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_snapshot": { + "name": "payload_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target_summary": { + "name": "target_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "redaction_summary": { + "name": "redaction_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "exported_at": { + "name": "exported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "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": { + "feedback_exports_feedback_vote_idx": { + "name": "feedback_exports_feedback_vote_idx", + "columns": [ + { + "expression": "feedback_vote_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_created_idx": { + "name": "feedback_exports_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": {} + }, + "feedback_exports_company_status_idx": { + "name": "feedback_exports_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_issue_idx": { + "name": "feedback_exports_company_issue_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": {} + }, + "feedback_exports_company_project_idx": { + "name": "feedback_exports_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_author_idx": { + "name": "feedback_exports_company_author_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_exports_company_id_companies_id_fk": { + "name": "feedback_exports_company_id_companies_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "feedback_exports_feedback_vote_id_feedback_votes_id_fk": { + "name": "feedback_exports_feedback_vote_id_feedback_votes_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "feedback_votes", + "columnsFrom": [ + "feedback_vote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_exports_issue_id_issues_id_fk": { + "name": "feedback_exports_issue_id_issues_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_exports_project_id_projects_id_fk": { + "name": "feedback_exports_project_id_projects_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_votes": { + "name": "feedback_votes", + "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 + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_with_labs": { + "name": "shared_with_labs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redaction_summary": { + "name": "redaction_summary", + "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": { + "feedback_votes_company_issue_idx": { + "name": "feedback_votes_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": {} + }, + "feedback_votes_issue_target_idx": { + "name": "feedback_votes_issue_target_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_author_idx": { + "name": "feedback_votes_author_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_company_target_author_idx": { + "name": "feedback_votes_company_target_author_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_votes_company_id_companies_id_fk": { + "name": "feedback_votes_company_id_companies_id_fk", + "tableFrom": "feedback_votes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "feedback_votes_issue_id_issues_id_fk": { + "name": "feedback_votes_issue_id_issues_id_fk", + "tableFrom": "feedback_votes", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.finance_events": { + "name": "finance_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": false + }, + "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 + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_kind": { + "name": "event_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'debit'" + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_adapter_type": { + "name": "execution_adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing_tier": { + "name": "pricing_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "estimated": { + "name": "estimated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "external_invoice_id": { + "name": "external_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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": { + "finance_events_company_occurred_idx": { + "name": "finance_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": {} + }, + "finance_events_company_biller_occurred_idx": { + "name": "finance_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_kind_occurred_idx": { + "name": "finance_events_company_kind_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_direction_occurred_idx": { + "name": "finance_events_company_direction_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "direction", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_heartbeat_run_idx": { + "name": "finance_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_cost_event_idx": { + "name": "finance_events_company_cost_event_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "finance_events_company_id_companies_id_fk": { + "name": "finance_events_company_id_companies_id_fk", + "tableFrom": "finance_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_agent_id_agents_id_fk": { + "name": "finance_events_agent_id_agents_id_fk", + "tableFrom": "finance_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_issue_id_issues_id_fk": { + "name": "finance_events_issue_id_issues_id_fk", + "tableFrom": "finance_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "finance_events_project_id_projects_id_fk": { + "name": "finance_events_project_id_projects_id_fk", + "tableFrom": "finance_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_goal_id_goals_id_fk": { + "name": "finance_events_goal_id_goals_id_fk", + "tableFrom": "finance_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "finance_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "finance_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_cost_event_id_cost_events_id_fk": { + "name": "finance_events_cost_event_id_cost_events_id_fk", + "tableFrom": "finance_events", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folders": { + "name": "folders", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "system_key": { + "name": "system_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "folders_company_kind_position_idx": { + "name": "folders_company_kind_position_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folders_company_kind_root_slug_uq": { + "name": "folders_company_kind_root_slug_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folders\".\"parent_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folders_company_kind_parent_slug_uq": { + "name": "folders_company_kind_parent_slug_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folders\".\"parent_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folders_company_kind_system_key_uq": { + "name": "folders_company_kind_system_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "system_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folders\".\"system_key\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folders_company_kind_parent_position_idx": { + "name": "folders_company_kind_parent_position_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folders_company_id_companies_id_fk": { + "name": "folders_company_id_companies_id_fk", + "tableFrom": "folders", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folders_parent_id_folders_id_fk": { + "name": "folders_parent_id_folders_id_fk", + "tableFrom": "folders", + "tableTo": "folders", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "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": "bigint", + "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 + }, + "source_instance_id": { + "name": "source_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_event_id": { + "name": "source_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_seq": { + "name": "source_seq", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source_payload_sha256": { + "name": "source_payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protocol_schema_version": { + "name": "protocol_schema_version", + "type": "integer", + "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_run_source_event_uq": { + "name": "heartbeat_run_events_run_source_event_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"heartbeat_run_events\".\"source_event_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_run_source_seq_uq": { + "name": "heartbeat_run_events_run_source_seq_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"heartbeat_run_events\".\"source_instance_id\" is not null and \"heartbeat_run_events\".\"source_seq\" is not null", + "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_run_watchdog_decisions": { + "name": "heartbeat_run_watchdog_decisions", + "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 + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "evaluation_issue_id": { + "name": "evaluation_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snoozed_until": { + "name": "snoozed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "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_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_watchdog_decisions_company_run_created_idx": { + "name": "heartbeat_run_watchdog_decisions_company_run_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_watchdog_decisions_company_run_snooze_idx": { + "name": "heartbeat_run_watchdog_decisions_company_run_snooze_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "snoozed_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_watchdog_decisions_company_id_companies_id_fk": { + "name": "heartbeat_run_watchdog_decisions_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_watchdog_decisions_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_watchdog_decisions_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "heartbeat_run_watchdog_decisions_evaluation_issue_id_issues_id_fk": { + "name": "heartbeat_run_watchdog_decisions_evaluation_issue_id_issues_id_fk", + "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "issues", + "columnsFrom": [ + "evaluation_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "heartbeat_run_watchdog_decisions_created_by_agent_id_agents_id_fk": { + "name": "heartbeat_run_watchdog_decisions_created_by_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "heartbeat_run_watchdog_decisions_created_by_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_watchdog_decisions_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_watchdog_decisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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'" + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "runtime_mode": { + "name": "runtime_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'legacy'" + }, + "runtime_mode_resolver_version": { + "name": "runtime_mode_resolver_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_mode_reason": { + "name": "runtime_mode_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_mode_resolved_at": { + "name": "runtime_mode_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "runner_profile_json": { + "name": "runner_profile_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "runner_instance_id": { + "name": "runner_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "native_session_id": { + "name": "native_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "native_issue_id": { + "name": "native_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "driver_kind": { + "name": "driver_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "driver_version": { + "name": "driver_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completion_contract_id": { + "name": "completion_contract_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "completion_contract_sha256": { + "name": "completion_contract_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_event_seq": { + "name": "next_event_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "native_phase": { + "name": "native_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "native_phase_updated_at": { + "name": "native_phase_updated_at", + "type": "timestamp with time zone", + "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 + }, + "process_pid": { + "name": "process_pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "process_group_id": { + "name": "process_group_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "process_started_at": { + "name": "process_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_output_at": { + "name": "last_output_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_output_seq": { + "name": "last_output_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_output_stream": { + "name": "last_output_stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_output_bytes": { + "name": "last_output_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "retry_of_run_id": { + "name": "retry_of_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "process_loss_retry_count": { + "name": "process_loss_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_retry_at": { + "name": "scheduled_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scheduled_retry_attempt": { + "name": "scheduled_retry_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_retry_reason": { + "name": "scheduled_retry_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_comment_status": { + "name": "issue_comment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_applicable'" + }, + "issue_comment_satisfied_by_comment_id": { + "name": "issue_comment_satisfied_by_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_comment_retry_queued_at": { + "name": "issue_comment_retry_queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "liveness_state": { + "name": "liveness_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "liveness_reason": { + "name": "liveness_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation_attempt": { + "name": "continuation_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_useful_action_at": { + "name": "last_useful_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "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": {} + }, + "heartbeat_runs_company_responsible_user_idx": { + "name": "heartbeat_runs_company_responsible_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "responsible_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_runs_company_liveness_idx": { + "name": "heartbeat_runs_company_liveness_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "liveness_state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_runs_company_status_last_output_idx": { + "name": "heartbeat_runs_company_status_last_output_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_output_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_runs_company_status_process_started_idx": { + "name": "heartbeat_runs_company_status_process_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "process_started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_runs_company_created_at_desc_idx": { + "name": "heartbeat_runs_company_created_at_desc_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_runs_company_ctx_issue_created_idx": { + "name": "heartbeat_runs_company_ctx_issue_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"context_snapshot\" ->> 'issueId')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_runs_company_ctx_task_created_idx": { + "name": "heartbeat_runs_company_ctx_task_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"context_snapshot\" ->> 'taskId')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_runs_company_ctx_taskkey_created_idx": { + "name": "heartbeat_runs_company_ctx_taskkey_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"context_snapshot\" ->> 'taskKey')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "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" + }, + "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "retry_of_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "heartbeat_runs_company_native_issue_id_uq": { + "name": "heartbeat_runs_company_native_issue_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "native_issue_id", + "id" + ] + }, + "heartbeat_runs_company_native_issue_contract_id_uq": { + "name": "heartbeat_runs_company_native_issue_contract_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "native_issue_id", + "id", + "completion_contract_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inbox_dismissals": { + "name": "inbox_dismissals", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_key": { + "name": "item_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'dismiss'" + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "snoozed_until": { + "name": "snoozed_until", + "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": { + "inbox_dismissals_company_user_idx": { + "name": "inbox_dismissals_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": {} + }, + "inbox_dismissals_company_item_idx": { + "name": "inbox_dismissals_company_item_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_dismissals_company_user_item_idx": { + "name": "inbox_dismissals_company_user_item_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inbox_dismissals_company_id_companies_id_fk": { + "name": "inbox_dismissals_company_id_companies_id_fk", + "tableFrom": "inbox_dismissals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection_grants": { + "name": "connection_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 + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant": { + "name": "provider_tenant", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_secret_refs": { + "name": "credential_secret_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_agent_id": { + "name": "revoked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_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": { + "connection_grants_company_connection_idx": { + "name": "connection_grants_company_connection_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connection_grants_subject_user_idx": { + "name": "connection_grants_subject_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connection_grants_user_uq": { + "name": "connection_grants_user_uq", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connection_grants_default_uq": { + "name": "connection_grants_default_uq", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"connection_grants\".\"is_default\" = true and \"connection_grants\".\"kind\" = 'workspace'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connection_grants_company_id_companies_id_fk": { + "name": "connection_grants_company_id_companies_id_fk", + "tableFrom": "connection_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connection_grants_created_by_agent_id_agents_id_fk": { + "name": "connection_grants_created_by_agent_id_agents_id_fk", + "tableFrom": "connection_grants", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "connection_grants_revoked_by_agent_id_agents_id_fk": { + "name": "connection_grants_revoked_by_agent_id_agents_id_fk", + "tableFrom": "connection_grants", + "tableTo": "agents", + "columnsFrom": [ + "revoked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "connection_grants_company_connection_fk": { + "name": "connection_grants_company_connection_fk", + "tableFrom": "connection_grants", + "tableTo": "tool_connections", + "columnsFrom": [ + "company_id", + "connection_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "connection_grants_kind_check": { + "name": "connection_grants_kind_check", + "value": "\"connection_grants\".\"kind\" in ('workspace', 'user')" + }, + "connection_grants_status_check": { + "name": "connection_grants_status_check", + "value": "\"connection_grants\".\"status\" in ('active', 'revoked', 'expired', 'needs_reauthorization')" + }, + "connection_grants_subject_check": { + "name": "connection_grants_subject_check", + "value": "(\"connection_grants\".\"kind\" = 'user' and \"connection_grants\".\"subject_user_id\" is not null) or (\"connection_grants\".\"kind\" = 'workspace' and \"connection_grants\".\"subject_user_id\" is null)" + }, + "connection_grants_default_check": { + "name": "connection_grants_default_check", + "value": "\"connection_grants\".\"is_default\" = false or \"connection_grants\".\"kind\" = 'workspace'" + } + }, + "isRLSEnabled": false + }, + "public.connection_token_issuances": { + "name": "connection_token_issuances", + "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 + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_scope": { + "name": "requested_scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "issued_scope": { + "name": "issued_scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "ttl_seconds": { + "name": "ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connection_token_issuances_company_created_idx": { + "name": "connection_token_issuances_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": {} + }, + "connection_token_issuances_connection_created_idx": { + "name": "connection_token_issuances_connection_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connection_token_issuances_agent_connection_idx": { + "name": "connection_token_issuances_agent_connection_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connection_token_issuances_run_idx": { + "name": "connection_token_issuances_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": {} + } + }, + "foreignKeys": { + "connection_token_issuances_company_id_companies_id_fk": { + "name": "connection_token_issuances_company_id_companies_id_fk", + "tableFrom": "connection_token_issuances", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connection_token_issuances_application_id_tool_applications_id_fk": { + "name": "connection_token_issuances_application_id_tool_applications_id_fk", + "tableFrom": "connection_token_issuances", + "tableTo": "tool_applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "connection_token_issuances_connection_id_tool_connections_id_fk": { + "name": "connection_token_issuances_connection_id_tool_connections_id_fk", + "tableFrom": "connection_token_issuances", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connection_token_issuances_agent_id_agents_id_fk": { + "name": "connection_token_issuances_agent_id_agents_id_fk", + "tableFrom": "connection_token_issuances", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connection_token_issuances_run_id_heartbeat_runs_id_fk": { + "name": "connection_token_issuances_run_id_heartbeat_runs_id_fk", + "tableFrom": "connection_token_issuances", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "connection_token_issuances_issue_id_issues_id_fk": { + "name": "connection_token_issuances_issue_id_issues_id_fk", + "tableFrom": "connection_token_issuances", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "connection_token_issuances_project_id_projects_id_fk": { + "name": "connection_token_issuances_project_id_projects_id_fk", + "tableFrom": "connection_token_issuances", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_settings": { + "name": "instance_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "default_environment_id": { + "name": "default_environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "general": { + "name": "general", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "experimental": { + "name": "experimental", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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_settings_singleton_key_idx": { + "name": "instance_settings_singleton_key_idx", + "columns": [ + { + "expression": "singleton_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "instance_settings_default_environment_id_environments_id_fk": { + "name": "instance_settings_default_environment_id_environments_id_fk", + "tableFrom": "instance_settings", + "tableTo": "environments", + "columnsFrom": [ + "default_environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + }, + "on_behalf_of_user_id": { + "name": "on_behalf_of_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_type": { + "name": "author_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "derived_author_agent_id": { + "name": "derived_author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "derived_created_by_run_id": { + "name": "derived_created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "derived_author_source": { + "name": "derived_author_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "presentation": { + "name": "presentation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_by_type": { + "name": "deleted_by_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_by_agent_id": { + "name": "deleted_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "deleted_by_user_id": { + "name": "deleted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_by_run_id": { + "name": "deleted_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_trust": { + "name": "source_trust", + "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": { + "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": {} + }, + "issue_comments_body_search_idx": { + "name": "issue_comments_body_search_idx", + "columns": [ + { + "expression": "body", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "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": "cascade", + "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" + }, + "issue_comments_on_behalf_of_user_id_user_id_fk": { + "name": "issue_comments_on_behalf_of_user_id_user_id_fk", + "tableFrom": "issue_comments", + "tableTo": "user", + "columnsFrom": [ + "on_behalf_of_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_comments_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_comments_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_comments", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_comments_derived_author_agent_id_agents_id_fk": { + "name": "issue_comments_derived_author_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "derived_author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_comments", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "derived_created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_comments_deleted_by_agent_id_agents_id_fk": { + "name": "issue_comments_deleted_by_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "deleted_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_comments_deleted_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_comments_deleted_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_comments", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "deleted_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_create_idempotency_keys": { + "name": "issue_create_idempotency_keys", + "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 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_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_create_idempotency_keys_company_key_uq": { + "name": "issue_create_idempotency_keys_company_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_create_idempotency_keys_issue_idx": { + "name": "issue_create_idempotency_keys_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_create_idempotency_keys_company_created_at_idx": { + "name": "issue_create_idempotency_keys_company_created_at_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": { + "issue_create_idempotency_keys_company_id_companies_id_fk": { + "name": "issue_create_idempotency_keys_company_id_companies_id_fk", + "tableFrom": "issue_create_idempotency_keys", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_create_idempotency_keys_issue_id_issues_id_fk": { + "name": "issue_create_idempotency_keys_issue_id_issues_id_fk", + "tableFrom": "issue_create_idempotency_keys", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_documents": { + "name": "issue_documents", + "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 + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "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_documents_company_issue_key_uq": { + "name": "issue_documents_company_issue_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_document_uq": { + "name": "issue_documents_document_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_company_issue_updated_idx": { + "name": "issue_documents_company_issue_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_documents_company_id_companies_id_fk": { + "name": "issue_documents_company_id_companies_id_fk", + "tableFrom": "issue_documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_documents_issue_id_issues_id_fk": { + "name": "issue_documents_issue_id_issues_id_fk", + "tableFrom": "issue_documents", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_documents_document_id_documents_id_fk": { + "name": "issue_documents_document_id_documents_id_fk", + "tableFrom": "issue_documents", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_execution_decisions": { + "name": "issue_execution_decisions", + "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 + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_type": { + "name": "stage_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_agent_id": { + "name": "actor_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_run_id": { + "name": "created_by_run_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_execution_decisions_company_issue_idx": { + "name": "issue_execution_decisions_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_execution_decisions_stage_idx": { + "name": "issue_execution_decisions_stage_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stage_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_execution_decisions_company_id_companies_id_fk": { + "name": "issue_execution_decisions_company_id_companies_id_fk", + "tableFrom": "issue_execution_decisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_execution_decisions_issue_id_issues_id_fk": { + "name": "issue_execution_decisions_issue_id_issues_id_fk", + "tableFrom": "issue_execution_decisions", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_execution_decisions_actor_agent_id_agents_id_fk": { + "name": "issue_execution_decisions_actor_agent_id_agents_id_fk", + "tableFrom": "issue_execution_decisions", + "tableTo": "agents", + "columnsFrom": [ + "actor_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_execution_decisions_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_execution_decisions_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_execution_decisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_inbox_archives": { + "name": "issue_inbox_archives", + "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 + }, + "archived_by_actor_type": { + "name": "archived_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "archived_by_agent_id": { + "name": "archived_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "archived_by_run_id": { + "name": "archived_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_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_inbox_archives_company_issue_idx": { + "name": "issue_inbox_archives_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_inbox_archives_company_user_idx": { + "name": "issue_inbox_archives_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_inbox_archives_company_issue_user_idx": { + "name": "issue_inbox_archives_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_inbox_archives_company_id_companies_id_fk": { + "name": "issue_inbox_archives_company_id_companies_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_inbox_archives_issue_id_issues_id_fk": { + "name": "issue_inbox_archives_issue_id_issues_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_inbox_archives_archived_by_agent_id_agents_id_fk": { + "name": "issue_inbox_archives_archived_by_agent_id_agents_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "agents", + "columnsFrom": [ + "archived_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "archived_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "issue_inbox_archives_archived_by_actor_type_check": { + "name": "issue_inbox_archives_archived_by_actor_type_check", + "value": "\"issue_inbox_archives\".\"archived_by_actor_type\" in ('user', 'agent')" + } + }, + "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_plan_decompositions": { + "name": "issue_plan_decompositions", + "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 + }, + "source_issue_id": { + "name": "source_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accepted_plan_revision_id": { + "name": "accepted_plan_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accepted_interaction_id": { + "name": "accepted_interaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'in_flight'" + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_child_count": { + "name": "requested_child_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "requested_children": { + "name": "requested_children", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "child_issue_ids": { + "name": "child_issue_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_run_id": { + "name": "owner_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_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": { + "issue_plan_decompositions_company_source_status_idx": { + "name": "issue_plan_decompositions_company_source_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_plan_decompositions_active_owner_idx": { + "name": "issue_plan_decompositions_active_owner_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"issue_plan_decompositions\".\"status\" = 'in_flight'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_plan_decompositions_source_revision_uq": { + "name": "issue_plan_decompositions_source_revision_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "accepted_plan_revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_plan_decompositions_company_id_companies_id_fk": { + "name": "issue_plan_decompositions_company_id_companies_id_fk", + "tableFrom": "issue_plan_decompositions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_plan_decompositions_source_issue_id_issues_id_fk": { + "name": "issue_plan_decompositions_source_issue_id_issues_id_fk", + "tableFrom": "issue_plan_decompositions", + "tableTo": "issues", + "columnsFrom": [ + "source_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_plan_decompositions_accepted_plan_revision_id_document_revisions_id_fk": { + "name": "issue_plan_decompositions_accepted_plan_revision_id_document_revisions_id_fk", + "tableFrom": "issue_plan_decompositions", + "tableTo": "document_revisions", + "columnsFrom": [ + "accepted_plan_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_plan_decompositions_accepted_interaction_id_issue_thread_interactions_id_fk": { + "name": "issue_plan_decompositions_accepted_interaction_id_issue_thread_interactions_id_fk", + "tableFrom": "issue_plan_decompositions", + "tableTo": "issue_thread_interactions", + "columnsFrom": [ + "accepted_interaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_plan_decompositions_owner_agent_id_agents_id_fk": { + "name": "issue_plan_decompositions_owner_agent_id_agents_id_fk", + "tableFrom": "issue_plan_decompositions", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_plan_decompositions_owner_run_id_heartbeat_runs_id_fk": { + "name": "issue_plan_decompositions_owner_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_plan_decompositions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "owner_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "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": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_recovery_actions": { + "name": "issue_recovery_actions", + "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 + }, + "source_issue_id": { + "name": "source_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recovery_issue_id": { + "name": "recovery_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_owner_agent_id": { + "name": "previous_owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "return_owner_agent_id": { + "name": "return_owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cause": { + "name": "cause", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence": { + "name": "evidence", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wake_policy": { + "name": "wake_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "monitor_policy": { + "name": "monitor_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "issue_recovery_actions_company_source_status_idx": { + "name": "issue_recovery_actions_company_source_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_recovery_actions_company_owner_status_idx": { + "name": "issue_recovery_actions_company_owner_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_recovery_actions_company_recovery_issue_idx": { + "name": "issue_recovery_actions_company_recovery_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recovery_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_recovery_actions_active_source_uq": { + "name": "issue_recovery_actions_active_source_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issue_recovery_actions\".\"status\" in ('active', 'escalated')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_recovery_actions_active_fingerprint_uq": { + "name": "issue_recovery_actions_active_fingerprint_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cause", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issue_recovery_actions\".\"status\" in ('active', 'escalated')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_recovery_actions_company_id_companies_id_fk": { + "name": "issue_recovery_actions_company_id_companies_id_fk", + "tableFrom": "issue_recovery_actions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_recovery_actions_source_issue_id_issues_id_fk": { + "name": "issue_recovery_actions_source_issue_id_issues_id_fk", + "tableFrom": "issue_recovery_actions", + "tableTo": "issues", + "columnsFrom": [ + "source_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_recovery_actions_recovery_issue_id_issues_id_fk": { + "name": "issue_recovery_actions_recovery_issue_id_issues_id_fk", + "tableFrom": "issue_recovery_actions", + "tableTo": "issues", + "columnsFrom": [ + "recovery_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_recovery_actions_owner_agent_id_agents_id_fk": { + "name": "issue_recovery_actions_owner_agent_id_agents_id_fk", + "tableFrom": "issue_recovery_actions", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_recovery_actions_previous_owner_agent_id_agents_id_fk": { + "name": "issue_recovery_actions_previous_owner_agent_id_agents_id_fk", + "tableFrom": "issue_recovery_actions", + "tableTo": "agents", + "columnsFrom": [ + "previous_owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_recovery_actions_return_owner_agent_id_agents_id_fk": { + "name": "issue_recovery_actions_return_owner_agent_id_agents_id_fk", + "tableFrom": "issue_recovery_actions", + "tableTo": "agents", + "columnsFrom": [ + "return_owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_reference_mentions": { + "name": "issue_reference_mentions", + "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 + }, + "source_issue_id": { + "name": "source_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_issue_id": { + "name": "target_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_record_id": { + "name": "source_record_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "document_key": { + "name": "document_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "matched_text": { + "name": "matched_text", + "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": { + "issue_reference_mentions_company_source_issue_idx": { + "name": "issue_reference_mentions_company_source_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_reference_mentions_company_target_issue_idx": { + "name": "issue_reference_mentions_company_target_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_reference_mentions_company_issue_pair_idx": { + "name": "issue_reference_mentions_company_issue_pair_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_reference_mentions_company_source_mention_record_uq": { + "name": "issue_reference_mentions_company_source_mention_record_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issue_reference_mentions\".\"source_record_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_reference_mentions_company_source_mention_null_record_uq": { + "name": "issue_reference_mentions_company_source_mention_null_record_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issue_reference_mentions\".\"source_record_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_reference_mentions_company_id_companies_id_fk": { + "name": "issue_reference_mentions_company_id_companies_id_fk", + "tableFrom": "issue_reference_mentions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_reference_mentions_source_issue_id_issues_id_fk": { + "name": "issue_reference_mentions_source_issue_id_issues_id_fk", + "tableFrom": "issue_reference_mentions", + "tableTo": "issues", + "columnsFrom": [ + "source_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_reference_mentions_target_issue_id_issues_id_fk": { + "name": "issue_reference_mentions_target_issue_id_issues_id_fk", + "tableFrom": "issue_reference_mentions", + "tableTo": "issues", + "columnsFrom": [ + "target_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_relations": { + "name": "issue_relations", + "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 + }, + "related_issue_id": { + "name": "related_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_relations_company_issue_idx": { + "name": "issue_relations_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_relations_company_related_issue_idx": { + "name": "issue_relations_company_related_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "related_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_type_idx": { + "name": "issue_relations_company_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_edge_uq": { + "name": "issue_relations_company_edge_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "related_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_relations_company_id_companies_id_fk": { + "name": "issue_relations_company_id_companies_id_fk", + "tableFrom": "issue_relations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_relations_issue_id_issues_id_fk": { + "name": "issue_relations_issue_id_issues_id_fk", + "tableFrom": "issue_relations", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_relations_related_issue_id_issues_id_fk": { + "name": "issue_relations_related_issue_id_issues_id_fk", + "tableFrom": "issue_relations", + "tableTo": "issues", + "columnsFrom": [ + "related_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_relations_created_by_agent_id_agents_id_fk": { + "name": "issue_relations_created_by_agent_id_agents_id_fk", + "tableFrom": "issue_relations", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_thread_interactions": { + "name": "issue_thread_interactions", + "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 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "continuation_policy": { + "name": "continuation_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'wake_assignee'" + }, + "requested_resolver_policy": { + "name": "requested_resolver_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'anyone'" + }, + "effective_resolver_policy": { + "name": "effective_resolver_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'anyone'" + }, + "resolver_policy_provenance": { + "name": "resolver_policy_provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherited'" + }, + "effective_resolver_policy_source": { + "name": "effective_resolver_policy_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_comment_id": { + "name": "source_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_run_id": { + "name": "source_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "addressee_agent_id": { + "name": "addressee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_by_agent_id": { + "name": "resolved_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_run_id": { + "name": "resolved_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_user_id": { + "name": "resolved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "issue_thread_interactions_issue_idx": { + "name": "issue_thread_interactions_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_thread_interactions_company_issue_created_at_idx": { + "name": "issue_thread_interactions_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_thread_interactions_company_issue_status_idx": { + "name": "issue_thread_interactions_company_issue_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_thread_interactions_company_issue_idempotency_uq": { + "name": "issue_thread_interactions_company_issue_idempotency_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issue_thread_interactions\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_thread_interactions_source_comment_idx": { + "name": "issue_thread_interactions_source_comment_idx", + "columns": [ + { + "expression": "source_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_thread_interactions_addressee_agent_idx": { + "name": "issue_thread_interactions_addressee_agent_idx", + "columns": [ + { + "expression": "addressee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_thread_interactions_company_id_companies_id_fk": { + "name": "issue_thread_interactions_company_id_companies_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_thread_interactions_issue_id_issues_id_fk": { + "name": "issue_thread_interactions_issue_id_issues_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_thread_interactions_source_comment_id_issue_comments_id_fk": { + "name": "issue_thread_interactions_source_comment_id_issue_comments_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "issue_comments", + "columnsFrom": [ + "source_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk": { + "name": "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "source_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_thread_interactions_created_by_agent_id_agents_id_fk": { + "name": "issue_thread_interactions_created_by_agent_id_agents_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_thread_interactions_addressee_agent_id_agents_id_fk": { + "name": "issue_thread_interactions_addressee_agent_id_agents_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "agents", + "columnsFrom": [ + "addressee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_thread_interactions_resolved_by_agent_id_agents_id_fk": { + "name": "issue_thread_interactions_resolved_by_agent_id_agents_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "agents", + "columnsFrom": [ + "resolved_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_thread_interactions_resolved_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_thread_interactions_resolved_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "resolved_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_tree_hold_members": { + "name": "issue_tree_hold_members", + "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 + }, + "hold_id": { + "name": "hold_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_issue_id": { + "name": "parent_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "issue_identifier": { + "name": "issue_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_title": { + "name": "issue_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_status": { + "name": "issue_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "active_run_id": { + "name": "active_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "active_run_status": { + "name": "active_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skipped": { + "name": "skipped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_tree_hold_members_hold_issue_uq": { + "name": "issue_tree_hold_members_hold_issue_uq", + "columns": [ + { + "expression": "hold_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_tree_hold_members_company_issue_idx": { + "name": "issue_tree_hold_members_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_tree_hold_members_hold_depth_idx": { + "name": "issue_tree_hold_members_hold_depth_idx", + "columns": [ + { + "expression": "hold_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depth", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_tree_hold_members_company_id_companies_id_fk": { + "name": "issue_tree_hold_members_company_id_companies_id_fk", + "tableFrom": "issue_tree_hold_members", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_tree_hold_members_hold_id_issue_tree_holds_id_fk": { + "name": "issue_tree_hold_members_hold_id_issue_tree_holds_id_fk", + "tableFrom": "issue_tree_hold_members", + "tableTo": "issue_tree_holds", + "columnsFrom": [ + "hold_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_tree_hold_members_issue_id_issues_id_fk": { + "name": "issue_tree_hold_members_issue_id_issues_id_fk", + "tableFrom": "issue_tree_hold_members", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_tree_hold_members_parent_issue_id_issues_id_fk": { + "name": "issue_tree_hold_members_parent_issue_id_issues_id_fk", + "tableFrom": "issue_tree_hold_members", + "tableTo": "issues", + "columnsFrom": [ + "parent_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_tree_hold_members_assignee_agent_id_agents_id_fk": { + "name": "issue_tree_hold_members_assignee_agent_id_agents_id_fk", + "tableFrom": "issue_tree_hold_members", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_tree_hold_members_active_run_id_heartbeat_runs_id_fk": { + "name": "issue_tree_hold_members_active_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_tree_hold_members", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "active_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_tree_holds": { + "name": "issue_tree_holds", + "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 + }, + "root_issue_id": { + "name": "root_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_policy": { + "name": "release_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "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_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_by_actor_type": { + "name": "released_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "released_by_agent_id": { + "name": "released_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "released_by_user_id": { + "name": "released_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "released_by_run_id": { + "name": "released_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "release_reason": { + "name": "release_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_metadata": { + "name": "release_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": { + "issue_tree_holds_company_root_status_idx": { + "name": "issue_tree_holds_company_root_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "root_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_tree_holds_company_status_mode_idx": { + "name": "issue_tree_holds_company_status_mode_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_tree_holds_company_id_companies_id_fk": { + "name": "issue_tree_holds_company_id_companies_id_fk", + "tableFrom": "issue_tree_holds", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_tree_holds_root_issue_id_issues_id_fk": { + "name": "issue_tree_holds_root_issue_id_issues_id_fk", + "tableFrom": "issue_tree_holds", + "tableTo": "issues", + "columnsFrom": [ + "root_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_tree_holds_created_by_agent_id_agents_id_fk": { + "name": "issue_tree_holds_created_by_agent_id_agents_id_fk", + "tableFrom": "issue_tree_holds", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_tree_holds_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_tree_holds_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_tree_holds", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_tree_holds_released_by_agent_id_agents_id_fk": { + "name": "issue_tree_holds_released_by_agent_id_agents_id_fk", + "tableFrom": "issue_tree_holds", + "tableTo": "agents", + "columnsFrom": [ + "released_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_tree_holds_released_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_tree_holds_released_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_tree_holds", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "released_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_watchdogs": { + "name": "issue_watchdogs", + "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 + }, + "watchdog_agent_id": { + "name": "watchdog_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "watchdog_issue_id": { + "name": "watchdog_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_observed_fingerprint": { + "name": "last_observed_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_reviewed_fingerprint": { + "name": "last_reviewed_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_observed_stop_snapshot": { + "name": "last_observed_stop_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_reviewed_stop_snapshot": { + "name": "last_reviewed_stop_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_completed_at": { + "name": "last_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trigger_count": { + "name": "trigger_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_run_id": { + "name": "updated_by_run_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_watchdogs_company_issue_uq": { + "name": "issue_watchdogs_company_issue_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_watchdogs_company_status_idx": { + "name": "issue_watchdogs_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": {} + }, + "issue_watchdogs_company_agent_idx": { + "name": "issue_watchdogs_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "watchdog_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_watchdogs_company_watchdog_issue_uq": { + "name": "issue_watchdogs_company_watchdog_issue_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "watchdog_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issue_watchdogs\".\"watchdog_issue_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_watchdogs_company_id_companies_id_fk": { + "name": "issue_watchdogs_company_id_companies_id_fk", + "tableFrom": "issue_watchdogs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_watchdogs_issue_id_issues_id_fk": { + "name": "issue_watchdogs_issue_id_issues_id_fk", + "tableFrom": "issue_watchdogs", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_watchdogs_watchdog_agent_id_agents_id_fk": { + "name": "issue_watchdogs_watchdog_agent_id_agents_id_fk", + "tableFrom": "issue_watchdogs", + "tableTo": "agents", + "columnsFrom": [ + "watchdog_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_watchdogs_watchdog_issue_id_issues_id_fk": { + "name": "issue_watchdogs_watchdog_issue_id_issues_id_fk", + "tableFrom": "issue_watchdogs", + "tableTo": "issues", + "columnsFrom": [ + "watchdog_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_watchdogs_created_by_agent_id_agents_id_fk": { + "name": "issue_watchdogs_created_by_agent_id_agents_id_fk", + "tableFrom": "issue_watchdogs", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_watchdogs_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_watchdogs_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_watchdogs", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_watchdogs_updated_by_agent_id_agents_id_fk": { + "name": "issue_watchdogs_updated_by_agent_id_agents_id_fk", + "tableFrom": "issue_watchdogs", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_watchdogs_updated_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_watchdogs_updated_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_watchdogs", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "updated_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_work_products": { + "name": "issue_work_products", + "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 + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "runtime_service_id": { + "name": "runtime_service_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_trust": { + "name": "source_trust", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_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_work_products_company_issue_type_idx": { + "name": "issue_work_products_company_issue_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_execution_workspace_type_idx": { + "name": "issue_work_products_company_execution_workspace_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_provider_external_id_idx": { + "name": "issue_work_products_company_provider_external_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_updated_idx": { + "name": "issue_work_products_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": { + "issue_work_products_company_id_companies_id_fk": { + "name": "issue_work_products_company_id_companies_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_work_products_project_id_projects_id_fk": { + "name": "issue_work_products_project_id_projects_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_issue_id_issues_id_fk": { + "name": "issue_work_products_issue_id_issues_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_work_products_execution_workspace_id_execution_workspaces_id_fk": { + "name": "issue_work_products_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk": { + "name": "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "workspace_runtime_services", + "columnsFrom": [ + "runtime_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_work_products_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + }, + "project_workspace_id": { + "name": "project_workspace_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'" + }, + "status_version": { + "name": "status_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_status_decision_id": { + "name": "last_status_decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "work_mode": { + "name": "work_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "harness_kind": { + "name": "harness_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "review_policy": { + "name": "review_policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "responsible_user_id": { + "name": "responsible_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 + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_fingerprint": { + "name": "origin_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "superseded_by_issue_id": { + "name": "superseded_by_issue_id", + "type": "uuid", + "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 + }, + "execution_policy": { + "name": "execution_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_state": { + "name": "execution_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "monitor_next_check_at": { + "name": "monitor_next_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "monitor_wake_requested_at": { + "name": "monitor_wake_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "monitor_last_triggered_at": { + "name": "monitor_last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "monitor_attempt_count": { + "name": "monitor_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "monitor_notes": { + "name": "monitor_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "monitor_scheduled_by": { + "name": "monitor_scheduled_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_preference": { + "name": "execution_workspace_preference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_settings": { + "name": "execution_workspace_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_trust": { + "name": "source_trust", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "unblock_descriptor": { + "name": "unblock_descriptor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "blocked_transition_at": { + "name": "blocked_transition_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "blocked_owner_notified_at": { + "name": "blocked_owner_notified_at", + "type": "timestamp with time zone", + "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_harness_kind_idx": { + "name": "issues_company_harness_kind_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "harness_kind", + "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_responsible_user_idx": { + "name": "issues_company_responsible_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "responsible_user_id", + "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_company_origin_idx": { + "name": "issues_company_origin_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_superseded_by_issue_idx": { + "name": "issues_superseded_by_issue_idx", + "columns": [ + { + "expression": "superseded_by_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_workspace_idx": { + "name": "issues_company_project_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_execution_workspace_idx": { + "name": "issues_company_execution_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_monitor_due_idx": { + "name": "issues_company_monitor_due_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "monitor_next_check_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_updated_idx": { + "name": "issues_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": {} + }, + "issues_company_created_idx": { + "name": "issues_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": {} + }, + "issues_open_normalized_title_created_idx": { + "name": "issues_open_normalized_title_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(regexp_replace(btrim(\"title\"), '\\s+', ' ', 'g'))", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"issues\".\"hidden_at\" is null and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_priority_idx": { + "name": "issues_company_priority_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "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": {} + }, + "issues_title_search_idx": { + "name": "issues_title_search_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "issues_identifier_search_idx": { + "name": "issues_identifier_search_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "issues_description_search_idx": { + "name": "issues_description_search_idx", + "columns": [ + { + "expression": "description", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "issues_open_routine_execution_uq": { + "name": "issues_open_routine_execution_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'routine_execution'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"execution_run_id\" is not null\n and \"issues\".\"status\" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_active_liveness_recovery_incident_uq": { + "name": "issues_active_liveness_recovery_incident_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'harness_liveness_escalation'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_active_liveness_recovery_leaf_uq": { + "name": "issues_active_liveness_recovery_leaf_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'harness_liveness_escalation'\n and \"issues\".\"origin_fingerprint\" <> 'default'\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_active_stale_run_evaluation_uq": { + "name": "issues_active_stale_run_evaluation_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'stale_active_run_evaluation'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_active_task_watchdog_uq": { + "name": "issues_active_task_watchdog_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'task_watchdog'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_active_productivity_review_uq": { + "name": "issues_active_productivity_review_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'issue_productivity_review'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_active_stranded_issue_recovery_uq": { + "name": "issues_active_stranded_issue_recovery_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'stranded_issue_recovery'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_onboarding_first_task_uq": { + "name": "issues_onboarding_first_task_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'onboarding_first_task'", + "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_project_workspace_id_project_workspaces_id_fk": { + "name": "issues_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "issues", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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" + }, + "issues_superseded_by_issue_id_issues_id_fk": { + "name": "issues_superseded_by_issue_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "superseded_by_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_workspace_id_execution_workspaces_id_fk": { + "name": "issues_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "issues", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "issues_company_id_uq": { + "name": "issues_company_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "id" + ] + } + }, + "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": {} + }, + "join_requests_pending_human_user_uq": { + "name": "join_requests_pending_human_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requesting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_email_uq": { + "name": "join_requests_pending_human_email_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"request_email_snapshot\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", + "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.native_run_finalizations": { + "name": "native_run_finalizations", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "result_id": { + "name": "result_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assessment_id": { + "name": "assessment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "decision_id": { + "name": "decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_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": {}, + "foreignKeys": { + "native_run_finalizations_company_id_companies_id_fk": { + "name": "native_run_finalizations_company_id_companies_id_fk", + "tableFrom": "native_run_finalizations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_finalizations_issue_company_fk": { + "name": "native_run_finalizations_issue_company_fk", + "tableFrom": "native_run_finalizations", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_finalizations_run_owner_fk": { + "name": "native_run_finalizations_run_owner_fk", + "tableFrom": "native_run_finalizations", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id" + ], + "columnsTo": [ + "company_id", + "native_issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_finalizations_result_owner_fk": { + "name": "native_run_finalizations_result_owner_fk", + "tableFrom": "native_run_finalizations", + "tableTo": "native_run_results", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "result_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "run_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_finalizations_assessment_owner_fk": { + "name": "native_run_finalizations_assessment_owner_fk", + "tableFrom": "native_run_finalizations", + "tableTo": "work_assessments", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "assessment_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "run_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_finalizations_decision_owner_fk": { + "name": "native_run_finalizations_decision_owner_fk", + "tableFrom": "native_run_finalizations", + "tableTo": "status_decisions", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "assessment_id", + "decision_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "run_id", + "assessment_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "native_run_finalizations_assessment_requires_result_check": { + "name": "native_run_finalizations_assessment_requires_result_check", + "value": "\"native_run_finalizations\".\"assessment_id\" is null or \"native_run_finalizations\".\"result_id\" is not null" + }, + "native_run_finalizations_decision_requires_assessment_check": { + "name": "native_run_finalizations_decision_requires_assessment_check", + "value": "\"native_run_finalizations\".\"decision_id\" is null or \"native_run_finalizations\".\"assessment_id\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.native_run_results": { + "name": "native_run_results", + "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 + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completion_contract_id": { + "name": "completion_contract_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "caller_result_id": { + "name": "caller_result_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caller_dedupe_key": { + "name": "caller_dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "server_fingerprint": { + "name": "server_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_status": { + "name": "schema_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rejection_code": { + "name": "rejection_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "canonical_sha256": { + "name": "canonical_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "native_run_results_run_fingerprint_uq": { + "name": "native_run_results_run_fingerprint_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "native_run_results_run_caller_result_uq": { + "name": "native_run_results_run_caller_result_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "caller_result_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "native_run_results_run_caller_dedupe_uq": { + "name": "native_run_results_run_caller_dedupe_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "caller_dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "native_run_results_company_id_companies_id_fk": { + "name": "native_run_results_company_id_companies_id_fk", + "tableFrom": "native_run_results", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_results_issue_company_fk": { + "name": "native_run_results_issue_company_fk", + "tableFrom": "native_run_results", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_results_run_contract_owner_fk": { + "name": "native_run_results_run_contract_owner_fk", + "tableFrom": "native_run_results", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "completion_contract_id" + ], + "columnsTo": [ + "company_id", + "native_issue_id", + "id", + "completion_contract_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "native_run_results_completion_contract_owner_fk": { + "name": "native_run_results_completion_contract_owner_fk", + "tableFrom": "native_run_results", + "tableTo": "completion_contracts", + "columnsFrom": [ + "company_id", + "issue_id", + "completion_contract_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "native_run_results_company_issue_run_id_uq": { + "name": "native_run_results_company_issue_run_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "issue_id", + "run_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_automation_executions": { + "name": "pipeline_automation_executions", + "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 + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "triggering_event_id": { + "name": "triggering_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_issue_id": { + "name": "execution_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "retry_of_execution_id": { + "name": "retry_of_execution_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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": { + "pipeline_automation_executions_idempotency_uq": { + "name": "pipeline_automation_executions_idempotency_uq", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "triggering_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_automation_executions_company_case_idx": { + "name": "pipeline_automation_executions_company_case_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_automation_executions_routine_idx": { + "name": "pipeline_automation_executions_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_automation_executions_execution_issue_idx": { + "name": "pipeline_automation_executions_execution_issue_idx", + "columns": [ + { + "expression": "execution_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_automation_executions_retry_of_execution_idx": { + "name": "pipeline_automation_executions_retry_of_execution_idx", + "columns": [ + { + "expression": "retry_of_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_automation_executions_company_id_companies_id_fk": { + "name": "pipeline_automation_executions_company_id_companies_id_fk", + "tableFrom": "pipeline_automation_executions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_automation_executions_case_id_pipeline_cases_id_fk": { + "name": "pipeline_automation_executions_case_id_pipeline_cases_id_fk", + "tableFrom": "pipeline_automation_executions", + "tableTo": "pipeline_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_automation_executions_routine_id_routines_id_fk": { + "name": "pipeline_automation_executions_routine_id_routines_id_fk", + "tableFrom": "pipeline_automation_executions", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_automation_executions_execution_issue_id_issues_id_fk": { + "name": "pipeline_automation_executions_execution_issue_id_issues_id_fk", + "tableFrom": "pipeline_automation_executions", + "tableTo": "issues", + "columnsFrom": [ + "execution_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pipeline_automation_executions_status_check": { + "name": "pipeline_automation_executions_status_check", + "value": "\"pipeline_automation_executions\".\"status\" in ('succeeded', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.pipeline_case_blockers": { + "name": "pipeline_case_blockers", + "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 + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_by_case_id": { + "name": "blocked_by_case_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": { + "pipeline_case_blockers_case_blocked_by_uq": { + "name": "pipeline_case_blockers_case_blocked_by_uq", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blocked_by_case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_case_blockers_blocked_by_idx": { + "name": "pipeline_case_blockers_blocked_by_idx", + "columns": [ + { + "expression": "blocked_by_case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_case_blockers_company_case_idx": { + "name": "pipeline_case_blockers_company_case_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_case_blockers_company_id_companies_id_fk": { + "name": "pipeline_case_blockers_company_id_companies_id_fk", + "tableFrom": "pipeline_case_blockers", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_case_blockers_case_id_pipeline_cases_id_fk": { + "name": "pipeline_case_blockers_case_id_pipeline_cases_id_fk", + "tableFrom": "pipeline_case_blockers", + "tableTo": "pipeline_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_case_blockers_blocked_by_case_id_pipeline_cases_id_fk": { + "name": "pipeline_case_blockers_blocked_by_case_id_pipeline_cases_id_fk", + "tableFrom": "pipeline_case_blockers", + "tableTo": "pipeline_cases", + "columnsFrom": [ + "blocked_by_case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pipeline_case_blockers_no_self_block_check": { + "name": "pipeline_case_blockers_no_self_block_check", + "value": "\"pipeline_case_blockers\".\"case_id\" <> \"pipeline_case_blockers\".\"blocked_by_case_id\"" + } + }, + "isRLSEnabled": false + }, + "public.pipeline_case_documents": { + "name": "pipeline_case_documents", + "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 + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "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": { + "pipeline_case_documents_company_case_key_uq": { + "name": "pipeline_case_documents_company_case_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_case_documents_document_uq": { + "name": "pipeline_case_documents_document_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_case_documents_company_case_updated_idx": { + "name": "pipeline_case_documents_company_case_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_case_documents_company_id_companies_id_fk": { + "name": "pipeline_case_documents_company_id_companies_id_fk", + "tableFrom": "pipeline_case_documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_case_documents_case_id_pipeline_cases_id_fk": { + "name": "pipeline_case_documents_case_id_pipeline_cases_id_fk", + "tableFrom": "pipeline_case_documents", + "tableTo": "pipeline_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_case_documents_document_id_documents_id_fk": { + "name": "pipeline_case_documents_document_id_documents_id_fk", + "tableFrom": "pipeline_case_documents", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_case_events": { + "name": "pipeline_case_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 + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_agent_id": { + "name": "actor_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "from_stage_id": { + "name": "from_stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "to_stage_id": { + "name": "to_stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "pipeline_case_events_case_created_idx": { + "name": "pipeline_case_events_case_created_idx", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_case_events_company_case_idx": { + "name": "pipeline_case_events_company_case_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_case_events_company_id_companies_id_fk": { + "name": "pipeline_case_events_company_id_companies_id_fk", + "tableFrom": "pipeline_case_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_case_events_case_id_pipeline_cases_id_fk": { + "name": "pipeline_case_events_case_id_pipeline_cases_id_fk", + "tableFrom": "pipeline_case_events", + "tableTo": "pipeline_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_case_events_actor_agent_id_agents_id_fk": { + "name": "pipeline_case_events_actor_agent_id_agents_id_fk", + "tableFrom": "pipeline_case_events", + "tableTo": "agents", + "columnsFrom": [ + "actor_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pipeline_case_events_from_stage_id_pipeline_stages_id_fk": { + "name": "pipeline_case_events_from_stage_id_pipeline_stages_id_fk", + "tableFrom": "pipeline_case_events", + "tableTo": "pipeline_stages", + "columnsFrom": [ + "from_stage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pipeline_case_events_to_stage_id_pipeline_stages_id_fk": { + "name": "pipeline_case_events_to_stage_id_pipeline_stages_id_fk", + "tableFrom": "pipeline_case_events", + "tableTo": "pipeline_stages", + "columnsFrom": [ + "to_stage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pipeline_case_events_type_check": { + "name": "pipeline_case_events_type_check", + "value": "\"pipeline_case_events\".\"type\" in (\n 'ingested',\n 'updated',\n 'claimed',\n 'lease_released',\n 'lease_expired',\n 'transitioned',\n 'transition_forced',\n 'transition_suggested',\n 'suggestion_resolved',\n 'review_decided',\n 'conversation_opened',\n 'issue_linked',\n 'issue_unlinked',\n 'automation_executed',\n 'automation_failed',\n 'automation_retry_requested',\n 'automation_effects_retired',\n 'automation_retry_dispatched',\n 'blockers_set',\n 'blockers_resolved',\n 'children_terminal',\n 'upstream_drift',\n 'drift_acknowledged'\n )" + }, + "pipeline_case_events_actor_type_check": { + "name": "pipeline_case_events_actor_type_check", + "value": "\"pipeline_case_events\".\"actor_type\" in ('user', 'agent', 'system')" + }, + "pipeline_case_events_agent_run_check": { + "name": "pipeline_case_events_agent_run_check", + "value": "\"pipeline_case_events\".\"actor_type\" <> 'agent' or \"pipeline_case_events\".\"run_id\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.pipeline_case_issue_links": { + "name": "pipeline_case_issue_links", + "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 + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "automation_attempt_id": { + "name": "automation_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retired_by_attempt_id": { + "name": "retired_by_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "retired_reason": { + "name": "retired_reason", + "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": { + "pipeline_case_issue_links_case_issue_uq": { + "name": "pipeline_case_issue_links_case_issue_uq", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_case_issue_links_issue_idx": { + "name": "pipeline_case_issue_links_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_case_issue_links_company_case_idx": { + "name": "pipeline_case_issue_links_company_case_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_case_issue_links_automation_attempt_idx": { + "name": "pipeline_case_issue_links_automation_attempt_idx", + "columns": [ + { + "expression": "automation_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_case_issue_links_company_id_companies_id_fk": { + "name": "pipeline_case_issue_links_company_id_companies_id_fk", + "tableFrom": "pipeline_case_issue_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_case_issue_links_case_id_pipeline_cases_id_fk": { + "name": "pipeline_case_issue_links_case_id_pipeline_cases_id_fk", + "tableFrom": "pipeline_case_issue_links", + "tableTo": "pipeline_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_case_issue_links_issue_id_issues_id_fk": { + "name": "pipeline_case_issue_links_issue_id_issues_id_fk", + "tableFrom": "pipeline_case_issue_links", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pipeline_case_issue_links_role_check": { + "name": "pipeline_case_issue_links_role_check", + "value": "\"pipeline_case_issue_links\".\"role\" in ('origin', 'conversation', 'work', 'automation')" + } + }, + "isRLSEnabled": false + }, + "public.pipeline_cases": { + "name": "pipeline_cases", + "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 + }, + "pipeline_id": { + "name": "pipeline_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "case_key": { + "name": "case_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "workspace_ref": { + "name": "workspace_ref", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_case_id": { + "name": "parent_case_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_case_version": { + "name": "parent_case_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_key": { + "name": "request_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "automation_attempt_id": { + "name": "automation_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "pending_suggestion": { + "name": "pending_suggestion", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lease_owner_type": { + "name": "lease_owner_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_agent_id": { + "name": "lease_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_user_id": { + "name": "lease_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terminal_kind": { + "name": "terminal_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retired_by_attempt_id": { + "name": "retired_by_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "retired_reason": { + "name": "retired_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hidden_from_board_at": { + "name": "hidden_from_board_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "child_count": { + "name": "child_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "terminal_child_count": { + "name": "terminal_child_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_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": { + "pipeline_cases_pipeline_case_key_uq": { + "name": "pipeline_cases_pipeline_case_key_uq", + "columns": [ + { + "expression": "pipeline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "case_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_cases_parent_request_key_uq": { + "name": "pipeline_cases_parent_request_key_uq", + "columns": [ + { + "expression": "parent_case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"pipeline_cases\".\"request_key\" is not null and \"pipeline_cases\".\"retired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_cases_company_idx": { + "name": "pipeline_cases_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_cases_pipeline_stage_idx": { + "name": "pipeline_cases_pipeline_stage_idx", + "columns": [ + { + "expression": "pipeline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_cases_parent_idx": { + "name": "pipeline_cases_parent_idx", + "columns": [ + { + "expression": "parent_case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_cases_automation_attempt_idx": { + "name": "pipeline_cases_automation_attempt_idx", + "columns": [ + { + "expression": "automation_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_cases_retired_idx": { + "name": "pipeline_cases_retired_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retired_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_cases_lease_expires_idx": { + "name": "pipeline_cases_lease_expires_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"pipeline_cases\".\"lease_expires_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_cases_company_id_companies_id_fk": { + "name": "pipeline_cases_company_id_companies_id_fk", + "tableFrom": "pipeline_cases", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_cases_pipeline_id_pipelines_id_fk": { + "name": "pipeline_cases_pipeline_id_pipelines_id_fk", + "tableFrom": "pipeline_cases", + "tableTo": "pipelines", + "columnsFrom": [ + "pipeline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_cases_stage_id_pipeline_stages_id_fk": { + "name": "pipeline_cases_stage_id_pipeline_stages_id_fk", + "tableFrom": "pipeline_cases", + "tableTo": "pipeline_stages", + "columnsFrom": [ + "stage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pipeline_cases_parent_case_id_pipeline_cases_id_fk": { + "name": "pipeline_cases_parent_case_id_pipeline_cases_id_fk", + "tableFrom": "pipeline_cases", + "tableTo": "pipeline_cases", + "columnsFrom": [ + "parent_case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pipeline_cases_lease_agent_id_agents_id_fk": { + "name": "pipeline_cases_lease_agent_id_agents_id_fk", + "tableFrom": "pipeline_cases", + "tableTo": "agents", + "columnsFrom": [ + "lease_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pipeline_cases_created_by_agent_id_agents_id_fk": { + "name": "pipeline_cases_created_by_agent_id_agents_id_fk", + "tableFrom": "pipeline_cases", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pipeline_cases_terminal_kind_check": { + "name": "pipeline_cases_terminal_kind_check", + "value": "\"pipeline_cases\".\"terminal_kind\" is null or \"pipeline_cases\".\"terminal_kind\" in ('done', 'cancelled')" + }, + "pipeline_cases_lease_owner_type_check": { + "name": "pipeline_cases_lease_owner_type_check", + "value": "\"pipeline_cases\".\"lease_owner_type\" is null or \"pipeline_cases\".\"lease_owner_type\" in ('user', 'agent')" + } + }, + "isRLSEnabled": false + }, + "public.pipeline_documents": { + "name": "pipeline_documents", + "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 + }, + "pipeline_id": { + "name": "pipeline_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "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": { + "pipeline_documents_company_pipeline_key_uq": { + "name": "pipeline_documents_company_pipeline_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_documents_document_uq": { + "name": "pipeline_documents_document_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_documents_company_pipeline_updated_idx": { + "name": "pipeline_documents_company_pipeline_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_documents_company_id_companies_id_fk": { + "name": "pipeline_documents_company_id_companies_id_fk", + "tableFrom": "pipeline_documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_documents_pipeline_id_pipelines_id_fk": { + "name": "pipeline_documents_pipeline_id_pipelines_id_fk", + "tableFrom": "pipeline_documents", + "tableTo": "pipelines", + "columnsFrom": [ + "pipeline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_documents_document_id_documents_id_fk": { + "name": "pipeline_documents_document_id_documents_id_fk", + "tableFrom": "pipeline_documents", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_stages": { + "name": "pipeline_stages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pipeline_id": { + "name": "pipeline_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "pipeline_stages_pipeline_key_uq": { + "name": "pipeline_stages_pipeline_key_uq", + "columns": [ + { + "expression": "pipeline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_stages_pipeline_position_idx": { + "name": "pipeline_stages_pipeline_position_idx", + "columns": [ + { + "expression": "pipeline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_stages_pipeline_id_pipelines_id_fk": { + "name": "pipeline_stages_pipeline_id_pipelines_id_fk", + "tableFrom": "pipeline_stages", + "tableTo": "pipelines", + "columnsFrom": [ + "pipeline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pipeline_stages_kind_check": { + "name": "pipeline_stages_kind_check", + "value": "\"pipeline_stages\".\"kind\" in ('working', 'review', 'done', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.pipeline_transitions": { + "name": "pipeline_transitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pipeline_id": { + "name": "pipeline_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_stage_id": { + "name": "from_stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "to_stage_id": { + "name": "to_stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "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": { + "pipeline_transitions_pipeline_edge_uq": { + "name": "pipeline_transitions_pipeline_edge_uq", + "columns": [ + { + "expression": "pipeline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "from_stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_transitions_pipeline_from_idx": { + "name": "pipeline_transitions_pipeline_from_idx", + "columns": [ + { + "expression": "pipeline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "from_stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipeline_transitions_pipeline_to_idx": { + "name": "pipeline_transitions_pipeline_to_idx", + "columns": [ + { + "expression": "pipeline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_transitions_pipeline_id_pipelines_id_fk": { + "name": "pipeline_transitions_pipeline_id_pipelines_id_fk", + "tableFrom": "pipeline_transitions", + "tableTo": "pipelines", + "columnsFrom": [ + "pipeline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_transitions_from_stage_id_pipeline_stages_id_fk": { + "name": "pipeline_transitions_from_stage_id_pipeline_stages_id_fk", + "tableFrom": "pipeline_transitions", + "tableTo": "pipeline_stages", + "columnsFrom": [ + "from_stage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipeline_transitions_to_stage_id_pipeline_stages_id_fk": { + "name": "pipeline_transitions_to_stage_id_pipeline_stages_id_fk", + "tableFrom": "pipeline_transitions", + "tableTo": "pipeline_stages", + "columnsFrom": [ + "to_stage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipelines": { + "name": "pipelines", + "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 + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enforce_transitions": { + "name": "enforce_transitions", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "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": { + "pipelines_company_key_uq": { + "name": "pipelines_company_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipelines_company_idx": { + "name": "pipelines_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pipelines_company_project_idx": { + "name": "pipelines_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": {} + } + }, + "foreignKeys": { + "pipelines_company_id_companies_id_fk": { + "name": "pipelines_company_id_companies_id_fk", + "tableFrom": "pipelines", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pipelines_project_id_projects_id_fk": { + "name": "pipelines_project_id_projects_id_fk", + "tableFrom": "pipelines", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pipelines_created_by_agent_id_agents_id_fk": { + "name": "pipelines_created_by_agent_id_agents_id_fk", + "tableFrom": "pipelines", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_company_settings": { + "name": "plugin_company_settings", + "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 + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "plugin_company_settings_company_idx": { + "name": "plugin_company_settings_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_company_settings_plugin_idx": { + "name": "plugin_company_settings_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_company_settings_company_plugin_uq": { + "name": "plugin_company_settings_company_plugin_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_company_settings_company_id_companies_id_fk": { + "name": "plugin_company_settings_company_id_companies_id_fk", + "tableFrom": "plugin_company_settings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_company_settings_plugin_id_plugins_id_fk": { + "name": "plugin_company_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_company_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_config": { + "name": "plugin_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "config_json": { + "name": "config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "plugin_config_plugin_company_idx": { + "name": "plugin_config_plugin_company_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_config_plugin_id_plugins_id_fk": { + "name": "plugin_config_plugin_id_plugins_id_fk", + "tableFrom": "plugin_config", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_config_company_id_companies_id_fk": { + "name": "plugin_config_company_id_companies_id_fk", + "tableFrom": "plugin_config", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_database_namespaces": { + "name": "plugin_database_namespaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace_name": { + "name": "namespace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace_mode": { + "name": "namespace_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schema'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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": { + "plugin_database_namespaces_plugin_idx": { + "name": "plugin_database_namespaces_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_database_namespaces_namespace_idx": { + "name": "plugin_database_namespaces_namespace_idx", + "columns": [ + { + "expression": "namespace_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_database_namespaces_status_idx": { + "name": "plugin_database_namespaces_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_database_namespaces_plugin_id_plugins_id_fk": { + "name": "plugin_database_namespaces_plugin_id_plugins_id_fk", + "tableFrom": "plugin_database_namespaces", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_entities": { + "name": "plugin_entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "plugin_entities_plugin_idx": { + "name": "plugin_entities_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_company_idx": { + "name": "plugin_entities_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_type_idx": { + "name": "plugin_entities_type_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_entities_scope_idx": { + "name": "plugin_entities_scope_idx", + "columns": [ + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_entities_plugin_id_plugins_id_fk": { + "name": "plugin_entities_plugin_id_plugins_id_fk", + "tableFrom": "plugin_entities", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_entities_company_id_companies_id_fk": { + "name": "plugin_entities_company_id_companies_id_fk", + "tableFrom": "plugin_entities", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugin_entities_external_idx": { + "name": "plugin_entities_external_idx", + "nullsNotDistinct": true, + "columns": [ + "company_id", + "plugin_id", + "entity_type", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_job_runs": { + "name": "plugin_job_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs": { + "name": "logs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_job_runs_job_idx": { + "name": "plugin_job_runs_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_job_runs_plugin_idx": { + "name": "plugin_job_runs_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_job_runs_company_idx": { + "name": "plugin_job_runs_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_job_runs_status_idx": { + "name": "plugin_job_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_job_runs_job_id_plugin_jobs_id_fk": { + "name": "plugin_job_runs_job_id_plugin_jobs_id_fk", + "tableFrom": "plugin_job_runs", + "tableTo": "plugin_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_job_runs_plugin_id_plugins_id_fk": { + "name": "plugin_job_runs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_job_runs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_job_runs_company_id_companies_id_fk": { + "name": "plugin_job_runs_company_id_companies_id_fk", + "tableFrom": "plugin_job_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_jobs": { + "name": "plugin_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_key": { + "name": "job_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_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": { + "plugin_jobs_plugin_idx": { + "name": "plugin_jobs_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_jobs_next_run_idx": { + "name": "plugin_jobs_next_run_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_jobs_unique_idx": { + "name": "plugin_jobs_unique_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "job_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_jobs_plugin_id_plugins_id_fk": { + "name": "plugin_jobs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_jobs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_logs": { + "name": "plugin_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_logs_plugin_time_idx": { + "name": "plugin_logs_plugin_time_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_logs_company_idx": { + "name": "plugin_logs_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_logs_level_idx": { + "name": "plugin_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_logs_plugin_id_plugins_id_fk": { + "name": "plugin_logs_plugin_id_plugins_id_fk", + "tableFrom": "plugin_logs", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_logs_company_id_companies_id_fk": { + "name": "plugin_logs_company_id_companies_id_fk", + "tableFrom": "plugin_logs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_managed_resources": { + "name": "plugin_managed_resources", + "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 + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_kind": { + "name": "resource_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "defaults_json": { + "name": "defaults_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "plugin_managed_resources_company_idx": { + "name": "plugin_managed_resources_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_managed_resources_plugin_idx": { + "name": "plugin_managed_resources_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_managed_resources_resource_idx": { + "name": "plugin_managed_resources_resource_idx", + "columns": [ + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_managed_resources_company_plugin_resource_uq": { + "name": "plugin_managed_resources_company_plugin_resource_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_managed_resources_company_id_companies_id_fk": { + "name": "plugin_managed_resources_company_id_companies_id_fk", + "tableFrom": "plugin_managed_resources", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_managed_resources_plugin_id_plugins_id_fk": { + "name": "plugin_managed_resources_plugin_id_plugins_id_fk", + "tableFrom": "plugin_managed_resources", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_migrations": { + "name": "plugin_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace_name": { + "name": "namespace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "migration_key": { + "name": "migration_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "plugin_migrations_plugin_key_idx": { + "name": "plugin_migrations_plugin_key_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_migrations_plugin_idx": { + "name": "plugin_migrations_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_migrations_status_idx": { + "name": "plugin_migrations_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_state": { + "name": "plugin_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "state_key": { + "name": "state_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_state_plugin_scope_idx": { + "name": "plugin_state_plugin_scope_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_state_plugin_id_plugins_id_fk": { + "name": "plugin_state_plugin_id_plugins_id_fk", + "tableFrom": "plugin_state", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plugin_state_unique_entry_idx": { + "name": "plugin_state_unique_entry_idx", + "nullsNotDistinct": true, + "columns": [ + "plugin_id", + "scope_kind", + "scope_id", + "namespace", + "state_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_webhook_deliveries": { + "name": "plugin_webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "webhook_key": { + "name": "webhook_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_webhook_deliveries_plugin_idx": { + "name": "plugin_webhook_deliveries_plugin_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_webhook_deliveries_company_idx": { + "name": "plugin_webhook_deliveries_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_webhook_deliveries_status_idx": { + "name": "plugin_webhook_deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_webhook_deliveries_key_idx": { + "name": "plugin_webhook_deliveries_key_idx", + "columns": [ + { + "expression": "webhook_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_webhook_deliveries_plugin_id_plugins_id_fk": { + "name": "plugin_webhook_deliveries_plugin_id_plugins_id_fk", + "tableFrom": "plugin_webhook_deliveries", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_webhook_deliveries_company_id_companies_id_fk": { + "name": "plugin_webhook_deliveries_company_id_companies_id_fk", + "tableFrom": "plugin_webhook_deliveries", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plugin_key": { + "name": "plugin_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_name": { + "name": "package_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_version": { + "name": "api_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "manifest_json": { + "name": "manifest_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'installed'" + }, + "install_order": { + "name": "install_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "package_path": { + "name": "package_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_at": { + "name": "installed_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": { + "plugins_plugin_key_idx": { + "name": "plugins_plugin_key_idx", + "columns": [ + { + "expression": "plugin_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugins_status_idx": { + "name": "plugins_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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_memberships": { + "name": "project_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 + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'joined'" + }, + "starred_at": { + "name": "starred_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": { + "project_memberships_company_user_idx": { + "name": "project_memberships_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": {} + }, + "project_memberships_company_user_starred_idx": { + "name": "project_memberships_company_user_starred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_memberships_project_idx": { + "name": "project_memberships_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_memberships_company_user_project_uq": { + "name": "project_memberships_company_user_project_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_memberships_company_id_companies_id_fk": { + "name": "project_memberships_company_id_companies_id_fk", + "tableFrom": "project_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_memberships_project_id_projects_id_fk": { + "name": "project_memberships_project_id_projects_id_fk", + "tableFrom": "project_memberships", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "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 + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_path'" + }, + "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 + }, + "default_ref": { + "name": "default_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "setup_command": { + "name": "setup_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cleanup_command": { + "name": "cleanup_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_provider": { + "name": "remote_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_workspace_ref": { + "name": "remote_workspace_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_workspace_key": { + "name": "shared_workspace_key", + "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": {} + }, + "project_workspaces_project_source_type_idx": { + "name": "project_workspaces_project_source_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_company_shared_key_idx": { + "name": "project_workspaces_company_shared_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shared_workspace_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_remote_ref_idx": { + "name": "project_workspaces_project_remote_ref_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "remote_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "remote_workspace_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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 + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env": { + "name": "env", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_policy": { + "name": "execution_workspace_policy", + "type": "jsonb", + "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 + }, + "public.routine_documents": { + "name": "routine_documents", + "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 + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "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": { + "routine_documents_company_routine_key_uq": { + "name": "routine_documents_company_routine_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_documents_document_uq": { + "name": "routine_documents_document_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_documents_company_routine_updated_idx": { + "name": "routine_documents_company_routine_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_documents_company_id_companies_id_fk": { + "name": "routine_documents_company_id_companies_id_fk", + "tableFrom": "routine_documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "routine_documents_routine_id_routines_id_fk": { + "name": "routine_documents_routine_id_routines_id_fk", + "tableFrom": "routine_documents", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_documents_document_id_documents_id_fk": { + "name": "routine_documents_document_id_documents_id_fk", + "tableFrom": "routine_documents", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_revisions": { + "name": "routine_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 + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "restored_from_revision_id": { + "name": "restored_from_revision_id", + "type": "uuid", + "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_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "responsible_user_id": { + "name": "responsible_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": { + "routine_revisions_routine_revision_uq": { + "name": "routine_revisions_routine_revision_uq", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_revisions_company_routine_created_idx": { + "name": "routine_revisions_company_routine_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_revisions_company_responsible_user_idx": { + "name": "routine_revisions_company_responsible_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "responsible_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_revisions_company_id_companies_id_fk": { + "name": "routine_revisions_company_id_companies_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_revisions_routine_id_routines_id_fk": { + "name": "routine_revisions_routine_id_routines_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_revisions_restored_from_revision_id_routine_revisions_id_fk": { + "name": "routine_revisions_restored_from_revision_id_routine_revisions_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "routine_revisions", + "columnsFrom": [ + "restored_from_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_revisions_created_by_agent_id_agents_id_fk": { + "name": "routine_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_revisions_created_by_run_id_heartbeat_runs_id_fk": { + "name": "routine_revisions_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "routine_revisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_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 + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "triggered_at": { + "name": "triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "routine_revision_id": { + "name": "routine_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_payload": { + "name": "trigger_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dispatch_fingerprint": { + "name": "dispatch_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_issue_id": { + "name": "linked_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "coalesced_into_run_id": { + "name": "coalesced_into_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "superseded_by_run_id": { + "name": "superseded_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_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": { + "routine_runs_company_routine_idx": { + "name": "routine_runs_company_routine_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_revision_idx": { + "name": "routine_runs_revision_idx", + "columns": [ + { + "expression": "routine_revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_company_responsible_user_idx": { + "name": "routine_runs_company_responsible_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "responsible_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_trigger_idx": { + "name": "routine_runs_trigger_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_dispatch_fingerprint_idx": { + "name": "routine_runs_dispatch_fingerprint_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatch_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_linked_issue_idx": { + "name": "routine_runs_linked_issue_idx", + "columns": [ + { + "expression": "linked_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_superseded_by_run_idx": { + "name": "routine_runs_superseded_by_run_idx", + "columns": [ + { + "expression": "superseded_by_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_trigger_idempotency_idx": { + "name": "routine_runs_trigger_idempotency_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_company_id_companies_id_fk": { + "name": "routine_runs_company_id_companies_id_fk", + "tableFrom": "routine_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_runs_trigger_id_routine_triggers_id_fk": { + "name": "routine_runs_trigger_id_routine_triggers_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routine_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_runs_routine_revision_id_routine_revisions_id_fk": { + "name": "routine_runs_routine_revision_id_routine_revisions_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routine_revisions", + "columnsFrom": [ + "routine_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_runs_linked_issue_id_issues_id_fk": { + "name": "routine_runs_linked_issue_id_issues_id_fk", + "tableFrom": "routine_runs", + "tableTo": "issues", + "columnsFrom": [ + "linked_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_runs_superseded_by_run_id_routine_runs_id_fk": { + "name": "routine_runs_superseded_by_run_id_routine_runs_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routine_runs", + "columnsFrom": [ + "superseded_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_triggers": { + "name": "routine_triggers", + "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 + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "signing_mode": { + "name": "signing_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replay_window_sec": { + "name": "replay_window_sec", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_rotated_at": { + "name": "last_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "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 + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_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": { + "routine_triggers_company_routine_idx": { + "name": "routine_triggers_company_routine_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_triggers_company_kind_idx": { + "name": "routine_triggers_company_kind_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_triggers_next_run_idx": { + "name": "routine_triggers_next_run_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_triggers_public_id_idx": { + "name": "routine_triggers_public_id_idx", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_triggers_public_id_uq": { + "name": "routine_triggers_public_id_uq", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_triggers_company_id_companies_id_fk": { + "name": "routine_triggers_company_id_companies_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_triggers_routine_id_routines_id_fk": { + "name": "routine_triggers_routine_id_routines_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_triggers_secret_id_company_secrets_id_fk": { + "name": "routine_triggers_secret_id_company_secrets_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_triggers_created_by_agent_id_agents_id_fk": { + "name": "routine_triggers_created_by_agent_id_agents_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_triggers_updated_by_agent_id_agents_id_fk": { + "name": "routine_triggers_updated_by_agent_id_agents_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "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 + }, + "folder_id": { + "name": "folder_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_issue_id": { + "name": "parent_issue_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 + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "concurrency_policy": { + "name": "concurrency_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'coalesce_if_active'" + }, + "lifecycle_policy": { + "name": "lifecycle_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'independent'" + }, + "catch_up_policy": { + "name": "catch_up_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skip_missed'" + }, + "activity_gate_policy": { + "name": "activity_gate_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'always'" + }, + "activity_gate_scope": { + "name": "activity_gate_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company'" + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "env": { + "name": "env", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "latest_revision_id": { + "name": "latest_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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 + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_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": { + "routines_company_status_idx": { + "name": "routines_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": {} + }, + "routines_company_assignee_idx": { + "name": "routines_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_company_project_idx": { + "name": "routines_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": {} + }, + "routines_company_folder_idx": { + "name": "routines_company_folder_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_company_responsible_user_idx": { + "name": "routines_company_responsible_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "responsible_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_company_origin_idx": { + "name": "routines_company_origin_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_company_id_companies_id_fk": { + "name": "routines_company_id_companies_id_fk", + "tableFrom": "routines", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_project_id_projects_id_fk": { + "name": "routines_project_id_projects_id_fk", + "tableFrom": "routines", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_folder_id_folders_id_fk": { + "name": "routines_folder_id_folders_id_fk", + "tableFrom": "routines", + "tableTo": "folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routines_goal_id_goals_id_fk": { + "name": "routines_goal_id_goals_id_fk", + "tableFrom": "routines", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routines_parent_issue_id_issues_id_fk": { + "name": "routines_parent_issue_id_issues_id_fk", + "tableFrom": "routines", + "tableTo": "issues", + "columnsFrom": [ + "parent_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routines_assignee_agent_id_agents_id_fk": { + "name": "routines_assignee_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "routines_created_by_agent_id_agents_id_fk": { + "name": "routines_created_by_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routines_updated_by_agent_id_agents_id_fk": { + "name": "routines_updated_by_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_access_events": { + "name": "secret_access_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 + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_secret_definition_id": { + "name": "user_secret_definition_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_owner_user_id": { + "name": "credential_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_type": { + "name": "credential_subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_id": { + "name": "credential_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumer_type": { + "name": "consumer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumer_id": { + "name": "consumer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_path": { + "name": "config_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_access_events_company_created_idx": { + "name": "secret_access_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": {} + }, + "secret_access_events_secret_created_idx": { + "name": "secret_access_events_secret_created_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": {} + }, + "secret_access_events_user_definition_created_idx": { + "name": "secret_access_events_user_definition_created_idx", + "columns": [ + { + "expression": "user_secret_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_access_events_company_credential_owner_idx": { + "name": "secret_access_events_company_credential_owner_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_access_events_consumer_idx": { + "name": "secret_access_events_consumer_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "consumer_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "consumer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_access_events_run_idx": { + "name": "secret_access_events_run_idx", + "columns": [ + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_access_events_company_id_companies_id_fk": { + "name": "secret_access_events_company_id_companies_id_fk", + "tableFrom": "secret_access_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "secret_access_events_secret_id_company_secrets_id_fk": { + "name": "secret_access_events_secret_id_company_secrets_id_fk", + "tableFrom": "secret_access_events", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "secret_access_events_user_secret_definition_id_user_secret_definitions_id_fk": { + "name": "secret_access_events_user_secret_definition_id_user_secret_definitions_id_fk", + "tableFrom": "secret_access_events", + "tableTo": "user_secret_definitions", + "columnsFrom": [ + "user_secret_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "secret_access_events_issue_id_issues_id_fk": { + "name": "secret_access_events_issue_id_issues_id_fk", + "tableFrom": "secret_access_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "secret_access_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "secret_access_events_plugin_id_plugins_id_fk": { + "name": "secret_access_events_plugin_id_plugins_id_fk", + "tableFrom": "secret_access_events", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.smoke_run_steps": { + "name": "smoke_run_steps", + "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 + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scenario_step": { + "name": "scenario_step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "screenshot_artifact_ref": { + "name": "screenshot_artifact_ref", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "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": { + "smoke_run_steps_company_run_idx": { + "name": "smoke_run_steps_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": {} + }, + "smoke_run_steps_company_path_idx": { + "name": "smoke_run_steps_company_path_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "smoke_run_steps_company_id_companies_id_fk": { + "name": "smoke_run_steps_company_id_companies_id_fk", + "tableFrom": "smoke_run_steps", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "smoke_run_steps_run_id_smoke_runs_id_fk": { + "name": "smoke_run_steps_run_id_smoke_runs_id_fk", + "tableFrom": "smoke_run_steps", + "tableTo": "smoke_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.smoke_runs": { + "name": "smoke_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 + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "smoke_runs_company_started_idx": { + "name": "smoke_runs_company_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "smoke_runs_company_status_idx": { + "name": "smoke_runs_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": { + "smoke_runs_company_id_companies_id_fk": { + "name": "smoke_runs_company_id_companies_id_fk", + "tableFrom": "smoke_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.status_card_updates": { + "name": "status_card_updates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation_issue_id": { + "name": "generation_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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, + "default": 0 + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "query_version": { + "name": "query_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "status_card_updates_card_started_idx": { + "name": "status_card_updates_card_started_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "status_card_updates_generation_issue_idx": { + "name": "status_card_updates_generation_issue_idx", + "columns": [ + { + "expression": "generation_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "status_card_updates_card_id_status_cards_id_fk": { + "name": "status_card_updates_card_id_status_cards_id_fk", + "tableFrom": "status_card_updates", + "tableTo": "status_cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_card_updates_generation_issue_id_issues_id_fk": { + "name": "status_card_updates_generation_issue_id_issues_id_fk", + "tableFrom": "status_card_updates", + "tableTo": "issues", + "columnsFrom": [ + "generation_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "status_card_updates_run_id_heartbeat_runs_id_fk": { + "name": "status_card_updates_run_id_heartbeat_runs_id_fk", + "tableFrom": "status_card_updates", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.status_cards": { + "name": "status_cards", + "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 + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title_pinned": { + "name": "title_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "interest_prompt": { + "name": "interest_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queries": { + "name": "queries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "query_version": { + "name": "query_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "query_compiled_at": { + "name": "query_compiled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "query_compiled_by_agent_id": { + "name": "query_compiled_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "refresh_policy": { + "name": "refresh_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'compiling'" + }, + "pending_change_count": { + "name": "pending_change_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pending_change_hash": { + "name": "pending_change_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_change_at": { + "name": "last_change_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fingerprint_at": { + "name": "fingerprint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "mentioned_issue_ids": { + "name": "mentioned_issue_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_update_run_kind": { + "name": "last_update_run_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_generated_at": { + "name": "last_generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_model": { + "name": "last_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "generating_issue_id": { + "name": "generating_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_eval_at": { + "name": "next_eval_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_by_user_id": { + "name": "archived_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_by_agent_id": { + "name": "archived_by_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": { + "status_cards_company_archived_idx": { + "name": "status_cards_company_archived_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "status_cards_company_next_eval_idx": { + "name": "status_cards_company_next_eval_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_eval_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "status_cards_company_id_companies_id_fk": { + "name": "status_cards_company_id_companies_id_fk", + "tableFrom": "status_cards", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_cards_created_by_agent_id_agents_id_fk": { + "name": "status_cards_created_by_agent_id_agents_id_fk", + "tableFrom": "status_cards", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "status_cards_query_compiled_by_agent_id_agents_id_fk": { + "name": "status_cards_query_compiled_by_agent_id_agents_id_fk", + "tableFrom": "status_cards", + "tableTo": "agents", + "columnsFrom": [ + "query_compiled_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "status_cards_agent_id_agents_id_fk": { + "name": "status_cards_agent_id_agents_id_fk", + "tableFrom": "status_cards", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "status_cards_document_id_documents_id_fk": { + "name": "status_cards_document_id_documents_id_fk", + "tableFrom": "status_cards", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "status_cards_generating_issue_id_issues_id_fk": { + "name": "status_cards_generating_issue_id_issues_id_fk", + "tableFrom": "status_cards", + "tableTo": "issues", + "columnsFrom": [ + "generating_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "status_cards_archived_by_agent_id_agents_id_fk": { + "name": "status_cards_archived_by_agent_id_agents_id_fk", + "tableFrom": "status_cards", + "tableTo": "agents", + "columnsFrom": [ + "archived_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.status_decision_effects": { + "name": "status_decision_effects", + "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 + }, + "decision_id": { + "name": "decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "effect_kind": { + "name": "effect_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "delivery_state": { + "name": "delivery_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_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": { + "status_decision_effects_decision_ordinal_uq": { + "name": "status_decision_effects_decision_ordinal_uq", + "columns": [ + { + "expression": "decision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "status_decision_effects_company_idempotency_uq": { + "name": "status_decision_effects_company_idempotency_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "status_decision_effects_company_id_companies_id_fk": { + "name": "status_decision_effects_company_id_companies_id_fk", + "tableFrom": "status_decision_effects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_decision_effects_issue_company_fk": { + "name": "status_decision_effects_issue_company_fk", + "tableFrom": "status_decision_effects", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_decision_effects_decision_owner_fk": { + "name": "status_decision_effects_decision_owner_fk", + "tableFrom": "status_decision_effects", + "tableTo": "status_decisions", + "columnsFrom": [ + "company_id", + "issue_id", + "decision_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.status_decisions": { + "name": "status_decisions", + "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 + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assessment_id": { + "name": "assessment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "decision_version": { + "name": "decision_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "policy_version": { + "name": "policy_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision_json": { + "name": "decision_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decision_digest": { + "name": "decision_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "application_state": { + "name": "application_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'proposed'" + }, + "supersedes_decision_id": { + "name": "supersedes_decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_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": { + "status_decisions_company_issue_version_uq": { + "name": "status_decisions_company_issue_version_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "decision_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "status_decisions_company_assessment_uq": { + "name": "status_decisions_company_assessment_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assessment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "status_decisions_company_issue_digest_uq": { + "name": "status_decisions_company_issue_digest_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "decision_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "status_decisions_company_id_companies_id_fk": { + "name": "status_decisions_company_id_companies_id_fk", + "tableFrom": "status_decisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_decisions_issue_company_fk": { + "name": "status_decisions_issue_company_fk", + "tableFrom": "status_decisions", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_decisions_assessment_owner_fk": { + "name": "status_decisions_assessment_owner_fk", + "tableFrom": "status_decisions", + "tableTo": "work_assessments", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "assessment_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "run_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_decisions_supersedes_owner_fk": { + "name": "status_decisions_supersedes_owner_fk", + "tableFrom": "status_decisions", + "tableTo": "status_decisions", + "columnsFrom": [ + "company_id", + "issue_id", + "supersedes_decision_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "status_decisions_company_issue_id_uq": { + "name": "status_decisions_company_issue_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "issue_id", + "id" + ] + }, + "status_decisions_company_issue_run_assessment_id_uq": { + "name": "status_decisions_company_issue_run_assessment_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "issue_id", + "run_id", + "assessment_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summary_slots": { + "name": "summary_slots", + "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 + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slot_key": { + "name": "slot_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "generating_issue_id": { + "name": "generating_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_generated_at": { + "name": "last_generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_generated_by_agent_id": { + "name": "last_generated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_model": { + "name": "last_model", + "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": { + "summary_slots_document_uq": { + "name": "summary_slots_document_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "summary_slots_company_scope_idx": { + "name": "summary_slots_company_scope_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "summary_slots_company_generating_issue_idx": { + "name": "summary_slots_company_generating_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generating_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "summary_slots_company_updated_idx": { + "name": "summary_slots_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": { + "summary_slots_company_id_companies_id_fk": { + "name": "summary_slots_company_id_companies_id_fk", + "tableFrom": "summary_slots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "summary_slots_document_id_documents_id_fk": { + "name": "summary_slots_document_id_documents_id_fk", + "tableFrom": "summary_slots", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "summary_slots_generating_issue_id_issues_id_fk": { + "name": "summary_slots_generating_issue_id_issues_id_fk", + "tableFrom": "summary_slots", + "tableTo": "issues", + "columnsFrom": [ + "generating_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "summary_slots_last_generated_by_agent_id_agents_id_fk": { + "name": "summary_slots_last_generated_by_agent_id_agents_id_fk", + "tableFrom": "summary_slots", + "tableTo": "agents", + "columnsFrom": [ + "last_generated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "summary_slots_company_scope_slot_uq": { + "name": "summary_slots_company_scope_slot_uq", + "nullsNotDistinct": true, + "columns": [ + "company_id", + "scope_kind", + "scope_id", + "slot_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_access_audit_events": { + "name": "tool_access_audit_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 + }, + "gateway_id": { + "name": "gateway_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "gateway_token_id": { + "name": "gateway_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "gateway_public_id": { + "name": "gateway_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "catalog_entry_id": { + "name": "catalog_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_access_audit_company_created_idx": { + "name": "tool_access_audit_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": {} + }, + "tool_access_audit_connection_idx": { + "name": "tool_access_audit_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_access_audit_gateway_idx": { + "name": "tool_access_audit_gateway_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gateway_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_access_audit_events_company_id_companies_id_fk": { + "name": "tool_access_audit_events_company_id_companies_id_fk", + "tableFrom": "tool_access_audit_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_access_audit_events_gateway_id_tool_mcp_gateways_id_fk": { + "name": "tool_access_audit_events_gateway_id_tool_mcp_gateways_id_fk", + "tableFrom": "tool_access_audit_events", + "tableTo": "tool_mcp_gateways", + "columnsFrom": [ + "gateway_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_access_audit_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk": { + "name": "tool_access_audit_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk", + "tableFrom": "tool_access_audit_events", + "tableTo": "tool_mcp_gateway_tokens", + "columnsFrom": [ + "gateway_token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_access_audit_events_connection_id_tool_connections_id_fk": { + "name": "tool_access_audit_events_connection_id_tool_connections_id_fk", + "tableFrom": "tool_access_audit_events", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_access_audit_events_catalog_entry_id_tool_catalog_entries_id_fk": { + "name": "tool_access_audit_events_catalog_entry_id_tool_catalog_entries_id_fk", + "tableFrom": "tool_access_audit_events", + "tableTo": "tool_catalog_entries", + "columnsFrom": [ + "catalog_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_action_requests": { + "name": "tool_action_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 + }, + "invocation_id": { + "name": "invocation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "interaction_id": { + "name": "interaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "canonical_arguments_hash": { + "name": "canonical_arguments_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_arguments_summary": { + "name": "canonical_arguments_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "signed_arguments": { + "name": "signed_arguments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_markdown": { + "name": "preview_markdown", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "resolved_by_agent_id": { + "name": "resolved_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_user_id": { + "name": "resolved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by_agent_id": { + "name": "decided_by_agent_id", + "type": "uuid", + "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 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "tool_action_requests_company_status_idx": { + "name": "tool_action_requests_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": {} + }, + "tool_action_requests_invocation_idx": { + "name": "tool_action_requests_invocation_idx", + "columns": [ + { + "expression": "invocation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_action_requests_issue_idx": { + "name": "tool_action_requests_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": {} + } + }, + "foreignKeys": { + "tool_action_requests_company_id_companies_id_fk": { + "name": "tool_action_requests_company_id_companies_id_fk", + "tableFrom": "tool_action_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_action_requests_invocation_id_tool_invocations_id_fk": { + "name": "tool_action_requests_invocation_id_tool_invocations_id_fk", + "tableFrom": "tool_action_requests", + "tableTo": "tool_invocations", + "columnsFrom": [ + "invocation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_action_requests_issue_id_issues_id_fk": { + "name": "tool_action_requests_issue_id_issues_id_fk", + "tableFrom": "tool_action_requests", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_action_requests_interaction_id_issue_thread_interactions_id_fk": { + "name": "tool_action_requests_interaction_id_issue_thread_interactions_id_fk", + "tableFrom": "tool_action_requests", + "tableTo": "issue_thread_interactions", + "columnsFrom": [ + "interaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_action_requests_approval_id_approvals_id_fk": { + "name": "tool_action_requests_approval_id_approvals_id_fk", + "tableFrom": "tool_action_requests", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_action_requests_requested_by_agent_id_agents_id_fk": { + "name": "tool_action_requests_requested_by_agent_id_agents_id_fk", + "tableFrom": "tool_action_requests", + "tableTo": "agents", + "columnsFrom": [ + "requested_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_action_requests_resolved_by_agent_id_agents_id_fk": { + "name": "tool_action_requests_resolved_by_agent_id_agents_id_fk", + "tableFrom": "tool_action_requests", + "tableTo": "agents", + "columnsFrom": [ + "resolved_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_action_requests_decided_by_agent_id_agents_id_fk": { + "name": "tool_action_requests_decided_by_agent_id_agents_id_fk", + "tableFrom": "tool_action_requests", + "tableTo": "agents", + "columnsFrom": [ + "decided_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_applications": { + "name": "tool_applications", + "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 + }, + "application_key": { + "name": "application_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "plugin_id": { + "name": "plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "tool_applications_company_idx": { + "name": "tool_applications_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_applications_company_status_idx": { + "name": "tool_applications_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": {} + }, + "tool_applications_company_name_uq": { + "name": "tool_applications_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": {} + }, + "tool_applications_company_key_uq": { + "name": "tool_applications_company_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "application_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_applications_company_id_companies_id_fk": { + "name": "tool_applications_company_id_companies_id_fk", + "tableFrom": "tool_applications", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_applications_plugin_id_plugins_id_fk": { + "name": "tool_applications_plugin_id_plugins_id_fk", + "tableFrom": "tool_applications", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_applications_owner_agent_id_agents_id_fk": { + "name": "tool_applications_owner_agent_id_agents_id_fk", + "tableFrom": "tool_applications", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_call_events": { + "name": "tool_call_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 + }, + "event_type": { + "name": "event_type", + "type": "text", + "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": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "gateway_id": { + "name": "gateway_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "gateway_token_id": { + "name": "gateway_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "gateway_public_id": { + "name": "gateway_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_subject_type": { + "name": "client_subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_subject_id": { + "name": "client_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_session_id": { + "name": "mcp_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "catalog_entry_id": { + "name": "catalog_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invocation_id": { + "name": "invocation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action_request_id": { + "name": "action_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "runtime_slot_id": { + "name": "runtime_slot_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "matched_policy_ids": { + "name": "matched_policy_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy_explanation": { + "name": "policy_explanation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_scope_summary": { + "name": "credential_scope_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "header_policy_summary": { + "name": "header_policy_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "arguments_summary": { + "name": "arguments_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_summary": { + "name": "request_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_hash": { + "name": "result_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_size_bytes": { + "name": "result_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redaction_plan": { + "name": "redaction_plan", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rate_limit_state": { + "name": "rate_limit_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_call_events_company_created_idx": { + "name": "tool_call_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": {} + }, + "tool_call_events_run_idx": { + "name": "tool_call_events_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": {} + }, + "tool_call_events_issue_idx": { + "name": "tool_call_events_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": {} + }, + "tool_call_events_invocation_idx": { + "name": "tool_call_events_invocation_idx", + "columns": [ + { + "expression": "invocation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_call_events_gateway_idx": { + "name": "tool_call_events_gateway_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gateway_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_call_events_company_id_companies_id_fk": { + "name": "tool_call_events_company_id_companies_id_fk", + "tableFrom": "tool_call_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_call_events_agent_id_agents_id_fk": { + "name": "tool_call_events_agent_id_agents_id_fk", + "tableFrom": "tool_call_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_call_events_run_id_heartbeat_runs_id_fk": { + "name": "tool_call_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "tool_call_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_call_events_issue_id_issues_id_fk": { + "name": "tool_call_events_issue_id_issues_id_fk", + "tableFrom": "tool_call_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_call_events_gateway_id_tool_mcp_gateways_id_fk": { + "name": "tool_call_events_gateway_id_tool_mcp_gateways_id_fk", + "tableFrom": "tool_call_events", + "tableTo": "tool_mcp_gateways", + "columnsFrom": [ + "gateway_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_call_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk": { + "name": "tool_call_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk", + "tableFrom": "tool_call_events", + "tableTo": "tool_mcp_gateway_tokens", + "columnsFrom": [ + "gateway_token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_call_events_application_id_tool_applications_id_fk": { + "name": "tool_call_events_application_id_tool_applications_id_fk", + "tableFrom": "tool_call_events", + "tableTo": "tool_applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_call_events_connection_id_tool_connections_id_fk": { + "name": "tool_call_events_connection_id_tool_connections_id_fk", + "tableFrom": "tool_call_events", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_call_events_catalog_entry_id_tool_catalog_entries_id_fk": { + "name": "tool_call_events_catalog_entry_id_tool_catalog_entries_id_fk", + "tableFrom": "tool_call_events", + "tableTo": "tool_catalog_entries", + "columnsFrom": [ + "catalog_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_call_events_invocation_id_tool_invocations_id_fk": { + "name": "tool_call_events_invocation_id_tool_invocations_id_fk", + "tableFrom": "tool_call_events", + "tableTo": "tool_invocations", + "columnsFrom": [ + "invocation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_call_events_action_request_id_tool_action_requests_id_fk": { + "name": "tool_call_events_action_request_id_tool_action_requests_id_fk", + "tableFrom": "tool_call_events", + "tableTo": "tool_action_requests", + "columnsFrom": [ + "action_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_call_events_runtime_slot_id_tool_runtime_slots_id_fk": { + "name": "tool_call_events_runtime_slot_id_tool_runtime_slots_id_fk", + "tableFrom": "tool_call_events", + "tableTo": "tool_runtime_slots", + "columnsFrom": [ + "runtime_slot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_catalog_entries": { + "name": "tool_catalog_entries", + "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 + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entry_kind": { + "name": "entry_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'tool'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "output_schema": { + "name": "output_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "risk_level": { + "name": "risk_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "is_read_only": { + "name": "is_read_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_write": { + "name": "is_write", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_destructive": { + "name": "is_destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version_hash": { + "name": "version_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reviewed_by_agent_id": { + "name": "reviewed_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_by_user_id": { + "name": "reviewed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quarantined_at": { + "name": "quarantined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quarantine_reason": { + "name": "quarantine_reason", + "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": { + "tool_catalog_entries_company_idx": { + "name": "tool_catalog_entries_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_catalog_entries_application_idx": { + "name": "tool_catalog_entries_application_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_catalog_entries_connection_idx": { + "name": "tool_catalog_entries_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_catalog_entries_company_status_idx": { + "name": "tool_catalog_entries_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": {} + }, + "tool_catalog_entries_connection_name_uq": { + "name": "tool_catalog_entries_connection_name_uq", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_catalog_entries_company_id_companies_id_fk": { + "name": "tool_catalog_entries_company_id_companies_id_fk", + "tableFrom": "tool_catalog_entries", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_catalog_entries_application_id_tool_applications_id_fk": { + "name": "tool_catalog_entries_application_id_tool_applications_id_fk", + "tableFrom": "tool_catalog_entries", + "tableTo": "tool_applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_catalog_entries_connection_id_tool_connections_id_fk": { + "name": "tool_catalog_entries_connection_id_tool_connections_id_fk", + "tableFrom": "tool_catalog_entries", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_catalog_entries_reviewed_by_agent_id_agents_id_fk": { + "name": "tool_catalog_entries_reviewed_by_agent_id_agents_id_fk", + "tableFrom": "tool_catalog_entries", + "tableTo": "agents", + "columnsFrom": [ + "reviewed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_connection_installs": { + "name": "tool_connection_installs", + "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 + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "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()" + } + }, + "indexes": { + "tool_connection_installs_company_target_idx": { + "name": "tool_connection_installs_company_target_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_connection_installs_connection_idx": { + "name": "tool_connection_installs_connection_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_connection_installs_target_uq": { + "name": "tool_connection_installs_target_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_connection_installs_company_id_companies_id_fk": { + "name": "tool_connection_installs_company_id_companies_id_fk", + "tableFrom": "tool_connection_installs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_connection_installs_connection_id_tool_connections_id_fk": { + "name": "tool_connection_installs_connection_id_tool_connections_id_fk", + "tableFrom": "tool_connection_installs", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_connection_installs_created_by_agent_id_agents_id_fk": { + "name": "tool_connection_installs_created_by_agent_id_agents_id_fk", + "tableFrom": "tool_connection_installs", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "tool_connection_installs_target_type_check": { + "name": "tool_connection_installs_target_type_check", + "value": "\"tool_connection_installs\".\"target_type\" in ('company', 'agent')" + } + }, + "isRLSEnabled": false + }, + "public.tool_connections": { + "name": "tool_connections", + "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 + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uid": { + "name": "uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'managed'" + }, + "ownership": { + "name": "ownership", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'customer'" + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "transport_config": { + "name": "transport_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "credential_refs": { + "name": "credential_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "credential_secret_refs": { + "name": "credential_secret_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unchecked'" + }, + "health_message": { + "name": "health_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "health_checked_at": { + "name": "health_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_health_at": { + "name": "last_health_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_catalog_refresh_at": { + "name": "last_catalog_refresh_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "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": { + "tool_connections_company_idx": { + "name": "tool_connections_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_connections_application_idx": { + "name": "tool_connections_application_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_connections_company_enabled_idx": { + "name": "tool_connections_company_enabled_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_connections_company_uid_uq": { + "name": "tool_connections_company_uid_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_connections_company_id_companies_id_fk": { + "name": "tool_connections_company_id_companies_id_fk", + "tableFrom": "tool_connections", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_connections_application_id_tool_applications_id_fk": { + "name": "tool_connections_application_id_tool_applications_id_fk", + "tableFrom": "tool_connections", + "tableTo": "tool_applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tool_connections_created_by_agent_id_agents_id_fk": { + "name": "tool_connections_created_by_agent_id_agents_id_fk", + "tableFrom": "tool_connections", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tool_connections_company_id_uq": { + "name": "tool_connections_company_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "tool_connections_ownership_check": { + "name": "tool_connections_ownership_check", + "value": "\"tool_connections\".\"ownership\" in ('platform_shared', 'platform_provisioned', 'customer', 'dcr')" + }, + "tool_connections_transport_check": { + "name": "tool_connections_transport_check", + "value": "\"tool_connections\".\"transport\" in ('mcp_remote', 'rest_api', 'local_stdio')" + }, + "tool_connections_auth_kind_check": { + "name": "tool_connections_auth_kind_check", + "value": "\"tool_connections\".\"auth_kind\" in ('oauth', 'api_key', 'none')" + } + }, + "isRLSEnabled": false + }, + "public.tool_gateway_rate_limit_counters": { + "name": "tool_gateway_rate_limit_counters", + "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 + }, + "counter_key": { + "name": "counter_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_start_at": { + "name": "window_start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_ms": { + "name": "window_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "limit": { + "name": "limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reset_at": { + "name": "reset_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_gateway_rate_limit_counters_company_idx": { + "name": "tool_gateway_rate_limit_counters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_gateway_rate_limit_counters_window_uq": { + "name": "tool_gateway_rate_limit_counters_window_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "counter_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_gateway_rate_limit_counters_company_id_companies_id_fk": { + "name": "tool_gateway_rate_limit_counters_company_id_companies_id_fk", + "tableFrom": "tool_gateway_rate_limit_counters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_gateway_sessions": { + "name": "tool_gateway_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 + }, + "run_id": { + "name": "run_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 + }, + "gateway_id": { + "name": "gateway_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "gateway_token_id": { + "name": "gateway_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "gateway_public_id": { + "name": "gateway_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_subject_type": { + "name": "client_subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_subject_id": { + "name": "client_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_session_id": { + "name": "mcp_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_gateway_sessions_token_hash_uq": { + "name": "tool_gateway_sessions_token_hash_uq", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_gateway_sessions_company_agent_idx": { + "name": "tool_gateway_sessions_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": {} + }, + "tool_gateway_sessions_company_expires_idx": { + "name": "tool_gateway_sessions_company_expires_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_gateway_sessions_run_idx": { + "name": "tool_gateway_sessions_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": {} + }, + "tool_gateway_sessions_issue_idx": { + "name": "tool_gateway_sessions_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": {} + }, + "tool_gateway_sessions_gateway_idx": { + "name": "tool_gateway_sessions_gateway_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gateway_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_gateway_sessions_company_id_companies_id_fk": { + "name": "tool_gateway_sessions_company_id_companies_id_fk", + "tableFrom": "tool_gateway_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_gateway_sessions_agent_id_agents_id_fk": { + "name": "tool_gateway_sessions_agent_id_agents_id_fk", + "tableFrom": "tool_gateway_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_gateway_sessions_run_id_heartbeat_runs_id_fk": { + "name": "tool_gateway_sessions_run_id_heartbeat_runs_id_fk", + "tableFrom": "tool_gateway_sessions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_gateway_sessions_issue_id_issues_id_fk": { + "name": "tool_gateway_sessions_issue_id_issues_id_fk", + "tableFrom": "tool_gateway_sessions", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_gateway_sessions_project_id_projects_id_fk": { + "name": "tool_gateway_sessions_project_id_projects_id_fk", + "tableFrom": "tool_gateway_sessions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_gateway_sessions_gateway_id_tool_mcp_gateways_id_fk": { + "name": "tool_gateway_sessions_gateway_id_tool_mcp_gateways_id_fk", + "tableFrom": "tool_gateway_sessions", + "tableTo": "tool_mcp_gateways", + "columnsFrom": [ + "gateway_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_gateway_sessions_gateway_token_id_tool_mcp_gateway_tokens_id_fk": { + "name": "tool_gateway_sessions_gateway_token_id_tool_mcp_gateway_tokens_id_fk", + "tableFrom": "tool_gateway_sessions", + "tableTo": "tool_mcp_gateway_tokens", + "columnsFrom": [ + "gateway_token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_invocations": { + "name": "tool_invocations", + "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 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "gateway_id": { + "name": "gateway_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "gateway_token_id": { + "name": "gateway_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "gateway_public_id": { + "name": "gateway_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_subject_type": { + "name": "client_subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_subject_id": { + "name": "client_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_session_id": { + "name": "mcp_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "catalog_entry_id": { + "name": "catalog_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "catalog_version_hash": { + "name": "catalog_version_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "catalog_schema_hash": { + "name": "catalog_schema_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_key": { + "name": "application_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "upstream_tool_name": { + "name": "upstream_tool_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "risk_level": { + "name": "risk_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "arguments_hash": { + "name": "arguments_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "arguments_summary": { + "name": "arguments_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "policy_decision": { + "name": "policy_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "matched_policy_ids": { + "name": "matched_policy_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "policy_explanation": { + "name": "policy_explanation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_scope_summary": { + "name": "credential_scope_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "header_policy_summary": { + "name": "header_policy_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approval_state": { + "name": "approval_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_required'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "upstream_request_id": { + "name": "upstream_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_hash": { + "name": "result_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_size_bytes": { + "name": "result_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "result_artifact_id": { + "name": "result_artifact_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "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 + }, + "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": { + "tool_invocations_company_created_idx": { + "name": "tool_invocations_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": {} + }, + "tool_invocations_run_idx": { + "name": "tool_invocations_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": {} + }, + "tool_invocations_issue_idx": { + "name": "tool_invocations_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": {} + }, + "tool_invocations_gateway_idx": { + "name": "tool_invocations_gateway_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gateway_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_invocations_company_idempotency_uq": { + "name": "tool_invocations_company_idempotency_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_invocations_company_id_companies_id_fk": { + "name": "tool_invocations_company_id_companies_id_fk", + "tableFrom": "tool_invocations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_invocations_agent_id_agents_id_fk": { + "name": "tool_invocations_agent_id_agents_id_fk", + "tableFrom": "tool_invocations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_invocations_issue_id_issues_id_fk": { + "name": "tool_invocations_issue_id_issues_id_fk", + "tableFrom": "tool_invocations", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_invocations_run_id_heartbeat_runs_id_fk": { + "name": "tool_invocations_run_id_heartbeat_runs_id_fk", + "tableFrom": "tool_invocations", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_invocations_gateway_id_tool_mcp_gateways_id_fk": { + "name": "tool_invocations_gateway_id_tool_mcp_gateways_id_fk", + "tableFrom": "tool_invocations", + "tableTo": "tool_mcp_gateways", + "columnsFrom": [ + "gateway_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_invocations_gateway_token_id_tool_mcp_gateway_tokens_id_fk": { + "name": "tool_invocations_gateway_token_id_tool_mcp_gateway_tokens_id_fk", + "tableFrom": "tool_invocations", + "tableTo": "tool_mcp_gateway_tokens", + "columnsFrom": [ + "gateway_token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_invocations_application_id_tool_applications_id_fk": { + "name": "tool_invocations_application_id_tool_applications_id_fk", + "tableFrom": "tool_invocations", + "tableTo": "tool_applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_invocations_connection_id_tool_connections_id_fk": { + "name": "tool_invocations_connection_id_tool_connections_id_fk", + "tableFrom": "tool_invocations", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_invocations_catalog_entry_id_tool_catalog_entries_id_fk": { + "name": "tool_invocations_catalog_entry_id_tool_catalog_entries_id_fk", + "tableFrom": "tool_invocations", + "tableTo": "tool_catalog_entries", + "columnsFrom": [ + "catalog_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_mcp_gateway_tokens": { + "name": "tool_mcp_gateway_tokens", + "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 + }, + "gateway_id": { + "name": "gateway_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gateway_client'" + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_label": { + "name": "client_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "owner_note": { + "name": "owner_note", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "allowed_actions": { + "name": "allowed_actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"tools/list\",\"tools/call\"]'::jsonb" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expiry_override_reason": { + "name": "expiry_override_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_override_by_user_id": { + "name": "expiry_override_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_override_by_agent_id": { + "name": "expiry_override_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expiry_override_at": { + "name": "expiry_override_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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_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": { + "tool_mcp_gateway_tokens_token_hash_uq": { + "name": "tool_mcp_gateway_tokens_token_hash_uq", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_mcp_gateway_tokens_gateway_idx": { + "name": "tool_mcp_gateway_tokens_gateway_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gateway_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_mcp_gateway_tokens_subject_idx": { + "name": "tool_mcp_gateway_tokens_subject_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_mcp_gateway_tokens_company_expires_idx": { + "name": "tool_mcp_gateway_tokens_company_expires_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_mcp_gateway_tokens_company_id_companies_id_fk": { + "name": "tool_mcp_gateway_tokens_company_id_companies_id_fk", + "tableFrom": "tool_mcp_gateway_tokens", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_mcp_gateway_tokens_gateway_id_tool_mcp_gateways_id_fk": { + "name": "tool_mcp_gateway_tokens_gateway_id_tool_mcp_gateways_id_fk", + "tableFrom": "tool_mcp_gateway_tokens", + "tableTo": "tool_mcp_gateways", + "columnsFrom": [ + "gateway_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_mcp_gateway_tokens_expiry_override_by_agent_id_agents_id_fk": { + "name": "tool_mcp_gateway_tokens_expiry_override_by_agent_id_agents_id_fk", + "tableFrom": "tool_mcp_gateway_tokens", + "tableTo": "agents", + "columnsFrom": [ + "expiry_override_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_mcp_gateway_tokens_created_by_agent_id_agents_id_fk": { + "name": "tool_mcp_gateway_tokens_created_by_agent_id_agents_id_fk", + "tableFrom": "tool_mcp_gateway_tokens", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_mcp_gateways": { + "name": "tool_mcp_gateways", + "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 + }, + "gateway_public_id": { + "name": "gateway_public_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gw_' || replace(gen_random_uuid()::text, '-', '')" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_slug": { + "name": "display_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "default_profile_mode": { + "name": "default_profile_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gateway_only'" + }, + "context_scope_type": { + "name": "context_scope_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "context_scope_id": { + "name": "context_scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approval_issue_id": { + "name": "approval_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_config": { + "name": "auth_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"version\":1,\"bearer\":{\"enabled\":true,\"tokenPrefix\":\"pcgw\",\"defaultTtlSeconds\":7776000,\"requireFiniteExpiry\":true,\"longLivedTokenRequiresOverride\":true},\"oauth\":{\"enabled\":false,\"reservedFor\":\"v1_5\",\"dynamicClientRegistration\":false,\"authorizationCodePkce\":false}}'::jsonb" + }, + "header_policy": { + "name": "header_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"version\":1,\"callerPassthrough\":{\"enabled\":false,\"allowedHeaders\":[]},\"staticHeaders\":[],\"generatedMetadata\":{\"enabled\":false,\"allowedHeaders\":[]},\"responseHeaders\":{\"forwardMcpRequiredHeaders\":true,\"forwardSafeCacheHeaders\":true}}'::jsonb" + }, + "metadata_policy": { + "name": "metadata_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"version\":1,\"forwardCompanyId\":false,\"forwardGatewayId\":false,\"forwardProjectId\":false,\"forwardIssueId\":false,\"forwardAgentId\":false,\"forwardRunId\":false,\"forwardCorrelationId\":true}'::jsonb" + }, + "on_demand_tools_config": { + "name": "on_demand_tools_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"enabled\":false,\"searchToolName\":\"search_tools\",\"runToolName\":\"run_tool\"}'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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 + }, + "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": { + "tool_mcp_gateways_company_idx": { + "name": "tool_mcp_gateways_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_mcp_gateways_company_status_idx": { + "name": "tool_mcp_gateways_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": {} + }, + "tool_mcp_gateways_profile_idx": { + "name": "tool_mcp_gateways_profile_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_mcp_gateways_public_id_uq": { + "name": "tool_mcp_gateways_public_id_uq", + "columns": [ + { + "expression": "gateway_public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_mcp_gateways_company_slug_uq": { + "name": "tool_mcp_gateways_company_slug_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_mcp_gateways_company_name_uq": { + "name": "tool_mcp_gateways_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": { + "tool_mcp_gateways_company_id_companies_id_fk": { + "name": "tool_mcp_gateways_company_id_companies_id_fk", + "tableFrom": "tool_mcp_gateways", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_mcp_gateways_profile_id_tool_profiles_id_fk": { + "name": "tool_mcp_gateways_profile_id_tool_profiles_id_fk", + "tableFrom": "tool_mcp_gateways", + "tableTo": "tool_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "tool_mcp_gateways_agent_id_agents_id_fk": { + "name": "tool_mcp_gateways_agent_id_agents_id_fk", + "tableFrom": "tool_mcp_gateways", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_mcp_gateways_project_id_projects_id_fk": { + "name": "tool_mcp_gateways_project_id_projects_id_fk", + "tableFrom": "tool_mcp_gateways", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_mcp_gateways_issue_id_issues_id_fk": { + "name": "tool_mcp_gateways_issue_id_issues_id_fk", + "tableFrom": "tool_mcp_gateways", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_mcp_gateways_approval_issue_id_issues_id_fk": { + "name": "tool_mcp_gateways_approval_issue_id_issues_id_fk", + "tableFrom": "tool_mcp_gateways", + "tableTo": "issues", + "columnsFrom": [ + "approval_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_mcp_gateways_created_by_agent_id_agents_id_fk": { + "name": "tool_mcp_gateways_created_by_agent_id_agents_id_fk", + "tableFrom": "tool_mcp_gateways", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_oauth_states": { + "name": "tool_oauth_states", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_session_id": { + "name": "created_by_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_scopes": { + "name": "requested_scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "return_to": { + "name": "return_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "interaction_id": { + "name": "interaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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": true, + "default": "now()" + } + }, + "indexes": { + "tool_oauth_states_company_idx": { + "name": "tool_oauth_states_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_oauth_states_connection_idx": { + "name": "tool_oauth_states_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_oauth_states_actor_idx": { + "name": "tool_oauth_states_actor_idx", + "columns": [ + { + "expression": "created_by_actor_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by_actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_oauth_states_expires_at_idx": { + "name": "tool_oauth_states_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_oauth_states_company_id_companies_id_fk": { + "name": "tool_oauth_states_company_id_companies_id_fk", + "tableFrom": "tool_oauth_states", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_oauth_states_connection_id_tool_connections_id_fk": { + "name": "tool_oauth_states_connection_id_tool_connections_id_fk", + "tableFrom": "tool_oauth_states", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policies": { + "name": "tool_policies", + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy_type": { + "name": "policy_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "selectors": { + "name": "selectors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "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": { + "tool_policies_company_enabled_idx": { + "name": "tool_policies_company_enabled_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_policies_company_type_idx": { + "name": "tool_policies_company_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "policy_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_policies_company_name_uq": { + "name": "tool_policies_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": { + "tool_policies_company_id_companies_id_fk": { + "name": "tool_policies_company_id_companies_id_fk", + "tableFrom": "tool_policies", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_policies_created_by_agent_id_agents_id_fk": { + "name": "tool_policies_created_by_agent_id_agents_id_fk", + "tableFrom": "tool_policies", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_profile_bindings": { + "name": "tool_profile_bindings", + "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 + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "tool_profile_bindings_company_target_idx": { + "name": "tool_profile_bindings_company_target_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_profile_bindings_target_profile_uq": { + "name": "tool_profile_bindings_target_profile_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_profile_bindings_company_id_companies_id_fk": { + "name": "tool_profile_bindings_company_id_companies_id_fk", + "tableFrom": "tool_profile_bindings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_profile_bindings_profile_id_tool_profiles_id_fk": { + "name": "tool_profile_bindings_profile_id_tool_profiles_id_fk", + "tableFrom": "tool_profile_bindings", + "tableTo": "tool_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_profile_bindings_created_by_agent_id_agents_id_fk": { + "name": "tool_profile_bindings_created_by_agent_id_agents_id_fk", + "tableFrom": "tool_profile_bindings", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_profile_entries": { + "name": "tool_profile_entries", + "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 + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selector_type": { + "name": "selector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'include'" + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "catalog_entry_id": { + "name": "catalog_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "risk_level": { + "name": "risk_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "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": { + "tool_profile_entries_company_profile_idx": { + "name": "tool_profile_entries_company_profile_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_profile_entries_application_idx": { + "name": "tool_profile_entries_application_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_profile_entries_connection_idx": { + "name": "tool_profile_entries_connection_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_profile_entries_catalog_entry_idx": { + "name": "tool_profile_entries_catalog_entry_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "catalog_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_profile_entries_company_id_companies_id_fk": { + "name": "tool_profile_entries_company_id_companies_id_fk", + "tableFrom": "tool_profile_entries", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_profile_entries_profile_id_tool_profiles_id_fk": { + "name": "tool_profile_entries_profile_id_tool_profiles_id_fk", + "tableFrom": "tool_profile_entries", + "tableTo": "tool_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_profile_entries_application_id_tool_applications_id_fk": { + "name": "tool_profile_entries_application_id_tool_applications_id_fk", + "tableFrom": "tool_profile_entries", + "tableTo": "tool_applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_profile_entries_connection_id_tool_connections_id_fk": { + "name": "tool_profile_entries_connection_id_tool_connections_id_fk", + "tableFrom": "tool_profile_entries", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_profile_entries_catalog_entry_id_tool_catalog_entries_id_fk": { + "name": "tool_profile_entries_catalog_entry_id_tool_catalog_entries_id_fk", + "tableFrom": "tool_profile_entries", + "tableTo": "tool_catalog_entries", + "columnsFrom": [ + "catalog_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_profiles": { + "name": "tool_profiles", + "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 + }, + "profile_key": { + "name": "profile_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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'" + }, + "default_action": { + "name": "default_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'deny'" + }, + "new_tools_reviewed_at": { + "name": "new_tools_reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "tool_profiles_company_status_idx": { + "name": "tool_profiles_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": {} + }, + "tool_profiles_company_key_uq": { + "name": "tool_profiles_company_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_profiles_company_name_uq": { + "name": "tool_profiles_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": { + "tool_profiles_company_id_companies_id_fk": { + "name": "tool_profiles_company_id_companies_id_fk", + "tableFrom": "tool_profiles", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_rate_limit_counters": { + "name": "tool_rate_limit_counters", + "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 + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "counter_key": { + "name": "counter_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_kind": { + "name": "window_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_start_at": { + "name": "window_start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "limit": { + "name": "limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reset_at": { + "name": "reset_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_rate_limit_counters_company_idx": { + "name": "tool_rate_limit_counters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_rate_limit_counters_window_uq": { + "name": "tool_rate_limit_counters_window_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "policy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "counter_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_rate_limit_counters_company_id_companies_id_fk": { + "name": "tool_rate_limit_counters_company_id_companies_id_fk", + "tableFrom": "tool_rate_limit_counters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_rate_limit_counters_policy_id_tool_policies_id_fk": { + "name": "tool_rate_limit_counters_policy_id_tool_policies_id_fk", + "tableFrom": "tool_rate_limit_counters", + "tableTo": "tool_policies", + "columnsFrom": [ + "policy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_runtime_metric_counters": { + "name": "tool_runtime_metric_counters", + "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 + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bucket_start_at": { + "name": "bucket_start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "tool_runtime_metric_counters_company_metric_idx": { + "name": "tool_runtime_metric_counters_company_metric_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bucket_start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_runtime_metric_counters_bucket_uq": { + "name": "tool_runtime_metric_counters_bucket_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bucket_start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_runtime_metric_counters_company_id_companies_id_fk": { + "name": "tool_runtime_metric_counters_company_id_companies_id_fk", + "tableFrom": "tool_runtime_metric_counters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_runtime_slots": { + "name": "tool_runtime_slots", + "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 + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_scope_type": { + "name": "owner_scope_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connection'" + }, + "owner_scope_id": { + "name": "owner_scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_kind": { + "name": "runtime_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_stdio'" + }, + "slot_key": { + "name": "slot_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "reuse_key": { + "name": "reuse_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_scope": { + "name": "workspace_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_scope_hash": { + "name": "credential_scope_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "process_id": { + "name": "process_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "command_template_key": { + "name": "command_template_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unchecked'" + }, + "health_message": { + "name": "health_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health_check_at": { + "name": "last_health_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "idle_expires_at": { + "name": "idle_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "idle_deadline_at": { + "name": "idle_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "tool_runtime_slots_company_idx": { + "name": "tool_runtime_slots_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_runtime_slots_connection_idx": { + "name": "tool_runtime_slots_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_runtime_slots_execution_workspace_idx": { + "name": "tool_runtime_slots_execution_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_runtime_slots_slot_key_uq": { + "name": "tool_runtime_slots_slot_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slot_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_runtime_slots_company_id_companies_id_fk": { + "name": "tool_runtime_slots_company_id_companies_id_fk", + "tableFrom": "tool_runtime_slots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_runtime_slots_application_id_tool_applications_id_fk": { + "name": "tool_runtime_slots_application_id_tool_applications_id_fk", + "tableFrom": "tool_runtime_slots", + "tableTo": "tool_applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_runtime_slots_connection_id_tool_connections_id_fk": { + "name": "tool_runtime_slots_connection_id_tool_connections_id_fk", + "tableFrom": "tool_runtime_slots", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_runtime_slots_project_workspace_id_project_workspaces_id_fk": { + "name": "tool_runtime_slots_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "tool_runtime_slots", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_runtime_slots_execution_workspace_id_execution_workspaces_id_fk": { + "name": "tool_runtime_slots_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "tool_runtime_slots", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tool_runtime_slots_issue_id_issues_id_fk": { + "name": "tool_runtime_slots_issue_id_issues_id_fk", + "tableFrom": "tool_runtime_slots", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_stdio_command_templates": { + "name": "tool_stdio_command_templates", + "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 + }, + "template_key": { + "name": "template_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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'" + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "env_keys": { + "name": "env_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "tools": { + "name": "tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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 + }, + "disabled_at": { + "name": "disabled_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": { + "tool_stdio_command_templates_company_idx": { + "name": "tool_stdio_command_templates_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_stdio_command_templates_company_status_idx": { + "name": "tool_stdio_command_templates_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": {} + }, + "tool_stdio_command_templates_company_key_uq": { + "name": "tool_stdio_command_templates_company_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_stdio_command_templates_company_id_companies_id_fk": { + "name": "tool_stdio_command_templates_company_id_companies_id_fk", + "tableFrom": "tool_stdio_command_templates", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_stdio_command_templates_created_by_agent_id_agents_id_fk": { + "name": "tool_stdio_command_templates_created_by_agent_id_agents_id_fk", + "tableFrom": "tool_stdio_command_templates", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_inbox_agent_policies": { + "name": "user_inbox_agent_policies", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "allowed_agent_ids": { + "name": "allowed_agent_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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": { + "user_inbox_agent_policies_company_user_uq": { + "name": "user_inbox_agent_policies_company_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_inbox_agent_policies_allowed_agent_ids_idx": { + "name": "user_inbox_agent_policies_allowed_agent_ids_idx", + "columns": [ + { + "expression": "allowed_agent_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "user_inbox_agent_policies_company_id_companies_id_fk": { + "name": "user_inbox_agent_policies_company_id_companies_id_fk", + "tableFrom": "user_inbox_agent_policies", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_inbox_agent_policies_mode_check": { + "name": "user_inbox_agent_policies_mode_check", + "value": "\"user_inbox_agent_policies\".\"mode\" in ('open', 'allowlist', 'disabled')" + } + }, + "isRLSEnabled": false + }, + "public.user_secret_declarations": { + "name": "user_secret_declarations", + "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 + }, + "user_secret_definition_id": { + "name": "user_secret_definition_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_path": { + "name": "config_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_selector": { + "name": "version_selector", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'latest'" + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_missing_override": { + "name": "allow_missing_override", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "label": { + "name": "label", + "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": { + "user_secret_declarations_company_idx": { + "name": "user_secret_declarations_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_secret_declarations_definition_idx": { + "name": "user_secret_declarations_definition_idx", + "columns": [ + { + "expression": "user_secret_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_secret_declarations_target_idx": { + "name": "user_secret_declarations_target_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_secret_declarations_company_required_idx": { + "name": "user_secret_declarations_company_required_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "required", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_secret_declarations_target_path_uq": { + "name": "user_secret_declarations_target_path_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "config_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_secret_declarations_required_override_idx": { + "name": "user_secret_declarations_required_override_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "allow_missing_override", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_secret_declarations\".\"allow_missing_override\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_secret_declarations_company_id_companies_id_fk": { + "name": "user_secret_declarations_company_id_companies_id_fk", + "tableFrom": "user_secret_declarations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk": { + "name": "user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk", + "tableFrom": "user_secret_declarations", + "tableTo": "user_secret_definitions", + "columnsFrom": [ + "user_secret_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_secret_definitions": { + "name": "user_secret_definitions", + "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 + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "managed_mode": { + "name": "managed_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip_managed'" + }, + "provider_config_id": { + "name": "provider_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "usage_guidance": { + "name": "usage_guidance", + "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 + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_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": { + "user_secret_definitions_company_status_idx": { + "name": "user_secret_definitions_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": {} + }, + "user_secret_definitions_company_provider_idx": { + "name": "user_secret_definitions_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": {} + }, + "user_secret_definitions_provider_config_idx": { + "name": "user_secret_definitions_provider_config_idx", + "columns": [ + { + "expression": "provider_config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_secret_definitions_company_key_uq": { + "name": "user_secret_definitions_company_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_secret_definitions\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_secret_definitions_company_id_companies_id_fk": { + "name": "user_secret_definitions_company_id_companies_id_fk", + "tableFrom": "user_secret_definitions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_secret_definitions_provider_config_id_company_secret_provider_configs_id_fk": { + "name": "user_secret_definitions_provider_config_id_company_secret_provider_configs_id_fk", + "tableFrom": "user_secret_definitions", + "tableTo": "company_secret_provider_configs", + "columnsFrom": [ + "provider_config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_secret_definitions_created_by_agent_id_agents_id_fk": { + "name": "user_secret_definitions_created_by_agent_id_agents_id_fk", + "tableFrom": "user_secret_definitions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_secret_definitions_updated_by_agent_id_agents_id_fk": { + "name": "user_secret_definitions_updated_by_agent_id_agents_id_fk", + "tableFrom": "user_secret_definitions", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_sidebar_preferences": { + "name": "user_sidebar_preferences", + "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 + }, + "company_order": { + "name": "company_order", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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": { + "user_sidebar_preferences_user_uq": { + "name": "user_sidebar_preferences_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_assessments": { + "name": "work_assessments", + "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 + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contract_id": { + "name": "contract_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "result_id": { + "name": "result_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger_kind": { + "name": "trigger_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_ref": { + "name": "trigger_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_capability": { + "name": "trigger_capability", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_actor_company_id": { + "name": "trigger_actor_company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prior_issue_status": { + "name": "prior_issue_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prior_status_version": { + "name": "prior_status_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "prior_decision_id": { + "name": "prior_decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "policy_version": { + "name": "policy_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assessment_json": { + "name": "assessment_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "input_digest": { + "name": "input_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "supersedes_assessment_id": { + "name": "supersedes_assessment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_assessments_company_issue_input_uq": { + "name": "work_assessments_company_issue_input_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "input_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "work_assessments_company_id_companies_id_fk": { + "name": "work_assessments_company_id_companies_id_fk", + "tableFrom": "work_assessments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_assessments_trigger_actor_company_id_companies_id_fk": { + "name": "work_assessments_trigger_actor_company_id_companies_id_fk", + "tableFrom": "work_assessments", + "tableTo": "companies", + "columnsFrom": [ + "trigger_actor_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_assessments_issue_company_fk": { + "name": "work_assessments_issue_company_fk", + "tableFrom": "work_assessments", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_assessments_run_owner_fk": { + "name": "work_assessments_run_owner_fk", + "tableFrom": "work_assessments", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id" + ], + "columnsTo": [ + "company_id", + "native_issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_assessments_contract_owner_fk": { + "name": "work_assessments_contract_owner_fk", + "tableFrom": "work_assessments", + "tableTo": "completion_contracts", + "columnsFrom": [ + "company_id", + "issue_id", + "contract_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_assessments_result_owner_fk": { + "name": "work_assessments_result_owner_fk", + "tableFrom": "work_assessments", + "tableTo": "native_run_results", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "result_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "run_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "work_assessments_supersedes_owner_fk": { + "name": "work_assessments_supersedes_owner_fk", + "tableFrom": "work_assessments", + "tableTo": "work_assessments", + "columnsFrom": [ + "company_id", + "issue_id", + "run_id", + "supersedes_assessment_id" + ], + "columnsTo": [ + "company_id", + "issue_id", + "run_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "work_assessments_company_issue_run_id_uq": { + "name": "work_assessments_company_issue_run_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "issue_id", + "run_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "work_assessments_trigger_actor_company_check": { + "name": "work_assessments_trigger_actor_company_check", + "value": "\"work_assessments\".\"trigger_actor_company_id\" = \"work_assessments\".\"company_id\"" + } + }, + "isRLSEnabled": false + }, + "public.workspace_operations": { + "name": "workspace_operations", + "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 + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "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 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_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": { + "workspace_operations_company_run_started_idx": { + "name": "workspace_operations_company_run_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_operations_company_workspace_started_idx": { + "name": "workspace_operations_company_workspace_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_operations_company_workspace_issue_started_idx": { + "name": "workspace_operations_company_workspace_issue_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_operations_company_id_companies_id_fk": { + "name": "workspace_operations_company_id_companies_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_operations_execution_workspace_id_execution_workspaces_id_fk": { + "name": "workspace_operations_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_operations_issue_id_issues_id_fk": { + "name": "workspace_operations_issue_id_issues_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_runtime_services": { + "name": "workspace_runtime_services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reuse_key": { + "name": "reuse_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_by_run_id": { + "name": "started_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stop_policy": { + "name": "stop_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exposure": { + "name": "exposure", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exposure_handle": { + "name": "exposure_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backend_url": { + "name": "backend_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "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": { + "workspace_runtime_services_company_workspace_status_idx": { + "name": "workspace_runtime_services_company_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_execution_workspace_status_idx": { + "name": "workspace_runtime_services_company_execution_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_project_status_idx": { + "name": "workspace_runtime_services_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_run_idx": { + "name": "workspace_runtime_services_run_idx", + "columns": [ + { + "expression": "started_by_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_updated_idx": { + "name": "workspace_runtime_services_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": { + "workspace_runtime_services_company_id_companies_id_fk": { + "name": "workspace_runtime_services_company_id_companies_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_id_projects_id_fk": { + "name": "workspace_runtime_services_project_id_projects_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": { + "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk": { + "name": "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_issue_id_issues_id_fk": { + "name": "workspace_runtime_services_issue_id_issues_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_owner_agent_id_agents_id_fk": { + "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { + "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "started_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 e5ac9dd490c..3169fccb599 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1580,9 +1580,16 @@ { "idx": 227, "version": "7", - "when": 1787470797345, - "tag": "0227_zippy_blockbuster", + "when": 1787668790923, + "tag": "0227_modern_pandemic", + "breakpoints": true + }, + { + "idx": 228, + "version": "7", + "when": 1787685849681, + "tag": "0228_zippy_blockbuster", "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/schema/completion_contracts.ts b/packages/db/src/schema/completion_contracts.ts new file mode 100644 index 00000000000..963ead9c9ae --- /dev/null +++ b/packages/db/src/schema/completion_contracts.ts @@ -0,0 +1,59 @@ +import { + foreignKey, + pgTable, + uuid, + text, + timestamp, + integer, + jsonb, + unique, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; + +export const completionContracts = pgTable( + "completion_contracts", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + issueId: uuid("issue_id").notNull(), + revision: integer("revision").notNull(), + schemaVersion: text("schema_version").notNull(), + policyVersion: text("policy_version").notNull(), + risk: text("risk").notNull(), + completionAuthority: text("completion_authority").notNull(), + incompleteCriteriaPolicy: text("incomplete_criteria_policy").notNull(), + contractJson: jsonb("contract_json").$type>().notNull(), + canonicalSha256: text("canonical_sha256").notNull(), + createdByActorType: text("created_by_actor_type").notNull(), + createdByActorId: text("created_by_actor_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + supersedesContractId: uuid("supersedes_contract_id"), + }, + (table) => ({ + companyIssueIdUq: unique("completion_contracts_company_issue_id_uq").on( + table.companyId, + table.issueId, + table.id, + ), + issueCompanyFk: foreignKey({ + columns: [table.companyId, table.issueId], + foreignColumns: [issues.companyId, issues.id], + name: "completion_contracts_issue_company_fk", + }), + supersedesOwnerFk: foreignKey({ + columns: [table.companyId, table.issueId, table.supersedesContractId], + foreignColumns: [table.companyId, table.issueId, table.id], + name: "completion_contracts_supersedes_owner_fk", + }), + issueRevisionUq: uniqueIndex("completion_contracts_issue_revision_uq").on( + table.issueId, + table.revision, + ), + issueHashUq: uniqueIndex("completion_contracts_issue_hash_uq").on( + table.issueId, + table.canonicalSha256, + ), + }), +); diff --git a/packages/db/src/schema/heartbeat_run_events.ts b/packages/db/src/schema/heartbeat_run_events.ts index 13c1f8edabd..03be7c57e76 100644 --- a/packages/db/src/schema/heartbeat_run_events.ts +++ b/packages/db/src/schema/heartbeat_run_events.ts @@ -1,4 +1,16 @@ -import { pgTable, uuid, text, timestamp, integer, jsonb, index, bigserial } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { + pgTable, + uuid, + text, + timestamp, + integer, + jsonb, + index, + bigserial, + bigint, + uniqueIndex, +} from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; import { agents } from "./agents.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; @@ -10,19 +22,29 @@ export const heartbeatRunEvents = pgTable( companyId: uuid("company_id").notNull().references(() => companies.id), runId: uuid("run_id").notNull().references(() => heartbeatRuns.id), agentId: uuid("agent_id").notNull().references(() => agents.id), - seq: integer("seq").notNull(), + seq: bigint("seq", { mode: "number" }).notNull(), eventType: text("event_type").notNull(), stream: text("stream"), level: text("level"), color: text("color"), message: text("message"), payload: jsonb("payload").$type>(), + sourceInstanceId: text("source_instance_id"), + sourceEventId: text("source_event_id"), + sourceSeq: bigint("source_seq", { mode: "number" }), + sourcePayloadSha256: text("source_payload_sha256"), + protocolSchemaVersion: integer("protocol_schema_version"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ runSeqIdx: index("heartbeat_run_events_run_seq_idx").on(table.runId, table.seq), + runSourceEventUq: uniqueIndex("heartbeat_run_events_run_source_event_uq") + .on(table.runId, table.sourceEventId) + .where(sql`${table.sourceEventId} is not null`), + runSourceSeqUq: uniqueIndex("heartbeat_run_events_run_source_seq_uq") + .on(table.runId, table.sourceInstanceId, table.sourceSeq) + .where(sql`${table.sourceInstanceId} is not null and ${table.sourceSeq} is not null`), companyRunIdx: index("heartbeat_run_events_company_run_idx").on(table.companyId, table.runId), companyCreatedIdx: index("heartbeat_run_events_company_created_idx").on(table.companyId, table.createdAt), }), ); - diff --git a/packages/db/src/schema/heartbeat_runs.ts b/packages/db/src/schema/heartbeat_runs.ts index 6965e1669fa..1433605cfad 100644 --- a/packages/db/src/schema/heartbeat_runs.ts +++ b/packages/db/src/schema/heartbeat_runs.ts @@ -1,5 +1,17 @@ import { sql } from "drizzle-orm"; -import { type AnyPgColumn, pgTable, uuid, text, timestamp, jsonb, index, integer, bigint, boolean } from "drizzle-orm/pg-core"; +import { + type AnyPgColumn, + pgTable, + uuid, + text, + timestamp, + jsonb, + index, + integer, + bigint, + boolean, + unique, +} from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; import { agents } from "./agents.js"; import { agentWakeupRequests } from "./agent_wakeup_requests.js"; @@ -22,6 +34,21 @@ export const heartbeatRuns = pgTable( signal: text("signal"), usageJson: jsonb("usage_json").$type>(), resultJson: jsonb("result_json").$type>(), + runtimeMode: text("runtime_mode").notNull().default("legacy"), + runtimeModeResolverVersion: text("runtime_mode_resolver_version"), + runtimeModeReason: text("runtime_mode_reason"), + runtimeModeResolvedAt: timestamp("runtime_mode_resolved_at", { withTimezone: true }), + runnerProfileJson: jsonb("runner_profile_json").$type>(), + runnerInstanceId: uuid("runner_instance_id"), + nativeSessionId: uuid("native_session_id"), + nativeIssueId: uuid("native_issue_id"), + driverKind: text("driver_kind"), + driverVersion: text("driver_version"), + completionContractId: uuid("completion_contract_id"), + completionContractSha256: text("completion_contract_sha256"), + nextEventSeq: bigint("next_event_seq", { mode: "number" }).notNull().default(1), + nativePhase: text("native_phase"), + nativePhaseUpdatedAt: timestamp("native_phase_updated_at", { withTimezone: true }), sessionIdBefore: text("session_id_before"), sessionIdAfter: text("session_id_after"), logStore: text("log_store"), @@ -60,6 +87,19 @@ export const heartbeatRuns = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ + companyNativeIssueRunUq: unique("heartbeat_runs_company_native_issue_id_uq").on( + table.companyId, + table.nativeIssueId, + table.id, + ), + companyNativeIssueRunContractUq: unique( + "heartbeat_runs_company_native_issue_contract_id_uq", + ).on( + table.companyId, + table.nativeIssueId, + table.id, + table.completionContractId, + ), companyAgentStartedIdx: index("heartbeat_runs_company_agent_started_idx").on( table.companyId, table.agentId, diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index ad878e2e830..62af02ac829 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -111,6 +111,12 @@ export { documentAnnotationComments } from "./document_annotation_comments.js"; export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js"; export { heartbeatRuns } from "./heartbeat_runs.js"; export { heartbeatRunEvents } from "./heartbeat_run_events.js"; +export { completionContracts } from "./completion_contracts.js"; +export { nativeRunResults } from "./native_run_results.js"; +export { nativeRunFinalizations } from "./native_run_finalizations.js"; +export { workAssessments } from "./work_assessments.js"; +export { statusDecisions } from "./status_decisions.js"; +export { statusDecisionEffects } from "./status_decision_effects.js"; export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js"; export { smokeRuns, smokeRunSteps } from "./smoke_lab.js"; export { costEvents } from "./cost_events.js"; diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts index 48ed6b89a7c..453f8667927 100644 --- a/packages/db/src/schema/issues.ts +++ b/packages/db/src/schema/issues.ts @@ -9,6 +9,8 @@ import { jsonb, index, uniqueIndex, + unique, + bigint, } from "drizzle-orm/pg-core"; import { agents } from "./agents.js"; import { projects } from "./projects.js"; @@ -31,6 +33,8 @@ export const issues = pgTable( title: text("title").notNull(), description: text("description"), status: text("status").notNull().default("backlog"), + statusVersion: bigint("status_version", { mode: "number" }).notNull().default(0), + lastStatusDecisionId: uuid("last_status_decision_id"), workMode: text("work_mode").notNull().default("standard"), harnessKind: text("harness_kind"), priority: text("priority").notNull().default("medium"), @@ -78,6 +82,7 @@ export const issues = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ + companyIdUq: unique("issues_company_id_uq").on(table.companyId, table.id), companyStatusIdx: index("issues_company_status_idx").on(table.companyId, table.status), companyHarnessKindIdx: index("issues_company_harness_kind_idx").on(table.companyId, table.harnessKind), assigneeStatusIdx: index("issues_company_assignee_status_idx").on( diff --git a/packages/db/src/schema/native_run_finalizations.ts b/packages/db/src/schema/native_run_finalizations.ts new file mode 100644 index 00000000000..33d433d8a92 --- /dev/null +++ b/packages/db/src/schema/native_run_finalizations.ts @@ -0,0 +1,95 @@ +import { sql } from "drizzle-orm"; +import { + check, + foreignKey, + pgTable, + uuid, + text, + timestamp, + integer, + jsonb, +} from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; +import { nativeRunResults } from "./native_run_results.js"; +import { statusDecisions } from "./status_decisions.js"; +import { workAssessments } from "./work_assessments.js"; + +export const nativeRunFinalizations = pgTable( + "native_run_finalizations", + { + runId: uuid("run_id").primaryKey(), + companyId: uuid("company_id").notNull().references(() => companies.id), + issueId: uuid("issue_id").notNull(), + phase: text("phase").notNull(), + attempt: integer("attempt").notNull().default(0), + leaseOwner: text("lease_owner"), + leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }), + resultId: uuid("result_id"), + assessmentId: uuid("assessment_id"), + decisionId: uuid("decision_id"), + failureCode: text("failure_code"), + failureDetail: jsonb("failure_detail").$type>(), + nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + issueCompanyFk: foreignKey({ + columns: [table.companyId, table.issueId], + foreignColumns: [issues.companyId, issues.id], + name: "native_run_finalizations_issue_company_fk", + }), + runOwnerFk: foreignKey({ + columns: [table.companyId, table.issueId, table.runId], + foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.nativeIssueId, heartbeatRuns.id], + name: "native_run_finalizations_run_owner_fk", + }), + resultOwnerFk: foreignKey({ + columns: [table.companyId, table.issueId, table.runId, table.resultId], + foreignColumns: [ + nativeRunResults.companyId, + nativeRunResults.issueId, + nativeRunResults.runId, + nativeRunResults.id, + ], + name: "native_run_finalizations_result_owner_fk", + }), + assessmentOwnerFk: foreignKey({ + columns: [table.companyId, table.issueId, table.runId, table.assessmentId], + foreignColumns: [ + workAssessments.companyId, + workAssessments.issueId, + workAssessments.runId, + workAssessments.id, + ], + name: "native_run_finalizations_assessment_owner_fk", + }), + decisionOwnerFk: foreignKey({ + columns: [ + table.companyId, + table.issueId, + table.runId, + table.assessmentId, + table.decisionId, + ], + foreignColumns: [ + statusDecisions.companyId, + statusDecisions.issueId, + statusDecisions.runId, + statusDecisions.assessmentId, + statusDecisions.id, + ], + name: "native_run_finalizations_decision_owner_fk", + }), + assessmentRequiresResultCheck: check( + "native_run_finalizations_assessment_requires_result_check", + sql`${table.assessmentId} is null or ${table.resultId} is not null`, + ), + decisionRequiresAssessmentCheck: check( + "native_run_finalizations_decision_requires_assessment_check", + sql`${table.decisionId} is null or ${table.assessmentId} is not null`, + ), + }), +); diff --git a/packages/db/src/schema/native_run_results.ts b/packages/db/src/schema/native_run_results.ts new file mode 100644 index 00000000000..f004666c654 --- /dev/null +++ b/packages/db/src/schema/native_run_results.ts @@ -0,0 +1,83 @@ +import { + foreignKey, + pgTable, + uuid, + text, + timestamp, + jsonb, + unique, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; +import { completionContracts } from "./completion_contracts.js"; + +export const nativeRunResults = pgTable( + "native_run_results", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + issueId: uuid("issue_id").notNull(), + runId: uuid("run_id").notNull(), + turnId: text("turn_id"), + completionContractId: uuid("completion_contract_id").notNull(), + callerResultId: text("caller_result_id"), + callerDedupeKey: text("caller_dedupe_key"), + serverFingerprint: text("server_fingerprint").notNull(), + schemaStatus: text("schema_status").notNull(), + rejectionCode: text("rejection_code"), + resultJson: jsonb("result_json").$type>().notNull(), + canonicalSha256: text("canonical_sha256").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIssueRunIdUq: unique("native_run_results_company_issue_run_id_uq").on( + table.companyId, + table.issueId, + table.runId, + table.id, + ), + issueCompanyFk: foreignKey({ + columns: [table.companyId, table.issueId], + foreignColumns: [issues.companyId, issues.id], + name: "native_run_results_issue_company_fk", + }), + runContractOwnerFk: foreignKey({ + columns: [ + table.companyId, + table.issueId, + table.runId, + table.completionContractId, + ], + foreignColumns: [ + heartbeatRuns.companyId, + heartbeatRuns.nativeIssueId, + heartbeatRuns.id, + heartbeatRuns.completionContractId, + ], + name: "native_run_results_run_contract_owner_fk", + }), + completionContractOwnerFk: foreignKey({ + columns: [table.companyId, table.issueId, table.completionContractId], + foreignColumns: [ + completionContracts.companyId, + completionContracts.issueId, + completionContracts.id, + ], + name: "native_run_results_completion_contract_owner_fk", + }), + runFingerprintUq: uniqueIndex("native_run_results_run_fingerprint_uq").on( + table.runId, + table.serverFingerprint, + ), + runCallerResultUq: uniqueIndex("native_run_results_run_caller_result_uq").on( + table.runId, + table.callerResultId, + ), + runCallerDedupeUq: uniqueIndex("native_run_results_run_caller_dedupe_uq").on( + table.runId, + table.callerDedupeKey, + ), + }), +); diff --git a/packages/db/src/schema/status_decision_effects.ts b/packages/db/src/schema/status_decision_effects.ts new file mode 100644 index 00000000000..989c70d1818 --- /dev/null +++ b/packages/db/src/schema/status_decision_effects.ts @@ -0,0 +1,56 @@ +import { + foreignKey, + pgTable, + uuid, + text, + timestamp, + integer, + jsonb, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; +import { statusDecisions } from "./status_decisions.js"; + +export const statusDecisionEffects = pgTable( + "status_decision_effects", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + issueId: uuid("issue_id").notNull(), + decisionId: uuid("decision_id").notNull(), + ordinal: integer("ordinal").notNull(), + effectKind: text("effect_kind").notNull(), + targetType: text("target_type").notNull(), + targetId: text("target_id"), + idempotencyKey: text("idempotency_key").notNull(), + payload: jsonb("payload").$type>().notNull(), + deliveryState: text("delivery_state").notNull().default("pending"), + attemptCount: integer("attempt_count").notNull().default(0), + nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }), + lastError: text("last_error"), + deliveredAt: timestamp("delivered_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + issueCompanyFk: foreignKey({ + columns: [table.companyId, table.issueId], + foreignColumns: [issues.companyId, issues.id], + name: "status_decision_effects_issue_company_fk", + }), + decisionOwnerFk: foreignKey({ + columns: [table.companyId, table.issueId, table.decisionId], + foreignColumns: [statusDecisions.companyId, statusDecisions.issueId, statusDecisions.id], + name: "status_decision_effects_decision_owner_fk", + }), + decisionOrdinalUq: uniqueIndex("status_decision_effects_decision_ordinal_uq").on( + table.decisionId, + table.ordinal, + ), + idempotencyUq: uniqueIndex("status_decision_effects_company_idempotency_uq").on( + table.companyId, + table.idempotencyKey, + ), + }), +); diff --git a/packages/db/src/schema/status_decisions.ts b/packages/db/src/schema/status_decisions.ts new file mode 100644 index 00000000000..3ee36ea44c2 --- /dev/null +++ b/packages/db/src/schema/status_decisions.ts @@ -0,0 +1,86 @@ +import { + foreignKey, + pgTable, + uuid, + text, + timestamp, + bigint, + jsonb, + unique, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; +import { workAssessments } from "./work_assessments.js"; + +export const statusDecisions = pgTable( + "status_decisions", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + issueId: uuid("issue_id").notNull(), + runId: uuid("run_id").notNull(), + assessmentId: uuid("assessment_id").notNull(), + decisionVersion: bigint("decision_version", { mode: "number" }).notNull(), + policyVersion: text("policy_version").notNull(), + fromStatus: text("from_status").notNull(), + toStatus: text("to_status").notNull(), + reasonCode: text("reason_code").notNull(), + decisionJson: jsonb("decision_json").$type>().notNull(), + decisionDigest: text("decision_digest").notNull(), + applicationState: text("application_state").notNull().default("proposed"), + supersedesDecisionId: uuid("supersedes_decision_id"), + appliedAt: timestamp("applied_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIssueIdUq: unique("status_decisions_company_issue_id_uq").on( + table.companyId, + table.issueId, + table.id, + ), + companyIssueRunAssessmentIdUq: unique( + "status_decisions_company_issue_run_assessment_id_uq", + ).on( + table.companyId, + table.issueId, + table.runId, + table.assessmentId, + table.id, + ), + issueCompanyFk: foreignKey({ + columns: [table.companyId, table.issueId], + foreignColumns: [issues.companyId, issues.id], + name: "status_decisions_issue_company_fk", + }), + assessmentOwnerFk: foreignKey({ + columns: [table.companyId, table.issueId, table.runId, table.assessmentId], + foreignColumns: [ + workAssessments.companyId, + workAssessments.issueId, + workAssessments.runId, + workAssessments.id, + ], + name: "status_decisions_assessment_owner_fk", + }), + supersedesOwnerFk: foreignKey({ + columns: [table.companyId, table.issueId, table.supersedesDecisionId], + foreignColumns: [table.companyId, table.issueId, table.id], + name: "status_decisions_supersedes_owner_fk", + }), + issueVersionUq: uniqueIndex("status_decisions_company_issue_version_uq").on( + table.companyId, + table.issueId, + table.decisionVersion, + ), + assessmentUq: uniqueIndex("status_decisions_company_assessment_uq").on( + table.companyId, + table.assessmentId, + ), + issueDigestUq: uniqueIndex("status_decisions_company_issue_digest_uq").on( + table.companyId, + table.issueId, + table.decisionDigest, + ), + }), +); diff --git a/packages/db/src/schema/work_assessments.ts b/packages/db/src/schema/work_assessments.ts new file mode 100644 index 00000000000..fd666be4137 --- /dev/null +++ b/packages/db/src/schema/work_assessments.ts @@ -0,0 +1,94 @@ +import { sql } from "drizzle-orm"; +import { + check, + foreignKey, + pgTable, + uuid, + text, + timestamp, + bigint, + jsonb, + unique, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; +import { completionContracts } from "./completion_contracts.js"; +import { nativeRunResults } from "./native_run_results.js"; + +export const workAssessments = pgTable( + "work_assessments", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + issueId: uuid("issue_id").notNull(), + runId: uuid("run_id").notNull(), + turnId: text("turn_id"), + contractId: uuid("contract_id").notNull(), + resultId: uuid("result_id").notNull(), + triggerKind: text("trigger_kind").notNull(), + triggerRef: text("trigger_ref"), + triggerCapability: text("trigger_capability"), + triggerActorCompanyId: uuid("trigger_actor_company_id").notNull().references(() => companies.id), + priorIssueStatus: text("prior_issue_status").notNull(), + priorStatusVersion: bigint("prior_status_version", { mode: "number" }).notNull(), + priorDecisionId: uuid("prior_decision_id"), + policyVersion: text("policy_version").notNull(), + assessmentJson: jsonb("assessment_json").$type>().notNull(), + inputDigest: text("input_digest").notNull(), + supersedesAssessmentId: uuid("supersedes_assessment_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIssueRunIdUq: unique("work_assessments_company_issue_run_id_uq").on( + table.companyId, + table.issueId, + table.runId, + table.id, + ), + issueCompanyFk: foreignKey({ + columns: [table.companyId, table.issueId], + foreignColumns: [issues.companyId, issues.id], + name: "work_assessments_issue_company_fk", + }), + runOwnerFk: foreignKey({ + columns: [table.companyId, table.issueId, table.runId], + foreignColumns: [heartbeatRuns.companyId, heartbeatRuns.nativeIssueId, heartbeatRuns.id], + name: "work_assessments_run_owner_fk", + }), + contractOwnerFk: foreignKey({ + columns: [table.companyId, table.issueId, table.contractId], + foreignColumns: [ + completionContracts.companyId, + completionContracts.issueId, + completionContracts.id, + ], + name: "work_assessments_contract_owner_fk", + }), + resultOwnerFk: foreignKey({ + columns: [table.companyId, table.issueId, table.runId, table.resultId], + foreignColumns: [ + nativeRunResults.companyId, + nativeRunResults.issueId, + nativeRunResults.runId, + nativeRunResults.id, + ], + name: "work_assessments_result_owner_fk", + }), + supersedesOwnerFk: foreignKey({ + columns: [table.companyId, table.issueId, table.runId, table.supersedesAssessmentId], + foreignColumns: [table.companyId, table.issueId, table.runId, table.id], + name: "work_assessments_supersedes_owner_fk", + }), + triggerActorCompanyCheck: check( + "work_assessments_trigger_actor_company_check", + sql`${table.triggerActorCompanyId} = ${table.companyId}`, + ), + issueInputUq: uniqueIndex("work_assessments_company_issue_input_uq").on( + table.companyId, + table.issueId, + table.inputDigest, + ), + }), +); diff --git a/packages/google-sheets-mcp-server/package.json b/packages/google-sheets-mcp-server/package.json index 366b1471786..0b602c1246f 100644 --- a/packages/google-sheets-mcp-server/package.json +++ b/packages/google-sheets-mcp-server/package.json @@ -46,7 +46,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", - "googleapis": "^174.0.1", + "googleapis": "^176.0.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/paperclip-runner/.gitignore b/packages/paperclip-runner/.gitignore new file mode 100644 index 00000000000..31faa12bf82 --- /dev/null +++ b/packages/paperclip-runner/.gitignore @@ -0,0 +1 @@ +/runner/target/ diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md new file mode 100644 index 00000000000..58a76c39cad --- /dev/null +++ b/packages/paperclip-runner/README.md @@ -0,0 +1,80 @@ +# Paperclip Runner + +This private workspace package contains the staged Paperclip Runner work. + +The package currently exposes the language-neutral PRP v1 TypeScript +contract, provider-neutral structured questions and responses, deterministic +fixture validation/replay, structured-result normalization, and the session +reducer oracle. It also contains a package-local Rust runner, scripted fake +harness, bounded process supervisor, cross-language replay oracle, and durable +PRP transport. The transport authenticates and encrypts loopback WebSocket +sessions, persists an ACK-driven outbox and command journal, and reconnects with +a short-lived lease. The Rust runner now includes a Codex-only app-server +provider bridge with durable thread resume, cancellation, structured questions, +and provider-neutral event normalization. The root surface now also exposes an +authenticated durable PRP authority for server-side use. It stores only +bootstrap and reconnect credential digests, validates immutable run identity on +every connection and event, and persists commands and cumulative event ACK +state across server restarts. +The package also publishes the canonical semantic action declarations and +their input and output schemas. Its package-local dispatcher projects only +bound, run-authorized actions and emits redacted semantic receipts. It does not +add application bindings, a server adapter, or production Paperclip behavior. + +The first and only installed provider is Codex. Dynamic semantic tools remain +undiscoverable unless the hidden server coordinator projects one of the five +same-task read bindings for an already persisted native Codex run. Catalog +membership alone does not grant authority, and no production adapter can create +or start such a run yet. See +[`SEMANTIC_ACTIONS.md`](SEMANTIC_ACTIONS.md) for the catalog boundary. + +The package has two initial public surfaces: + +- `@paperclipai/paperclip-runner` contains runtime contracts, validation, + replay/reducer logic, the semantic catalog, the authorization dispatcher, and + the Node-only durable server authority. +- `@paperclipai/paperclip-runner/testing` adds Node-only fixture loading and a + provider-neutral semantic conformance kit for deterministic test adapters. + +No SDK, browser, React, eval, live-console, lab, or provider-experiment entry +point is exported. The package remains private in this wave. The server route +at `/api/runner/v1/connect/:runId` has no authority until the hidden coordinator +registers an exact existing run binding, and no production adapter starts it. + +Run the complete contract gate with: + +```sh +pnpm --filter @paperclipai/paperclip-runner check:protocol +``` + +Run the Rust runner gate with: + +```sh +pnpm --filter @paperclipai/paperclip-runner check:runner +``` + +This command checks Rust formatting, builds and tests the minimal workspace in +release mode, verifies bounded process cleanup, launches the real +`paperclip-runnerd` binary through the fake harness, and compares the Rust +conformance and replay summaries with the shared fixtures. The checked-in Cargo +lock and pinned Rust toolchain keep this verification reproducible. + +Durability and failure semantics are documented in +[`runner/DURABLE_TRANSPORT.md`](runner/DURABLE_TRANSPORT.md). The fault suite +drops a connection before its event ACK, reconnects with the bound lease, +replays the same event, and proves the duplicated command effect ran once. +Codex launch, resume, cancellation, and normalization behavior is documented in +[`runner/CODEX_PROVIDER.md`](runner/CODEX_PROVIDER.md). + +Use `generate:protocol-manifest` after a schema or fixture change, +`generate:protocol-types` after a schema change, and +`generate:replay-goldens` after an intentional reducer change. Commit generated +outputs with their sources; do not edit them by hand. + +Use `generate:semantic-action-catalog` after changing a semantic action +declaration. Its checked-in JSON inventory must land with the source change. + +The gate compiles every schema with AJV 2020-12, validates accepted fixtures, +rejects unsupported required versions, checks generated TypeScript schema +drift, runs the TypeScript contract tests, and compares reducer snapshots and +parity summaries byte-for-byte with their checked-in golden files. diff --git a/packages/paperclip-runner/SEMANTIC_ACTIONS.md b/packages/paperclip-runner/SEMANTIC_ACTIONS.md new file mode 100644 index 00000000000..25551bb1d90 --- /dev/null +++ b/packages/paperclip-runner/SEMANTIC_ACTIONS.md @@ -0,0 +1,55 @@ +# Semantic action catalog + +The package exports a versioned, provider-neutral catalog for the first Codex +runner slice. Each declaration has a stable operation ID, placement metadata, +required claims, supported task modes, an effect class, and JSON Schema input +and output contracts. + +The catalog is descriptive. Importing it or finding an operation in it does not +grant permission to show or call that operation. A run-scoped dispatcher can +project only actions that have current actor, task, company, claim, mode, and +application-binding authority. It rechecks that authority before each call. +The package still adds no application binding, credential, server route, or +Codex tool installation. Until a later server integration supplies those +bindings, Codex receives no dynamic Paperclip tools. + +The initial catalog excludes scenario-only and lab operations, other-provider +extensions, and a generic API escape hatch. Those additions need their own +reviewed schemas and authority boundaries. + +## Public API + +```ts +import { + PAPERCLIP_SEMANTIC_ACTION_CATALOG, + paperclipSemanticAction, +} from "@paperclipai/paperclip-runner"; + +const writeDocument = paperclipSemanticAction("write_document"); +``` + +`PAPERCLIP_SEMANTIC_ACTION_CATALOG` and every nested declaration are frozen. +`paperclipSemanticAction` returns `undefined` for unknown operation IDs. + +## Run-scoped authority + +`PaperclipSemanticDispatcher` accepts a current-context provider and an +explicit list of application bindings. Unbound actions are absent. Actor claims +and run-delegated claims are intersected. Optional discovery returns only bound +actions that pass the same authorization check. Mutation actions also require +an atomic idempotency store. The store must provide an idempotent recovery path +for a mutation that succeeds before its primary receipt commit fails. Raw tool +content never enters semantic receipts; receipts contain a digest and +allowlisted references only. + +## Generated inventory + +`generated/semantic-action-catalog.json` is a deterministic projection of the +runtime declarations. Change the TypeScript source, then run: + +```sh +pnpm --filter @paperclipai/paperclip-runner generate:semantic-action-catalog +``` + +The package build and catalog tests compare the checked-in inventory byte for +byte. Do not edit the generated file directly. diff --git a/packages/paperclip-runner/generated/semantic-action-catalog.json b/packages/paperclip-runner/generated/semantic-action-catalog.json new file mode 100644 index 00000000000..c01e62e819c --- /dev/null +++ b/packages/paperclip-runner/generated/semantic-action-catalog.json @@ -0,0 +1,1914 @@ +[ + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Read the active task, actor, wake context, ancestors, and budget summary.", + "effect": "read", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "operationId": "get_task_context", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "always", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Get active task context", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Read bounded comments on the active task.", + "effect": "read", + "inputSchema": { + "additionalProperties": false, + "properties": { + "limit": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + "required": [], + "type": "object" + }, + "operationId": "get_task_history", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "always", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Get active task history", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "List revisioned documents on the active task.", + "effect": "read", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "operationId": "list_documents", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "always", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "List task documents", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Read the current revision of one active-task document.", + "effect": "read", + "inputSchema": { + "additionalProperties": false, + "properties": { + "key": { + "description": "Stable issue-document key.", + "maxLength": 120, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "operationId": "read_document", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "always", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Read task document", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Read bounded revision history for one active-task document.", + "effect": "read", + "inputSchema": { + "additionalProperties": false, + "properties": { + "key": { + "description": "Stable issue-document key.", + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "limit": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "operationId": "list_document_revisions", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "always", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "List document revisions", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Append a durable progress comment to the active task.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Multiline progress update.", + "maxLength": 20000, + "minLength": 1, + "type": "string" + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "body" + ], + "type": "object" + }, + "operationId": "report_progress", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "always", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Report durable progress", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Append an answer to a status-only wake without changing task disposition.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Concise status answer.", + "maxLength": 20000, + "minLength": 1, + "type": "string" + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "body" + ], + "type": "object" + }, + "operationId": "answer_status_question", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "always", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Answer status question", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "planning", + "skill_test" + ], + "description": "Create or update an active-task document with optimistic revision safety.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "baseRevisionId": { + "description": "Current revision id, or null when creating.", + "maxLength": 240, + "type": [ + "string", + "null" + ] + }, + "body": { + "description": "Markdown document body.", + "maxLength": 200000, + "minLength": 1, + "type": "string" + }, + "changeSummary": { + "description": "Optional revision summary.", + "maxLength": 20000, + "type": [ + "string", + "null" + ] + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "key": { + "description": "Stable issue-document key.", + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "title": { + "description": "Document title.", + "maxLength": 300, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "key", + "title", + "body", + "baseRevisionId" + ], + "type": "object" + }, + "operationId": "write_document", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "always", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Write revisioned document", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Create a typed, durable interaction on the active task.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "continuationPolicy": { + "enum": [ + "none", + "wake_assignee", + "wake_assignee_on_accept" + ] + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "interactionKind": { + "enum": [ + "confirmation", + "checkbox", + "questions", + "suggest_tasks", + "item_verdicts" + ] + }, + "payload": { + "additionalProperties": true, + "type": "object" + }, + "prompt": { + "description": "Question or decision prompt.", + "maxLength": 10000, + "minLength": 1, + "type": "string" + }, + "targetRevisionId": { + "description": "Optional bound document revision.", + "maxLength": 240, + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "Interaction card title.", + "maxLength": 300, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "interactionKind", + "title", + "prompt", + "continuationPolicy" + ], + "type": "object" + }, + "operationId": "request_human_input", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "always", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Request structured human input", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "planning", + "skill_test" + ], + "description": "Register attachment metadata and its work product without returning bytes or credentials.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "byteSize": { + "maximum": 100000000, + "minimum": 0, + "type": "integer" + }, + "contentRef": { + "description": "Opaque content reference.", + "maxLength": 2000, + "minLength": 1, + "type": "string" + }, + "contentType": { + "description": "Media type.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "filename": { + "description": "Display filename.", + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "sha256": { + "pattern": "^[a-fA-F0-9]{64}$", + "type": "string" + }, + "title": { + "description": "Work-product title.", + "maxLength": 500, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "filename", + "contentType", + "byteSize", + "sha256", + "contentRef", + "title" + ], + "type": "object" + }, + "operationId": "register_deliverable", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "always", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Register inspectable deliverable", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "skill_test" + ], + "description": "Finish the active task with a durable summary.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "summary": { + "description": "Completion summary.", + "maxLength": 20000, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "summary" + ], + "type": "object" + }, + "operationId": "finish_task", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "always", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Finish active task", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "skill_test" + ], + "description": "Block the active task with a durable reason and optional first-class dependencies.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "blockedByTaskIds": { + "description": "Task identifiers that block this task.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "reason": { + "description": "Block reason.", + "maxLength": 20000, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "reason" + ], + "type": "object" + }, + "operationId": "block_task", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "always", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Block active task", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "skill_test" + ], + "description": "Move the active task to review with a durable summary.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "summary": { + "description": "Review handoff summary.", + "maxLength": 20000, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "summary" + ], + "type": "object" + }, + "operationId": "request_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "always", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Request task review", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "List redacted actor profiles in the run company.", + "effect": "read", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "operationId": "list_agents", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "discovery:agents:read" + ], + "schema": "paperclip.semantic-action.v1", + "title": "List company agents", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Read one redacted actor profile in the run company.", + "effect": "read", + "inputSchema": { + "additionalProperties": false, + "properties": { + "actorId": { + "description": "Actor identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "actorId" + ], + "type": "object" + }, + "operationId": "get_agent", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "discovery:agents:read" + ], + "schema": "paperclip.semantic-action.v1", + "title": "Get company agent", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Search tasks by text and status within the run company.", + "effect": "read", + "inputSchema": { + "additionalProperties": false, + "properties": { + "limit": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + }, + "query": { + "maxLength": 500, + "type": "string" + }, + "statuses": { + "items": { + "enum": [ + "backlog", + "todo", + "in_progress", + "in_review", + "done", + "blocked", + "cancelled" + ] + }, + "maxItems": 7, + "type": "array", + "uniqueItems": true + } + }, + "required": [], + "type": "object" + }, + "operationId": "search_tasks", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "discovery:tasks:read" + ], + "schema": "paperclip.semantic-action.v1", + "title": "Search company tasks", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "List approvals in the run company.", + "effect": "governance", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "operationId": "list_approvals", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "governance:approvals:read" + ], + "schema": "paperclip.semantic-action.v1", + "title": "List approvals", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Read one approval without protected data.", + "effect": "governance", + "inputSchema": { + "additionalProperties": false, + "properties": { + "approvalId": { + "description": "Approval identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "approvalId" + ], + "type": "object" + }, + "operationId": "get_approval", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "governance:approvals:read" + ], + "schema": "paperclip.semantic-action.v1", + "title": "Get approval", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Read one approval, its comments, and linked tasks.", + "effect": "governance", + "inputSchema": { + "additionalProperties": false, + "properties": { + "approvalId": { + "description": "Approval identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "approvalId" + ], + "type": "object" + }, + "operationId": "get_approval_context", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "governance:approvals:read" + ], + "schema": "paperclip.semantic-action.v1", + "title": "Get approval context", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Read active-task workspace services.", + "effect": "read", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "operationId": "get_workspace_runtime", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "workspace:read" + ], + "schema": "paperclip.semantic-action.v1", + "title": "Get workspace runtime", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "skill_test" + ], + "description": "Start or stop one active-task workspace service.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "action": { + "enum": [ + "start", + "stop" + ] + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "serviceId": { + "description": "Workspace service identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "serviceId", + "action" + ], + "type": "object" + }, + "operationId": "control_workspace_service", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "workspace:control" + ], + "schema": "paperclip.semantic-action.v1", + "title": "Control workspace service", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "skill_test" + ], + "description": "Replace the active task's first-class blocker set.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "blockedByTaskIds": { + "description": "Replacement blocker task identifiers.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "blockedByTaskIds" + ], + "type": "object" + }, + "operationId": "set_dependencies", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "dependencies:write" + ], + "schema": "paperclip.semantic-action.v1", + "title": "Set task dependencies", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "skill_test" + ], + "description": "Create one child task under the active task.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "assigneeActorId": { + "description": "Optional actor assignee.", + "maxLength": 200, + "type": [ + "string", + "null" + ] + }, + "blockedByTaskIds": { + "description": "Initial blocker task identifiers.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "description": { + "description": "Child task description.", + "maxLength": 20000, + "type": [ + "string", + "null" + ] + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "priority": { + "enum": [ + "critical", + "high", + "medium", + "low" + ] + }, + "title": { + "description": "Child task title.", + "maxLength": 500, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "title" + ], + "type": "object" + }, + "operationId": "create_task", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "delegation:tasks:create" + ], + "schema": "paperclip.semantic-action.v1", + "title": "Create child task", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "skill_test" + ], + "description": "Create a governed approval and waiting posture.", + "effect": "governance", + "inputSchema": { + "additionalProperties": false, + "properties": { + "approvalType": { + "description": "Stable approval type.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "payload": { + "additionalProperties": true, + "type": "object" + } + }, + "required": [ + "idempotencyKey", + "approvalType", + "payload" + ], + "type": "object" + }, + "operationId": "request_approval", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "governance:approvals:request" + ], + "schema": "paperclip.semantic-action.v1", + "title": "Request approval", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "skill_test" + ], + "allowedRoles": [ + "board", + "approver", + "security" + ], + "description": "Decide an approval as an explicitly authorized approver.", + "effect": "governance", + "inputSchema": { + "additionalProperties": false, + "properties": { + "approvalId": { + "description": "Approval identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "decision": { + "enum": [ + "approved", + "rejected", + "cancelled" + ] + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "note": { + "description": "Decision note.", + "maxLength": 20000, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "approvalId", + "decision", + "note" + ], + "type": "object" + }, + "operationId": "decide_approval", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "governance:approvals:decide" + ], + "schema": "paperclip.semantic-action.v1", + "title": "Decide approval", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Add a durable comment to an approval.", + "effect": "governance", + "inputSchema": { + "additionalProperties": false, + "properties": { + "approvalId": { + "description": "Approval identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "body": { + "description": "Approval comment.", + "maxLength": 20000, + "minLength": 1, + "type": "string" + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "approvalId", + "body" + ], + "type": "object" + }, + "operationId": "comment_on_approval", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "governance:approvals:comment" + ], + "schema": "paperclip.semantic-action.v1", + "title": "Comment on approval", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "skill_test" + ], + "description": "Schedule a bounded continuation wake.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "delaySeconds": { + "maximum": 86400, + "minimum": 1, + "type": "integer" + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "payload": { + "additionalProperties": true, + "type": "object" + }, + "reason": { + "enum": [ + "manual", + "issue_commented", + "interaction_resolved", + "approval_resolved", + "blockers_resolved", + "scheduled_retry", + "resume" + ] + } + }, + "required": [ + "idempotencyKey", + "reason", + "delaySeconds" + ], + "type": "object" + }, + "operationId": "schedule_wake", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "control_plane:wakes" + ], + "schema": "paperclip.semantic-action.v1", + "title": "Schedule bounded wake", + "version": 1 + } +] diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json new file mode 100644 index 00000000000..626d410d8e5 --- /dev/null +++ b/packages/paperclip-runner/package.json @@ -0,0 +1,62 @@ +{ + "name": "@paperclipai/paperclip-runner", + "version": "0.0.0", + "private": true, + "license": "MIT", + "engines": { + "node": ">=24.11.0" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "import": "./dist/testing.js" + } + }, + "sideEffects": false, + "files": [ + "dist", + "protocol", + "README.md" + ], + "scripts": { + "build": "pnpm run check:protocol-manifest && pnpm run build:typescript && node scripts/generate-replay-goldens.mjs --check && node scripts/generate-semantic-action-catalog.mjs --check", + "build:typescript": "pnpm run check:protocol-types && tsc -p tsconfig.json", + "build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins", + "typecheck": "pnpm run typecheck:typescript && pnpm run typecheck:rust", + "typecheck:typescript": "node --check scripts/protocol-contract.mjs && node --check scripts/generate-protocol-manifest.mjs && node --check scripts/generate-protocol-schema-module.mjs && node --check scripts/generate-replay-goldens.mjs && node --check scripts/generate-semantic-action-catalog.mjs && pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit", + "typecheck:rust": "cargo fmt --manifest-path runner/Cargo.toml --all -- --check && cargo check --manifest-path runner/Cargo.toml --locked --workspace", + "test": "pnpm run test:typescript && pnpm run test:rust", + "test:typescript": "node --test test/protocol-contract.test.mjs && vitest run", + "test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace", + "test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider", + "test:durable": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core durable::", + "generate:protocol-manifest": "node scripts/generate-protocol-manifest.mjs", + "check:protocol-manifest": "node scripts/generate-protocol-manifest.mjs --check", + "generate:protocol-types": "node scripts/generate-protocol-schema-module.mjs", + "check:protocol-types": "node scripts/generate-protocol-schema-module.mjs --check", + "generate:replay-goldens": "pnpm run build:typescript && node scripts/generate-replay-goldens.mjs", + "check:replay-goldens": "pnpm run build:typescript && node scripts/generate-replay-goldens.mjs --check", + "generate:semantic-action-catalog": "pnpm run build:typescript && node scripts/generate-semantic-action-catalog.mjs", + "check:semantic-action-catalog": "pnpm run build:typescript && node scripts/generate-semantic-action-catalog.mjs --check", + "check:protocol": "pnpm run typecheck:typescript && pnpm run check:protocol-manifest && pnpm run test:typescript && pnpm run check:replay-goldens", + "check:conformance-parity": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core runs_the_mock_core_path_with_stable_output", + "check:replay-parity": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core replay_fixture_parity", + "check:runner": "pnpm run typecheck:rust && pnpm run test:rust && pnpm run check:conformance-parity && pnpm run check:replay-parity", + "check:all": "pnpm run check:protocol && pnpm run check:runner", + "trace:conformance:rust": "cargo run --quiet --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin conformance-tracer" + }, + "dependencies": { + "ajv": "^8.20.0", + "json-schema-to-ts": "^3.1.1" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.7.3", + "vitest": "^4.1.10" + } +} diff --git a/packages/paperclip-runner/protocol/README.md b/packages/paperclip-runner/protocol/README.md new file mode 100644 index 00000000000..d43dd8a7a41 --- /dev/null +++ b/packages/paperclip-runner/protocol/README.md @@ -0,0 +1,42 @@ +# PRP v1 Contract + +The JSON Schema files in `schemas/` are the language-neutral source of truth for +Paperclip Runner Protocol version 1. The fixtures in `fixtures/` define accepted +and rejected compatibility cases. + +## Compatibility + +- `protocolVersion`, `fixtureVersion`, and `event.schemaVersion` are required. +- A consumer fails closed when a required version or schema discriminator is + not supported. +- A v1 envelope can contain an unknown optional property when its schema marks + that object as extensible. +- A consumer ignores an unknown optional property until a later contract gives + it meaning. +- A required field, enum value, or typed structured-input field is not optional. +- Question and answer identifiers are stable across the provider boundary. + +The `unknown-optional-fields.json` fixture must be accepted. The +`unsupported-required-version.json` fixture must be rejected. + +## Scope + +The first provider descriptor and adapter fixture cover Codex only. The schemas +for provider-neutral events and semantic receipts do not enable those actions. +Discovery and authorization are separate contracts. + +The conformance manifest records every source file and its SHA-256 digest. Run +`pnpm generate:protocol-manifest` from this package after a source change. CI +runs the same generator with `--check` to reject drift. This check also compiles +the JSON Schemas and validates every replay, question, and cross-language +conformance fixture against its declared schema. + +The files in `fixtures/replay/golden/` are deterministic reducer oracles. Each +accepted replay fixture has a complete session snapshot and a compact parity +summary. `pnpm generate:replay-goldens` updates them after an intentional +reducer change; package build and CI fail when they drift. + +The files in `fixtures/local-runner/scripts/` drive the package-local fake +harness. They cover successful, failed, interrupted, interactive, duplicate +terminal, process-cleanup, and oversized-frame behavior without starting a +production adapter. diff --git a/packages/paperclip-runner/protocol/fixtures/conformance-expected-output.json b/packages/paperclip-runner/protocol/fixtures/conformance-expected-output.json new file mode 100644 index 00000000000..6dc9bc9affa --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/conformance-expected-output.json @@ -0,0 +1 @@ +{"schemaVersion":"paperclip.runner.conformance.output.v1","runIdentity":{"runId":"run_conformance_0001","sessionId":"session_conformance_0001"},"result":{"status":"succeeded","summary":"Standalone Conformance fixture accepted."}} diff --git a/packages/paperclip-runner/protocol/fixtures/conformance-minimal-run.json b/packages/paperclip-runner/protocol/fixtures/conformance-minimal-run.json new file mode 100644 index 00000000000..ad6a9fc5797 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/conformance-minimal-run.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": "paperclip.runner.conformance.fixture.v1", + "run": { + "runId": "run_conformance_0001", + "sessionId": "session_conformance_0001", + "companyId": "company_conformance", + "issueId": "issue_conformance", + "agentId": "agent_conformance" + }, + "events": [ + { + "eventId": "event_conformance_0001", + "runId": "run_conformance_0001", + "sequence": 1, + "type": "run.started" + }, + { + "eventId": "event_conformance_0002", + "runId": "run_conformance_0001", + "sequence": 2, + "type": "run.completed" + } + ], + "result": { + "runId": "run_conformance_0001", + "status": "succeeded", + "summary": "Standalone Conformance fixture accepted." + } +} diff --git a/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/duplicate-terminal.json b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/duplicate-terminal.json new file mode 100644 index 00000000000..bd44173cda9 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/duplicate-terminal.json @@ -0,0 +1,45 @@ +{ + "schema": "paperclip.fake_harness.script.v1", + "name": "duplicate-terminal", + "steps": [ + { + "kind": "result", + "payload": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "done", + "summary": "The duplicate terminal guard accepted one result.", + "completionClaim": { + "contractRevision": "local_runner-contract-v1", + "objectiveSatisfied": true, + "criteria": [], + "remainingWork": [] + }, + "evidence": [], + "verification": [], + "attentionRequests": [], + "artifacts": [] + } + }, + { "kind": "event", "event_type": "turn.completed", "payload": {} }, + { "kind": "event", "event_type": "session.closed", "payload": {} }, + { + "kind": "terminal", + "payload": { + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "completed", + "runTerminalState": "succeeded", + "reportedWorkDisposition": "done" + } + }, + { + "kind": "terminal", + "payload": { + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "failed", + "runTerminalState": "failed", + "reportedWorkDisposition": "yielded" + } + }, + { "kind": "exit", "code": 0 } + ] +} diff --git a/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/error.json b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/error.json new file mode 100644 index 00000000000..341227f3780 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/error.json @@ -0,0 +1,53 @@ +{ + "schema": "paperclip.fake_harness.script.v1", + "name": "error", + "steps": [ + { "kind": "delay", "milliseconds": 5 }, + { "kind": "log", "stream": "stderr", "text": "scripted harness failure" }, + { + "kind": "event", + "event_type": "item.failed", + "item_id": "item_local_runner_error", + "payload": { "kind": "diagnostic", "message": "The fake harness failed as requested." } + }, + { + "kind": "result", + "payload": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "yielded", + "summary": "The fake harness reported a scripted error.", + "completionClaim": { + "contractRevision": "local_runner-contract-v1", + "objectiveSatisfied": false, + "criteria": [], + "remainingWork": [ + { "description": "Inspect the scripted failure.", "blocksCompletion": true } + ] + }, + "evidence": [], + "verification": [ + { "commandOrCheck": "scripted error scenario", "status": "failed" } + ], + "attentionRequests": [], + "artifacts": [], + "continuation": { + "kind": "retry", + "summary": "Retry with a successful script.", + "idempotencyKey": "local_runner-error-retry" + } + } + }, + { "kind": "event", "event_type": "turn.failed", "payload": {} }, + { "kind": "event", "event_type": "session.failed", "payload": {} }, + { + "kind": "terminal", + "payload": { + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "failed", + "runTerminalState": "failed", + "reportedWorkDisposition": "yielded" + } + }, + { "kind": "exit", "code": 7 } + ] +} diff --git a/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/happy-path.json b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/happy-path.json new file mode 100644 index 00000000000..493c9f08a40 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/happy-path.json @@ -0,0 +1,90 @@ +{ + "schema": "paperclip.fake_harness.script.v1", + "name": "happy-path", + "steps": [ + { + "kind": "event", + "event_type": "item.started", + "item_id": "item_local_runner_command", + "payload": { "kind": "command", "text": "pnpm test --filter fake" } + }, + { "kind": "delay", "milliseconds": 15 }, + { "kind": "log", "stream": "stdout", "text": "prepare workspace" }, + { "kind": "log", "stream": "stdout", "text": "load scripted driver" }, + { "kind": "log", "stream": "stdout", "text": "run deterministic check" }, + { "kind": "log", "stream": "stdout", "text": "write fixture output" }, + { "kind": "log", "stream": "stdout", "text": "verification passed" }, + { + "kind": "event", + "event_type": "item.delta", + "item_id": "item_local_runner_command", + "payload": { "text": "Fake driver output is live. " } + }, + { + "kind": "event", + "event_type": "item.completed", + "item_id": "item_local_runner_command", + "payload": { "kind": "command", "text": "Fake driver check passed." } + }, + { + "kind": "event", + "event_type": "item.started", + "item_id": "item_local_runner_file", + "payload": { "kind": "file_change", "text": "local-runner-output.json" } + }, + { + "kind": "event", + "event_type": "item.completed", + "item_id": "item_local_runner_file", + "payload": { "kind": "file_change", "text": "Wrote local-runner-output.json" } + }, + { + "kind": "result", + "payload": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "done", + "summary": "The live fake-harness run completed successfully.", + "completionClaim": { + "contractRevision": "local_runner-contract-v1", + "objectiveSatisfied": true, + "criteria": [ + { + "criterionId": "local-runner", + "status": "satisfied", + "evidenceRefs": ["event:item_local_runner_command"] + } + ], + "remainingWork": [] + }, + "evidence": [{ "kind": "event", "ref": "item_local_runner_command" }], + "verification": [ + { "commandOrCheck": "fake driver conformance", "status": "passed" } + ], + "attentionRequests": [], + "artifacts": [ + { "kind": "fixture", "ref": "local-runner-output.json", "title": "Live trace" } + ] + } + }, + { + "kind": "event", + "event_type": "turn.completed", + "payload": {} + }, + { + "kind": "event", + "event_type": "session.closed", + "payload": { "reason": "script_complete" } + }, + { + "kind": "terminal", + "payload": { + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "completed", + "runTerminalState": "succeeded", + "reportedWorkDisposition": "done" + } + }, + { "kind": "exit", "code": 0 } + ] +} diff --git a/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/interrupted.json b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/interrupted.json new file mode 100644 index 00000000000..a9b6cdf71c9 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/interrupted.json @@ -0,0 +1,58 @@ +{ + "schema": "paperclip.fake_harness.script.v1", + "name": "interrupted", + "steps": [ + { + "kind": "event", + "event_type": "item.started", + "item_id": "item_local_runner_long_command", + "payload": { "kind": "command", "text": "fake long-running command" } + }, + { "kind": "log", "stream": "stdout", "text": "waiting for interruption" }, + { "kind": "await_interrupt" }, + { + "kind": "event", + "event_type": "item.failed", + "item_id": "item_local_runner_long_command", + "payload": { "kind": "command", "message": "Interrupted by the operator." } + }, + { + "kind": "result", + "payload": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "yielded", + "summary": "The scripted turn was interrupted.", + "completionClaim": { + "contractRevision": "local_runner-contract-v1", + "objectiveSatisfied": false, + "criteria": [], + "remainingWork": [ + { "description": "Start a replacement turn.", "blocksCompletion": true } + ] + }, + "evidence": [], + "verification": [ + { "commandOrCheck": "turn interruption", "status": "passed" } + ], + "attentionRequests": [], + "artifacts": [], + "continuation": { + "kind": "same_agent", + "summary": "A replacement turn can continue the session.", + "idempotencyKey": "local_runner-interrupted-continuation" + } + } + }, + { "kind": "event", "event_type": "session.closed", "payload": {} }, + { + "kind": "terminal", + "payload": { + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "interrupted", + "runTerminalState": "cancelled", + "reportedWorkDisposition": "yielded" + } + }, + { "kind": "exit", "code": 130 } + ] +} diff --git a/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/linger.json b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/linger.json new file mode 100644 index 00000000000..b46ba34d81a --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/linger.json @@ -0,0 +1,15 @@ +{ + "schema": "paperclip.fake_harness.script.v1", + "name": "linger", + "steps": [ + { "kind": "spawn_worker" }, + { + "kind": "event", + "event_type": "item.started", + "item_id": "item_local_runner_linger", + "payload": { "kind": "command", "text": "wait for supervisor cleanup" } + }, + { "kind": "await_interrupt" }, + { "kind": "exit", "code": 130 } + ] +} diff --git a/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/oversized-line.json b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/oversized-line.json new file mode 100644 index 00000000000..38776bcdec2 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/oversized-line.json @@ -0,0 +1,12 @@ +{ + "schema": "paperclip.fake_harness.script.v1", + "name": "oversized-line", + "steps": [ + { + "kind": "log", + "stream": "stdout", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { "kind": "exit", "code": 0 } + ] +} diff --git a/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/permission-input.json b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/permission-input.json new file mode 100644 index 00000000000..8ca9ed3d5e6 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/local-runner/scripts/permission-input.json @@ -0,0 +1,83 @@ +{ + "schema": "paperclip.fake_harness.script.v1", + "name": "permission-input", + "steps": [ + { + "kind": "request", + "request": { + "schema": "paperclip.runtime_request.v1", + "requestKind": "runtime", + "requestId": "request_local_runner_permission", + "type": "permission", + "status": "pending", + "prompt": "Allow the fake driver to write its local fixture?", + "choices": [ + { "key": "allow", "label": "Allow" }, + { "key": "deny", "label": "Deny" } + ] + } + }, + { "kind": "await_resolution", "request_id": "request_local_runner_permission" }, + { + "kind": "request", + "request": { + "schema": "paperclip.runtime_request.v1", + "requestKind": "runtime", + "requestId": "request_local_runner_input", + "type": "input", + "status": "pending", + "prompt": "Name the local trace.", + "choices": [] + } + }, + { "kind": "await_resolution", "request_id": "request_local_runner_input" }, + { + "kind": "event", + "event_type": "item.started", + "item_id": "item_local_runner_permission", + "payload": { "kind": "assistant_message", "text": "" } + }, + { "kind": "delay", "milliseconds": 10 }, + { + "kind": "event", + "event_type": "item.completed", + "item_id": "item_local_runner_permission", + "payload": { + "kind": "assistant_message", + "text": "Permission and input were resolved through the local protocol." + } + }, + { + "kind": "result", + "payload": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "done", + "summary": "The permission and input flow completed.", + "completionClaim": { + "contractRevision": "local_runner-contract-v1", + "objectiveSatisfied": true, + "criteria": [], + "remainingWork": [] + }, + "evidence": [], + "verification": [ + { "commandOrCheck": "permission and input round trip", "status": "passed" } + ], + "attentionRequests": [], + "artifacts": [] + } + }, + { "kind": "event", "event_type": "turn.completed", "payload": {} }, + { "kind": "event", "event_type": "session.closed", "payload": {} }, + { + "kind": "terminal", + "payload": { + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "completed", + "runTerminalState": "succeeded", + "reportedWorkDisposition": "done" + } + }, + { "kind": "exit", "code": 0 } + ] +} diff --git a/packages/paperclip-runner/protocol/fixtures/questions/codex.json b/packages/paperclip-runner/protocol/fixtures/questions/codex.json new file mode 100644 index 00000000000..d1ec06850fe --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/questions/codex.json @@ -0,0 +1,40 @@ +{ + "schema": "paperclip.question_adapter_fixture.v1", + "adapter": "codex", + "nativeRequest": { + "method": "item/tool/requestUserInput", + "params": { + "title": "Deployment input", + "questions": [{ + "id": "environment", + "header": "Environment", + "question": "Where should we deploy?", + "options": [ + { "label": "Staging", "description": "Deploy to staging first." }, + { "label": "Production", "description": "Deploy directly to production." } + ] + }] + } + }, + "canonicalQuestionSet": { + "schema": "paperclip.question_set.v1", + "title": "Deployment input", + "submitLabel": "Submit answers", + "questions": [{ + "id": "environment", + "header": "Environment", + "prompt": "Where should we deploy?", + "required": true, + "answerMode": "single_select", + "options": [ + { "id": "option-1", "label": "Staging", "description": "Deploy to staging first." }, + { "id": "option-2", "label": "Production", "description": "Deploy directly to production." } + ] + }] + }, + "canonicalResponse": { + "schema": "paperclip.question_response.v1", + "answers": { "environment": { "selectedOptionIds": ["option-1"] } } + }, + "nativeResponse": { "answers": { "environment": { "answers": ["Staging"] } } } +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/duplicate-event.json b/packages/paperclip-runner/protocol/fixtures/replay/duplicate-event.json new file mode 100644 index 00000000000..4555eb448a5 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/duplicate-event.json @@ -0,0 +1,30 @@ +{ + "schema": "paperclip.prp.fixture.v1", "fixtureVersion": 1, "protocolVersion": 1, + "name": "Duplicate event", "description": "At-least-once delivery repeats one source event without repeating its effect.", + "identity": { + "schema": "paperclip.prp.identity.v1", "companyId": "company_replay", "issueId": "issue_replay_duplicate", + "runId": "run_replay_duplicate", "environmentLeaseId": "lease_replay_duplicate", "runnerInstanceId": "runner_replay", + "normalizedSessionId": "session_replay_duplicate", "driverSessionId": "driver_replay_duplicate" + }, + "capabilities": { + "schema": "paperclip.prp.capabilities.v1", "sessionReusePolicy": "new_per_run", "driver": { "kind": "fake", "version": "1.0.0" }, + "steer": false, "interrupt": true, "resume": true, "runtimeRequests": false, "structuredResult": true, "typedEvents": true + }, + "commands": [ + { "schema": "paperclip.prp.command.v1", "commandId": "command_duplicate_prepare", "controllerSeq": 1, "type": "run.prepare", "issuedAt": "2026-08-07T12:30:00.000Z", "payload": {} } + ], + "events": [ + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_01", "sourceSeq": 1, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_duplicate", "normalizedSessionId": "session_replay_duplicate", "eventType": "session.started", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:30:00.010Z", "payload": {} }, + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_02", "sourceSeq": 2, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_duplicate", "normalizedSessionId": "session_replay_duplicate", "turnId": "turn_replay_duplicate", "eventType": "turn.started", "schemaVersion": 1, "priority": 1, "emittedAt": "2026-08-07T12:30:00.020Z", "payload": {} }, + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_03", "sourceSeq": 3, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_duplicate", "normalizedSessionId": "session_replay_duplicate", "turnId": "turn_replay_duplicate", "itemId": "item_replay_duplicate", "eventType": "item.completed", "schemaVersion": 1, "priority": 1, "emittedAt": "2026-08-07T12:30:00.030Z", "payload": { "kind": "assistant_message", "text": "Exactly one projection." } }, + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_03", "sourceSeq": 3, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_duplicate", "normalizedSessionId": "session_replay_duplicate", "turnId": "turn_replay_duplicate", "itemId": "item_replay_duplicate", "eventType": "item.completed", "schemaVersion": 1, "priority": 1, "emittedAt": "2026-08-07T12:30:00.030Z", "payload": { "kind": "assistant_message", "text": "Exactly one projection." } }, + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_04", "sourceSeq": 4, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_duplicate", "normalizedSessionId": "session_replay_duplicate", "eventType": "run.result.proposed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:30:00.040Z", + "payload": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "done", "summary": "Duplicate delivery was idempotent.", "completionClaim": { "contractRevision": "replay-contract-v1", "objectiveSatisfied": true, "criteria": [], "remainingWork": [] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [] } + }, + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_05", "sourceSeq": 5, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_duplicate", "normalizedSessionId": "session_replay_duplicate", "turnId": "turn_replay_duplicate", "eventType": "turn.completed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:30:00.050Z", "payload": {} }, + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_06", "sourceSeq": 6, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_duplicate", "normalizedSessionId": "session_replay_duplicate", "eventType": "run.terminal", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:30:00.060Z", "payload": { "schema": "paperclip.prp.terminal.v1", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "done" } } + ], + "requests": [], + "result": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "done", "summary": "Duplicate delivery was idempotent.", "completionClaim": { "contractRevision": "replay-contract-v1", "objectiveSatisfied": true, "criteria": [], "remainingWork": [] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [] } +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/failed-run.json b/packages/paperclip-runner/protocol/fixtures/replay/failed-run.json new file mode 100644 index 00000000000..8327cd5f5d5 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/failed-run.json @@ -0,0 +1,106 @@ +{ + "schema": "paperclip.prp.fixture.v1", + "fixtureVersion": 1, + "protocolVersion": 1, + "name": "Failed run", + "description": "The driver reports an item and turn failure before the run terminalizes.", + "identity": { + "schema": "paperclip.prp.identity.v1", + "companyId": "company_replay", + "issueId": "issue_replay_failed", + "runId": "run_replay_failed", + "environmentLeaseId": "lease_replay_failed", + "runnerInstanceId": "runner_replay", + "normalizedSessionId": "session_replay_failed", + "driverSessionId": "driver_replay_failed" + }, + "capabilities": { + "schema": "paperclip.prp.capabilities.v1", + "sessionReusePolicy": "new_per_run", + "driver": { "kind": "fake", "version": "1.0.0" }, + "steer": true, + "interrupt": true, + "resume": true, + "runtimeRequests": true, + "structuredResult": true, + "typedEvents": true + }, + "commands": [ + { + "schema": "paperclip.prp.command.v1", + "commandId": "command_failed_prepare", + "controllerSeq": 1, + "type": "run.prepare", + "issuedAt": "2026-08-07T12:10:00.000Z", + "payload": {} + }, + { + "schema": "paperclip.prp.command.v1", + "commandId": "command_failed_turn", + "controllerSeq": 2, + "type": "turn.start", + "issuedAt": "2026-08-07T12:10:00.010Z", + "payload": { "text": "Run failing scenario" } + } + ], + "events": [ + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_failed_01", "sourceSeq": 1, + "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_failed", + "normalizedSessionId": "session_replay_failed", "eventType": "session.started", "schemaVersion": 1, + "priority": 0, "emittedAt": "2026-08-07T12:10:00.020Z", "payload": {} + }, + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_failed_02", "sourceSeq": 2, + "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_failed", + "normalizedSessionId": "session_replay_failed", "turnId": "turn_replay_failed", + "eventType": "turn.started", "schemaVersion": 1, "priority": 1, + "emittedAt": "2026-08-07T12:10:00.030Z", "payload": {} + }, + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_failed_03", "sourceSeq": 3, + "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_failed", + "normalizedSessionId": "session_replay_failed", "turnId": "turn_replay_failed", "itemId": "item_failed_command", + "eventType": "item.failed", "schemaVersion": 1, "priority": 0, + "emittedAt": "2026-08-07T12:10:00.040Z", "payload": { "kind": "command", "message": "Command exited with code 1" } + }, + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_failed_04", "sourceSeq": 4, + "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_failed", + "normalizedSessionId": "session_replay_failed", "turnId": "turn_replay_failed", + "eventType": "run.result.proposed", "schemaVersion": 1, "priority": 0, + "emittedAt": "2026-08-07T12:10:00.050Z", + "payload": { + "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "yielded", + "summary": "The scripted command failed and needs a retry.", + "completionClaim": { "contractRevision": "replay-contract-v1", "objectiveSatisfied": false, "criteria": [], "remainingWork": [{ "description": "Retry the failed command", "blocksCompletion": true }] }, + "evidence": [], "verification": [{ "commandOrCheck": "fake command", "status": "failed" }], + "attentionRequests": [], "artifacts": [], + "continuation": { "kind": "retry", "summary": "Retry after correcting the command", "idempotencyKey": "retry_replay_failed" } + } + }, + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_failed_05", "sourceSeq": 5, + "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_failed", + "normalizedSessionId": "session_replay_failed", "turnId": "turn_replay_failed", + "eventType": "turn.failed", "schemaVersion": 1, "priority": 0, + "emittedAt": "2026-08-07T12:10:00.060Z", "payload": { "message": "Fake driver failure" } + }, + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_failed_06", "sourceSeq": 6, + "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_failed", + "normalizedSessionId": "session_replay_failed", "eventType": "run.terminal", "schemaVersion": 1, + "priority": 0, "emittedAt": "2026-08-07T12:10:00.070Z", + "payload": { "schema": "paperclip.prp.terminal.v1", "turnTerminalState": "failed", "runTerminalState": "failed", "reportedWorkDisposition": "yielded" } + } + ], + "requests": [], + "result": { + "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "yielded", + "summary": "The scripted command failed and needs a retry.", + "completionClaim": { "contractRevision": "replay-contract-v1", "objectiveSatisfied": false, "criteria": [], "remainingWork": [{ "description": "Retry the failed command", "blocksCompletion": true }] }, + "evidence": [], "verification": [{ "commandOrCheck": "fake command", "status": "failed" }], + "attentionRequests": [], "artifacts": [], + "continuation": { "kind": "retry", "summary": "Retry after correcting the command", "idempotencyKey": "retry_replay_failed" } + } +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/golden/duplicate-event.snapshot.json b/packages/paperclip-runner/protocol/fixtures/replay/golden/duplicate-event.snapshot.json new file mode 100644 index 00000000000..f9266589f97 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/golden/duplicate-event.snapshot.json @@ -0,0 +1,130 @@ +{ + "schema": "paperclip.prp.session-snapshot.v1", + "fixtureName": "Duplicate event", + "identity": { + "schema": "paperclip.prp.identity.v1", + "companyId": "company_replay", + "issueId": "issue_replay_duplicate", + "runId": "run_replay_duplicate", + "environmentLeaseId": "lease_replay_duplicate", + "runnerInstanceId": "runner_replay", + "normalizedSessionId": "session_replay_duplicate", + "driverSessionId": "driver_replay_duplicate" + }, + "capabilities": { + "schema": "paperclip.prp.capabilities.v1", + "sessionReusePolicy": "new_per_run", + "driver": { + "kind": "fake", + "version": "1.0.0" + }, + "steer": false, + "interrupt": true, + "resume": true, + "runtimeRequests": false, + "structuredResult": true, + "typedEvents": true + }, + "runPhase": "terminal", + "sessionState": "running", + "turnState": "completed", + "activeTurnId": null, + "items": [ + { + "itemId": "item_replay_duplicate", + "kind": "assistant_message", + "status": "completed", + "text": "Exactly one projection." + } + ], + "requests": [], + "proposedResult": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "done", + "summary": "Duplicate delivery was idempotent.", + "completionClaim": { + "contractRevision": "replay-contract-v1", + "objectiveSatisfied": true, + "criteria": [], + "remainingWork": [] + }, + "evidence": [], + "verification": [], + "attentionRequests": [], + "artifacts": [] + }, + "terminal": { + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "completed", + "runTerminalState": "succeeded", + "reportedWorkDisposition": "done" + }, + "timeline": [ + { + "position": 1, + "sourceEventId": "event_duplicate_01", + "sourceSeq": 1, + "eventType": "session.started", + "emittedAt": "2026-08-07T12:30:00.010Z", + "summary": "session started" + }, + { + "position": 2, + "sourceEventId": "event_duplicate_02", + "sourceSeq": 2, + "eventType": "turn.started", + "emittedAt": "2026-08-07T12:30:00.020Z", + "summary": "turn started" + }, + { + "position": 3, + "sourceEventId": "event_duplicate_03", + "sourceSeq": 3, + "eventType": "item.completed", + "emittedAt": "2026-08-07T12:30:00.030Z", + "itemId": "item_replay_duplicate", + "summary": "Completed assistant_message" + }, + { + "position": 4, + "sourceEventId": "event_duplicate_04", + "sourceSeq": 4, + "eventType": "run.result.proposed", + "emittedAt": "2026-08-07T12:30:00.040Z", + "summary": "Duplicate delivery was idempotent." + }, + { + "position": 5, + "sourceEventId": "event_duplicate_05", + "sourceSeq": 5, + "eventType": "turn.completed", + "emittedAt": "2026-08-07T12:30:00.050Z", + "summary": "turn completed" + }, + { + "position": 6, + "sourceEventId": "event_duplicate_06", + "sourceSeq": 6, + "eventType": "run.terminal", + "emittedAt": "2026-08-07T12:30:00.060Z", + "summary": "Run succeeded" + } + ], + "sourceCursors": { + "runner:runner_replay": 6 + }, + "processedEventIds": [ + "event_duplicate_01", + "event_duplicate_02", + "event_duplicate_03", + "event_duplicate_04", + "event_duplicate_05", + "event_duplicate_06" + ], + "duplicateEventIds": [ + "event_duplicate_03" + ], + "outOfOrderEventIds": [], + "gaps": [], + "integrity": "complete" +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/golden/duplicate-event.summary.json b/packages/paperclip-runner/protocol/fixtures/replay/golden/duplicate-event.summary.json new file mode 100644 index 00000000000..73538edbcac --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/golden/duplicate-event.summary.json @@ -0,0 +1,11 @@ +{ + "runId": "run_replay_duplicate", + "integrity": "complete", + "timelineCount": 6, + "duplicateEventIds": [ + "event_duplicate_03" + ], + "gaps": [], + "turnTerminalState": "completed", + "runTerminalState": "succeeded" +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/golden/failed-run.snapshot.json b/packages/paperclip-runner/protocol/fixtures/replay/golden/failed-run.snapshot.json new file mode 100644 index 00000000000..9cb7b875219 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/golden/failed-run.snapshot.json @@ -0,0 +1,143 @@ +{ + "schema": "paperclip.prp.session-snapshot.v1", + "fixtureName": "Failed run", + "identity": { + "schema": "paperclip.prp.identity.v1", + "companyId": "company_replay", + "issueId": "issue_replay_failed", + "runId": "run_replay_failed", + "environmentLeaseId": "lease_replay_failed", + "runnerInstanceId": "runner_replay", + "normalizedSessionId": "session_replay_failed", + "driverSessionId": "driver_replay_failed" + }, + "capabilities": { + "schema": "paperclip.prp.capabilities.v1", + "sessionReusePolicy": "new_per_run", + "driver": { + "kind": "fake", + "version": "1.0.0" + }, + "steer": true, + "interrupt": true, + "resume": true, + "runtimeRequests": true, + "structuredResult": true, + "typedEvents": true + }, + "runPhase": "terminal", + "sessionState": "running", + "turnState": "failed", + "activeTurnId": null, + "items": [ + { + "itemId": "item_failed_command", + "kind": "command", + "status": "failed", + "text": "" + } + ], + "requests": [], + "proposedResult": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "yielded", + "summary": "The scripted command failed and needs a retry.", + "completionClaim": { + "contractRevision": "replay-contract-v1", + "objectiveSatisfied": false, + "criteria": [], + "remainingWork": [ + { + "description": "Retry the failed command", + "blocksCompletion": true + } + ] + }, + "evidence": [], + "verification": [ + { + "commandOrCheck": "fake command", + "status": "failed" + } + ], + "attentionRequests": [], + "artifacts": [], + "continuation": { + "kind": "retry", + "summary": "Retry after correcting the command", + "idempotencyKey": "retry_replay_failed" + } + }, + "terminal": { + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "failed", + "runTerminalState": "failed", + "reportedWorkDisposition": "yielded" + }, + "timeline": [ + { + "position": 1, + "sourceEventId": "event_failed_01", + "sourceSeq": 1, + "eventType": "session.started", + "emittedAt": "2026-08-07T12:10:00.020Z", + "summary": "session started" + }, + { + "position": 2, + "sourceEventId": "event_failed_02", + "sourceSeq": 2, + "eventType": "turn.started", + "emittedAt": "2026-08-07T12:10:00.030Z", + "summary": "turn started" + }, + { + "position": 3, + "sourceEventId": "event_failed_03", + "sourceSeq": 3, + "eventType": "item.failed", + "emittedAt": "2026-08-07T12:10:00.040Z", + "itemId": "item_failed_command", + "summary": "Command exited with code 1" + }, + { + "position": 4, + "sourceEventId": "event_failed_04", + "sourceSeq": 4, + "eventType": "run.result.proposed", + "emittedAt": "2026-08-07T12:10:00.050Z", + "summary": "The scripted command failed and needs a retry." + }, + { + "position": 5, + "sourceEventId": "event_failed_05", + "sourceSeq": 5, + "eventType": "turn.failed", + "emittedAt": "2026-08-07T12:10:00.060Z", + "summary": "turn failed" + }, + { + "position": 6, + "sourceEventId": "event_failed_06", + "sourceSeq": 6, + "eventType": "run.terminal", + "emittedAt": "2026-08-07T12:10:00.070Z", + "summary": "Run failed" + } + ], + "sourceCursors": { + "runner:runner_replay": 6 + }, + "processedEventIds": [ + "event_failed_01", + "event_failed_02", + "event_failed_03", + "event_failed_04", + "event_failed_05", + "event_failed_06" + ], + "duplicateEventIds": [], + "outOfOrderEventIds": [], + "gaps": [], + "integrity": "complete" +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/golden/failed-run.summary.json b/packages/paperclip-runner/protocol/fixtures/replay/golden/failed-run.summary.json new file mode 100644 index 00000000000..318a6728948 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/golden/failed-run.summary.json @@ -0,0 +1,9 @@ +{ + "runId": "run_replay_failed", + "integrity": "complete", + "timelineCount": 6, + "duplicateEventIds": [], + "gaps": [], + "turnTerminalState": "failed", + "runTerminalState": "failed" +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/golden/happy-path.snapshot.json b/packages/paperclip-runner/protocol/fixtures/replay/golden/happy-path.snapshot.json new file mode 100644 index 00000000000..55d0858ff44 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/golden/happy-path.snapshot.json @@ -0,0 +1,166 @@ +{ + "schema": "paperclip.prp.session-snapshot.v1", + "fixtureName": "Happy path", + "identity": { + "schema": "paperclip.prp.identity.v1", + "companyId": "company_replay", + "issueId": "issue_replay_happy", + "runId": "run_replay_happy", + "environmentLeaseId": "lease_replay_happy", + "runnerInstanceId": "runner_replay", + "normalizedSessionId": "session_replay_happy", + "driverSessionId": "driver_replay_happy", + "providerSessionId": "provider_replay_happy" + }, + "capabilities": { + "schema": "paperclip.prp.capabilities.v1", + "sessionReusePolicy": "new_per_run", + "driver": { + "kind": "fake", + "version": "1.0.0" + }, + "steer": true, + "interrupt": true, + "resume": true, + "runtimeRequests": true, + "structuredResult": true, + "typedEvents": true, + "unsupported": [] + }, + "runPhase": "terminal", + "sessionState": "running", + "turnState": "completed", + "activeTurnId": null, + "items": [ + { + "itemId": "item_replay_message", + "kind": "assistant_message", + "status": "completed", + "text": "Validated fixture. Replay complete." + } + ], + "requests": [], + "proposedResult": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "done", + "summary": "The scripted run completed successfully.", + "completionClaim": { + "contractRevision": "replay-contract-v1", + "objectiveSatisfied": true, + "criteria": [], + "remainingWork": [] + }, + "evidence": [], + "verification": [ + { + "commandOrCheck": "fixture replay", + "status": "passed" + } + ], + "attentionRequests": [], + "artifacts": [] + }, + "terminal": { + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "completed", + "runTerminalState": "succeeded", + "reportedWorkDisposition": "done", + "workAssessmentId": "assessment_replay_happy", + "statusDecisionId": "decision_replay_happy" + }, + "timeline": [ + { + "position": 1, + "sourceEventId": "event_happy_01", + "sourceSeq": 1, + "eventType": "runtime.phase.changed", + "emittedAt": "2026-08-07T12:00:00.030Z", + "summary": "Phase changed to workspace preparing" + }, + { + "position": 2, + "sourceEventId": "event_happy_02", + "sourceSeq": 2, + "eventType": "session.started", + "emittedAt": "2026-08-07T12:00:00.040Z", + "summary": "session started" + }, + { + "position": 3, + "sourceEventId": "event_happy_03", + "sourceSeq": 3, + "eventType": "turn.started", + "emittedAt": "2026-08-07T12:00:00.050Z", + "summary": "turn started" + }, + { + "position": 4, + "sourceEventId": "event_happy_04", + "sourceSeq": 4, + "eventType": "item.started", + "emittedAt": "2026-08-07T12:00:00.060Z", + "itemId": "item_replay_message", + "summary": "Started assistant_message" + }, + { + "position": 5, + "sourceEventId": "event_happy_05", + "sourceSeq": 5, + "eventType": "item.delta", + "emittedAt": "2026-08-07T12:00:00.070Z", + "itemId": "item_replay_message", + "summary": "Validated fixture. " + }, + { + "position": 6, + "sourceEventId": "event_happy_06", + "sourceSeq": 6, + "eventType": "item.completed", + "emittedAt": "2026-08-07T12:00:00.080Z", + "itemId": "item_replay_message", + "summary": "Completed assistant_message" + }, + { + "position": 7, + "sourceEventId": "event_happy_07", + "sourceSeq": 7, + "eventType": "run.result.proposed", + "emittedAt": "2026-08-07T12:00:00.090Z", + "summary": "The scripted run completed successfully." + }, + { + "position": 8, + "sourceEventId": "event_happy_08", + "sourceSeq": 8, + "eventType": "turn.completed", + "emittedAt": "2026-08-07T12:00:00.100Z", + "summary": "turn completed" + }, + { + "position": 9, + "sourceEventId": "event_happy_09", + "sourceSeq": 9, + "eventType": "run.terminal", + "emittedAt": "2026-08-07T12:00:00.110Z", + "summary": "Run succeeded" + } + ], + "sourceCursors": { + "runner:runner_replay": 9 + }, + "processedEventIds": [ + "event_happy_01", + "event_happy_02", + "event_happy_03", + "event_happy_04", + "event_happy_05", + "event_happy_06", + "event_happy_07", + "event_happy_08", + "event_happy_09" + ], + "duplicateEventIds": [], + "outOfOrderEventIds": [], + "gaps": [], + "integrity": "complete" +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/golden/happy-path.summary.json b/packages/paperclip-runner/protocol/fixtures/replay/golden/happy-path.summary.json new file mode 100644 index 00000000000..4d26a94a7b8 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/golden/happy-path.summary.json @@ -0,0 +1,9 @@ +{ + "runId": "run_replay_happy", + "integrity": "complete", + "timelineCount": 9, + "duplicateEventIds": [], + "gaps": [], + "turnTerminalState": "completed", + "runTerminalState": "succeeded" +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/golden/interrupted-run.snapshot.json b/packages/paperclip-runner/protocol/fixtures/replay/golden/interrupted-run.snapshot.json new file mode 100644 index 00000000000..39b84778d4d --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/golden/interrupted-run.snapshot.json @@ -0,0 +1,138 @@ +{ + "schema": "paperclip.prp.session-snapshot.v1", + "fixtureName": "Interrupted run", + "identity": { + "schema": "paperclip.prp.identity.v1", + "companyId": "company_replay", + "issueId": "issue_replay_interrupted", + "runId": "run_replay_interrupted", + "environmentLeaseId": "lease_replay_interrupted", + "runnerInstanceId": "runner_replay", + "normalizedSessionId": "session_replay_interrupted", + "driverSessionId": "driver_replay_interrupted" + }, + "capabilities": { + "schema": "paperclip.prp.capabilities.v1", + "sessionReusePolicy": "reuse_per_issue", + "driver": { + "kind": "fake", + "version": "1.0.0" + }, + "steer": true, + "interrupt": true, + "resume": true, + "runtimeRequests": true, + "structuredResult": true, + "typedEvents": true + }, + "runPhase": "terminal", + "sessionState": "running", + "turnState": "interrupted", + "activeTurnId": null, + "items": [], + "requests": [ + { + "requestId": "request_interrupted_permission", + "requestKind": "runtime", + "type": "permission", + "status": "pending", + "prompt": "Allow the fake command?" + } + ], + "proposedResult": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "yielded", + "summary": "The turn was interrupted.", + "completionClaim": { + "contractRevision": "replay-contract-v1", + "objectiveSatisfied": false, + "criteria": [], + "remainingWork": [ + { + "description": "Resume the interrupted turn", + "blocksCompletion": true + } + ] + }, + "evidence": [], + "verification": [], + "attentionRequests": [], + "artifacts": [], + "continuation": { + "kind": "same_agent", + "summary": "Resume in the same normalized session", + "idempotencyKey": "resume_replay_interrupted" + } + }, + "terminal": { + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "interrupted", + "runTerminalState": "cancelled", + "reportedWorkDisposition": "yielded" + }, + "timeline": [ + { + "position": 1, + "sourceEventId": "event_interrupted_01", + "sourceSeq": 1, + "eventType": "session.started", + "emittedAt": "2026-08-07T12:20:00.020Z", + "summary": "session started" + }, + { + "position": 2, + "sourceEventId": "event_interrupted_02", + "sourceSeq": 2, + "eventType": "turn.started", + "emittedAt": "2026-08-07T12:20:00.030Z", + "summary": "turn started" + }, + { + "position": 3, + "sourceEventId": "event_interrupted_03", + "sourceSeq": 3, + "eventType": "runtime_request.created", + "emittedAt": "2026-08-07T12:20:00.040Z", + "summary": "permission: Allow the fake command?" + }, + { + "position": 4, + "sourceEventId": "event_interrupted_04", + "sourceSeq": 4, + "eventType": "turn.interrupted", + "emittedAt": "2026-08-07T12:20:00.060Z", + "summary": "turn interrupted" + }, + { + "position": 5, + "sourceEventId": "event_interrupted_05", + "sourceSeq": 5, + "eventType": "run.result.proposed", + "emittedAt": "2026-08-07T12:20:00.070Z", + "summary": "The turn was interrupted." + }, + { + "position": 6, + "sourceEventId": "event_interrupted_06", + "sourceSeq": 6, + "eventType": "run.terminal", + "emittedAt": "2026-08-07T12:20:00.080Z", + "summary": "Run cancelled" + } + ], + "sourceCursors": { + "runner:runner_replay": 6 + }, + "processedEventIds": [ + "event_interrupted_01", + "event_interrupted_02", + "event_interrupted_03", + "event_interrupted_04", + "event_interrupted_05", + "event_interrupted_06" + ], + "duplicateEventIds": [], + "outOfOrderEventIds": [], + "gaps": [], + "integrity": "complete" +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/golden/interrupted-run.summary.json b/packages/paperclip-runner/protocol/fixtures/replay/golden/interrupted-run.summary.json new file mode 100644 index 00000000000..308e8f1cd05 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/golden/interrupted-run.summary.json @@ -0,0 +1,9 @@ +{ + "runId": "run_replay_interrupted", + "integrity": "complete", + "timelineCount": 6, + "duplicateEventIds": [], + "gaps": [], + "turnTerminalState": "interrupted", + "runTerminalState": "cancelled" +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/golden/source-gap.snapshot.json b/packages/paperclip-runner/protocol/fixtures/replay/golden/source-gap.snapshot.json new file mode 100644 index 00000000000..4b60a8556fa --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/golden/source-gap.snapshot.json @@ -0,0 +1,127 @@ +{ + "schema": "paperclip.prp.session-snapshot.v1", + "fixtureName": "Source sequence gap", + "identity": { + "schema": "paperclip.prp.identity.v1", + "companyId": "company_replay", + "issueId": "issue_replay_gap", + "runId": "run_replay_gap", + "environmentLeaseId": "lease_replay_gap", + "runnerInstanceId": "runner_replay", + "normalizedSessionId": "session_replay_gap", + "driverSessionId": "driver_replay_gap" + }, + "capabilities": { + "schema": "paperclip.prp.capabilities.v1", + "sessionReusePolicy": "new_per_run", + "driver": { + "kind": "fake", + "version": "1.0.0" + }, + "steer": false, + "interrupt": false, + "resume": true, + "runtimeRequests": false, + "structuredResult": true, + "typedEvents": true + }, + "runPhase": "terminal", + "sessionState": "running", + "turnState": "completed", + "activeTurnId": null, + "items": [], + "requests": [], + "proposedResult": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "needs_review", + "summary": "The source gap requires reconciliation.", + "completionClaim": { + "contractRevision": "replay-contract-v1", + "objectiveSatisfied": false, + "criteria": [], + "remainingWork": [ + { + "description": "Replay missing source event", + "blocksCompletion": true + } + ] + }, + "evidence": [], + "verification": [], + "attentionRequests": [], + "artifacts": [] + }, + "terminal": { + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "completed", + "runTerminalState": "succeeded", + "reportedWorkDisposition": "needs_review" + }, + "timeline": [ + { + "position": 1, + "sourceEventId": "event_gap_01", + "sourceSeq": 1, + "eventType": "session.started", + "emittedAt": "2026-08-07T12:40:00.010Z", + "summary": "session started" + }, + { + "position": 2, + "sourceEventId": "event_gap_02", + "sourceSeq": 2, + "eventType": "turn.started", + "emittedAt": "2026-08-07T12:40:00.020Z", + "summary": "turn started" + }, + { + "position": 3, + "sourceEventId": "event_gap_04", + "sourceSeq": 4, + "eventType": "run.result.proposed", + "emittedAt": "2026-08-07T12:40:00.040Z", + "summary": "The source gap requires reconciliation." + }, + { + "position": 4, + "sourceEventId": "event_gap_05", + "sourceSeq": 5, + "eventType": "turn.completed", + "emittedAt": "2026-08-07T12:40:00.050Z", + "summary": "turn completed" + }, + { + "position": 5, + "sourceEventId": "event_gap_06", + "sourceSeq": 6, + "eventType": "run.terminal", + "emittedAt": "2026-08-07T12:40:00.060Z", + "summary": "Run succeeded" + } + ], + "sourceCursors": { + "runner:runner_replay": 6 + }, + "processedEventIds": [ + "event_gap_01", + "event_gap_02", + "event_gap_04", + "event_gap_05", + "event_gap_06" + ], + "duplicateEventIds": [], + "outOfOrderEventIds": [], + "gaps": [ + { + "sourceKey": "runner:runner_replay", + "expected": 3, + "received": 4, + "missingCount": 1, + "missing": [ + 3 + ], + "truncated": false + } + ], + "integrity": "gap_detected" +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/golden/source-gap.summary.json b/packages/paperclip-runner/protocol/fixtures/replay/golden/source-gap.summary.json new file mode 100644 index 00000000000..3f86623bd6a --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/golden/source-gap.summary.json @@ -0,0 +1,20 @@ +{ + "runId": "run_replay_gap", + "integrity": "gap_detected", + "timelineCount": 5, + "duplicateEventIds": [], + "gaps": [ + { + "sourceKey": "runner:runner_replay", + "expected": 3, + "received": 4, + "missingCount": 1, + "missing": [ + 3 + ], + "truncated": false + } + ], + "turnTerminalState": "completed", + "runTerminalState": "succeeded" +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/golden/unknown-optional-fields.snapshot.json b/packages/paperclip-runner/protocol/fixtures/replay/golden/unknown-optional-fields.snapshot.json new file mode 100644 index 00000000000..cdca456aa70 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/golden/unknown-optional-fields.snapshot.json @@ -0,0 +1,111 @@ +{ + "schema": "paperclip.prp.session-snapshot.v1", + "fixtureName": "Unknown optional fields", + "identity": { + "schema": "paperclip.prp.identity.v1", + "companyId": "company_replay", + "issueId": "issue_replay_future", + "runId": "run_replay_future", + "environmentLeaseId": "lease_replay_future", + "runnerInstanceId": "runner_replay", + "normalizedSessionId": "session_replay_future", + "driverSessionId": "driver_replay_future", + "futureIdentityLabel": "preserved" + }, + "capabilities": { + "schema": "paperclip.prp.capabilities.v1", + "sessionReusePolicy": "new_per_run", + "driver": { + "kind": "fake", + "version": "1.1.0", + "futureDriverField": true + }, + "steer": true, + "interrupt": true, + "resume": true, + "runtimeRequests": true, + "structuredResult": true, + "typedEvents": true, + "futureCapability": { + "mode": "optional" + } + }, + "runPhase": "terminal", + "sessionState": "running", + "turnState": "completed", + "activeTurnId": null, + "items": [], + "requests": [], + "proposedResult": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "done", + "summary": "Unknown optional fields did not change the v1 projection.", + "completionClaim": { + "contractRevision": "replay-contract-v1", + "objectiveSatisfied": true, + "criteria": [], + "remainingWork": [] + }, + "evidence": [], + "verification": [], + "attentionRequests": [], + "artifacts": [], + "futureResultAnnotation": { + "safe": true + } + }, + "terminal": { + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "completed", + "runTerminalState": "succeeded", + "reportedWorkDisposition": "done", + "futureTerminalReason": "optional" + }, + "timeline": [ + { + "position": 1, + "sourceEventId": "event_future_01", + "sourceSeq": 1, + "eventType": "session.started", + "emittedAt": "2026-08-07T12:50:00.010Z", + "summary": "session started" + }, + { + "position": 2, + "sourceEventId": "event_future_02", + "sourceSeq": 2, + "eventType": "run.result.proposed", + "emittedAt": "2026-08-07T12:50:00.020Z", + "summary": "Unknown optional fields did not change the v1 projection." + }, + { + "position": 3, + "sourceEventId": "event_future_03", + "sourceSeq": 3, + "eventType": "turn.completed", + "emittedAt": "2026-08-07T12:50:00.030Z", + "summary": "turn completed" + }, + { + "position": 4, + "sourceEventId": "event_future_04", + "sourceSeq": 4, + "eventType": "run.terminal", + "emittedAt": "2026-08-07T12:50:00.040Z", + "summary": "Run succeeded" + } + ], + "sourceCursors": { + "runner:runner_replay": 4 + }, + "processedEventIds": [ + "event_future_01", + "event_future_02", + "event_future_03", + "event_future_04" + ], + "duplicateEventIds": [], + "outOfOrderEventIds": [], + "gaps": [], + "integrity": "complete" +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/golden/unknown-optional-fields.summary.json b/packages/paperclip-runner/protocol/fixtures/replay/golden/unknown-optional-fields.summary.json new file mode 100644 index 00000000000..24bebe0fbcf --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/golden/unknown-optional-fields.summary.json @@ -0,0 +1,9 @@ +{ + "runId": "run_replay_future", + "integrity": "complete", + "timelineCount": 4, + "duplicateEventIds": [], + "gaps": [], + "turnTerminalState": "completed", + "runTerminalState": "succeeded" +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/happy-path.json b/packages/paperclip-runner/protocol/fixtures/replay/happy-path.json new file mode 100644 index 00000000000..c5f656d22e1 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/happy-path.json @@ -0,0 +1,230 @@ +{ + "schema": "paperclip.prp.fixture.v1", + "fixtureVersion": 1, + "protocolVersion": 1, + "name": "Happy path", + "description": "A typed assistant message completes and the run proposes a successful result.", + "identity": { + "schema": "paperclip.prp.identity.v1", + "companyId": "company_replay", + "issueId": "issue_replay_happy", + "runId": "run_replay_happy", + "environmentLeaseId": "lease_replay_happy", + "runnerInstanceId": "runner_replay", + "normalizedSessionId": "session_replay_happy", + "driverSessionId": "driver_replay_happy", + "providerSessionId": "provider_replay_happy" + }, + "capabilities": { + "schema": "paperclip.prp.capabilities.v1", + "sessionReusePolicy": "new_per_run", + "driver": { "kind": "fake", "version": "1.0.0" }, + "steer": true, + "interrupt": true, + "resume": true, + "runtimeRequests": true, + "structuredResult": true, + "typedEvents": true, + "unsupported": [] + }, + "commands": [ + { + "schema": "paperclip.prp.command.v1", + "commandId": "command_happy_prepare", + "controllerSeq": 1, + "type": "run.prepare", + "issuedAt": "2026-08-07T12:00:00.000Z", + "payload": { "workspace": "fixture" } + }, + { + "schema": "paperclip.prp.command.v1", + "commandId": "command_happy_session", + "controllerSeq": 2, + "type": "session.open", + "issuedAt": "2026-08-07T12:00:00.010Z", + "payload": { "reuse": "new_per_run" } + }, + { + "schema": "paperclip.prp.command.v1", + "commandId": "command_happy_turn", + "controllerSeq": 3, + "type": "turn.start", + "issuedAt": "2026-08-07T12:00:00.020Z", + "payload": { "text": "Inspect the fixture" } + } + ], + "events": [ + { + "schema": "paperclip.prp.event.v1", + "sourceEventId": "event_happy_01", + "sourceSeq": 1, + "sourceInstanceId": "runner_replay", + "sourceKind": "runner", + "runId": "run_replay_happy", + "normalizedSessionId": "session_replay_happy", + "eventType": "runtime.phase.changed", + "schemaVersion": 1, + "priority": 1, + "emittedAt": "2026-08-07T12:00:00.030Z", + "payload": { "phase": "workspace_preparing" } + }, + { + "schema": "paperclip.prp.event.v1", + "sourceEventId": "event_happy_02", + "sourceSeq": 2, + "sourceInstanceId": "runner_replay", + "sourceKind": "runner", + "runId": "run_replay_happy", + "normalizedSessionId": "session_replay_happy", + "eventType": "session.started", + "schemaVersion": 1, + "priority": 0, + "emittedAt": "2026-08-07T12:00:00.040Z", + "payload": { "driverSessionId": "driver_replay_happy" } + }, + { + "schema": "paperclip.prp.event.v1", + "sourceEventId": "event_happy_03", + "sourceSeq": 3, + "sourceInstanceId": "runner_replay", + "sourceKind": "runner", + "runId": "run_replay_happy", + "normalizedSessionId": "session_replay_happy", + "turnId": "turn_replay_happy", + "eventType": "turn.started", + "schemaVersion": 1, + "priority": 1, + "emittedAt": "2026-08-07T12:00:00.050Z", + "payload": {} + }, + { + "schema": "paperclip.prp.event.v1", + "sourceEventId": "event_happy_04", + "sourceSeq": 4, + "sourceInstanceId": "runner_replay", + "sourceKind": "runner", + "runId": "run_replay_happy", + "normalizedSessionId": "session_replay_happy", + "turnId": "turn_replay_happy", + "itemId": "item_replay_message", + "eventType": "item.started", + "schemaVersion": 1, + "priority": 1, + "emittedAt": "2026-08-07T12:00:00.060Z", + "payload": { "kind": "assistant_message", "text": "" } + }, + { + "schema": "paperclip.prp.event.v1", + "sourceEventId": "event_happy_05", + "sourceSeq": 5, + "sourceInstanceId": "runner_replay", + "sourceKind": "runner", + "runId": "run_replay_happy", + "normalizedSessionId": "session_replay_happy", + "turnId": "turn_replay_happy", + "itemId": "item_replay_message", + "eventType": "item.delta", + "schemaVersion": 1, + "priority": 2, + "emittedAt": "2026-08-07T12:00:00.070Z", + "payload": { "text": "Validated fixture. " } + }, + { + "schema": "paperclip.prp.event.v1", + "sourceEventId": "event_happy_06", + "sourceSeq": 6, + "sourceInstanceId": "runner_replay", + "sourceKind": "runner", + "runId": "run_replay_happy", + "normalizedSessionId": "session_replay_happy", + "turnId": "turn_replay_happy", + "itemId": "item_replay_message", + "eventType": "item.completed", + "schemaVersion": 1, + "priority": 1, + "emittedAt": "2026-08-07T12:00:00.080Z", + "payload": { "kind": "assistant_message", "text": "Validated fixture. Replay complete." } + }, + { + "schema": "paperclip.prp.event.v1", + "sourceEventId": "event_happy_07", + "sourceSeq": 7, + "sourceInstanceId": "runner_replay", + "sourceKind": "runner", + "runId": "run_replay_happy", + "normalizedSessionId": "session_replay_happy", + "turnId": "turn_replay_happy", + "eventType": "run.result.proposed", + "schemaVersion": 1, + "priority": 0, + "emittedAt": "2026-08-07T12:00:00.090Z", + "payload": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "done", + "summary": "The scripted run completed successfully.", + "completionClaim": { + "contractRevision": "replay-contract-v1", + "objectiveSatisfied": true, + "criteria": [], + "remainingWork": [] + }, + "evidence": [], + "verification": [{ "commandOrCheck": "fixture replay", "status": "passed" }], + "attentionRequests": [], + "artifacts": [] + } + }, + { + "schema": "paperclip.prp.event.v1", + "sourceEventId": "event_happy_08", + "sourceSeq": 8, + "sourceInstanceId": "runner_replay", + "sourceKind": "runner", + "runId": "run_replay_happy", + "normalizedSessionId": "session_replay_happy", + "turnId": "turn_replay_happy", + "eventType": "turn.completed", + "schemaVersion": 1, + "priority": 0, + "emittedAt": "2026-08-07T12:00:00.100Z", + "payload": {} + }, + { + "schema": "paperclip.prp.event.v1", + "sourceEventId": "event_happy_09", + "sourceSeq": 9, + "sourceInstanceId": "runner_replay", + "sourceKind": "runner", + "runId": "run_replay_happy", + "normalizedSessionId": "session_replay_happy", + "eventType": "run.terminal", + "schemaVersion": 1, + "priority": 0, + "emittedAt": "2026-08-07T12:00:00.110Z", + "payload": { + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "completed", + "runTerminalState": "succeeded", + "reportedWorkDisposition": "done", + "workAssessmentId": "assessment_replay_happy", + "statusDecisionId": "decision_replay_happy" + } + } + ], + "requests": [], + "result": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "done", + "summary": "The scripted run completed successfully.", + "completionClaim": { + "contractRevision": "replay-contract-v1", + "objectiveSatisfied": true, + "criteria": [], + "remainingWork": [] + }, + "evidence": [], + "verification": [{ "commandOrCheck": "fixture replay", "status": "passed" }], + "attentionRequests": [], + "artifacts": [] + } +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/interrupted-run.json b/packages/paperclip-runner/protocol/fixtures/replay/interrupted-run.json new file mode 100644 index 00000000000..26bf4ed3aad --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/interrupted-run.json @@ -0,0 +1,90 @@ +{ + "schema": "paperclip.prp.fixture.v1", + "fixtureVersion": 1, + "protocolVersion": 1, + "name": "Interrupted run", + "description": "A pending runtime permission request is followed by an explicit turn interruption.", + "identity": { + "schema": "paperclip.prp.identity.v1", "companyId": "company_replay", "issueId": "issue_replay_interrupted", + "runId": "run_replay_interrupted", "environmentLeaseId": "lease_replay_interrupted", + "runnerInstanceId": "runner_replay", "normalizedSessionId": "session_replay_interrupted", + "driverSessionId": "driver_replay_interrupted" + }, + "capabilities": { + "schema": "paperclip.prp.capabilities.v1", "sessionReusePolicy": "reuse_per_issue", + "driver": { "kind": "fake", "version": "1.0.0" }, "steer": true, "interrupt": true, + "resume": true, "runtimeRequests": true, "structuredResult": true, "typedEvents": true + }, + "commands": [ + { + "schema": "paperclip.prp.command.v1", "commandId": "command_interrupted_prepare", "controllerSeq": 1, + "type": "run.prepare", "issuedAt": "2026-08-07T12:20:00.000Z", "payload": {} + }, + { + "schema": "paperclip.prp.command.v1", "commandId": "command_interrupted_turn", "controllerSeq": 2, + "type": "turn.start", "issuedAt": "2026-08-07T12:20:00.010Z", "payload": { "text": "Wait for interruption" } + }, + { + "schema": "paperclip.prp.command.v1", "commandId": "command_interrupted_interrupt", "controllerSeq": 3, + "type": "turn.interrupt", "issuedAt": "2026-08-07T12:20:00.050Z", "payload": { "reason": "operator" } + } + ], + "events": [ + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_interrupted_01", "sourceSeq": 1, + "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_interrupted", + "normalizedSessionId": "session_replay_interrupted", "eventType": "session.started", "schemaVersion": 1, + "priority": 0, "emittedAt": "2026-08-07T12:20:00.020Z", "payload": {} + }, + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_interrupted_02", "sourceSeq": 2, + "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_interrupted", + "normalizedSessionId": "session_replay_interrupted", "turnId": "turn_replay_interrupted", + "eventType": "turn.started", "schemaVersion": 1, "priority": 1, + "emittedAt": "2026-08-07T12:20:00.030Z", "payload": {} + }, + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_interrupted_03", "sourceSeq": 3, + "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_interrupted", + "normalizedSessionId": "session_replay_interrupted", "turnId": "turn_replay_interrupted", + "eventType": "runtime_request.created", "schemaVersion": 1, "priority": 0, + "emittedAt": "2026-08-07T12:20:00.040Z", + "payload": { "request": { "schema": "paperclip.runtime_request.v1", "requestKind": "runtime", "requestId": "request_interrupted_permission", "type": "permission", "status": "pending", "prompt": "Allow the fake command?" } } + }, + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_interrupted_04", "sourceSeq": 4, + "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_interrupted", + "normalizedSessionId": "session_replay_interrupted", "turnId": "turn_replay_interrupted", + "eventType": "turn.interrupted", "schemaVersion": 1, "priority": 0, + "emittedAt": "2026-08-07T12:20:00.060Z", "payload": { "reason": "operator" } + }, + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_interrupted_05", "sourceSeq": 5, + "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_interrupted", + "normalizedSessionId": "session_replay_interrupted", "eventType": "run.result.proposed", "schemaVersion": 1, + "priority": 0, "emittedAt": "2026-08-07T12:20:00.070Z", + "payload": { + "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "yielded", "summary": "The turn was interrupted.", + "completionClaim": { "contractRevision": "replay-contract-v1", "objectiveSatisfied": false, "criteria": [], "remainingWork": [{ "description": "Resume the interrupted turn", "blocksCompletion": true }] }, + "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [], + "continuation": { "kind": "same_agent", "summary": "Resume in the same normalized session", "idempotencyKey": "resume_replay_interrupted" } + } + }, + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_interrupted_06", "sourceSeq": 6, + "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_interrupted", + "normalizedSessionId": "session_replay_interrupted", "eventType": "run.terminal", "schemaVersion": 1, + "priority": 0, "emittedAt": "2026-08-07T12:20:00.080Z", + "payload": { "schema": "paperclip.prp.terminal.v1", "turnTerminalState": "interrupted", "runTerminalState": "cancelled", "reportedWorkDisposition": "yielded" } + } + ], + "requests": [ + { "schema": "paperclip.runtime_request.v1", "requestKind": "runtime", "requestId": "request_interrupted_permission", "type": "permission", "status": "pending", "prompt": "Allow the fake command?" } + ], + "result": { + "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "yielded", "summary": "The turn was interrupted.", + "completionClaim": { "contractRevision": "replay-contract-v1", "objectiveSatisfied": false, "criteria": [], "remainingWork": [{ "description": "Resume the interrupted turn", "blocksCompletion": true }] }, + "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [], + "continuation": { "kind": "same_agent", "summary": "Resume in the same normalized session", "idempotencyKey": "resume_replay_interrupted" } + } +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/source-gap.json b/packages/paperclip-runner/protocol/fixtures/replay/source-gap.json new file mode 100644 index 00000000000..130e88e22d2 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/source-gap.json @@ -0,0 +1,19 @@ +{ + "schema": "paperclip.prp.fixture.v1", "fixtureVersion": 1, "protocolVersion": 1, + "name": "Source sequence gap", "description": "The runner stream skips source sequence 3 and replay records the missing cursor.", + "identity": { "schema": "paperclip.prp.identity.v1", "companyId": "company_replay", "issueId": "issue_replay_gap", "runId": "run_replay_gap", "environmentLeaseId": "lease_replay_gap", "runnerInstanceId": "runner_replay", "normalizedSessionId": "session_replay_gap", "driverSessionId": "driver_replay_gap" }, + "capabilities": { "schema": "paperclip.prp.capabilities.v1", "sessionReusePolicy": "new_per_run", "driver": { "kind": "fake", "version": "1.0.0" }, "steer": false, "interrupt": false, "resume": true, "runtimeRequests": false, "structuredResult": true, "typedEvents": true }, + "commands": [{ "schema": "paperclip.prp.command.v1", "commandId": "command_gap_prepare", "controllerSeq": 1, "type": "run.prepare", "issuedAt": "2026-08-07T12:40:00.000Z", "payload": {} }], + "events": [ + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_gap_01", "sourceSeq": 1, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_gap", "normalizedSessionId": "session_replay_gap", "eventType": "session.started", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:40:00.010Z", "payload": {} }, + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_gap_02", "sourceSeq": 2, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_gap", "normalizedSessionId": "session_replay_gap", "turnId": "turn_replay_gap", "eventType": "turn.started", "schemaVersion": 1, "priority": 1, "emittedAt": "2026-08-07T12:40:00.020Z", "payload": {} }, + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_gap_04", "sourceSeq": 4, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_gap", "normalizedSessionId": "session_replay_gap", "eventType": "run.result.proposed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:40:00.040Z", + "payload": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "needs_review", "summary": "The source gap requires reconciliation.", "completionClaim": { "contractRevision": "replay-contract-v1", "objectiveSatisfied": false, "criteria": [], "remainingWork": [{ "description": "Replay missing source event", "blocksCompletion": true }] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [] } + }, + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_gap_05", "sourceSeq": 5, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_gap", "normalizedSessionId": "session_replay_gap", "turnId": "turn_replay_gap", "eventType": "turn.completed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:40:00.050Z", "payload": {} }, + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_gap_06", "sourceSeq": 6, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_gap", "normalizedSessionId": "session_replay_gap", "eventType": "run.terminal", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:40:00.060Z", "payload": { "schema": "paperclip.prp.terminal.v1", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "needs_review" } } + ], + "requests": [], + "result": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "needs_review", "summary": "The source gap requires reconciliation.", "completionClaim": { "contractRevision": "replay-contract-v1", "objectiveSatisfied": false, "criteria": [], "remainingWork": [{ "description": "Replay missing source event", "blocksCompletion": true }] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [] } +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/unknown-optional-fields.json b/packages/paperclip-runner/protocol/fixtures/replay/unknown-optional-fields.json new file mode 100644 index 00000000000..0a51d2ab444 --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/unknown-optional-fields.json @@ -0,0 +1,19 @@ +{ + "schema": "paperclip.prp.fixture.v1", "fixtureVersion": 1, "protocolVersion": 1, + "name": "Unknown optional fields", "description": "A future producer adds optional fields that a PRP v1 consumer preserves and ignores safely.", + "futureFixtureHint": { "producerVersion": "1.1-preview" }, + "identity": { "schema": "paperclip.prp.identity.v1", "companyId": "company_replay", "issueId": "issue_replay_future", "runId": "run_replay_future", "environmentLeaseId": "lease_replay_future", "runnerInstanceId": "runner_replay", "normalizedSessionId": "session_replay_future", "driverSessionId": "driver_replay_future", "futureIdentityLabel": "preserved" }, + "capabilities": { "schema": "paperclip.prp.capabilities.v1", "sessionReusePolicy": "new_per_run", "driver": { "kind": "fake", "version": "1.1.0", "futureDriverField": true }, "steer": true, "interrupt": true, "resume": true, "runtimeRequests": true, "structuredResult": true, "typedEvents": true, "futureCapability": { "mode": "optional" } }, + "commands": [{ "schema": "paperclip.prp.command.v1", "commandId": "command_future_prepare", "controllerSeq": 1, "type": "run.prepare", "issuedAt": "2026-08-07T12:50:00.000Z", "payload": {}, "futureDeadlineMode": "soft" }], + "events": [ + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_future_01", "sourceSeq": 1, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_future", "normalizedSessionId": "session_replay_future", "eventType": "session.started", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:50:00.010Z", "payload": { "futureSessionDetail": "ignored" }, "futureEnvelopeField": 42 }, + { + "schema": "paperclip.prp.event.v1", "sourceEventId": "event_future_02", "sourceSeq": 2, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_future", "normalizedSessionId": "session_replay_future", "eventType": "run.result.proposed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:50:00.020Z", + "payload": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "done", "summary": "Unknown optional fields did not change the v1 projection.", "completionClaim": { "contractRevision": "replay-contract-v1", "objectiveSatisfied": true, "criteria": [], "remainingWork": [] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [], "futureResultAnnotation": { "safe": true } } + }, + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_future_03", "sourceSeq": 3, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_future", "normalizedSessionId": "session_replay_future", "turnId": "turn_replay_future", "eventType": "turn.completed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:50:00.030Z", "payload": {} }, + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_future_04", "sourceSeq": 4, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_future", "normalizedSessionId": "session_replay_future", "eventType": "run.terminal", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:50:00.040Z", "payload": { "schema": "paperclip.prp.terminal.v1", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "done", "futureTerminalReason": "optional" } } + ], + "requests": [], + "result": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "done", "summary": "Unknown optional fields did not change the v1 projection.", "completionClaim": { "contractRevision": "replay-contract-v1", "objectiveSatisfied": true, "criteria": [], "remainingWork": [] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [], "futureResultAnnotation": { "safe": true } } +} diff --git a/packages/paperclip-runner/protocol/fixtures/replay/unsupported-required-version.json b/packages/paperclip-runner/protocol/fixtures/replay/unsupported-required-version.json new file mode 100644 index 00000000000..f8c63b4e46d --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/replay/unsupported-required-version.json @@ -0,0 +1,13 @@ +{ + "schema": "paperclip.prp.fixture.v1", "fixtureVersion": 1, "protocolVersion": 2, + "name": "Unsupported required version", "description": "A fixture that requires PRP v2 must fail closed in a PRP v1 consumer.", + "identity": { "schema": "paperclip.prp.identity.v1", "companyId": "company_replay", "issueId": "issue_replay_v2", "runId": "run_replay_v2", "environmentLeaseId": "lease_replay_v2", "runnerInstanceId": "runner_replay", "normalizedSessionId": "session_replay_v2", "driverSessionId": "driver_replay_v2" }, + "capabilities": { "schema": "paperclip.prp.capabilities.v1", "sessionReusePolicy": "new_per_run", "driver": { "kind": "future", "version": "2.0.0" }, "steer": true, "interrupt": true, "resume": true, "runtimeRequests": true, "structuredResult": true, "typedEvents": true }, + "commands": [{ "schema": "paperclip.prp.command.v1", "commandId": "command_v2_prepare", "controllerSeq": 1, "type": "run.prepare", "issuedAt": "2026-08-07T13:00:00.000Z", "payload": {} }], + "events": [ + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_v2_01", "sourceSeq": 1, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_v2", "normalizedSessionId": "session_replay_v2", "eventType": "run.result.proposed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T13:00:00.010Z", "payload": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "done", "summary": "This result requires PRP v2.", "completionClaim": { "contractRevision": "replay-contract-v2", "objectiveSatisfied": true, "criteria": [], "remainingWork": [] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [] } }, + { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_v2_02", "sourceSeq": 2, "sourceInstanceId": "runner_replay", "sourceKind": "runner", "runId": "run_replay_v2", "normalizedSessionId": "session_replay_v2", "eventType": "run.terminal", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T13:00:00.020Z", "payload": { "schema": "paperclip.prp.terminal.v1", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "done" } } + ], + "requests": [], + "result": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "done", "summary": "This result requires PRP v2.", "completionClaim": { "contractRevision": "replay-contract-v2", "objectiveSatisfied": true, "criteria": [], "remainingWork": [] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [] } +} diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json new file mode 100644 index 00000000000..3ebb45a9cbe --- /dev/null +++ b/packages/paperclip-runner/protocol/manifest.json @@ -0,0 +1,287 @@ +{ + "schema": "paperclip.prp.contract_manifest.v1", + "protocolVersion": 1, + "fixtureVersion": 1, + "generatedFrom": [ + "protocol/schemas", + "protocol/fixtures" + ], + "schemas": [ + { + "path": "schemas/capabilities.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json", + "sha256": "e62a80328e5edde136b35463033280922ac2220575a83046b07fe034d233db88" + }, + { + "path": "schemas/command.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/command.schema.json", + "sha256": "fc813e6b4d5b7e6890b496bd69848ada25dba58adbf7cf04163ca9e9bf2bcb79" + }, + { + "path": "schemas/conformance-fixture.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/conformance-fixture.schema.json", + "sha256": "0febc66d128da1f251f9e932d78353a67f47ee60b7427e0a347c287bb6b7ea79" + }, + { + "path": "schemas/conformance-output.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/conformance-output.schema.json", + "sha256": "96f78846dffc4a8300184751b9943214506f32ee1f393c6c0c453b666cf12d59" + }, + { + "path": "schemas/event.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/event.schema.json", + "sha256": "d69ac4acfd9cd265f9dcb6a6580e949a67a50347093d0a7c12c2000a2da6d482" + }, + { + "path": "schemas/fixture.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/fixture.schema.json", + "sha256": "8ac87c2d8c639981277b5f72b9757a95badf85ff482d11596e4c63e01c616cba" + }, + { + "path": "schemas/identity.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/identity.schema.json", + "sha256": "8a8a1943f14c739284daa241c5189f3b8026c050472c4f9909897456da506f48" + }, + { + "path": "schemas/provider-descriptor.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json", + "sha256": "26e8af9314f1ca49578bfbacdcc111332225b422ea4ff68918a25ddac59129a6" + }, + { + "path": "schemas/provider-event.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json", + "sha256": "84521ea87ae782a7c07d1ef68b70f87fc4c436e79951a8076b7ba55e923fa88b" + }, + { + "path": "schemas/question-adapter-fixture.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/question-adapter-fixture.schema.json", + "sha256": "002062abb4df9a98c92003eb4af43afbe8bdedd15d08b8697ad221fcc28ad9c7" + }, + { + "path": "schemas/question-response.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/question-response.schema.json", + "sha256": "dec308b0daa4d882deb3878a1ad6ae5360f3342060f63ca636d65d1d9af1e055" + }, + { + "path": "schemas/question-set.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json", + "sha256": "3bda40311ae3153bbf9d4a8c59f716680e17d711a4db3570193963918359751c" + }, + { + "path": "schemas/request.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/request.schema.json", + "sha256": "bd41f01c00eddc1b635d3131a7183ddb616ff8fe68d5e348ddd9593a674c8d8e" + }, + { + "path": "schemas/result.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/result.schema.json", + "sha256": "90f02c0a96e8ab9f75970fd788b61c18ccbd9abbd005bedfba280701729adce0" + }, + { + "path": "schemas/semantic-tool.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json", + "sha256": "f53f6832c0d65caaccf540662fdb08cf62d9bab45a18def7b559c4068ef54efc" + }, + { + "path": "schemas/stop-reason.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json", + "sha256": "ed0c0807904ff1198273458057e1a565e828dba499372c0c80e2c69a1d600a13" + }, + { + "path": "schemas/terminal.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/terminal.schema.json", + "sha256": "e9093399ac6422e77b060269910ffaba9a3d574ed578832821e310a0582edffd" + }, + { + "path": "schemas/usage.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/usage.schema.json", + "sha256": "28fdbb3202095144649dddc438e6270c66d599c6f7e86838687f58d644726456" + }, + { + "path": "schemas/workspace-diff.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json", + "sha256": "f71b21f41cfc4c40aeaa503d1981d29355b4dd8b15036fcf3d8df5c35f8b8f6f" + }, + { + "path": "schemas/workspace-file-reference.schema.json", + "id": "https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json", + "sha256": "0ab57e76c9710f520ed9d15cc5d0f25c68176beff3aff556ad62dba9b922eb23" + } + ], + "fixtures": [ + { + "path": "fixtures/conformance-expected-output.json", + "sha256": "5a643639c9df0a2925ba95c5c5dfa2217605018bfa0dd3954ab4aca5fcfdc2c6", + "expectation": "accept", + "compatibilityCase": "cross-language-output" + }, + { + "path": "fixtures/conformance-minimal-run.json", + "sha256": "1e4304bcb51c3d7aa0575cf47b1ff3901700a254a60cf7432003ce9af0175e3a", + "expectation": "accept", + "compatibilityCase": "cross-language-input" + }, + { + "path": "fixtures/local-runner/scripts/duplicate-terminal.json", + "sha256": "56928a21318ea4b390c65fb34f0d3114760619d79301f4d535391a8566b693ab", + "expectation": "accept", + "compatibilityCase": "canonical" + }, + { + "path": "fixtures/local-runner/scripts/error.json", + "sha256": "a4c74f8a85f8ab25adb13e83ae753fcac1a9cb289bad44d49be05df8f3170edb", + "expectation": "accept", + "compatibilityCase": "canonical" + }, + { + "path": "fixtures/local-runner/scripts/happy-path.json", + "sha256": "ccdeef3df1e2020b692da3bd361958643f2f3d5ec6e1e0b16cca9e0d9890da2f", + "expectation": "accept", + "compatibilityCase": "canonical" + }, + { + "path": "fixtures/local-runner/scripts/interrupted.json", + "sha256": "36dc94dc1a67d320a48921f2ed10f0e7ab760c518eea84d5f224e1e6ab8fa57c", + "expectation": "accept", + "compatibilityCase": "canonical" + }, + { + "path": "fixtures/local-runner/scripts/linger.json", + "sha256": "ac6fed60bea22b22c16501c03388828ccaae6e9ec44ceb2ec0e8b145ab590bb8", + "expectation": "accept", + "compatibilityCase": "canonical" + }, + { + "path": "fixtures/local-runner/scripts/oversized-line.json", + "sha256": "485b03653a2e41351f155b787a406f903e018b3c29b19c4ddf2dfc7c031552b7", + "expectation": "accept", + "compatibilityCase": "canonical" + }, + { + "path": "fixtures/local-runner/scripts/permission-input.json", + "sha256": "9b64b78ed75006b9190c80fd88c5ab555ef458f7ad023e8ebfdac8de373e1f9c", + "expectation": "accept", + "compatibilityCase": "canonical" + }, + { + "path": "fixtures/questions/codex.json", + "sha256": "ad673b6f9d70b74b53656e622d15fad1a66430f39d62b9ef71479d194a198bd6", + "expectation": "accept", + "compatibilityCase": "codex-structured-input" + }, + { + "path": "fixtures/replay/duplicate-event.json", + "sha256": "779c146966d2825ffcde88bfff8b1fb238c162d40c578980ad3bd1a7def405d7", + "expectation": "accept", + "compatibilityCase": "canonical" + }, + { + "path": "fixtures/replay/failed-run.json", + "sha256": "59281021b6ff11e4765213043580fc91eca8a02bedb3fe0c012bef9ad807da12", + "expectation": "accept", + "compatibilityCase": "canonical" + }, + { + "path": "fixtures/replay/golden/duplicate-event.snapshot.json", + "sha256": "24a6c221f9d46824128cde009b277dae2ea8672a608cf7fa2d7e4a714e38bc92", + "expectation": "accept", + "compatibilityCase": "deterministic-replay-oracle" + }, + { + "path": "fixtures/replay/golden/duplicate-event.summary.json", + "sha256": "dcbb2dae5810649cc59c6c0fe136e1033131d9510ebcbcc5f3435bfa998b95c9", + "expectation": "accept", + "compatibilityCase": "deterministic-replay-oracle" + }, + { + "path": "fixtures/replay/golden/failed-run.snapshot.json", + "sha256": "a2bb468e3ca94b092a86afb8a57d2d05e0ce3cd6e9f87927dea81aef5aa32dd1", + "expectation": "accept", + "compatibilityCase": "deterministic-replay-oracle" + }, + { + "path": "fixtures/replay/golden/failed-run.summary.json", + "sha256": "d7ac19d40b5d1d6106e2232cd2436ba1c4232bc6daa8a71957f12300ba851f65", + "expectation": "accept", + "compatibilityCase": "deterministic-replay-oracle" + }, + { + "path": "fixtures/replay/golden/happy-path.snapshot.json", + "sha256": "037c114df8f205a13fa9070b07e6f9707218b8249bd196925246b054a7690853", + "expectation": "accept", + "compatibilityCase": "deterministic-replay-oracle" + }, + { + "path": "fixtures/replay/golden/happy-path.summary.json", + "sha256": "00d2a033518c7b3f51ecb10aa64f7456922f6b3f930d853b3db35e72eaf55325", + "expectation": "accept", + "compatibilityCase": "deterministic-replay-oracle" + }, + { + "path": "fixtures/replay/golden/interrupted-run.snapshot.json", + "sha256": "dc216831c437cc8fd2c37cee6f40f342767b21e01a511f2235a443be0d0b7b0c", + "expectation": "accept", + "compatibilityCase": "deterministic-replay-oracle" + }, + { + "path": "fixtures/replay/golden/interrupted-run.summary.json", + "sha256": "753f226607b2d97301736d7f54b281ab7157397f3f32015c644787d370526332", + "expectation": "accept", + "compatibilityCase": "deterministic-replay-oracle" + }, + { + "path": "fixtures/replay/golden/source-gap.snapshot.json", + "sha256": "7db7f25e423a721e60adfcdfee02db6d5e6fcd4a644ee180f5e29810ebbc0d78", + "expectation": "accept", + "compatibilityCase": "deterministic-replay-oracle" + }, + { + "path": "fixtures/replay/golden/source-gap.summary.json", + "sha256": "861d0ee3fafe13bf8bf0fb71b7c1ef9a62cb26efba4dc8db889f36d27a14a813", + "expectation": "accept", + "compatibilityCase": "deterministic-replay-oracle" + }, + { + "path": "fixtures/replay/golden/unknown-optional-fields.snapshot.json", + "sha256": "cf5b13a4b8d7ae73d1632e0539ec50d2ebcee8fbb6a2f33b28db7ff786fa90ff", + "expectation": "accept", + "compatibilityCase": "deterministic-replay-oracle" + }, + { + "path": "fixtures/replay/golden/unknown-optional-fields.summary.json", + "sha256": "7d983bcad0439b7884e70d07f8070b53ed594fba2da3e8ac6998570e238e84dc", + "expectation": "accept", + "compatibilityCase": "deterministic-replay-oracle" + }, + { + "path": "fixtures/replay/happy-path.json", + "sha256": "dfe0aeb4e7a217f8d375afe6e6d1ab0151bb8e81921b07ae0aaa6c4d0b8f8a1d", + "expectation": "accept", + "compatibilityCase": "canonical" + }, + { + "path": "fixtures/replay/interrupted-run.json", + "sha256": "2056789605ffde847b3272e751226ce4be61527f6aaa4fe3f5557a621af0ad5c", + "expectation": "accept", + "compatibilityCase": "canonical" + }, + { + "path": "fixtures/replay/source-gap.json", + "sha256": "d8a3eb2861e8198226360e666e5296ff5d7215f81d255a273b21d2bfe7dcc2ef", + "expectation": "accept", + "compatibilityCase": "canonical" + }, + { + "path": "fixtures/replay/unknown-optional-fields.json", + "sha256": "4dbc4b70c299fe21791ca66949f3cb189c8f76514b62c52f5f40a489a84515c4", + "expectation": "accept", + "compatibilityCase": "additive-optional-fields" + }, + { + "path": "fixtures/replay/unsupported-required-version.json", + "sha256": "60ac964d2cf49dc80ada0cb9c946b25952f3e2be10719801d3f2f8bd98d27699", + "expectation": "reject", + "compatibilityCase": "unknown-required-version" + } + ] +} diff --git a/packages/paperclip-runner/protocol/schemas/capabilities.schema.json b/packages/paperclip-runner/protocol/schemas/capabilities.schema.json new file mode 100644 index 00000000000..fdaf331bb50 --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/capabilities.schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json", + "title": "PRP negotiated capabilities", + "type": "object", + "required": [ + "schema", + "sessionReusePolicy", + "driver", + "steer", + "interrupt", + "resume", + "runtimeRequests", + "structuredResult", + "typedEvents" + ], + "properties": { + "schema": { "const": "paperclip.prp.capabilities.v1" }, + "sessionReusePolicy": { + "enum": ["new_per_run", "reuse_per_issue", "reuse_per_workspace"] + }, + "driver": { + "type": "object", + "required": ["kind", "version"], + "properties": { + "kind": { "type": "string", "minLength": 1, "maxLength": 80 }, + "version": { "type": "string", "minLength": 1, "maxLength": 80 } + }, + "additionalProperties": true + }, + "steer": { "type": "boolean" }, + "interrupt": { "type": "boolean" }, + "resume": { "type": "boolean" }, + "runtimeRequests": { "type": "boolean" }, + "structuredResult": { "type": "boolean" }, + "typedEvents": { "type": "boolean" }, + "typedEventFamilies": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "required": ["family", "version", "availability", "detailLevel"], + "properties": { + "family": { "type": "string", "minLength": 1, "maxLength": 160, "pattern": "^[a-z][a-z0-9_.-]*$" }, + "version": { "const": 1 }, + "availability": { "enum": ["available", "unsupported", "policy_disabled"] }, + "detailLevel": { "enum": ["summary", "structured"] } + }, + "additionalProperties": false + } + }, + "semanticTools": { + "type": "object", + "required": ["schema", "schemaVersion", "operations"], + "properties": { + "schema": { "const": "paperclip.prp.semantic_tools.v1" }, + "schemaVersion": { "const": 1 }, + "operations": { + "type": "array", + "items": { + "type": "object", + "required": ["operationId", "version", "availability", "redactionDisposition"], + "properties": { + "operationId": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "version": { "const": 1 }, + "availability": { "enum": ["available", "denied", "unsupported"] }, + "redactionDisposition": { "const": "digest_only" }, + "requiredClaims": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 160 }, + "uniqueItems": true + }, + "reasonCode": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_.:-]*$" + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + }, + "unsupported": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": true +} diff --git a/packages/paperclip-runner/protocol/schemas/command.schema.json b/packages/paperclip-runner/protocol/schemas/command.schema.json new file mode 100644 index 00000000000..b6ae97cca73 --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/command.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/command.schema.json", + "title": "PRP runner command", + "type": "object", + "required": ["schema", "commandId", "controllerSeq", "type", "issuedAt", "payload"], + "properties": { + "schema": { "const": "paperclip.prp.command.v1" }, + "commandId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "controllerSeq": { "type": "integer", "minimum": 1 }, + "type": { + "enum": [ + "run.prepare", + "run.attach", + "session.open", + "turn.start", + "turn.steer", + "turn.interrupt", + "turn.stop", + "request.resolve", + "interaction.receipt", + "semantic_tool.result", + "session.snapshot", + "session.close", + "session.budget.increase", + "session.destroy", + "run.cancel", + "runner.drain", + "runner.suspend", + "runner.shutdown" + ] + }, + "issuedAt": { "type": "string", "format": "date-time" }, + "deadlineAt": { "type": "string", "format": "date-time" }, + "precondition": { + "type": "object", + "properties": { + "runnerState": { "type": "array", "items": { "type": "string" } }, + "runState": { "type": "array", "items": { "type": "string" } }, + "sessionState": { "type": "array", "items": { "type": "string" } }, + "activeTurnId": { "type": ["string", "null"] } + }, + "additionalProperties": true + }, + "payload": { "type": "object", "additionalProperties": true } + }, + "additionalProperties": true +} diff --git a/packages/paperclip-runner/protocol/schemas/conformance-fixture.schema.json b/packages/paperclip-runner/protocol/schemas/conformance-fixture.schema.json new file mode 100644 index 00000000000..cae7cd0b4b9 --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/conformance-fixture.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/conformance-fixture.schema.json", + "title": "Paperclip Runner cross-language conformance fixture", + "type": "object", + "required": ["schemaVersion", "run", "events", "result"], + "properties": { + "schemaVersion": { "const": "paperclip.runner.conformance.fixture.v1" }, + "run": { + "type": "object", + "required": ["runId", "sessionId", "companyId", "issueId", "agentId"], + "properties": { + "runId": { "$ref": "#/$defs/id" }, + "sessionId": { "$ref": "#/$defs/id" }, + "companyId": { "$ref": "#/$defs/id" }, + "issueId": { "$ref": "#/$defs/id" }, + "agentId": { "$ref": "#/$defs/id" } + }, + "additionalProperties": true + }, + "events": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["eventId", "runId", "sequence", "type"], + "properties": { + "eventId": { "$ref": "#/$defs/id" }, + "runId": { "$ref": "#/$defs/id" }, + "sequence": { "type": "integer", "minimum": 1 }, + "type": { "enum": ["run.started", "run.completed"] } + }, + "additionalProperties": true + } + }, + "result": { + "type": "object", + "required": ["runId", "status", "summary"], + "properties": { + "runId": { "$ref": "#/$defs/id" }, + "status": { "enum": ["succeeded", "failed", "interrupted"] }, + "summary": { "type": "string", "minLength": 1, "maxLength": 4000 } + }, + "additionalProperties": true + } + }, + "$defs": { + "id": { "type": "string", "minLength": 1, "maxLength": 160 } + }, + "additionalProperties": true +} diff --git a/packages/paperclip-runner/protocol/schemas/conformance-output.schema.json b/packages/paperclip-runner/protocol/schemas/conformance-output.schema.json new file mode 100644 index 00000000000..e348c75e4a6 --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/conformance-output.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/conformance-output.schema.json", + "title": "Paperclip Runner cross-language conformance output", + "type": "object", + "required": ["schemaVersion", "runIdentity", "result"], + "properties": { + "schemaVersion": { "const": "paperclip.runner.conformance.output.v1" }, + "runIdentity": { + "type": "object", + "required": ["runId", "sessionId"], + "properties": { + "runId": { "$ref": "#/$defs/id" }, + "sessionId": { "$ref": "#/$defs/id" } + }, + "additionalProperties": true + }, + "result": { + "type": "object", + "required": ["status", "summary"], + "properties": { + "status": { "enum": ["succeeded", "failed", "interrupted"] }, + "summary": { "type": "string", "minLength": 1, "maxLength": 4000 } + }, + "additionalProperties": true + } + }, + "$defs": { + "id": { "type": "string", "minLength": 1, "maxLength": 160 } + }, + "additionalProperties": true +} diff --git a/packages/paperclip-runner/protocol/schemas/event.schema.json b/packages/paperclip-runner/protocol/schemas/event.schema.json new file mode 100644 index 00000000000..355e1f64770 --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/event.schema.json @@ -0,0 +1,173 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/event.schema.json", + "title": "PRP native event", + "type": "object", + "required": [ + "schema", + "sourceEventId", + "sourceSeq", + "sourceInstanceId", + "sourceKind", + "runId", + "eventType", + "schemaVersion", + "priority", + "emittedAt", + "payload" + ], + "properties": { + "schema": { "const": "paperclip.prp.event.v1" }, + "sourceEventId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "sourceSeq": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, + "sourceInstanceId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "sourceKind": { "enum": ["runner", "control_plane"] }, + "runId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "normalizedSessionId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "turnId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "itemId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "eventType": { + "enum": [ + "runner.connected", "runner.reconnected", "runner.reconciled", "runner.disconnected", "runner.draining", "runner.suspending", "runner.suspended", "runner.stopped", "runner.diagnostic", + "runtime.phase.changed", "sandbox.metric", "workspace.ready", "workspace.change.updated", "workspace.diff.recorded", "workspace.file.referenced", + "harness.starting", "harness.ready", "harness.exited", "harness.diagnostic", + "plan.updated", + "tool.execution.started", "tool.execution.progressed", "tool.execution.completed", + "research.started", "research.progressed", "research.completed", + "delegation.started", "delegation.updated", "delegation.completed", + "model.route.changed", "model.verification.updated", "context.compacted", + "artifact.viewed", "artifact.generated", "review.mode.changed", + "hook.started", "hook.completed", "memory.citation.referenced", + "safety.review.started", "safety.review.completed", "terminal.input.sent", + "wait.started", "wait.completed", "provider.notice.recorded", + "session.starting", "session.started", "session.resuming", "session.resumed", "session.reconciled", "session.updated", "session.closed", "session.failed", + "turn.submitted", "turn.accepted", "turn.started", "turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled", + "item.started", "item.delta", "item.completed", "item.failed", "usage.reported", + "semantic_tool.input", "semantic_tool.result", + "mcp_app.discovered", "mcp_app.resource.resolved", "mcp_app.initializing", "mcp_app.ready", "mcp_app.tool_input", "mcp_app.tool_result", "mcp_app.action.requested", "mcp_app.action.resolved", "mcp_app.host_context.changed", "mcp_app.failed", "mcp_app.teardown", + "runtime_request.created", "runtime_request.resolved", "runtime_request.expired", "runtime_request.cancelled", + "interaction.request.proposed", "interaction.request.materialized", "interaction.request.rejected", "interaction.response.progressed", "interaction.response.resolved", "interaction.response.delivered", + "run.attached", "run.detached", "run.result.proposed", "run.result.accepted", "run.result.rejected", + "attention.request.proposed", "attention.request.routed", "attention.request.resolved", "attention.request.expired", "attention.request.superseded", + "work.assessment.recorded", "issue.status.decision.recorded", "issue.status.decision.applied", "issue.status.decision.rejected", "issue.status.decision.superseded", + "run.terminal" + ] + }, + "schemaVersion": { "const": 1 }, + "priority": { "enum": [0, 1, 2] }, + "emittedAt": { "type": "string", "format": "date-time" }, + "observedAt": { "type": "string", "format": "date-time" }, + "payload": { + "type": "object", + "properties": { + "channel": { "enum": ["progress", "final", "summary", "detail", "unknown"] }, + "providerPhase": { "type": "string" }, + "providerMethod": { "type": "string" } + }, + "additionalProperties": true + }, + "debug": { "type": "object", "additionalProperties": true } + }, + "allOf": [ + { "if": { "properties": { "eventType": { "const": "plan.updated" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/plan" } } } }, + { "if": { "properties": { "eventType": { "enum": ["tool.execution.started", "tool.execution.progressed", "tool.execution.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/toolExecution" } } } }, + { "if": { "properties": { "eventType": { "enum": ["research.started", "research.progressed", "research.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/research" } } } }, + { "if": { "properties": { "eventType": { "enum": ["delegation.started", "delegation.updated", "delegation.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/delegation" } } } }, + { "if": { "properties": { "eventType": { "const": "model.route.changed" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/modelRoute" } } } }, + { "if": { "properties": { "eventType": { "const": "model.verification.updated" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/modelVerification" } } } }, + { "if": { "properties": { "eventType": { "const": "context.compacted" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/contextCompacted" } } } }, + { "if": { "properties": { "eventType": { "const": "artifact.viewed" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/artifactViewed" } } } }, + { "if": { "properties": { "eventType": { "const": "artifact.generated" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/artifactGenerated" } } } }, + { "if": { "properties": { "eventType": { "const": "review.mode.changed" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/reviewMode" } } } }, + { "if": { "properties": { "eventType": { "enum": ["hook.started", "hook.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/hook" } } } }, + { "if": { "properties": { "eventType": { "const": "memory.citation.referenced" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/memoryCitation" } } } }, + { "if": { "properties": { "eventType": { "enum": ["safety.review.started", "safety.review.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/safetyReview" } } } }, + { "if": { "properties": { "eventType": { "const": "terminal.input.sent" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/terminalInput" } } } }, + { "if": { "properties": { "eventType": { "enum": ["wait.started", "wait.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/wait" } } } }, + { "if": { "properties": { "eventType": { "const": "provider.notice.recorded" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/providerNotice" } } } }, + { + "if": { "properties": { "eventType": { "const": "workspace.file.referenced" } } }, + "then": { + "properties": { + "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json" } + } + } + }, + { + "if": { "properties": { "eventType": { "enum": ["workspace.change.updated", "workspace.diff.recorded"] } } }, + "then": { + "properties": { + "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json" } + } + } + }, + { + "if": { "properties": { "eventType": { "const": "workspace.diff.recorded" } } }, + "then": { + "properties": { + "payload": { + "allOf": [ + { "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json" }, + { "type": "object", "properties": { "complete": { "const": true } } } + ] + } + } + } + }, + { + "if": { "properties": { "eventType": { "enum": ["semantic_tool.input", "mcp_app.tool_input"] } } }, + "then": { + "properties": { + "payload": { + "type": "object", + "properties": { + "semantic_tool": { + "allOf": [ + { "$ref": "https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json" }, + { "type": "object", "properties": { "phase": { "const": "input" } } } + ] + } + }, + "additionalProperties": true + } + } + } + }, + { + "if": { "properties": { "eventType": { "enum": ["semantic_tool.result", "mcp_app.tool_result"] } } }, + "then": { + "properties": { + "payload": { + "type": "object", + "properties": { + "semantic_tool": { + "allOf": [ + { "$ref": "https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json" }, + { "type": "object", "properties": { "phase": { "const": "result" } } } + ] + } + }, + "additionalProperties": true + } + } + } + }, + { + "if": { "properties": { "eventType": { "const": "run.terminal" } } }, + "then": { + "properties": { + "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/terminal.schema.json" } + } + } + }, + { + "if": { "properties": { "eventType": { "const": "run.result.proposed" } } }, + "then": { + "properties": { + "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/result.schema.json" } + } + } + } + ], + "additionalProperties": true +} diff --git a/packages/paperclip-runner/protocol/schemas/fixture.schema.json b/packages/paperclip-runner/protocol/schemas/fixture.schema.json new file mode 100644 index 00000000000..18d33ade541 --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/fixture.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/fixture.schema.json", + "title": "PRP scripted replay fixture", + "type": "object", + "required": [ + "schema", + "fixtureVersion", + "protocolVersion", + "name", + "description", + "identity", + "capabilities", + "commands", + "events", + "result" + ], + "properties": { + "schema": { "const": "paperclip.prp.fixture.v1" }, + "fixtureVersion": { "const": 1 }, + "protocolVersion": { "const": 1 }, + "name": { "type": "string", "minLength": 1, "maxLength": 120 }, + "description": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "identity": { "$ref": "https://paperclip.dev/schemas/prp/v1/identity.schema.json" }, + "capabilities": { "$ref": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json" }, + "commands": { + "type": "array", + "items": { "$ref": "https://paperclip.dev/schemas/prp/v1/command.schema.json" } + }, + "events": { + "type": "array", + "minItems": 1, + "items": { "$ref": "https://paperclip.dev/schemas/prp/v1/event.schema.json" } + }, + "requests": { + "type": "array", + "items": { "$ref": "https://paperclip.dev/schemas/prp/v1/request.schema.json" } + }, + "result": { "$ref": "https://paperclip.dev/schemas/prp/v1/result.schema.json" } + }, + "additionalProperties": true +} diff --git a/packages/paperclip-runner/protocol/schemas/identity.schema.json b/packages/paperclip-runner/protocol/schemas/identity.schema.json new file mode 100644 index 00000000000..8279ef857c7 --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/identity.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/identity.schema.json", + "title": "PRP identity graph", + "type": "object", + "required": [ + "schema", + "companyId", + "issueId", + "runId", + "environmentLeaseId", + "runnerInstanceId", + "normalizedSessionId", + "driverSessionId" + ], + "properties": { + "schema": { "const": "paperclip.prp.identity.v1" }, + "companyId": { "$ref": "#/$defs/stableId" }, + "issueId": { "$ref": "#/$defs/stableId" }, + "runId": { "$ref": "#/$defs/stableId" }, + "environmentLeaseId": { "$ref": "#/$defs/stableId" }, + "runnerInstanceId": { "$ref": "#/$defs/stableId" }, + "normalizedSessionId": { "$ref": "#/$defs/stableId" }, + "driverSessionId": { "$ref": "#/$defs/stableId" }, + "providerSessionId": { "$ref": "#/$defs/stableId" } + }, + "additionalProperties": true, + "$defs": { + "stableId": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + } + } +} diff --git a/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json b/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json new file mode 100644 index 00000000000..3bfbb66a060 --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json", + "title": "PRP provider runtime descriptor", + "type": "object", + "required": ["provider", "driver", "model", "executionKind", "providerVersion"], + "properties": { + "provider": { "const": "codex" }, + "driver": { "const": "codex_app_server" }, + "model": { "type": ["string", "null"], "maxLength": 240 }, + "executionKind": { "const": "local_process" }, + "providerVersion": { "type": "string", "minLength": 1, "maxLength": 120 }, + "providerSessionId": { "type": ["string", "null"], "maxLength": 240 }, + "processId": { "type": ["integer", "null"], "minimum": 1 } + }, + "additionalProperties": true +} diff --git a/packages/paperclip-runner/protocol/schemas/provider-event.schema.json b/packages/paperclip-runner/protocol/schemas/provider-event.schema.json new file mode 100644 index 00000000000..8af5200ddfd --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/provider-event.schema.json @@ -0,0 +1,167 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json", + "title": "Provider-neutral runner activity payload", + "oneOf": [ + { "$ref": "#/$defs/plan" }, + { "$ref": "#/$defs/toolExecution" }, + { "$ref": "#/$defs/research" }, + { "$ref": "#/$defs/delegation" }, + { "$ref": "#/$defs/modelRoute" }, + { "$ref": "#/$defs/modelVerification" }, + { "$ref": "#/$defs/contextCompacted" }, + { "$ref": "#/$defs/artifactViewed" }, + { "$ref": "#/$defs/artifactGenerated" }, + { "$ref": "#/$defs/reviewMode" }, + { "$ref": "#/$defs/hook" }, + { "$ref": "#/$defs/memoryCitation" }, + { "$ref": "#/$defs/safetyReview" }, + { "$ref": "#/$defs/terminalInput" }, + { "$ref": "#/$defs/wait" }, + { "$ref": "#/$defs/providerNotice" } + ], + "$defs": { + "id": { "type": "string", "minLength": 1, "maxLength": 160, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, + "shortText": { "type": "string", "maxLength": 4000 }, + "nullableShortText": { "type": ["string", "null"], "maxLength": 4000 }, + "safePath": { "type": ["string", "null"], "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$" }, + "safeUrl": { "type": ["string", "null"], "maxLength": 8192, "pattern": "^https?://" }, + "plan": { + "type": "object", + "required": ["schema", "planId", "revision", "steps", "complete", "syncStatus"], + "properties": { + "schema": { "const": "paperclip.plan.updated.v1" }, + "planId": { "$ref": "#/$defs/id" }, + "revision": { "type": "integer", "minimum": 1 }, + "explanation": { "$ref": "#/$defs/nullableShortText" }, + "steps": { "type": "array", "maxItems": 256, "items": { "type": "object", "required": ["stepId", "body", "status"], "properties": { "stepId": { "$ref": "#/$defs/id" }, "body": { "$ref": "#/$defs/shortText" }, "status": { "enum": ["pending", "in_progress", "completed", "blocked"] } }, "additionalProperties": false } }, + "complete": { "type": "boolean" }, + "syncStatus": { "enum": ["streaming", "pending", "synchronized", "conflict", "not_applicable"] }, + "documentRevision": { "type": ["integer", "null"], "minimum": 1 } + }, + "additionalProperties": false + }, + "toolExecution": { + "type": "object", + "required": ["schema", "executionId", "transport", "operation", "status", "output", "outputBytes", "outputTruncated", "outputDigest"], + "properties": { + "schema": { "const": "paperclip.tool.execution.v1" }, + "executionId": { "$ref": "#/$defs/id" }, + "transport": { "enum": ["process", "mcp", "dynamic", "builtin"] }, + "operation": { "enum": ["read", "search", "list", "execute", "edit", "unknown"] }, + "name": { "$ref": "#/$defs/nullableShortText" }, + "target": { "$ref": "#/$defs/safePath" }, + "namespace": { "type": ["string", "null"], "maxLength": 240 }, + "readOnly": { "type": ["boolean", "null"] }, + "status": { "enum": ["running", "completed", "failed", "cancelled", "interrupted"] }, + "durationMs": { "type": ["integer", "null"], "minimum": 0 }, + "exitCode": { "type": ["integer", "null"] }, + "progress": { "$ref": "#/$defs/nullableShortText" }, + "output": { "type": ["string", "null"], "maxLength": 65536 }, + "outputBytes": { "type": "integer", "minimum": 0 }, + "outputTruncated": { "type": "boolean" }, + "outputDigest": { "type": ["string", "null"], "pattern": "^sha256:[a-f0-9]{64}$" } + }, + "additionalProperties": false + }, + "research": { + "type": "object", + "required": ["schema", "researchId", "action", "status", "sources"], + "properties": { + "schema": { "const": "paperclip.research.v1" }, + "researchId": { "$ref": "#/$defs/id" }, + "action": { "enum": ["search", "open_page", "find_in_page", "other"] }, + "status": { "enum": ["running", "completed", "failed", "cancelled"] }, + "query": { "$ref": "#/$defs/nullableShortText" }, + "url": { "$ref": "#/$defs/safeUrl" }, + "pattern": { "$ref": "#/$defs/nullableShortText" }, + "sources": { "type": "array", "maxItems": 64, "items": { "type": "object", "required": ["sourceId", "title", "url", "snippet"], "properties": { "sourceId": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/shortText" }, "url": { "$ref": "#/$defs/safeUrl" }, "snippet": { "$ref": "#/$defs/nullableShortText" } }, "additionalProperties": false } } + }, + "additionalProperties": false + }, + "delegation": { + "type": "object", + "required": ["schema", "delegationId", "action", "status", "children"], + "properties": { + "schema": { "const": "paperclip.delegation.v1" }, + "delegationId": { "$ref": "#/$defs/id" }, + "action": { "enum": ["spawn", "message", "resume", "wait", "close"] }, + "status": { "enum": ["running", "waiting", "completed", "failed", "interrupted", "closed"] }, + "children": { "type": "array", "maxItems": 64, "items": { "type": "object", "required": ["childId", "role", "model", "status", "summary", "activitySummary"], "properties": { "childId": { "$ref": "#/$defs/id" }, "role": { "type": ["string", "null"], "maxLength": 160 }, "model": { "type": ["string", "null"], "maxLength": 240 }, "status": { "enum": ["running", "waiting", "completed", "failed", "interrupted", "closed"] }, "summary": { "$ref": "#/$defs/nullableShortText" }, "activitySummary": { "$ref": "#/$defs/nullableShortText" } }, "additionalProperties": false } } + }, + "additionalProperties": false + }, + "modelRoute": { + "type": "object", + "required": ["schema", "routeId", "provider", "requestedModel", "fromModel", "effectiveModel", "reason"], + "properties": { "schema": { "const": "paperclip.model.route_changed.v1" }, "routeId": { "$ref": "#/$defs/id" }, "provider": { "type": "string", "minLength": 1, "maxLength": 160 }, "requestedModel": { "type": "string", "minLength": 1, "maxLength": 240 }, "fromModel": { "type": ["string", "null"], "maxLength": 240 }, "effectiveModel": { "type": "string", "minLength": 1, "maxLength": 240 }, "reason": { "$ref": "#/$defs/shortText" } }, + "additionalProperties": false + }, + "modelVerification": { + "type": "object", + "required": ["schema", "verificationId", "status", "classes", "buffering", "summary"], + "properties": { "schema": { "const": "paperclip.model.verification.v1" }, "verificationId": { "$ref": "#/$defs/id" }, "status": { "enum": ["running", "completed", "failed"] }, "classes": { "type": "array", "maxItems": 32, "items": { "type": "string", "maxLength": 160 } }, "buffering": { "type": "boolean" }, "summary": { "$ref": "#/$defs/nullableShortText" } }, + "additionalProperties": false + }, + "contextCompacted": { + "type": "object", + "required": ["schema", "compactionId", "reason", "preTokens", "postTokens", "sameSession"], + "properties": { "schema": { "const": "paperclip.context.compacted.v1" }, "compactionId": { "$ref": "#/$defs/id" }, "reason": { "enum": ["context_window", "manual", "provider", "unknown"] }, "preTokens": { "type": ["integer", "null"], "minimum": 0 }, "postTokens": { "type": ["integer", "null"], "minimum": 0 }, "sameSession": { "type": "boolean" } }, + "additionalProperties": false + }, + "artifactViewed": { + "type": "object", + "required": ["schema", "artifactId", "reference", "mediaType", "title"], + "properties": { "schema": { "const": "paperclip.artifact.viewed.v1" }, "artifactId": { "$ref": "#/$defs/id" }, "reference": { "$ref": "#/$defs/safePath" }, "mediaType": { "type": ["string", "null"], "maxLength": 160 }, "title": { "$ref": "#/$defs/nullableShortText" } }, + "additionalProperties": false + }, + "artifactGenerated": { + "type": "object", + "required": ["schema", "artifactId", "status", "reference", "mediaType", "registered", "failure"], + "properties": { "schema": { "const": "paperclip.artifact.generated.v1" }, "artifactId": { "$ref": "#/$defs/id" }, "status": { "enum": ["running", "completed", "failed"] }, "reference": { "$ref": "#/$defs/safePath" }, "mediaType": { "type": ["string", "null"], "maxLength": 160 }, "registered": { "type": "boolean" }, "transparentBackground": { "type": ["boolean", "null"] }, "failure": { "$ref": "#/$defs/nullableShortText" } }, + "additionalProperties": false + }, + "reviewMode": { + "type": "object", + "required": ["schema", "reviewId", "state", "scope"], + "properties": { "schema": { "const": "paperclip.review.mode_changed.v1" }, "reviewId": { "$ref": "#/$defs/id" }, "state": { "enum": ["entered", "exited"] }, "scope": { "$ref": "#/$defs/nullableShortText" } }, + "additionalProperties": false + }, + "hook": { + "type": "object", + "required": ["schema", "hookId", "event", "scope", "status", "blocking", "durationMs", "summary"], + "properties": { "schema": { "const": "paperclip.hook.v1" }, "hookId": { "$ref": "#/$defs/id" }, "event": { "type": "string", "minLength": 1, "maxLength": 160 }, "scope": { "type": "string", "minLength": 1, "maxLength": 160 }, "status": { "enum": ["running", "completed", "failed", "cancelled"] }, "blocking": { "type": "boolean" }, "durationMs": { "type": ["integer", "null"], "minimum": 0 }, "summary": { "$ref": "#/$defs/nullableShortText" } }, + "additionalProperties": false + }, + "memoryCitation": { + "type": "object", + "required": ["schema", "citationId", "messageItemId", "label", "available", "reference"], + "properties": { "schema": { "const": "paperclip.memory.citation.v1" }, "citationId": { "$ref": "#/$defs/id" }, "messageItemId": { "$ref": "#/$defs/id" }, "label": { "$ref": "#/$defs/shortText" }, "available": { "type": "boolean" }, "reference": { "type": ["string", "null"], "maxLength": 4096 } }, + "additionalProperties": false + }, + "safetyReview": { + "type": "object", + "required": ["schema", "reviewId", "targetExecutionId", "status", "decision", "summary"], + "properties": { "schema": { "const": "paperclip.safety.review.v1" }, "reviewId": { "$ref": "#/$defs/id" }, "targetExecutionId": { "anyOf": [{ "$ref": "#/$defs/id" }, { "type": "null" }] }, "status": { "enum": ["running", "completed", "failed"] }, "decision": { "enum": ["pending", "allowed", "denied", "user_required", "unknown"] }, "summary": { "$ref": "#/$defs/nullableShortText" } }, + "additionalProperties": false + }, + "terminalInput": { + "type": "object", + "required": ["schema", "executionId", "origin", "inputClass", "byteCount"], + "properties": { "schema": { "const": "paperclip.terminal.input_sent.v1" }, "executionId": { "$ref": "#/$defs/id" }, "origin": { "enum": ["agent", "user", "system"] }, "inputClass": { "enum": ["text", "control", "eof"] }, "byteCount": { "type": "integer", "minimum": 0 } }, + "additionalProperties": false + }, + "wait": { + "type": "object", + "required": ["schema", "waitId", "reason", "status", "plannedDurationMs", "elapsedDurationMs"], + "properties": { "schema": { "const": "paperclip.wait.v1" }, "waitId": { "$ref": "#/$defs/id" }, "reason": { "enum": ["timer", "provider", "rate_limit", "unknown"] }, "status": { "enum": ["running", "completed", "interrupted", "cancelled"] }, "plannedDurationMs": { "type": ["integer", "null"], "minimum": 0 }, "elapsedDurationMs": { "type": ["integer", "null"], "minimum": 0 } }, + "additionalProperties": false + }, + "providerNotice": { + "type": "object", + "required": ["schema", "noticeId", "severity", "category", "scope", "recoverable", "userActionable", "summary"], + "properties": { "schema": { "const": "paperclip.provider.notice.v1" }, "noticeId": { "$ref": "#/$defs/id" }, "severity": { "enum": ["info", "warning", "error"] }, "category": { "type": "string", "minLength": 1, "maxLength": 160 }, "scope": { "enum": ["turn", "session", "environment", "account"] }, "recoverable": { "type": "boolean" }, "userActionable": { "type": "boolean" }, "summary": { "$ref": "#/$defs/shortText" } }, + "additionalProperties": false + } + } +} diff --git a/packages/paperclip-runner/protocol/schemas/question-adapter-fixture.schema.json b/packages/paperclip-runner/protocol/schemas/question-adapter-fixture.schema.json new file mode 100644 index 00000000000..04715dd336c --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/question-adapter-fixture.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/question-adapter-fixture.schema.json", + "title": "Harness question adapter conformance fixture", + "type": "object", + "required": ["schema", "adapter", "nativeRequest", "canonicalQuestionSet", "canonicalResponse", "nativeResponse"], + "properties": { + "schema": { "const": "paperclip.question_adapter_fixture.v1" }, + "adapter": { "type": "string", "minLength": 1, "maxLength": 160 }, + "nativeRequest": {}, + "canonicalQuestionSet": { "$ref": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json" }, + "canonicalResponse": { "$ref": "https://paperclip.dev/schemas/prp/v1/question-response.schema.json" }, + "nativeResponse": {} + }, + "additionalProperties": false +} diff --git a/packages/paperclip-runner/protocol/schemas/question-response.schema.json b/packages/paperclip-runner/protocol/schemas/question-response.schema.json new file mode 100644 index 00000000000..f089054853b --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/question-response.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/question-response.schema.json", + "title": "Paperclip question response", + "type": "object", + "required": ["schema", "answers"], + "properties": { + "schema": { "const": "paperclip.question_response.v1" }, + "answers": { + "type": "object", + "propertyNames": { "minLength": 1, "maxLength": 160 }, + "additionalProperties": { + "type": "object", + "properties": { + "selectedOptionIds": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 160 } + }, + "text": { "type": "string", "maxLength": 100000 }, + "customText": { "type": "string", "maxLength": 100000 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/packages/paperclip-runner/protocol/schemas/question-set.schema.json b/packages/paperclip-runner/protocol/schemas/question-set.schema.json new file mode 100644 index 00000000000..995bfa8f72d --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/question-set.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json", + "title": "Paperclip question set", + "type": "object", + "required": ["schema", "questions"], + "properties": { + "schema": { "const": "paperclip.question_set.v1" }, + "title": { "type": "string", "maxLength": 1000 }, + "description": { "type": "string", "maxLength": 4000 }, + "submitLabel": { "type": "string", "maxLength": 200 }, + "questions": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": { "$ref": "#/$defs/question" } + } + }, + "$defs": { + "option": { + "type": "object", + "required": ["id", "label"], + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 160 }, + "label": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "description": { "type": "string", "maxLength": 4000 } + }, + "additionalProperties": false + }, + "customAnswer": { + "type": "object", + "required": ["enabled"], + "properties": { + "enabled": { "const": true }, + "label": { "type": "string", "maxLength": 1000 }, + "placeholder": { "type": "string", "maxLength": 1000 } + }, + "additionalProperties": false + }, + "textValidation": { + "type": "object", + "properties": { + "minLength": { "type": "integer", "minimum": 0, "maximum": 100000 }, + "maxLength": { "type": "integer", "minimum": 0, "maximum": 100000 }, + "pattern": { "type": "string", "maxLength": 1000 }, + "inputType": { "enum": ["text", "number", "integer"] }, + "minimum": { "type": "number" }, + "maximum": { "type": "number" } + }, + "additionalProperties": false + }, + "question": { + "type": "object", + "required": ["id", "prompt", "required", "answerMode"], + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 160 }, + "header": { "type": "string", "maxLength": 1000 }, + "prompt": { "type": "string", "minLength": 1, "maxLength": 4000 }, + "helpText": { "type": "string", "maxLength": 4000 }, + "required": { "type": "boolean" }, + "answerMode": { "enum": ["single_select", "multi_select", "text"] }, + "options": { + "type": "array", + "maxItems": 128, + "items": { "$ref": "#/$defs/option" } + }, + "customAnswer": { "$ref": "#/$defs/customAnswer" }, + "textValidation": { "$ref": "#/$defs/textValidation" } + }, + "allOf": [ + { + "if": { "properties": { "answerMode": { "const": "text" } }, "required": ["answerMode"] }, + "then": { + "properties": { "options": { "type": "array", "maxItems": 0 } }, + "not": { "required": ["customAnswer"] } + } + }, + { + "if": { "properties": { "answerMode": { "enum": ["single_select", "multi_select"] } }, "required": ["answerMode"] }, + "then": { + "required": ["options"], + "properties": { "options": { "type": "array", "minItems": 1 } } + } + } + ], + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/packages/paperclip-runner/protocol/schemas/request.schema.json b/packages/paperclip-runner/protocol/schemas/request.schema.json new file mode 100644 index 00000000000..78441bc7bfe --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/request.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/request.schema.json", + "title": "PRP request", + "oneOf": [ + { + "type": "object", + "required": ["schema", "requestKind", "requestId", "type", "status", "prompt", "input"], + "properties": { + "schema": { "const": "paperclip.runtime_request.v2" }, + "requestKind": { "const": "runtime" }, + "requestId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "type": { "const": "input" }, + "status": { "enum": ["pending", "resolved", "expired", "cancelled"] }, + "prompt": { "type": "string", "minLength": 1, "maxLength": 4000 }, + "input": { "$ref": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json" }, + "origin": { + "type": "object", + "required": ["adapter"], + "properties": { + "adapter": { "type": "string", "minLength": 1, "maxLength": 160 }, + "provider": { "type": "string", "minLength": 1, "maxLength": 160 }, + "method": { "type": "string", "minLength": 1, "maxLength": 500 } + }, + "additionalProperties": false + }, + "turnId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "itemId": { "type": "string", "minLength": 1, "maxLength": 160 } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["schema", "requestKind", "requestId", "type", "status", "prompt"], + "properties": { + "schema": { "const": "paperclip.runtime_request.v1" }, + "requestKind": { "const": "runtime" }, + "requestId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "type": { "enum": ["permission", "input"] }, + "status": { "enum": ["pending", "resolved", "expired", "cancelled"] }, + "prompt": { "type": "string", "minLength": 1, "maxLength": 4000 }, + "choices": { + "type": "array", + "items": { + "type": "object", + "required": ["key", "label"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + }, + { + "type": "object", + "required": ["schema", "requestKind", "requestId", "kind", "blocking", "payload"], + "properties": { + "schema": { "const": "paperclip.interaction_request.v1" }, + "requestKind": { "const": "issue_thread" }, + "requestId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "kind": { + "enum": [ + "request_confirmation", + "request_checkbox_confirmation", + "request_item_verdicts", + "ask_user_questions", + "suggest_tasks" + ] + }, + "blocking": { "type": "boolean" }, + "payload": { "type": "object", "additionalProperties": true } + }, + "additionalProperties": true + } + ] +} diff --git a/packages/paperclip-runner/protocol/schemas/result.schema.json b/packages/paperclip-runner/protocol/schemas/result.schema.json new file mode 100644 index 00000000000..62983b280e9 --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/result.schema.json @@ -0,0 +1,151 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/result.schema.json", + "title": "PRP structured run result", + "type": "object", + "required": [ + "schema", + "reportedWorkDisposition", + "summary", + "completionClaim", + "evidence", + "verification", + "attentionRequests", + "artifacts" + ], + "properties": { + "schema": { "const": "paperclip.run_result.v1" }, + "reportedWorkDisposition": { + "enum": ["done", "blocked", "needs_review", "yielded"] + }, + "summary": { "type": "string", "minLength": 1, "maxLength": 12000 }, + "completionClaim": { + "type": "object", + "required": ["contractRevision", "objectiveSatisfied", "criteria", "remainingWork"], + "properties": { + "contractRevision": { "type": "string", "minLength": 1 }, + "objectiveSatisfied": { "type": "boolean" }, + "criteria": { + "type": "array", + "items": { + "type": "object", + "required": ["criterionId", "status", "evidenceRefs"], + "properties": { + "criterionId": { "type": "string", "minLength": 1 }, + "status": { "enum": ["satisfied", "not_satisfied", "unknown"] }, + "evidenceRefs": { "type": "array", "items": { "type": "string" } }, + "explanation": { "type": "string" } + }, + "additionalProperties": true + } + }, + "remainingWork": { + "type": "array", + "items": { + "type": "object", + "required": ["description", "blocksCompletion"], + "properties": { + "description": { "type": "string", "minLength": 1 }, + "blocksCompletion": { "type": "boolean" } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + }, + "evidence": { "type": "array", "items": { "type": "object" } }, + "verification": { + "type": "array", + "description": "Checks used to verify the work. Use failed only when the check actually ran and found a defect in the work. If a check could not run or complete because the environment, tool, dependency, permission, credential, policy, external service, or budget was unavailable, use not_run with reasonCode even when the attempted command exited non-zero.", + "items": { + "type": "object", + "required": ["commandOrCheck", "status"], + "properties": { + "commandOrCheck": { "type": "string", "minLength": 1 }, + "status": { + "enum": ["passed", "failed", "not_run"], + "description": "passed means the check ran and succeeded; failed means it ran to a meaningful verdict and found the work incorrect; not_run means no meaningful verdict was possible because the check was not attempted or could not complete." + }, + "reasonCode": { + "description": "Required for not_run; identify why the check had no meaningful verdict.", + "enum": [ + "environment_unavailable", + "tool_unavailable", + "dependency_missing", + "permission_denied", + "credential_missing", + "policy_restricted", + "external_service_unavailable", + "budget_exhausted", + "other" + ] + }, + "detail": { "type": "string" }, + "artifactRef": { "type": "string" } + }, + "additionalProperties": true + } + }, + "blocker": { + "type": "object", + "required": ["reasonCode", "owner", "unblockAction", "scope"], + "properties": { + "reasonCode": { "type": "string", "minLength": 1 }, + "owner": { "type": "object", "additionalProperties": true }, + "unblockAction": { "type": "string", "minLength": 1 }, + "scope": { "enum": ["current_track", "task_wide"] } + }, + "additionalProperties": true + }, + "attentionRequests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "summary": { "type": "string", "minLength": 1 }, + "ownerClass": { "enum": ["human", "agent", "external_system"] }, + "targetAgentId": { "type": "string", "minLength": 1 } + }, + "additionalProperties": true + } + }, + "artifacts": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "ref"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "ref": { "type": "string", "minLength": 1 }, + "title": { "type": "string" } + }, + "additionalProperties": true + } + }, + "continuation": { + "type": "object", + "required": ["kind", "summary", "idempotencyKey"], + "properties": { + "kind": { + "enum": ["same_agent", "retry", "delegated_issue", "response_wake", "monitor"] + }, + "summary": { "type": "string", "minLength": 1 }, + "idempotencyKey": { "type": "string", "minLength": 1 } + }, + "additionalProperties": true + } + }, + "allOf": [ + { + "if": { "properties": { "reportedWorkDisposition": { "const": "blocked" } } }, + "then": { "required": ["blocker"] } + }, + { + "if": { "properties": { "reportedWorkDisposition": { "const": "yielded" } } }, + "then": { "required": ["continuation"] } + } + ], + "additionalProperties": true +} diff --git a/packages/paperclip-runner/protocol/schemas/semantic-tool.schema.json b/packages/paperclip-runner/protocol/schemas/semantic-tool.schema.json new file mode 100644 index 00000000000..657a7e4e9f2 --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/semantic-tool.schema.json @@ -0,0 +1,164 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json", + "title": "PRP provider-neutral semantic tool envelope", + "type": "object", + "required": [ + "schema", + "schemaVersion", + "phase", + "operationId", + "callId", + "correlation", + "idempotencyKey", + "content" + ], + "properties": { + "schema": { "const": "paperclip.prp.semantic_tool.v1" }, + "schemaVersion": { "const": 1 }, + "phase": { "enum": ["input", "result"] }, + "operationId": { "$ref": "#/$defs/stableId" }, + "callId": { "$ref": "#/$defs/stableId" }, + "correlation": { + "type": "object", + "required": ["runId", "normalizedSessionId", "turnId", "itemId"], + "properties": { + "runId": { "$ref": "#/$defs/stableId" }, + "normalizedSessionId": { "$ref": "#/$defs/stableId" }, + "turnId": { "$ref": "#/$defs/stableId" }, + "itemId": { "$ref": "#/$defs/stableId" }, + "requestId": { "$ref": "#/$defs/stableId" } + }, + "additionalProperties": true + }, + "idempotencyKey": { + "type": ["string", "null"], + "minLength": 1, + "maxLength": 240 + }, + "content": { "$ref": "#/$defs/safeContent" }, + "outcome": { + "enum": ["succeeded", "denied", "conflict", "duplicate", "unavailable", "failed"] + }, + "code": { "$ref": "#/$defs/stableCode" }, + "retryable": { "type": "boolean" }, + "authorizationBoundary": { + "enum": ["company", "actor", "active_task", "grant", "governed_action", "lock", "revision"] + }, + "operationReceiptId": { "$ref": "#/$defs/stableId" }, + "auditReceiptId": { "$ref": "#/$defs/stableId" }, + "currentRevision": { + "oneOf": [ + { "type": "integer", "minimum": 0 }, + { "$ref": "#/$defs/stableId" } + ] + }, + "duplicateOfReceiptId": { "$ref": "#/$defs/stableId" }, + "artifactRefs": { + "type": "array", + "items": { + "allOf": [ + { "$ref": "#/$defs/safeReference" }, + { + "type": "object", + "properties": { "kind": { "enum": ["artifact", "work_product"] } } + } + ] + }, + "uniqueItems": true + }, + "targets": { + "type": "array", + "items": { "$ref": "#/$defs/immutableTarget" }, + "uniqueItems": true + }, + "causalRefs": { + "type": "array", + "items": { "$ref": "#/$defs/safeReference" }, + "uniqueItems": true + } + }, + "allOf": [ + { + "if": { "properties": { "phase": { "const": "result" } } }, + "then": { + "required": [ + "outcome", + "code", + "retryable", + "authorizationBoundary", + "operationReceiptId" + ] + } + } + ], + "additionalProperties": true, + "$defs": { + "stableId": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "stableCode": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_.:-]*$" + }, + "safeContent": { + "type": "object", + "required": ["digest", "redactionDisposition", "references"], + "properties": { + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "redactionDisposition": { + "enum": ["digest_only", "allowlisted_references", "redacted"] + }, + "references": { + "type": "array", + "items": { "$ref": "#/$defs/safeReference" }, + "uniqueItems": true + } + }, + "additionalProperties": true + }, + "safeReference": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "enum": [ + "task", + "document_revision", + "interaction", + "approval", + "decision", + "artifact", + "work_product", + "wake", + "monitor", + "audit", + "operation" + ] + }, + "id": { "$ref": "#/$defs/stableId" } + }, + "additionalProperties": true + }, + "immutableTarget": { + "type": "object", + "required": ["kind", "id", "immutable"], + "properties": { + "kind": { "enum": ["document_revision", "interaction", "approval", "decision"] }, + "id": { "$ref": "#/$defs/stableId" }, + "immutable": { "const": true }, + "revisionId": { "$ref": "#/$defs/stableId" }, + "decisionId": { "$ref": "#/$defs/stableId" } + }, + "additionalProperties": true + } + } +} diff --git a/packages/paperclip-runner/protocol/schemas/stop-reason.schema.json b/packages/paperclip-runner/protocol/schemas/stop-reason.schema.json new file mode 100644 index 00000000000..183615f559b --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/stop-reason.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json", + "title": "PRP terminal stop reason receipt", + "type": "object", + "required": [ + "schema", + "schemaVersion", + "receiptId", + "kind", + "code", + "retryable", + "decisionId", + "limitClass", + "aggregate" + ], + "properties": { + "schema": { "const": "paperclip.prp.stop_reason.v1" }, + "schemaVersion": { "const": 1 }, + "receiptId": { "$ref": "#/$defs/stableId" }, + "kind": { "enum": ["budget", "cost"] }, + "code": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_.:-]*$" + }, + "retryable": { "type": "boolean" }, + "decisionId": { "$ref": "#/$defs/stableId" }, + "limitClass": { + "enum": [ + "company_monthly", + "actor_monthly", + "project_monthly", + "run_cost", + "provider_quota" + ] + }, + "aggregate": { + "type": "object", + "required": ["unit", "observed", "limit", "window"], + "properties": { + "unit": { "enum": ["cents", "usd_micros", "tokens"] }, + "observed": { "type": "integer", "minimum": 0 }, + "limit": { "type": "integer", "minimum": 0 }, + "window": { "enum": ["run", "monthly_utc", "provider_window"] } + }, + "additionalProperties": true + }, + "relatedReceiptIds": { + "type": "array", + "items": { "$ref": "#/$defs/stableId" }, + "uniqueItems": true + } + }, + "additionalProperties": true, + "$defs": { + "stableId": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + } + } +} diff --git a/packages/paperclip-runner/protocol/schemas/terminal.schema.json b/packages/paperclip-runner/protocol/schemas/terminal.schema.json new file mode 100644 index 00000000000..3538d20120d --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/terminal.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/terminal.schema.json", + "title": "PRP run terminal state", + "type": "object", + "required": [ + "schema", + "turnTerminalState", + "runTerminalState", + "reportedWorkDisposition" + ], + "properties": { + "schema": { "const": "paperclip.prp.terminal.v1" }, + "turnTerminalState": { + "enum": ["completed", "failed", "interrupted", "cancelled"] + }, + "runTerminalState": { "enum": ["succeeded", "failed", "cancelled"] }, + "reportedWorkDisposition": { + "enum": ["done", "blocked", "needs_review", "yielded"] + }, + "workAssessmentId": { "type": "string", "minLength": 1 }, + "statusDecisionId": { "type": "string", "minLength": 1 }, + "stopReason": { "$ref": "https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json" } + }, + "additionalProperties": true +} diff --git a/packages/paperclip-runner/protocol/schemas/usage.schema.json b/packages/paperclip-runner/protocol/schemas/usage.schema.json new file mode 100644 index 00000000000..68c912f2dcb --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/usage.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/usage.schema.json", + "title": "PRP provider usage receipt", + "type": "object", + "required": ["provider", "model", "cumulative", "runDelta"], + "properties": { + "provider": { "type": "string", "minLength": 1, "maxLength": 80 }, + "model": { "type": ["string", "null"], "maxLength": 240 }, + "providerSessionId": { "type": ["string", "null"], "maxLength": 240 }, + "providerRequestId": { "type": ["string", "null"], "maxLength": 240 }, + "cumulative": { "$ref": "#/$defs/measurement" }, + "runDelta": { "$ref": "#/$defs/measurement" } + }, + "additionalProperties": false, + "$defs": { + "measurement": { + "type": "object", + "required": ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "activeSeconds", "requests", "providerCostUsd"], + "properties": { + "inputTokens": { "type": "integer", "minimum": 0 }, + "outputTokens": { "type": "integer", "minimum": 0 }, + "cacheReadTokens": { "type": "integer", "minimum": 0 }, + "cacheWriteTokens": { "type": "integer", "minimum": 0 }, + "activeSeconds": { "type": "number", "minimum": 0 }, + "requests": { "type": "integer", "minimum": 0 }, + "providerCostUsd": { "type": "number", "minimum": 0 } + }, + "additionalProperties": false + } + } +} diff --git a/packages/paperclip-runner/protocol/schemas/workspace-diff.schema.json b/packages/paperclip-runner/protocol/schemas/workspace-diff.schema.json new file mode 100644 index 00000000000..ebf317dbaee --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/workspace-diff.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json", + "title": "Paperclip workspace diff", + "type": "object", + "required": ["schema", "changeSetId", "revision", "source", "complete", "files", "totals", "patchArtifactRef"], + "properties": { + "schema": { "const": "paperclip.workspace.diff.v1" }, + "changeSetId": { "type": "string", "minLength": 1, "maxLength": 200 }, + "revision": { "type": "integer", "minimum": 1 }, + "source": { "enum": ["harness_reported", "runner_verified"] }, + "complete": { "type": "boolean" }, + "files": { + "type": "array", + "maxItems": 2000, + "items": { + "type": "object", + "required": ["path", "operation", "previousPath", "additions", "deletions", "binary", "diff"], + "properties": { + "path": { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" }, + "operation": { "enum": ["create", "modify", "delete", "rename", "mode_change"] }, + "previousPath": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" } + ] + }, + "additions": { "type": ["integer", "null"], "minimum": 0 }, + "deletions": { "type": ["integer", "null"], "minimum": 0 }, + "binary": { "type": "boolean" }, + "diff": { "type": ["string", "null"], "maxLength": 262144 } + }, + "additionalProperties": false + } + }, + "totals": { + "type": "object", + "required": ["files", "additions", "deletions"], + "properties": { + "files": { "type": "integer", "minimum": 0 }, + "additions": { "type": ["integer", "null"], "minimum": 0 }, + "deletions": { "type": ["integer", "null"], "minimum": 0 } + }, + "additionalProperties": false + }, + "patchArtifactRef": { "type": ["string", "null"], "maxLength": 2048 } + }, + "additionalProperties": false +} diff --git a/packages/paperclip-runner/protocol/schemas/workspace-file-reference.schema.json b/packages/paperclip-runner/protocol/schemas/workspace-file-reference.schema.json new file mode 100644 index 00000000000..e0dc8555d21 --- /dev/null +++ b/packages/paperclip-runner/protocol/schemas/workspace-file-reference.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json", + "title": "Paperclip workspace file reference", + "type": "object", + "required": ["schema", "referenceId", "source", "path", "displayName", "mediaType", "presentation", "line", "preview", "previewTruncated", "contentDigest"], + "properties": { + "schema": { "const": "paperclip.workspace.file_reference.v1" }, + "referenceId": { "type": "string", "minLength": 1, "maxLength": 240 }, + "source": { "enum": ["harness_reported", "runner_verified"] }, + "path": { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 255 }, + "mediaType": { "type": ["string", "null"], "maxLength": 160 }, + "presentation": { "enum": ["document", "code", "image", "generic"] }, + "line": { "type": ["integer", "null"], "minimum": 1 }, + "preview": { "type": ["string", "null"], "maxLength": 262144 }, + "previewTruncated": { "type": "boolean" }, + "contentDigest": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" } + ] + } + }, + "additionalProperties": false +} diff --git a/packages/paperclip-runner/runner/CODEX_PROVIDER.md b/packages/paperclip-runner/runner/CODEX_PROVIDER.md new file mode 100644 index 00000000000..0c9ca47a81f --- /dev/null +++ b/packages/paperclip-runner/runner/CODEX_PROVIDER.md @@ -0,0 +1,50 @@ +# Codex provider boundary + +`paperclip-runnerd` supports one provider in this layer: Codex app-server as a +local supervised process. The Paperclip server does not select or launch this +path yet. + +## Command lifecycle + +- `run.prepare` accepts a `provider` object containing `provider: "codex"`, + `driver: "codex_app_server"`, `providerVersion`, `command`, bounded `args`, an + existing absolute `cwd`, optional `model`, `instructions`, and + `approvalPolicy: "never"`. +- `session.open` initializes Codex and starts a thread. A recovered runner + resumes the recorded thread and reads it before accepting another turn. +- `turn.start` requires bounded non-empty `payload.text`. `turn.steer`, + `turn.interrupt`, `turn.stop`, and `run.cancel` use Codex's native turn IDs, + while PRP continues to use its own stable run and turn identities. +- `request.resolve` translates a validated `paperclip.question_response.v1` + response back to Codex's user-input response shape. +- `session.close` and `session.destroy` explicitly terminate the provider + process group. Runner suspend and shutdown also stop the process without + deleting the resumable thread identity. + +## Recovery and duplicate safety + +The provider descriptor, Codex thread ID, account session ID, active Codex turn +ID, and unacknowledged normalized-event prefix are written to a private, +bounded, atomically replaced sidecar in the runner state directory. On restart, +runnerd resumes that exact thread and reconciles the active turn from +`thread/read`. An unexpected provider exit retains the last active turn until +that reconciliation proves whether it is still running, so cancellation and a +later turn cannot diverge from Codex's native state. + +PRP journals every command before the provider effect. Exact command replay +returns the durable result without invoking Codex again. A crash in the effect +window remains indeterminate and is not retried. Codex JSON-RPC notifications +received before a synchronous response are buffered rather than lost. Reusing +a pending structured-input request ID with different content fails closed. +Normalized events remain in the provider sidecar until the durable PRP outbox +has committed and acknowledged them. + +## Normalization and authorization + +Codex-native envelopes do not cross PRP. The provider backend emits bounded, +redacted session, turn, item, plan, usage, tool-execution, notice, and structured +input events. Unknown notifications are ignored. + +Codex starts with an empty dynamic-tool inventory. Catalog presence is not +authorization, and semantic operations remain unavailable until the separate +catalog and run-scoped authorization layers land. diff --git a/packages/paperclip-runner/runner/Cargo.lock b/packages/paperclip-runner/runner/Cargo.lock new file mode 100644 index 00000000000..770faf90baf --- /dev/null +++ b/packages/paperclip-runner/runner/Cargo.lock @@ -0,0 +1,529 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "paperclip-runner-core" +version = "0.0.0" +dependencies = [ + "aes-gcm", + "getrandom 0.3.4", + "hmac", + "serde", + "serde_json", + "sha2", + "tungstenite", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/paperclip-runner/runner/Cargo.toml b/packages/paperclip-runner/runner/Cargo.toml new file mode 100644 index 00000000000..bf407f50119 --- /dev/null +++ b/packages/paperclip-runner/runner/Cargo.toml @@ -0,0 +1,18 @@ +[workspace] +members = ["crates/runner-core"] +resolver = "2" + +[workspace.package] +version = "0.0.0" +edition = "2021" +license = "MIT" +publish = false + +[workspace.dependencies] +aes-gcm = "0.10" +getrandom = "0.3" +hmac = "0.12" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +tungstenite = { version = "0.28", default-features = false, features = ["handshake"] } diff --git a/packages/paperclip-runner/runner/DURABLE_TRANSPORT.md b/packages/paperclip-runner/runner/DURABLE_TRANSPORT.md new file mode 100644 index 00000000000..fbc6d9550a6 --- /dev/null +++ b/packages/paperclip-runner/runner/DURABLE_TRANSPORT.md @@ -0,0 +1,67 @@ +# Durable PRP transport + +This layer gives `paperclip-runnerd` a provider-neutral, package-local PRP v1 +transport. Nothing in the Paperclip server invokes the durable mode yet. Codex +is the only installed provider; other providers remain unavailable. + +## Trust boundary + +- The runner accepts only `ws://` destinations whose complete DNS result is + loopback. Resolution happens once and reconnects reuse the pinned addresses. +- A bootstrap ticket is read from `PAPERCLIP_RUNNER_BOOTSTRAP_TICKET`, removed + from the environment immediately, and never sent over the socket. Both peers + prove possession through HMAC-SHA-256. +- A successful bootstrap exchanges the one-use ticket for a connection-bound, + expiring lease held only in memory. Once authentication starts, a failed + bootstrap attempt is not replayed automatically. +- Post-authentication frames use AES-256-GCM with direction-specific keys, + monotonically increasing counters, and session-bound associated data. + Plaintext, replayed, out-of-order, oversized, or incorrectly bound frames + fail closed. +- The durable state directory is private, symlinks are rejected, and updates + use a private temporary file, file sync, atomic rename, and directory sync. + Credentials and lease tokens are never written to this state. + +## Recovery contract + +Events enter the outbox before delivery. A cumulative ACK may advance only to a +source sequence the runner has produced; acknowledged prefixes are removed +atomically from durable state. After disconnect, every remaining event is sent +again with the same identity and source sequence. + +Executors retain polled events until runnerd acknowledges each event after its +outbox commit. Batches commit one event at a time, so a later oversized event or +capacity failure cannot roll back the accepted prefix or discard the +unacknowledged suffix. Each retained executor event has a stable identity that +runnerd derives into its PRP `sourceEventId`. If the process stops after the +outbox commit but before the executor acknowledgement, a bounded durable +receipt journal recognizes and byte-validates the retained copy without +appending a second event. Receipts outlive transport ACK removal; because the +provider queue is ordered and bounded, a possibly retained front event cannot +be evicted while later events advance the journal. + +Commands require a contiguous controller sequence. The runner journals a +pending command before invoking its executor and persists its result afterward. +An exact duplicate returns the stored result without repeating the effect. If +the process dies inside the effect window, recovery records an indeterminate +result and refuses to execute that command again. Recent results are bounded; +commands older than the compacted controller cursor fail closed. + +State written before complete-command fingerprints existed is migrated by +compacting its legacy command journal through the last recorded controller +sequence. The runner can recover, but it rejects redelivery of those older +commands instead of guessing an identity or repeating an uncertain effect. + +The outbox has separate hard and reserved limits. P1/P2 events cannot consume +the P0 reserve. When the soft limit is reached, the runner enters backpressure +and rejects the new non-P0 event rather than silently losing it. Exhausting the +P0 reserve is an explicit unrecoverable condition. + +## Current boundary + +Durable mode is selected only when `paperclip-runnerd` receives +`--connect-url`. Its executor accepts a Codex app-server descriptor through +`run.prepare`, owns the provider process group, resumes the persisted Codex +thread after runner restart, and translates provider notifications to PRP +events. The existing local fake-runner mode remains unchanged. Semantic tools, +server coordination, and the user-facing adapter belong to later layers. diff --git a/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml b/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml new file mode 100644 index 00000000000..6754c383b28 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "paperclip-runner-core" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +aes-gcm.workspace = true +getrandom.workspace = true +hmac.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tungstenite.workspace = true + +[[bin]] +name = "conformance-tracer" +path = "src/bin/conformance-tracer.rs" + +[[bin]] +name = "paperclip-runnerd" +path = "src/bin/paperclip-runnerd.rs" + +[[bin]] +name = "fake-harness" +path = "src/bin/fake-harness.rs" + +[[bin]] +name = "fake-codex-app-server" +path = "src/bin/fake-codex-app-server.rs" diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/conformance-tracer.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/conformance-tracer.rs new file mode 100644 index 00000000000..d444e3e6560 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/conformance-tracer.rs @@ -0,0 +1,11 @@ +use paperclip_runner_core::{run_conformance_tracer, CONFORMANCE_FIXTURE}; + +fn main() { + match run_conformance_tracer(CONFORMANCE_FIXTURE) { + Ok(output) => println!("{output}"), + Err(error) => { + eprintln!("Conformance tracer failed: {error}"); + std::process::exit(1); + } + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs new file mode 100644 index 00000000000..2284ea25a2f --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs @@ -0,0 +1,204 @@ +use std::fs::{self, OpenOptions}; +use std::io::{self, BufRead, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct FakeState { + thread_id: String, + active_turn_id: Option, +} + +fn argument(args: &[String], name: &str) -> Option { + args.iter() + .position(|value| value == name) + .and_then(|index| args.get(index + 1)) + .cloned() +} + +fn send(value: Value) -> io::Result<()> { + let mut stdout = io::stdout().lock(); + serde_json::to_writer(&mut stdout, &value)?; + stdout.write_all(b"\n")?; + stdout.flush() +} + +fn load_state(path: &Path) -> FakeState { + fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_else(|| FakeState { + thread_id: "codex-thread-1".to_owned(), + active_turn_id: None, + }) +} + +fn save_state(path: &Path, state: &FakeState) -> io::Result<()> { + fs::write(path, serde_json::to_vec_pretty(state)?) +} + +fn log_call(path: Option<&Path>, method: &str) -> io::Result<()> { + let Some(path) = path else { return Ok(()) }; + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + writeln!(file, "{method}") +} + +fn finish_turn(state_path: &Path, state: &mut FakeState, status: &str) -> io::Result<()> { + let turn_id = state + .active_turn_id + .clone() + .unwrap_or_else(|| "provider-turn-1".to_owned()); + send(json!({ + "method": "item/completed", + "params": {"item": { + "id": "message-1", + "type": "agentMessage", + "status": "completed", + "text": "Codex completed the fake turn." + }} + }))?; + send(json!({ + "method": "thread/tokenUsage/updated", + "params": { + "threadId": state.thread_id, + "tokenUsage": { + "total": {"inputTokens": 12, "outputTokens": 3}, + "last": {"inputTokens": 12, "outputTokens": 3, "requests": 1} + } + } + }))?; + send(json!({ + "method": "turn/completed", + "params": {"turn": {"id": turn_id, "status": status}} + }))?; + state.active_turn_id = None; + save_state(state_path, state) +} + +fn run() -> Result<(), Box> { + let args = std::env::args().skip(1).collect::>(); + let state_path = + PathBuf::from(argument(&args, "--state-file").ok_or("--state-file is required")?); + let call_log = argument(&args, "--call-log").map(PathBuf::from); + let emit_question = args.iter().any(|value| value == "--emit-question"); + let hold_turn = args.iter().any(|value| value == "--hold-turn"); + let exit_after_turn_start = args.iter().any(|value| value == "--exit-after-turn-start"); + let pre_response_notification = args + .iter() + .any(|value| value == "--notification-before-response"); + let mut state = load_state(&state_path); + + for line in io::stdin().lock().lines() { + let message: Value = serde_json::from_str(&line?)?; + if message.get("method").is_none() && message.get("id") == Some(&json!("runtime-request-1")) + { + finish_turn(&state_path, &mut state, "completed")?; + continue; + } + let Some(method) = message.get("method").and_then(Value::as_str) else { + continue; + }; + log_call(call_log.as_deref(), method)?; + let id = message.get("id").cloned(); + match method { + "initialize" => send(json!({ + "id": id, + "result": {"user": {"sessionId": "codex-account-session"}} + }))?, + "initialized" => {} + "thread/start" => { + state.thread_id = "codex-thread-1".to_owned(); + state.active_turn_id = None; + save_state(&state_path, &state)?; + if pre_response_notification { + send(json!({ + "method": "warning", + "params": {"message": "buffered before thread response"} + }))?; + } + send(json!({ + "id": id, + "result": {"thread": {"id": state.thread_id, "sessionId": "codex-account-session"}} + }))?; + } + "thread/resume" => send(json!({ + "id": id, + "result": {"thread": {"id": state.thread_id, "sessionId": "codex-account-session"}} + }))?, + "thread/read" => { + let turns = state + .active_turn_id + .as_ref() + .map(|turn_id| vec![json!({"id": turn_id, "status": "inProgress"})]) + .unwrap_or_default(); + send(json!({ + "id": id, + "result": {"thread": {"id": state.thread_id, "turns": turns}} + }))?; + } + "turn/start" => { + state.active_turn_id = Some("provider-turn-1".to_owned()); + save_state(&state_path, &state)?; + send(json!({ + "id": id, + "result": {"turn": {"id": "provider-turn-1", "status": "inProgress"}} + }))?; + send(json!({ + "method": "turn/started", + "params": {"turn": {"id": "provider-turn-1"}} + }))?; + if exit_after_turn_start { + return Ok(()); + } else if emit_question { + send(json!({ + "id": "runtime-request-1", + "method": "item/tool/requestUserInput", + "params": { + "threadId": state.thread_id, + "turnId": "provider-turn-1", + "itemId": "question-item-1", + "isBlocking": true, + "title": "Deployment input", + "questions": [{ + "id": "environment", + "header": "Environment", + "question": "Where should we deploy?", + "options": [ + {"label": "Staging", "description": "Deploy safely."}, + {"label": "Production", "description": "Deploy directly."} + ] + }] + } + }))?; + } else if !hold_turn { + finish_turn(&state_path, &mut state, "completed")?; + } + } + "turn/steer" => send(json!({"id": id, "result": {"accepted": true}}))?, + "turn/interrupt" => { + send(json!({"id": id, "result": {"accepted": true}}))?; + finish_turn(&state_path, &mut state, "interrupted")?; + } + _ if id.is_some() => send(json!({ + "id": id, + "error": {"code": -32601, "message": format!("unsupported fake method {method}")} + }))?, + _ => {} + } + } + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("fake-codex-app-server: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-harness.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-harness.rs new file mode 100644 index 00000000000..82a3f4c57ed --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-harness.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; +use std::process::ExitCode; + +use paperclip_runner_core::fake_harness::{load_fake_harness_script, run_fake_harness}; +use paperclip_runner_core::local_runner::LocalRunnerError; + +fn value(args: &[String], name: &str) -> Result { + let index = args + .iter() + .position(|argument| argument == name) + .ok_or_else(|| LocalRunnerError::invalid(format!("missing required argument {name}")))?; + args.get(index + 1) + .cloned() + .ok_or_else(|| LocalRunnerError::invalid(format!("missing value for {name}"))) +} + +fn run() -> Result { + let args = std::env::args().skip(1).collect::>(); + let script = load_fake_harness_script(&PathBuf::from(value(&args, "--script")?))?; + let delay_override_ms = args + .iter() + .position(|argument| argument == "--delay-ms") + .map(|index| { + args.get(index + 1) + .ok_or_else(|| LocalRunnerError::invalid("missing value for --delay-ms"))? + .parse::() + .map_err(|error| LocalRunnerError::invalid(format!("invalid --delay-ms: {error}"))) + }) + .transpose()?; + run_fake_harness(script, delay_override_ms) +} + +fn main() -> ExitCode { + match run() { + Ok(code) => ExitCode::from(u8::try_from(code.clamp(0, 255)).unwrap_or(1)), + Err(error) => { + eprintln!("fake-harness: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs new file mode 100644 index 00000000000..4764ed1e889 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs @@ -0,0 +1,108 @@ +use std::path::PathBuf; +use std::process::ExitCode; +use std::time::Duration; + +use paperclip_runner_core::durable::{ + capture_bootstrap_ticket, run_durable_runner, DurableRunnerConfig, +}; +use paperclip_runner_core::local_runner::{run_local_runner, LocalRunnerError, RunnerConfig}; +use paperclip_runner_core::provider_backend::CodexCommandExecutor; + +fn value(args: &[String], name: &str) -> Result { + let index = args + .iter() + .position(|argument| argument == name) + .ok_or_else(|| LocalRunnerError::invalid(format!("missing required argument {name}")))?; + args.get(index + 1) + .cloned() + .ok_or_else(|| LocalRunnerError::invalid(format!("missing value for {name}"))) +} + +fn optional_u64(args: &[String], name: &str) -> Result, LocalRunnerError> { + let Some(index) = args.iter().position(|argument| argument == name) else { + return Ok(None); + }; + let value = args + .get(index + 1) + .ok_or_else(|| LocalRunnerError::invalid(format!("missing value for {name}")))?; + value + .parse::() + .map(Some) + .map_err(|error| LocalRunnerError::invalid(format!("invalid {name}: {error}"))) +} + +fn usize_value(args: &[String], name: &str, default: usize) -> Result { + optional_u64(args, name)?.map_or(Ok(default), |value| { + usize::try_from(value) + .map_err(|error| LocalRunnerError::invalid(format!("invalid {name}: {error}"))) + }) +} + +fn run_durable(args: &[String]) -> Result<(), LocalRunnerError> { + let ticket = capture_bootstrap_ticket() + .map_err(|error| LocalRunnerError::invalid(error.to_string()))? + .ok_or_else(|| { + LocalRunnerError::invalid( + "PAPERCLIP_RUNNER_BOOTSTRAP_TICKET is required for durable mode", + ) + })?; + let duration = |name: &str, default: u64| { + optional_u64(args, name).map(|value| Duration::from_millis(value.unwrap_or(default))) + }; + let state_dir = PathBuf::from(value(args, "--state-dir")?); + run_durable_runner( + DurableRunnerConfig { + connect_url: value(args, "--connect-url")?, + state_dir: state_dir.clone(), + runner_instance_id: value(args, "--runner-id")?, + environment_lease_id: value(args, "--environment-lease-id")?, + run_id: value(args, "--run-id")?, + normalized_session_id: value(args, "--session-id")?, + turn_id: value(args, "--turn-id")?, + item_id: value(args, "--item-id")?, + runner_version: value(args, "--runner-version")?, + runner_digest: value(args, "--runner-digest")?, + max_outbox_bytes: usize_value(args, "--max-outbox-bytes", 16 * 1024 * 1024)?, + p0_reserve_bytes: usize_value(args, "--p0-reserve-bytes", 1024 * 1024)?, + max_frame_bytes: usize_value(args, "--max-frame-bytes", 1024 * 1024)?, + reconnect_delay: duration("--reconnect-delay-ms", 250)?, + max_runtime: duration("--max-runtime-ms", 60 * 60 * 1000)?, + }, + ticket, + CodexCommandExecutor::new(state_dir), + ) + .map_err(|error| LocalRunnerError::invalid(error.to_string())) +} + +fn run() -> Result<(), LocalRunnerError> { + let args = std::env::args().skip(1).collect::>(); + if args.iter().any(|argument| argument == "--connect-url") { + return run_durable(&args); + } + run_local_runner(RunnerConfig { + run_id: value(&args, "--run-id")?, + normalized_session_id: value(&args, "--session-id")?, + runner_instance_id: value(&args, "--runner-id")?, + fake_harness_path: PathBuf::from(value(&args, "--fake-harness")?), + script_path: PathBuf::from(value(&args, "--script")?), + delay_override_ms: optional_u64(&args, "--delay-ms")?, + log_max_lines: usize_value(&args, "--log-max-lines", 32)?, + log_max_bytes: usize_value(&args, "--log-max-bytes", 16_384)?, + command_history_limit: usize_value(&args, "--command-history-limit", 4096)?, + controller_max_line_bytes: usize_value(&args, "--controller-max-line-bytes", 64 * 1024)?, + harness_max_line_bytes: usize_value(&args, "--harness-max-line-bytes", 64 * 1024)?, + shutdown_grace: Duration::from_millis( + optional_u64(&args, "--shutdown-grace-ms")?.unwrap_or(100), + ), + }) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("paperclip-runnerd: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs new file mode 100644 index 00000000000..6195562247c --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -0,0 +1,847 @@ +use std::collections::{BTreeMap, VecDeque}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::durable::redact_text; +use crate::local_runner::LocalRunnerError; +use crate::process_supervisor::SupervisedProcess; + +pub const CODEX_APP_SERVER_MAX_FRAME_BYTES: usize = 4 * 1024 * 1024; +const MAX_BUFFERED_MESSAGES: usize = 1_024; +const MAX_INSTRUCTIONS_BYTES: usize = 1024 * 1024; +type QuestionOptionLabels = BTreeMap>; +type QuestionSetMapping = (String, Value, QuestionOptionLabels); + +fn default_approval_policy() -> String { + "never".to_owned() +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CodexProviderConfig { + pub provider: String, + pub driver: String, + pub provider_version: String, + pub command: PathBuf, + #[serde(default)] + pub args: Vec, + pub cwd: String, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub instructions: String, + #[serde(default = "default_approval_policy")] + pub approval_policy: String, +} + +impl CodexProviderConfig { + pub fn validate(&self) -> Result<(), LocalRunnerError> { + if self.provider != "codex" || self.driver != "codex_app_server" { + return Err(LocalRunnerError::invalid( + "the initial runner provider must be codex through codex_app_server", + )); + } + if self.provider_version.trim().is_empty() || self.provider_version.len() > 120 { + return Err(LocalRunnerError::invalid( + "Codex providerVersion is empty or oversized", + )); + } + if self.command.as_os_str().is_empty() { + return Err(LocalRunnerError::invalid("Codex command is required")); + } + let cwd = Path::new(&self.cwd); + if !cwd.is_absolute() || !cwd.is_dir() { + return Err(LocalRunnerError::invalid( + "Codex cwd must be an existing absolute directory", + )); + } + if self.args.len() > 64 + || self.args.iter().any(|argument| { + argument.len() > 4096 || argument.chars().any(|character| character == '\0') + }) + { + return Err(LocalRunnerError::invalid( + "Codex arguments exceed the bounded launch contract", + )); + } + if self + .model + .as_ref() + .is_some_and(|model| model.is_empty() || model.len() > 240) + { + return Err(LocalRunnerError::invalid("Codex model is invalid")); + } + if self.instructions.len() > MAX_INSTRUCTIONS_BYTES { + return Err(LocalRunnerError::invalid( + "Codex instructions exceed the 1 MiB limit", + )); + } + if self.approval_policy != "never" { + return Err(LocalRunnerError::invalid( + "the initial Codex runner requires approvalPolicy=never; governed actions use PRP", + )); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum CodexProviderEvent { + Notification { + method: String, + params: Value, + }, + RuntimeRequest { + request_id: String, + question_set: Value, + }, + Exited { + exit_code: Option, + success: bool, + }, +} + +#[derive(Clone, Debug, PartialEq)] +struct PendingRuntimeRequest { + rpc_id: Value, + method: String, + params: Value, + question_set: Value, + option_labels: QuestionOptionLabels, +} + +pub struct CodexProvider { + process: SupervisedProcess, + next_request_id: u64, + thread_id: String, + provider_session_id: Option, + active_provider_turn_id: Option, + pending_messages: VecDeque, + pending_runtime_requests: BTreeMap, + expected_shutdown: bool, +} + +impl CodexProvider { + pub fn start( + config: &CodexProviderConfig, + resume_thread_id: Option<&str>, + ) -> Result { + config.validate()?; + let mut provider = Self { + process: SupervisedProcess::spawn( + &config.command, + &config.args, + Duration::from_secs(2), + CODEX_APP_SERVER_MAX_FRAME_BYTES, + )?, + next_request_id: 1, + thread_id: String::new(), + provider_session_id: None, + active_provider_turn_id: None, + pending_messages: VecDeque::new(), + pending_runtime_requests: BTreeMap::new(), + expected_shutdown: false, + }; + let initialized = provider.request( + "initialize", + json!({ + "clientInfo": { + "name": "paperclip-runnerd", + "title": "Paperclip Runner", + "version": "1", + }, + "capabilities": { + "experimentalApi": true, + "requestAttestation": false, + }, + }), + )?; + provider.process.send(&json!({"method": "initialized"}))?; + + let mut params = json!({ + "cwd": config.cwd, + "model": config.model, + "approvalPolicy": config.approval_policy, + "permissions": "paperclip-runner-workspace-only", + "runtimeWorkspaceRoots": [config.cwd], + "baseInstructions": config.instructions, + }); + let params_object = params + .as_object_mut() + .expect("Codex thread parameters are an object"); + let method = if let Some(thread_id) = resume_thread_id { + params_object.insert("threadId".to_owned(), json!(thread_id)); + "thread/resume" + } else { + // This PR does not grant any semantic tools. A later catalog and + // authorization layer can project a run-scoped inventory here. + params_object.insert("dynamicTools".to_owned(), json!([])); + params_object.insert("experimentalRawEvents".to_owned(), json!(false)); + "thread/start" + }; + let opened = provider.request(method, params)?; + provider.thread_id = opened + .pointer("/thread/id") + .or_else(|| opened.get("threadId")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| LocalRunnerError::invalid(format!("Codex {method} omitted thread.id")))? + .to_owned(); + if resume_thread_id.is_some_and(|expected| expected != provider.thread_id) { + return Err(LocalRunnerError::invalid( + "Codex resumed a different provider thread", + )); + } + provider.provider_session_id = opened + .pointer("/thread/sessionId") + .or_else(|| initialized.pointer("/user/sessionId")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + + if resume_thread_id.is_some() { + let snapshot = provider.read_thread()?; + provider.active_provider_turn_id = latest_active_turn_id(&snapshot); + } + Ok(provider) + } + + pub fn process_id(&self) -> u32 { + self.process.id() + } + + pub fn thread_id(&self) -> &str { + &self.thread_id + } + + pub fn provider_session_id(&self) -> Option<&str> { + self.provider_session_id.as_deref() + } + + pub fn active_provider_turn_id(&self) -> Option<&str> { + self.active_provider_turn_id.as_deref() + } + + pub fn start_turn(&mut self, message: &str, cwd: &str) -> Result { + if self.active_provider_turn_id.is_some() { + return Err(LocalRunnerError::invalid( + "Codex already has an active provider turn", + )); + } + if message.is_empty() || message.len() > MAX_INSTRUCTIONS_BYTES { + return Err(LocalRunnerError::invalid( + "Codex turn text is empty or exceeds the 1 MiB limit", + )); + } + let result = self.request( + "turn/start", + json!({ + "threadId": self.thread_id, + "cwd": cwd, + "permissions": "paperclip-runner-workspace-only", + "runtimeWorkspaceRoots": [cwd], + "input": [{"type": "text", "text": message, "text_elements": []}], + }), + )?; + let provider_turn_id = result + .pointer("/turn/id") + .or_else(|| result.get("turnId")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| LocalRunnerError::invalid("Codex turn/start omitted turn.id"))? + .to_owned(); + self.active_provider_turn_id = Some(provider_turn_id); + Ok(result) + } + + pub fn steer_turn(&mut self, message: &str) -> Result { + let turn_id = self + .active_provider_turn_id + .clone() + .ok_or_else(|| LocalRunnerError::invalid("Codex has no active provider turn"))?; + if message.is_empty() || message.len() > MAX_INSTRUCTIONS_BYTES { + return Err(LocalRunnerError::invalid( + "Codex steering text is empty or oversized", + )); + } + self.request( + "turn/steer", + json!({ + "threadId": self.thread_id, + "expectedTurnId": turn_id, + "input": [{"type": "text", "text": message, "text_elements": []}], + }), + ) + } + + pub fn interrupt_turn(&mut self) -> Result { + let turn_id = self + .active_provider_turn_id + .clone() + .ok_or_else(|| LocalRunnerError::invalid("Codex has no active provider turn"))?; + self.cancel_pending_runtime_requests()?; + self.request( + "turn/interrupt", + json!({"threadId": self.thread_id, "turnId": turn_id}), + ) + } + + pub fn read_thread(&mut self) -> Result { + self.request( + "thread/read", + json!({"threadId": self.thread_id, "includeTurns": true}), + ) + } + + pub fn resolve_runtime_request( + &mut self, + request_id: &str, + response: &Value, + ) -> Result<(), LocalRunnerError> { + let pending = self + .pending_runtime_requests + .get(request_id) + .cloned() + .ok_or_else(|| { + LocalRunnerError::invalid("runtime response has no pending Codex request") + })?; + let result = codex_question_response(&pending, response)?; + self.process + .send(&json!({"id": pending.rpc_id, "result": result}))?; + self.pending_runtime_requests.remove(request_id); + Ok(()) + } + + pub fn poll(&mut self) -> Result, LocalRunnerError> { + let message = if let Some(message) = self.pending_messages.pop_front() { + message + } else { + let Some(line) = self.process.receive_stdout_line(Duration::from_millis(1))? else { + return if let Some(exit) = self.process.try_wait()? { + Ok(Some(CodexProviderEvent::Exited { + exit_code: exit.exit_code, + success: exit.success && self.expected_shutdown, + })) + } else { + Ok(None) + }; + }; + parse_provider_message(&line)? + }; + + if let (Some(rpc_id), Some(method)) = ( + message.get("id").cloned(), + message.get("method").and_then(Value::as_str), + ) { + if method == "item/tool/requestUserInput" { + let params = message.get("params").cloned().unwrap_or(Value::Null); + if params.get("threadId").and_then(Value::as_str) != Some(self.thread_id.as_str()) { + return Err(LocalRunnerError::invalid( + "Codex runtime request named another thread", + )); + } + let active_turn_id = self.active_provider_turn_id.as_deref().ok_or_else(|| { + LocalRunnerError::invalid( + "Codex runtime request arrived outside an active turn", + ) + })?; + if params.get("turnId").and_then(Value::as_str) != Some(active_turn_id) { + return Err(LocalRunnerError::invalid( + "Codex runtime request named another turn", + )); + } + let (request_id, question_set, option_labels) = + codex_question_set(&rpc_id, ¶ms)?; + let pending = PendingRuntimeRequest { + rpc_id, + method: method.to_owned(), + params, + question_set: question_set.clone(), + option_labels, + }; + if let Some(existing) = self.pending_runtime_requests.get(&request_id) { + if existing != &pending { + return Err(LocalRunnerError::invalid( + "Codex reused a runtime request id with different input", + )); + } + return Ok(None); + } else { + self.pending_runtime_requests + .insert(request_id.clone(), pending); + } + return Ok(Some(CodexProviderEvent::RuntimeRequest { + request_id, + question_set, + })); + } + self.process.send(&json!({ + "id": rpc_id, + "error": {"code": -32601, "message": "provider request is unavailable in this runner layer"}, + }))?; + return Ok(Some(CodexProviderEvent::Notification { + method: "warning".to_owned(), + params: json!({"message": format!("unsupported Codex request {}", bounded_method(method))}), + })); + } + + if let Some(method) = message.get("method").and_then(Value::as_str) { + let params = message.get("params").cloned().unwrap_or(Value::Null); + validate_notification_binding( + &self.thread_id, + self.active_provider_turn_id.as_deref(), + ¶ms, + )?; + if method == "turn/completed" { + self.active_provider_turn_id = None; + } + return Ok(Some(CodexProviderEvent::Notification { + method: method.to_owned(), + params, + })); + } + Ok(None) + } + + pub fn shutdown(&mut self) -> Result<(), LocalRunnerError> { + self.expected_shutdown = true; + self.cancel_pending_runtime_requests()?; + self.process.terminate_group().map(|_| ()) + } + + fn cancel_pending_runtime_requests(&mut self) -> Result<(), LocalRunnerError> { + let pending = std::mem::take(&mut self.pending_runtime_requests); + for request in pending.into_values() { + self.process.send(&json!({ + "id": request.rpc_id, + "result": {"answers": {}}, + }))?; + } + Ok(()) + } + + fn request(&mut self, method: &str, params: Value) -> Result { + let request_id = self.next_request_id; + self.next_request_id = self + .next_request_id + .checked_add(1) + .ok_or_else(|| LocalRunnerError::invalid("Codex request id exhausted"))?; + self.process + .send(&json!({"id": request_id, "method": method, "params": params}))?; + loop { + let line = self + .process + .receive_stdout_line(Duration::from_secs(30))? + .ok_or_else(|| { + LocalRunnerError::invalid(format!("Codex {method} response timed out")) + })?; + let message = parse_provider_message(&line)?; + if message.get("id").and_then(Value::as_u64) == Some(request_id) + && message.get("method").is_none() + { + if let Some(error) = message.get("error") { + return Err(LocalRunnerError::invalid(format!( + "Codex {method} failed: {}", + redact_text(&error.to_string()) + ))); + } + return Ok(message.get("result").cloned().unwrap_or(Value::Null)); + } + if self.pending_messages.len() >= MAX_BUFFERED_MESSAGES { + return Err(LocalRunnerError::invalid( + "Codex emitted too many messages before a request response", + )); + } + self.pending_messages.push_back(message); + } + } +} + +fn parse_provider_message(line: &str) -> Result { + let value: Value = serde_json::from_str(line).map_err(|error| { + LocalRunnerError::invalid(format!("Codex emitted invalid JSON-RPC: {error}")) + })?; + if !value.is_object() { + return Err(LocalRunnerError::invalid( + "Codex emitted a non-object JSON-RPC frame", + )); + } + Ok(value) +} + +fn validate_notification_binding( + thread_id: &str, + active_turn_id: Option<&str>, + params: &Value, +) -> Result<(), LocalRunnerError> { + let notification_thread_id = params + .get("threadId") + .or_else(|| params.pointer("/thread/id")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()); + if notification_thread_id.is_some_and(|value| value != thread_id) { + return Err(LocalRunnerError::invalid( + "Codex notification named another thread", + )); + } + let notification_turn_id = params + .get("turnId") + .or_else(|| params.pointer("/turn/id")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()); + if let Some(active_turn_id) = active_turn_id { + if notification_turn_id.is_some_and(|value| value != active_turn_id) { + return Err(LocalRunnerError::invalid( + "Codex notification named another active turn", + )); + } + } + Ok(()) +} + +fn latest_active_turn_id(snapshot: &Value) -> Option { + snapshot + .pointer("/thread/turns") + .and_then(Value::as_array) + .into_iter() + .flatten() + .rev() + .find(|turn| { + matches!( + turn.get("status").and_then(Value::as_str), + Some("inProgress" | "running" | "pending") + ) + }) + .and_then(|turn| turn.get("id").and_then(Value::as_str)) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + +fn bounded_method(method: &str) -> String { + method + .chars() + .filter(|character| character.is_ascii_alphanumeric() || "._/-".contains(*character)) + .take(160) + .collect() +} + +fn codex_question_set( + rpc_id: &Value, + params: &Value, +) -> Result { + let request_id = match rpc_id { + Value::String(value) if !value.is_empty() && value.len() <= 160 => value.clone(), + Value::Number(value) => value.to_string(), + _ => { + return Err(LocalRunnerError::invalid( + "Codex user-input request id is invalid", + )) + } + }; + let questions = params + .get("questions") + .and_then(Value::as_array) + .ok_or_else(|| LocalRunnerError::invalid("Codex user-input request omitted questions"))?; + if questions.is_empty() || questions.len() > 3 { + return Err(LocalRunnerError::invalid( + "Codex user-input request must contain one to three questions", + )); + } + let mut canonical = Vec::new(); + let mut option_labels = BTreeMap::new(); + for question in questions { + if question.get("isSecret").and_then(Value::as_bool) == Some(true) { + return Err(LocalRunnerError::invalid( + "Codex secret input cannot use the persisted question channel", + )); + } + let id = question + .get("id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty() && value.len() <= 160) + .ok_or_else(|| LocalRunnerError::invalid("Codex question id is invalid"))?; + if option_labels.contains_key(id) { + return Err(LocalRunnerError::invalid( + "Codex question ids must be unique", + )); + } + let prompt = question + .get("question") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| LocalRunnerError::invalid("Codex question prompt is required"))?; + let options = question + .get("options") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut labels = BTreeMap::new(); + let canonical_options = options + .iter() + .take(64) + .enumerate() + .filter_map(|(index, option)| { + let label = option.get("label")?.as_str()?.trim(); + if label.is_empty() { + return None; + } + let option_id = format!("option-{}", index + 1); + labels.insert(option_id.clone(), label.chars().take(240).collect()); + Some(json!({ + "id": option_id, + "label": label.chars().take(240).collect::(), + "description": option.get("description").and_then(Value::as_str).map(|value| value.chars().take(1000).collect::()), + })) + }) + .collect::>(); + if !options.is_empty() && canonical_options.len() != options.len() { + return Err(LocalRunnerError::invalid( + "Codex question contains an invalid option", + )); + } + option_labels.insert(id.to_owned(), labels); + let mut canonical_question = json!({ + "id": id, + "header": question.get("header").and_then(Value::as_str).unwrap_or("Question").chars().take(80).collect::(), + "prompt": prompt.chars().take(4000).collect::(), + "required": true, + "answerMode": if canonical_options.is_empty() { "text" } else { "single_select" }, + "options": canonical_options, + }); + if question.get("isOther").and_then(Value::as_bool) == Some(true) { + canonical_question + .as_object_mut() + .expect("canonical question is an object") + .insert( + "customAnswer".to_owned(), + json!({ + "enabled": true, + "label": "Other", + "placeholder": "Enter another answer", + }), + ); + } + canonical.push(canonical_question); + } + Ok(( + request_id, + json!({ + "schema": "paperclip.question_set.v1", + "title": params.get("title").and_then(Value::as_str).unwrap_or("Codex input").chars().take(240).collect::(), + "submitLabel": "Submit answers", + "questions": canonical, + }), + option_labels, + )) +} + +fn codex_question_response( + pending: &PendingRuntimeRequest, + response: &Value, +) -> Result { + let response_object = response + .as_object() + .ok_or_else(|| LocalRunnerError::invalid("runtime response must be an object"))?; + if response_object + .keys() + .any(|key| !matches!(key.as_str(), "schema" | "answers")) + { + return Err(LocalRunnerError::invalid( + "runtime response contains an unknown top-level field", + )); + } + if response.get("schema").and_then(Value::as_str) != Some("paperclip.question_response.v1") { + return Err(LocalRunnerError::invalid( + "runtime response requires paperclip.question_response.v1", + )); + } + let answers = response + .get("answers") + .and_then(Value::as_object) + .ok_or_else(|| LocalRunnerError::invalid("runtime response answers are required"))?; + let questions = pending + .question_set + .get("questions") + .and_then(Value::as_array) + .ok_or_else(|| LocalRunnerError::invalid("pending question set is malformed"))?; + let mut native = serde_json::Map::new(); + for question in questions { + let id = question + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| LocalRunnerError::invalid("pending question id is malformed"))?; + let answer = answers + .get(id) + .ok_or_else(|| LocalRunnerError::invalid(format!("missing answer for {id}")))?; + let answer = answer.as_object().ok_or_else(|| { + LocalRunnerError::invalid(format!("answer for {id} must be an object")) + })?; + if answer + .keys() + .any(|key| !matches!(key.as_str(), "selectedOptionIds" | "text" | "customText")) + { + return Err(LocalRunnerError::invalid(format!( + "answer for {id} contains an unknown field" + ))); + } + let selected = answer.get("selectedOptionIds"); + let text = answer.get("text"); + let custom = answer.get("customText"); + let values = if question.get("answerMode").and_then(Value::as_str) == Some("single_select") + { + if text.is_some() || (selected.is_some() && custom.is_some()) { + return Err(LocalRunnerError::invalid(format!( + "{id} must contain one selected option or one custom answer" + ))); + } + if let Some(custom_text) = custom { + if question + .pointer("/customAnswer/enabled") + .and_then(Value::as_bool) + != Some(true) + { + return Err(LocalRunnerError::invalid(format!( + "{id} does not allow a custom answer" + ))); + } + vec![custom_text + .as_str() + .filter(|value| !value.is_empty() && value.len() <= 4000) + .ok_or_else(|| { + LocalRunnerError::invalid(format!( + "{id} custom answer must be non-empty and bounded" + )) + })? + .to_owned()] + } else { + let selected = selected + .and_then(Value::as_array) + .filter(|values| values.len() == 1) + .ok_or_else(|| { + LocalRunnerError::invalid(format!("{id} requires one selected option")) + })?; + let option_id = selected[0] + .as_str() + .ok_or_else(|| LocalRunnerError::invalid("selected option id is invalid"))?; + vec![pending + .option_labels + .get(id) + .and_then(|labels| labels.get(option_id)) + .cloned() + .ok_or_else(|| LocalRunnerError::invalid("selected option is not available"))?] + } + } else { + if selected.is_some() || custom.is_some() { + return Err(LocalRunnerError::invalid(format!( + "{id} text answer cannot contain select or custom fields" + ))); + } + vec![text + .and_then(Value::as_str) + .filter(|value| !value.is_empty() && value.len() <= 4000) + .ok_or_else(|| LocalRunnerError::invalid(format!("{id} requires text")))? + .to_owned()] + }; + native.insert(id.to_owned(), json!({"answers": values})); + } + if answers.keys().any(|id| !native.contains_key(id)) { + return Err(LocalRunnerError::invalid( + "runtime response contains an unknown question id", + )); + } + Ok(json!({"answers": native})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_codex_questions_and_responses_without_provider_leakage() { + let (request_id, question_set, labels) = codex_question_set( + &json!(41), + &json!({ + "requestId": "request-1", + "questions": [{ + "id": "environment", + "header": "Environment", + "question": "Where should we deploy?", + "options": [{"label": "Staging", "description": "Deploy safely."}], + }], + }), + ) + .unwrap(); + assert_eq!(request_id, "41"); + assert_eq!(question_set["schema"], "paperclip.question_set.v1"); + let pending = PendingRuntimeRequest { + rpc_id: json!(41), + method: "item/tool/requestUserInput".to_owned(), + params: Value::Null, + question_set, + option_labels: labels, + }; + let native = codex_question_response( + &pending, + &json!({ + "schema": "paperclip.question_response.v1", + "answers": {"environment": {"selectedOptionIds": ["option-1"]}}, + }), + ) + .unwrap(); + assert_eq!(native["answers"]["environment"]["answers"][0], "Staging"); + assert!(codex_question_response( + &pending, + &json!({ + "schema": "paperclip.question_response.v1", + "answers": {"environment": { + "selectedOptionIds": ["option-1"], + "customText": "Production", + }}, + }), + ) + .is_err()); + assert!(codex_question_response( + &pending, + &json!({ + "schema": "paperclip.question_response.v1", + "answers": {"environment": {"selectedOptionIds": ["option-1"]}}, + "providerEnvelope": {}, + }), + ) + .is_err()); + } + + #[test] + fn finds_only_active_turns_during_resume() { + let snapshot = json!({"thread": {"turns": [ + {"id": "done", "status": "completed"}, + {"id": "active", "status": "inProgress"} + ]}}); + assert_eq!(latest_active_turn_id(&snapshot).as_deref(), Some("active")); + } + + #[test] + fn rejects_notifications_bound_to_another_thread_or_active_turn() { + assert!(validate_notification_binding( + "thread-1", + Some("turn-1"), + &json!({"threadId": "thread-2", "turnId": "turn-1"}), + ) + .is_err()); + assert!(validate_notification_binding( + "thread-1", + Some("turn-1"), + &json!({"threadId": "thread-1", "turnId": "turn-2"}), + ) + .is_err()); + assert!(validate_notification_binding( + "thread-1", + Some("turn-1"), + &json!({"threadId": "thread-1", "turnId": "turn-1"}), + ) + .is_ok()); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs new file mode 100644 index 00000000000..ef3ec614564 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs @@ -0,0 +1,162 @@ +mod runner; +mod state; +mod transport; + +use std::error::Error; +use std::fmt::{self, Display, Formatter}; +use std::path::PathBuf; +use std::time::Duration; + +pub use runner::{run_durable_runner, CommandExecution, CommandExecutor, PolledEvent}; +pub(crate) use state::{ + create_private_temporary_file, open_private_regular_file, redact_text, verify_private_directory, +}; +pub use state::{ + Command, CommandDisposition, DurableState, DurableStateStore, EventPriority, + StoredCommandResult, StoredOutboxEvent, +}; + +pub const PROTOCOL: &str = "paperclip.runner"; +pub const PROTOCOL_VERSION: u64 = 1; +pub const BOOTSTRAP_TICKET_ENV: &str = "PAPERCLIP_RUNNER_BOOTSTRAP_TICKET"; +const MAX_OUTBOX_BYTES: usize = 512 * 1024 * 1024; +const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DurableRunnerError(String); + +impl DurableRunnerError { + pub fn invalid(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl Display for DurableRunnerError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Error for DurableRunnerError {} + +#[derive(Debug)] +struct Secret(Vec); + +impl Secret { + fn new(value: String) -> Self { + Self(value.into_bytes()) + } + + fn expose(&self) -> Result<&str, DurableRunnerError> { + std::str::from_utf8(&self.0) + .map_err(|_| DurableRunnerError::invalid("transport credential is not valid UTF-8")) + } +} + +impl Drop for Secret { + fn drop(&mut self) { + self.0.fill(0); + } +} + +#[derive(Debug)] +pub struct BootstrapTicket(Secret); + +impl BootstrapTicket { + pub fn new(value: String) -> Result { + if value.trim().is_empty() { + return Err(DurableRunnerError::invalid( + "bootstrap ticket must not be empty", + )); + } + Ok(Self(Secret::new(value))) + } + + fn expose(&self) -> Result<&str, DurableRunnerError> { + self.0.expose() + } +} + +pub fn capture_bootstrap_ticket() -> Result, DurableRunnerError> { + let value = match std::env::var(BOOTSTRAP_TICKET_ENV) { + Ok(value) => value, + Err(std::env::VarError::NotPresent) => return Ok(None), + Err(error) => { + return Err(DurableRunnerError::invalid(format!( + "failed to read bootstrap ticket: {error}" + ))) + } + }; + std::env::remove_var(BOOTSTRAP_TICKET_ENV); + BootstrapTicket::new(value).map(Some) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DurableRunnerConfig { + pub connect_url: String, + pub state_dir: PathBuf, + pub runner_instance_id: String, + pub environment_lease_id: String, + pub run_id: String, + pub normalized_session_id: String, + pub turn_id: String, + pub item_id: String, + pub runner_version: String, + pub runner_digest: String, + pub max_outbox_bytes: usize, + pub p0_reserve_bytes: usize, + pub max_frame_bytes: usize, + pub reconnect_delay: Duration, + pub max_runtime: Duration, +} + +impl DurableRunnerConfig { + pub fn validate(&self) -> Result<(), DurableRunnerError> { + for (name, value) in [ + ("connect_url", self.connect_url.as_str()), + ("runner_instance_id", self.runner_instance_id.as_str()), + ("environment_lease_id", self.environment_lease_id.as_str()), + ("run_id", self.run_id.as_str()), + ("normalized_session_id", self.normalized_session_id.as_str()), + ("turn_id", self.turn_id.as_str()), + ("item_id", self.item_id.as_str()), + ("runner_version", self.runner_version.as_str()), + ("runner_digest", self.runner_digest.as_str()), + ] { + if value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) { + return Err(DurableRunnerError::invalid(format!( + "{name} must be a non-empty bounded string without control characters" + ))); + } + } + if self.max_outbox_bytes == 0 + || self.max_outbox_bytes > MAX_OUTBOX_BYTES + || self.p0_reserve_bytes >= self.max_outbox_bytes + { + return Err(DurableRunnerError::invalid( + "P0 reserve must be smaller than an outbox limit no larger than 512 MiB", + )); + } + if !(1024..=MAX_FRAME_BYTES).contains(&self.max_frame_bytes) { + return Err(DurableRunnerError::invalid( + "transport frame limit must be between 1 KiB and 16 MiB", + )); + } + if self.max_runtime.is_zero() { + return Err(DurableRunnerError::invalid( + "durable runner max runtime must be non-zero", + )); + } + if self.reconnect_delay.is_zero() || self.reconnect_delay > Duration::from_secs(60) { + return Err(DurableRunnerError::invalid( + "reconnect delay must be between one millisecond and 60 seconds", + )); + } + if self.max_runtime > Duration::from_secs(7 * 24 * 60 * 60) { + return Err(DurableRunnerError::invalid( + "durable runner max runtime must not exceed seven days", + )); + } + Ok(()) + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs new file mode 100644 index 00000000000..b4b2bc39a99 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs @@ -0,0 +1,649 @@ +use std::thread; +use std::time::Instant; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use super::state::{ + Command, CommandDisposition, DurableState, DurableStateStore, EventPriority, + StoredCommandResult, +}; +use super::transport::{ + current_unix_ms, validate_control_identity, AuthenticatedTransport, ConnectionMetadata, + LeaseCredential, ResolvedWsTarget, +}; +use super::{BootstrapTicket, DurableRunnerConfig, DurableRunnerError, PROTOCOL, PROTOCOL_VERSION}; + +#[derive(Clone, Debug, PartialEq)] +pub struct CommandExecution { + pub result: Value, + pub events: Vec<(String, EventPriority, Value)>, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PolledEvent { + pub executor_event_id: String, + pub event_type: String, + pub priority: EventPriority, + pub payload: Value, +} + +impl CommandExecution { + pub fn result(result: Value) -> Self { + Self { + result, + events: Vec::new(), + } + } +} + +pub trait CommandExecutor { + fn execute(&mut self, command: &Command) -> Result; + + fn poll_events(&mut self) -> Result, DurableRunnerError> { + Ok(Vec::new()) + } + + /// Removes the prefix returned by `poll_events` after each event is + /// durably committed to the PRP outbox. Implementations that retain + /// provider events must not remove them before this acknowledgement. + fn acknowledge_events(&mut self, _count: usize) -> Result<(), DurableRunnerError> { + Ok(()) + } + + fn shutdown(&mut self) -> Result<(), DurableRunnerError> { + Ok(()) + } +} + +pub fn run_durable_runner( + config: DurableRunnerConfig, + bootstrap_ticket: BootstrapTicket, + mut executor: E, +) -> Result<(), DurableRunnerError> { + config.validate()?; + let store = DurableStateStore::new(&config.state_dir)?; + let (mut state, recovered) = store.load_or_create(&config)?; + if state.lifecycle == "revoked" || state.lifecycle == "stopped" { + return Ok(()); + } + if recovered { + state.reconnect_count = state.reconnect_count.saturating_add(1); + state.record_diagnostic("runner restored its durable identity after process recovery"); + state.enqueue_event( + &config, + "runner.reconciled", + EventPriority::P0, + json!({"outcome": "same_durable_session_resumed"}), + )?; + store.save(&state)?; + } + // Resolve once. Every reconnect uses the same validated concrete addresses, + // so DNS cannot redirect a retry after the trust decision. + let target = ResolvedWsTarget::resolve(&config.connect_url)?; + let started = Instant::now(); + let mut bootstrap_ticket = Some(bootstrap_ticket); + let mut lease: Option = None; + + loop { + if started.elapsed() >= config.max_runtime { + let _ = executor.shutdown(); + state.lifecycle = "recoverable_failure".to_owned(); + state.recoverable_failure = Some("transport_reconnect_deadline_exceeded".to_owned()); + state.record_diagnostic( + "transport reconnect deadline elapsed; durable state is preserved", + ); + store.save(&state)?; + return Err(DurableRunnerError::invalid( + "transport reconnect deadline elapsed; durable state is preserved", + )); + } + if lease.as_ref().is_some_and(|credential| { + current_unix_ms().is_ok_and(|now| now >= credential.expires_at_unix_ms) + }) { + let _ = executor.shutdown(); + state.lifecycle = "recoverable_failure".to_owned(); + state.recoverable_failure = Some("lease_expired_requires_bootstrap".to_owned()); + state.record_diagnostic("connection lease expired; a fresh bootstrap is required"); + store.save(&state)?; + return Err(DurableRunnerError::invalid( + "connection lease expired; a fresh bootstrap is required", + )); + } + + let using_bootstrap = lease.is_none(); + let connection = AuthenticatedTransport::connect( + &target, + &config, + &state, + bootstrap_ticket.as_ref(), + lease.as_ref(), + ); + let (mut transport, welcome) = match connection { + Ok(connection) => connection, + Err(error) => { + state.record_diagnostic(format!("transport reconnect scheduled: {error}")); + store.save(&state)?; + if using_bootstrap && error.bootstrap_maybe_consumed { + return Err(DurableRunnerError::invalid( + "bootstrap connection failed closed; provide a fresh one-use ticket", + )); + } + thread::sleep(config.reconnect_delay); + continue; + } + }; + if let Some(next_lease) = welcome.lease { + lease = Some(next_lease); + // A bootstrap capability is one-use. It is destroyed only after a + // mutually authenticated secure welcome exchanges it for a lease. + bootstrap_ticket.take(); + } + if let Some(acked_source_seq) = welcome.acked_source_seq { + state.apply_ack(acked_source_seq)?; + } + state.lifecycle = "ready".to_owned(); + state.recoverable_failure = None; + store.save(&state)?; + let mut sent_source_seq = state.acked_source_seq; + + let mut stop_after_reply = false; + let mut disconnected = false; + for command in welcome.pending_commands { + let (result, stop) = + process_command(&mut state, &store, &config, &mut executor, &command)?; + stop_after_reply |= stop; + if let Err(error) = transport.send_json(&command_result_envelope(&state, &result)) { + state.record_diagnostic(error.to_string()); + disconnected = true; + break; + } + } + if !disconnected && send_outbox(&mut transport, &state, &mut sent_source_seq).is_err() { + state.record_diagnostic("outbox delivery failed; unacknowledged suffix will replay"); + disconnected = true; + } + if stop_after_reply && !disconnected { + executor.shutdown()?; + state.lifecycle = "stopped".to_owned(); + store.save(&state)?; + return Ok(()); + } + if disconnected { + state.reconnect_count = state.reconnect_count.saturating_add(1); + store.save(&state)?; + thread::sleep(config.reconnect_delay); + continue; + } + + let connection = welcome.connection; + loop { + if started.elapsed() >= config.max_runtime { + break; + } + poll_executor_events(&mut state, &store, &config, &mut executor)?; + if let Err(error) = send_outbox(&mut transport, &state, &mut sent_source_seq) { + state.record_diagnostic(error.to_string()); + state.reconnect_count = state.reconnect_count.saturating_add(1); + store.save(&state)?; + break; + } + if current_unix_ms()? >= connection.expires_at_unix_ms { + let _ = executor.shutdown(); + state.lifecycle = "recoverable_failure".to_owned(); + state.recoverable_failure = Some("lease_expired_requires_bootstrap".to_owned()); + state.record_diagnostic("active connection lease expired"); + store.save(&state)?; + return Err(DurableRunnerError::invalid( + "active connection lease expired; durable state is preserved", + )); + } + let message = match transport.receive_json() { + Ok(Some(message)) => message, + Ok(None) => continue, + Err(error) => { + state.record_diagnostic(error.to_string()); + state.reconnect_count = state.reconnect_count.saturating_add(1); + store.save(&state)?; + break; + } + }; + if let Err(error) = validate_control_identity(&message, &state, Some(&connection)) { + state.record_diagnostic(format!( + "control identity mismatch closed the connection: {error}" + )); + state.reconnect_count = state.reconnect_count.saturating_add(1); + store.save(&state)?; + break; + } + match message.get("kind").and_then(Value::as_str) { + Some("ack") => { + let acked = message + .pointer("/payload/ackedSourceSeq") + .and_then(Value::as_u64) + .ok_or_else(|| DurableRunnerError::invalid("ACK cursor is required"))?; + state.apply_ack(acked)?; + store.save(&state)?; + } + Some("command") => { + let command: Command = + serde_json::from_value(message.get("payload").cloned().ok_or_else( + || DurableRunnerError::invalid("command payload is required"), + )?) + .map_err(|error| { + DurableRunnerError::invalid(format!("command is malformed: {error}")) + })?; + let (result, stop) = + process_command(&mut state, &store, &config, &mut executor, &command)?; + let delivery = transport + .send_json(&command_result_envelope(&state, &result)) + .and_then(|()| send_outbox(&mut transport, &state, &mut sent_source_seq)); + if let Err(error) = delivery { + state.record_diagnostic(error.to_string()); + state.reconnect_count = state.reconnect_count.saturating_add(1); + store.save(&state)?; + break; + } + if stop { + executor.shutdown()?; + state.lifecycle = "stopped".to_owned(); + store.save(&state)?; + return Ok(()); + } + } + Some("revoke") => { + let epoch = message + .pointer("/payload/revocationEpoch") + .and_then(Value::as_u64) + .ok_or_else(|| { + DurableRunnerError::invalid("revoke revocation epoch is required") + })?; + if epoch <= connection.revocation_epoch { + return Err(DurableRunnerError::invalid( + "revoke must advance the authenticated revocation epoch", + )); + } + state.lifecycle = "revoked".to_owned(); + state.record_diagnostic("connection capability was revoked"); + executor.shutdown()?; + store.save(&state)?; + return Ok(()); + } + Some("ping") => { + transport.send_json(&control_envelope( + &state, + &connection, + "pong", + json!({ + "lifecycle": state.lifecycle, + "ackedSourceSeq": state.acked_source_seq, + "outboxBytes": state.outbox_bytes(), + }), + ))?; + } + _ => { + state.record_diagnostic( + "malformed or unsupported control frame closed the connection", + ); + state.reconnect_count = state.reconnect_count.saturating_add(1); + store.save(&state)?; + break; + } + } + } + thread::sleep(config.reconnect_delay); + } +} + +fn poll_executor_events( + state: &mut DurableState, + store: &DurableStateStore, + config: &DurableRunnerConfig, + executor: &mut E, +) -> Result<(), DurableRunnerError> { + let events = executor.poll_events()?; + if events.is_empty() { + return Ok(()); + } + for event in events { + // Commit and acknowledge one event at a time. If a later event is + // oversized or the outbox is full, the accepted prefix is already + // durable and the unacknowledged suffix remains with the executor. + if state.has_executor_event_receipt( + &event.executor_event_id, + &event.event_type, + event.priority, + &event.payload, + )? { + executor.acknowledge_events(1)?; + continue; + } + state.enqueue_executor_event( + config, + event.executor_event_id, + event.event_type, + event.priority, + event.payload, + )?; + store.save(state)?; + executor.acknowledge_events(1)?; + } + Ok(()) +} + +fn process_command( + state: &mut DurableState, + store: &DurableStateStore, + config: &DurableRunnerConfig, + executor: &mut E, + command: &Command, +) -> Result<(StoredCommandResult, bool), DurableRunnerError> { + match state.begin_command(command)? { + CommandDisposition::Replay(result) => { + let stop = result.status == "completed" + && matches!( + command.command_type.as_str(), + "runner.shutdown" | "runner.suspend" + ); + return Ok((result, stop)); + } + CommandDisposition::Reject(result) => { + return Ok((result, false)); + } + CommandDisposition::Execute => {} + } + // Persist the pending marker before any command effect. If the process dies + // in the effect window, recovery returns an indeterminate result and never + // executes the same logical command twice. + store.save(state)?; + let execution = executor.execute(command)?; + for (event_type, priority, payload) in execution.events { + state.enqueue_event(config, event_type, priority, payload)?; + } + let result = state.complete_command(command, execution.result)?; + store.save(state)?; + let stop = matches!( + command.command_type.as_str(), + "runner.shutdown" | "runner.suspend" + ); + Ok((result, stop)) +} + +fn send_outbox( + transport: &mut AuthenticatedTransport, + state: &DurableState, + sent_source_seq: &mut u64, +) -> Result<(), DurableRunnerError> { + for event in &state.outbox { + if event.source_seq <= *sent_source_seq { + continue; + } + transport.send_json(&event.envelope)?; + *sent_source_seq = event.source_seq; + } + Ok(()) +} + +fn command_result_envelope(state: &DurableState, result: &StoredCommandResult) -> Value { + json!({ + "protocol": PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": "command_result", + "runnerInstanceId": state.runner_instance_id, + "environmentLeaseId": state.environment_lease_id, + "runId": state.run_id, + "normalizedSessionId": state.normalized_session_id, + "turnId": state.turn_id, + "itemId": state.item_id, + "payload": result, + }) +} + +fn control_envelope( + state: &DurableState, + connection: &ConnectionMetadata, + kind: &str, + payload: Value, +) -> Value { + json!({ + "protocol": PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": kind, + "runnerInstanceId": state.runner_instance_id, + "environmentLeaseId": state.environment_lease_id, + "runId": state.run_id, + "normalizedSessionId": state.normalized_session_id, + "turnId": state.turn_id, + "itemId": state.item_id, + "connectionId": connection.connection_id, + "connectionLeaseId": connection.lease_id, + "payload": payload, + }) +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::fs; + use std::path::PathBuf; + use std::time::Duration; + + use super::*; + + struct CountingExecutor { + calls: usize, + } + + struct RetainingEventExecutor { + events: VecDeque, + fail_acknowledgement: bool, + } + + impl CommandExecutor for CountingExecutor { + fn execute(&mut self, _command: &Command) -> Result { + self.calls += 1; + Ok(CommandExecution::result(json!({"calls": self.calls}))) + } + } + + impl CommandExecutor for RetainingEventExecutor { + fn execute(&mut self, _command: &Command) -> Result { + Ok(CommandExecution::result(json!({"status": "completed"}))) + } + + fn poll_events(&mut self) -> Result, DurableRunnerError> { + Ok(self.events.iter().cloned().collect()) + } + + fn acknowledge_events(&mut self, count: usize) -> Result<(), DurableRunnerError> { + if self.fail_acknowledgement { + return Err(DurableRunnerError::invalid( + "simulated crash before provider acknowledgement", + )); + } + if count > self.events.len() { + return Err(DurableRunnerError::invalid( + "test acknowledgement exceeded pending events", + )); + } + self.events.drain(..count); + Ok(()) + } + } + + fn config(directory: PathBuf) -> DurableRunnerConfig { + DurableRunnerConfig { + connect_url: "ws://127.0.0.1:3000/path".to_owned(), + state_dir: directory, + runner_instance_id: "runner_1".to_owned(), + environment_lease_id: "environment_1".to_owned(), + run_id: "run_1".to_owned(), + normalized_session_id: "session_1".to_owned(), + turn_id: "turn_1".to_owned(), + item_id: "item_1".to_owned(), + runner_version: "0.0.0".to_owned(), + runner_digest: "sha256:test".to_owned(), + max_outbox_bytes: 64 * 1024, + p0_reserve_bytes: 4096, + max_frame_bytes: 64 * 1024, + reconnect_delay: Duration::from_millis(1), + max_runtime: Duration::from_secs(1), + } + } + + fn command(command_type: &str) -> Command { + Command { + schema: "paperclip.prp.command.v1".to_owned(), + command_id: "command_1".to_owned(), + controller_seq: 1, + command_type: command_type.to_owned(), + issued_at: "2026-08-24T00:00:00.000Z".to_owned(), + deadline_at: None, + precondition: None, + payload: json!({}), + } + } + + #[test] + fn event_batch_keeps_accepted_prefix_and_unacknowledged_suffix() { + let directory = std::env::temp_dir().join(format!( + "paperclip-runner-event-batch-failure-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&directory); + let mut config = config(directory.clone()); + config.max_frame_bytes = 1024; + let store = DurableStateStore::new(&directory).unwrap(); + let (mut state, _) = store.load_or_create(&config).unwrap(); + let mut executor = RetainingEventExecutor { + events: VecDeque::from([ + PolledEvent { + executor_event_id: "provider-event-1".to_owned(), + event_type: "provider.notice.recorded".to_owned(), + priority: EventPriority::P1, + payload: json!({"message": "durable prefix"}), + }, + PolledEvent { + executor_event_id: "provider-event-2".to_owned(), + event_type: "provider.notice.recorded".to_owned(), + priority: EventPriority::P1, + payload: json!({"message": "x".repeat(2048)}), + }, + ]), + fail_acknowledgement: false, + }; + + let error = poll_executor_events(&mut state, &store, &config, &mut executor) + .expect_err("the oversized suffix must fail closed"); + assert!(error.to_string().contains("transport frame limit")); + assert_eq!(state.outbox.len(), 1); + assert_eq!(state.outbox[0].event_type, "provider.notice.recorded"); + assert_eq!(executor.events.len(), 1); + assert_eq!( + executor.events[0].payload["message"], + Value::String("x".repeat(2048)) + ); + + let (reloaded, recovered) = store.load_or_create(&config).unwrap(); + assert!(recovered); + assert_eq!(reloaded.outbox.len(), 1); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn receipt_survives_outbox_ack_and_prevents_duplicate_delivery() { + let directory = std::env::temp_dir().join(format!( + "paperclip-runner-event-ack-crash-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&directory); + let config = config(directory.clone()); + let store = DurableStateStore::new(&directory).unwrap(); + let (mut state, _) = store.load_or_create(&config).unwrap(); + let mut executor = RetainingEventExecutor { + events: VecDeque::from([PolledEvent { + executor_event_id: "provider-event-before-ack-crash".to_owned(), + event_type: "provider.notice.recorded".to_owned(), + priority: EventPriority::P1, + payload: json!({"message": "deliver exactly once"}), + }]), + fail_acknowledgement: true, + }; + + let error = poll_executor_events(&mut state, &store, &config, &mut executor) + .expect_err("simulate a crash after outbox persistence"); + assert!(error + .to_string() + .contains("before provider acknowledgement")); + assert_eq!(state.outbox.len(), 1); + assert_eq!(executor.events.len(), 1); + state + .apply_ack(1) + .expect("controller ACK removes the durable outbox copy"); + store.save(&state).unwrap(); + + let (mut recovered_state, recovered) = store.load_or_create(&config).unwrap(); + assert!(recovered); + assert!(recovered_state.outbox.is_empty()); + executor.fail_acknowledgement = false; + executor.events[0].payload = json!({"message": "different data"}); + let mismatch = poll_executor_events(&mut recovered_state, &store, &config, &mut executor) + .expect_err("a retained identity cannot name different event data"); + assert!(mismatch.to_string().contains("reused with different")); + executor.events[0].payload = json!({"message": "deliver exactly once"}); + poll_executor_events(&mut recovered_state, &store, &config, &mut executor) + .expect("recovery acknowledges the retained provider copy"); + assert!(executor.events.is_empty()); + assert!(recovered_state.outbox.is_empty()); + assert_eq!(recovered_state.highest_source_seq(), 1); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn duplicate_delivery_replays_the_durable_result() { + let directory = std::env::temp_dir().join(format!( + "paperclip-runner-command-replay-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&directory); + let config = config(directory.clone()); + let store = DurableStateStore::new(&directory).unwrap(); + let (mut state, _) = store.load_or_create(&config).unwrap(); + let mut executor = CountingExecutor { calls: 0 }; + let command = command("session.open"); + let first = process_command(&mut state, &store, &config, &mut executor, &command) + .unwrap() + .0; + let replay = process_command(&mut state, &store, &config, &mut executor, &command) + .unwrap() + .0; + assert_eq!(executor.calls, 1); + assert_eq!(first, replay); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn completed_shutdown_replay_still_stops_after_delivery() { + let directory = std::env::temp_dir().join(format!( + "paperclip-runner-shutdown-replay-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&directory); + let config = config(directory.clone()); + let store = DurableStateStore::new(&directory).unwrap(); + let (mut state, _) = store.load_or_create(&config).unwrap(); + let mut executor = CountingExecutor { calls: 0 }; + let command = command("runner.shutdown"); + + let (_, first_stop) = + process_command(&mut state, &store, &config, &mut executor, &command).unwrap(); + let (_, replay_stop) = + process_command(&mut state, &store, &config, &mut executor, &command).unwrap(); + + assert!(first_stop); + assert!(replay_stop); + assert_eq!(executor.calls, 1); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs new file mode 100644 index 00000000000..50c6df265ca --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs @@ -0,0 +1,1364 @@ +use std::collections::{BTreeMap, HashSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use super::{DurableRunnerConfig, DurableRunnerError, PROTOCOL, PROTOCOL_VERSION}; + +const STATE_SCHEMA: &str = "paperclip.runner.durable.state.v1"; +const STATE_FILE: &str = "runner-state.json"; +const MAX_RECENT_COMMANDS: usize = 128; +const MAX_DIAGNOSTICS: usize = 32; +const MAX_COMMAND_RESULT_BYTES: usize = 64 * 1024; +const MAX_EXECUTOR_EVENT_RECEIPTS: usize = 256; +const STATE_OVERHEAD_BYTES: usize = 16 * 1024 * 1024; +const TEMP_FILE_ATTEMPTS: usize = 32; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EventPriority { + P0, + P1, + P2, +} + +impl EventPriority { + fn number(self) -> u8 { + match self { + Self::P0 => 0, + Self::P1 => 1, + Self::P2 => 2, + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Command { + pub schema: String, + pub command_id: String, + pub controller_seq: u64, + #[serde(rename = "type")] + pub command_type: String, + pub issued_at: String, + #[serde(default)] + pub deadline_at: Option, + #[serde(default)] + pub precondition: Option, + #[serde(default)] + pub payload: Value, +} + +impl Command { + pub fn validate(&self) -> Result<(), DurableRunnerError> { + if self.schema != "paperclip.prp.command.v1" { + return Err(DurableRunnerError::invalid( + "command requires the paperclip.prp.command.v1 schema", + )); + } + if self.command_id.is_empty() + || self.command_id.len() > 160 + || self.command_id.chars().any(char::is_control) + { + return Err(DurableRunnerError::invalid( + "commandId is empty, oversized, or contains control characters", + )); + } + if self.issued_at.is_empty() + || self.issued_at.len() > 64 + || self.issued_at.chars().any(char::is_control) + || self.deadline_at.as_ref().is_some_and(|deadline| { + deadline.is_empty() || deadline.len() > 64 || deadline.chars().any(char::is_control) + }) + { + return Err(DurableRunnerError::invalid( + "command timestamps are empty, oversized, or contain control characters", + )); + } + if self.controller_seq == 0 { + return Err(DurableRunnerError::invalid( + "command controllerSeq must be positive", + )); + } + if !self.payload.is_object() { + return Err(DurableRunnerError::invalid( + "command payload must be an object", + )); + } + if self + .precondition + .as_ref() + .is_some_and(|precondition| !precondition.is_object()) + { + return Err(DurableRunnerError::invalid( + "command precondition must be an object", + )); + } + if !matches!( + self.command_type.as_str(), + "run.prepare" + | "run.attach" + | "session.open" + | "turn.start" + | "turn.steer" + | "turn.interrupt" + | "turn.stop" + | "request.resolve" + | "interaction.receipt" + | "semantic_tool.result" + | "session.snapshot" + | "session.close" + | "session.budget.increase" + | "session.destroy" + | "run.cancel" + | "runner.drain" + | "runner.suspend" + | "runner.shutdown" + ) { + return Err(DurableRunnerError::invalid( + "command type is not supported by PRP v1", + )); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredOutboxEvent { + pub source_seq: u64, + pub priority: u8, + pub event_type: String, + pub envelope: Value, + pub byte_size: usize, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredCommandResult { + pub command_id: String, + pub controller_seq: u64, + pub command_type: String, + pub status: String, + pub result: Value, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ExecutorEventReceipt { + fingerprint: String, + source_seq: u64, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum CommandDisposition { + Execute, + Replay(StoredCommandResult), + Reject(StoredCommandResult), +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DurableState { + pub schema: String, + pub runner_instance_id: String, + pub environment_lease_id: String, + pub run_id: String, + pub normalized_session_id: String, + pub turn_id: String, + pub item_id: String, + pub lifecycle: String, + pub next_source_seq: u64, + pub acked_source_seq: u64, + pub last_controller_command_seq: u64, + pub compacted_through_controller_seq: u64, + pub reconnect_count: u64, + pub max_outbox_bytes: usize, + pub p0_reserve_bytes: usize, + pub peak_outbox_bytes: usize, + pub outbox: Vec, + pub processed_commands: BTreeMap, + #[serde(default)] + pub processed_command_fingerprints: BTreeMap, + #[serde(default)] + executor_event_receipts: BTreeMap, + pub diagnostics: Vec, + pub backpressure: bool, + pub recoverable_failure: Option, +} + +impl DurableState { + pub(crate) fn new(config: &DurableRunnerConfig) -> Self { + Self { + schema: STATE_SCHEMA.to_owned(), + runner_instance_id: config.runner_instance_id.clone(), + environment_lease_id: config.environment_lease_id.clone(), + run_id: config.run_id.clone(), + normalized_session_id: config.normalized_session_id.clone(), + turn_id: config.turn_id.clone(), + item_id: config.item_id.clone(), + lifecycle: "connecting".to_owned(), + next_source_seq: 1, + acked_source_seq: 0, + last_controller_command_seq: 0, + compacted_through_controller_seq: 0, + reconnect_count: 0, + max_outbox_bytes: config.max_outbox_bytes, + p0_reserve_bytes: config.p0_reserve_bytes, + peak_outbox_bytes: 0, + outbox: Vec::new(), + processed_commands: BTreeMap::new(), + processed_command_fingerprints: BTreeMap::new(), + executor_event_receipts: BTreeMap::new(), + diagnostics: Vec::new(), + backpressure: false, + recoverable_failure: None, + } + } + + pub fn outbox_bytes(&self) -> usize { + self.outbox.iter().map(|event| event.byte_size).sum() + } + + pub fn highest_source_seq(&self) -> u64 { + self.next_source_seq.saturating_sub(1) + } + + pub fn enqueue_event( + &mut self, + config: &DurableRunnerConfig, + event_type: impl Into, + priority: EventPriority, + payload: Value, + ) -> Result { + let source_event_id = format!( + "event_{}_{:016}", + self.runner_instance_id, self.next_source_seq + ); + self.enqueue_event_with_source_event_id( + config, + source_event_id, + event_type, + priority, + payload, + ) + } + + fn source_event_id_for_executor( + &self, + executor_event_id: &str, + ) -> Result { + if executor_event_id.is_empty() + || executor_event_id.len() > 160 + || executor_event_id.chars().any(char::is_control) + { + return Err(DurableRunnerError::invalid( + "executor event identity is empty, oversized, or contains control characters", + )); + } + let mut hasher = Sha256::new(); + hasher.update(b"paperclip.executor-event.v1\0"); + hasher.update(self.runner_instance_id.as_bytes()); + hasher.update(b"\0"); + hasher.update(executor_event_id.as_bytes()); + Ok(format!("event_executor_{:x}", hasher.finalize())) + } + + fn has_source_event_id(&self, source_event_id: &str) -> bool { + self.outbox.iter().any(|event| { + event + .envelope + .pointer("/payload/sourceEventId") + .and_then(Value::as_str) + == Some(source_event_id) + }) + } + + pub(crate) fn has_executor_event_receipt( + &self, + executor_event_id: &str, + event_type: &str, + priority: EventPriority, + payload: &Value, + ) -> Result { + self.source_event_id_for_executor(executor_event_id)?; + let Some(existing) = self.executor_event_receipts.get(executor_event_id) else { + return Ok(false); + }; + if existing.fingerprint != executor_event_fingerprint(event_type, priority, payload) { + return Err(DurableRunnerError::invalid( + "executor event identity was reused with different event data", + )); + } + Ok(true) + } + + pub(crate) fn enqueue_executor_event( + &mut self, + config: &DurableRunnerConfig, + executor_event_id: String, + event_type: String, + priority: EventPriority, + payload: Value, + ) -> Result { + if self.has_executor_event_receipt(&executor_event_id, &event_type, priority, &payload)? { + return Err(DurableRunnerError::invalid( + "executor event identity is already committed", + )); + } + let source_event_id = self.source_event_id_for_executor(&executor_event_id)?; + let fingerprint = executor_event_fingerprint(&event_type, priority, &payload); + let source_seq = self.enqueue_event_with_source_event_id( + config, + source_event_id, + event_type, + priority, + payload, + )?; + self.executor_event_receipts.insert( + executor_event_id, + ExecutorEventReceipt { + fingerprint, + source_seq, + }, + ); + self.compact_executor_event_receipts(); + Ok(source_seq) + } + + pub(crate) fn enqueue_event_with_source_event_id( + &mut self, + config: &DurableRunnerConfig, + source_event_id: String, + event_type: impl Into, + priority: EventPriority, + payload: Value, + ) -> Result { + let event_type = event_type.into(); + if source_event_id.is_empty() + || source_event_id.len() > 160 + || source_event_id.chars().any(char::is_control) + || self.has_source_event_id(&source_event_id) + { + return Err(DurableRunnerError::invalid( + "source event identity is malformed or already queued", + )); + } + if event_type.is_empty() + || event_type.len() > 160 + || event_type.chars().any(char::is_control) + { + return Err(DurableRunnerError::invalid( + "event type is empty, oversized, or contains control characters", + )); + } + if !payload.is_object() { + return Err(DurableRunnerError::invalid( + "durable event payload must be an object", + )); + } + + let source_seq = self.next_source_seq; + let emitted_at = current_timestamp()?; + let envelope = json!({ + "protocol": PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": "event", + "runnerInstanceId": self.runner_instance_id, + "environmentLeaseId": self.environment_lease_id, + "runId": self.run_id, + "normalizedSessionId": self.normalized_session_id, + "turnId": self.turn_id, + "itemId": self.item_id, + "payload": { + "schema": "paperclip.prp.event.v1", + "sourceEventId": source_event_id, + "sourceSeq": source_seq, + "sourceInstanceId": self.runner_instance_id, + "sourceKind": "runner", + "runId": self.run_id, + "normalizedSessionId": self.normalized_session_id, + "turnId": self.turn_id, + "itemId": self.item_id, + "eventType": event_type, + "schemaVersion": 1, + "priority": priority.number(), + "emittedAt": emitted_at, + "payload": sanitize_value(&payload), + }, + }); + let byte_size = serde_json::to_vec(&envelope) + .map_err(|error| DurableRunnerError::invalid(error.to_string()))? + .len(); + if byte_size > config.max_frame_bytes { + return Err(DurableRunnerError::invalid( + "durable event exceeds the transport frame limit", + )); + } + let projected = self.outbox_bytes().saturating_add(byte_size); + let non_p0_limit = config + .max_outbox_bytes + .saturating_sub(config.p0_reserve_bytes); + + if priority != EventPriority::P0 && projected > non_p0_limit { + self.backpressure = true; + self.lifecycle = "backpressure".to_owned(); + self.record_diagnostic("outbox soft limit reached; non-P0 event rejected"); + return Err(DurableRunnerError::invalid( + "outbox soft limit reached; reserved storage is available only to P0 events", + )); + } + if projected > config.max_outbox_bytes { + self.lifecycle = "unrecoverable".to_owned(); + self.record_diagnostic("P0 outbox reserve exhausted; operator recovery is required"); + return Err(DurableRunnerError::invalid( + "durable outbox limit exhausted", + )); + } + + self.next_source_seq = self + .next_source_seq + .checked_add(1) + .ok_or_else(|| DurableRunnerError::invalid("source sequence exhausted"))?; + self.outbox.push(StoredOutboxEvent { + source_seq, + priority: priority.number(), + event_type, + envelope, + byte_size, + }); + self.peak_outbox_bytes = self.peak_outbox_bytes.max(projected); + Ok(source_seq) + } + + pub fn apply_ack(&mut self, acked_source_seq: u64) -> Result<(), DurableRunnerError> { + if acked_source_seq < self.acked_source_seq { + return Err(DurableRunnerError::invalid( + "cumulative ACK cannot move behind the durable cursor", + )); + } + if acked_source_seq > self.highest_source_seq() { + return Err(DurableRunnerError::invalid( + "cumulative ACK cannot move beyond the produced source cursor", + )); + } + self.acked_source_seq = acked_source_seq; + self.outbox + .retain(|event| event.source_seq > acked_source_seq); + if self.backpressure + && self.outbox_bytes() < self.max_outbox_bytes.saturating_sub(self.p0_reserve_bytes) + { + self.backpressure = false; + if self.lifecycle == "backpressure" { + self.lifecycle = "ready".to_owned(); + } + } + Ok(()) + } + + pub fn begin_command( + &mut self, + command: &Command, + ) -> Result { + command.validate()?; + let fingerprint = command_fingerprint(command)?; + if let Some(previous) = self.processed_commands.get(&command.command_id) { + let previous_fingerprint = self + .processed_command_fingerprints + .get(&command.command_id) + .ok_or_else(|| { + DurableRunnerError::invalid( + "durable command journal is missing its identity fingerprint", + ) + })?; + if previous_fingerprint != &fingerprint { + return Err(DurableRunnerError::invalid( + "commandId was reused with different command data", + )); + } + return Ok(CommandDisposition::Replay(previous.clone())); + } + if command.controller_seq <= self.compacted_through_controller_seq { + return Ok(CommandDisposition::Reject(command_result( + command, + "rejected", + json!({ + "code": "command_history_compacted", + "message": "command is older than the bounded replay journal and was not re-executed", + }), + ))); + } + let expected = self + .last_controller_command_seq + .checked_add(1) + .ok_or_else(|| DurableRunnerError::invalid("controller sequence exhausted"))?; + if command.controller_seq != expected { + return Err(DurableRunnerError::invalid(format!( + "controller sequence must be contiguous: expected {expected}, received {}", + command.controller_seq + ))); + } + + self.last_controller_command_seq = command.controller_seq; + self.processed_commands.insert( + command.command_id.clone(), + command_result( + command, + "pending", + json!({ + "code": "execution_indeterminate", + "message": "command was journaled before its effect", + }), + ), + ); + self.processed_command_fingerprints + .insert(command.command_id.clone(), fingerprint); + self.compact_command_history(); + Ok(CommandDisposition::Execute) + } + + pub fn complete_command( + &mut self, + command: &Command, + result: Value, + ) -> Result { + let stored = self + .processed_commands + .get_mut(&command.command_id) + .ok_or_else(|| { + DurableRunnerError::invalid("command was not journaled before completion") + })?; + if stored.controller_seq != command.controller_seq || stored.status != "pending" { + return Err(DurableRunnerError::invalid( + "command completion does not match a pending journal entry", + )); + } + let result = sanitize_value(&result); + let result_bytes = serde_json::to_vec(&result) + .map_err(|error| DurableRunnerError::invalid(error.to_string()))? + .len(); + if result_bytes > MAX_COMMAND_RESULT_BYTES { + return Err(DurableRunnerError::invalid( + "command result exceeds the 64 KiB durable journal limit", + )); + } + stored.status = "completed".to_owned(); + stored.result = result; + Ok(stored.clone()) + } + + pub fn reconcile_pending_commands(&mut self) -> bool { + let mut changed = false; + for command in self.processed_commands.values_mut() { + if command.status == "pending" { + command.status = "indeterminate".to_owned(); + command.result = json!({ + "code": "execution_indeterminate", + "message": "runner recovered after journaling this command; it will not execute twice", + }); + changed = true; + } + } + changed + } + + fn has_legacy_command_journal(&self) -> bool { + !self.processed_commands.is_empty() && self.processed_command_fingerprints.is_empty() + } + + fn compact_legacy_command_journal(&mut self) { + self.processed_commands.clear(); + self.processed_command_fingerprints.clear(); + self.compacted_through_controller_seq = self.last_controller_command_seq; + self.record_diagnostic( + "pre-fingerprint command journal was compacted; prior commands remain non-reexecutable", + ); + } + + pub(crate) fn record_diagnostic(&mut self, message: impl Into) { + self.diagnostics.push(redact_text(&message.into())); + if self.diagnostics.len() > MAX_DIAGNOSTICS { + self.diagnostics.remove(0); + } + } + + fn compact_command_history(&mut self) { + while self.processed_commands.len() > MAX_RECENT_COMMANDS { + let Some(oldest_id) = self + .processed_commands + .values() + .min_by_key(|command| command.controller_seq) + .map(|command| command.command_id.clone()) + else { + break; + }; + if let Some(oldest) = self.processed_commands.remove(&oldest_id) { + self.processed_command_fingerprints.remove(&oldest_id); + self.compacted_through_controller_seq = self + .compacted_through_controller_seq + .max(oldest.controller_seq); + } + } + } + + fn compact_executor_event_receipts(&mut self) { + while self.executor_event_receipts.len() > MAX_EXECUTOR_EVENT_RECEIPTS { + let Some(oldest_id) = self + .executor_event_receipts + .iter() + .min_by_key(|(_, receipt)| receipt.source_seq) + .map(|(event_id, _)| event_id.clone()) + else { + break; + }; + self.executor_event_receipts.remove(&oldest_id); + } + } +} + +fn command_result(command: &Command, status: &str, result: Value) -> StoredCommandResult { + StoredCommandResult { + command_id: command.command_id.clone(), + controller_seq: command.controller_seq, + command_type: command.command_type.clone(), + status: status.to_owned(), + result, + } +} + +fn command_fingerprint(command: &Command) -> Result { + let value = serde_json::to_value(command).map_err(|error| { + DurableRunnerError::invalid(format!("failed to fingerprint durable command: {error}")) + })?; + let digest = Sha256::digest(canonical_json(&value).as_bytes()); + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut fingerprint = String::with_capacity(digest.len() * 2); + for byte in digest { + fingerprint.push(HEX[usize::from(byte >> 4)] as char); + fingerprint.push(HEX[usize::from(byte & 0x0f)] as char); + } + Ok(fingerprint) +} + +fn executor_event_fingerprint( + event_type: &str, + priority: EventPriority, + payload: &Value, +) -> String { + let identity = json!({ + "eventType": event_type, + "priority": priority.number(), + "payload": sanitize_value(payload), + }); + format!("{:x}", Sha256::digest(canonical_json(&identity).as_bytes())) +} + +fn canonical_json(value: &Value) -> String { + match value { + Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(canonical_json) + .collect::>() + .join(",") + ), + Value::Object(values) => { + let mut keys = values.keys().collect::>(); + keys.sort(); + format!( + "{{{}}}", + keys.into_iter() + .map(|key| format!( + "{}:{}", + serde_json::to_string(key).expect("JSON object key should serialize"), + canonical_json(&values[key]) + )) + .collect::>() + .join(",") + ) + } + _ => value.to_string(), + } +} + +#[derive(Clone, Debug)] +pub struct DurableStateStore { + path: PathBuf, +} + +impl DurableStateStore { + pub fn new(state_dir: &Path) -> Result { + if let Ok(metadata) = fs::symlink_metadata(state_dir) { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(DurableRunnerError::invalid(format!( + "runner state directory {} must be a real directory", + state_dir.display() + ))); + } + } + fs::create_dir_all(state_dir).map_err(|error| { + DurableRunnerError::invalid(format!( + "failed to create runner state directory {}: {error}", + state_dir.display() + )) + })?; + #[cfg(unix)] + fs::set_permissions(state_dir, fs::Permissions::from_mode(0o700)).map_err(|error| { + DurableRunnerError::invalid(format!( + "failed to secure runner state directory {}: {error}", + state_dir.display() + )) + })?; + verify_private_directory(state_dir)?; + Ok(Self { + path: state_dir.join(STATE_FILE), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn load_or_create( + &self, + config: &DurableRunnerConfig, + ) -> Result<(DurableState, bool), DurableRunnerError> { + let mut bytes = Vec::new(); + match open_private_regular_file(&self.path) { + Ok(mut file) => { + let maximum_state_bytes = + config.max_outbox_bytes.saturating_add(STATE_OVERHEAD_BYTES); + let file_bytes = usize::try_from( + file.metadata() + .map_err(|error| DurableRunnerError::invalid(error.to_string()))? + .len(), + ) + .map_err(|_| DurableRunnerError::invalid("durable state length overflowed"))?; + if file_bytes > maximum_state_bytes { + return Err(DurableRunnerError::invalid( + "durable state exceeds its configured storage bound", + )); + } + file.read_to_end(&mut bytes).map_err(|error| { + DurableRunnerError::invalid(format!( + "failed to read durable state {}: {error}", + self.path.display() + )) + })? + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let state = DurableState::new(config); + self.save(&state)?; + return Ok((state, false)); + } + Err(error) => { + return Err(DurableRunnerError::invalid(format!( + "failed to open durable state {}: {error}", + self.path.display() + ))) + } + }; + let mut state: DurableState = serde_json::from_slice(&bytes).map_err(|error| { + DurableRunnerError::invalid(format!( + "durable state is malformed and cannot be recovered: {error}" + )) + })?; + let has_legacy_command_journal = state.has_legacy_command_journal(); + validate_binding(&state, config, has_legacy_command_journal)?; + let mut changed = false; + if has_legacy_command_journal { + state.compact_legacy_command_journal(); + validate_binding(&state, config, false)?; + changed = true; + } + if state.reconcile_pending_commands() { + changed = true; + } + if changed { + self.save(&state)?; + } + Ok((state, true)) + } + + pub fn save(&self, state: &DurableState) -> Result<(), DurableRunnerError> { + let bytes = serde_json::to_vec_pretty(state).map_err(|error| { + DurableRunnerError::invalid(format!("failed to serialize durable state: {error}")) + })?; + if bytes.len() > state.max_outbox_bytes.saturating_add(STATE_OVERHEAD_BYTES) { + return Err(DurableRunnerError::invalid( + "durable state exceeds its configured storage bound", + )); + } + let (temporary, mut file) = create_private_temporary_file(&self.path)?; + let result = (|| -> Result<(), DurableRunnerError> { + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| { + DurableRunnerError::invalid(format!("failed to commit durable state: {error}")) + })?; + drop(file); + fs::rename(&temporary, &self.path).map_err(|error| { + DurableRunnerError::invalid(format!( + "failed to atomically replace durable state: {error}" + )) + })?; + #[cfg(unix)] + if let Some(parent) = self.path.parent() { + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| { + DurableRunnerError::invalid(format!( + "failed to sync durable state directory: {error}" + )) + })?; + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result + } +} + +fn validate_binding( + state: &DurableState, + config: &DurableRunnerConfig, + allow_legacy_command_journal: bool, +) -> Result<(), DurableRunnerError> { + if state.schema != STATE_SCHEMA + || state.runner_instance_id != config.runner_instance_id + || state.environment_lease_id != config.environment_lease_id + || state.run_id != config.run_id + || state.normalized_session_id != config.normalized_session_id + || state.turn_id != config.turn_id + || state.item_id != config.item_id + || state.max_outbox_bytes != config.max_outbox_bytes + || state.p0_reserve_bytes != config.p0_reserve_bytes + { + return Err(DurableRunnerError::invalid( + "durable state binding does not match this runner invocation", + )); + } + let outbox_bytes = state.outbox.iter().try_fold(0_usize, |total, event| { + let serialized = serde_json::to_vec(&event.envelope) + .map_err(|error| DurableRunnerError::invalid(error.to_string()))?; + if event.byte_size != serialized.len() + || event + .envelope + .pointer("/payload/sourceSeq") + .and_then(Value::as_u64) + != Some(event.source_seq) + || event.priority > 2 + { + return Err(DurableRunnerError::invalid( + "durable outbox metadata does not match its envelope", + )); + } + total + .checked_add(event.byte_size) + .ok_or_else(|| DurableRunnerError::invalid("durable outbox size overflowed")) + })?; + let outbox_cursors_are_valid = match (state.outbox.first(), state.outbox.last()) { + (None, None) => state.acked_source_seq == state.highest_source_seq(), + (Some(first), Some(last)) => { + state.acked_source_seq.checked_add(1) == Some(first.source_seq) + && last.source_seq == state.highest_source_seq() + } + _ => false, + }; + let mut command_sequences = state + .processed_commands + .iter() + .map(|(key, command)| { + if key != &command.command_id + || command.controller_seq <= state.compacted_through_controller_seq + || command.controller_seq > state.last_controller_command_seq + || !matches!( + command.status.as_str(), + "pending" | "completed" | "indeterminate" + ) + { + return Err(DurableRunnerError::invalid( + "durable command journal metadata is inconsistent", + )); + } + Ok(command.controller_seq) + }) + .collect::, _>>()?; + let command_fingerprints_are_valid = (state.processed_command_fingerprints.len() + == state.processed_commands.len() + && state + .processed_command_fingerprints + .iter() + .all(|(key, value)| { + state.processed_commands.contains_key(key) + && value.len() == 64 + && value.bytes().all(|byte| byte.is_ascii_hexdigit()) + })) + || (allow_legacy_command_journal + && !state.processed_commands.is_empty() + && state.processed_command_fingerprints.is_empty()); + let mut executor_receipt_sequences = HashSet::new(); + let executor_event_receipts_are_valid = state.executor_event_receipts.len() + <= MAX_EXECUTOR_EVENT_RECEIPTS + && state + .executor_event_receipts + .iter() + .all(|(event_id, receipt)| { + state.source_event_id_for_executor(event_id).is_ok() + && receipt.fingerprint.len() == 64 + && receipt + .fingerprint + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + && receipt.source_seq > 0 + && receipt.source_seq <= state.highest_source_seq() + && executor_receipt_sequences.insert(receipt.source_seq) + }); + command_sequences.sort_unstable(); + let command_cursors_are_valid = match (command_sequences.first(), command_sequences.last()) { + (None, None) => state.compacted_through_controller_seq == state.last_controller_command_seq, + (Some(first), Some(last)) => { + state.compacted_through_controller_seq.checked_add(1) == Some(*first) + && *last == state.last_controller_command_seq + && command_sequences + .windows(2) + .all(|pair| pair[0].checked_add(1) == Some(pair[1])) + } + _ => false, + }; + + if state.next_source_seq == 0 + || state.acked_source_seq > state.highest_source_seq() + || !outbox_cursors_are_valid + || state + .outbox + .windows(2) + .any(|pair| pair[0].source_seq.checked_add(1) != Some(pair[1].source_seq)) + || outbox_bytes > state.max_outbox_bytes + || state.peak_outbox_bytes < outbox_bytes + || state.compacted_through_controller_seq > state.last_controller_command_seq + || !command_cursors_are_valid + || !command_fingerprints_are_valid + || !executor_event_receipts_are_valid + { + return Err(DurableRunnerError::invalid( + "durable state cursors, bounds, or journals are inconsistent", + )); + } + Ok(()) +} + +pub(crate) fn verify_private_directory(path: &Path) -> Result<(), DurableRunnerError> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| DurableRunnerError::invalid(error.to_string()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(DurableRunnerError::invalid( + "durable state directory must not be a symlink", + )); + } + #[cfg(unix)] + if metadata.permissions().mode() & 0o077 != 0 { + return Err(DurableRunnerError::invalid( + "durable state directory must not be accessible by group or other users", + )); + } + Ok(()) +} + +pub(crate) fn open_private_regular_file(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + options.custom_flags(no_follow_flag()); + let file = options.open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "durable state path is not a regular file", + )); + } + #[cfg(unix)] + if metadata.permissions().mode() & 0o077 != 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "durable state file is accessible by group or other users", + )); + } + Ok(file) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +const fn no_follow_flag() -> i32 { + 0o400000 +} + +#[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))] +const fn no_follow_flag() -> i32 { + 0x00000100 +} + +#[cfg(all( + unix, + not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "freebsd" + )) +))] +const fn no_follow_flag() -> i32 { + 0 +} + +pub(crate) fn create_private_temporary_file( + path: &Path, +) -> Result<(PathBuf, File), DurableRunnerError> { + let parent = path + .parent() + .ok_or_else(|| DurableRunnerError::invalid("durable state path has no parent"))?; + let process_id = std::process::id(); + for attempt in 0..TEMP_FILE_ATTEMPTS { + let temporary = parent.join(format!(".{STATE_FILE}.{process_id}.{attempt}.tmp")); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600).custom_flags(no_follow_flag()); + match options.open(&temporary) { + Ok(file) => return Ok((temporary, file)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(DurableRunnerError::invalid(format!( + "failed to create private durable state temporary file: {error}" + ))) + } + } + } + Err(DurableRunnerError::invalid( + "failed to allocate a private durable state temporary file", + )) +} + +fn sensitive_key(key: &str) -> bool { + let normalized = key.to_ascii_lowercase().replace(['-', '_'], ""); + if matches!( + normalized.as_str(), + "inputtokens" + | "outputtokens" + | "cachereadtokens" + | "cachewritetokens" + | "pretokens" + | "posttokens" + ) { + return false; + } + [ + "authorization", + "cookie", + "password", + "secret", + "token", + "ticket", + "apikey", + "credential", + ] + .iter() + .any(|needle| normalized.contains(needle)) +} + +fn sanitize_value(value: &Value) -> Value { + match value { + Value::Object(object) => Value::Object( + object + .iter() + .map(|(key, value)| { + ( + key.clone(), + if sensitive_key(key) { + Value::String("[REDACTED]".to_owned()) + } else { + sanitize_value(value) + }, + ) + }) + .collect(), + ), + Value::Array(values) => Value::Array(values.iter().map(sanitize_value).collect()), + Value::String(value) => Value::String(redact_text(value)), + value => value.clone(), + } +} + +pub(crate) fn redact_text(input: &str) -> String { + let (bounded, truncated) = if input.len() > 4096 { + let boundary = input + .char_indices() + .map(|(index, _)| index) + .take_while(|index| *index <= 4096) + .last() + .unwrap_or(0); + (&input[..boundary], true) + } else { + (input, false) + }; + let normalized = bounded.to_ascii_lowercase(); + if [ + "authorization", + "bearer ", + "api_key", + "apikey", + "password=", + "secret=", + "ticket=", + "token=", + ] + .iter() + .any(|marker| normalized.contains(marker)) + { + "[REDACTED diagnostic containing a sensitive marker]".to_owned() + } else if truncated { + format!("{bounded}…[truncated]") + } else { + bounded.to_owned() + } +} + +fn current_timestamp() -> Result { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| { + DurableRunnerError::invalid(format!("system clock is invalid: {error}")) + })?; + let total_seconds = i64::try_from(duration.as_secs()) + .map_err(|_| DurableRunnerError::invalid("system clock value overflowed"))?; + let days = total_seconds.div_euclid(86_400); + let second_of_day = total_seconds.rem_euclid(86_400); + let shifted = days + 719_468; + let era = shifted.div_euclid(146_097); + let day_of_era = shifted - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + if !(0..=9999).contains(&year) { + return Err(DurableRunnerError::invalid( + "system clock is outside the supported RFC 3339 range", + )); + } + let hour = second_of_day / 3600; + let minute = second_of_day % 3600 / 60; + let second = second_of_day % 60; + Ok(format!( + "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{:03}Z", + duration.subsec_millis() + )) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + fn config(state_dir: PathBuf) -> DurableRunnerConfig { + DurableRunnerConfig { + connect_url: "ws://127.0.0.1:3000/api/runner/v1/connect/run_1".to_owned(), + state_dir, + runner_instance_id: "runner_1".to_owned(), + environment_lease_id: "environment_1".to_owned(), + run_id: "run_1".to_owned(), + normalized_session_id: "session_1".to_owned(), + turn_id: "turn_1".to_owned(), + item_id: "item_1".to_owned(), + runner_version: "0.0.0".to_owned(), + runner_digest: "sha256:test".to_owned(), + max_outbox_bytes: 16_384, + p0_reserve_bytes: 4096, + max_frame_bytes: 65_536, + reconnect_delay: Duration::from_millis(1), + max_runtime: Duration::from_secs(1), + } + } + + fn command(id: &str, sequence: u64) -> Command { + Command { + schema: "paperclip.prp.command.v1".to_owned(), + command_id: id.to_owned(), + controller_seq: sequence, + command_type: "session.open".to_owned(), + issued_at: "2026-08-24T00:00:00.000Z".to_owned(), + deadline_at: None, + precondition: None, + payload: json!({}), + } + } + + fn temporary_directory(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "paperclip-runner-durable-{label}-{}", + std::process::id() + )) + } + + #[test] + fn cumulative_ack_is_monotonic_and_bounded() { + let config = config(PathBuf::from("unused")); + let mut state = DurableState::new(&config); + state + .enqueue_event(&config, "runner.connected", EventPriority::P0, json!({})) + .unwrap(); + state + .enqueue_event(&config, "runner.reconnected", EventPriority::P1, json!({})) + .unwrap(); + state.apply_ack(1).unwrap(); + assert_eq!(state.outbox.len(), 1); + assert!(state.apply_ack(0).is_err()); + assert!(state.apply_ack(3).is_err()); + } + + #[test] + fn duplicate_command_replays_without_executing() { + let config = config(PathBuf::from("unused")); + let mut state = DurableState::new(&config); + let command = command("command_1", 1); + assert_eq!( + state.begin_command(&command).unwrap(), + CommandDisposition::Execute + ); + state + .complete_command(&command, json!({"ok": true})) + .unwrap(); + assert!(matches!( + state.begin_command(&command).unwrap(), + CommandDisposition::Replay(result) if result.result == json!({"ok": true}) + )); + } + + #[test] + fn command_gaps_and_identifier_reuse_fail_closed() { + let config = config(PathBuf::from("unused")); + let mut state = DurableState::new(&config); + let mut unknown = command("command_unknown", 1); + unknown.command_type = "future.required.command".to_owned(); + assert!(state.begin_command(&unknown).is_err()); + assert!(state.begin_command(&command("command_2", 2)).is_err()); + let first = command("command_1", 1); + state.begin_command(&first).unwrap(); + state.complete_command(&first, json!({})).unwrap(); + assert!(state.begin_command(&command("command_1", 2)).is_err()); + } + + #[test] + fn recovery_marks_ambiguous_effect_without_reexecution() { + let directory = temporary_directory("ambiguous"); + let _ = fs::remove_dir_all(&directory); + let config = config(directory.clone()); + let store = DurableStateStore::new(&directory).unwrap(); + let (mut state, _) = store.load_or_create(&config).unwrap(); + let command = command("command_1", 1); + state.begin_command(&command).unwrap(); + store.save(&state).unwrap(); + + let (mut recovered, existed) = store.load_or_create(&config).unwrap(); + assert!(existed); + assert!(matches!( + recovered.begin_command(&command).unwrap(), + CommandDisposition::Replay(result) if result.status == "indeterminate" + )); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn state_binding_prevents_cross_run_reuse() { + let directory = temporary_directory("binding"); + let _ = fs::remove_dir_all(&directory); + let config = config(directory.clone()); + let store = DurableStateStore::new(&directory).unwrap(); + store.load_or_create(&config).unwrap(); + let mut wrong = config.clone(); + wrong.run_id = "run_2".to_owned(); + assert!(store.load_or_create(&wrong).is_err()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn event_payloads_are_redacted_before_persistence() { + let config = config(PathBuf::from("unused")); + let mut state = DurableState::new(&config); + state + .enqueue_event( + &config, + "runner.diagnostic", + EventPriority::P1, + json!({"nested": {"api_token": "secret-value", "inputTokens": 42}}), + ) + .unwrap(); + assert_eq!( + state.outbox[0] + .envelope + .pointer("/payload/payload/nested/api_token"), + Some(&Value::String("[REDACTED]".to_owned())) + ); + assert_eq!( + state.outbox[0] + .envelope + .pointer("/payload/payload/nested/inputTokens"), + Some(&json!(42)) + ); + } + + #[test] + fn outbox_reserves_capacity_for_p0_and_bounds_frames() { + let mut bounds_config = config(PathBuf::from("unused")); + bounds_config.max_outbox_bytes = 1800; + bounds_config.p0_reserve_bytes = 600; + let mut state = DurableState::new(&bounds_config); + while state + .enqueue_event( + &bounds_config, + "item.delta", + EventPriority::P1, + json!({"text": "x".repeat(200)}), + ) + .is_ok() + {} + assert!(state.backpressure); + assert!(state + .enqueue_event( + &bounds_config, + "runner.diagnostic", + EventPriority::P0, + json!({"message": "storage pressure"}), + ) + .is_ok()); + + let mut frame_limited = config(PathBuf::from("unused")); + frame_limited.max_frame_bytes = 1024; + let mut state = DurableState::new(&frame_limited); + assert!(state + .enqueue_event( + &frame_limited, + "item.delta", + EventPriority::P1, + json!({"text": "x".repeat(2048)}), + ) + .is_err()); + assert!(state.outbox.is_empty()); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs new file mode 100644 index 00000000000..8386624a59c --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs @@ -0,0 +1,1574 @@ +use std::io; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use aes_gcm::aead::{Aead, Payload}; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use hmac::{Hmac, Mac}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tungstenite::client::{client_with_config, IntoClientRequest}; +use tungstenite::protocol::WebSocketConfig; +use tungstenite::{Message, WebSocket}; + +use super::state::{Command, DurableState}; +use super::{ + BootstrapTicket, DurableRunnerConfig, DurableRunnerError, Secret, PROTOCOL, PROTOCOL_VERSION, +}; + +const SECURE_FRAME_SCHEMA: &str = "paperclip.runner.secure-frame.v1"; +const AUTH_TIMEOUT: Duration = Duration::from_secs(2); + +type HmacSha256 = Hmac; + +#[derive(Clone, Debug)] +struct ParsedWsUrl { + host: String, + authority: String, + port: u16, + path: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct ResolvedWsTarget { + authority: String, + path: String, + addresses: Vec, +} + +impl ResolvedWsTarget { + pub(crate) fn resolve(input: &str) -> Result { + resolve_ws_target_with(input, |host, port| { + (host, port) + .to_socket_addrs() + .map(|addresses| addresses.collect()) + }) + } + + fn request_url(&self) -> String { + format!("ws://{}{}", self.authority, self.path) + } +} + +fn parse_ws_url(input: &str) -> Result { + let remainder = input + .strip_prefix("ws://") + .ok_or_else(|| DurableRunnerError::invalid("runner transport accepts exactly ws://"))?; + if remainder.is_empty() + || remainder + .chars() + .any(|character| character.is_ascii_control() || character.is_ascii_whitespace()) + || remainder.contains(['?', '#', '\\']) + { + return Err(DurableRunnerError::invalid( + "WebSocket URL contains query, fragment, whitespace, or path ambiguity", + )); + } + let (authority, path) = remainder + .split_once('/') + .map_or((remainder, "/".to_owned()), |(authority, path)| { + (authority, format!("/{path}")) + }); + if authority.is_empty() || authority.contains(['@', '%']) { + return Err(DurableRunnerError::invalid( + "WebSocket authority must not contain userinfo or encoding ambiguity", + )); + } + let (host, port) = if authority.starts_with('[') { + let closing = authority + .find(']') + .ok_or_else(|| DurableRunnerError::invalid("bracketed IPv6 authority is malformed"))?; + let host = &authority[1..closing]; + let port = authority[closing + 1..].strip_prefix(':').ok_or_else(|| { + DurableRunnerError::invalid("bracketed IPv6 authority requires a port") + })?; + host.parse::() + .map_err(|_| DurableRunnerError::invalid("bracketed WebSocket host must be IPv6"))?; + (host, port) + } else { + let (host, port) = authority.rsplit_once(':').ok_or_else(|| { + DurableRunnerError::invalid("WebSocket URL requires an explicit port") + })?; + if host.is_empty() || host.contains(':') { + return Err(DurableRunnerError::invalid( + "WebSocket host is empty or contains unbracketed IPv6", + )); + } + (host, port) + }; + let port = port + .parse::() + .map_err(|error| DurableRunnerError::invalid(format!("invalid WebSocket port: {error}")))?; + if port == 0 { + return Err(DurableRunnerError::invalid( + "WebSocket port must be non-zero", + )); + } + Ok(ParsedWsUrl { + host: host.to_owned(), + authority: authority.to_owned(), + port, + path, + }) +} + +fn resolve_ws_target_with( + input: &str, + resolver: F, +) -> Result +where + F: FnOnce(&str, u16) -> io::Result>, +{ + let parsed = parse_ws_url(input)?; + let mut addresses = resolver(&parsed.host, parsed.port).map_err(|error| { + DurableRunnerError::invalid(format!("failed to resolve WebSocket destination: {error}")) + })?; + addresses.sort_unstable(); + addresses.dedup(); + if addresses.is_empty() { + return Err(DurableRunnerError::invalid( + "WebSocket destination resolved to no addresses", + )); + } + if addresses.iter().any(|address| !address.ip().is_loopback()) { + return Err(DurableRunnerError::invalid( + "every WebSocket destination must resolve to loopback", + )); + } + Ok(ResolvedWsTarget { + authority: parsed.authority, + path: parsed.path, + addresses, + }) +} + +#[derive(Debug)] +struct CredentialMaterial { + credential_id: String, + auth_key: [u8; 32], +} + +impl CredentialMaterial { + fn from_token(token: &str) -> Self { + Self { + credential_id: format!( + "sha256:{}", + hex_encode(&digest_domain( + "paperclip-runner-credential-id-v1", + &[token.as_bytes()] + )) + ), + auth_key: digest_domain("paperclip-runner-auth-key-v1", &[token.as_bytes()]), + } + } +} + +impl Drop for CredentialMaterial { + fn drop(&mut self) { + self.auth_key.fill(0); + } +} + +#[derive(Debug)] +pub(crate) struct LeaseCredential { + pub(crate) lease_id: String, + pub(crate) expires_at_unix_ms: u64, + pub(crate) revocation_epoch: u64, + token: Secret, +} + +impl LeaseCredential { + fn expose(&self) -> Result<&str, DurableRunnerError> { + self.token.expose() + } +} + +#[derive(Clone, Debug)] +pub(crate) struct ConnectionMetadata { + pub(crate) connection_id: String, + pub(crate) lease_id: String, + pub(crate) expires_at_unix_ms: u64, + pub(crate) revocation_epoch: u64, +} + +#[derive(Debug)] +pub(crate) struct Welcome { + pub(crate) connection: ConnectionMetadata, + pub(crate) lease: Option, + pub(crate) acked_source_seq: Option, + pub(crate) pending_commands: Vec, +} + +struct SecureChannel { + send_cipher: Aes256Gcm, + receive_cipher: Aes256Gcm, + send_counter: u64, + receive_counter: u64, + session_id: String, +} + +impl SecureChannel { + fn client( + auth_key: &[u8], + challenge: &[u8], + server_proof: &[u8], + client_proof: &[u8], + ) -> Result { + Self::new(auth_key, challenge, server_proof, client_proof, false) + } + + #[cfg(test)] + fn server( + auth_key: &[u8], + challenge: &[u8], + server_proof: &[u8], + client_proof: &[u8], + ) -> Result { + Self::new(auth_key, challenge, server_proof, client_proof, true) + } + + fn new( + auth_key: &[u8], + challenge: &[u8], + server_proof: &[u8], + client_proof: &[u8], + server_direction: bool, + ) -> Result { + let session_binding = digest_domain( + "paperclip-runner-session-binding-v1", + &[challenge, server_proof, client_proof], + ); + let client_to_server = hmac_domain( + auth_key, + "paperclip-runner-client-to-core-key-v1", + &[&session_binding], + ); + let server_to_client = hmac_domain( + auth_key, + "paperclip-runner-core-to-client-key-v1", + &[&session_binding], + ); + let (send_key, receive_key) = if server_direction { + (server_to_client, client_to_server) + } else { + (client_to_server, server_to_client) + }; + Ok(Self { + send_cipher: Aes256Gcm::new_from_slice(&send_key) + .map_err(|_| DurableRunnerError::invalid("failed to initialize encryption"))?, + receive_cipher: Aes256Gcm::new_from_slice(&receive_key) + .map_err(|_| DurableRunnerError::invalid("failed to initialize decryption"))?, + send_counter: 0, + receive_counter: 0, + session_id: format!("sha256:{}", hex_encode(&session_binding)), + }) + } + + fn nonce(direction: &[u8; 4], counter: u64) -> [u8; 12] { + let mut nonce = [0_u8; 12]; + nonce[..4].copy_from_slice(direction); + nonce[4..].copy_from_slice(&counter.to_be_bytes()); + nonce + } + + fn aad(&self, direction: &str, counter: u64) -> Vec { + format!( + "{SECURE_FRAME_SCHEMA}\0{}\0{direction}\0{counter}", + self.session_id + ) + .into_bytes() + } + + fn encrypt( + &mut self, + plaintext: &[u8], + server_direction: bool, + ) -> Result { + let counter = self.send_counter; + let direction = if server_direction { b"P3S1" } else { b"P3C1" }; + let label = if server_direction { + "core_to_client" + } else { + "client_to_core" + }; + let nonce = Self::nonce(direction, counter); + let aad = self.aad(label, counter); + let ciphertext = self + .send_cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad: &aad, + }, + ) + .map_err(|_| DurableRunnerError::invalid("secure transport encryption failed"))?; + self.send_counter = self + .send_counter + .checked_add(1) + .ok_or_else(|| DurableRunnerError::invalid("secure send counter exhausted"))?; + Ok(json!({ + "schema": SECURE_FRAME_SCHEMA, + "counter": counter, + "ciphertext": hex_encode(&ciphertext), + })) + } + + fn decrypt( + &mut self, + frame: &Value, + server_direction: bool, + ) -> Result { + if frame.get("schema").and_then(Value::as_str) != Some(SECURE_FRAME_SCHEMA) { + return Err(DurableRunnerError::invalid( + "unauthenticated plaintext control frame was rejected", + )); + } + let counter = frame + .get("counter") + .and_then(Value::as_u64) + .ok_or_else(|| DurableRunnerError::invalid("secure frame counter is required"))?; + if counter != self.receive_counter { + return Err(DurableRunnerError::invalid( + "secure frame counter was replayed or arrived out of order", + )); + } + let ciphertext = frame + .get("ciphertext") + .and_then(Value::as_str) + .ok_or_else(|| DurableRunnerError::invalid("secure frame ciphertext is required"))?; + let ciphertext = hex_decode(ciphertext)?; + let direction = if server_direction { b"P3C1" } else { b"P3S1" }; + let label = if server_direction { + "client_to_core" + } else { + "core_to_client" + }; + let nonce = Self::nonce(direction, counter); + let aad = self.aad(label, counter); + let plaintext = self + .receive_cipher + .decrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &ciphertext, + aad: &aad, + }, + ) + .map_err(|_| DurableRunnerError::invalid("secure frame authentication failed"))?; + self.receive_counter = self + .receive_counter + .checked_add(1) + .ok_or_else(|| DurableRunnerError::invalid("secure receive counter exhausted"))?; + serde_json::from_slice(&plaintext).map_err(|error| { + DurableRunnerError::invalid(format!("secure JSON is malformed: {error}")) + }) + } +} + +pub(crate) struct AuthenticatedTransport { + socket: WebSocket, + secure_channel: SecureChannel, + max_frame_bytes: usize, +} + +#[derive(Debug)] +pub(crate) struct ConnectFailure { + error: DurableRunnerError, + pub(crate) bootstrap_maybe_consumed: bool, +} + +impl std::fmt::Display for ConnectFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.error.fmt(formatter) + } +} + +impl ConnectFailure { + fn retryable(error: DurableRunnerError) -> Self { + Self { + error, + bootstrap_maybe_consumed: false, + } + } + + fn after_auth_started(error: DurableRunnerError, credential_kind: &str) -> Self { + Self { + error, + bootstrap_maybe_consumed: credential_kind == "bootstrap", + } + } +} + +impl AuthenticatedTransport { + pub(crate) fn connect( + target: &ResolvedWsTarget, + config: &DurableRunnerConfig, + state: &DurableState, + bootstrap: Option<&BootstrapTicket>, + lease: Option<&LeaseCredential>, + ) -> Result<(Self, Welcome), ConnectFailure> { + let (credential_token, credential_kind, expected_lease) = match (lease, bootstrap) { + (Some(lease), _) => ( + lease.expose().map_err(ConnectFailure::retryable)?, + "lease", + Some(lease), + ), + (None, Some(bootstrap)) => ( + bootstrap.expose().map_err(ConnectFailure::retryable)?, + "bootstrap", + None, + ), + (None, None) => { + return Err(ConnectFailure::retryable(DurableRunnerError::invalid( + "a bootstrap or unexpired connection lease is required", + ))) + } + }; + let credential = CredentialMaterial::from_token(credential_token); + let stream = TcpStream::connect(target.addresses.as_slice()) + .map_err(|error| { + DurableRunnerError::invalid(format!("WebSocket connect failed: {error}")) + }) + .map_err(ConnectFailure::retryable)?; + stream + .set_read_timeout(Some(AUTH_TIMEOUT)) + .and_then(|()| stream.set_write_timeout(Some(AUTH_TIMEOUT))) + .map_err(|error| DurableRunnerError::invalid(error.to_string())) + .map_err(ConnectFailure::retryable)?; + let request = target + .request_url() + .into_client_request() + .map_err(|error| { + DurableRunnerError::invalid(format!("invalid WebSocket request: {error}")) + }) + .map_err(ConnectFailure::retryable)?; + let websocket_config = WebSocketConfig::default() + .max_message_size(Some(config.max_frame_bytes)) + .max_frame_size(Some(config.max_frame_bytes)); + let (mut socket, _) = client_with_config(request, stream, Some(websocket_config)) + .map_err(|error| { + DurableRunnerError::invalid(format!("WebSocket upgrade failed: {error}")) + }) + .map_err(ConnectFailure::retryable)?; + + let authenticate = + || -> Result<(Self, Welcome), DurableRunnerError> { + let client_nonce = random_nonce()?; + send_plain( + &mut socket, + &json!({ + "protocol": PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": "auth_hello", + "payload": { + "credentialId": credential.credential_id, + "credentialKind": credential_kind, + "clientNonce": client_nonce, + "protocolMin": PROTOCOL_VERSION, + "protocolMax": PROTOCOL_VERSION, + "runnerInstanceId": state.runner_instance_id, + "environmentLeaseId": state.environment_lease_id, + "runId": state.run_id, + "normalizedSessionId": state.normalized_session_id, + "turnId": state.turn_id, + "itemId": state.item_id, + "runnerVersion": config.runner_version, + "runnerDigest": config.runner_digest, + "resume": { + "lastControllerCommandSeq": state.last_controller_command_seq, + "nextSourceEventSeq": state.next_source_seq, + "ackedSourceSeq": state.acked_source_seq, + }, + }, + }), + config.max_frame_bytes, + )?; + + let challenge_value = receive_plain(&mut socket, config.max_frame_bytes)?; + validate_envelope_kind(&challenge_value, "auth_challenge")?; + let challenge: AuthChallenge = + serde_json::from_value(challenge_value.get("payload").cloned().ok_or_else( + || DurableRunnerError::invalid("challenge payload is required"), + )?) + .map_err(|error| { + DurableRunnerError::invalid(format!("invalid auth challenge: {error}")) + })?; + validate_challenge( + &challenge, + state, + config, + &credential, + credential_kind, + &client_nonce, + expected_lease, + )?; + let signing_bytes = challenge_signing_bytes(&challenge); + verify_hmac_hex( + &credential.auth_key, + "paperclip-runner-server-proof-v1", + &[&signing_bytes], + &challenge.server_proof, + )?; + let client_proof = hex_encode(&hmac_domain( + &credential.auth_key, + "paperclip-runner-client-proof-v1", + &[&signing_bytes, challenge.server_proof.as_bytes()], + )); + send_plain( + &mut socket, + &json!({ + "protocol": PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": "auth_response", + "payload": { + "credentialId": credential.credential_id, + "clientNonce": client_nonce, + "serverNonce": challenge.server_nonce, + "clientProof": client_proof, + }, + }), + config.max_frame_bytes, + )?; + let secure_channel = SecureChannel::client( + &credential.auth_key, + &signing_bytes, + challenge.server_proof.as_bytes(), + client_proof.as_bytes(), + )?; + let mut transport = Self { + socket, + secure_channel, + max_frame_bytes: config.max_frame_bytes, + }; + transport + .socket + .get_mut() + .set_read_timeout(Some(Duration::from_millis(250))) + .map_err(|error| DurableRunnerError::invalid(error.to_string()))?; + let mut welcome_value = transport.receive_json()?.ok_or_else(|| { + DurableRunnerError::invalid("authenticated welcome timed out") + })?; + let welcome = + validate_welcome(&mut welcome_value, state, credential_kind, expected_lease)?; + Ok((transport, welcome)) + }; + authenticate().map_err(|error| ConnectFailure::after_auth_started(error, credential_kind)) + } + + pub(crate) fn send_json(&mut self, value: &Value) -> Result<(), DurableRunnerError> { + let bytes = serde_json::to_vec(value) + .map_err(|error| DurableRunnerError::invalid(error.to_string()))?; + if bytes.len() > self.max_frame_bytes { + return Err(DurableRunnerError::invalid( + "outbound secure frame exceeds the configured limit", + )); + } + let frame = self.secure_channel.encrypt(&bytes, false)?; + send_plain(&mut self.socket, &frame, self.max_frame_bytes) + } + + pub(crate) fn receive_json(&mut self) -> Result, DurableRunnerError> { + let frame = match receive_plain_optional(&mut self.socket, self.max_frame_bytes)? { + Some(frame) => frame, + None => return Ok(None), + }; + self.secure_channel.decrypt(&frame, false).map(Some) + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AuthChallenge { + credential_id: String, + credential_kind: String, + client_nonce: String, + server_nonce: String, + runner_instance_id: String, + environment_lease_id: String, + run_id: String, + normalized_session_id: String, + turn_id: String, + item_id: String, + runner_version: String, + runner_digest: String, + selected_version: u64, + credential_expires_at_unix_ms: u64, + credential_lease_id: Option, + revocation_epoch: u64, + server_proof: String, +} + +fn validate_challenge( + challenge: &AuthChallenge, + state: &DurableState, + config: &DurableRunnerConfig, + credential: &CredentialMaterial, + credential_kind: &str, + client_nonce: &str, + expected_lease: Option<&LeaseCredential>, +) -> Result<(), DurableRunnerError> { + for (field, actual, expected) in [ + ( + "credentialId", + challenge.credential_id.as_str(), + credential.credential_id.as_str(), + ), + ( + "credentialKind", + challenge.credential_kind.as_str(), + credential_kind, + ), + ("clientNonce", challenge.client_nonce.as_str(), client_nonce), + ( + "runnerInstanceId", + challenge.runner_instance_id.as_str(), + state.runner_instance_id.as_str(), + ), + ( + "environmentLeaseId", + challenge.environment_lease_id.as_str(), + state.environment_lease_id.as_str(), + ), + ("runId", challenge.run_id.as_str(), state.run_id.as_str()), + ( + "normalizedSessionId", + challenge.normalized_session_id.as_str(), + state.normalized_session_id.as_str(), + ), + ("turnId", challenge.turn_id.as_str(), state.turn_id.as_str()), + ("itemId", challenge.item_id.as_str(), state.item_id.as_str()), + ( + "runnerVersion", + challenge.runner_version.as_str(), + config.runner_version.as_str(), + ), + ( + "runnerDigest", + challenge.runner_digest.as_str(), + config.runner_digest.as_str(), + ), + ] { + if actual != expected { + return Err(DurableRunnerError::invalid(format!( + "authentication challenge {field} does not match this session" + ))); + } + } + if challenge.server_nonce.is_empty() + || challenge.selected_version != PROTOCOL_VERSION + || challenge.credential_expires_at_unix_ms <= current_unix_ms()? + { + return Err(DurableRunnerError::invalid( + "authentication challenge is expired or selected an unsupported protocol", + )); + } + match expected_lease { + Some(lease) + if challenge.credential_lease_id.as_deref() == Some(lease.lease_id.as_str()) + && challenge.credential_expires_at_unix_ms == lease.expires_at_unix_ms + && challenge.revocation_epoch == lease.revocation_epoch => {} + None if challenge.credential_lease_id.is_none() => {} + _ => { + return Err(DurableRunnerError::invalid( + "authentication challenge changed the credential lease binding", + )) + } + } + Ok(()) +} + +fn challenge_signing_bytes(challenge: &AuthChallenge) -> Vec { + let lease_id = challenge.credential_lease_id.as_deref().unwrap_or(""); + [ + challenge.credential_id.as_str(), + challenge.credential_kind.as_str(), + challenge.client_nonce.as_str(), + challenge.server_nonce.as_str(), + challenge.runner_instance_id.as_str(), + challenge.environment_lease_id.as_str(), + challenge.run_id.as_str(), + challenge.normalized_session_id.as_str(), + challenge.turn_id.as_str(), + challenge.item_id.as_str(), + challenge.runner_version.as_str(), + challenge.runner_digest.as_str(), + lease_id, + ] + .iter() + .fold(Vec::new(), |mut output, part| { + output.extend_from_slice(&(part.len() as u64).to_be_bytes()); + output.extend_from_slice(part.as_bytes()); + output + }) + .into_iter() + .chain(challenge.selected_version.to_be_bytes()) + .chain(challenge.credential_expires_at_unix_ms.to_be_bytes()) + .chain(challenge.revocation_epoch.to_be_bytes()) + .collect() +} + +fn validate_welcome( + value: &mut Value, + state: &DurableState, + credential_kind: &str, + expected_lease: Option<&LeaseCredential>, +) -> Result { + validate_control_identity(value, state, None)?; + validate_envelope_kind(value, "welcome")?; + let connection_id = required_string(value, "connectionId")?.to_owned(); + let connection_lease_id = required_string(value, "connectionLeaseId")?.to_owned(); + let payload = value + .get_mut("payload") + .ok_or_else(|| DurableRunnerError::invalid("welcome payload is required"))?; + if payload.get("selectedVersion").and_then(Value::as_u64) != Some(PROTOCOL_VERSION) + || payload.get("connectionLeaseId").and_then(Value::as_str) + != Some(connection_lease_id.as_str()) + { + return Err(DurableRunnerError::invalid( + "welcome protocol or lease identity is inconsistent", + )); + } + let expires_at_unix_ms = payload + .get("connectionLeaseExpiresAtUnixMs") + .and_then(Value::as_u64) + .ok_or_else(|| DurableRunnerError::invalid("welcome lease expiry is required"))?; + if expires_at_unix_ms <= current_unix_ms()? { + return Err(DurableRunnerError::invalid( + "welcome carried an expired connection lease", + )); + } + let revocation_epoch = payload + .get("connectionLeaseRevocationEpoch") + .and_then(Value::as_u64) + .ok_or_else(|| DurableRunnerError::invalid("welcome revocation epoch is required"))?; + if let Some(expected) = expected_lease { + if connection_lease_id != expected.lease_id + || expires_at_unix_ms != expected.expires_at_unix_ms + || revocation_epoch != expected.revocation_epoch + { + return Err(DurableRunnerError::invalid( + "welcome changed the authenticated connection lease binding", + )); + } + } + let lease = match payload.get_mut("connectionLeaseToken") { + Some(Value::String(token)) if credential_kind == "bootstrap" && !token.is_empty() => { + let token = std::mem::take(token); + Some(LeaseCredential { + lease_id: connection_lease_id.clone(), + expires_at_unix_ms, + revocation_epoch, + token: Secret::new(token), + }) + } + None | Some(Value::Null) if credential_kind == "lease" => None, + _ => { + return Err(DurableRunnerError::invalid( + "bootstrap welcome must exchange the ticket for a connection lease token", + )) + } + }; + let pending_commands = payload + .get("pendingCommands") + .and_then(Value::as_array) + .map(|commands| { + commands + .iter() + .cloned() + .map(|command| { + serde_json::from_value(command).map_err(|error| { + DurableRunnerError::invalid(format!("invalid pending command: {error}")) + }) + }) + .collect::, _>>() + }) + .transpose()? + .unwrap_or_default(); + Ok(Welcome { + connection: ConnectionMetadata { + connection_id, + lease_id: connection_lease_id, + expires_at_unix_ms, + revocation_epoch, + }, + lease, + acked_source_seq: payload.get("ackedSourceSeq").and_then(Value::as_u64), + pending_commands, + }) +} + +pub(crate) fn validate_control_identity( + value: &Value, + state: &DurableState, + connection: Option<&ConnectionMetadata>, +) -> Result<(), DurableRunnerError> { + if value.get("protocol").and_then(Value::as_str) != Some(PROTOCOL) + || value.get("version").and_then(Value::as_u64) != Some(PROTOCOL_VERSION) + { + return Err(DurableRunnerError::invalid( + "control envelope protocol identity is invalid", + )); + } + for (field, expected) in [ + ("runnerInstanceId", state.runner_instance_id.as_str()), + ("environmentLeaseId", state.environment_lease_id.as_str()), + ("runId", state.run_id.as_str()), + ("normalizedSessionId", state.normalized_session_id.as_str()), + ("turnId", state.turn_id.as_str()), + ("itemId", state.item_id.as_str()), + ] { + if required_string(value, field)? != expected { + return Err(DurableRunnerError::invalid(format!( + "control envelope {field} does not match the authenticated session" + ))); + } + } + if let Some(connection) = connection { + if required_string(value, "connectionId")? != connection.connection_id + || required_string(value, "connectionLeaseId")? != connection.lease_id + || current_unix_ms()? >= connection.expires_at_unix_ms + { + return Err(DurableRunnerError::invalid( + "control envelope connection lease is mismatched or expired", + )); + } + } + Ok(()) +} + +fn validate_envelope_kind(value: &Value, kind: &str) -> Result<(), DurableRunnerError> { + if value.get("protocol").and_then(Value::as_str) != Some(PROTOCOL) + || value.get("version").and_then(Value::as_u64) != Some(PROTOCOL_VERSION) + || value.get("kind").and_then(Value::as_str) != Some(kind) + { + return Err(DurableRunnerError::invalid(format!( + "expected a PRP v1 {kind} envelope" + ))); + } + Ok(()) +} + +fn required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, DurableRunnerError> { + value + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| DurableRunnerError::invalid(format!("{field} is required"))) +} + +fn send_plain( + socket: &mut WebSocket, + value: &Value, + max_frame_bytes: usize, +) -> Result<(), DurableRunnerError> { + let bytes = serde_json::to_vec(value) + .map_err(|error| DurableRunnerError::invalid(error.to_string()))?; + if bytes.len() > max_frame_bytes { + return Err(DurableRunnerError::invalid( + "outbound WebSocket message exceeds the configured limit", + )); + } + let text = + String::from_utf8(bytes).map_err(|error| DurableRunnerError::invalid(error.to_string()))?; + socket + .send(Message::Text(text.into())) + .map_err(map_websocket_error) +} + +fn receive_plain( + socket: &mut WebSocket, + max_frame_bytes: usize, +) -> Result { + receive_plain_optional(socket, max_frame_bytes)? + .ok_or_else(|| DurableRunnerError::invalid("WebSocket message timed out")) +} + +fn receive_plain_optional( + socket: &mut WebSocket, + max_frame_bytes: usize, +) -> Result, DurableRunnerError> { + loop { + match socket.read() { + Ok(Message::Text(text)) => { + if text.len() > max_frame_bytes { + return Err(DurableRunnerError::invalid( + "inbound WebSocket message exceeds the configured limit", + )); + } + return serde_json::from_slice(text.as_bytes()) + .map(Some) + .map_err(|error| { + DurableRunnerError::invalid(format!("malformed WebSocket JSON: {error}")) + }); + } + Ok(Message::Ping(payload)) => socket + .send(Message::Pong(payload)) + .map_err(map_websocket_error)?, + Ok(Message::Pong(_)) => {} + Ok(Message::Close(_)) => { + return Err(DurableRunnerError::invalid( + "WebSocket peer closed the connection", + )) + } + Ok(_) => { + return Err(DurableRunnerError::invalid( + "binary and continuation WebSocket messages are not accepted", + )) + } + Err(tungstenite::Error::Io(error)) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => + { + return Ok(None) + } + Err(error) => return Err(map_websocket_error(error)), + } + } +} + +fn map_websocket_error(error: tungstenite::Error) -> DurableRunnerError { + DurableRunnerError::invalid(format!("WebSocket transport failed: {error}")) +} + +fn random_nonce() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes).map_err(|error| { + DurableRunnerError::invalid(format!("secure randomness failed: {error}")) + })?; + Ok(hex_encode(&bytes)) +} + +pub(crate) fn current_unix_ms() -> Result { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| DurableRunnerError::invalid(format!("system clock is invalid: {error}")))? + .as_millis(); + u64::try_from(millis).map_err(|_| DurableRunnerError::invalid("system clock overflowed")) +} + +fn digest_domain(domain: &str, parts: &[&[u8]]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update((domain.len() as u64).to_be_bytes()); + digest.update(domain.as_bytes()); + for part in parts { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part); + } + digest.finalize().into() +} + +fn hmac_domain(key: &[u8], domain: &str, parts: &[&[u8]]) -> [u8; 32] { + let mut mac = + ::new_from_slice(key).expect("HMAC accepts keys of every length"); + mac.update(&(domain.len() as u64).to_be_bytes()); + mac.update(domain.as_bytes()); + for part in parts { + mac.update(&(part.len() as u64).to_be_bytes()); + mac.update(part); + } + mac.finalize().into_bytes().into() +} + +fn verify_hmac_hex( + key: &[u8], + domain: &str, + parts: &[&[u8]], + expected: &str, +) -> Result<(), DurableRunnerError> { + let expected = hex_decode(expected)?; + let mut mac = + ::new_from_slice(key).expect("HMAC accepts keys of every length"); + mac.update(&(domain.len() as u64).to_be_bytes()); + mac.update(domain.as_bytes()); + for part in parts { + mac.update(&(part.len() as u64).to_be_bytes()); + mac.update(part); + } + mac.verify_slice(&expected) + .map_err(|_| DurableRunnerError::invalid("transport authentication proof is invalid")) +} + +fn hex_encode(input: &[u8]) -> String { + input.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn hex_decode(input: &str) -> Result, DurableRunnerError> { + if !input.len().is_multiple_of(2) { + return Err(DurableRunnerError::invalid("hex value has an odd length")); + } + input + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let high = hex_nibble(pair[0])?; + let low = hex_nibble(pair[1])?; + Ok(high << 4 | low) + }) + .collect() +} + +fn hex_nibble(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => Err(DurableRunnerError::invalid( + "hex value contains invalid characters", + )), + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, TcpListener}; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::thread; + + use tungstenite::accept; + + use super::*; + + fn config(port: u16) -> DurableRunnerConfig { + DurableRunnerConfig { + connect_url: format!("ws://127.0.0.1:{port}/api/runner/v1/connect/run_1"), + state_dir: PathBuf::from("unused"), + runner_instance_id: "runner_1".to_owned(), + environment_lease_id: "environment_1".to_owned(), + run_id: "run_1".to_owned(), + normalized_session_id: "session_1".to_owned(), + turn_id: "turn_1".to_owned(), + item_id: "item_1".to_owned(), + runner_version: "0.0.0".to_owned(), + runner_digest: "sha256:test".to_owned(), + max_outbox_bytes: 64 * 1024, + p0_reserve_bytes: 4096, + max_frame_bytes: 64 * 1024, + reconnect_delay: Duration::from_millis(1), + max_runtime: Duration::from_secs(1), + } + } + + fn test_state(config: &DurableRunnerConfig) -> DurableState { + DurableState::new(config) + } + + struct ServerCredential<'a> { + token: &'a str, + kind: &'a str, + lease_id: Option<&'a str>, + expires_at_unix_ms: u64, + revocation_epoch: u64, + } + + fn server_authenticate( + socket: &mut WebSocket, + config: &DurableRunnerConfig, + state: &DurableState, + server_credential: ServerCredential<'_>, + ) -> SecureChannel { + let hello = receive_plain(socket, config.max_frame_bytes).unwrap(); + let payload = hello.get("payload").unwrap(); + let credential = CredentialMaterial::from_token(server_credential.token); + assert_eq!(payload["credentialId"], credential.credential_id); + assert_eq!(payload["credentialKind"], server_credential.kind); + let mut challenge = AuthChallenge { + credential_id: credential.credential_id.clone(), + credential_kind: server_credential.kind.to_owned(), + client_nonce: payload["clientNonce"].as_str().unwrap().to_owned(), + server_nonce: random_nonce().unwrap(), + runner_instance_id: state.runner_instance_id.clone(), + environment_lease_id: state.environment_lease_id.clone(), + run_id: state.run_id.clone(), + normalized_session_id: state.normalized_session_id.clone(), + turn_id: state.turn_id.clone(), + item_id: state.item_id.clone(), + runner_version: config.runner_version.clone(), + runner_digest: config.runner_digest.clone(), + selected_version: PROTOCOL_VERSION, + credential_expires_at_unix_ms: server_credential.expires_at_unix_ms, + credential_lease_id: server_credential.lease_id.map(str::to_owned), + revocation_epoch: server_credential.revocation_epoch, + server_proof: String::new(), + }; + let signing = challenge_signing_bytes(&challenge); + challenge.server_proof = hex_encode(&hmac_domain( + &credential.auth_key, + "paperclip-runner-server-proof-v1", + &[&signing], + )); + send_plain( + socket, + &json!({ + "protocol": PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": "auth_challenge", + "payload": { + "credentialId": &challenge.credential_id, + "credentialKind": &challenge.credential_kind, + "clientNonce": &challenge.client_nonce, + "serverNonce": &challenge.server_nonce, + "runnerInstanceId": &challenge.runner_instance_id, + "environmentLeaseId": &challenge.environment_lease_id, + "runId": &challenge.run_id, + "normalizedSessionId": &challenge.normalized_session_id, + "turnId": &challenge.turn_id, + "itemId": &challenge.item_id, + "runnerVersion": &challenge.runner_version, + "runnerDigest": &challenge.runner_digest, + "selectedVersion": challenge.selected_version, + "credentialExpiresAtUnixMs": challenge.credential_expires_at_unix_ms, + "credentialLeaseId": &challenge.credential_lease_id, + "revocationEpoch": challenge.revocation_epoch, + "serverProof": &challenge.server_proof, + }, + }), + config.max_frame_bytes, + ) + .unwrap(); + let response = receive_plain(socket, config.max_frame_bytes).unwrap(); + let client_proof = response["payload"]["clientProof"] + .as_str() + .unwrap() + .to_owned(); + verify_hmac_hex( + &credential.auth_key, + "paperclip-runner-client-proof-v1", + &[&signing, challenge.server_proof.as_bytes()], + &client_proof, + ) + .unwrap(); + SecureChannel::server( + &credential.auth_key, + &signing, + challenge.server_proof.as_bytes(), + client_proof.as_bytes(), + ) + .unwrap() + } + + fn send_secure( + socket: &mut WebSocket, + secure: &mut SecureChannel, + config: &DurableRunnerConfig, + value: &Value, + ) { + let encrypted = secure + .encrypt(&serde_json::to_vec(value).unwrap(), true) + .unwrap(); + send_plain(socket, &encrypted, config.max_frame_bytes).unwrap(); + } + + fn receive_secure( + socket: &mut WebSocket, + secure: &mut SecureChannel, + config: &DurableRunnerConfig, + ) -> Value { + let frame = receive_plain(socket, config.max_frame_bytes).unwrap(); + secure.decrypt(&frame, true).unwrap() + } + + fn welcome( + state: &DurableState, + connection_id: &str, + lease_token: Option<&str>, + expires_at_unix_ms: u64, + acked_source_seq: u64, + pending_commands: Vec, + ) -> Value { + json!({ + "protocol": PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": "welcome", + "runnerInstanceId": state.runner_instance_id, + "environmentLeaseId": state.environment_lease_id, + "runId": state.run_id, + "normalizedSessionId": state.normalized_session_id, + "turnId": state.turn_id, + "itemId": state.item_id, + "connectionId": connection_id, + "connectionLeaseId": "lease_1", + "payload": { + "selectedVersion": PROTOCOL_VERSION, + "connectionLeaseId": "lease_1", + "connectionLeaseToken": lease_token, + "connectionLeaseExpiresAtUnixMs": expires_at_unix_ms, + "connectionLeaseRevocationEpoch": 1, + "ackedSourceSeq": acked_source_seq, + "pendingCommands": pending_commands, + }, + }) + } + + fn control(state: &DurableState, connection_id: &str, kind: &str, payload: Value) -> Value { + json!({ + "protocol": PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": kind, + "runnerInstanceId": state.runner_instance_id, + "environmentLeaseId": state.environment_lease_id, + "runId": state.run_id, + "normalizedSessionId": state.normalized_session_id, + "turnId": state.turn_id, + "itemId": state.item_id, + "connectionId": connection_id, + "connectionLeaseId": "lease_1", + "payload": payload, + }) + } + + #[test] + fn url_resolution_rejects_non_loopback_and_ambiguous_inputs() { + let public = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), 80); + assert!( + resolve_ws_target_with("ws://example.test:80/path", |_, _| Ok(vec![public])).is_err() + ); + for input in [ + "wss://127.0.0.1:80/path", + "ws://user@127.0.0.1:80/path", + "ws://127.0.0.1/path", + "ws://127.0.0.1:80/path?ticket=secret", + ] { + assert!( + resolve_ws_target_with(input, |_, _| Ok(vec![])).is_err(), + "{input}" + ); + } + } + + #[test] + fn secure_frames_reject_replay_and_tampering() { + let key = [7_u8; 32]; + let mut client = SecureChannel::client(&key, b"challenge", b"server", b"client").unwrap(); + let mut server = SecureChannel::server(&key, b"challenge", b"server", b"client").unwrap(); + let frame = client.encrypt(br#"{"ok":true}"#, false).unwrap(); + assert_eq!(server.decrypt(&frame, true).unwrap(), json!({"ok": true})); + assert!(server.decrypt(&frame, true).is_err()); + + let mut client = SecureChannel::client(&key, b"challenge", b"server", b"client").unwrap(); + let mut server = SecureChannel::server(&key, b"challenge", b"server", b"client").unwrap(); + let mut frame = client.encrypt(br#"{"ok":true}"#, false).unwrap(); + frame["ciphertext"] = Value::String("00".repeat(32)); + assert!(server.decrypt(&frame, true).is_err()); + assert!(hex_decode("éé").is_err()); + assert!(server.decrypt(&json!({"kind": "command"}), true).is_err()); + } + + #[test] + fn control_identity_mismatch_fails_closed() { + let config = config(3000); + let state = test_state(&config); + let mut envelope = control(&state, "connection_1", "ack", json!({"ackedSourceSeq": 0})); + let connection = ConnectionMetadata { + connection_id: "connection_1".to_owned(), + lease_id: "lease_1".to_owned(), + expires_at_unix_ms: current_unix_ms().unwrap() + 60_000, + revocation_epoch: 1, + }; + validate_control_identity(&envelope, &state, Some(&connection)).unwrap(); + envelope["runId"] = Value::String("run_from_another_binding".to_owned()); + assert!(validate_control_identity(&envelope, &state, Some(&connection)).is_err()); + } + + #[test] + fn authenticates_bootstrap_and_receives_bound_lease() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let config = config(port); + let state = test_state(&config); + let server_config = config.clone(); + let server_state = state.clone(); + let handle = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut socket = accept(stream).unwrap(); + let hello = receive_plain(&mut socket, server_config.max_frame_bytes).unwrap(); + let payload = hello.get("payload").unwrap(); + let credential = CredentialMaterial::from_token("bootstrap-secret"); + assert_eq!(payload["credentialId"], credential.credential_id); + let expires = current_unix_ms().unwrap() + 60_000; + let mut challenge = AuthChallenge { + credential_id: credential.credential_id.clone(), + credential_kind: "bootstrap".to_owned(), + client_nonce: payload["clientNonce"].as_str().unwrap().to_owned(), + server_nonce: "server-nonce".to_owned(), + runner_instance_id: server_state.runner_instance_id.clone(), + environment_lease_id: server_state.environment_lease_id.clone(), + run_id: server_state.run_id.clone(), + normalized_session_id: server_state.normalized_session_id.clone(), + turn_id: server_state.turn_id.clone(), + item_id: server_state.item_id.clone(), + runner_version: server_config.runner_version.clone(), + runner_digest: server_config.runner_digest.clone(), + selected_version: PROTOCOL_VERSION, + credential_expires_at_unix_ms: expires, + credential_lease_id: None, + revocation_epoch: 0, + server_proof: String::new(), + }; + let signing = challenge_signing_bytes(&challenge); + challenge.server_proof = hex_encode(&hmac_domain( + &credential.auth_key, + "paperclip-runner-server-proof-v1", + &[&signing], + )); + send_plain( + &mut socket, + &json!({ + "protocol": PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": "auth_challenge", + "payload": { + "credentialId": challenge.credential_id, + "credentialKind": challenge.credential_kind, + "clientNonce": challenge.client_nonce, + "serverNonce": challenge.server_nonce, + "runnerInstanceId": challenge.runner_instance_id, + "environmentLeaseId": challenge.environment_lease_id, + "runId": challenge.run_id, + "normalizedSessionId": challenge.normalized_session_id, + "turnId": challenge.turn_id, + "itemId": challenge.item_id, + "runnerVersion": challenge.runner_version, + "runnerDigest": challenge.runner_digest, + "selectedVersion": challenge.selected_version, + "credentialExpiresAtUnixMs": challenge.credential_expires_at_unix_ms, + "credentialLeaseId": challenge.credential_lease_id, + "revocationEpoch": challenge.revocation_epoch, + "serverProof": challenge.server_proof, + }, + }), + server_config.max_frame_bytes, + ) + .unwrap(); + let response = receive_plain(&mut socket, server_config.max_frame_bytes).unwrap(); + let client_proof = response["payload"]["clientProof"].as_str().unwrap(); + verify_hmac_hex( + &credential.auth_key, + "paperclip-runner-client-proof-v1", + &[&signing, challenge.server_proof.as_bytes()], + client_proof, + ) + .unwrap(); + let mut secure = SecureChannel::server( + &credential.auth_key, + &signing, + challenge.server_proof.as_bytes(), + client_proof.as_bytes(), + ) + .unwrap(); + let welcome = json!({ + "protocol": PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": "welcome", + "runnerInstanceId": server_state.runner_instance_id, + "environmentLeaseId": server_state.environment_lease_id, + "runId": server_state.run_id, + "normalizedSessionId": server_state.normalized_session_id, + "turnId": server_state.turn_id, + "itemId": server_state.item_id, + "connectionId": "connection_1", + "connectionLeaseId": "lease_1", + "payload": { + "selectedVersion": PROTOCOL_VERSION, + "connectionLeaseId": "lease_1", + "connectionLeaseToken": "lease-secret", + "connectionLeaseExpiresAtUnixMs": expires, + "connectionLeaseRevocationEpoch": 1, + "ackedSourceSeq": 0, + "pendingCommands": [], + }, + }); + let encrypted = secure + .encrypt(&serde_json::to_vec(&welcome).unwrap(), true) + .unwrap(); + send_plain(&mut socket, &encrypted, server_config.max_frame_bytes).unwrap(); + }); + + let target = ResolvedWsTarget::resolve(&config.connect_url).unwrap(); + let ticket = BootstrapTicket::new("bootstrap-secret".to_owned()).unwrap(); + let (_, welcome) = + AuthenticatedTransport::connect(&target, &config, &state, Some(&ticket), None).unwrap(); + assert_eq!(welcome.connection.lease_id, "lease_1"); + assert_eq!(welcome.lease.unwrap().expose().unwrap(), "lease-secret"); + handle.join().unwrap(); + } + + #[test] + fn reconnect_replays_unacked_events_and_not_command_effects() { + struct EventExecutor { + session_open_calls: Arc, + shutdown_calls: Arc, + } + + impl super::super::CommandExecutor for EventExecutor { + fn execute( + &mut self, + command: &Command, + ) -> Result { + if command.command_type == "session.open" { + let calls = self.session_open_calls.fetch_add(1, Ordering::SeqCst) + 1; + return Ok(super::super::CommandExecution { + result: json!({"status": "completed", "calls": calls}), + events: vec![( + "provider.notice.recorded".to_owned(), + super::super::EventPriority::P1, + json!({"message": "durable event"}), + )], + }); + } + Ok(super::super::CommandExecution::result( + json!({"status": "completed"}), + )) + } + + fn shutdown(&mut self) -> Result<(), DurableRunnerError> { + self.shutdown_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let mut config = config(port); + config.max_runtime = Duration::from_secs(5); + let directory = std::env::temp_dir().join(format!( + "paperclip-runner-reconnect-fault-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&directory); + config.state_dir = directory.clone(); + let state = test_state(&config); + let expires = current_unix_ms().unwrap() + 60_000; + let open_command = json!({ + "schema": "paperclip.prp.command.v1", + "commandId": "command_open", + "controllerSeq": 1, + "type": "session.open", + "issuedAt": "2026-08-24T00:00:00.000Z", + "payload": {}, + }); + let shutdown_command = json!({ + "schema": "paperclip.prp.command.v1", + "commandId": "command_shutdown", + "controllerSeq": 2, + "type": "runner.shutdown", + "issuedAt": "2026-08-24T00:00:01.000Z", + "payload": {}, + }); + let server_config = config.clone(); + let server_state = state.clone(); + let server_open = open_command.clone(); + let server = thread::spawn(move || { + let (first_stream, _) = listener.accept().unwrap(); + let mut first = accept(first_stream).unwrap(); + let mut first_secure = server_authenticate( + &mut first, + &server_config, + &server_state, + ServerCredential { + token: "bootstrap-secret", + kind: "bootstrap", + lease_id: None, + expires_at_unix_ms: expires, + revocation_epoch: 0, + }, + ); + send_secure( + &mut first, + &mut first_secure, + &server_config, + &welcome( + &server_state, + "connection_1", + Some("lease-secret"), + expires, + 0, + vec![server_open.clone()], + ), + ); + let first_result = receive_secure(&mut first, &mut first_secure, &server_config); + let first_event = receive_secure(&mut first, &mut first_secure, &server_config); + assert_eq!(first_result["kind"], "command_result"); + assert_eq!(first_event["kind"], "event"); + drop(first); + + let (second_stream, _) = listener.accept().unwrap(); + let mut second = accept(second_stream).unwrap(); + let mut second_secure = server_authenticate( + &mut second, + &server_config, + &server_state, + ServerCredential { + token: "lease-secret", + kind: "lease", + lease_id: Some("lease_1"), + expires_at_unix_ms: expires, + revocation_epoch: 1, + }, + ); + send_secure( + &mut second, + &mut second_secure, + &server_config, + &welcome( + &server_state, + "connection_2", + None, + expires, + 0, + vec![server_open], + ), + ); + let replayed_result = receive_secure(&mut second, &mut second_secure, &server_config); + let replayed_event = receive_secure(&mut second, &mut second_secure, &server_config); + assert_eq!(replayed_result, first_result); + assert_eq!(replayed_event, first_event); + send_secure( + &mut second, + &mut second_secure, + &server_config, + &control( + &server_state, + "connection_2", + "ack", + json!({"ackedSourceSeq": 1}), + ), + ); + send_secure( + &mut second, + &mut second_secure, + &server_config, + &control(&server_state, "connection_2", "command", shutdown_command), + ); + let shutdown_result = receive_secure(&mut second, &mut second_secure, &server_config); + assert_eq!(shutdown_result["kind"], "command_result"); + }); + + let session_open_calls = Arc::new(AtomicUsize::new(0)); + let shutdown_calls = Arc::new(AtomicUsize::new(0)); + super::super::run_durable_runner( + config, + BootstrapTicket::new("bootstrap-secret".to_owned()).unwrap(), + EventExecutor { + session_open_calls: session_open_calls.clone(), + shutdown_calls: shutdown_calls.clone(), + }, + ) + .unwrap(); + server.join().unwrap(); + assert_eq!(session_open_calls.load(Ordering::SeqCst), 1); + assert_eq!(shutdown_calls.load(Ordering::SeqCst), 1); + let store = super::super::DurableStateStore::new(&directory).unwrap(); + let state_bytes = std::fs::read(store.path()).unwrap(); + let final_state: DurableState = serde_json::from_slice(&state_bytes).unwrap(); + assert_eq!(final_state.acked_source_seq, 1); + assert!(final_state.outbox.is_empty()); + assert_eq!(final_state.reconnect_count, 1); + std::fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/fake_harness.rs b/packages/paperclip-runner/runner/crates/runner-core/src/fake_harness.rs new file mode 100644 index 00000000000..c5f91a2535c --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/fake_harness.rs @@ -0,0 +1,327 @@ +use std::io::{Read, Write}; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::local_runner::{HarnessCommand, LocalRunnerError}; +use crate::process_supervisor::{read_bounded_line, BoundedLine}; + +const HARNESS_COMMAND_SCHEMA: &str = "paperclip.fake_harness.command.v1"; +const HARNESS_MESSAGE_SCHEMA: &str = "paperclip.fake_harness.message.v1"; +const HARNESS_COMMAND_TIMEOUT: Duration = Duration::from_secs(10); +const INTERACTIVE_REQUEST_TIMEOUT: Duration = Duration::from_secs(5 * 60); +const HARNESS_COMMAND_QUEUE_CAPACITY: usize = 256; +const HARNESS_MAX_COMMAND_BYTES: usize = 64 * 1024; +const HARNESS_MAX_SCRIPT_BYTES: u64 = 1024 * 1024; +const HARNESS_MAX_SCRIPT_STEPS: usize = 4096; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FakeHarnessScript { + pub schema: String, + pub name: String, + pub steps: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum FakeHarnessStep { + Delay { + milliseconds: u64, + }, + Log { + stream: String, + text: String, + }, + Event { + event_type: String, + #[serde(default)] + turn_id: Option, + #[serde(default)] + item_id: Option, + #[serde(default)] + payload: Value, + }, + Request { + request: Value, + }, + AwaitResolution { + request_id: String, + }, + AwaitInterrupt, + Result { + payload: Value, + }, + Terminal { + payload: Value, + }, + SpawnWorker, + Exit { + code: i32, + }, +} + +pub fn load_fake_harness_script(path: &Path) -> Result { + let file = std::fs::File::open(path).map_err(|error| { + LocalRunnerError::invalid(format!( + "failed to read fake harness script {}: {error}", + path.display() + )) + })?; + let mut input = String::new(); + file.take(HARNESS_MAX_SCRIPT_BYTES + 1) + .read_to_string(&mut input) + .map_err(|error| { + LocalRunnerError::invalid(format!( + "failed to read fake harness script {}: {error}", + path.display() + )) + })?; + if input.len() as u64 > HARNESS_MAX_SCRIPT_BYTES { + return Err(LocalRunnerError::invalid(format!( + "fake harness script cannot exceed {HARNESS_MAX_SCRIPT_BYTES} bytes" + ))); + } + let script: FakeHarnessScript = serde_json::from_str(&input).map_err(|error| { + LocalRunnerError::invalid(format!("fake harness script must be valid JSON: {error}")) + })?; + if script.schema != "paperclip.fake_harness.script.v1" { + return Err(LocalRunnerError::invalid( + "fake harness script schema is unsupported", + )); + } + if script.name.trim().is_empty() || script.name.len() > 160 || script.steps.is_empty() { + return Err(LocalRunnerError::invalid( + "fake harness script requires a name of at most 160 bytes and at least one step", + )); + } + if script.steps.len() > HARNESS_MAX_SCRIPT_STEPS { + return Err(LocalRunnerError::invalid(format!( + "fake harness script cannot exceed {HARNESS_MAX_SCRIPT_STEPS} steps" + ))); + } + Ok(script) +} + +pub fn run_fake_harness( + script: FakeHarnessScript, + delay_override_ms: Option, +) -> Result { + let (command_sender, command_receiver) = + mpsc::sync_channel::(HARNESS_COMMAND_QUEUE_CAPACITY); + thread::spawn(move || { + let stdin = std::io::stdin(); + let mut reader = stdin.lock(); + loop { + match read_bounded_line(&mut reader, HARNESS_MAX_COMMAND_BYTES) { + Ok(BoundedLine::Line(line)) if line.trim().is_empty() => {} + Ok(BoundedLine::Line(line)) => { + if let Ok(command) = serde_json::from_str::(&line) { + if command_sender.send(command).is_err() { + return; + } + } + } + Ok(BoundedLine::TooLong) => {} + Ok(BoundedLine::Eof) | Err(_) => return, + } + } + }); + + let mut output = FakeHarnessOutput::default(); + output.send( + "ready", + json!({ "version": "1.0.0", "script": script.name }), + )?; + receive_harness_command(&command_receiver, "session.open", None)?; + output.send_event( + "session.started", + None, + None, + json!({ "driverSessionId": "driver_local_runner_fake" }), + )?; + output.send_event( + "runtime.phase.changed", + None, + None, + json!({ "phase": "executing" }), + )?; + let turn = receive_harness_command(&command_receiver, "turn.start", None)?; + let turn_id = turn + .payload + .get("turnId") + .and_then(Value::as_str) + .unwrap_or("turn_local_runner") + .to_owned(); + output.send_event("turn.started", Some(&turn_id), None, json!({}))?; + + let mut workers = Vec::::new(); + for step in script.steps { + match step { + FakeHarnessStep::Delay { milliseconds } => { + thread::sleep(Duration::from_millis( + delay_override_ms.unwrap_or(milliseconds), + )); + } + FakeHarnessStep::Log { stream, text } => { + output.send("log", json!({ "stream": stream, "text": text }))?; + } + FakeHarnessStep::Event { + event_type, + turn_id: step_turn_id, + item_id, + payload, + } => { + output.send_event( + &event_type, + step_turn_id.as_deref().or(Some(&turn_id)), + item_id.as_deref(), + payload, + )?; + } + FakeHarnessStep::Request { request } => { + output.send("request", json!({ "request": request }))?; + } + FakeHarnessStep::AwaitResolution { request_id } => { + let command = receive_harness_command( + &command_receiver, + "request.resolve", + Some(&request_id), + )?; + output.send_event( + "runtime_request.resolved", + Some(&turn_id), + None, + json!({ + "requestId": request_id, + "response": command.payload.get("response").cloned().unwrap_or(Value::Null) + }), + )?; + } + FakeHarnessStep::AwaitInterrupt => { + let command = receive_harness_command(&command_receiver, "turn.interrupt", None)?; + output.send_event( + "turn.interrupted", + Some(&turn_id), + None, + json!({ "reason": command.payload.get("reason").cloned().unwrap_or_else(|| json!("operator")) }), + )?; + } + FakeHarnessStep::Result { payload } => { + output.send("result", json!({ "result": payload }))?; + } + FakeHarnessStep::Terminal { payload } => { + output.send("terminal", json!({ "terminal": payload }))?; + } + FakeHarnessStep::SpawnWorker => { + let worker = Command::new("sh") + .args(["-c", "sleep 60"]) + .env_clear() + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| { + LocalRunnerError::invalid(format!("failed to spawn fake worker: {error}")) + })?; + output.send( + "diagnostic", + json!({ "workerPid": worker.id(), "purpose": "cleanup_conformance" }), + )?; + workers.push(worker); + } + FakeHarnessStep::Exit { code } => { + drop(workers); + return Ok(code); + } + } + } + drop(workers); + Ok(0) +} + +fn receive_harness_command( + receiver: &Receiver, + command_type: &str, + request_id: Option<&str>, +) -> Result { + let timeout = if command_type == "request.resolve" { + INTERACTIVE_REQUEST_TIMEOUT + } else { + HARNESS_COMMAND_TIMEOUT + }; + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(LocalRunnerError::invalid(format!( + "timed out waiting for {command_type}" + ))); + } + let command = receiver.recv_timeout(remaining).map_err(|_| { + LocalRunnerError::invalid(format!("timed out waiting for {command_type}")) + })?; + if command.schema != HARNESS_COMMAND_SCHEMA { + continue; + } + if command.command_type != command_type { + continue; + } + if let Some(request_id) = request_id { + if command.payload.get("requestId").and_then(Value::as_str) != Some(request_id) { + continue; + } + } + return Ok(command); + } +} + +#[derive(Default)] +struct FakeHarnessOutput { + sequence: u64, +} + +impl FakeHarnessOutput { + fn send(&mut self, message_type: &str, payload: Value) -> Result<(), LocalRunnerError> { + self.sequence += 1; + let message = json!({ + "schema": HARNESS_MESSAGE_SCHEMA, + "messageSeq": self.sequence, + "type": message_type, + "payload": payload, + }); + let stdout = std::io::stdout(); + let mut lock = stdout.lock(); + serde_json::to_writer(&mut lock, &message).map_err(|error| { + LocalRunnerError::invalid(format!("harness message serialization failed: {error}")) + })?; + lock.write_all(b"\n") + .and_then(|_| lock.flush()) + .map_err(|error| { + LocalRunnerError::invalid(format!("harness message write failed: {error}")) + }) + } + + fn send_event( + &mut self, + event_type: &str, + turn_id: Option<&str>, + item_id: Option<&str>, + payload: Value, + ) -> Result<(), LocalRunnerError> { + self.send( + "event", + json!({ + "eventType": event_type, + "turnId": turn_id, + "itemId": item_id, + "payload": payload, + }), + ) + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs new file mode 100644 index 00000000000..9960607baba --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs @@ -0,0 +1,346 @@ +#![forbid(unsafe_code)] + +pub mod codex_provider; +pub mod durable; +pub mod fake_harness; +pub mod local_runner; +pub mod process_supervisor; +pub mod provider_backend; +pub mod provider_events; +pub mod replay; + +use std::error::Error; +use std::fmt::{self, Display, Formatter}; + +use serde::{Deserialize, Serialize}; + +pub const CONFORMANCE_FIXTURE_SCHEMA: &str = "paperclip.runner.conformance.fixture.v1"; +pub const CONFORMANCE_OUTPUT_SCHEMA: &str = "paperclip.runner.conformance.output.v1"; +pub const CONFORMANCE_FIXTURE: &str = + include_str!("../../../../protocol/fixtures/conformance-minimal-run.json"); +pub const CONFORMANCE_EXPECTED_OUTPUT: &str = + include_str!("../../../../protocol/fixtures/conformance-expected-output.json"); + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NativeRunIdentity { + pub run_id: String, + pub session_id: String, + pub company_id: String, + pub issue_id: String, + pub agent_id: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NativeRunEvent { + pub event_id: String, + pub run_id: String, + pub sequence: u64, + #[serde(rename = "type")] + pub event_type: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct NativeRunResult { + pub run_id: String, + pub status: String, + pub summary: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConformanceFixture { + pub schema_version: String, + pub run: NativeRunIdentity, + pub events: Vec, + pub result: NativeRunResult, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConformanceError(String); + +impl ConformanceError { + fn invalid(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl Display for ConformanceError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Error for ConformanceError {} + +fn validate_identifier(value: &str, path: &str) -> Result<(), ConformanceError> { + let mut characters = value.chars(); + let starts_lowercase = characters + .next() + .is_some_and(|character| character.is_ascii_lowercase()); + let remainder_is_valid = characters.all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_' + }); + + if starts_lowercase && remainder_is_valid { + Ok(()) + } else { + Err(ConformanceError::invalid(format!( + "{path} must be a stable lowercase identifier" + ))) + } +} + +pub fn validate_conformance_fixture(input: &str) -> Result { + let fixture: ConformanceFixture = serde_json::from_str(input).map_err(|error| { + ConformanceError::invalid(format!("fixture must be valid JSON: {error}")) + })?; + + if fixture.schema_version != CONFORMANCE_FIXTURE_SCHEMA { + return Err(ConformanceError::invalid(format!( + "schemaVersion must be {CONFORMANCE_FIXTURE_SCHEMA}" + ))); + } + + for (path, value) in [ + ("run.runId", fixture.run.run_id.as_str()), + ("run.sessionId", fixture.run.session_id.as_str()), + ("run.companyId", fixture.run.company_id.as_str()), + ("run.issueId", fixture.run.issue_id.as_str()), + ("run.agentId", fixture.run.agent_id.as_str()), + ] { + validate_identifier(value, path)?; + } + + if fixture.events.len() < 2 { + return Err(ConformanceError::invalid( + "events must contain at least run.started and run.completed", + )); + } + + for (index, event) in fixture.events.iter().enumerate() { + validate_identifier(&event.event_id, &format!("events[{index}].eventId"))?; + if event.run_id != fixture.run.run_id { + return Err(ConformanceError::invalid(format!( + "events[{index}].runId must match run.runId" + ))); + } + let expected_sequence = index as u64 + 1; + if event.sequence != expected_sequence { + return Err(ConformanceError::invalid(format!( + "events[{index}].sequence must be {expected_sequence}" + ))); + } + if !matches!(event.event_type.as_str(), "run.started" | "run.completed") { + return Err(ConformanceError::invalid(format!( + "events[{index}].type is unsupported" + ))); + } + } + + if fixture + .events + .first() + .map(|event| event.event_type.as_str()) + != Some("run.started") + { + return Err(ConformanceError::invalid( + "the first event must be run.started", + )); + } + if fixture.events.last().map(|event| event.event_type.as_str()) != Some("run.completed") { + return Err(ConformanceError::invalid( + "the final event must be run.completed", + )); + } + if fixture.result.run_id != fixture.run.run_id { + return Err(ConformanceError::invalid( + "result.runId must match run.runId", + )); + } + if fixture.result.status != "succeeded" { + return Err(ConformanceError::invalid( + "the Conformance fixture result must be succeeded", + )); + } + if fixture.result.summary.trim().is_empty() { + return Err(ConformanceError::invalid( + "result.summary must be a non-empty string", + )); + } + + Ok(fixture) +} + +#[derive(Default)] +pub struct MockControlPlane { + running: bool, + opened_run_id: Option, + events: Vec, + result: Option, +} + +impl MockControlPlane { + pub fn start(&mut self) -> Result<(), ConformanceError> { + if self.running { + return Err(ConformanceError::invalid( + "mock control plane is already running", + )); + } + self.running = true; + Ok(()) + } + + pub fn stop(&mut self) { + self.running = false; + } + + pub fn open_run(&mut self, identity: &NativeRunIdentity) -> Result<(), ConformanceError> { + self.require_running()?; + if self.opened_run_id.is_some() { + return Err(ConformanceError::invalid( + "mock control plane accepts one Conformance run", + )); + } + self.opened_run_id = Some(identity.run_id.clone()); + Ok(()) + } + + pub fn append_event(&mut self, event: &NativeRunEvent) -> Result { + let run_id = self.require_run_id()?; + if event.run_id != run_id { + return Err(ConformanceError::invalid( + "event runId does not match the opened run", + )); + } + let expected_sequence = self.events.len() as u64 + 1; + if event.sequence != expected_sequence { + return Err(ConformanceError::invalid(format!( + "event sequence must be {expected_sequence}" + ))); + } + self.events.push(event.clone()); + Ok(event.sequence) + } + + pub fn complete_run(&mut self, result: &NativeRunResult) -> Result<(), ConformanceError> { + let run_id = self.require_run_id()?; + if result.run_id != run_id { + return Err(ConformanceError::invalid( + "result runId does not match the opened run", + )); + } + if self.events.last().map(|event| event.event_type.as_str()) != Some("run.completed") { + return Err(ConformanceError::invalid( + "run.completed must be ingested before the result", + )); + } + if self.result.is_some() { + return Err(ConformanceError::invalid( + "mock control plane accepts one terminal result", + )); + } + self.result = Some(result.clone()); + Ok(()) + } + + fn require_running(&self) -> Result<(), ConformanceError> { + if self.running { + Ok(()) + } else { + Err(ConformanceError::invalid( + "mock control plane must be started first", + )) + } + } + + fn require_run_id(&self) -> Result<&str, ConformanceError> { + self.require_running()?; + self.opened_run_id + .as_deref() + .ok_or_else(|| ConformanceError::invalid("a run must be opened first")) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ConformanceTracerOutput<'a> { + schema_version: &'static str, + run_identity: ConformanceRunIdentityOutput<'a>, + result: ConformanceResultOutput<'a>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ConformanceRunIdentityOutput<'a> { + run_id: &'a str, + session_id: &'a str, +} + +#[derive(Serialize)] +struct ConformanceResultOutput<'a> { + status: &'a str, + summary: &'a str, +} + +pub fn run_conformance_tracer(input: &str) -> Result { + let fixture = validate_conformance_fixture(input)?; + let mut mock_core = MockControlPlane::default(); + mock_core.start()?; + + let replay_result = (|| { + mock_core.open_run(&fixture.run)?; + for event in &fixture.events { + mock_core.append_event(event)?; + } + mock_core.complete_run(&fixture.result)?; + serde_json::to_string(&ConformanceTracerOutput { + schema_version: CONFORMANCE_OUTPUT_SCHEMA, + run_identity: ConformanceRunIdentityOutput { + run_id: &fixture.run.run_id, + session_id: &fixture.run.session_id, + }, + result: ConformanceResultOutput { + status: &fixture.result.status, + summary: &fixture.result.summary, + }, + }) + .map_err(|error| ConformanceError::invalid(format!("output serialization failed: {error}"))) + })(); + + mock_core.stop(); + replay_result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_the_shared_fixture() { + let fixture = validate_conformance_fixture(CONFORMANCE_FIXTURE) + .expect("shared fixture should validate"); + + assert_eq!(fixture.events.len(), 2); + assert_eq!(fixture.events[1].sequence, 2); + assert_eq!(fixture.result.status, "succeeded"); + } + + #[test] + fn rejects_a_sequence_gap() { + let invalid = CONFORMANCE_FIXTURE.replacen(r#""sequence": 2"#, r#""sequence": 3"#, 1); + + let error = validate_conformance_fixture(&invalid).expect_err("sequence gap must fail"); + assert_eq!(error.to_string(), "events[1].sequence must be 2"); + } + + #[test] + fn runs_the_mock_core_path_with_stable_output() { + assert_eq!( + run_conformance_tracer(CONFORMANCE_FIXTURE).expect("tracer should succeed"), + CONFORMANCE_EXPECTED_OUTPUT.trim_end(), + ); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/local_runner.rs b/packages/paperclip-runner/runner/crates/runner-core/src/local_runner.rs new file mode 100644 index 00000000000..d98ccb04665 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/local_runner.rs @@ -0,0 +1,906 @@ +use std::collections::HashMap; +use std::error::Error; +use std::fmt::{self, Display, Formatter}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::process_supervisor::{ + read_bounded_line, BoundedLine, BoundedLogBuffer, ProcessExitFact, ProcessOutput, + SupervisedProcess, +}; + +const RUNNER_STREAM_SCHEMA: &str = "paperclip.runner.stream.v1"; +const RUNNER_COMMAND_SCHEMA: &str = "paperclip.prp.command.v1"; +const HARNESS_COMMAND_SCHEMA: &str = "paperclip.fake_harness.command.v1"; +const HARNESS_MESSAGE_SCHEMA: &str = "paperclip.fake_harness.message.v1"; +const CONTROLLER_COMMAND_QUEUE_CAPACITY: usize = 256; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LocalRunnerError(String); + +impl LocalRunnerError { + pub fn invalid(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl Display for LocalRunnerError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Error for LocalRunnerError {} + +#[derive(Clone, Debug)] +pub struct RunnerConfig { + pub run_id: String, + pub normalized_session_id: String, + pub runner_instance_id: String, + pub fake_harness_path: PathBuf, + pub script_path: PathBuf, + pub delay_override_ms: Option, + pub log_max_lines: usize, + pub log_max_bytes: usize, + pub command_history_limit: usize, + pub controller_max_line_bytes: usize, + pub harness_max_line_bytes: usize, + pub shutdown_grace: Duration, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RunnerCommand { + pub schema: String, + pub command_id: String, + pub controller_seq: u64, + #[serde(rename = "type")] + pub command_type: String, + pub issued_at: String, + #[serde(default)] + pub payload: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HarnessCommand { + pub schema: String, + pub command_id: String, + #[serde(rename = "type")] + pub command_type: String, + #[serde(default)] + pub payload: Value, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HarnessMessage { + schema: String, + message_seq: u64, + #[serde(rename = "type")] + message_type: String, + #[serde(default)] + payload: Value, +} + +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum RunnerStreamMessage<'a> { + Event { + event: &'a Value, + }, + CommandReceipt { + #[serde(rename = "commandId")] + command_id: &'a str, + #[serde(rename = "controllerSeq")] + controller_seq: u64, + status: &'a str, + detail: &'a str, + }, + Diagnostic { + level: &'a str, + message: &'a str, + }, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum TerminalIntent { + #[default] + Natural, + Interrupted, + Cancelled, + ControllerClosed, + ProtocolViolation, +} + +fn enum_field<'a>(value: &'a Value, key: &str, allowed: &[&str]) -> Option<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .filter(|candidate| allowed.contains(candidate)) +} + +fn valid_terminal_proposal(value: &Value) -> bool { + value.get("schema").and_then(Value::as_str) == Some("paperclip.prp.terminal.v1") + && enum_field( + value, + "turnTerminalState", + &["completed", "failed", "interrupted", "cancelled"], + ) + .is_some() + && enum_field( + value, + "runTerminalState", + &["succeeded", "failed", "cancelled"], + ) + .is_some() + && enum_field( + value, + "reportedWorkDisposition", + &["done", "blocked", "needs_review", "yielded"], + ) + .is_some() +} + +fn reconcile_terminal( + proposal: Option, + exit: &ProcessExitFact, + semantic_result: Option<&Value>, + intent: TerminalIntent, +) -> Value { + let (turn_terminal_state, run_terminal_state, reason) = match intent { + TerminalIntent::Interrupted => ("interrupted", "cancelled", "turn_interrupted"), + TerminalIntent::Cancelled => ("cancelled", "cancelled", "run_cancelled"), + TerminalIntent::ControllerClosed => ("cancelled", "cancelled", "controller_closed"), + TerminalIntent::ProtocolViolation => ("failed", "failed", "harness_protocol_violation"), + TerminalIntent::Natural if exit.success && semantic_result.is_some() => { + ("completed", "succeeded", "successful_process_and_result") + } + TerminalIntent::Natural if !exit.success => ("failed", "failed", "harness_process_failed"), + TerminalIntent::Natural => ("failed", "failed", "semantic_result_missing"), + }; + + let proposal_valid = proposal.as_ref().is_some_and(valid_terminal_proposal); + let proposal_contradicted = proposal.as_ref().is_some_and(|value| { + proposal_valid + && (value.get("turnTerminalState").and_then(Value::as_str) != Some(turn_terminal_state) + || value.get("runTerminalState").and_then(Value::as_str) + != Some(run_terminal_state)) + }); + let proposed_disposition = proposal.as_ref().and_then(|value| { + enum_field( + value, + "reportedWorkDisposition", + &["done", "blocked", "needs_review", "yielded"], + ) + .map(str::to_owned) + }); + let result_disposition = semantic_result.and_then(|value| { + enum_field( + value, + "reportedWorkDisposition", + &["done", "blocked", "needs_review", "yielded"], + ) + .map(str::to_owned) + }); + let reported_work_disposition = if run_terminal_state == "succeeded" { + result_disposition + .or(proposed_disposition) + .unwrap_or_else(|| "done".to_owned()) + } else { + result_disposition + .filter(|value| value != "done") + .or_else(|| proposed_disposition.filter(|value| value != "done")) + .unwrap_or_else(|| "yielded".to_owned()) + }; + let harness_terminal_proposal = proposal.unwrap_or(Value::Null); + + json!({ + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": turn_terminal_state, + "runTerminalState": run_terminal_state, + "reportedWorkDisposition": reported_work_disposition, + "harnessTerminalProposal": harness_terminal_proposal, + "reconciliation": { + "authority": "runner", + "reason": reason, + "proposalValid": proposal_valid, + "proposalContradicted": proposal_contradicted, + "processExit": exit, + "semanticResultObserved": semantic_result.is_some(), + } + }) +} + +struct RunnerState { + config: RunnerConfig, + source_seq: u64, + next_controller_seq: u64, + commands: HashMap, + next_harness_message_seq: u64, + harness: Option, + logs: BoundedLogBuffer, + semantic_result: Option, + pending_terminal: Option, + terminal_intent: TerminalIntent, + terminal_emitted: bool, + stdout_closed: bool, +} + +impl RunnerState { + fn new(config: RunnerConfig) -> Self { + Self { + logs: BoundedLogBuffer::new(config.log_max_lines, config.log_max_bytes), + config, + source_seq: 0, + next_controller_seq: 1, + commands: HashMap::new(), + next_harness_message_seq: 1, + harness: None, + semantic_result: None, + pending_terminal: None, + terminal_intent: TerminalIntent::Natural, + terminal_emitted: false, + stdout_closed: false, + } + } + + fn write_stream(&self, message: &RunnerStreamMessage<'_>) -> Result<(), LocalRunnerError> { + let stdout = std::io::stdout(); + let mut lock = stdout.lock(); + let envelope = json!({ + "schema": RUNNER_STREAM_SCHEMA, + "message": message, + }); + serde_json::to_writer(&mut lock, &envelope).map_err(|error| { + LocalRunnerError::invalid(format!("stream serialization failed: {error}")) + })?; + lock.write_all(b"\n") + .and_then(|_| lock.flush()) + .map_err(|error| LocalRunnerError::invalid(format!("stream write failed: {error}"))) + } + + fn command_receipt( + &self, + command: &RunnerCommand, + status: &str, + detail: &str, + ) -> Result<(), LocalRunnerError> { + self.write_stream(&RunnerStreamMessage::CommandReceipt { + command_id: &command.command_id, + controller_seq: command.controller_seq, + status, + detail, + }) + } + + fn diagnostic(&self, level: &str, message: &str) -> Result<(), LocalRunnerError> { + self.write_stream(&RunnerStreamMessage::Diagnostic { level, message }) + } + + fn emit_event( + &mut self, + event_type: &str, + payload: Value, + turn_id: Option<&str>, + item_id: Option<&str>, + ) -> Result<(), LocalRunnerError> { + if self.terminal_emitted { + return Ok(()); + } + self.source_seq += 1; + let mut event = json!({ + "schema": "paperclip.prp.event.v1", + "sourceEventId": format!("event_{}_{:03}", self.config.run_id, self.source_seq), + "sourceSeq": self.source_seq, + "sourceInstanceId": self.config.runner_instance_id, + "sourceKind": "runner", + "runId": self.config.run_id, + "normalizedSessionId": self.config.normalized_session_id, + "eventType": event_type, + "schemaVersion": 1, + "priority": event_priority(event_type), + "emittedAt": format!("2026-08-07T21:00:{:02}.{:03}Z", (self.source_seq / 1000) % 60, self.source_seq % 1000), + "payload": payload, + }); + if let Some(turn_id) = turn_id { + event["turnId"] = Value::String(turn_id.to_owned()); + } + if let Some(item_id) = item_id { + event["itemId"] = Value::String(item_id.to_owned()); + } + self.write_stream(&RunnerStreamMessage::Event { event: &event })?; + if event_type == "run.terminal" { + self.terminal_emitted = true; + } + Ok(()) + } + + fn start_harness(&mut self) -> Result<(), LocalRunnerError> { + self.emit_event( + "runtime.phase.changed", + json!({ "phase": "workspace_preparing" }), + None, + None, + )?; + self.emit_event( + "workspace.ready", + json!({ "workingDirectory": "standalone-fixture" }), + None, + None, + )?; + self.emit_event( + "harness.starting", + json!({ "driverKind": "fake", "transport": "stdio_jsonl" }), + None, + None, + )?; + let mut args = vec![ + "--script".to_owned(), + self.config.script_path.display().to_string(), + ]; + if let Some(milliseconds) = self.config.delay_override_ms { + args.push("--delay-ms".to_owned()); + args.push(milliseconds.to_string()); + } + let harness = SupervisedProcess::spawn( + &self.config.fake_harness_path, + &args, + self.config.shutdown_grace, + self.config.harness_max_line_bytes, + )?; + let ready_deadline = Instant::now() + Duration::from_secs(2); + loop { + let remaining = ready_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(LocalRunnerError::invalid( + "fake harness did not become ready", + )); + } + match harness.recv_timeout(remaining) { + Ok(ProcessOutput::Stdout(line)) => { + let message = parse_harness_message(&line)?; + self.accept_harness_message_sequence(&message)?; + if message.message_type == "ready" { + self.emit_event( + "harness.ready", + json!({ + "driverKind": "fake", + "driverVersion": message.payload.get("version").cloned().unwrap_or_else(|| json!("1.0.0")), + "transport": "stdio_jsonl" + }), + None, + None, + )?; + break; + } + return Err(LocalRunnerError::invalid( + "fake harness emitted data before ready", + )); + } + Ok(ProcessOutput::Stderr(line)) => self.logs.push(format!("stderr: {line}")), + Ok(ProcessOutput::StdoutError(message)) => { + return Err(LocalRunnerError::invalid(message)); + } + Ok(ProcessOutput::StdoutClosed) => { + return Err(LocalRunnerError::invalid( + "fake harness exited before ready", + )); + } + Ok(ProcessOutput::StderrClosed) => {} + Err(RecvTimeoutError::Timeout) => { + return Err(LocalRunnerError::invalid("fake harness ready timed out")); + } + Err(RecvTimeoutError::Disconnected) => { + return Err(LocalRunnerError::invalid( + "fake harness output channel closed", + )); + } + } + } + self.harness = Some(harness); + Ok(()) + } + + fn handle_command(&mut self, command: RunnerCommand) -> Result<(), LocalRunnerError> { + if command.schema != RUNNER_COMMAND_SCHEMA { + self.command_receipt(&command, "rejected", "unsupported command schema")?; + return Ok(()); + } + let canonical = canonical_json(&serde_json::to_value(&command).map_err(|error| { + LocalRunnerError::invalid(format!("command canonicalization failed: {error}")) + })?); + if let Some(previous) = self.commands.get(&command.command_id) { + if previous == &canonical { + self.command_receipt(&command, "duplicate", "equivalent command already applied")?; + } else { + self.command_receipt( + &command, + "rejected", + "commandId was reused with different data", + )?; + } + return Ok(()); + } + if command.controller_seq != self.next_controller_seq { + self.command_receipt(&command, "rejected", "controllerSeq is not contiguous")?; + return Ok(()); + } + if self.commands.len() >= self.config.command_history_limit.max(1) { + self.command_receipt(&command, "rejected", "command history limit reached")?; + return Ok(()); + } + self.commands.insert(command.command_id.clone(), canonical); + self.next_controller_seq += 1; + + match command.command_type.as_str() { + "run.prepare" => { + if self.harness.is_some() { + self.command_receipt(&command, "rejected", "run is already prepared")?; + return Ok(()); + } + self.start_harness()?; + } + "session.open" => { + self.emit_event("session.starting", json!({}), None, None)?; + self.send_harness_command(&command)?; + } + "turn.start" => { + self.emit_event( + "turn.submitted", + json!({ "text": command.payload.get("text").cloned().unwrap_or(Value::Null) }), + command.payload.get("turnId").and_then(Value::as_str), + None, + )?; + self.send_harness_command(&command)?; + } + "request.resolve" | "session.close" => { + self.send_harness_command(&command)?; + } + "turn.interrupt" | "turn.stop" => { + self.terminal_intent = TerminalIntent::Interrupted; + self.send_harness_command(&command)?; + } + "run.cancel" => { + self.terminal_intent = TerminalIntent::Cancelled; + self.send_harness_command(&command)?; + } + unsupported => { + self.command_receipt( + &command, + "rejected", + &format!("command {unsupported} is not implemented in Local runner"), + )?; + return Ok(()); + } + } + self.command_receipt(&command, "accepted", "command applied") + } + + fn send_harness_command(&mut self, command: &RunnerCommand) -> Result<(), LocalRunnerError> { + let harness = self + .harness + .as_mut() + .ok_or_else(|| LocalRunnerError::invalid("run.prepare must start the harness first"))?; + harness.send(&HarnessCommand { + schema: HARNESS_COMMAND_SCHEMA.to_owned(), + command_id: command.command_id.clone(), + command_type: command.command_type.clone(), + payload: command.payload.clone(), + }) + } + + fn drain_harness_output(&mut self) -> Result<(), LocalRunnerError> { + loop { + let output = match self.harness.as_ref().map(SupervisedProcess::try_recv) { + Some(Ok(output)) => output, + Some(Err(mpsc::TryRecvError::Empty)) | None => return Ok(()), + Some(Err(mpsc::TryRecvError::Disconnected)) => { + self.stdout_closed = true; + return Ok(()); + } + }; + match output { + ProcessOutput::Stdout(line) => self.handle_harness_line(&line)?, + ProcessOutput::Stderr(line) => self.logs.push(format!("stderr: {line}")), + ProcessOutput::StdoutError(message) => { + self.logs.push(format!("stdout: [{message}]")); + self.terminal_intent = TerminalIntent::ProtocolViolation; + self.terminate_harness()?; + return Ok(()); + } + ProcessOutput::StdoutClosed => self.stdout_closed = true, + ProcessOutput::StderrClosed => {} + } + } + } + + fn handle_harness_line(&mut self, line: &str) -> Result<(), LocalRunnerError> { + let message = parse_harness_message(line)?; + self.accept_harness_message_sequence(&message)?; + match message.message_type.as_str() { + "event" => { + let event_type = message + .payload + .get("eventType") + .and_then(Value::as_str) + .ok_or_else(|| LocalRunnerError::invalid("harness event requires eventType"))? + .to_owned(); + let turn_id = message + .payload + .get("turnId") + .and_then(Value::as_str) + .map(str::to_owned); + let item_id = message + .payload + .get("itemId") + .and_then(Value::as_str) + .map(str::to_owned); + let payload = message + .payload + .get("payload") + .cloned() + .unwrap_or_else(|| json!({})); + self.emit_event(&event_type, payload, turn_id.as_deref(), item_id.as_deref())?; + } + "request" => { + let request = + message.payload.get("request").cloned().ok_or_else(|| { + LocalRunnerError::invalid("harness request requires request") + })?; + self.emit_event( + "runtime_request.created", + json!({ "request": request }), + None, + None, + )?; + } + "result" => { + let result = + message.payload.get("result").cloned().ok_or_else(|| { + LocalRunnerError::invalid("harness result requires result") + })?; + if self.semantic_result.is_none() { + self.semantic_result = Some(result.clone()); + self.emit_event("run.result.proposed", result, None, None)?; + } else { + self.emit_event( + "harness.diagnostic", + json!({ + "code": "duplicate_semantic_result_ignored", + "message": "Duplicate semantic result ignored; the first result remains authoritative." + }), + None, + None, + )?; + } + } + "terminal" => { + let terminal = message.payload.get("terminal").cloned().ok_or_else(|| { + LocalRunnerError::invalid("harness terminal requires terminal") + })?; + if self.pending_terminal.is_none() { + self.pending_terminal = Some(terminal); + } else { + self.emit_event( + "harness.diagnostic", + json!({ + "code": "duplicate_terminal_ignored", + "message": "Duplicate terminal event ignored; the first terminal event remains authoritative." + }), + None, + None, + )?; + } + } + "log" => { + let stream = message + .payload + .get("stream") + .and_then(Value::as_str) + .unwrap_or("stdout"); + let text = message + .payload + .get("text") + .and_then(Value::as_str) + .unwrap_or_default(); + self.logs.push(format!("{stream}: {text}")); + } + "diagnostic" => { + self.logs.push(format!("diagnostic: {}", message.payload)); + } + "ready" => { + self.diagnostic("warning", "duplicate harness ready message ignored")?; + } + unsupported => { + return Err(LocalRunnerError::invalid(format!( + "unsupported fake harness message {unsupported} at sequence {}", + message.message_seq + ))); + } + } + Ok(()) + } + + fn accept_harness_message_sequence( + &mut self, + message: &HarnessMessage, + ) -> Result<(), LocalRunnerError> { + if message.message_seq != self.next_harness_message_seq { + return Err(LocalRunnerError::invalid(format!( + "fake harness message sequence must be {}; received {}", + self.next_harness_message_seq, message.message_seq + ))); + } + self.next_harness_message_seq += 1; + Ok(()) + } + + fn finish_harness(&mut self) -> Result<(), LocalRunnerError> { + let exit = self + .harness + .as_mut() + .ok_or_else(|| LocalRunnerError::invalid("harness was not started"))? + .wait()?; + self.finalize_harness(exit) + } + + fn terminate_harness(&mut self) -> Result<(), LocalRunnerError> { + let exit = self + .harness + .as_mut() + .ok_or_else(|| LocalRunnerError::invalid("harness was not started"))? + .terminate_group()?; + self.finalize_harness(exit) + } + + fn finalize_harness(&mut self, exit: ProcessExitFact) -> Result<(), LocalRunnerError> { + self.emit_event( + "harness.exited", + json!({ + "processExit": &exit, + "semanticResultObserved": self.semantic_result.is_some(), + "logs": self.logs.snapshot(), + }), + None, + None, + )?; + let terminal = reconcile_terminal( + self.pending_terminal.take(), + &exit, + self.semantic_result.as_ref(), + self.terminal_intent, + ); + self.emit_event("run.terminal", terminal, None, None) + } +} + +pub fn run_local_runner(config: RunnerConfig) -> Result<(), LocalRunnerError> { + let controller_max_line_bytes = config.controller_max_line_bytes.max(1); + let (command_sender, command_receiver) = mpsc::sync_channel(CONTROLLER_COMMAND_QUEUE_CAPACITY); + thread::spawn(move || { + let stdin = std::io::stdin(); + let mut reader = stdin.lock(); + loop { + let command = match read_bounded_line(&mut reader, controller_max_line_bytes) { + Ok(BoundedLine::Line(line)) if line.trim().is_empty() => continue, + Ok(BoundedLine::Line(line)) => serde_json::from_str::(&line) + .map_err(|error| format!("invalid command JSON: {error}")), + Ok(BoundedLine::TooLong) => Err(format!( + "controller command exceeded {controller_max_line_bytes} bytes" + )), + Ok(BoundedLine::Eof) => return, + Err(error) => Err(format!("failed to read controller input: {error}")), + }; + if command_sender.send(command).is_err() { + return; + } + } + }); + + let mut state = RunnerState::new(config); + loop { + match command_receiver.recv_timeout(Duration::from_millis(2)) { + Ok(Ok(command)) => state.handle_command(command)?, + Ok(Err(error)) => state.diagnostic("error", &error)?, + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + if state.harness.is_none() { + return Err(LocalRunnerError::invalid( + "controller closed before run.prepare", + )); + } + state.terminal_intent = TerminalIntent::ControllerClosed; + state.terminate_harness()?; + return Ok(()); + } + } + state.drain_harness_output()?; + if state.terminal_emitted { + return Ok(()); + } + if state.stdout_closed { + state.finish_harness()?; + return Ok(()); + } + } +} + +fn parse_harness_message(line: &str) -> Result { + let message: HarnessMessage = serde_json::from_str(line).map_err(|error| { + LocalRunnerError::invalid(format!("fake harness emitted invalid JSONL: {error}")) + })?; + if message.schema != HARNESS_MESSAGE_SCHEMA { + return Err(LocalRunnerError::invalid( + "fake harness message schema is unsupported", + )); + } + Ok(message) +} + +fn event_priority(event_type: &str) -> u8 { + match event_type { + "run.result.proposed" + | "run.terminal" + | "turn.completed" + | "turn.failed" + | "turn.interrupted" + | "harness.exited" => 0, + "item.delta" | "harness.diagnostic" | "runner.diagnostic" => 2, + _ => 1, + } +} + +fn canonical_json(value: &Value) -> String { + match value { + Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(canonical_json) + .collect::>() + .join(",") + ), + Value::Object(values) => { + let mut keys = values.keys().collect::>(); + keys.sort(); + format!( + "{{{}}}", + keys.into_iter() + .map(|key| format!( + "{}:{}", + serde_json::to_string(key).expect("JSON object key should serialize"), + canonical_json(&values[key]) + )) + .collect::>() + .join(",") + ) + } + _ => value.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::BufReader; + use std::path::Path; + + use crate::fake_harness::load_fake_harness_script; + use crate::process_supervisor::{read_bounded_line, BoundedLine}; + + #[test] + fn bounded_logs_keep_only_the_tail() { + let mut logs = BoundedLogBuffer::new(2, 12); + logs.push("first"); + logs.push("second"); + logs.push("third"); + + assert_eq!(logs.snapshot().lines, vec!["second", "third"]); + assert_eq!(logs.snapshot().dropped_lines, 1); + assert!(logs.snapshot().retained_bytes <= 12); + } + + #[test] + fn bounded_line_reader_rejects_an_oversized_frame_and_recovers() { + let mut source = vec![b'x'; 4_096]; + source.extend_from_slice(b"\nnext\n"); + let mut reader = BufReader::new(std::io::Cursor::new(source)); + + assert_eq!( + read_bounded_line(&mut reader, 64).expect("oversized line should be classified"), + BoundedLine::TooLong + ); + assert_eq!( + read_bounded_line(&mut reader, 64).expect("reader should continue after the frame"), + BoundedLine::Line("next".to_owned()) + ); + } + + #[test] + fn nonzero_exit_overrides_a_success_terminal_proposal() { + let terminal = reconcile_terminal( + Some(json!({ + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "completed", + "runTerminalState": "succeeded", + "reportedWorkDisposition": "done" + })), + &ProcessExitFact { + exit_code: Some(7), + success: false, + signal: None, + }, + Some(&json!({ "reportedWorkDisposition": "done" })), + TerminalIntent::Natural, + ); + + assert_eq!(terminal["turnTerminalState"], "failed"); + assert_eq!(terminal["runTerminalState"], "failed"); + assert_eq!(terminal["reportedWorkDisposition"], "yielded"); + assert_eq!(terminal["reconciliation"]["authority"], "runner"); + assert_eq!(terminal["reconciliation"]["proposalValid"], true); + assert_eq!(terminal["reconciliation"]["proposalContradicted"], true); + assert_eq!( + terminal["harnessTerminalProposal"]["runTerminalState"], + "succeeded" + ); + } + + #[test] + fn successful_process_and_result_override_a_failed_terminal_proposal() { + let terminal = reconcile_terminal( + Some(json!({ + "schema": "paperclip.prp.terminal.v1", + "turnTerminalState": "failed", + "runTerminalState": "failed", + "reportedWorkDisposition": "yielded" + })), + &ProcessExitFact { + exit_code: Some(0), + success: true, + signal: None, + }, + Some(&json!({ "reportedWorkDisposition": "done" })), + TerminalIntent::Natural, + ); + + assert_eq!(terminal["turnTerminalState"], "completed"); + assert_eq!(terminal["runTerminalState"], "succeeded"); + assert_eq!(terminal["reportedWorkDisposition"], "done"); + assert_eq!(terminal["reconciliation"]["proposalContradicted"], true); + } + + #[test] + fn canonical_json_sorts_object_keys_without_reordering_arrays() { + assert_eq!( + canonical_json(&json!({ "z": [2, 1], "a": { "b": true } })), + r#"{"a":{"b":true},"z":[2,1]}"# + ); + } + + #[test] + fn all_local_runner_scripts_load() { + let scripts = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../protocol/fixtures/local-runner/scripts"); + for name in [ + "happy-path", + "permission-input", + "interrupted", + "error", + "duplicate-terminal", + "linger", + "oversized-line", + ] { + let script = load_fake_harness_script(&scripts.join(format!("{name}.json"))) + .expect("Local runner script should load"); + assert_eq!(script.name, name); + } + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs new file mode 100644 index 00000000000..768e7dba25d --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs @@ -0,0 +1,412 @@ +use std::collections::VecDeque; +use std::io::{self, BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender}; +use std::thread; +use std::time::{Duration, Instant}; + +#[cfg(unix)] +use std::os::unix::process::CommandExt; + +use serde::Serialize; + +use crate::local_runner::LocalRunnerError; + +const PROCESS_OUTPUT_QUEUE_CAPACITY: usize = 256; + +pub(crate) enum ProcessOutput { + Stdout(String), + Stderr(String), + StdoutError(String), + StdoutClosed, + StderrClosed, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum BoundedLine { + Line(String), + TooLong, + Eof, +} + +pub(crate) fn read_bounded_line( + reader: &mut R, + max_bytes: usize, +) -> io::Result { + let max_bytes = max_bytes.max(1); + let mut bytes = Vec::with_capacity(max_bytes.min(8 * 1024)); + let mut too_long = false; + + loop { + let buffer = reader.fill_buf()?; + if buffer.is_empty() { + if too_long { + return Ok(BoundedLine::TooLong); + } + if bytes.is_empty() { + return Ok(BoundedLine::Eof); + } + return String::from_utf8(bytes) + .map(BoundedLine::Line) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)); + } + + let newline = buffer.iter().position(|byte| *byte == b'\n'); + let content_len = newline.unwrap_or(buffer.len()); + if !too_long { + let remaining = max_bytes.saturating_sub(bytes.len()); + let copy_len = remaining.min(content_len); + bytes.extend_from_slice(&buffer[..copy_len]); + if content_len > remaining { + too_long = true; + bytes.clear(); + } + } + let consumed = newline.map_or(buffer.len(), |index| index + 1); + reader.consume(consumed); + + if newline.is_some() { + if too_long { + return Ok(BoundedLine::TooLong); + } + if bytes.last() == Some(&b'\r') { + bytes.pop(); + } + return String::from_utf8(bytes) + .map(BoundedLine::Line) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)); + } + } +} + +fn forward_bounded_output( + reader: R, + sender: SyncSender, + stdout: bool, + max_line_bytes: usize, +) { + thread::spawn(move || { + let mut reader = BufReader::new(reader); + loop { + let output = match read_bounded_line(&mut reader, max_line_bytes) { + Ok(BoundedLine::Line(line)) if stdout => ProcessOutput::Stdout(line), + Ok(BoundedLine::Line(line)) => ProcessOutput::Stderr(line), + Ok(BoundedLine::TooLong) if stdout => ProcessOutput::StdoutError(format!( + "harness stdout frame exceeded {max_line_bytes} bytes" + )), + Ok(BoundedLine::TooLong) => ProcessOutput::Stderr(format!( + "[harness stderr frame exceeded {max_line_bytes} bytes]" + )), + Ok(BoundedLine::Eof) => break, + Err(error) if stdout => { + ProcessOutput::StdoutError(format!("failed to read harness stdout: {error}")) + } + Err(error) => { + ProcessOutput::Stderr(format!("[failed to read harness stderr: {error}]")) + } + }; + let terminal_output = matches!(&output, ProcessOutput::StdoutError(_)); + if sender.send(output).is_err() || terminal_output { + return; + } + } + let closed = if stdout { + ProcessOutput::StdoutClosed + } else { + ProcessOutput::StderrClosed + }; + let _ = sender.send(closed); + }); +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessExitFact { + pub exit_code: Option, + pub success: bool, + pub signal: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BoundedLogSnapshot { + pub lines: Vec, + pub retained_bytes: usize, + pub dropped_lines: usize, +} + +#[derive(Debug)] +pub struct BoundedLogBuffer { + max_lines: usize, + max_bytes: usize, + retained_bytes: usize, + dropped_lines: usize, + lines: VecDeque, +} + +impl BoundedLogBuffer { + pub fn new(max_lines: usize, max_bytes: usize) -> Self { + Self { + max_lines: max_lines.max(1), + max_bytes: max_bytes.max(1), + retained_bytes: 0, + dropped_lines: 0, + lines: VecDeque::new(), + } + } + + pub fn push(&mut self, line: impl Into) { + let mut line = line.into(); + if line.len() > self.max_bytes { + let mut end = self.max_bytes; + while !line.is_char_boundary(end) { + end -= 1; + } + line.truncate(end); + } + self.retained_bytes += line.len(); + self.lines.push_back(line); + while self.lines.len() > self.max_lines || self.retained_bytes > self.max_bytes { + if let Some(removed) = self.lines.pop_front() { + self.retained_bytes = self.retained_bytes.saturating_sub(removed.len()); + self.dropped_lines += 1; + } else { + break; + } + } + } + + pub fn snapshot(&self) -> BoundedLogSnapshot { + BoundedLogSnapshot { + lines: self.lines.iter().cloned().collect(), + retained_bytes: self.retained_bytes, + dropped_lines: self.dropped_lines, + } + } +} + +pub struct SupervisedProcess { + child: Child, + stdin: Option, + output: Receiver, + process_group_id: u32, + shutdown_grace: Duration, + finished: bool, +} + +impl SupervisedProcess { + pub fn spawn( + program: &Path, + args: &[String], + shutdown_grace: Duration, + max_line_bytes: usize, + ) -> Result { + let mut command = Command::new(program); + command + .args(args) + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for key in [ + "PATH", + "PATHEXT", + "SystemRoot", + "WINDIR", + "HOME", + "USERPROFILE", + "LANG", + "LC_ALL", + "TMPDIR", + "TEMP", + "TMP", + "TZ", + ] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } + #[cfg(unix)] + command.process_group(0); + + let mut child = command.spawn().map_err(|error| { + LocalRunnerError::invalid(format!( + "failed to start supervised process {}: {error}", + program.display() + )) + })?; + let process_group_id = child.id(); + let stdin = child + .stdin + .take() + .ok_or_else(|| LocalRunnerError::invalid("supervised process stdin was not piped"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| LocalRunnerError::invalid("supervised process stdout was not piped"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| LocalRunnerError::invalid("supervised process stderr was not piped"))?; + let (sender, output) = mpsc::sync_channel(PROCESS_OUTPUT_QUEUE_CAPACITY); + forward_bounded_output(stdout, sender.clone(), true, max_line_bytes.max(1)); + forward_bounded_output(stderr, sender, false, max_line_bytes.max(1)); + + Ok(Self { + child, + stdin: Some(stdin), + output, + process_group_id, + shutdown_grace, + finished: false, + }) + } + + pub fn id(&self) -> u32 { + self.child.id() + } + + pub fn send(&mut self, value: &T) -> Result<(), LocalRunnerError> { + let stdin = self + .stdin + .as_mut() + .ok_or_else(|| LocalRunnerError::invalid("supervised process stdin is closed"))?; + serde_json::to_writer(&mut *stdin, value).map_err(|error| { + LocalRunnerError::invalid(format!("command serialization failed: {error}")) + })?; + stdin + .write_all(b"\n") + .and_then(|_| stdin.flush()) + .map_err(|error| { + LocalRunnerError::invalid(format!("failed to write process command: {error}")) + }) + } + + pub(crate) fn recv_timeout( + &self, + timeout: Duration, + ) -> Result { + self.output.recv_timeout(timeout) + } + + pub fn receive_stdout_line( + &self, + timeout: Duration, + ) -> Result, LocalRunnerError> { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(None); + } + match self.recv_timeout(remaining) { + Ok(ProcessOutput::Stdout(line)) => return Ok(Some(line)), + Ok(ProcessOutput::Stderr(_)) | Ok(ProcessOutput::StderrClosed) => {} + Ok(ProcessOutput::StdoutError(message)) => { + return Err(LocalRunnerError::invalid(message)); + } + Ok(ProcessOutput::StdoutClosed) => return Ok(None), + Err(RecvTimeoutError::Timeout) => return Ok(None), + Err(RecvTimeoutError::Disconnected) => { + return Err(LocalRunnerError::invalid("process output channel closed")); + } + } + } + } + + pub(crate) fn try_recv(&self) -> Result { + self.output.try_recv() + } + + pub fn try_wait(&mut self) -> Result, LocalRunnerError> { + self.child + .try_wait() + .map(|status| status.map(exit_fact)) + .map_err(|error| { + LocalRunnerError::invalid(format!("failed to inspect process: {error}")) + }) + } + + pub fn wait(&mut self) -> Result { + let status = self.child.wait().map_err(|error| { + LocalRunnerError::invalid(format!("failed to wait for process: {error}")) + })?; + // The group leader can exit while descendants remain alive. Reap the + // leader first, then clear any remaining members of its private group. + #[cfg(unix)] + signal_process_group(self.process_group_id, "KILL"); + self.finished = true; + Ok(exit_fact(status)) + } + + pub fn terminate_group(&mut self) -> Result { + self.stdin.take(); + #[cfg(unix)] + signal_process_group(self.process_group_id, "TERM"); + #[cfg(not(unix))] + let _ = self.child.kill(); + + let deadline = Instant::now() + self.shutdown_grace; + loop { + if let Some(fact) = self.try_wait()? { + #[cfg(unix)] + signal_process_group(self.process_group_id, "KILL"); + self.finished = true; + return Ok(fact); + } + if Instant::now() >= deadline { + break; + } + thread::sleep(Duration::from_millis(5)); + } + + #[cfg(unix)] + signal_process_group(self.process_group_id, "KILL"); + #[cfg(not(unix))] + let _ = self.child.kill(); + self.wait() + } +} + +impl Drop for SupervisedProcess { + fn drop(&mut self) { + if !self.finished { + let _ = self.terminate_group(); + } + } +} + +#[cfg(unix)] +fn signal_process_group(process_group_id: u32, signal: &str) { + let _ = Command::new("kill") + .args([ + format!("-{signal}"), + "--".to_owned(), + format!("-{process_group_id}"), + ]) + .env_clear() + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +fn exit_fact(status: ExitStatus) -> ProcessExitFact { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + ProcessExitFact { + exit_code: status.code(), + success: status.success(), + signal: status.signal(), + } + } + #[cfg(not(unix))] + { + ProcessExitFact { + exit_code: status.code(), + success: status.success(), + signal: None, + } + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs new file mode 100644 index 00000000000..77186cf892d --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -0,0 +1,781 @@ +use std::collections::{HashSet, VecDeque}; +use std::fs; +use std::io::{Read, Write}; +use std::path::PathBuf; + +#[cfg(unix)] +use std::fs::File; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::codex_provider::{CodexProvider, CodexProviderConfig, CodexProviderEvent}; +use crate::durable::{ + create_private_temporary_file, open_private_regular_file, verify_private_directory, Command, + CommandExecution, CommandExecutor, DurableRunnerError, EventPriority, PolledEvent, +}; +use crate::provider_events::{normalize_codex_notification, NormalizedProviderEvent}; + +const PROVIDER_STATE_SCHEMA: &str = "paperclip.runner.codex-provider-state.v1"; +const PROVIDER_STATE_FILE: &str = "codex-provider-state.json"; +const MAX_PROVIDER_STATE_BYTES: u64 = 2 * 1024 * 1024; +const MAX_EVENTS_PER_POLL: usize = 128; + +fn initial_provider_event_seq() -> u64 { + 1 +} + +fn provider_event_id(sequence: u64) -> String { + format!("codex_provider_{sequence:016}") +} + +fn provider_event_sequence(event_id: &str) -> Option { + let sequence = event_id.strip_prefix("codex_provider_")?.parse().ok()?; + (provider_event_id(sequence) == event_id).then_some(sequence) +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct CodexProviderState { + schema: String, + lifecycle: String, + config: CodexProviderConfig, + #[serde(default)] + thread_id: Option, + #[serde(default)] + provider_session_id: Option, + #[serde(default)] + active_provider_turn_id: Option, + #[serde(default)] + pending_events: VecDeque, + #[serde(default = "initial_provider_event_seq")] + next_provider_event_seq: u64, +} + +impl CodexProviderState { + fn new(config: CodexProviderConfig) -> Self { + Self { + schema: PROVIDER_STATE_SCHEMA.to_owned(), + lifecycle: "prepared".to_owned(), + config, + thread_id: None, + provider_session_id: None, + active_provider_turn_id: None, + pending_events: VecDeque::new(), + next_provider_event_seq: initial_provider_event_seq(), + } + } + + fn validate(&self) -> Result<(), DurableRunnerError> { + self.config + .validate() + .map_err(|error| DurableRunnerError::invalid(error.to_string()))?; + let mut pending_event_ids = HashSet::new(); + if self.schema != PROVIDER_STATE_SCHEMA + || !matches!( + self.lifecycle.as_str(), + "prepared" | "session_open" | "turn_active" | "provider_exited" | "closed" + ) + || self + .thread_id + .as_ref() + .is_some_and(|value| value.is_empty() || value.len() > 240) + || self + .provider_session_id + .as_ref() + .is_some_and(|value| value.is_empty() || value.len() > 240) + || self + .active_provider_turn_id + .as_ref() + .is_some_and(|value| value.is_empty() || value.len() > 240) + || (self.thread_id.is_none() + && (self.provider_session_id.is_some() + || self.active_provider_turn_id.is_some() + || matches!(self.lifecycle.as_str(), "session_open" | "turn_active"))) + || (self.lifecycle == "turn_active" && self.active_provider_turn_id.is_none()) + || (matches!( + self.lifecycle.as_str(), + "prepared" | "session_open" | "closed" + ) && self.active_provider_turn_id.is_some()) + || self.next_provider_event_seq == 0 + || self.pending_events.len() > MAX_EVENTS_PER_POLL + 1 + || self.pending_events.iter().any(|event| { + provider_event_sequence(&event.executor_event_id) + .is_none_or(|sequence| sequence >= self.next_provider_event_seq) + || !pending_event_ids.insert(event.executor_event_id.as_str()) + || event.event_type.is_empty() + || event.event_type.len() > 160 + || event.event_type.chars().any(char::is_control) + || !event.payload.is_object() + }) + { + return Err(DurableRunnerError::invalid( + "Codex provider state is malformed or inconsistent", + )); + } + Ok(()) + } + + fn push_event(&mut self, event: NormalizedProviderEvent) -> Result<(), DurableRunnerError> { + let sequence = self.next_provider_event_seq; + self.next_provider_event_seq = sequence + .checked_add(1) + .ok_or_else(|| DurableRunnerError::invalid("provider event sequence exhausted"))?; + self.pending_events.push_back(PolledEvent { + executor_event_id: provider_event_id(sequence), + event_type: event.event_type, + priority: event.priority, + payload: event.payload, + }); + Ok(()) + } + + fn extend_events( + &mut self, + events: impl IntoIterator, + ) -> Result<(), DurableRunnerError> { + for event in events { + self.push_event(event)?; + } + Ok(()) + } +} + +pub struct CodexCommandExecutor { + state_dir: PathBuf, + state: Option, + provider: Option, + restore_checked: bool, +} + +impl CodexCommandExecutor { + pub fn new(state_dir: impl Into) -> Self { + Self { + state_dir: state_dir.into(), + state: None, + provider: None, + restore_checked: false, + } + } + + fn state_path(&self) -> PathBuf { + self.state_dir.join(PROVIDER_STATE_FILE) + } + + fn restore(&mut self) -> Result<(), DurableRunnerError> { + if self.restore_checked { + return Ok(()); + } + self.restore_checked = true; + let path = self.state_path(); + let mut file = match open_private_regular_file(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(DurableRunnerError::invalid(format!( + "failed to open private Codex provider state: {error}" + ))) + } + }; + let metadata = file.metadata().map_err(|error| { + DurableRunnerError::invalid(format!("failed to inspect Codex provider state: {error}")) + })?; + if metadata.len() > MAX_PROVIDER_STATE_BYTES { + return Err(DurableRunnerError::invalid( + "Codex provider state must be a bounded regular file", + )); + } + let mut input = Vec::with_capacity(metadata.len() as usize); + file.read_to_end(&mut input).map_err(|error| { + DurableRunnerError::invalid(format!("failed to read Codex provider state: {error}")) + })?; + let state: CodexProviderState = serde_json::from_slice(&input).map_err(|error| { + DurableRunnerError::invalid(format!("Codex provider state is malformed: {error}")) + })?; + state.validate()?; + self.state = Some(state); + self.restore_provider_if_needed() + } + + fn restore_provider_if_needed(&mut self) -> Result<(), DurableRunnerError> { + let Some(state) = self.state.as_ref() else { + return Ok(()); + }; + if self.provider.is_some() + || !matches!( + state.lifecycle.as_str(), + "session_open" | "turn_active" | "provider_exited" + ) + { + return Ok(()); + } + let provider_had_exited = state.lifecycle == "provider_exited"; + let thread_id = state.thread_id.clone().ok_or_else(|| { + DurableRunnerError::invalid("recoverable Codex state omitted its thread id") + })?; + let previous_active_turn_id = state.active_provider_turn_id.clone(); + let provider = CodexProvider::start(&state.config, Some(&thread_id)).map_err(|error| { + DurableRunnerError::invalid(format!("failed to resume Codex provider: {error}")) + })?; + let recovered_active_turn_id = provider.active_provider_turn_id().map(str::to_owned); + self.provider = Some(provider); + if provider_had_exited || recovered_active_turn_id != previous_active_turn_id { + let state = self + .state + .as_mut() + .expect("Codex state remains available during recovery"); + state.active_provider_turn_id = recovered_active_turn_id.clone(); + state.lifecycle = if recovered_active_turn_id.is_some() { + "turn_active".to_owned() + } else { + "session_open".to_owned() + }; + state.push_event(NormalizedProviderEvent { + event_type: "session.reconciled".to_owned(), + priority: EventPriority::P0, + payload: json!({ + "provider": "codex", + "providerSessionId": thread_id, + "previousProviderTurnId": previous_active_turn_id, + "activeProviderTurnId": recovered_active_turn_id, + }), + })?; + self.save_state()?; + } + Ok(()) + } + + fn save_state(&self) -> Result<(), DurableRunnerError> { + let state = self + .state + .as_ref() + .ok_or_else(|| DurableRunnerError::invalid("Codex provider state is unavailable"))?; + self.persist_state(state) + } + + fn persist_state(&self, state: &CodexProviderState) -> Result<(), DurableRunnerError> { + state.validate()?; + fs::create_dir_all(&self.state_dir).map_err(|error| { + DurableRunnerError::invalid(format!( + "failed to create provider state directory: {error}" + )) + })?; + #[cfg(unix)] + fs::set_permissions(&self.state_dir, fs::Permissions::from_mode(0o700)).map_err( + |error| { + DurableRunnerError::invalid(format!( + "failed to protect provider state directory: {error}" + )) + }, + )?; + verify_private_directory(&self.state_dir)?; + let path = self.state_path(); + let bytes = serde_json::to_vec_pretty(state).map_err(|error| { + DurableRunnerError::invalid(format!( + "failed to serialize Codex provider state: {error}" + )) + })?; + if bytes.len() as u64 > MAX_PROVIDER_STATE_BYTES { + return Err(DurableRunnerError::invalid( + "Codex provider state exceeds the 2 MiB limit", + )); + } + let (temporary, mut file) = create_private_temporary_file(&path)?; + let result = (|| -> std::io::Result<()> { + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + fs::rename(&temporary, &path)?; + #[cfg(unix)] + File::open(&self.state_dir)?.sync_all()?; + Ok(()) + })(); + if let Err(error) = result { + let _ = fs::remove_file(&temporary); + return Err(DurableRunnerError::invalid(format!( + "failed to replace provider state atomically: {error}" + ))); + } + #[cfg(unix)] + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).map_err(|error| { + DurableRunnerError::invalid(format!("failed to protect provider state: {error}")) + })?; + Ok(()) + } + + fn prepare(&mut self, payload: &Value) -> Result { + let config: CodexProviderConfig = serde_json::from_value( + payload + .get("provider") + .cloned() + .ok_or_else(|| DurableRunnerError::invalid("run.prepare requires provider"))?, + ) + .map_err(|error| { + DurableRunnerError::invalid(format!("run.prepare provider is invalid: {error}")) + })?; + config + .validate() + .map_err(|error| DurableRunnerError::invalid(error.to_string()))?; + if let Some(state) = &self.state { + if state.config != config { + return Err(DurableRunnerError::invalid( + "Codex provider configuration changed across the durable run", + )); + } + if state.lifecycle == "closed" { + return Err(DurableRunnerError::invalid( + "Codex provider session is already closed", + )); + } + } else { + self.state = Some(CodexProviderState::new(config)); + self.save_state()?; + } + Ok(CommandExecution::result(json!({ + "status": "prepared", + "provider": "codex", + "driver": "codex_app_server", + }))) + } + + fn ensure_provider(&mut self) -> Result<&mut CodexProvider, DurableRunnerError> { + self.restore_provider_if_needed()?; + if self.provider.is_none() { + let state = self.state.as_ref().ok_or_else(|| { + DurableRunnerError::invalid("Codex provider has not been prepared") + })?; + if state.lifecycle == "closed" { + return Err(DurableRunnerError::invalid( + "Codex provider session is closed", + )); + } + let provider = CodexProvider::start(&state.config, state.thread_id.as_deref()) + .map_err(|error| { + DurableRunnerError::invalid(format!("failed to start Codex provider: {error}")) + })?; + self.provider = Some(provider); + } + self.provider + .as_mut() + .ok_or_else(|| DurableRunnerError::invalid("Codex provider is unavailable")) + } + + fn open_session(&mut self) -> Result { + self.restore_provider_if_needed()?; + if self + .state + .as_ref() + .is_some_and(|state| state.lifecycle == "turn_active") + { + return Err(DurableRunnerError::invalid( + "cannot open a new Codex session while a provider turn is active", + )); + } + let resumed = self + .state + .as_ref() + .and_then(|state| state.thread_id.as_ref()) + .is_some(); + let (thread_id, provider_session_id, process_id) = { + let provider = self.ensure_provider()?; + ( + provider.thread_id().to_owned(), + provider.provider_session_id().map(str::to_owned), + provider.process_id(), + ) + }; + let provider_version = { + let state = self + .state + .as_mut() + .expect("Codex state exists after provider start"); + state.thread_id = Some(thread_id.clone()); + state.provider_session_id = provider_session_id.clone(); + state.active_provider_turn_id = None; + state.lifecycle = "session_open".to_owned(); + state.config.provider_version.clone() + }; + self.save_state()?; + Ok(CommandExecution { + result: json!({ + "status": if resumed { "resumed" } else { "started" }, + "provider": "codex", + "driver": "codex_app_server", + "providerVersion": provider_version, + "providerSessionId": thread_id, + "processId": process_id, + }), + events: vec![( + if resumed { + "session.resumed" + } else { + "session.started" + } + .to_owned(), + EventPriority::P0, + json!({ + "provider": "codex", + "providerSessionId": thread_id, + "providerAccountSessionId": provider_session_id, + "processId": process_id, + }), + )], + }) + } + + fn start_turn(&mut self, payload: &Value) -> Result { + self.restore_provider_if_needed()?; + if self + .state + .as_ref() + .is_some_and(|state| state.active_provider_turn_id.is_some()) + { + return Err(DurableRunnerError::invalid( + "Codex already has an active provider turn", + )); + } + let text = payload + .get("text") + .and_then(Value::as_str) + .ok_or_else(|| DurableRunnerError::invalid("turn.start payload.text is required"))?; + let cwd = self + .state + .as_ref() + .ok_or_else(|| DurableRunnerError::invalid("Codex provider is not prepared"))? + .config + .cwd + .clone(); + let (provider_turn_id, thread_id) = { + let provider = self.ensure_provider()?; + provider.start_turn(text, &cwd).map_err(|error| { + DurableRunnerError::invalid(format!("Codex turn/start failed: {error}")) + })?; + ( + provider + .active_provider_turn_id() + .ok_or_else(|| { + DurableRunnerError::invalid("Codex turn/start omitted its turn identity") + })? + .to_owned(), + provider.thread_id().to_owned(), + ) + }; + let state = self + .state + .as_mut() + .expect("Codex state exists after turn start"); + state.active_provider_turn_id = Some(provider_turn_id.clone()); + state.lifecycle = "turn_active".to_owned(); + self.save_state()?; + Ok(CommandExecution { + result: json!({"status": "accepted", "providerTurnId": provider_turn_id}), + events: vec![( + "turn.accepted".to_owned(), + EventPriority::P0, + json!({"provider": "codex", "providerSessionId": thread_id, "providerTurnId": provider_turn_id}), + )], + }) + } + + fn interrupt_turn(&mut self, reason: &str) -> Result { + self.restore_provider_if_needed()?; + let provider_turn_id = self + .state + .as_ref() + .and_then(|state| state.active_provider_turn_id.clone()); + if provider_turn_id.is_none() { + return Ok(CommandExecution::result(json!({ + "status": "already_settled", + "reason": reason, + }))); + } + self.ensure_provider()?.interrupt_turn().map_err(|error| { + DurableRunnerError::invalid(format!("Codex turn interrupt failed: {error}")) + })?; + Ok(CommandExecution::result(json!({ + "status": "interrupt_requested", + "reason": reason, + "providerTurnId": provider_turn_id, + }))) + } + + fn steer_turn(&mut self, payload: &Value) -> Result { + let text = payload + .get("text") + .and_then(Value::as_str) + .ok_or_else(|| DurableRunnerError::invalid("turn.steer payload.text is required"))?; + self.ensure_provider()?.steer_turn(text).map_err(|error| { + DurableRunnerError::invalid(format!("Codex turn steer failed: {error}")) + })?; + Ok(CommandExecution::result(json!({"status": "steered"}))) + } + + fn resolve_request(&mut self, payload: &Value) -> Result { + let request_id = payload + .get("requestId") + .and_then(Value::as_str) + .ok_or_else(|| DurableRunnerError::invalid("request.resolve requires requestId"))?; + let response = payload + .get("response") + .ok_or_else(|| DurableRunnerError::invalid("request.resolve requires response"))?; + self.ensure_provider()? + .resolve_runtime_request(request_id, response) + .map_err(|error| { + DurableRunnerError::invalid(format!("Codex runtime response failed: {error}")) + })?; + Ok(CommandExecution { + result: json!({"status": "delivered", "requestId": request_id}), + events: vec![( + "runtime_request.resolved".to_owned(), + EventPriority::P0, + json!({"provider": "codex", "requestId": request_id, "status": "delivered"}), + )], + }) + } + + fn close_session(&mut self) -> Result { + if let Some(provider) = self.provider.as_mut() { + provider.shutdown().map_err(|error| { + DurableRunnerError::invalid(format!("failed to stop Codex provider: {error}")) + })?; + } + self.provider = None; + let state = self + .state + .as_mut() + .ok_or_else(|| DurableRunnerError::invalid("Codex provider is not prepared"))?; + state.active_provider_turn_id = None; + state.lifecycle = "closed".to_owned(); + let thread_id = state.thread_id.clone(); + self.save_state()?; + Ok(CommandExecution { + result: json!({"status": "closed", "providerSessionId": thread_id}), + events: vec![( + "session.closed".to_owned(), + EventPriority::P0, + json!({"provider": "codex", "providerSessionId": thread_id}), + )], + }) + } + + fn snapshot(&mut self) -> Result { + self.restore_provider_if_needed()?; + let state = self + .state + .as_ref() + .ok_or_else(|| DurableRunnerError::invalid("Codex provider is not prepared"))?; + Ok(CommandExecution::result(json!({ + "status": state.lifecycle, + "provider": "codex", + "driver": "codex_app_server", + "providerSessionId": state.thread_id, + "activeProviderTurnId": state.active_provider_turn_id, + }))) + } + + fn poll_provider(&mut self) -> Result<(), DurableRunnerError> { + self.restore()?; + if self + .state + .as_ref() + .is_some_and(|state| !state.pending_events.is_empty()) + { + return Ok(()); + } + if self.provider.is_none() { + return Ok(()); + } + for _ in 0..MAX_EVENTS_PER_POLL { + let event = self + .provider + .as_mut() + .expect("provider remains present while polling") + .poll() + .map_err(|error| { + DurableRunnerError::invalid(format!("Codex provider failed: {error}")) + })?; + let Some(event) = event else { break }; + match event { + CodexProviderEvent::Notification { method, params } => { + let normalized = normalize_codex_notification(&method, ¶ms); + let state = self + .state + .as_mut() + .expect("Codex state remains available while polling"); + if method == "turn/completed" { + state.active_provider_turn_id = None; + state.lifecycle = "session_open".to_owned(); + } + state.extend_events(normalized)?; + self.save_state()?; + } + CodexProviderEvent::RuntimeRequest { + request_id, + question_set, + } => { + let prompt = question_set + .get("title") + .or_else(|| question_set.pointer("/questions/0/prompt")) + .and_then(Value::as_str) + .unwrap_or("Codex needs your input"); + self.state + .as_mut() + .expect("Codex state remains available while polling") + .push_event(NormalizedProviderEvent { + event_type: "runtime_request.created".to_owned(), + priority: EventPriority::P0, + payload: json!({ + "request": { + "schema": "paperclip.runtime_request.v2", + "requestKind": "runtime", + "requestId": request_id, + "type": "input", + "status": "pending", + "prompt": prompt, + "input": question_set, + "origin": { + "adapter": "codex-app-server", + "provider": "codex", + "method": "item/tool/requestUserInput", + }, + }, + }), + })?; + self.save_state()?; + } + CodexProviderEvent::Exited { exit_code, success } => { + self.provider = None; + if let Some(state) = self.state.as_mut() { + state.lifecycle = "provider_exited".to_owned(); + state.push_event(NormalizedProviderEvent { + event_type: "session.failed".to_owned(), + priority: EventPriority::P0, + payload: json!({ + "provider": "codex", + "code": "provider_exited", + "exitCode": exit_code, + "expected": success, + }), + })?; + } + self.save_state()?; + break; + } + } + } + Ok(()) + } +} + +impl CommandExecutor for CodexCommandExecutor { + fn execute(&mut self, command: &Command) -> Result { + self.restore()?; + match command.command_type.as_str() { + "run.prepare" => self.prepare(&command.payload), + "run.attach" => { + if self.state.is_none() && command.payload.get("provider").is_some() { + self.prepare(&command.payload)?; + } + let mut execution = self.open_session()?; + execution.events.push(( + "run.attached".to_owned(), + EventPriority::P0, + json!({"provider": "codex"}), + )); + Ok(execution) + } + "session.open" => self.open_session(), + "turn.start" => self.start_turn(&command.payload), + "turn.steer" => self.steer_turn(&command.payload), + "turn.interrupt" | "turn.stop" | "run.cancel" => { + self.interrupt_turn(&command.command_type) + } + "request.resolve" => self.resolve_request(&command.payload), + "session.snapshot" => self.snapshot(), + "session.close" | "session.destroy" => self.close_session(), + "runner.drain" | "runner.suspend" | "runner.shutdown" => { + Ok(CommandExecution::result(json!({"status": "completed"}))) + } + _ => Ok(CommandExecution::result(json!({ + "status": "rejected", + "code": "provider_command_unavailable", + "message": "the Codex provider does not implement this command in the current layer", + }))), + } + } + + fn poll_events(&mut self) -> Result, DurableRunnerError> { + self.poll_provider()?; + Ok(self + .state + .as_ref() + .into_iter() + .flat_map(|state| state.pending_events.iter()) + .cloned() + .collect()) + } + + fn acknowledge_events(&mut self, count: usize) -> Result<(), DurableRunnerError> { + if count == 0 { + return Ok(()); + } + let mut next_state = self + .state + .clone() + .ok_or_else(|| DurableRunnerError::invalid("Codex provider state is unavailable"))?; + if count > next_state.pending_events.len() { + return Err(DurableRunnerError::invalid( + "provider event acknowledgement exceeded the pending prefix", + )); + } + next_state.pending_events.drain(..count); + self.persist_state(&next_state)?; + self.state = Some(next_state); + Ok(()) + } + + fn shutdown(&mut self) -> Result<(), DurableRunnerError> { + if let Some(provider) = self.provider.as_mut() { + provider.shutdown().map_err(|error| { + DurableRunnerError::invalid(format!("failed to stop Codex provider: {error}")) + })?; + } + self.provider = None; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_inconsistent_provider_state() { + let state = CodexProviderState { + schema: PROVIDER_STATE_SCHEMA.to_owned(), + lifecycle: "turn_active".to_owned(), + config: CodexProviderConfig { + provider: "codex".to_owned(), + driver: "codex_app_server".to_owned(), + provider_version: "test".to_owned(), + command: PathBuf::from("codex"), + args: Vec::new(), + cwd: std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(), + model: None, + instructions: String::new(), + approval_policy: "never".to_owned(), + }, + thread_id: Some("thread-1".to_owned()), + provider_session_id: None, + active_provider_turn_id: None, + pending_events: VecDeque::new(), + next_provider_event_seq: initial_provider_event_seq(), + }; + assert!(state.validate().is_err()); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs new file mode 100644 index 00000000000..f9e7b0d8fe2 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs @@ -0,0 +1,375 @@ +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use crate::durable::{redact_text, EventPriority}; + +const MAX_TEXT_CHARS: usize = 4_000; + +#[derive(Clone, Debug, PartialEq)] +pub struct NormalizedProviderEvent { + pub event_type: String, + pub priority: EventPriority, + pub payload: Value, +} + +fn bounded_text(value: &str, max_chars: usize) -> String { + redact_text(value).chars().take(max_chars).collect() +} + +fn string(value: Option<&Value>) -> &str { + value.and_then(Value::as_str).unwrap_or("") +} + +fn stable_id(value: &str, fallback: &str) -> String { + let value: String = value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || "._:-".contains(character) { + character + } else { + '-' + } + }) + .take(160) + .collect(); + if value + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + { + value + } else { + fallback.to_owned() + } +} + +fn item(params: &Value) -> &Value { + params.get("item").unwrap_or(&Value::Null) +} + +fn provider_status(value: &str, completed: bool) -> &'static str { + match value { + "failed" | "error" => "failed", + "cancelled" | "canceled" => "cancelled", + "interrupted" | "aborted" => "interrupted", + _ if completed => "completed", + _ => "running", + } +} + +fn bounded_output(value: &str) -> Value { + let output = redact_text(value); + let output_truncated = output != value; + json!({ + "output": output, + "outputBytes": value.len(), + "outputTruncated": output_truncated, + "outputDigest": format!("sha256:{:x}", Sha256::digest(value.as_bytes())), + }) +} + +fn measurement(value: &Value) -> Value { + json!({ + "inputTokens": value.get("inputTokens").and_then(Value::as_u64).unwrap_or(0), + "outputTokens": value.get("outputTokens").and_then(Value::as_u64).unwrap_or(0), + "cacheReadTokens": value.get("cachedInputTokens").or_else(|| value.get("cacheReadTokens")).and_then(Value::as_u64).unwrap_or(0), + "cacheWriteTokens": value.get("cacheWriteTokens").and_then(Value::as_u64).unwrap_or(0), + "activeSeconds": value.get("activeSeconds").and_then(Value::as_f64).filter(|value| *value >= 0.0).unwrap_or(0.0), + "requests": value.get("requests").and_then(Value::as_u64).unwrap_or(0), + "providerCostUsd": value.get("providerCostUsd").and_then(Value::as_f64).filter(|value| *value >= 0.0).unwrap_or(0.0), + }) +} + +/// Converts Codex app-server notifications into provider-neutral PRP events. +/// Provider-native envelopes are consumed here and never cross the PRP boundary. +pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec { + let mut events = Vec::new(); + let push = |events: &mut Vec, + event_type: &str, + priority: EventPriority, + payload: Value| { + events.push(NormalizedProviderEvent { + event_type: event_type.to_owned(), + priority, + payload, + }); + }; + + match method { + "thread/compacted" => push( + &mut events, + "context.compacted", + EventPriority::P1, + json!({ + "schema": "paperclip.context.compacted.v1", + "compactionId": stable_id(string(params.get("threadId")), "codex-compaction"), + "reason": "provider", + "preTokens": Value::Null, + "postTokens": Value::Null, + "sameSession": true, + }), + ), + "turn/started" => push( + &mut events, + "turn.started", + EventPriority::P0, + json!({ + "provider": "codex", + "providerTurnId": params.pointer("/turn/id").or_else(|| params.get("turnId")).and_then(Value::as_str), + }), + ), + "turn/completed" => { + let status = string( + params + .pointer("/turn/status") + .or_else(|| params.get("status")), + ); + let event_type = match status { + "failed" | "error" => "turn.failed", + "cancelled" | "canceled" => "turn.cancelled", + "interrupted" | "aborted" => "turn.interrupted", + _ => "turn.completed", + }; + push( + &mut events, + event_type, + EventPriority::P0, + json!({ + "provider": "codex", + "providerTurnId": params.pointer("/turn/id").or_else(|| params.get("turnId")).and_then(Value::as_str), + "status": provider_status(status, true), + }), + ); + } + "turn/plan/updated" => { + let plan_id = stable_id(string(params.get("turnId")), "codex-plan"); + let steps = params + .get("plan") + .and_then(Value::as_array) + .map(|steps| { + steps + .iter() + .take(256) + .enumerate() + .filter_map(|(index, step)| { + let body = bounded_text(string(step.get("step")), MAX_TEXT_CHARS); + if body.trim().is_empty() { + return None; + } + Some(json!({ + "stepId": format!("step-{}", index + 1), + "body": body, + "status": match string(step.get("status")) { + "inProgress" | "in_progress" => "in_progress", + "completed" => "completed", + "blocked" | "failed" | "error" => "blocked", + _ => "pending", + }, + })) + }) + .collect::>() + }) + .unwrap_or_default(); + let complete = !steps.is_empty() + && steps + .iter() + .all(|step| step.get("status").and_then(Value::as_str) == Some("completed")); + push( + &mut events, + "plan.updated", + EventPriority::P1, + json!({ + "schema": "paperclip.plan.updated.v1", + "planId": plan_id, + "revision": params.get("revision").and_then(Value::as_u64).filter(|value| *value > 0).unwrap_or(1), + "explanation": params.get("explanation").and_then(Value::as_str).map(|value| bounded_text(value, MAX_TEXT_CHARS)), + "steps": steps, + "complete": complete, + "syncStatus": "not_applicable", + "documentRevision": Value::Null, + }), + ); + } + "thread/tokenUsage/updated" => { + let cumulative = params + .get("tokenUsage") + .and_then(|value| value.get("total")) + .or_else(|| params.get("total")) + .unwrap_or(&Value::Null); + let run_delta = params + .get("tokenUsage") + .and_then(|value| value.get("last")) + .or_else(|| params.get("last")) + .unwrap_or(cumulative); + push( + &mut events, + "usage.reported", + EventPriority::P0, + json!({ + "provider": "codex", + "model": params.get("model").and_then(Value::as_str).map(|value| bounded_text(value, 240)), + "providerSessionId": params.get("threadId").and_then(Value::as_str).map(|value| bounded_text(value, 240)), + "providerRequestId": Value::Null, + "cumulative": measurement(cumulative), + "runDelta": measurement(run_delta), + }), + ); + } + "error" | "warning" | "deprecationNotice" | "configWarning" => push( + &mut events, + "provider.notice.recorded", + EventPriority::P0, + json!({ + "schema": "paperclip.provider.notice.v1", + "noticeId": stable_id(&format!("codex-{method}"), "codex-notice"), + "severity": if method == "error" { "error" } else { "warning" }, + "category": method.replace('/', "_"), + "scope": if method.contains("config") { "environment" } else { "turn" }, + "recoverable": method != "error", + "userActionable": true, + "summary": bounded_text(string(params.get("message")), MAX_TEXT_CHARS), + }), + ), + "item/agentMessage/delta" => push( + &mut events, + "item.delta", + EventPriority::P2, + json!({ + "provider": "codex", + "itemId": stable_id(string(params.get("itemId")), "codex-message"), + "kind": "agentMessage", + "channel": "progress", + "providerMethod": method, + "text": bounded_text(string(params.get("delta")), MAX_TEXT_CHARS), + }), + ), + "item/started" | "item/completed" => { + let provider_item = item(params); + let item_id = stable_id(string(provider_item.get("id")), "codex-item"); + let item_type = string(provider_item.get("type")); + let completed = method == "item/completed"; + if matches!(item_type, "commandExecution" | "mcpToolCall") { + let mut payload = json!({ + "schema": "paperclip.tool.execution.v1", + "executionId": item_id, + "transport": if item_type == "mcpToolCall" { "mcp" } else { "process" }, + "operation": if item_type == "commandExecution" { "execute" } else { "unknown" }, + "name": provider_item.get("tool").or_else(|| provider_item.get("command")).and_then(Value::as_str).map(|value| bounded_text(value, 240)), + "target": Value::Null, + "namespace": provider_item.get("server").and_then(Value::as_str).map(|value| bounded_text(value, 240)), + "readOnly": provider_item.get("readOnlyHint").and_then(Value::as_bool), + "status": provider_status(string(provider_item.get("status")), completed), + "durationMs": provider_item.get("durationMs").and_then(Value::as_u64), + "exitCode": provider_item.get("exitCode").and_then(Value::as_i64), + "progress": Value::Null, + }); + if let (Some(object), Value::Object(output)) = ( + payload.as_object_mut(), + bounded_output(string( + provider_item + .get("aggregatedOutput") + .or_else(|| provider_item.get("output")), + )), + ) { + object.extend(output); + } + push( + &mut events, + if completed { + "tool.execution.completed" + } else { + "tool.execution.started" + }, + if completed { + EventPriority::P1 + } else { + EventPriority::P2 + }, + payload, + ); + } else { + push( + &mut events, + if completed { + "item.completed" + } else { + "item.started" + }, + if completed { + EventPriority::P1 + } else { + EventPriority::P2 + }, + json!({ + "provider": "codex", + "itemId": item_id, + "kind": bounded_text(item_type, 160), + "status": provider_status(string(provider_item.get("status")), completed), + "channel": if item_type == "agentMessage" { "progress" } else { "detail" }, + "text": provider_item.get("text").and_then(Value::as_str).map(|value| bounded_text(value, MAX_TEXT_CHARS)), + }), + ); + } + } + _ => {} + } + + events +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_codex_plan_without_retaining_native_envelope() { + let events = normalize_codex_notification( + "turn/plan/updated", + &json!({ + "turnId": "turn-1", + "revision": 2, + "plan": [{"step": "Inspect", "status": "inProgress"}], + "accessToken": "secret-value", + }), + ); + assert_eq!(events[0].event_type, "plan.updated"); + assert_eq!(events[0].payload["steps"][0]["status"], "in_progress"); + assert!(!events[0].payload.to_string().contains("secret-value")); + } + + #[test] + fn bounds_and_redacts_command_output() { + let events = normalize_codex_notification( + "item/completed", + &json!({"item": { + "id": "exec-1", + "type": "commandExecution", + "status": "completed", + "command": "printenv", + "aggregatedOutput": "Authorization: Bearer top-secret", + }}), + ); + assert_eq!(events[0].event_type, "tool.execution.completed"); + assert_eq!(events[0].payload["outputTruncated"], true); + assert_eq!(events[0].payload["outputBytes"], 32); + assert!(!events[0].payload.to_string().contains("top-secret")); + } + + #[test] + fn maps_terminal_and_usage_events_at_priority_zero() { + let terminal = normalize_codex_notification( + "turn/completed", + &json!({"turn": {"id": "provider-turn", "status": "failed"}}), + ); + assert_eq!(terminal[0].event_type, "turn.failed"); + assert_eq!(terminal[0].priority, EventPriority::P0); + + let usage = normalize_codex_notification( + "thread/tokenUsage/updated", + &json!({"tokenUsage": {"total": {"inputTokens": 12, "outputTokens": 3}}}), + ); + assert_eq!(usage[0].event_type, "usage.reported"); + assert_eq!(usage[0].payload["cumulative"]["inputTokens"], 12); + assert_eq!(usage[0].priority, EventPriority::P0); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/replay.rs b/packages/paperclip-runner/runner/crates/runner-core/src/replay.rs new file mode 100644 index 00000000000..0adf8d0010a --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/replay.rs @@ -0,0 +1,408 @@ +use std::collections::HashMap; +use std::error::Error; +use std::fmt::{self, Display, Formatter}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const PRP_PROTOCOL_VERSION: u64 = 1; +const PRP_FIXTURE_VERSION: u64 = 1; +const PRP_FIXTURE_SCHEMA: &str = "paperclip.prp.fixture.v1"; +const PRP_EVENT_SCHEMA: &str = "paperclip.prp.event.v1"; +const PRP_SEMANTIC_TOOL_SCHEMA: &str = "paperclip.prp.semantic_tool.v1"; +const PRP_STOP_REASON_SCHEMA: &str = "paperclip.prp.stop_reason.v1"; +const PRP_SEMANTIC_TOOLS_CAPABILITY_SCHEMA: &str = "paperclip.prp.semantic_tools.v1"; +const MAX_EXACT_JSON_INTEGER: u64 = 9_007_199_254_740_991; +const MAX_RECORDED_MISSING_SEQUENCES: u64 = 256; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReplayFixture { + schema: String, + fixture_version: u64, + protocol_version: u64, + identity: ReplayIdentity, + capabilities: Value, + events: Vec, + result: Value, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReplayIdentity { + run_id: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct ReplayEvent { + schema: String, + source_event_id: String, + source_seq: u64, + source_instance_id: String, + source_kind: String, + run_id: String, + schema_version: u64, + event_type: String, + payload: Value, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReplaySequenceGap { + source_key: String, + expected: u64, + received: u64, + missing_count: u64, + missing: Vec, + truncated: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReplayParitySummary { + run_id: String, + integrity: String, + timeline_count: usize, + duplicate_event_ids: Vec, + gaps: Vec, + turn_terminal_state: Option, + run_terminal_state: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReplayError(String); + +impl ReplayError { + fn invalid(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl Display for ReplayError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Error for ReplayError {} + +pub fn reduce_replay_fixture(input: &str) -> Result { + let fixture: ReplayFixture = serde_json::from_str(input) + .map_err(|error| ReplayError::invalid(format!("fixture must be valid JSON: {error}")))?; + if fixture.protocol_version != PRP_PROTOCOL_VERSION { + return Err(ReplayError::invalid(format!( + "unsupported required protocolVersion {}; expected {PRP_PROTOCOL_VERSION}", + fixture.protocol_version + ))); + } + if fixture.fixture_version != PRP_FIXTURE_VERSION { + return Err(ReplayError::invalid(format!( + "unsupported required fixtureVersion {}; expected {PRP_FIXTURE_VERSION}", + fixture.fixture_version + ))); + } + if fixture.schema != PRP_FIXTURE_SCHEMA { + return Err(ReplayError::invalid(format!( + "unsupported required fixture schema {}; expected {PRP_FIXTURE_SCHEMA}", + fixture.schema + ))); + } + validate_optional_versioned_envelope( + fixture.capabilities.get("semanticTools"), + PRP_SEMANTIC_TOOLS_CAPABILITY_SCHEMA, + "semanticTools", + )?; + + let mut delivery_counts = HashMap::<&str, usize>::new(); + for event in &fixture.events { + *delivery_counts + .entry(event.source_event_id.as_str()) + .or_default() += 1; + } + let mut duplicate_event_ids = delivery_counts + .into_iter() + .filter_map(|(event_id, count)| (count > 1).then(|| event_id.to_owned())) + .collect::>(); + duplicate_event_ids.sort(); + + let mut seen = HashMap::<&str, &ReplayEvent>::new(); + let mut cursors = HashMap::::new(); + let mut gaps = Vec::::new(); + let mut timeline_count = 0_usize; + let mut turn_terminal_state = None; + let mut run_terminal_state = None; + let mut proposed_result_count = 0_usize; + let mut terminal_count = 0_usize; + + for event in &fixture.events { + if event.source_seq > MAX_EXACT_JSON_INTEGER { + return Err(ReplayError::invalid(format!( + "sourceSeq {} exceeds the exact JSON integer range", + event.source_seq + ))); + } + if event.schema != PRP_EVENT_SCHEMA { + return Err(ReplayError::invalid(format!( + "unsupported required event schema {}; expected {PRP_EVENT_SCHEMA}", + event.schema + ))); + } + if event.schema_version != 1 { + return Err(ReplayError::invalid(format!( + "unsupported required event schemaVersion {}; expected 1", + event.schema_version + ))); + } + validate_optional_versioned_envelope( + event.payload.get("semantic_tool"), + PRP_SEMANTIC_TOOL_SCHEMA, + "semantic_tool", + )?; + validate_optional_versioned_envelope( + event.payload.get("stopReason"), + PRP_STOP_REASON_SCHEMA, + "stopReason", + )?; + if event.run_id != fixture.identity.run_id { + return Err(ReplayError::invalid( + "event runId must match identity.runId", + )); + } + if let Some(previous) = seen.get(event.source_event_id.as_str()) { + if *previous != event { + return Err(ReplayError::invalid( + "duplicate sourceEventId deliveries must be byte-equivalent", + )); + } + continue; + } + seen.insert(event.source_event_id.as_str(), event); + + let key = format!("{}:{}", event.source_kind, event.source_instance_id); + let cursor = *cursors.get(&key).unwrap_or(&0); + let expected = cursor + 1; + if event.source_seq <= cursor { + continue; + } + if event.source_seq > expected { + let missing_count = event.source_seq - expected; + let recorded_count = missing_count.min(MAX_RECORDED_MISSING_SEQUENCES); + gaps.push(ReplaySequenceGap { + source_key: key.clone(), + expected, + received: event.source_seq, + missing_count, + missing: (expected..expected + recorded_count).collect(), + truncated: recorded_count < missing_count, + }); + } + cursors.insert(key, event.source_seq); + timeline_count += 1; + + match event.event_type.as_str() { + "run.result.proposed" => { + proposed_result_count += 1; + if event.payload != fixture.result { + return Err(ReplayError::invalid( + "fixture result must match the run.result.proposed event payload", + )); + } + } + "run.terminal" => { + terminal_count += 1; + turn_terminal_state = event + .payload + .get("turnTerminalState") + .and_then(Value::as_str) + .map(str::to_owned); + run_terminal_state = event + .payload + .get("runTerminalState") + .and_then(Value::as_str) + .map(str::to_owned); + } + _ => {} + } + } + + if proposed_result_count != 1 { + return Err(ReplayError::invalid( + "scripted fixtures must contain exactly one unique run.result.proposed event", + )); + } + if terminal_count != 1 { + return Err(ReplayError::invalid( + "scripted fixtures must contain exactly one unique run.terminal event", + )); + } + + Ok(ReplayParitySummary { + run_id: fixture.identity.run_id, + integrity: if gaps.is_empty() { + "complete".to_owned() + } else { + "gap_detected".to_owned() + }, + timeline_count, + duplicate_event_ids, + gaps, + turn_terminal_state, + run_terminal_state, + }) +} + +fn validate_optional_versioned_envelope( + envelope: Option<&Value>, + expected_schema: &str, + field: &str, +) -> Result<(), ReplayError> { + let Some(envelope) = envelope else { + return Ok(()); + }; + let schema = envelope + .get("schema") + .and_then(Value::as_str) + .unwrap_or("missing"); + if schema != expected_schema { + return Err(ReplayError::invalid(format!( + "unsupported required {field} schema {schema}; expected {expected_schema}" + ))); + } + let version = envelope + .get("schemaVersion") + .and_then(Value::as_u64) + .unwrap_or(0); + if version != 1 { + return Err(ReplayError::invalid(format!( + "unsupported required {field} schemaVersion {version}; expected 1" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + reduce_replay_fixture, ReplayParitySummary, MAX_EXACT_JSON_INTEGER, + MAX_RECORDED_MISSING_SEQUENCES, + }; + use serde_json::Value; + + fn assert_fixture_parity(fixture: &str, expected: &str) { + let actual = reduce_replay_fixture(fixture).expect("fixture should reduce"); + let expected: ReplayParitySummary = + serde_json::from_str(expected).expect("golden summary should parse"); + assert_eq!(actual, expected); + } + + macro_rules! parity_case { + ($name:ident, $fixture:literal, $golden:literal) => { + #[test] + fn $name() { + assert_fixture_parity( + include_str!(concat!( + "../../../../protocol/fixtures/replay/", + $fixture, + ".json" + )), + include_str!(concat!( + "../../../../protocol/fixtures/replay/golden/", + $golden, + ".summary.json" + )), + ); + } + }; + } + + parity_case!(replay_fixture_parity_happy_path, "happy-path", "happy-path"); + parity_case!(replay_fixture_parity_failed_run, "failed-run", "failed-run"); + parity_case!( + replay_fixture_parity_interrupted_run, + "interrupted-run", + "interrupted-run" + ); + parity_case!( + replay_fixture_parity_duplicate_event, + "duplicate-event", + "duplicate-event" + ); + parity_case!(replay_fixture_parity_source_gap, "source-gap", "source-gap"); + parity_case!( + replay_fixture_parity_unknown_optional_fields, + "unknown-optional-fields", + "unknown-optional-fields" + ); + + #[test] + fn replay_fixture_parity_rejects_unsupported_required_version() { + let error = reduce_replay_fixture(include_str!( + "../../../../protocol/fixtures/replay/unsupported-required-version.json" + )) + .expect_err("PRP v2 fixture must fail closed"); + assert!(error + .to_string() + .contains("unsupported required protocolVersion 2")); + } + + #[test] + fn replay_fixture_parity_rejects_unsupported_fixture_version() { + let fixture = include_str!("../../../../protocol/fixtures/replay/happy-path.json") + .replace("\"fixtureVersion\": 1", "\"fixtureVersion\": 2"); + let error = + reduce_replay_fixture(&fixture).expect_err("fixture version 2 must fail closed"); + assert!(error + .to_string() + .contains("unsupported required fixtureVersion 2")); + } + + #[test] + fn replay_fixture_parity_bounds_large_sequence_gap_details() { + let mut fixture: Value = serde_json::from_str(include_str!( + "../../../../protocol/fixtures/replay/source-gap.json" + )) + .expect("fixture should parse"); + fixture["events"][2]["sourceSeq"] = Value::from(MAX_EXACT_JSON_INTEGER - 2); + fixture["events"][3]["sourceSeq"] = Value::from(MAX_EXACT_JSON_INTEGER - 1); + fixture["events"][4]["sourceSeq"] = Value::from(MAX_EXACT_JSON_INTEGER); + + let summary = reduce_replay_fixture(&fixture.to_string()).expect("fixture should reduce"); + let gap = summary.gaps.first().expect("large gap should be recorded"); + assert_eq!(gap.expected, 3); + assert_eq!(gap.received, MAX_EXACT_JSON_INTEGER - 2); + assert_eq!(gap.missing_count, MAX_EXACT_JSON_INTEGER - 5); + assert_eq!(gap.missing.len() as u64, MAX_RECORDED_MISSING_SEQUENCES); + assert_eq!(gap.missing.last(), Some(&258)); + assert!(gap.truncated); + } + + #[test] + fn replay_fixture_parity_rejects_inexact_json_sequence_values() { + let mut fixture: Value = serde_json::from_str(include_str!( + "../../../../protocol/fixtures/replay/happy-path.json" + )) + .expect("fixture should parse"); + fixture["events"][0]["sourceSeq"] = Value::from(MAX_EXACT_JSON_INTEGER + 1); + + let error = reduce_replay_fixture(&fixture.to_string()) + .expect_err("an inexact JSON sequence must fail closed"); + assert!(error + .to_string() + .contains("exceeds the exact JSON integer range")); + } + + #[test] + fn replay_fixture_parity_rejects_a_mismatched_declared_result() { + let mut fixture: Value = serde_json::from_str(include_str!( + "../../../../protocol/fixtures/replay/happy-path.json" + )) + .expect("fixture should parse"); + fixture["result"]["summary"] = Value::String("Contradictory result".to_owned()); + let error = reduce_replay_fixture(&fixture.to_string()) + .expect_err("mismatched result must fail closed"); + assert!(error + .to_string() + .contains("fixture result must match the run.result.proposed event payload")); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs new file mode 100644 index 00000000000..22859843e66 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs @@ -0,0 +1,385 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use paperclip_runner_core::codex_provider::{ + CodexProvider, CodexProviderConfig, CodexProviderEvent, +}; +use paperclip_runner_core::durable::{Command, CommandExecutor, DurableRunnerError, PolledEvent}; +use paperclip_runner_core::provider_backend::CodexCommandExecutor; +use paperclip_runner_core::provider_events::normalize_codex_notification; +use serde_json::{json, Value}; + +static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(1); + +fn temporary_directory(label: &str) -> PathBuf { + let directory = std::env::temp_dir().join(format!( + "paperclip-runner-codex-{label}-{}-{}", + std::process::id(), + NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).expect("create Codex integration-test directory"); + directory +} + +fn provider_config(directory: &Path, switches: &[&str]) -> CodexProviderConfig { + let mut args = vec![ + "--state-file".to_owned(), + directory + .join("fake-state.json") + .to_string_lossy() + .into_owned(), + "--call-log".to_owned(), + directory.join("calls.log").to_string_lossy().into_owned(), + ]; + args.extend(switches.iter().map(|value| (*value).to_owned())); + CodexProviderConfig { + provider: "codex".to_owned(), + driver: "codex_app_server".to_owned(), + provider_version: "fake-1".to_owned(), + command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")), + args, + cwd: std::env::current_dir() + .expect("resolve test cwd") + .to_string_lossy() + .into_owned(), + model: Some("test-model".to_owned()), + instructions: "Stay inside the test workspace.".to_owned(), + approval_policy: "never".to_owned(), + } +} + +fn command(id: &str, sequence: u64, command_type: &str, payload: Value) -> Command { + Command { + schema: "paperclip.prp.command.v1".to_owned(), + command_id: id.to_owned(), + controller_seq: sequence, + command_type: command_type.to_owned(), + issued_at: "2026-08-24T00:00:00.000Z".to_owned(), + deadline_at: None, + precondition: None, + payload, + } +} + +fn call_count(directory: &Path, method: &str) -> usize { + fs::read_to_string(directory.join("calls.log")) + .unwrap_or_default() + .lines() + .filter(|line| *line == method) + .count() +} + +fn poll_and_ack( + executor: &mut CodexCommandExecutor, +) -> Result, DurableRunnerError> { + let events = executor.poll_events()?; + executor.acknowledge_events(events.len())?; + Ok(events) +} + +#[test] +fn codex_transport_buffers_notifications_while_waiting_for_responses() { + let directory = temporary_directory("buffering"); + let config = provider_config(&directory, &["--notification-before-response"]); + let mut provider = CodexProvider::start(&config, None).expect("start fake Codex provider"); + let event = provider + .poll() + .expect("poll buffered notification") + .expect("buffered notification is available"); + let CodexProviderEvent::Notification { method, params } = event else { + panic!("expected the pre-response warning notification"); + }; + assert_eq!(method, "warning"); + let normalized = normalize_codex_notification(&method, ¶ms); + assert_eq!(normalized[0].event_type, "provider.notice.recorded"); + + provider + .start_turn("Complete the fake task.", &config.cwd) + .expect("start provider turn"); + let mut event_types = Vec::new(); + for _ in 0..16 { + if let Some(CodexProviderEvent::Notification { method, params }) = + provider.poll().expect("poll provider event") + { + event_types.extend( + normalize_codex_notification(&method, ¶ms) + .into_iter() + .map(|event| event.event_type), + ); + } + if event_types.iter().any(|event| event == "turn.completed") { + break; + } + } + assert!(event_types.iter().any(|event| event == "turn.started")); + assert!(event_types.iter().any(|event| event == "item.completed")); + assert!(event_types.iter().any(|event| event == "usage.reported")); + assert!(event_types.iter().any(|event| event == "turn.completed")); + provider.shutdown().expect("stop provider"); + fs::remove_dir_all(directory).expect("remove Codex integration-test directory"); +} + +#[test] +fn durable_backend_resumes_the_active_thread_without_restarting_the_turn() { + let directory = temporary_directory("resume"); + let config = provider_config(&directory, &["--hold-turn"]); + let mut first = CodexCommandExecutor::new(&directory); + first + .execute(&command( + "prepare", + 1, + "run.prepare", + json!({"provider": config}), + )) + .expect("prepare Codex provider"); + first + .execute(&command("open", 2, "session.open", json!({}))) + .expect("open Codex session"); + first + .execute(&command( + "turn", + 3, + "turn.start", + json!({"text": "Hold this turn for recovery."}), + )) + .expect("start held provider turn"); + assert_eq!(call_count(&directory, "turn/start"), 1); + first.shutdown().expect("stop first provider process"); + drop(first); + + let mut recovered = CodexCommandExecutor::new(&directory); + let snapshot = recovered + .execute(&command("snapshot", 4, "session.snapshot", json!({}))) + .expect("restore provider session"); + assert_eq!(snapshot.result["status"], "turn_active"); + assert_eq!(snapshot.result["activeProviderTurnId"], "provider-turn-1"); + assert_eq!(call_count(&directory, "turn/start"), 1); + assert_eq!(call_count(&directory, "thread/resume"), 1); + assert_eq!(call_count(&directory, "thread/read"), 1); + + recovered + .execute(&command("interrupt", 5, "turn.interrupt", json!({}))) + .expect("interrupt recovered provider turn"); + let mut terminal_seen = false; + for _ in 0..16 { + let events = poll_and_ack(&mut recovered).expect("poll interrupted turn"); + terminal_seen |= events + .iter() + .any(|event| event.event_type == "turn.interrupted"); + if terminal_seen { + break; + } + } + assert!(terminal_seen); + recovered + .shutdown() + .expect("stop recovered provider process"); + fs::remove_dir_all(directory).expect("remove Codex integration-test directory"); +} + +#[test] +fn provider_exit_preserves_and_reconciles_the_active_turn() { + let directory = temporary_directory("exit-active-turn"); + let config = provider_config(&directory, &["--exit-after-turn-start"]); + let mut executor = CodexCommandExecutor::new(&directory); + executor + .execute(&command( + "prepare", + 1, + "run.prepare", + json!({"provider": config}), + )) + .expect("prepare Codex provider"); + executor + .execute(&command("open", 2, "session.open", json!({}))) + .expect("open Codex session"); + executor + .execute(&command( + "turn", + 3, + "turn.start", + json!({"text": "Keep the native turn active while the provider exits."}), + )) + .expect("start provider turn"); + + let mut provider_exit_seen = false; + for _ in 0..32 { + provider_exit_seen |= poll_and_ack(&mut executor) + .expect("poll provider exit") + .iter() + .any(|event| event.event_type == "session.failed"); + if provider_exit_seen { + break; + } + } + assert!(provider_exit_seen); + let persisted: Value = serde_json::from_slice( + &fs::read(directory.join("codex-provider-state.json")) + .expect("read provider state after exit"), + ) + .expect("parse provider state after exit"); + assert_eq!(persisted["lifecycle"], "provider_exited"); + assert_eq!(persisted["activeProviderTurnId"], "provider-turn-1"); + + let interrupted = executor + .execute(&command("interrupt", 4, "turn.interrupt", json!({}))) + .expect("interrupt reconciled provider turn"); + assert_eq!(interrupted.result["status"], "interrupt_requested"); + assert_eq!(call_count(&directory, "thread/resume"), 1); + assert_eq!(call_count(&directory, "thread/read"), 1); + assert_eq!(call_count(&directory, "turn/interrupt"), 1); + + let mut terminal_seen = false; + for _ in 0..32 { + terminal_seen |= poll_and_ack(&mut executor) + .expect("poll reconciled interruption") + .iter() + .any(|event| event.event_type == "turn.interrupted"); + if terminal_seen { + break; + } + } + assert!(terminal_seen); + executor.shutdown().expect("stop resumed provider process"); + fs::remove_dir_all(directory).expect("remove Codex integration-test directory"); +} + +#[test] +fn unacknowledged_provider_events_survive_executor_restart() { + let directory = temporary_directory("pending-event-recovery"); + let config = provider_config(&directory, &["--emit-question"]); + let mut first = CodexCommandExecutor::new(&directory); + first + .execute(&command( + "prepare", + 1, + "run.prepare", + json!({"provider": config}), + )) + .expect("prepare Codex provider"); + first + .execute(&command("open", 2, "session.open", json!({}))) + .expect("open Codex session"); + first + .execute(&command( + "turn", + 3, + "turn.start", + json!({"text": "Emit a durable question."}), + )) + .expect("start provider turn"); + + let mut retained = None; + for _ in 0..32 { + let events = first.poll_events().expect("poll provider events"); + if events + .iter() + .any(|event| event.event_type == "runtime_request.created") + { + retained = Some(events); + break; + } + first + .acknowledge_events(events.len()) + .expect("acknowledge events before the question"); + } + let retained = retained.expect("observe a durable runtime request"); + first.shutdown().expect("stop first provider process"); + drop(first); + + let mut recovered = CodexCommandExecutor::new(&directory); + let replayed = recovered + .poll_events() + .expect("reload unacknowledged provider events"); + assert_eq!(&replayed[..retained.len()], retained.as_slice()); + recovered + .acknowledge_events(replayed.len()) + .expect("acknowledge reloaded provider events"); + recovered + .shutdown() + .expect("stop recovered provider process"); + fs::remove_dir_all(directory).expect("remove Codex integration-test directory"); +} + +#[test] +fn structured_question_round_trips_through_the_normalized_backend() { + let directory = temporary_directory("questions"); + let config = provider_config(&directory, &["--emit-question"]); + let mut executor = CodexCommandExecutor::new(&directory); + executor + .execute(&command( + "prepare", + 1, + "run.prepare", + json!({"provider": config}), + )) + .expect("prepare provider"); + executor + .execute(&command("open", 2, "session.open", json!({}))) + .expect("open provider session"); + let started = executor + .execute(&command( + "turn", + 3, + "turn.start", + json!({"text": "Ask for deployment input."}), + )) + .expect("start provider turn"); + assert_eq!(started.events.len(), 1); + assert_eq!(started.events[0].0, "turn.accepted"); + + let mut question_set = None; + let mut provider_started_events = 0; + for _ in 0..16 { + for event in poll_and_ack(&mut executor).expect("poll question") { + provider_started_events += usize::from(event.event_type == "turn.started"); + if event.event_type == "runtime_request.created" { + assert_eq!( + event.payload["request"]["schema"], + "paperclip.runtime_request.v2" + ); + question_set = event.payload.pointer("/request/input").cloned(); + } + } + if question_set.is_some() { + break; + } + } + let question_set = question_set.expect("normalized question set is emitted"); + assert_eq!(provider_started_events, 1); + assert_eq!(question_set["schema"], "paperclip.question_set.v1"); + assert_eq!( + question_set["questions"][0]["options"][0]["label"], + "Staging" + ); + + executor + .execute(&command( + "resolve", + 4, + "request.resolve", + json!({ + "requestId": "runtime-request-1", + "response": { + "schema": "paperclip.question_response.v1", + "answers": {"environment": {"selectedOptionIds": ["option-1"]}} + } + }), + )) + .expect("deliver normalized response"); + let mut completed = false; + for _ in 0..16 { + completed |= poll_and_ack(&mut executor) + .expect("poll completed question turn") + .iter() + .any(|event| event.event_type == "turn.completed"); + if completed { + break; + } + } + assert!(completed); + executor.shutdown().expect("stop provider process"); + fs::remove_dir_all(directory).expect("remove Codex integration-test directory"); +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/durable_recovery.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/durable_recovery.rs new file mode 100644 index 00000000000..6ac1f3f914b --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/durable_recovery.rs @@ -0,0 +1,185 @@ +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use paperclip_runner_core::durable::{ + Command, CommandDisposition, DurableRunnerConfig, DurableStateStore, +}; +use serde_json::json; + +static NEXT_TEMPORARY_DIRECTORY: AtomicU64 = AtomicU64::new(0); + +fn config(state_dir: PathBuf) -> DurableRunnerConfig { + DurableRunnerConfig { + connect_url: "ws://127.0.0.1:3000/api/runner/v1/connect/run_1".to_owned(), + state_dir, + runner_instance_id: "runner_1".to_owned(), + environment_lease_id: "environment_1".to_owned(), + run_id: "run_1".to_owned(), + normalized_session_id: "session_1".to_owned(), + turn_id: "turn_1".to_owned(), + item_id: "item_1".to_owned(), + runner_version: "0.0.0".to_owned(), + runner_digest: "sha256:test".to_owned(), + max_outbox_bytes: 16_384, + p0_reserve_bytes: 4096, + max_frame_bytes: 65_536, + reconnect_delay: Duration::from_millis(1), + max_runtime: Duration::from_secs(1), + } +} + +fn command() -> Command { + Command { + schema: "paperclip.prp.command.v1".to_owned(), + command_id: "command_1".to_owned(), + controller_seq: 1, + command_type: "session.open".to_owned(), + issued_at: "2026-08-24T00:00:00.000Z".to_owned(), + deadline_at: None, + precondition: None, + payload: json!({}), + } +} + +fn temporary_directory() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must follow the Unix epoch") + .as_nanos(); + let sequence = NEXT_TEMPORARY_DIRECTORY.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "paperclip-runner-public-recovery-{}-{nonce}-{sequence}", + std::process::id() + )) +} + +#[test] +fn public_store_never_reexecutes_a_journaled_command_after_recovery() { + let directory = temporary_directory(); + let config = config(directory.clone()); + let store = DurableStateStore::new(&directory).expect("create private state store"); + let (mut state, existed) = store.load_or_create(&config).expect("create durable state"); + assert!(!existed); + + let command = command(); + assert_eq!( + state.begin_command(&command).expect("journal command"), + CommandDisposition::Execute + ); + store + .save(&state) + .expect("persist command before its external effect"); + + let (mut recovered, existed) = store + .load_or_create(&config) + .expect("recover durable state"); + assert!(existed); + assert!(matches!( + recovered + .begin_command(&command) + .expect("look up recovered command"), + CommandDisposition::Replay(result) + if result.status == "indeterminate" + && result.result["code"] == "execution_indeterminate" + )); + + fs::remove_dir_all(directory).expect("remove integration-test state"); +} + +#[test] +fn duplicate_replay_requires_the_complete_command_identity() { + let directory = temporary_directory(); + let config = config(directory.clone()); + let store = DurableStateStore::new(&directory).expect("create private state store"); + let (mut state, _) = store.load_or_create(&config).expect("create durable state"); + let original = command(); + + assert_eq!( + state.begin_command(&original).expect("journal command"), + CommandDisposition::Execute + ); + state + .complete_command(&original, json!({"ok": true})) + .expect("complete command"); + + let mut changed_payload = original.clone(); + changed_payload.payload = json!({"changed": true}); + let mut changed_precondition = original.clone(); + changed_precondition.precondition = Some(json!({"ifMatch": "different"})); + let mut changed_issued_at = original.clone(); + changed_issued_at.issued_at = "2026-08-24T00:00:01.000Z".to_owned(); + let mut changed_deadline = original.clone(); + changed_deadline.deadline_at = Some("2026-08-24T00:01:00.000Z".to_owned()); + let mut changed_type = original.clone(); + changed_type.command_type = "session.close".to_owned(); + let mut changed_sequence = original.clone(); + changed_sequence.controller_seq = 2; + + for conflicting in [ + changed_payload, + changed_precondition, + changed_issued_at, + changed_deadline, + changed_type, + changed_sequence, + ] { + let error = state + .begin_command(&conflicting) + .expect_err("changed duplicate must fail closed"); + assert!(error + .to_string() + .contains("commandId was reused with different command data")); + } + assert!(matches!( + state.begin_command(&original).expect("replay exact command"), + CommandDisposition::Replay(result) if result.result == json!({"ok": true}) + )); + + fs::remove_dir_all(directory).expect("remove integration-test state"); +} + +#[test] +fn pre_fingerprint_journal_recovers_without_reexecuting_old_commands() { + let directory = temporary_directory(); + let config = config(directory.clone()); + let store = DurableStateStore::new(&directory).expect("create private state store"); + let (mut state, _) = store.load_or_create(&config).expect("create durable state"); + let command = command(); + assert_eq!( + state.begin_command(&command).expect("journal command"), + CommandDisposition::Execute + ); + store.save(&state).expect("persist pending command"); + + let mut legacy: serde_json::Value = + serde_json::from_slice(&fs::read(store.path()).expect("read current durable state")) + .expect("parse current durable state"); + legacy + .as_object_mut() + .expect("durable state must be an object") + .remove("processedCommandFingerprints"); + fs::write( + store.path(), + serde_json::to_vec_pretty(&legacy).expect("serialize legacy state"), + ) + .expect("write simulated pre-fingerprint state"); + + let (mut recovered, existed) = store + .load_or_create(&config) + .expect("migrate pre-fingerprint state"); + assert!(existed); + assert!(recovered.processed_commands.is_empty()); + assert!(recovered.processed_command_fingerprints.is_empty()); + assert_eq!(recovered.compacted_through_controller_seq, 1); + assert!(matches!( + recovered + .begin_command(&command) + .expect("reject migrated command without reexecution"), + CommandDisposition::Reject(result) + if result.result["code"] == "command_history_compacted" + )); + + fs::remove_dir_all(directory).expect("remove integration-test state"); +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/local_runner.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/local_runner.rs new file mode 100644 index 00000000000..91cba4d71d7 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/local_runner.rs @@ -0,0 +1,126 @@ +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde_json::{json, Value}; + +fn run_fixture(script_name: &str, commands: &[Value]) -> (std::process::ExitStatus, Vec) { + let runnerd = PathBuf::from(env!("CARGO_BIN_EXE_paperclip-runnerd")); + let fake_harness = PathBuf::from(env!("CARGO_BIN_EXE_fake-harness")); + let script = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../protocol/fixtures/local-runner/scripts") + .join(format!("{script_name}.json")); + let mut child = Command::new(runnerd) + .args([ + "--run-id", + "run_local_test", + "--session-id", + "session_local_test", + "--runner-id", + "runner_local_test", + "--fake-harness", + ]) + .arg(fake_harness) + .args(["--script"]) + .arg(script) + .args(["--delay-ms", "0", "--shutdown-grace-ms", "100"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("local runner should start"); + let mut stdin = child.stdin.take().expect("runner stdin should be piped"); + for command in commands { + serde_json::to_writer(&mut stdin, command).expect("command should serialize"); + stdin.write_all(b"\n").expect("command should write"); + } + stdin.flush().expect("commands should flush"); + + let mut output = String::new(); + child + .stdout + .take() + .expect("runner stdout should be piped") + .read_to_string(&mut output) + .expect("runner output should be readable"); + let status = child.wait().expect("local runner should exit"); + let messages = output + .lines() + .map(|line| serde_json::from_str(line).expect("runner output should be JSONL")) + .collect(); + (status, messages) +} + +fn command(id: &str, sequence: u64, command_type: &str, payload: Value) -> Value { + json!({ + "schema": "paperclip.prp.command.v1", + "commandId": id, + "controllerSeq": sequence, + "type": command_type, + "issuedAt": "2026-08-07T21:00:00.000Z", + "payload": payload, + }) +} + +fn event_type(message: &Value) -> Option<&str> { + (message["message"]["kind"] == "event") + .then(|| message["message"]["event"]["eventType"].as_str()) + .flatten() +} + +#[test] +fn happy_path_emits_one_result_and_one_terminal() { + let commands = [ + command("command_prepare", 1, "run.prepare", json!({})), + command("command_session", 2, "session.open", json!({})), + command( + "command_turn", + 3, + "turn.start", + json!({ "turnId": "turn_local_test", "text": "Run the fixture." }), + ), + ]; + let (status, messages) = run_fixture("happy-path", &commands); + + assert!(status.success()); + assert_eq!( + messages + .iter() + .filter(|message| event_type(message) == Some("run.result.proposed")) + .count(), + 1 + ); + let terminals = messages + .iter() + .filter(|message| event_type(message) == Some("run.terminal")) + .collect::>(); + assert_eq!(terminals.len(), 1); + assert_eq!( + terminals[0]["message"]["event"]["payload"]["runTerminalState"], + "succeeded" + ); +} + +#[test] +fn equivalent_command_redelivery_is_idempotent() { + let prepare = command("command_prepare", 1, "run.prepare", json!({})); + let commands = [ + prepare.clone(), + prepare, + command("command_session", 2, "session.open", json!({})), + command( + "command_turn", + 3, + "turn.start", + json!({ "turnId": "turn_local_test", "text": "Run the fixture." }), + ), + ]; + let (status, messages) = run_fixture("happy-path", &commands); + + assert!(status.success()); + assert!(messages.iter().any(|message| { + message["message"]["kind"] == "command_receipt" + && message["message"]["commandId"] == "command_prepare" + && message["message"]["status"] == "duplicate" + })); +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/process_supervisor.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/process_supervisor.rs new file mode 100644 index 00000000000..5353a40979c --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/process_supervisor.rs @@ -0,0 +1,176 @@ +#![cfg(unix)] + +use std::path::PathBuf; +use std::process::Command; +use std::process::Stdio; +use std::time::Duration; + +use paperclip_runner_core::local_runner::HarnessCommand; +use paperclip_runner_core::process_supervisor::SupervisedProcess; +use serde_json::{json, Value}; + +fn process_exists(pid: u64) -> bool { + Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +fn spawn_linger_process() -> (SupervisedProcess, u32, u64) { + let harness = PathBuf::from(env!("CARGO_BIN_EXE_fake-harness")); + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../protocol/fixtures/local-runner/scripts/linger.json"); + let mut process = SupervisedProcess::spawn( + &harness, + &[ + "--script".to_owned(), + script.display().to_string(), + "--delay-ms".to_owned(), + "1".to_owned(), + ], + Duration::from_millis(50), + 64 * 1024, + ) + .expect("fake harness should start"); + let harness_pid = process.id(); + process + .receive_stdout_line(Duration::from_secs(1)) + .expect("ready line should be readable") + .expect("ready line should exist"); + process + .send(&HarnessCommand { + schema: "paperclip.fake_harness.command.v1".to_owned(), + command_id: "open".to_owned(), + command_type: "session.open".to_owned(), + payload: json!({}), + }) + .expect("session.open should send"); + process + .receive_stdout_line(Duration::from_secs(1)) + .expect("session line should be readable") + .expect("session line should exist"); + process + .send(&HarnessCommand { + schema: "paperclip.fake_harness.command.v1".to_owned(), + command_id: "turn".to_owned(), + command_type: "turn.start".to_owned(), + payload: json!({ "turnId": "turn_cleanup" }), + }) + .expect("turn.start should send"); + + let mut worker_pid = None; + for _ in 0..5 { + let line = process + .receive_stdout_line(Duration::from_secs(1)) + .expect("harness output should be readable") + .expect("harness output should continue"); + let message: Value = serde_json::from_str(&line).expect("harness output should be JSON"); + if message["type"] == "diagnostic" { + worker_pid = message["payload"]["workerPid"].as_u64(); + break; + } + } + let worker_pid = worker_pid.expect("linger script should report its worker pid"); + assert!(process_exists(u64::from(harness_pid))); + assert!(process_exists(worker_pid)); + (process, harness_pid, worker_pid) +} + +#[test] +fn forced_process_group_cleanup_stops_harness_and_worker() { + let (mut process, harness_pid, worker_pid) = spawn_linger_process(); + + process + .terminate_group() + .expect("process group cleanup should finish"); + std::thread::sleep(Duration::from_millis(20)); + assert!(!process_exists(u64::from(harness_pid))); + assert!(!process_exists(worker_pid)); +} + +#[test] +fn natural_harness_exit_also_cleans_up_workers() { + let (mut process, harness_pid, worker_pid) = spawn_linger_process(); + process + .send(&HarnessCommand { + schema: "paperclip.fake_harness.command.v1".to_owned(), + command_id: "interrupt".to_owned(), + command_type: "turn.interrupt".to_owned(), + payload: json!({ "reason": "cleanup_test" }), + }) + .expect("turn.interrupt should send"); + process + .wait() + .expect("harness should exit after interruption"); + + std::thread::sleep(Duration::from_millis(20)); + assert!(!process_exists(u64::from(harness_pid))); + assert!(!process_exists(worker_pid)); +} + +#[test] +fn oversized_harness_stdout_frame_is_rejected() { + let harness = PathBuf::from(env!("CARGO_BIN_EXE_fake-harness")); + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../protocol/fixtures/local-runner/scripts/oversized-line.json"); + let mut process = SupervisedProcess::spawn( + &harness, + &[ + "--script".to_owned(), + script.display().to_string(), + "--delay-ms".to_owned(), + "1".to_owned(), + ], + Duration::from_millis(50), + 512, + ) + .expect("fake harness should start"); + process + .receive_stdout_line(Duration::from_secs(1)) + .expect("ready frame should fit") + .expect("ready frame should exist"); + process + .send(&HarnessCommand { + schema: "paperclip.fake_harness.command.v1".to_owned(), + command_id: "open".to_owned(), + command_type: "session.open".to_owned(), + payload: json!({}), + }) + .expect("session.open should send"); + process + .receive_stdout_line(Duration::from_secs(1)) + .expect("session frame should fit") + .expect("session frame should exist"); + process + .send(&HarnessCommand { + schema: "paperclip.fake_harness.command.v1".to_owned(), + command_id: "turn".to_owned(), + command_type: "turn.start".to_owned(), + payload: json!({ "turnId": "turn_oversized" }), + }) + .expect("turn.start should send"); + process + .receive_stdout_line(Duration::from_secs(1)) + .expect("turn frame should fit") + .expect("turn frame should exist"); + + let mut oversized_error = None; + for _ in 0..3 { + match process.receive_stdout_line(Duration::from_secs(1)) { + Ok(Some(_)) => {} + Ok(None) => break, + Err(error) => { + oversized_error = Some(error); + break; + } + } + } + let error = oversized_error.expect("oversized harness frame must be rejected"); + assert!(error.to_string().contains("exceeded 512 bytes")); + + process + .terminate_group() + .expect("oversized-frame harness should be cleaned up"); +} diff --git a/packages/paperclip-runner/rust-toolchain.toml b/packages/paperclip-runner/rust-toolchain.toml new file mode 100644 index 00000000000..cf01515b3a1 --- /dev/null +++ b/packages/paperclip-runner/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +profile = "minimal" +components = ["rustfmt"] diff --git a/packages/paperclip-runner/scripts/generate-protocol-manifest.mjs b/packages/paperclip-runner/scripts/generate-protocol-manifest.mjs new file mode 100644 index 00000000000..6535eb07fcb --- /dev/null +++ b/packages/paperclip-runner/scripts/generate-protocol-manifest.mjs @@ -0,0 +1,129 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + SUPPORTED_FIXTURE_VERSION, + SUPPORTED_PROTOCOL_VERSION, + assertCodexQuestionFixture, + assertConformanceFixturePair, + assertReplayFixtureCompatibility, + assertSchemaInstance, + compileProtocolValidators, + listJsonFiles, + loadSchemaCatalog, + portableRelative, + readJson, + sha256, +} from "./protocol-contract.mjs"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const protocolRoot = resolve(packageRoot, "protocol"); +const schemaDirectory = resolve(protocolRoot, "schemas"); +const fixtureDirectory = resolve(protocolRoot, "fixtures"); +const outputPath = resolve(protocolRoot, "manifest.json"); +const expectedRejectedFixture = + "fixtures/replay/unsupported-required-version.json"; + +export async function buildProtocolManifest() { + const schemas = await loadSchemaCatalog(schemaDirectory); + const validators = compileProtocolValidators(schemas); + const fixtureFiles = await listJsonFiles(fixtureDirectory); + const fixtures = []; + const fixtureValues = new Map(); + + for (const path of fixtureFiles) { + const relativePath = portableRelative(protocolRoot, path); + const { source, value } = await readJson(path); + fixtureValues.set(relativePath, value); + let expectation = "accept"; + let compatibilityCase = "canonical"; + + if (relativePath.startsWith("fixtures/replay/golden/")) { + compatibilityCase = "deterministic-replay-oracle"; + } else if (relativePath.startsWith("fixtures/replay/")) { + if (relativePath === expectedRejectedFixture) { + expectation = "reject"; + compatibilityCase = "unknown-required-version"; + try { + assertReplayFixtureCompatibility(value); + throw new Error(`${relativePath} did not fail closed`); + } catch (error) { + if ( + !String(error.message).startsWith("unsupported_required_version:") + ) + throw error; + } + assertSchemaInstance(validators.fixture, value, relativePath, false); + } else { + assertReplayFixtureCompatibility(value); + assertSchemaInstance(validators.fixture, value, relativePath); + if (relativePath.endsWith("unknown-optional-fields.json")) { + compatibilityCase = "additive-optional-fields"; + } + } + } else if (relativePath === "fixtures/questions/codex.json") { + assertCodexQuestionFixture(value); + assertSchemaInstance( + validators.questionAdapterFixture, + value, + relativePath, + ); + compatibilityCase = "codex-structured-input"; + } else if (relativePath === "fixtures/conformance-minimal-run.json") { + assertSchemaInstance(validators.conformanceFixture, value, relativePath); + compatibilityCase = "cross-language-input"; + } else if (relativePath === "fixtures/conformance-expected-output.json") { + assertSchemaInstance(validators.conformanceOutput, value, relativePath); + compatibilityCase = "cross-language-output"; + } + + fixtures.push({ + path: relativePath, + sha256: sha256(source), + expectation, + compatibilityCase, + }); + } + + assertConformanceFixturePair( + fixtureValues.get("fixtures/conformance-minimal-run.json"), + fixtureValues.get("fixtures/conformance-expected-output.json"), + ); + + return { + schema: "paperclip.prp.contract_manifest.v1", + protocolVersion: SUPPORTED_PROTOCOL_VERSION, + fixtureVersion: SUPPORTED_FIXTURE_VERSION, + generatedFrom: ["protocol/schemas", "protocol/fixtures"], + schemas: schemas.map((record) => ({ + path: portableRelative(protocolRoot, record.path), + id: record.value.$id, + sha256: sha256(record.source), + })), + fixtures, + }; +} + +async function main() { + const encoded = `${JSON.stringify(await buildProtocolManifest(), null, 2)}\n`; + if (process.argv.includes("--check")) { + const current = await readFile(outputPath, "utf8").catch(() => ""); + if (current !== encoded) { + process.stderr.write( + "The generated PRP contract manifest is stale. Run pnpm generate:protocol-manifest.\n", + ); + process.exitCode = 1; + } else { + process.stdout.write( + "The generated PRP contract manifest matches its sources.\n", + ); + } + } else { + await writeFile(outputPath, encoded); + process.stdout.write(`Wrote ${outputPath}\n`); + } +} + +if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) + await main(); diff --git a/packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs b/packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs new file mode 100644 index 00000000000..210870ec00c --- /dev/null +++ b/packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs @@ -0,0 +1,70 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const schemaDirectory = resolve(packageRoot, "protocol/schemas"); +const outputPath = resolve( + packageRoot, + "src/protocol/generated/schema-bundle.ts", +); +const schemaNames = [ + "identity", + "capabilities", + "command", + "provider-descriptor", + "provider-event", + "workspace-diff", + "workspace-file-reference", + "semantic-tool", + "usage", + "stop-reason", + "terminal", + "question-set", + "question-response", + "question-adapter-fixture", + "request", + "result", + "event", + "fixture", +]; + +const schemas = await Promise.all( + schemaNames.map(async (name) => ({ + name, + value: JSON.parse( + await readFile(resolve(schemaDirectory, `${name}.schema.json`), "utf8"), + ), + })), +); + +const identifier = (name) => + name.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); +const declarations = schemas + .map( + ({ name, value }) => + `export const ${identifier(name)}Schema = ${JSON.stringify(value, null, 2)} as const;`, + ) + .join("\n\n"); +const bundle = schemaNames + .map((name) => ` ${JSON.stringify(name)}: ${identifier(name)}Schema,`) + .join("\n"); +const generated = `// Generated by scripts/generate-protocol-schema-module.mjs. Do not edit.\n\n${declarations}\n\nexport const prpSchemaBundle = {\n${bundle}\n} as const;\n`; + +if (process.argv.includes("--check")) { + const current = await readFile(outputPath, "utf8").catch(() => ""); + if (current !== generated) { + process.stderr.write( + "Generated PRP schema module is stale. Run pnpm generate:protocol-types.\n", + ); + process.exitCode = 1; + } else { + process.stdout.write( + "Generated PRP schema module matches the JSON Schema sources.\n", + ); + } +} else { + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, generated); + process.stdout.write("Generated PRP schema module.\n"); +} diff --git a/packages/paperclip-runner/scripts/generate-replay-goldens.mjs b/packages/paperclip-runner/scripts/generate-replay-goldens.mjs new file mode 100644 index 00000000000..e2699d08253 --- /dev/null +++ b/packages/paperclip-runner/scripts/generate-replay-goldens.mjs @@ -0,0 +1,68 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + parsePrpFixtureText, + replayParitySummary, + reducePrpFixture, +} from "../dist/index.js"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const fixtureDirectory = resolve(packageRoot, "protocol/fixtures/replay"); +const goldenDirectory = resolve(fixtureDirectory, "golden"); +const fixtureNames = [ + "happy-path", + "failed-run", + "interrupted-run", + "duplicate-event", + "source-gap", + "unknown-optional-fields", +]; +const check = process.argv.includes("--check"); +const stale = []; + +await mkdir(goldenDirectory, { recursive: true }); +for (const fixtureName of fixtureNames) { + const source = await readFile( + resolve(fixtureDirectory, `${fixtureName}.json`), + "utf8", + ); + const validation = parsePrpFixtureText(source); + if (!validation.ok) { + throw new Error( + `${fixtureName}.json failed validation: ${validation.issues.map((issue) => issue.message).join("; ")}`, + ); + } + const snapshot = reducePrpFixture(validation.fixture); + const outputs = [ + ["snapshot", snapshot], + ["summary", replayParitySummary(snapshot)], + ]; + for (const [kind, value] of outputs) { + const path = resolve(goldenDirectory, `${fixtureName}.${kind}.json`); + const generated = `${JSON.stringify(value, null, 2)}\n`; + if (check) { + if ((await readFile(path, "utf8").catch(() => "")) !== generated) { + stale.push(`${fixtureName}.${kind}.json`); + } + } else { + await writeFile(path, generated); + } + } +} + +if (stale.length > 0) { + process.stderr.write( + `Replay golden files are stale:\n- ${stale.join("\n- ")}\n`, + ); + process.exitCode = 1; +} else if (check) { + process.stdout.write( + "Replay golden snapshots and parity summaries are current.\n", + ); +} else { + process.stdout.write( + "Generated Replay golden snapshots and parity summaries.\n", + ); +} diff --git a/packages/paperclip-runner/scripts/generate-semantic-action-catalog.mjs b/packages/paperclip-runner/scripts/generate-semantic-action-catalog.mjs new file mode 100644 index 00000000000..6c62dd653dc --- /dev/null +++ b/packages/paperclip-runner/scripts/generate-semantic-action-catalog.mjs @@ -0,0 +1,26 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { canonicalPaperclipSemanticActionCatalog } from "../dist/catalog/semantic-action-catalog.js"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const outputPath = resolve( + packageRoot, + "generated/semantic-action-catalog.json", +); +const generated = canonicalPaperclipSemanticActionCatalog(); + +if (process.argv.includes("--check")) { + const current = await readFile(outputPath, "utf8").catch(() => ""); + if (current !== generated) { + process.stderr.write( + "generated/semantic-action-catalog.json is stale; run generate:semantic-action-catalog\n", + ); + process.exitCode = 1; + } +} else { + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, generated); + process.stdout.write(`wrote ${outputPath}\n`); +} diff --git a/packages/paperclip-runner/scripts/protocol-contract.mjs b/packages/paperclip-runner/scripts/protocol-contract.mjs new file mode 100644 index 00000000000..35743d4c7d2 --- /dev/null +++ b/packages/paperclip-runner/scripts/protocol-contract.mjs @@ -0,0 +1,243 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import { relative, resolve, sep } from "node:path"; + +import Ajv2020 from "ajv/dist/2020.js"; + +export const JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"; +export const PRP_SCHEMA_ID_PREFIX = "https://paperclip.dev/schemas/prp/v1/"; +export const SUPPORTED_FIXTURE_VERSION = 1; +export const SUPPORTED_PROTOCOL_VERSION = 1; +export const SUPPORTED_EVENT_SCHEMA_VERSION = 1; + +function contractError(code, detail) { + return new Error(`${code}: ${detail}`); +} + +export async function readJson(path) { + const source = await readFile(path, "utf8"); + try { + return { source, value: JSON.parse(source) }; + } catch (error) { + throw contractError("invalid_json", `${path}: ${error.message}`); + } +} + +export async function listJsonFiles(root) { + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => resolve(entry.parentPath, entry.name)) + .sort(); +} + +export function portableRelative(root, path) { + return relative(root, path).split(sep).join("/"); +} + +export function sha256(source) { + return createHash("sha256").update(source).digest("hex"); +} + +function collectReferences(value, output = []) { + if (Array.isArray(value)) { + for (const item of value) collectReferences(item, output); + return output; + } + if (value === null || typeof value !== "object") return output; + for (const [key, child] of Object.entries(value)) { + if (key === "$ref" && typeof child === "string") output.push(child); + else collectReferences(child, output); + } + return output; +} + +function resolveJsonPointer(value, fragment) { + if (fragment === "") return value; + if (!fragment.startsWith("/")) return undefined; + return fragment + .slice(1) + .split("/") + .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) + .reduce((current, part) => { + if (current === null || typeof current !== "object") return undefined; + return Object.hasOwn(current, part) ? current[part] : undefined; + }, value); +} + +export async function loadSchemaCatalog(schemaDirectory) { + const files = await listJsonFiles(schemaDirectory); + const records = await Promise.all(files.map(async (path) => ({ path, ...(await readJson(path)) }))); + const ids = new Map(); + + for (const record of records) { + const schema = record.value; + if (schema.$schema !== JSON_SCHEMA_DIALECT) { + throw contractError("unsupported_schema_dialect", portableRelative(schemaDirectory, record.path)); + } + if (typeof schema.$id !== "string" || !schema.$id.startsWith(PRP_SCHEMA_ID_PREFIX)) { + throw contractError("invalid_schema_id", portableRelative(schemaDirectory, record.path)); + } + if (ids.has(schema.$id)) throw contractError("duplicate_schema_id", schema.$id); + if (typeof schema.title !== "string" || schema.title.length === 0) { + throw contractError("missing_schema_title", schema.$id); + } + ids.set(schema.$id, record); + } + + for (const record of records) { + for (const reference of collectReferences(record.value)) { + const [targetId, fragment = ""] = reference.split("#", 2); + const target = targetId === "" ? record : ids.get(targetId); + if (target === undefined) { + throw contractError("unresolved_schema_reference", `${record.value.$id} -> ${reference}`); + } + if (resolveJsonPointer(target.value, fragment) === undefined) { + throw contractError("unresolved_schema_fragment", `${record.value.$id} -> ${reference}`); + } + } + } + + return records; +} + +export function compileProtocolValidators(schemaRecords) { + const ajv = new Ajv2020({ + allErrors: true, + strict: true, + strictRequired: false, + formats: { + "date-time": /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/, + }, + }); + for (const record of schemaRecords) ajv.addSchema(record.value); + + const get = (name) => { + const id = `${PRP_SCHEMA_ID_PREFIX}${name}.schema.json`; + const validator = ajv.getSchema(id); + if (validator === undefined) throw contractError("missing_schema_validator", id); + return validator; + }; + return { + conformanceFixture: get("conformance-fixture"), + conformanceOutput: get("conformance-output"), + fixture: get("fixture"), + questionAdapterFixture: get("question-adapter-fixture"), + }; +} + +export function assertSchemaInstance(validator, value, location, expectedValid = true) { + const valid = validator(value); + if (valid !== expectedValid) { + const detail = (validator.errors ?? []) + .slice(0, 8) + .map((error) => `${error.instancePath || "/"} ${error.message}`) + .join("; "); + const expectation = expectedValid ? "accepted" : "rejected"; + throw contractError("schema_validation_failed", `${location} must be ${expectation}: ${detail || "no AJV error"}`); + } + return value; +} + +function requireSchema(value, expected, location) { + if (value?.schema !== expected) { + throw contractError("unsupported_required_schema", `${location} requires ${String(value?.schema)}`); + } +} + +function requireVersion(value, expected, name) { + if (value !== expected) { + throw contractError("unsupported_required_version", `${name}=${String(value)}; supported=${expected}`); + } +} + +export function assertReplayFixtureCompatibility(fixture) { + requireSchema(fixture, "paperclip.prp.fixture.v1", "fixture"); + requireVersion(fixture.fixtureVersion, SUPPORTED_FIXTURE_VERSION, "fixtureVersion"); + requireVersion(fixture.protocolVersion, SUPPORTED_PROTOCOL_VERSION, "protocolVersion"); + requireSchema(fixture.identity, "paperclip.prp.identity.v1", "identity"); + requireSchema(fixture.capabilities, "paperclip.prp.capabilities.v1", "capabilities"); + + if (!Array.isArray(fixture.commands)) throw contractError("invalid_fixture", "commands must be an array"); + for (const [index, command] of fixture.commands.entries()) { + requireSchema(command, "paperclip.prp.command.v1", `commands[${index}]`); + } + + if (!Array.isArray(fixture.events) || fixture.events.length === 0) { + throw contractError("invalid_fixture", "events must be a non-empty array"); + } + for (const [index, event] of fixture.events.entries()) { + requireSchema(event, "paperclip.prp.event.v1", `events[${index}]`); + requireVersion(event.schemaVersion, SUPPORTED_EVENT_SCHEMA_VERSION, `events[${index}].schemaVersion`); + } + + requireSchema(fixture.result, "paperclip.run_result.v1", "result"); + return fixture; +} + +export function assertCodexQuestionFixture(fixture) { + requireSchema(fixture, "paperclip.question_adapter_fixture.v1", "question fixture"); + if (fixture.adapter !== "codex") throw contractError("unsupported_provider", String(fixture.adapter)); + requireSchema(fixture.canonicalQuestionSet, "paperclip.question_set.v1", "canonicalQuestionSet"); + requireSchema(fixture.canonicalResponse, "paperclip.question_response.v1", "canonicalResponse"); + if (fixture.nativeRequest?.method !== "item/tool/requestUserInput") { + throw contractError("invalid_codex_question_fixture", "native request method"); + } + + const questions = fixture.canonicalQuestionSet.questions; + if (!Array.isArray(questions) || questions.length === 0) { + throw contractError("invalid_codex_question_fixture", "questions must be non-empty"); + } + const questionIds = new Set(); + const optionIdsByQuestion = new Map(); + for (const question of questions) { + if (typeof question.id !== "string" || question.id.length === 0 || questionIds.has(question.id)) { + throw contractError("invalid_codex_question_fixture", "question IDs must be unique"); + } + questionIds.add(question.id); + const optionIds = new Set(); + for (const option of question.options ?? []) { + if (typeof option.id !== "string" || option.id.length === 0 || optionIds.has(option.id)) { + throw contractError("invalid_codex_question_fixture", `option IDs for ${question.id} must be unique`); + } + optionIds.add(option.id); + } + optionIdsByQuestion.set(question.id, optionIds); + } + for (const [answerId, answer] of Object.entries(fixture.canonicalResponse.answers ?? {})) { + if (!questionIds.has(answerId)) { + throw contractError("invalid_codex_question_fixture", `answer has unknown question ID ${answerId}`); + } + for (const optionId of answer.selectedOptionIds ?? []) { + if (!optionIdsByQuestion.get(answerId)?.has(optionId)) { + throw contractError("invalid_codex_question_fixture", `answer has unknown option ID ${optionId}`); + } + } + } + return fixture; +} + +export function assertConformanceFixturePair(fixture, output) { + if (fixture?.schemaVersion !== "paperclip.runner.conformance.fixture.v1") { + throw contractError("unsupported_required_schema", `conformance fixture requires ${String(fixture?.schemaVersion)}`); + } + if (output?.schemaVersion !== "paperclip.runner.conformance.output.v1") { + throw contractError("unsupported_required_schema", `conformance output requires ${String(output?.schemaVersion)}`); + } + if (fixture.run?.runId !== output.runIdentity?.runId || fixture.run?.sessionId !== output.runIdentity?.sessionId) { + throw contractError("conformance_identity_mismatch", "run or session identity differs"); + } + for (const [index, event] of (fixture.events ?? []).entries()) { + if (event.runId !== fixture.run.runId || event.sequence !== index + 1) { + throw contractError("invalid_conformance_event", `events[${index}] does not match the run sequence`); + } + } + if ( + fixture.result?.status !== output.result?.status + || fixture.result?.summary !== output.result?.summary + || fixture.result?.runId !== fixture.run.runId + ) { + throw contractError("conformance_result_mismatch", "expected output does not match the fixture result"); + } + return { fixture, output }; +} diff --git a/packages/paperclip-runner/src/catalog/index.ts b/packages/paperclip-runner/src/catalog/index.ts new file mode 100644 index 00000000000..863cde6445e --- /dev/null +++ b/packages/paperclip-runner/src/catalog/index.ts @@ -0,0 +1,2 @@ +export * from "./semantic-action-catalog.js"; +export * from "./semantic-action-types.js"; diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts new file mode 100644 index 00000000000..ca51f0e280a --- /dev/null +++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts @@ -0,0 +1,106 @@ +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import Ajv2020 from "ajv/dist/2020.js"; +import { describe, expect, it } from "vitest"; + +import { + PAPERCLIP_SEMANTIC_ACTION_CATALOG, + canonicalPaperclipSemanticActionCatalog, + paperclipSemanticAction, +} from "./semantic-action-catalog.js"; + +const packageRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); + +describe("semantic action catalog", () => { + it("defines one immutable v1 declaration for each Codex-spine action", () => { + const operationIds = PAPERCLIP_SEMANTIC_ACTION_CATALOG.map( + (action) => action.operationId, + ); + + expect(operationIds).toHaveLength(27); + expect(new Set(operationIds).size).toBe(operationIds.length); + expect(operationIds).not.toContain("generic_api_request"); + expect(Object.isFrozen(PAPERCLIP_SEMANTIC_ACTION_CATALOG)).toBe(true); + expect( + Object.isFrozen(paperclipSemanticAction("write_document")?.inputSchema), + ).toBe(true); + }); + + it("compiles every operation input and output schema", () => { + const ajv = new Ajv2020({ + allErrors: true, + allowUnionTypes: true, + strict: true, + }); + + for (const action of PAPERCLIP_SEMANTIC_ACTION_CATALOG) { + expect( + () => ajv.compile(action.inputSchema), + `${action.operationId} input`, + ).not.toThrow(); + expect( + () => ajv.compile(action.outputSchema), + `${action.operationId} output`, + ).not.toThrow(); + } + }); + + it("keeps bounded mutation inputs and rejects undeclared fields", () => { + const ajv = new Ajv2020({ + allErrors: true, + allowUnionTypes: true, + strict: true, + }); + const writeDocument = paperclipSemanticAction("write_document"); + const taskContext = paperclipSemanticAction("get_task_context"); + expect(writeDocument).toBeDefined(); + expect(taskContext).toBeDefined(); + + const validateWrite = ajv.compile(writeDocument!.inputSchema); + expect( + validateWrite({ + idempotencyKey: "write-1", + key: "plan", + title: "Plan", + body: "A bounded body", + baseRevisionId: null, + }), + ).toBe(true); + expect( + validateWrite({ + key: "plan", + title: "Plan", + body: "Body", + baseRevisionId: null, + }), + ).toBe(false); + + const validateContext = ajv.compile(taskContext!.inputSchema); + expect(validateContext({})).toBe(true); + expect(validateContext({ companyId: "forged-company" })).toBe(false); + }); + + it("matches the checked-in generated inventory byte for byte", async () => { + const generated = await readFile( + resolve(packageRoot, "generated/semantic-action-catalog.json"), + "utf8", + ); + + expect(generated).toBe(canonicalPaperclipSemanticActionCatalog()); + }); + + it("does not carry executable authorization or binding hooks", () => { + for (const action of PAPERCLIP_SEMANTIC_ACTION_CATALOG) { + const keys = Object.keys(action); + expect(keys).not.toContain("authorize"); + expect(keys).not.toContain("execute"); + expect(keys).not.toContain("binding"); + } + }); +}); diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts new file mode 100644 index 00000000000..d04b6084f24 --- /dev/null +++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts @@ -0,0 +1,566 @@ +import type { + PaperclipJsonSchema, + PaperclipSemanticActionDescriptor, + PaperclipSemanticActionId, + PaperclipSemanticActionMode, +} from "./semantic-action-types.js"; + +const ALL_MODES = ["standard", "ask", "planning", "skill_test"] as const; +const WORK_MODES = ["standard", "planning", "skill_test"] as const; +const STANDARD_MODE = ["standard", "skill_test"] as const; + +const text = ( + description: string, + maxLength = 20_000, +): PaperclipJsonSchema => ({ + type: "string", + description, + minLength: 1, + maxLength, +}); + +const nullableText = ( + description: string, + maxLength = 20_000, +): PaperclipJsonSchema => ({ + type: ["string", "null"], + description, + maxLength, +}); + +const stringArray = (description: string): PaperclipJsonSchema => ({ + type: "array", + description, + items: { type: "string", minLength: 1 }, + maxItems: 200, + uniqueItems: true, +}); + +const object = ( + properties: Readonly> = {}, + required: readonly string[] = [], +): PaperclipJsonSchema => ({ + type: "object", + properties, + required, + additionalProperties: false, +}); + +const openObject: PaperclipJsonSchema = { + type: "object", + additionalProperties: true, +}; +const idempotency = { + idempotencyKey: text("Caller-stable retry key.", 240), +} as const; +const operationReceipt = object( + { + commandId: text("Stable command identifier.", 200), + disposition: { enum: ["applied", "duplicate"] }, + stateRevision: { type: "integer", minimum: 0 }, + entityRefs: stringArray("Entities affected by the operation."), + scheduledWakeIds: stringArray( + "Wake identifiers scheduled by the operation.", + ), + }, + [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds", + ], +); + +interface DescriptorInput { + readonly operationId: PaperclipSemanticActionId; + readonly title: string; + readonly description: string; + readonly placement?: "always" | "optional"; + readonly effect?: "read" | "write" | "governance"; + readonly requiredClaims?: readonly string[]; + readonly allowedModes?: readonly PaperclipSemanticActionMode[]; + readonly allowedRoles?: readonly string[]; + readonly inputSchema?: PaperclipJsonSchema; + readonly outputSchema?: PaperclipJsonSchema; +} + +function descriptor(input: DescriptorInput): PaperclipSemanticActionDescriptor { + return { + schema: "paperclip.semantic-action.v1", + operationId: input.operationId, + version: 1, + title: input.title, + description: input.description, + placement: input.placement ?? "always", + effect: input.effect ?? "read", + requiredClaims: input.requiredClaims ?? [], + allowedModes: input.allowedModes ?? ALL_MODES, + ...(input.allowedRoles === undefined + ? {} + : { allowedRoles: input.allowedRoles }), + inputSchema: input.inputSchema ?? object(), + outputSchema: input.outputSchema ?? openObject, + }; +} + +const descriptors: readonly PaperclipSemanticActionDescriptor[] = [ + descriptor({ + operationId: "get_task_context", + title: "Get active task context", + description: + "Read the active task, actor, wake context, ancestors, and budget summary.", + }), + descriptor({ + operationId: "get_task_history", + title: "Get active task history", + description: "Read bounded comments on the active task.", + inputSchema: object({ + limit: { type: "integer", minimum: 1, maximum: 200, default: 50 }, + }), + }), + descriptor({ + operationId: "list_documents", + title: "List task documents", + description: "List revisioned documents on the active task.", + }), + descriptor({ + operationId: "read_document", + title: "Read task document", + description: "Read the current revision of one active-task document.", + inputSchema: object({ key: text("Stable issue-document key.", 120) }, [ + "key", + ]), + }), + descriptor({ + operationId: "list_document_revisions", + title: "List document revisions", + description: "Read bounded revision history for one active-task document.", + inputSchema: object( + { + key: text("Stable issue-document key.", 120), + limit: { type: "integer", minimum: 1, maximum: 200, default: 50 }, + }, + ["key"], + ), + }), + descriptor({ + operationId: "report_progress", + title: "Report durable progress", + description: "Append a durable progress comment to the active task.", + effect: "write", + inputSchema: object( + { ...idempotency, body: text("Multiline progress update.") }, + ["idempotencyKey", "body"], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "answer_status_question", + title: "Answer status question", + description: + "Append an answer to a status-only wake without changing task disposition.", + effect: "write", + inputSchema: object( + { ...idempotency, body: text("Concise status answer.") }, + ["idempotencyKey", "body"], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "write_document", + title: "Write revisioned document", + description: + "Create or update an active-task document with optimistic revision safety.", + effect: "write", + allowedModes: WORK_MODES, + inputSchema: object( + { + ...idempotency, + key: text("Stable issue-document key.", 120), + title: text("Document title.", 300), + body: text("Markdown document body.", 200_000), + baseRevisionId: nullableText( + "Current revision id, or null when creating.", + 240, + ), + changeSummary: nullableText("Optional revision summary."), + }, + ["idempotencyKey", "key", "title", "body", "baseRevisionId"], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "request_human_input", + title: "Request structured human input", + description: "Create a typed, durable interaction on the active task.", + effect: "write", + allowedModes: ALL_MODES, + inputSchema: object( + { + ...idempotency, + interactionKind: { + enum: [ + "confirmation", + "checkbox", + "questions", + "suggest_tasks", + "item_verdicts", + ], + }, + title: text("Interaction card title.", 300), + prompt: text("Question or decision prompt.", 10_000), + payload: openObject, + targetRevisionId: nullableText( + "Optional bound document revision.", + 240, + ), + continuationPolicy: { + enum: ["none", "wake_assignee", "wake_assignee_on_accept"], + }, + }, + [ + "idempotencyKey", + "interactionKind", + "title", + "prompt", + "continuationPolicy", + ], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "register_deliverable", + title: "Register inspectable deliverable", + description: + "Register attachment metadata and its work product without returning bytes or credentials.", + effect: "write", + allowedModes: WORK_MODES, + inputSchema: object( + { + ...idempotency, + filename: text("Display filename.", 500), + contentType: text("Media type.", 200), + byteSize: { type: "integer", minimum: 0, maximum: 100_000_000 }, + sha256: { type: "string", pattern: "^[a-fA-F0-9]{64}$" }, + contentRef: text("Opaque content reference.", 2_000), + title: text("Work-product title.", 500), + }, + [ + "idempotencyKey", + "filename", + "contentType", + "byteSize", + "sha256", + "contentRef", + "title", + ], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "finish_task", + title: "Finish active task", + description: "Finish the active task with a durable summary.", + effect: "write", + allowedModes: STANDARD_MODE, + inputSchema: object( + { ...idempotency, summary: text("Completion summary.") }, + ["idempotencyKey", "summary"], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "block_task", + title: "Block active task", + description: + "Block the active task with a durable reason and optional first-class dependencies.", + effect: "write", + allowedModes: STANDARD_MODE, + inputSchema: object( + { + ...idempotency, + reason: text("Block reason."), + blockedByTaskIds: stringArray("Task identifiers that block this task."), + }, + ["idempotencyKey", "reason"], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "request_review", + title: "Request task review", + description: "Move the active task to review with a durable summary.", + effect: "write", + allowedModes: STANDARD_MODE, + inputSchema: object( + { ...idempotency, summary: text("Review handoff summary.") }, + ["idempotencyKey", "summary"], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "list_agents", + title: "List company agents", + description: "List redacted actor profiles in the run company.", + placement: "optional", + requiredClaims: ["discovery:agents:read"], + }), + descriptor({ + operationId: "get_agent", + title: "Get company agent", + description: "Read one redacted actor profile in the run company.", + placement: "optional", + requiredClaims: ["discovery:agents:read"], + inputSchema: object({ actorId: text("Actor identifier.", 200) }, [ + "actorId", + ]), + }), + descriptor({ + operationId: "search_tasks", + title: "Search company tasks", + description: "Search tasks by text and status within the run company.", + placement: "optional", + requiredClaims: ["discovery:tasks:read"], + inputSchema: object({ + query: { type: "string", maxLength: 500 }, + statuses: { + type: "array", + items: { + enum: [ + "backlog", + "todo", + "in_progress", + "in_review", + "done", + "blocked", + "cancelled", + ], + }, + maxItems: 7, + uniqueItems: true, + }, + limit: { type: "integer", minimum: 1, maximum: 200, default: 50 }, + }), + }), + descriptor({ + operationId: "list_approvals", + title: "List approvals", + description: "List approvals in the run company.", + placement: "optional", + effect: "governance", + requiredClaims: ["governance:approvals:read"], + }), + descriptor({ + operationId: "get_approval", + title: "Get approval", + description: "Read one approval without protected data.", + placement: "optional", + effect: "governance", + requiredClaims: ["governance:approvals:read"], + inputSchema: object({ approvalId: text("Approval identifier.", 200) }, [ + "approvalId", + ]), + }), + descriptor({ + operationId: "get_approval_context", + title: "Get approval context", + description: "Read one approval, its comments, and linked tasks.", + placement: "optional", + effect: "governance", + requiredClaims: ["governance:approvals:read"], + inputSchema: object({ approvalId: text("Approval identifier.", 200) }, [ + "approvalId", + ]), + }), + descriptor({ + operationId: "get_workspace_runtime", + title: "Get workspace runtime", + description: "Read active-task workspace services.", + placement: "optional", + requiredClaims: ["workspace:read"], + }), + descriptor({ + operationId: "control_workspace_service", + title: "Control workspace service", + description: "Start or stop one active-task workspace service.", + placement: "optional", + effect: "write", + requiredClaims: ["workspace:control"], + allowedModes: STANDARD_MODE, + inputSchema: object( + { + ...idempotency, + serviceId: text("Workspace service identifier.", 200), + action: { enum: ["start", "stop"] }, + }, + ["idempotencyKey", "serviceId", "action"], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "set_dependencies", + title: "Set task dependencies", + description: "Replace the active task's first-class blocker set.", + placement: "optional", + effect: "write", + requiredClaims: ["dependencies:write"], + allowedModes: STANDARD_MODE, + inputSchema: object( + { + ...idempotency, + blockedByTaskIds: stringArray("Replacement blocker task identifiers."), + }, + ["idempotencyKey", "blockedByTaskIds"], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "create_task", + title: "Create child task", + description: "Create one child task under the active task.", + placement: "optional", + effect: "write", + requiredClaims: ["delegation:tasks:create"], + allowedModes: STANDARD_MODE, + inputSchema: object( + { + ...idempotency, + title: text("Child task title.", 500), + description: nullableText("Child task description."), + assigneeActorId: nullableText("Optional actor assignee.", 200), + priority: { enum: ["critical", "high", "medium", "low"] }, + blockedByTaskIds: stringArray("Initial blocker task identifiers."), + }, + ["idempotencyKey", "title"], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "request_approval", + title: "Request approval", + description: "Create a governed approval and waiting posture.", + placement: "optional", + effect: "governance", + requiredClaims: ["governance:approvals:request"], + allowedModes: STANDARD_MODE, + inputSchema: object( + { + ...idempotency, + approvalType: text("Stable approval type.", 200), + payload: openObject, + }, + ["idempotencyKey", "approvalType", "payload"], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "decide_approval", + title: "Decide approval", + description: "Decide an approval as an explicitly authorized approver.", + placement: "optional", + effect: "governance", + requiredClaims: ["governance:approvals:decide"], + allowedRoles: ["board", "approver", "security"], + allowedModes: STANDARD_MODE, + inputSchema: object( + { + ...idempotency, + approvalId: text("Approval identifier.", 200), + decision: { enum: ["approved", "rejected", "cancelled"] }, + note: text("Decision note."), + }, + ["idempotencyKey", "approvalId", "decision", "note"], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "comment_on_approval", + title: "Comment on approval", + description: "Add a durable comment to an approval.", + placement: "optional", + effect: "governance", + requiredClaims: ["governance:approvals:comment"], + inputSchema: object( + { + ...idempotency, + approvalId: text("Approval identifier.", 200), + body: text("Approval comment."), + }, + ["idempotencyKey", "approvalId", "body"], + ), + outputSchema: operationReceipt, + }), + descriptor({ + operationId: "schedule_wake", + title: "Schedule bounded wake", + description: "Schedule a bounded continuation wake.", + placement: "optional", + effect: "write", + requiredClaims: ["control_plane:wakes"], + allowedModes: STANDARD_MODE, + inputSchema: object( + { + ...idempotency, + reason: { + enum: [ + "manual", + "issue_commented", + "interaction_resolved", + "approval_resolved", + "blockers_resolved", + "scheduled_retry", + "resume", + ], + }, + payload: openObject, + delaySeconds: { type: "integer", minimum: 1, maximum: 86_400 }, + }, + ["idempotencyKey", "reason", "delaySeconds"], + ), + outputSchema: operationReceipt, + }), +]; + +const byId = new Map( + descriptors.map((item) => [item.operationId, deepFreeze(item)]), +); +if (byId.size !== descriptors.length) + throw new Error("duplicate semantic action operation id"); + +/** + * Canonical declarations only. Consumers must not treat membership as + * permission to expose or invoke an action. + */ +export const PAPERCLIP_SEMANTIC_ACTION_CATALOG = Object.freeze([ + ...byId.values(), +]); + +export function paperclipSemanticAction( + operationId: string, +): PaperclipSemanticActionDescriptor | undefined { + return byId.get(operationId as PaperclipSemanticActionId); +} + +export function canonicalPaperclipSemanticActionCatalog(): string { + return `${JSON.stringify(sortKeys(PAPERCLIP_SEMANTIC_ACTION_CATALOG), null, 2)}\n`; +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) + return value; + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys); + if (typeof value !== "object" || value === null) return value; + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, sortKeys(record[key])]), + ); +} diff --git a/packages/paperclip-runner/src/catalog/semantic-action-types.ts b/packages/paperclip-runner/src/catalog/semantic-action-types.ts new file mode 100644 index 00000000000..0eb1130bb5c --- /dev/null +++ b/packages/paperclip-runner/src/catalog/semantic-action-types.ts @@ -0,0 +1,84 @@ +export type PaperclipSemanticActionId = + | "get_task_context" + | "get_task_history" + | "list_documents" + | "read_document" + | "list_document_revisions" + | "report_progress" + | "answer_status_question" + | "write_document" + | "request_human_input" + | "register_deliverable" + | "finish_task" + | "block_task" + | "request_review" + | "list_agents" + | "get_agent" + | "search_tasks" + | "list_approvals" + | "get_approval" + | "get_approval_context" + | "get_workspace_runtime" + | "control_workspace_service" + | "set_dependencies" + | "create_task" + | "request_approval" + | "decide_approval" + | "comment_on_approval" + | "schedule_wake"; + +export type PaperclipSemanticActionPlacement = "always" | "optional"; +export type PaperclipSemanticActionMode = + "standard" | "ask" | "planning" | "skill_test"; +export type PaperclipSemanticActionEffect = "read" | "write" | "governance"; + +export type PaperclipJsonValue = + | null + | boolean + | number + | string + | readonly PaperclipJsonValue[] + | { readonly [key: string]: PaperclipJsonValue }; + +/** The JSON Schema subset used by the v1 semantic action catalog. */ +export interface PaperclipJsonSchema { + readonly type?: string | readonly string[]; + readonly title?: string; + readonly description?: string; + readonly properties?: Readonly>; + readonly required?: readonly string[]; + readonly additionalProperties?: boolean | PaperclipJsonSchema; + readonly items?: PaperclipJsonSchema; + readonly enum?: readonly PaperclipJsonValue[]; + readonly oneOf?: readonly PaperclipJsonSchema[]; + readonly anyOf?: readonly PaperclipJsonSchema[]; + readonly minimum?: number; + readonly maximum?: number; + readonly minLength?: number; + readonly maxLength?: number; + readonly minItems?: number; + readonly maxItems?: number; + readonly uniqueItems?: boolean; + readonly pattern?: string; + readonly format?: string; + readonly default?: PaperclipJsonValue; +} + +/** + * A transport-neutral declaration. Catalog membership never grants discovery + * or invocation authority; a run-scoped authorization layer must do that. + */ +export interface PaperclipSemanticActionDescriptor { + readonly schema: "paperclip.semantic-action.v1"; + readonly operationId: PaperclipSemanticActionId; + readonly version: 1; + readonly title: string; + readonly description: string; + readonly placement: PaperclipSemanticActionPlacement; + readonly effect: PaperclipSemanticActionEffect; + readonly requiredClaims: readonly string[]; + readonly allowedModes: readonly PaperclipSemanticActionMode[]; + readonly allowedRoles?: readonly string[]; + readonly inputSchema: PaperclipJsonSchema; + readonly outputSchema: PaperclipJsonSchema; +} diff --git a/packages/paperclip-runner/src/conformance/semantic-conformance.test.ts b/packages/paperclip-runner/src/conformance/semantic-conformance.test.ts new file mode 100644 index 00000000000..d44c42184f7 --- /dev/null +++ b/packages/paperclip-runner/src/conformance/semantic-conformance.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; + +import { + SemanticConformanceMismatchError, + runSemanticConformanceKit, + type SemanticConformanceAdapter, + type SemanticConformanceObservation, +} from "./semantic-conformance.js"; + +const allowed: SemanticConformanceObservation = { + authorization: { outcome: "allowed" }, + state: { task: { status: "done" } }, + effects: [{ kind: "issue_status", status: "done" }], + audit: [{ action: "finish_task" }], +}; + +function adapter( + id: string, + observation: SemanticConformanceObservation, +): SemanticConformanceAdapter { + return { id, execute: async () => structuredClone(observation) }; +} + +describe("semantic conformance kit", () => { + it("accepts equivalent observations independent of object key order", async () => { + const report = await runSemanticConformanceKit({ + vectors: [ + { + id: "finish", + operationId: "finish_task", + input: { summary: "done" }, + }, + ], + adapters: [ + adapter("mock", allowed), + adapter("real", { + audit: [{ action: "finish_task" }], + effects: [{ status: "done", kind: "issue_status" }], + state: { task: { status: "done" } }, + authorization: { outcome: "allowed" }, + }), + ], + }); + + expect(report.schema).toBe("paperclip.semantic-conformance-report.v1"); + expect(report.rows).toHaveLength(1); + expect(report.rows[0]?.adapterIds).toEqual(["mock", "real"]); + }); + + it("fails explicitly when adapters diverge", async () => { + await expect( + runSemanticConformanceKit({ + vectors: [{ id: "finish", operationId: "finish_task", input: {} }], + adapters: [ + adapter("mock", allowed), + adapter("real", { + ...allowed, + authorization: { outcome: "denied", code: "forbidden" }, + }), + ], + }), + ).rejects.toBeInstanceOf(SemanticConformanceMismatchError); + }); + + it("requires at least two adapters", async () => { + await expect( + runSemanticConformanceKit({ + vectors: [], + adapters: [adapter("only", allowed)], + }), + ).rejects.toThrow("semantic_conformance_requires_two_adapters"); + }); + + it("requires unique adapter identities", async () => { + await expect( + runSemanticConformanceKit({ + vectors: [], + adapters: [ + adapter("duplicate", allowed), + adapter("duplicate", allowed), + ], + }), + ).rejects.toThrow("semantic_conformance_adapter_ids_must_be_unique"); + }); + + it("fails closed for non-JSON normalized observations", async () => { + await expect( + runSemanticConformanceKit({ + vectors: [{ id: "finish", operationId: "finish_task", input: {} }], + adapters: [ + adapter("mock", allowed), + { + id: "invalid", + execute: async () => + ({ + ...allowed, + state: new Date(), + }) as unknown as SemanticConformanceObservation, + }, + ], + }), + ).rejects.toThrow("semantic_conformance_non_json_observation"); + }); + + it("fails closed for sparse normalized arrays", async () => { + const sparseEffects = Array(1); + await expect( + runSemanticConformanceKit({ + vectors: [{ id: "finish", operationId: "finish_task", input: {} }], + adapters: [ + adapter("mock", allowed), + { + id: "invalid", + execute: async () => + ({ + ...allowed, + effects: sparseEffects, + }) as unknown as SemanticConformanceObservation, + }, + ], + }), + ).rejects.toThrow("semantic_conformance_non_json_observation"); + }); +}); diff --git a/packages/paperclip-runner/src/conformance/semantic-conformance.ts b/packages/paperclip-runner/src/conformance/semantic-conformance.ts new file mode 100644 index 00000000000..38408d6d983 --- /dev/null +++ b/packages/paperclip-runner/src/conformance/semantic-conformance.ts @@ -0,0 +1,167 @@ +export type SemanticConformanceAuthorization = + | { readonly outcome: "allowed" } + | { readonly outcome: "denied"; readonly code: string }; + +export type SemanticConformanceJsonValue = + | null + | boolean + | number + | string + | readonly SemanticConformanceJsonValue[] + | { readonly [key: string]: SemanticConformanceJsonValue }; + +export interface SemanticConformanceVector { + readonly id: string; + readonly operationId: string; + readonly input: SemanticConformanceJsonValue; +} + +export interface SemanticConformanceObservation { + readonly authorization: SemanticConformanceAuthorization; + readonly state: SemanticConformanceJsonValue; + readonly effects: readonly SemanticConformanceJsonValue[]; + readonly audit: readonly SemanticConformanceJsonValue[]; +} + +export interface SemanticConformanceAdapter { + readonly id: string; + execute( + vector: SemanticConformanceVector, + ): Promise; +} + +export interface SemanticConformanceReportRow { + readonly vectorId: string; + readonly operationId: string; + readonly adapterIds: readonly string[]; + readonly observation: SemanticConformanceObservation; +} + +export interface SemanticConformanceReport { + readonly schema: "paperclip.semantic-conformance-report.v1"; + readonly rows: readonly SemanticConformanceReportRow[]; +} + +export class SemanticConformanceMismatchError extends Error { + readonly code = "semantic_conformance_mismatch" as const; + + constructor( + readonly vectorId: string, + readonly baselineAdapterId: string, + readonly mismatchedAdapterId: string, + ) { + super( + `Semantic conformance mismatch for ${vectorId}: ${mismatchedAdapterId} differs from ${baselineAdapterId}`, + ); + this.name = "SemanticConformanceMismatchError"; + } +} + +/** + * Compare normalized authorization, state, effects, and audit output from two + * or more adapters. Adapters own setup and normalization; the kit owns a + * deterministic, provider-neutral comparison. + */ +export async function runSemanticConformanceKit(input: { + readonly vectors: readonly SemanticConformanceVector[]; + readonly adapters: readonly SemanticConformanceAdapter[]; +}): Promise { + if (input.adapters.length < 2) { + throw new Error("semantic_conformance_requires_two_adapters"); + } + if ( + new Set(input.adapters.map((adapter) => adapter.id)).size !== + input.adapters.length + ) { + throw new Error("semantic_conformance_adapter_ids_must_be_unique"); + } + + const rows: SemanticConformanceReportRow[] = []; + for (const vector of input.vectors) { + const observations = await Promise.all( + input.adapters.map(async (adapter) => ({ + adapter, + observation: await adapter.execute(vector), + })), + ); + const baseline = observations[0]!; + const baselineJson = canonicalJson(baseline.observation); + for (const candidate of observations.slice(1)) { + if (canonicalJson(candidate.observation) !== baselineJson) { + throw new SemanticConformanceMismatchError( + vector.id, + baseline.adapter.id, + candidate.adapter.id, + ); + } + } + rows.push( + Object.freeze({ + vectorId: vector.id, + operationId: vector.operationId, + adapterIds: Object.freeze(input.adapters.map((adapter) => adapter.id)), + observation: structuredClone(baseline.observation), + }), + ); + } + + return Object.freeze({ + schema: "paperclip.semantic-conformance-report.v1", + rows: Object.freeze(rows), + }); +} + +function canonicalJson( + value: unknown, + ancestors = new WeakSet(), +): string { + if ( + value === null || + typeof value === "boolean" || + typeof value === "string" + ) { + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isFinite(value)) throw invalidObservation(); + return JSON.stringify(value); + } + if (typeof value !== "object") throw invalidObservation(); + if (ancestors.has(value)) { + throw new Error("semantic_conformance_cyclic_observation"); + } + + const prototype = Object.getPrototypeOf(value); + if ( + !Array.isArray(value) && + prototype !== Object.prototype && + prototype !== null + ) { + throw invalidObservation(); + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + const entries: string[] = []; + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) throw invalidObservation(); + entries.push(canonicalJson(value[index], ancestors)); + } + return `[${entries.join(",")}]`; + } + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${canonicalJson(record[key], ancestors)}`, + ) + .join(",")}}`; + } finally { + ancestors.delete(value); + } +} + +function invalidObservation(): Error { + return new Error("semantic_conformance_non_json_observation"); +} diff --git a/packages/paperclip-runner/src/contracts/completion-result.test.ts b/packages/paperclip-runner/src/contracts/completion-result.test.ts new file mode 100644 index 00000000000..321f0789bba --- /dev/null +++ b/packages/paperclip-runner/src/contracts/completion-result.test.ts @@ -0,0 +1,107 @@ +import Ajv2020 from "ajv/dist/2020.js"; +import { describe, expect, it } from "vitest"; +import { + PRP_COMPLETION_RESULT_OUTPUT_SCHEMA, + PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA, +} from "./completion-result.js"; + +const baseResult = { + schema: "paperclip.run_result.v1", + reportedWorkDisposition: "done", + summary: "Completed the requested work.", + completionClaim: { + contractRevision: "1", + objectiveSatisfied: true, + criteria: [ + { criterionId: "objective", status: "satisfied", evidenceRefs: [] }, + ], + remainingWork: [], + }, + evidence: [], + verification: [], + attentionRequests: [], + artifacts: [], +}; + +describe("provider-neutral completion result schema", () => { + const validate = new Ajv2020({ allErrors: true, strict: false }).compile( + PRP_COMPLETION_RESULT_OUTPUT_SCHEMA, + ); + + it("allows done with no verification and no actionable attention", () => { + expect(validate(structuredClone(baseResult))).toBe(true); + }); + + it("allows provider tool callers to omit the constant schema discriminator", () => { + const providerValidate = new Ajv2020({ + allErrors: true, + strict: false, + }).compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA); + const providerResult = structuredClone(baseResult) as Record< + string, + unknown + >; + delete providerResult.schema; + expect(providerValidate(providerResult)).toBe(true); + }); + + it("admits known smaller-model aliases at the provider boundary for canonical normalization", () => { + const providerValidate = new Ajv2020({ + allErrors: true, + strict: false, + }).compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA); + const providerResult = structuredClone(baseResult); + providerResult.schema = "paperclip_paperclip_finish"; + providerResult.reportedWorkDisposition = "completed"; + providerResult.completionClaim.criteria[0]!.status = "passed"; + providerResult.verification = [ + { commandOrCheck: "model check", status: "pass" } as never, + ]; + expect(providerValidate(providerResult)).toBe(true); + }); + + it("requires a reason code for verification that was not run", () => { + const result = structuredClone(baseResult); + result.verification = [ + { commandOrCheck: "Run tests", status: "not_run" } as never, + ]; + expect(validate(result)).toBe(false); + + result.verification = [ + { + commandOrCheck: "Run tests", + status: "not_run", + reasonCode: "tool_unavailable", + } as never, + ]; + expect(validate(result)).toBe(true); + }); + + it("requires needs_review for actionable attention", () => { + const result = structuredClone(baseResult); + result.attentionRequests = [ + { + kind: "review", + summary: "Confirm the external result.", + ownerClass: "human", + } as never, + ]; + expect(validate(result)).toBe(false); + + result.reportedWorkDisposition = "needs_review"; + expect(validate(result)).toBe(true); + }); + + it("requires agent-owned attention to identify its target agent", () => { + const result = structuredClone(baseResult); + result.reportedWorkDisposition = "needs_review"; + result.attentionRequests = [ + { + kind: "agent_handoff", + summary: "Ask the deployment agent to continue.", + ownerClass: "agent", + } as never, + ]; + expect(validate(result)).toBe(false); + }); +}); diff --git a/packages/paperclip-runner/src/contracts/completion-result.ts b/packages/paperclip-runner/src/contracts/completion-result.ts new file mode 100644 index 00000000000..11be498be4b --- /dev/null +++ b/packages/paperclip-runner/src/contracts/completion-result.ts @@ -0,0 +1,387 @@ +/** Provider-neutral semantic completion tools and their strict model-facing schemas. */ +export const PRP_COMPLETION_TOOL_NAME = "paperclip_finish" as const; +export const PRP_BLOCK_TOOL_NAME = "paperclip_block" as const; +export const PRP_SEMANTIC_TOOL_NAMES = [ + PRP_COMPLETION_TOOL_NAME, + PRP_BLOCK_TOOL_NAME, +] as const; + +export const PRP_VERIFICATION_REASON_CODES = [ + "environment_unavailable", + "tool_unavailable", + "dependency_missing", + "permission_denied", + "credential_missing", + "policy_restricted", + "external_service_unavailable", + "budget_exhausted", + "other", +] as const; + +export const PRP_ACTIONABLE_ATTENTION_KINDS = [ + "review", + "human_input", + "approval", + "credential", + "external_action", + "agent_handoff", +] as const; + +export const PRP_ATTENTION_OWNER_CLASSES = [ + "human", + "agent", + "external_system", +] as const; + +const completionClaimSchema = { + type: "object", + additionalProperties: false, + required: [ + "contractRevision", + "objectiveSatisfied", + "criteria", + "remainingWork", + ], + properties: { + contractRevision: { type: "string", minLength: 1 }, + objectiveSatisfied: { type: "boolean" }, + criteria: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["criterionId", "status", "evidenceRefs"], + properties: { + criterionId: { type: "string", minLength: 1 }, + status: { enum: ["satisfied", "not_satisfied", "unknown"] }, + evidenceRefs: { type: "array", items: { type: "string" } }, + }, + }, + }, + remainingWork: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["description", "blocksCompletion"], + properties: { + description: { type: "string", minLength: 1 }, + blocksCompletion: { type: "boolean" }, + }, + }, + }, + }, +} as const; + +const evidenceSchema = { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["ref"], + properties: { ref: { type: "string", minLength: 1 } }, + }, +} as const; + +const verificationSchema = { + type: "array", + description: + "Checks used to verify the work. Use failed only when the check actually ran and found a defect in the work. If a check could not run or complete because the environment, tool, dependency, permission, credential, policy, external service, or budget was unavailable, use not_run with reasonCode even when the attempted command exited non-zero.", + items: { + type: "object", + additionalProperties: false, + required: ["commandOrCheck", "status"], + properties: { + commandOrCheck: { type: "string", minLength: 1 }, + status: { + enum: ["passed", "failed", "not_run"], + description: + "passed: the check ran and succeeded. failed: the check ran to a meaningful verdict and found the work incorrect. not_run: the check had no meaningful verdict because it was not attempted or an environment/tool/dependency/permission/credential/policy/service/budget limitation prevented it from completing; command launch or setup failures are not_run.", + }, + reasonCode: { + enum: [...PRP_VERIFICATION_REASON_CODES], + description: + "Required for not_run; identify why the check had no meaningful verdict.", + }, + detail: { type: "string" }, + artifactRef: { type: "string", minLength: 1 }, + }, + allOf: [ + { + if: { + properties: { status: { const: "not_run" } }, + required: ["status"], + }, + then: { required: ["reasonCode"] }, + }, + ], + }, +} as const; + +const attentionRequestsSchema = { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["kind", "summary", "ownerClass"], + properties: { + kind: { enum: [...PRP_ACTIONABLE_ATTENTION_KINDS] }, + summary: { type: "string", minLength: 1 }, + ownerClass: { enum: [...PRP_ATTENTION_OWNER_CLASSES] }, + targetAgentId: { type: "string", minLength: 1 }, + }, + allOf: [ + { + if: { + properties: { ownerClass: { const: "agent" } }, + required: ["ownerClass"], + }, + then: { required: ["targetAgentId"] }, + }, + ], + }, +} as const; + +const artifactsSchema = { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["kind", "ref"], + properties: { + kind: { type: "string", minLength: 1 }, + ref: { type: "string", minLength: 1 }, + }, + }, +} as const; + +const commonResultProperties = { + schema: { type: "string", const: "paperclip.run_result.v1" }, + summary: { type: "string", minLength: 1 }, + completionClaim: completionClaimSchema, + evidence: evidenceSchema, + verification: verificationSchema, + attentionRequests: attentionRequestsSchema, + artifacts: artifactsSchema, +} as const; + +export const PRP_COMPLETION_RESULT_OUTPUT_SCHEMA = { + type: "object", + additionalProperties: false, + required: [ + "reportedWorkDisposition", + "summary", + "completionClaim", + "evidence", + "verification", + "attentionRequests", + "artifacts", + ], + properties: { + ...commonResultProperties, + reportedWorkDisposition: { enum: ["done", "needs_review"] }, + }, + allOf: [ + { + if: { + properties: { reportedWorkDisposition: { const: "done" } }, + required: ["reportedWorkDisposition"], + }, + then: { properties: { attentionRequests: { maxItems: 0 } } }, + }, + { + if: { + properties: { reportedWorkDisposition: { const: "needs_review" } }, + required: ["reportedWorkDisposition"], + }, + then: { properties: { attentionRequests: { minItems: 1 } } }, + }, + ], +} as const; + +export const PRP_BLOCK_RESULT_OUTPUT_SCHEMA = { + type: "object", + additionalProperties: false, + required: [ + "reportedWorkDisposition", + "summary", + "completionClaim", + "evidence", + "verification", + "attentionRequests", + "artifacts", + "blocker", + ], + properties: { + ...commonResultProperties, + reportedWorkDisposition: { type: "string", const: "blocked" }, + attentionRequests: { + type: "array", + maxItems: 0, + items: attentionRequestsSchema.items, + }, + blocker: { + type: "object", + additionalProperties: false, + required: ["reasonCode", "owner", "unblockAction", "scope"], + properties: { + reasonCode: { type: "string", minLength: 1 }, + owner: { + type: "object", + additionalProperties: false, + required: ["kind", "name"], + properties: { + kind: { enum: ["agent", "user", "system", "external"] }, + name: { type: "string", minLength: 1 }, + }, + }, + unblockAction: { type: "string", minLength: 1 }, + scope: { enum: ["current_track", "task_wide"] }, + }, + }, + }, +} as const; + +const providerVerificationCompatibilitySchema = { + type: "array", + description: + "Checks used to verify the work. Use failed only when the check actually ran and found a defect in the work. If a check could not run or complete because the environment, tool, dependency, permission, credential, policy, external service, or budget was unavailable, use not_run with reasonCode even when the attempted command exited non-zero.", + items: { + type: "object", + additionalProperties: true, + required: ["status"], + properties: { + commandOrCheck: { type: "string", minLength: 1 }, + command: { type: "string", minLength: 1 }, + status: { + enum: [ + "passed", + "failed", + "not_run", + "blocked", + "skipped", + "pass", + "success", + "succeeded", + "fail", + ], + description: + "passed: the check ran and succeeded. failed: the check ran to a meaningful verdict and found the work incorrect. not_run: no meaningful verdict because the check was not attempted or could not complete. Prefer not_run over legacy blocked/skipped, and include reasonCode for any unavailable check.", + }, + reasonCode: { + enum: [...PRP_VERIFICATION_REASON_CODES], + description: + "Required for not_run; identify why the check had no meaningful verdict.", + }, + detail: { type: "string" }, + result: { type: "string" }, + cwd: { type: "string" }, + artifactRef: { type: "string", minLength: 1 }, + }, + }, +} as const; + +const providerCompletionClaimCompatibilitySchema = { + ...completionClaimSchema, + properties: { + ...completionClaimSchema.properties, + criteria: { + ...completionClaimSchema.properties.criteria, + items: { + ...completionClaimSchema.properties.criteria.items, + properties: { + ...completionClaimSchema.properties.criteria.items.properties, + status: { + enum: [ + "satisfied", + "not_satisfied", + "unknown", + "pass", + "passed", + "complete", + "completed", + "fail", + "failed", + "incomplete", + ], + description: + "Use satisfied when the criterion is met, not_satisfied when it is not met, or unknown when no verdict is available. Common provider aliases are accepted and normalized.", + }, + }, + }, + }, + }, +} as const; + +const providerCommonResultProperties = { + ...commonResultProperties, + schema: { + enum: [ + "paperclip.run_result.v1", + "paperclip_finish", + "paperclip_paperclip_finish", + "paperclip_finish.v1", + ], + }, + completionClaim: providerCompletionClaimCompatibilitySchema, +} as const; + +const providerAttentionCompatibilitySchema = { + type: "array", + items: { + type: "object", + additionalProperties: true, + required: ["kind", "summary"], + properties: { + kind: { type: "string", minLength: 1 }, + summary: { type: "string", minLength: 1 }, + ownerClass: { enum: [...PRP_ATTENTION_OWNER_CLASSES] }, + targetAgentId: { type: "string", minLength: 1 }, + requestedCapability: { type: "string", minLength: 1 }, + requiredAuthority: { type: "string", minLength: 1 }, + target: { type: "object", additionalProperties: true }, + }, + }, +} as const; + +/** + * Provider-facing compatibility schemas. Canonical PRP validation still uses + * the strict schemas above after legacy aliases have been normalized. + */ +export const PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA = { + type: "object", + additionalProperties: true, + required: [ + "reportedWorkDisposition", + "summary", + "completionClaim", + "evidence", + "verification", + ], + properties: { + ...providerCommonResultProperties, + reportedWorkDisposition: { enum: ["done", "needs_review", "completed"] }, + verification: providerVerificationCompatibilitySchema, + attentionRequests: providerAttentionCompatibilitySchema, + }, +} as const; + +export const PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA = { + type: "object", + additionalProperties: true, + required: [ + "reportedWorkDisposition", + "summary", + "completionClaim", + "evidence", + "verification", + "blocker", + ], + properties: { + ...providerCommonResultProperties, + reportedWorkDisposition: { type: "string", const: "blocked" }, + verification: providerVerificationCompatibilitySchema, + attentionRequests: providerAttentionCompatibilitySchema, + blocker: PRP_BLOCK_RESULT_OUTPUT_SCHEMA.properties.blocker, + }, +} as const; diff --git a/packages/paperclip-runner/src/contracts/question-set.test.ts b/packages/paperclip-runner/src/contracts/question-set.test.ts new file mode 100644 index 00000000000..04644da0aaf --- /dev/null +++ b/packages/paperclip-runner/src/contracts/question-set.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { + PAPERCLIP_QUESTION_RESPONSE_SCHEMA, + PAPERCLIP_QUESTION_SET_SCHEMA, + parsePaperclipQuestionResponse, + parsePaperclipQuestionSet, + type PaperclipQuestionSet, +} from "./question-set.js"; + +const questionSet: PaperclipQuestionSet = { + schema: PAPERCLIP_QUESTION_SET_SCHEMA, + title: "Release input", + questions: [ + { + id: "environment", + prompt: "Where should we deploy?", + required: true, + answerMode: "single_select", + options: [ + { id: "staging", label: "Staging" }, + { id: "production", label: "Production" }, + ], + customAnswer: { enabled: true, label: "Other" }, + }, + { + id: "replicas", + prompt: "How many replicas?", + required: true, + answerMode: "text", + textValidation: { inputType: "integer", minimum: 1, maximum: 20 }, + }, + ], +}; + +describe("Paperclip question-set contract", () => { + it("round-trips the portable presentation model", () => { + expect(parsePaperclipQuestionSet(questionSet)).toEqual(questionSet); + expect( + parsePaperclipQuestionResponse(questionSet, { + schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, + answers: { + environment: { selectedOptionIds: ["staging"] }, + replicas: { text: "3" }, + }, + }), + ).toEqual({ + schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, + answers: { + environment: { selectedOptionIds: ["staging"] }, + replicas: { text: "3" }, + }, + }); + }); + + it("rejects missing, unknown, and provider-shaped answers", () => { + expect(() => + parsePaperclipQuestionResponse(questionSet, { + schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, + answers: { + environment: { selectedOptionIds: ["unknown"] }, + replicas: { text: "3" }, + }, + }), + ).toThrow(/unknown option/); + expect(() => + parsePaperclipQuestionResponse(questionSet, { + schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, + answers: { environment: { selectedOptionIds: ["staging"] } }, + }), + ).toThrow(/replicas.*required/); + expect(() => + parsePaperclipQuestionResponse(questionSet, { + answers: { environment: { answers: ["Staging"] } }, + }), + ).toThrow(/paperclip.question_response.v1/); + expect(() => + parsePaperclipQuestionResponse(questionSet, { + schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, + answers: { + environment: { answers: ["Staging"] }, + replicas: { text: "3" }, + }, + }), + ).toThrow(/canonical response contract/); + }); + + it("applies typed numeric validation before an adapter sees the answer", () => { + expect(() => + parsePaperclipQuestionResponse(questionSet, { + schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, + answers: { + environment: { customText: "Canary" }, + replicas: { text: "3.5" }, + }, + }), + ).toThrow(/valid integer/); + expect(() => + parsePaperclipQuestionResponse(questionSet, { + schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, + answers: { + environment: { customText: "Canary" }, + replicas: { text: "21" }, + }, + }), + ).toThrow(/at most 20/); + }); +}); diff --git a/packages/paperclip-runner/src/contracts/question-set.ts b/packages/paperclip-runner/src/contracts/question-set.ts new file mode 100644 index 00000000000..a0a0323fea9 --- /dev/null +++ b/packages/paperclip-runner/src/contracts/question-set.ts @@ -0,0 +1,664 @@ +export const PAPERCLIP_QUESTION_SET_SCHEMA = + "paperclip.question_set.v1" as const; +export const PAPERCLIP_QUESTION_RESPONSE_SCHEMA = + "paperclip.question_response.v1" as const; +export const PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2 = + "paperclip.runtime_request.v2" as const; + +export type PaperclipQuestionAnswerMode = + "single_select" | "multi_select" | "text"; + +export interface PaperclipQuestionOption { + id: string; + label: string; + description?: string; +} + +export interface PaperclipQuestionCustomAnswer { + enabled: true; + label?: string; + placeholder?: string; +} + +export interface PaperclipQuestionTextValidation { + minLength?: number; + maxLength?: number; + pattern?: string; + inputType?: "text" | "number" | "integer"; + minimum?: number; + maximum?: number; +} + +export interface PaperclipQuestion { + id: string; + header?: string; + prompt: string; + helpText?: string; + required: boolean; + answerMode: PaperclipQuestionAnswerMode; + options?: PaperclipQuestionOption[]; + customAnswer?: PaperclipQuestionCustomAnswer; + textValidation?: PaperclipQuestionTextValidation; +} + +export interface PaperclipQuestionSet { + schema: typeof PAPERCLIP_QUESTION_SET_SCHEMA; + title?: string; + description?: string; + submitLabel?: string; + questions: PaperclipQuestion[]; +} + +export interface PaperclipQuestionAnswer { + selectedOptionIds?: string[]; + text?: string; + customText?: string; +} + +export interface PaperclipQuestionResponse { + schema: typeof PAPERCLIP_QUESTION_RESPONSE_SCHEMA; + answers: Record; +} + +export interface PaperclipRuntimeRequestOrigin { + adapter: string; + provider?: string; + method?: string; +} + +export interface PaperclipRuntimeInputRequest { + schema: typeof PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2; + requestKind: "runtime"; + requestId: string; + type: "input"; + status: "pending" | "resolved" | "expired" | "cancelled"; + prompt: string; + input: PaperclipQuestionSet; + origin?: PaperclipRuntimeRequestOrigin; + turnId?: string; + itemId?: string; +} + +export class PaperclipQuestionValidationError extends Error { + readonly code = "invalid_question_response" as const; + readonly path: string; + + constructor(path: string, detail: string) { + super(`${path}: ${detail}`); + this.name = "PaperclipQuestionValidationError"; + this.path = path; + } +} + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function rejectUnknownKeys( + value: Record, + allowed: readonly string[], + path: string, +): void { + const allowedKeys = new Set(allowed); + const unknown = Object.keys(value).find((key) => !allowedKeys.has(key)); + if (unknown !== undefined) { + throw new PaperclipQuestionValidationError( + `${path}/${unknown}`, + "is not part of the canonical response contract", + ); + } +} + +function requiredText(value: unknown, path: string, maxLength = 4_000): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > maxLength + ) { + throw new PaperclipQuestionValidationError( + path, + `must be a non-empty string of at most ${maxLength} characters`, + ); + } + return value; +} + +function optionalText( + value: unknown, + path: string, + maxLength = 4_000, +): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string" || value.length > maxLength) { + throw new PaperclipQuestionValidationError( + path, + `must be a string of at most ${maxLength} characters`, + ); + } + return value; +} + +function optionalFiniteNumber( + value: unknown, + path: string, +): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new PaperclipQuestionValidationError(path, "must be a finite number"); + } + return value; +} + +/** Parse and sanitize the provider-neutral presentation contract at an adapter boundary. */ +export function parsePaperclipQuestionSet( + value: unknown, +): PaperclipQuestionSet { + const candidate = record(value); + if ( + candidate === null || + candidate.schema !== PAPERCLIP_QUESTION_SET_SCHEMA + ) { + throw new PaperclipQuestionValidationError( + "/input", + `must use ${PAPERCLIP_QUESTION_SET_SCHEMA}`, + ); + } + if ( + !Array.isArray(candidate.questions) || + candidate.questions.length === 0 || + candidate.questions.length > 64 + ) { + throw new PaperclipQuestionValidationError( + "/input/questions", + "must contain between 1 and 64 questions", + ); + } + const questionIds = new Set(); + const questions = candidate.questions.map( + (rawQuestion, questionIndex): PaperclipQuestion => { + const path = `/input/questions/${questionIndex}`; + const question = record(rawQuestion); + if (question === null) + throw new PaperclipQuestionValidationError(path, "must be an object"); + const id = requiredText(question.id, `${path}/id`, 160); + if (questionIds.has(id)) + throw new PaperclipQuestionValidationError( + `${path}/id`, + "must be unique", + ); + questionIds.add(id); + const answerMode = question.answerMode; + if ( + answerMode !== "single_select" && + answerMode !== "multi_select" && + answerMode !== "text" + ) { + throw new PaperclipQuestionValidationError( + `${path}/answerMode`, + "must be single_select, multi_select, or text", + ); + } + if (typeof question.required !== "boolean") { + throw new PaperclipQuestionValidationError( + `${path}/required`, + "must be boolean", + ); + } + if (Array.isArray(question.options) && question.options.length > 128) { + throw new PaperclipQuestionValidationError( + `${path}/options`, + "cannot contain more than 128 options", + ); + } + const options: PaperclipQuestionOption[] | undefined = Array.isArray( + question.options, + ) + ? question.options.map((rawOption, optionIndex) => { + const optionPath = `${path}/options/${optionIndex}`; + const option = record(rawOption); + if (option === null) + throw new PaperclipQuestionValidationError( + optionPath, + "must be an object", + ); + return { + id: requiredText(option.id, `${optionPath}/id`, 160), + label: requiredText(option.label, `${optionPath}/label`, 1_000), + ...(optionalText( + option.description, + `${optionPath}/description`, + ) !== undefined + ? { + description: optionalText( + option.description, + `${optionPath}/description`, + ), + } + : {}), + }; + }) + : undefined; + if ( + options !== undefined && + new Set(options.map((option) => option.id)).size !== options.length + ) { + throw new PaperclipQuestionValidationError( + `${path}/options`, + "option IDs must be unique within a question", + ); + } + if (answerMode !== "text" && (!options || options.length === 0)) { + throw new PaperclipQuestionValidationError( + `${path}/options`, + "select questions require at least one option", + ); + } + if ( + answerMode === "text" && + options !== undefined && + options.length > 0 + ) { + throw new PaperclipQuestionValidationError( + `${path}/options`, + "text questions cannot define options", + ); + } + const custom = record(question.customAnswer); + const customAnswer = + custom === null + ? undefined + : { + enabled: true as const, + ...(optionalText( + custom.label, + `${path}/customAnswer/label`, + 1_000, + ) !== undefined + ? { + label: optionalText( + custom.label, + `${path}/customAnswer/label`, + 1_000, + ), + } + : {}), + ...(optionalText( + custom.placeholder, + `${path}/customAnswer/placeholder`, + 1_000, + ) !== undefined + ? { + placeholder: optionalText( + custom.placeholder, + `${path}/customAnswer/placeholder`, + 1_000, + ), + } + : {}), + }; + if (custom !== null && custom.enabled !== true) { + throw new PaperclipQuestionValidationError( + `${path}/customAnswer/enabled`, + "must be true when customAnswer is present", + ); + } + if (answerMode === "text" && customAnswer !== undefined) { + throw new PaperclipQuestionValidationError( + `${path}/customAnswer`, + "text questions do not use a separate custom answer", + ); + } + const validation = record(question.textValidation); + const textValidation: PaperclipQuestionTextValidation | undefined = + validation === null + ? undefined + : { + ...(typeof validation.minLength === "number" + ? { minLength: validation.minLength } + : {}), + ...(typeof validation.maxLength === "number" + ? { maxLength: validation.maxLength } + : {}), + ...(optionalText( + validation.pattern, + `${path}/textValidation/pattern`, + 1_000, + ) !== undefined + ? { + pattern: optionalText( + validation.pattern, + `${path}/textValidation/pattern`, + 1_000, + ), + } + : {}), + ...(validation.inputType === "number" || + validation.inputType === "integer" || + validation.inputType === "text" + ? { inputType: validation.inputType } + : {}), + ...(optionalFiniteNumber( + validation.minimum, + `${path}/textValidation/minimum`, + ) !== undefined + ? { + minimum: optionalFiniteNumber( + validation.minimum, + `${path}/textValidation/minimum`, + ), + } + : {}), + ...(optionalFiniteNumber( + validation.maximum, + `${path}/textValidation/maximum`, + ) !== undefined + ? { + maximum: optionalFiniteNumber( + validation.maximum, + `${path}/textValidation/maximum`, + ), + } + : {}), + }; + if (validation !== null) { + for (const key of ["minLength", "maxLength"] as const) { + const raw = validation[key]; + if ( + raw !== undefined && + (!Number.isSafeInteger(raw) || + (raw as number) < 0 || + (raw as number) > 100_000) + ) { + throw new PaperclipQuestionValidationError( + `${path}/textValidation/${key}`, + "must be an integer from 0 through 100000", + ); + } + } + if ( + validation.inputType !== undefined && + !["text", "number", "integer"].includes(String(validation.inputType)) + ) { + throw new PaperclipQuestionValidationError( + `${path}/textValidation/inputType`, + "must be text, number, or integer", + ); + } + if ( + textValidation?.minLength !== undefined && + textValidation.maxLength !== undefined && + textValidation.minLength > textValidation.maxLength + ) { + throw new PaperclipQuestionValidationError( + `${path}/textValidation`, + "minLength cannot exceed maxLength", + ); + } + if ( + textValidation?.minimum !== undefined && + textValidation.maximum !== undefined && + textValidation.minimum > textValidation.maximum + ) { + throw new PaperclipQuestionValidationError( + `${path}/textValidation`, + "minimum cannot exceed maximum", + ); + } + if (textValidation?.pattern !== undefined) { + try { + new RegExp(textValidation.pattern); + } catch { + throw new PaperclipQuestionValidationError( + `${path}/textValidation/pattern`, + "must be a valid regular expression", + ); + } + } + } + return { + id, + ...(optionalText(question.header, `${path}/header`, 1_000) !== undefined + ? { header: optionalText(question.header, `${path}/header`, 1_000) } + : {}), + prompt: requiredText(question.prompt, `${path}/prompt`), + ...(optionalText(question.helpText, `${path}/helpText`) !== undefined + ? { helpText: optionalText(question.helpText, `${path}/helpText`) } + : {}), + required: question.required, + answerMode, + ...(options !== undefined ? { options } : {}), + ...(customAnswer !== undefined ? { customAnswer } : {}), + ...(textValidation !== undefined ? { textValidation } : {}), + }; + }, + ); + return { + schema: PAPERCLIP_QUESTION_SET_SCHEMA, + ...(optionalText(candidate.title, "/input/title", 1_000) !== undefined + ? { title: optionalText(candidate.title, "/input/title", 1_000) } + : {}), + ...(optionalText(candidate.description, "/input/description") !== undefined + ? { + description: optionalText( + candidate.description, + "/input/description", + ), + } + : {}), + ...(optionalText(candidate.submitLabel, "/input/submitLabel", 200) !== + undefined + ? { + submitLabel: optionalText( + candidate.submitLabel, + "/input/submitLabel", + 200, + ), + } + : {}), + questions, + }; +} + +function answerHasValue(answer: PaperclipQuestionAnswer): boolean { + return Boolean( + answer.text?.trim() || + answer.customText?.trim() || + answer.selectedOptionIds?.length, + ); +} + +/** Revalidate untrusted UI input against the persisted question set. */ +export function parsePaperclipQuestionResponse( + questionSetValue: unknown, + responseValue: unknown, +): PaperclipQuestionResponse { + const questionSet = parsePaperclipQuestionSet(questionSetValue); + const response = record(responseValue); + if ( + response === null || + response.schema !== PAPERCLIP_QUESTION_RESPONSE_SCHEMA + ) { + throw new PaperclipQuestionValidationError( + "/response", + `must use ${PAPERCLIP_QUESTION_RESPONSE_SCHEMA}`, + ); + } + rejectUnknownKeys(response, ["schema", "answers"], "/response"); + const rawAnswers = record(response.answers); + if (rawAnswers === null) + throw new PaperclipQuestionValidationError( + "/response/answers", + "must be an object keyed by question ID", + ); + const questions = new Map( + questionSet.questions.map((question) => [question.id, question]), + ); + for (const questionId of Object.keys(rawAnswers)) { + if (!questions.has(questionId)) + throw new PaperclipQuestionValidationError( + `/response/answers/${questionId}`, + "does not match a question in the persisted set", + ); + } + const answers: Record = {}; + for (const question of questionSet.questions) { + const path = `/response/answers/${question.id}`; + const raw = rawAnswers[question.id]; + if (raw === undefined) { + if (question.required) + throw new PaperclipQuestionValidationError(path, "is required"); + continue; + } + const answer = record(raw); + if (answer === null) + throw new PaperclipQuestionValidationError(path, "must be an object"); + rejectUnknownKeys( + answer, + ["selectedOptionIds", "text", "customText"], + path, + ); + const selectedOptionIds = + answer.selectedOptionIds === undefined + ? undefined + : Array.isArray(answer.selectedOptionIds) && + answer.selectedOptionIds.every((entry) => typeof entry === "string") + ? [...answer.selectedOptionIds] + : null; + if (selectedOptionIds === null) + throw new PaperclipQuestionValidationError( + `${path}/selectedOptionIds`, + "must be an array of strings", + ); + if ( + selectedOptionIds !== undefined && + new Set(selectedOptionIds).size !== selectedOptionIds.length + ) { + throw new PaperclipQuestionValidationError( + `${path}/selectedOptionIds`, + "cannot contain duplicates", + ); + } + const textValue = optionalText(answer.text, `${path}/text`, 100_000); + const customText = optionalText( + answer.customText, + `${path}/customText`, + 100_000, + ); + if (question.answerMode === "text") { + if (selectedOptionIds?.length || customText !== undefined) + throw new PaperclipQuestionValidationError( + path, + "text answers only carry text", + ); + } else { + if (textValue !== undefined) + throw new PaperclipQuestionValidationError( + path, + "select answers do not carry text", + ); + const allowed = new Set( + (question.options ?? []).map((option) => option.id), + ); + for (const optionId of selectedOptionIds ?? []) { + if (!allowed.has(optionId)) + throw new PaperclipQuestionValidationError( + `${path}/selectedOptionIds`, + `contains unknown option ${optionId}`, + ); + } + if ( + question.answerMode === "single_select" && + (selectedOptionIds?.length ?? 0) > 1 + ) { + throw new PaperclipQuestionValidationError( + `${path}/selectedOptionIds`, + "single-select answers choose at most one option", + ); + } + if (customText !== undefined && question.customAnswer?.enabled !== true) { + throw new PaperclipQuestionValidationError( + `${path}/customText`, + "custom answers are not enabled for this question", + ); + } + if ( + customText?.trim() && + (selectedOptionIds?.length ?? 0) > 0 && + question.answerMode === "single_select" + ) { + throw new PaperclipQuestionValidationError( + path, + "single-select answers cannot select an option and a custom answer", + ); + } + } + const parsed: PaperclipQuestionAnswer = { + ...(selectedOptionIds !== undefined ? { selectedOptionIds } : {}), + ...(textValue !== undefined ? { text: textValue } : {}), + ...(customText !== undefined ? { customText } : {}), + }; + if (question.required && !answerHasValue(parsed)) + throw new PaperclipQuestionValidationError(path, "is required"); + const boundedText = + question.answerMode === "text" ? parsed.text : parsed.customText; + if (boundedText !== undefined) { + const validation = question.textValidation; + if ( + validation?.minLength !== undefined && + boundedText.length < validation.minLength + ) { + throw new PaperclipQuestionValidationError( + path, + `must contain at least ${validation.minLength} characters`, + ); + } + if ( + validation?.maxLength !== undefined && + boundedText.length > validation.maxLength + ) { + throw new PaperclipQuestionValidationError( + path, + `must contain at most ${validation.maxLength} characters`, + ); + } + if ( + validation?.pattern !== undefined && + !new RegExp(validation.pattern).test(boundedText) + ) { + throw new PaperclipQuestionValidationError( + path, + "does not match the required format", + ); + } + if ( + validation?.inputType === "number" || + validation?.inputType === "integer" + ) { + const numeric = Number(boundedText); + if ( + !Number.isFinite(numeric) || + (validation.inputType === "integer" && !Number.isInteger(numeric)) + ) { + throw new PaperclipQuestionValidationError( + path, + `must be a valid ${validation.inputType}`, + ); + } + if (validation.minimum !== undefined && numeric < validation.minimum) + throw new PaperclipQuestionValidationError( + path, + `must be at least ${validation.minimum}`, + ); + if (validation.maximum !== undefined && numeric > validation.maximum) + throw new PaperclipQuestionValidationError( + path, + `must be at most ${validation.maximum}`, + ); + } + } + if (answerHasValue(parsed)) answers[question.id] = parsed; + } + return { schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA, answers }; +} diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts new file mode 100644 index 00000000000..9b1ab8fedf7 --- /dev/null +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -0,0 +1,618 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + createHmac, +} from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { connect, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { validatePrpEvent } from "../protocol/replay-contract.js"; +import { digestPaperclipSemanticContent } from "../semantic-tools/receipts.js"; +import { DurablePrpControlPlane } from "./durable-prp-control-plane.js"; +import type { DurableRecoveryIdentity } from "./prp-transport-types.js"; + +const identity: DurableRecoveryIdentity = { + runnerInstanceId: "runner-test-1", + environmentLeaseId: "environment-test-1", + runId: "00000000-0000-4000-8000-000000000001", + normalizedSessionId: "session-test-1", + turnId: "turn-test-1", + itemId: "item-test-1", +}; +const expectedRunnerVersion = "0.3.0"; +const expectedRunnerDigest = `sha256:${"a".repeat(64)}`; + +function domainDigest(domain: string, parts: readonly Buffer[]): Buffer { + const digest = createHash("sha256") + .update(domain) + .update(Buffer.from([0])); + for (const part of parts) { + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(part.length)); + digest.update(length).update(part); + } + return digest.digest(); +} + +function domainHmac( + key: Buffer, + domain: string, + parts: readonly Buffer[], +): Buffer { + const digest = createHmac("sha256", key) + .update(domain) + .update(Buffer.from([0])); + for (const part of parts) { + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(part.length)); + digest.update(length).update(part); + } + return digest.digest(); +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value === "object" && value !== null) { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function credentialMaterial(token: string): { + credentialId: string; + authKey: Buffer; +} { + const bytes = Buffer.from(token); + const authKey = domainDigest("paperclip-runner-auth-key-v1", [bytes]); + return { + credentialId: `sha256:${domainDigest("paperclip-runner-credential-id-v1", [bytes]).toString("hex")}`, + authKey, + }; +} + +class ServerFrameReader { + #buffer = Buffer.alloc(0); + #frames: Array | null> = []; + #waiters: Array<(value: Record | null) => void> = []; + + constructor(socket: Socket) { + socket.on("data", (chunk: Buffer) => { + this.#buffer = Buffer.concat([this.#buffer, chunk]); + this.#drain(); + }); + socket.once("close", () => this.#publish(null)); + } + + next(): Promise | null> { + const queued = this.#frames.shift(); + if (queued !== undefined) return Promise.resolve(queued); + return new Promise((resolveFrame, rejectFrame) => { + const timer = setTimeout( + () => rejectFrame(new Error("Server WebSocket frame timed out.")), + 2_000, + ); + this.#waiters.push((value) => { + clearTimeout(timer); + resolveFrame(value); + }); + }); + } + + #publish(value: Record | null): void { + const waiter = this.#waiters.shift(); + if (waiter) waiter(value); + else this.#frames.push(value); + } + + #drain(): void { + while (this.#buffer.length >= 2) { + let length = this.#buffer[1]! & 0x7f; + let cursor = 2; + if (length === 126) { + if (this.#buffer.length < 4) return; + length = this.#buffer.readUInt16BE(2); + cursor = 4; + } else if (length === 127) { + if (this.#buffer.length < 10) return; + length = Number(this.#buffer.readBigUInt64BE(2)); + cursor = 10; + } + if (this.#buffer.length < cursor + length) return; + const payload = this.#buffer.subarray(cursor, cursor + length); + this.#buffer = this.#buffer.subarray(cursor + length); + this.#publish( + JSON.parse(payload.toString("utf8")) as Record, + ); + } + } +} + +async function upgradeSocket(url: string): Promise<{ + socket: Socket; + reader: ServerFrameReader; +}> { + const parsed = new URL(url); + const socket = await new Promise((resolveSocket, rejectSocket) => { + const candidate = connect(Number(parsed.port), parsed.hostname); + let response = Buffer.alloc(0); + const onData = (chunk: Buffer): void => { + response = Buffer.concat([response, chunk]); + const boundary = response.indexOf("\r\n\r\n"); + if (boundary < 0) return; + candidate.off("data", onData); + const status = Number( + response.toString("utf8").match(/^HTTP\/1\.1 (\d{3})/)?.[1], + ); + if (status !== 101) { + candidate.destroy(); + rejectSocket(new Error(`WebSocket upgrade returned ${String(status)}`)); + } else { + resolveSocket(candidate); + } + }; + candidate.once("error", rejectSocket); + candidate.once("connect", () => { + candidate.write( + [ + `GET ${parsed.pathname} HTTP/1.1`, + `Host: ${parsed.host}`, + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version: 13", + "\r\n", + ].join("\r\n"), + ); + }); + candidate.on("data", onData); + }); + return { socket, reader: new ServerFrameReader(socket) }; +} + +function sendMaskedJson(socket: Socket, value: unknown): void { + const payload = Buffer.from(JSON.stringify(value)); + const mask = Buffer.from([0x11, 0x22, 0x33, 0x44]); + const header: number[] = [0x81]; + if (payload.length <= 125) { + header.push(0x80 | payload.length); + } else if (payload.length <= 0xffff) { + header.push(0x80 | 126, payload.length >>> 8, payload.length & 0xff); + } else { + throw new Error("Test client frame exceeds the supported size."); + } + const masked = Buffer.from(payload); + for (let index = 0; index < masked.length; index += 1) { + masked[index] = masked[index]! ^ mask[index % mask.length]!; + } + socket.write(Buffer.concat([Buffer.from(header), mask, masked])); +} + +interface AuthenticatedClient { + socket: Socket; + reader: ServerFrameReader; + authKey: Buffer; + sessionId: string; + sendCounter: bigint; + receiveCounter: bigint; + leaseToken: string | null; + welcome: Record; +} + +function authHello( + credentialId: string, + selectedIdentity: DurableRecoveryIdentity = identity, +): Record { + return { + protocol: "paperclip.runner", + version: 1, + kind: "auth_hello", + payload: { + credentialId, + clientNonce: "client-nonce-test", + protocolMin: 1, + protocolMax: 1, + ...selectedIdentity, + runnerVersion: expectedRunnerVersion, + runnerDigest: expectedRunnerDigest, + }, + }; +} + +async function authenticate( + controlPlane: DurablePrpControlPlane, + token: string, + selectedIdentity: DurableRecoveryIdentity = identity, +): Promise { + const { socket, reader } = await upgradeSocket(controlPlane.connectUrl); + const material = credentialMaterial(token); + sendMaskedJson(socket, authHello(material.credentialId, selectedIdentity)); + const challenge = await reader.next(); + if (challenge === null) return null; + const challengePayload = challenge.payload as Record; + const serverProof = challengePayload.serverProof; + if (typeof serverProof !== "string") throw new Error("Missing server proof."); + const canonicalPayload = { ...challengePayload }; + delete canonicalPayload.serverProof; + const canonicalChallenge = canonicalJson(canonicalPayload); + const expectedServerProof = domainHmac( + material.authKey, + "paperclip-runner-server-proof-v1", + [Buffer.from(canonicalChallenge)], + ).toString("hex"); + expect(serverProof).toBe(expectedServerProof); + const clientProof = domainHmac( + material.authKey, + "paperclip-runner-client-proof-v1", + [Buffer.from(canonicalChallenge), Buffer.from(serverProof)], + ).toString("hex"); + sendMaskedJson(socket, { + protocol: "paperclip.runner", + version: 1, + kind: "auth_response", + payload: { + credentialId: material.credentialId, + clientNonce: challengePayload.clientNonce, + serverNonce: challengePayload.serverNonce, + clientProof, + }, + }); + const binding = domainDigest("paperclip-runner-session-binding-v1", [ + Buffer.from(canonicalChallenge), + Buffer.from(serverProof), + Buffer.from(clientProof), + ]); + const client: AuthenticatedClient = { + socket, + reader, + authKey: material.authKey, + sessionId: `sha256:${binding.toString("hex")}`, + sendCounter: 0n, + receiveCounter: 0n, + leaseToken: null, + welcome: {}, + }; + const welcome = await receiveSecure(client); + if (welcome === null) return null; + expect(welcome.kind).toBe("welcome"); + client.welcome = welcome; + const leaseToken = (welcome.payload as Record) + .connectionLeaseToken; + client.leaseToken = typeof leaseToken === "string" ? leaseToken : null; + return client; +} + +function secureNonce(prefix: "P3C1" | "P3S1", counter: bigint): Buffer { + const nonce = Buffer.alloc(12); + nonce.write(prefix, 0, "ascii"); + nonce.writeBigUInt64BE(counter, 4); + return nonce; +} + +function secureAad( + client: AuthenticatedClient, + direction: "client_to_core" | "core_to_client", + counter: bigint, +): Buffer { + return Buffer.from( + `paperclip.runner.secure-frame.v1\0${client.sessionId}\0${direction}\0${counter}`, + ); +} + +function sendSecure( + client: AuthenticatedClient, + value: Record, +): void { + const binding = Buffer.from(client.sessionId.slice("sha256:".length), "hex"); + const key = domainHmac( + client.authKey, + "paperclip-runner-client-to-core-key-v1", + [binding], + ); + const counter = client.sendCounter; + const cipher = createCipheriv( + "aes-256-gcm", + key, + secureNonce("P3C1", counter), + ); + cipher.setAAD(secureAad(client, "client_to_core", counter)); + const encrypted = Buffer.concat([ + cipher.update(Buffer.from(JSON.stringify(value))), + cipher.final(), + cipher.getAuthTag(), + ]); + sendMaskedJson(client.socket, { + schema: "paperclip.runner.secure-frame.v1", + counter: Number(counter), + ciphertext: encrypted.toString("hex"), + }); + client.sendCounter += 1n; +} + +async function receiveSecure( + client: AuthenticatedClient, +): Promise | null> { + const frame = await client.reader.next(); + if (frame === null) return null; + const counter = BigInt(frame.counter as number); + expect(counter).toBe(client.receiveCounter); + const binding = Buffer.from(client.sessionId.slice("sha256:".length), "hex"); + const key = domainHmac( + client.authKey, + "paperclip-runner-core-to-client-key-v1", + [binding], + ); + const sealed = Buffer.from(String(frame.ciphertext), "hex"); + const decipher = createDecipheriv( + "aes-256-gcm", + key, + secureNonce("P3S1", counter), + ); + decipher.setAAD(secureAad(client, "core_to_client", counter)); + decipher.setAuthTag(sealed.subarray(-16)); + const value = JSON.parse( + Buffer.concat([ + decipher.update(sealed.subarray(0, -16)), + decipher.final(), + ]).toString("utf8"), + ) as Record; + client.receiveCounter += 1n; + return value; +} + +function semanticInputEvent(sourceSeq = 1): Record { + return { + protocol: "paperclip.runner", + version: 1, + kind: "event", + runnerInstanceId: identity.runnerInstanceId, + environmentLeaseId: identity.environmentLeaseId, + runId: identity.runId, + normalizedSessionId: identity.normalizedSessionId, + turnId: identity.turnId, + itemId: identity.itemId, + payload: { + sourceSeq, + sourceEventId: `semantic-event-${sourceSeq}`, + sourceInstanceId: identity.runnerInstanceId, + sourceKind: "runner", + runId: identity.runId, + normalizedSessionId: identity.normalizedSessionId, + turnId: identity.turnId, + itemId: identity.itemId, + eventType: "semantic_tool.input", + schema: "paperclip.prp.event.v1", + schemaVersion: 1, + priority: 0, + emittedAt: "2026-08-25T18:00:00.000Z", + payload: { + semantic_tool: { + schema: "paperclip.prp.semantic_tool.v1", + schemaVersion: 1, + phase: "input", + callId: "call-1", + operationId: "get_task_context", + correlation: { + runId: identity.runId, + normalizedSessionId: identity.normalizedSessionId, + turnId: identity.turnId, + itemId: identity.itemId, + }, + idempotencyKey: null, + content: { + digest: digestPaperclipSemanticContent({}), + redactionDisposition: "digest_only", + references: [], + }, + input: {}, + }, + }, + }, + }; +} + +describe.sequential("DurablePrpControlPlane", () => { + it("exchanges a one-use bootstrap for a run-bound reconnect lease", async () => { + const root = mkdtempSync(resolve(tmpdir(), "paperclip-prp-auth-")); + const controlPlane = new DurablePrpControlPlane({ + stateDirectory: root, + identity, + expectedRunnerVersion, + expectedRunnerDigest, + }); + try { + await controlPlane.start(); + const cancel = controlPlane.queueCommand( + "run.cancel", + { reason: "test" }, + "command-cancel-1", + ); + expect( + controlPlane.queueCommand( + "run.cancel", + { reason: "test" }, + "command-cancel-1", + ), + ).toEqual(cancel); + expect(() => + controlPlane.queueCommand( + "run.cancel", + { reason: "different" }, + "command-cancel-1", + ), + ).toThrow("command replay conflicts"); + expect(() => controlPlane.queueCommand("unknown.command")).toThrow( + "command is invalid", + ); + const ticket = controlPlane.issueBootstrapTicket(); + const first = await authenticate(controlPlane, ticket); + expect(first?.leaseToken).toEqual(expect.any(String)); + first?.socket.destroy(); + + const reused = await authenticate(controlPlane, ticket); + expect(reused).toBeNull(); + + const lease = await authenticate(controlPlane, first!.leaseToken!); + expect(lease).not.toBeNull(); + expect(lease?.leaseToken).toBeNull(); + lease?.socket.destroy(); + + const wrongRun = await authenticate( + controlPlane, + controlPlane.issueBootstrapTicket(), + { + ...identity, + runId: "00000000-0000-4000-8000-000000000999", + }, + ); + expect(wrongRun).toBeNull(); + } finally { + await controlPlane.stop(); + rmSync(root, { recursive: true, force: true }); + } + }); + + it("recovers one semantic call from the durable event after a coordinator restart", async () => { + const root = mkdtempSync(resolve(tmpdir(), "paperclip-prp-recovery-")); + let firstCalls = 0; + const first = new DurablePrpControlPlane({ + stateDirectory: root, + identity, + expectedRunnerVersion, + expectedRunnerDigest, + onSemanticToolInput: async () => { + firstCalls += 1; + return new Promise(() => undefined); + }, + }); + let leaseToken: string; + try { + await first.start(); + const client = await authenticate(first, first.issueBootstrapTicket()); + leaseToken = client!.leaseToken!; + const validation = validatePrpEvent(semanticInputEvent().payload); + expect(validation, JSON.stringify(validation)).toMatchObject({ + ok: true, + }); + sendSecure(client!, semanticInputEvent()); + const ack = await receiveSecure(client!); + expect(ack).toMatchObject({ kind: "ack" }); + expect(firstCalls).toBe(1); + client?.socket.destroy(); + } finally { + await first.stop(); + } + + let recoveredCalls = 0; + const recovered = new DurablePrpControlPlane({ + stateDirectory: root, + identity, + expectedRunnerVersion, + expectedRunnerDigest, + onSemanticToolInput: async (call) => { + recoveredCalls += 1; + return { + result: { ok: true, operationId: call.operationId }, + }; + }, + }); + try { + await recovered.start(); + const client = await authenticate(recovered, leaseToken!); + sendSecure(client!, semanticInputEvent()); + const outcomes = [ + await receiveSecure(client!), + await receiveSecure(client!), + ]; + const command = outcomes.find((outcome) => outcome?.kind === "command"); + expect(outcomes.some((outcome) => outcome?.kind === "ack")).toBe(true); + expect(command?.payload).toMatchObject({ + type: "semantic_tool.result", + payload: { + callId: "call-1", + operationId: "get_task_context", + result: { ok: true, operationId: "get_task_context" }, + isError: false, + }, + }); + expect(recoveredCalls).toBe(1); + + const tampered = semanticInputEvent(2); + const tamperedEvent = tampered.payload as Record; + const tamperedPayload = tamperedEvent.payload as Record; + const tamperedSemantic = tamperedPayload.semantic_tool as Record< + string, + unknown + >; + tamperedSemantic.content = { + ...(tamperedSemantic.content as Record), + digest: `sha256:${"0".repeat(64)}`, + }; + sendSecure(client!, tampered); + await expect(receiveSecure(client!)).resolves.toBeNull(); + client?.socket.destroy(); + } finally { + await recovered.stop(); + rmSync(root, { recursive: true, force: true }); + } + }); + + it("does not acknowledge an event before the caller commits it", async () => { + const root = mkdtempSync(resolve(tmpdir(), "paperclip-prp-commit-order-")); + const first = new DurablePrpControlPlane({ + stateDirectory: root, + identity, + expectedRunnerVersion, + expectedRunnerDigest, + onSemanticToolInput: async () => ({ result: { ok: true } }), + onCommittedEvent: async () => { + throw new Error("database commit failed"); + }, + }); + let leaseToken: string; + try { + await first.start(); + const client = await authenticate(first, first.issueBootstrapTicket()); + leaseToken = client!.leaseToken!; + sendSecure(client!, semanticInputEvent()); + await expect(receiveSecure(client!)).resolves.toBeNull(); + } finally { + await first.stop(); + } + + let committed = 0; + const recovered = new DurablePrpControlPlane({ + stateDirectory: root, + identity, + expectedRunnerVersion, + expectedRunnerDigest, + onSemanticToolInput: async () => ({ result: { ok: true } }), + onCommittedEvent: async () => { + committed += 1; + }, + }); + try { + await recovered.start(); + const client = await authenticate(recovered, leaseToken!); + expect(client?.welcome.payload).toMatchObject({ ackedSourceSeq: 0 }); + sendSecure(client!, semanticInputEvent()); + await expect(receiveSecure(client!)).resolves.toMatchObject({ + kind: "ack", + payload: { ackedSourceSeq: 1 }, + }); + expect(committed).toBe(1); + client?.socket.destroy(); + } finally { + await recovered.stop(); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts new file mode 100644 index 00000000000..74ae7072be5 --- /dev/null +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -0,0 +1,1659 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + createHmac, + randomUUID, + timingSafeEqual, +} from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, + type Stats, +} from "node:fs"; +import { createServer, type IncomingMessage, type Server } from "node:http"; +import { dirname, resolve } from "node:path"; +import type { Duplex } from "node:stream"; + +import { + validatePrpEvent, + type PrpEvent, +} from "../protocol/replay-contract.js"; +import { digestPaperclipSemanticContent } from "../semantic-tools/receipts.js"; +import { + type DurableRecoveryCommittedEvent, + type DurableRecoveryCoreCommand, + type DurableRecoveryIdentity, +} from "./prp-transport-types.js"; + +const protocol = "paperclip.runner"; +const protocolVersion = 1; +const secureFrameSchema = "paperclip.runner.secure-frame.v1"; +const websocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const coreStateSchema = "paperclip.runner.durable.control-plane-state.v1"; +const maxFrameBytes = 1024 * 1024; +const maxCommandBytes = maxFrameBytes - 4 * 1024; +const maxCommands = 500; +const maxCommittedEventWindow = 64; +const maxStateBytes = 192 * 1024 * 1024; +const authChallengeTtlMs = 5_000; +const stableIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,239}$/; +const runnerDigestPattern = /^sha256:[0-9a-f]{64}$/; +const commandTypes = new Set([ + "run.prepare", + "session.open", + "turn.start", + "turn.steer", + "turn.interrupt", + "turn.stop", + "request.resolve", + "interaction.receipt", + "semantic_tool.result", + "session.snapshot", + "session.close", + "session.budget.increase", + "session.destroy", + "run.cancel", + "runner.drain", + "runner.suspend", + "runner.shutdown", +]); + +interface BootstrapTicketRecord { + recordId: string; + credentialId: string; + authKeyDigest: string; + identity: DurableRecoveryIdentity; + runnerVersion: string; + runnerDigest: string; + expiresAt: string; + expiresAtUnixMs: number; + usedAt: string | null; +} + +interface ConnectionLeaseRecord { + recordId: string; + credentialId: string; + authKeyDigest: string; + leaseId: string; + identity: DurableRecoveryIdentity; + protocolVersion: number; + expiresAt: string; + expiresAtUnixMs: number; + revocationEpoch: number; + revokedAt: string | null; +} + +interface StoredCoreState { + schema: typeof coreStateSchema; + identity: DurableRecoveryIdentity; + tickets: Record; + leases: Record; + commands: DurableRecoveryCoreCommand[]; + committedEvents: DurableRecoveryCommittedEvent[]; + ackedSourceSeq: number; + connectionCount: number; + commandDeliveryCounts: Record; + replayDeliveries: number; + duplicateCommandResults: number; + freshBootstraps: number; + malformedFrames: number; + lastLeaseId: string | null; + lastLeaseExpiresAt: string | null; +} + +type PendingAuthorization = + | { + kind: "bootstrap"; + recordId: string; + credentialId: string; + authKey: Buffer; + identity: DurableRecoveryIdentity; + runnerVersion: string; + runnerDigest: string; + expiresAt: string; + expiresAtUnixMs: number; + recordSnapshot: string; + } + | { + kind: "lease"; + recordId: string; + credentialId: string; + authKey: Buffer; + identity: DurableRecoveryIdentity; + protocolVersion: number; + expiresAt: string; + expiresAtUnixMs: number; + leaseId: string; + revocationEpoch: number; + recordSnapshot: string; + }; + +type LiveAuthorization = + | { + kind: "bootstrap"; + authKey: Buffer; + ticket: BootstrapTicketRecord; + } + | { + kind: "lease"; + authKey: Buffer; + lease: ConnectionLeaseRecord; + }; + +interface PendingChallenge { + authorization: PendingAuthorization; + deadlineUnixMs: number; + canonicalChallenge: string; + serverProof: string; + clientNonce: string; + serverNonce: string; +} + +interface SecureChannel { + sendKey: Buffer; + receiveKey: Buffer; + sendCounter: bigint; + receiveCounter: bigint; + sessionId: string; +} + +export interface DurablePrpControlPlaneOptions { + stateDirectory: string; + identity: DurableRecoveryIdentity; + expectedRunnerVersion: string; + expectedRunnerDigest: string; + onSemanticToolInput?: (input: { + readonly callId: string; + readonly operationId: string; + readonly input: unknown; + /** Internal trace lineage for the canonical semantic_tool.input event. */ + readonly sourceEventId: string; + readonly sourceEventType: string; + readonly correlation: { + readonly runId: string; + readonly normalizedSessionId: string; + readonly turnId: string; + readonly itemId: string; + }; + }) => Promise<{ readonly result: unknown; readonly isError?: boolean }>; + /** Persist the canonical event before the runner receives its cumulative ACK. */ + onCommittedEvent?: (event: PrpEvent) => Promise; + connectionLeaseTtlMs?: number; +} + +function domainDigest(domain: string, parts: readonly Buffer[]): Buffer { + const digest = createHash("sha256") + .update(domain) + .update(Buffer.from([0])); + for (const part of parts) { + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(part.length)); + digest.update(length).update(part); + } + return digest.digest(); +} + +function domainHmac( + key: Buffer, + domain: string, + parts: readonly Buffer[], +): Buffer { + const digest = createHmac("sha256", key) + .update(domain) + .update(Buffer.from([0])); + for (const part of parts) { + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(part.length)); + digest.update(length).update(part); + } + return digest.digest(); +} + +function credentialMaterial(token: string): { + credentialId: string; + authKey: Buffer; +} { + const bytes = Buffer.from(token); + return { + credentialId: `sha256:${domainDigest("paperclip-runner-credential-id-v1", [bytes]).toString("hex")}`, + authKey: domainDigest("paperclip-runner-auth-key-v1", [bytes]), + }; +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + if (typeof value === "object" && value !== null) { + const object = value as Record; + return `{${Object.keys(object) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStoredCoreState( + value: unknown, + identity: DurableRecoveryIdentity, +): value is StoredCoreState { + if (!isRecord(value)) return false; + const commands = value.commands; + const events = value.committedEvents; + if ( + value.schema !== coreStateSchema || + canonicalJson(value.identity) !== canonicalJson(identity) || + !isRecord(value.tickets) || + !isRecord(value.leases) || + !Array.isArray(commands) || + commands.length > maxCommands || + !Array.isArray(events) || + events.length > maxCommittedEventWindow || + !Number.isSafeInteger(value.ackedSourceSeq) || + (value.ackedSourceSeq as number) < 0 || + !Number.isSafeInteger(value.connectionCount) || + (value.connectionCount as number) < 0 || + !isRecord(value.commandDeliveryCounts) + ) { + return false; + } + if ( + !commands.every( + (command, index) => + isRecord(command) && + command.schema === "paperclip.prp.command.v1" && + typeof command.commandId === "string" && + stableIdPattern.test(command.commandId) && + command.commandId.length <= 160 && + command.controllerSeq === index + 1 && + typeof command.type === "string" && + commandTypes.has(command.type) && + typeof command.issuedAt === "string" && + isRecord(command.payload) && + ["pending", "completed", "failed", "rejected"].includes( + String(command.status), + ) && + (command.result === null || isRecord(command.result)), + ) + ) { + return false; + } + if ( + !events.every( + (event) => + isRecord(event) && + Number.isSafeInteger(event.sourceSeq) && + (event.sourceSeq as number) > 0 && + typeof event.sourceEventId === "string" && + typeof event.eventType === "string" && + (event.priority === 0 || + event.priority === 1 || + event.priority === 2) && + isRecord(event.envelope) && + Number.isSafeInteger(event.deliveryCount) && + (event.deliveryCount as number) > 0 && + event.logicalEffectCount === 1, + ) + ) { + return false; + } + return [ + "replayDeliveries", + "duplicateCommandResults", + "freshBootstraps", + "malformedFrames", + ].every( + (field) => + Number.isSafeInteger(value[field]) && (value[field] as number) >= 0, + ); +} + +function authKeyFromDigest(digest: string): Buffer { + const hex = digest.match(/^sha256:([0-9a-f]{64})$/)?.[1]; + if (hex === undefined) + throw new Error("Stored transport authentication key is malformed."); + return Buffer.from(hex, "hex"); +} + +function proofMatches(expected: Buffer, supplied: unknown): boolean { + if (typeof supplied !== "string" || !/^[0-9a-f]{64}$/.test(supplied)) + return false; + return timingSafeEqual(expected, Buffer.from(supplied, "hex")); +} + +function createSecureChannel( + authKey: Buffer, + canonicalChallenge: string, + serverProof: string, + clientProof: string, +): SecureChannel { + const parts = [ + Buffer.from(canonicalChallenge), + Buffer.from(serverProof), + Buffer.from(clientProof), + ]; + const binding = domainDigest("paperclip-runner-session-binding-v1", parts); + return { + sendKey: domainHmac(authKey, "paperclip-runner-core-to-client-key-v1", [ + binding, + ]), + receiveKey: domainHmac(authKey, "paperclip-runner-client-to-core-key-v1", [ + binding, + ]), + sendCounter: 0n, + receiveCounter: 0n, + sessionId: `sha256:${binding.toString("hex")}`, + }; +} + +function secureNonce(prefix: "P3C1" | "P3S1", counter: bigint): Buffer { + const nonce = Buffer.alloc(12); + nonce.write(prefix, 0, "ascii"); + nonce.writeBigUInt64BE(counter, 4); + return nonce; +} + +function secureAad( + channel: SecureChannel, + direction: "client_to_core" | "core_to_client", + counter: bigint, +): Buffer { + return Buffer.from( + `${secureFrameSchema}\0${channel.sessionId}\0${direction}\0${counter}`, + ); +} + +function encryptSecureJson( + channel: SecureChannel, + value: unknown, +): Record { + const counter = channel.sendCounter; + const cipher = createCipheriv( + "aes-256-gcm", + channel.sendKey, + secureNonce("P3S1", counter), + ); + cipher.setAAD(secureAad(channel, "core_to_client", counter)); + const ciphertext = Buffer.concat([ + cipher.update(Buffer.from(JSON.stringify(value))), + cipher.final(), + cipher.getAuthTag(), + ]); + channel.sendCounter += 1n; + return { + schema: secureFrameSchema, + counter: Number(counter), + ciphertext: ciphertext.toString("hex"), + }; +} + +function decryptSecureJson( + channel: SecureChannel, + value: unknown, +): Record { + if (typeof value !== "object" || value === null) { + throw new Error("Secure frame must be an object."); + } + const frame = value as Record; + if ( + frame.schema !== secureFrameSchema || + typeof frame.counter !== "number" || + !Number.isSafeInteger(frame.counter) || + BigInt(frame.counter) !== channel.receiveCounter || + typeof frame.ciphertext !== "string" || + !/^[0-9a-f]+$/.test(frame.ciphertext) || + frame.ciphertext.length % 2 !== 0 + ) { + throw new Error("Secure frame metadata or counter is invalid."); + } + const sealed = Buffer.from(frame.ciphertext, "hex"); + if (sealed.length < 16) + throw new Error("Secure frame authentication tag is missing."); + const ciphertext = sealed.subarray(0, -16); + const tag = sealed.subarray(-16); + const counter = channel.receiveCounter; + const decipher = createDecipheriv( + "aes-256-gcm", + channel.receiveKey, + secureNonce("P3C1", counter), + ); + decipher.setAAD(secureAad(channel, "client_to_core", counter)); + decipher.setAuthTag(tag); + const plaintext = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + channel.receiveCounter += 1n; + return JSON.parse(plaintext.toString("utf8")) as Record; +} + +function initialCoreState(identity: DurableRecoveryIdentity): StoredCoreState { + return { + schema: coreStateSchema, + identity, + tickets: {}, + leases: {}, + commands: [], + committedEvents: [], + ackedSourceSeq: 0, + connectionCount: 0, + commandDeliveryCounts: {}, + replayDeliveries: 0, + duplicateCommandResults: 0, + freshBootstraps: 0, + malformedFrames: 0, + lastLeaseId: null, + lastLeaseExpiresAt: null, + }; +} + +function isNodeError(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} + +function verifyPrivateDirectory(path: string): void { + const metadata = lstatSync(path); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error(`Private state directory is not a real directory: ${path}`); + } + if (process.platform !== "win32") { + if ((metadata.mode & 0o777) !== 0o700) { + throw new Error( + `Private state directory does not use mode 0700: ${path}`, + ); + } + if (process.geteuid !== undefined && metadata.uid !== process.geteuid()) { + throw new Error( + `Private state directory is not owned by the daemon user: ${path}`, + ); + } + } +} + +function verifyPrivateRegularFile(file: Stats, path: string): void { + if (!file.isFile()) { + throw new Error(`Private state path is not a regular file: ${path}`); + } + if (process.platform !== "win32") { + if ((file.mode & 0o777) !== 0o600) { + throw new Error(`Private state file does not use mode 0600: ${path}`); + } + if (process.geteuid !== undefined && file.uid !== process.geteuid()) { + throw new Error( + `Private state file is not owned by the daemon user: ${path}`, + ); + } + } +} + +function readPrivateFile(path: string): string | null { + let descriptor: number; + try { + descriptor = openSync( + path, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + } catch (error) { + if (isNodeError(error, "ENOENT")) return null; + throw error; + } + try { + const metadata = fstatSync(descriptor); + verifyPrivateRegularFile(metadata, path); + if (metadata.size > maxStateBytes) { + throw new Error(`Private state file exceeds its size bound: ${path}`); + } + return readFileSync(descriptor, "utf8"); + } finally { + closeSync(descriptor); + } +} + +function syncParentDirectory(path: string): void { + if (process.platform === "win32") return; + const descriptor = openSync( + dirname(path), + constants.O_RDONLY | + (constants.O_DIRECTORY ?? 0) | + (constants.O_NOFOLLOW ?? 0), + ); + try { + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } +} + +function atomicPrivateWrite(path: string, contents: string): void { + const temporary = resolve( + dirname(path), + `.${path.split(/[\\/]/).at(-1)}.${randomUUID()}.tmp`, + ); + let descriptor: number | null = null; + let created = false; + try { + descriptor = openSync( + temporary, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + (constants.O_NOFOLLOW ?? 0), + 0o600, + ); + created = true; + if (process.platform !== "win32") fchmodSync(descriptor, 0o600); + verifyPrivateRegularFile(fstatSync(descriptor), temporary); + writeFileSync(descriptor, contents, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = null; + renameSync(temporary, path); + created = false; + syncParentDirectory(path); + } finally { + if (descriptor !== null) closeSync(descriptor); + if (created) { + try { + unlinkSync(temporary); + } catch (error) { + if (!isNodeError(error, "ENOENT")) throw error; + } + } + } +} + +class DurableCoreStore { + readonly path: string; + #state: StoredCoreState; + + constructor(directory: string, identity: DurableRecoveryIdentity) { + try { + const metadata = lstatSync(directory); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error( + `Private state directory is not a real directory: ${directory}`, + ); + } + } catch (error) { + if (!isNodeError(error, "ENOENT")) throw error; + mkdirSync(directory, { recursive: true, mode: 0o700 }); + } + if (process.platform !== "win32") chmodSync(directory, 0o700); + verifyPrivateDirectory(directory); + this.path = resolve(directory, "control-plane-state.json"); + const stored = readPrivateFile(this.path); + if (stored !== null) { + const parsed = JSON.parse(stored) as unknown; + if (!isStoredCoreState(parsed, identity)) { + throw new Error( + "Control-plane state is invalid or does not match the requested PRP identity.", + ); + } + this.#state = parsed; + } else { + this.#state = initialCoreState(identity); + this.save(); + } + } + + get state(): StoredCoreState { + return this.#state; + } + + save(): void { + atomicPrivateWrite(this.path, `${JSON.stringify(this.#state, null, 2)}\n`); + } +} + +class PrpWebSocketConnection { + readonly socket: Duplex; + pendingChallenge: PendingChallenge | null = null; + secureChannel: SecureChannel | null = null; + lease: ConnectionLeaseRecord | null = null; + connectionId: string | null = null; + #buffer = Buffer.alloc(0); + #closed = false; + #onText: (text: string) => void | Promise; + #onClose: () => void; + #processing = Promise.resolve(); + + constructor( + socket: Duplex, + onText: (text: string) => void | Promise, + onClose: () => void, + ) { + this.socket = socket; + this.#onText = onText; + this.#onClose = onClose; + socket.on("data", (chunk: Buffer) => this.#consume(chunk)); + socket.on("close", () => { + if (!this.#closed) { + this.#closed = true; + this.#onClose(); + } + }); + socket.on("error", () => this.close()); + } + + acceptInitialData(data: Buffer): void { + if (data.length > 0) this.#consume(data); + } + + sendJson(value: unknown): void { + const wire = + this.secureChannel === null + ? value + : encryptSecureJson(this.secureChannel, value); + this.sendText(JSON.stringify(wire)); + } + + sendText(text: string): void { + if (this.#closed) { + return; + } + const payload = Buffer.from(text); + const header: number[] = [0x81]; + if (payload.length <= 125) { + header.push(payload.length); + } else if (payload.length <= 0xffff) { + header.push(126, (payload.length >>> 8) & 0xff, payload.length & 0xff); + } else { + const length = BigInt(payload.length); + header.push(127); + for (let shift = 56n; shift >= 0n; shift -= 8n) { + header.push(Number((length >> shift) & 0xffn)); + } + } + this.socket.write(Buffer.concat([Buffer.from(header), payload])); + } + + close(): void { + if (this.#closed) { + return; + } + this.#closed = true; + this.socket.destroy(); + this.#onClose(); + } + + #consume(chunk: Buffer): void { + this.#buffer = Buffer.concat([this.#buffer, chunk]); + while (this.#buffer.length >= 2) { + const first = this.#buffer[0]!; + const second = this.#buffer[1]!; + const opcode = first & 0x0f; + const masked = (second & 0x80) !== 0; + let length = second & 0x7f; + let cursor = 2; + if (length === 126) { + if (this.#buffer.length < 4) return; + length = this.#buffer.readUInt16BE(2); + cursor = 4; + } else if (length === 127) { + if (this.#buffer.length < 10) return; + const extended = this.#buffer.readBigUInt64BE(2); + if (extended > BigInt(maxFrameBytes)) { + this.close(); + return; + } + length = Number(extended); + cursor = 10; + } + if (length > maxFrameBytes || !masked) { + this.close(); + return; + } + if (this.#buffer.length < cursor + 4 + length) return; + const mask = this.#buffer.subarray(cursor, cursor + 4); + cursor += 4; + const payload = Buffer.from( + this.#buffer.subarray(cursor, cursor + length), + ); + this.#buffer = this.#buffer.subarray(cursor + length); + for (let index = 0; index < payload.length; index += 1) { + payload[index] = payload[index]! ^ mask[index % 4]!; + } + if (opcode === 0x1) { + const text = payload.toString("utf8"); + this.#processing = this.#processing + .then(() => this.#onText(text)) + .catch(() => this.close()); + } else if (opcode === 0x8) { + this.close(); + return; + } else if (opcode === 0x9) { + this.#sendControl(0x0a, payload); + } else if (opcode !== 0x0a) { + this.close(); + return; + } + } + } + + #sendControl(opcode: number, payload: Buffer): void { + if (payload.length > 125 || this.#closed) return; + this.socket.write( + Buffer.concat([Buffer.from([0x80 | opcode, payload.length]), payload]), + ); + } +} + +/** Authenticated, replay-safe PRP transport authority. Business operations are caller supplied. */ +export class DurablePrpControlPlane { + readonly #identity: DurableRecoveryIdentity; + readonly #store: DurableCoreStore; + #expectedRunnerVersion: string; + #expectedRunnerDigest: string; + #server: Server | null = null; + #connections = new Set(); + #pendingSemanticCalls = new Set(); + #port: number | null = null; + #onSemanticToolInput?: DurablePrpControlPlaneOptions["onSemanticToolInput"]; + #onCommittedEvent?: DurablePrpControlPlaneOptions["onCommittedEvent"]; + #connectionLeaseTtlMs: number; + + constructor(options: DurablePrpControlPlaneOptions) { + if ( + !Object.values(options.identity).every( + (value) => typeof value === "string" && stableIdPattern.test(value), + ) || + !stableIdPattern.test(options.expectedRunnerVersion) || + !runnerDigestPattern.test(options.expectedRunnerDigest) || + (options.connectionLeaseTtlMs !== undefined && + (!Number.isInteger(options.connectionLeaseTtlMs) || + options.connectionLeaseTtlMs < 60_000 || + options.connectionLeaseTtlMs > 24 * 60 * 60 * 1_000)) + ) { + throw new Error("Durable PRP control plane options are invalid."); + } + this.#identity = structuredClone(options.identity); + this.#store = new DurableCoreStore( + options.stateDirectory, + options.identity, + ); + this.#expectedRunnerVersion = options.expectedRunnerVersion; + this.#expectedRunnerDigest = options.expectedRunnerDigest; + this.#onSemanticToolInput = options.onSemanticToolInput; + this.#onCommittedEvent = options.onCommittedEvent; + this.#connectionLeaseTtlMs = options.connectionLeaseTtlMs ?? 60_000; + } + + get connectUrl(): string { + if (this.#port === null) { + throw new Error("Durable PRP control plane is not listening."); + } + return `ws://127.0.0.1:${this.#port}/durableRecovery/connect`; + } + + async start(port = 0): Promise { + if (this.#server !== null) { + throw new Error("Durable PRP control plane is already running."); + } + const server = createServer((_request, response) => { + response.writeHead(404).end(); + }); + this.#server = server; + server.on("upgrade", (request, socket, head) => + this.handleUpgrade(request, socket, "/durableRecovery/connect", head), + ); + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(port, "127.0.0.1", () => { + server.off("error", rejectListen); + resolveListen(); + }); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("Durable PRP control plane did not bind a TCP port."); + } + this.#port = address.port; + } + + async stop(): Promise { + for (const connection of this.#connections) { + connection.close(); + } + this.#connections.clear(); + const server = this.#server; + this.#server = null; + this.#port = null; + if (server !== null) { + await new Promise((resolveClose) => + server.close(() => resolveClose()), + ); + } + } + + /** Forces a resumable re-authentication after an immutable run attachment rotates. */ + disconnectActiveRunner(): void { + const connections = [...this.#connections]; + this.#connections.clear(); + for (const connection of connections) connection.close(); + } + + activeRunnerConnectionCount(): number { + return [...this.#connections].filter( + (connection) => connection.secureChannel !== null, + ).length; + } + + issueBootstrapTicket(ttlMs = 5_000): string { + if (!Number.isInteger(ttlMs) || ttlMs < 1_000 || ttlMs > 60_000) { + throw new Error("Durable PRP bootstrap TTL is invalid."); + } + this.#pruneCredentials(); + const ticket = `bootstrap_${randomUUID()}`; + const material = credentialMaterial(ticket); + const expiresAtUnixMs = Date.now() + ttlMs; + this.#store.state.tickets[material.credentialId] = { + recordId: `bootstrap_ticket_${randomUUID()}`, + credentialId: material.credentialId, + authKeyDigest: `sha256:${material.authKey.toString("hex")}`, + identity: structuredClone(this.#identity), + runnerVersion: this.#expectedRunnerVersion, + runnerDigest: this.#expectedRunnerDigest, + expiresAt: new Date(expiresAtUnixMs).toISOString(), + expiresAtUnixMs, + usedAt: null, + }; + this.#store.state.freshBootstraps += 1; + this.#store.save(); + return ticket; + } + + queueCommand( + type: string, + payload: Record = {}, + commandId?: string, + deliverImmediately = false, + ): DurableRecoveryCoreCommand { + if ( + !commandTypes.has(type) || + (commandId !== undefined && + (commandId.length > 160 || !stableIdPattern.test(commandId))) + ) { + throw new Error("Durable PRP command is invalid."); + } + if (commandId !== undefined) { + const existing = this.#store.state.commands.find( + (candidate) => candidate.commandId === commandId, + ); + if (existing !== undefined) { + if ( + existing.type !== type || + canonicalJson(existing.payload) !== canonicalJson(payload) + ) { + throw new Error( + "Durable PRP command replay conflicts with persisted state.", + ); + } + if (deliverImmediately && existing.status === "pending") { + for (const connection of this.#connections) { + if (connection.secureChannel !== null) + this.#sendNextCommand(connection); + } + } + return existing; + } + } + const controllerSeq = this.#store.state.commands.length + 1; + const command: DurableRecoveryCoreCommand = { + schema: "paperclip.prp.command.v1", + commandId: + commandId ?? `command_prp_${controllerSeq.toString().padStart(8, "0")}`, + controllerSeq, + type, + issuedAt: new Date().toISOString(), + payload, + status: "pending", + result: null, + }; + if ( + this.#store.state.commands.length >= maxCommands || + Buffer.byteLength(JSON.stringify(command)) > maxCommandBytes + ) { + throw new Error("Durable PRP command journal bound exceeded."); + } + this.#store.state.commands.push(command); + this.#store.save(); + if (deliverImmediately) { + for (const connection of this.#connections) { + if (connection.secureChannel !== null) { + this.#sendNextCommand(connection); + } + } + } + return command; + } + + /** Attach one HTTP upgrade to this run-bound authority. */ + handleUpgrade( + request: IncomingMessage, + socket: Duplex, + expectedPath = "/api/runner/v1/connect", + head: Buffer = Buffer.alloc(0), + ): void { + const requestPath = new URL(request.url ?? "/", "http://paperclip.invalid") + .pathname; + if (requestPath !== expectedPath) { + socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + const websocketKey = request.headers["sec-websocket-key"]; + const decodedWebSocketKey = + typeof websocketKey === "string" + ? Buffer.from(websocketKey, "base64") + : Buffer.alloc(0); + if ( + request.method !== "GET" || + request.headers.upgrade?.toLowerCase() !== "websocket" || + request.headers["sec-websocket-version"] !== "13" || + typeof websocketKey !== "string" || + decodedWebSocketKey.length !== 16 || + decodedWebSocketKey.toString("base64") !== websocketKey + ) { + socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + const accept = createHash("sha1") + .update(`${websocketKey}${websocketGuid}`) + .digest("base64"); + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${accept}`, + "\r\n", + ].join("\r\n"), + ); + let connection!: PrpWebSocketConnection; + connection = new PrpWebSocketConnection( + socket, + (text): Promise => this.#handleText(connection, text), + () => this.#connections.delete(connection), + ); + this.#connections.add(connection); + connection.acceptInitialData(head); + } + + async #handleText( + connection: PrpWebSocketConnection, + text: string, + ): Promise { + let envelope: Record; + try { + const wire = JSON.parse(text) as unknown; + envelope = + connection.secureChannel === null + ? (wire as Record) + : decryptSecureJson(connection.secureChannel, wire); + } catch { + this.#store.state.malformedFrames += 1; + this.#store.save(); + connection.close(); + return; + } + if ( + envelope.protocol !== protocol || + envelope.version !== protocolVersion + ) { + connection.close(); + return; + } + const kind = envelope.kind; + if (connection.secureChannel === null && kind === "auth_hello") { + this.#authHello(connection, envelope); + return; + } + if (connection.secureChannel === null && kind === "auth_response") { + this.#authResponse(connection, envelope); + return; + } + if ( + connection.secureChannel === null || + connection.lease === null || + connection.lease.revokedAt !== null || + connection.lease.expiresAtUnixMs <= Date.now() + ) { + connection.close(); + return; + } + if (kind === "event") { + await this.#event(connection, envelope); + return; + } + if (kind === "command_result") { + this.#commandResult(connection, envelope); + return; + } + if (kind !== "pong") { + connection.close(); + } + } + + #authorizeHello( + payload: Record, + ): PendingAuthorization | null { + this.#pruneCredentials(); + const credentialId = payload.credentialId; + if (typeof credentialId !== "string") return null; + const ticket = this.#store.state.tickets[credentialId]; + const lease = this.#store.state.leases[credentialId]; + const authorization: PendingAuthorization | null = + ticket !== undefined && + typeof ticket.recordId === "string" && + ticket.credentialId === credentialId && + ticket.usedAt === null && + ticket.expiresAtUnixMs > Date.now() + ? { + kind: "bootstrap", + recordId: ticket.recordId, + credentialId: ticket.credentialId, + authKey: authKeyFromDigest(ticket.authKeyDigest), + identity: structuredClone(ticket.identity), + runnerVersion: ticket.runnerVersion, + runnerDigest: ticket.runnerDigest, + expiresAt: ticket.expiresAt, + expiresAtUnixMs: ticket.expiresAtUnixMs, + recordSnapshot: canonicalJson(ticket), + } + : lease !== undefined && + typeof lease.recordId === "string" && + lease.credentialId === credentialId && + lease.revokedAt === null && + lease.expiresAtUnixMs > Date.now() + ? { + kind: "lease", + recordId: lease.recordId, + credentialId: lease.credentialId, + authKey: authKeyFromDigest(lease.authKeyDigest), + identity: structuredClone(lease.identity), + protocolVersion: lease.protocolVersion, + expiresAt: lease.expiresAt, + expiresAtUnixMs: lease.expiresAtUnixMs, + leaseId: lease.leaseId, + revocationEpoch: lease.revocationEpoch, + recordSnapshot: canonicalJson(lease), + } + : null; + if (authorization === null) return null; + const identity = authorization.identity; + if ( + payload.runnerInstanceId !== identity.runnerInstanceId || + payload.environmentLeaseId !== identity.environmentLeaseId || + payload.runId !== identity.runId || + payload.normalizedSessionId !== identity.normalizedSessionId || + payload.turnId !== identity.turnId || + payload.itemId !== identity.itemId || + payload.runnerVersion !== this.#expectedRunnerVersion || + payload.runnerDigest !== this.#expectedRunnerDigest || + payload.protocolMin !== 1 || + payload.protocolMax !== 1 || + (authorization.kind === "bootstrap" && + (authorization.runnerVersion !== this.#expectedRunnerVersion || + authorization.runnerDigest !== this.#expectedRunnerDigest)) || + (authorization.kind === "lease" && + authorization.protocolVersion !== protocolVersion) + ) { + return null; + } + return authorization; + } + + #pruneCredentials(): void { + const now = Date.now(); + for (const [credentialId, ticket] of Object.entries( + this.#store.state.tickets, + )) { + if (ticket.usedAt !== null || ticket.expiresAtUnixMs <= now) { + delete this.#store.state.tickets[credentialId]; + } + } + for (const [credentialId, lease] of Object.entries( + this.#store.state.leases, + )) { + if (lease.revokedAt !== null || lease.expiresAtUnixMs <= now) { + delete this.#store.state.leases[credentialId]; + } + } + } + + #reauthorizePendingChallenge( + pending: PendingChallenge, + now: number, + ): LiveAuthorization | null { + if (pending.deadlineUnixMs <= now) return null; + const expected = pending.authorization; + if (expected.kind === "bootstrap") { + const ticket = this.#store.state.tickets[expected.credentialId]; + if ( + ticket === undefined || + ticket.recordId !== expected.recordId || + ticket.credentialId !== expected.credentialId || + ticket.usedAt !== null || + ticket.expiresAtUnixMs <= now || + canonicalJson(ticket) !== expected.recordSnapshot + ) { + return null; + } + return { + kind: "bootstrap", + authKey: authKeyFromDigest(ticket.authKeyDigest), + ticket, + }; + } + + const lease = this.#store.state.leases[expected.credentialId]; + if ( + lease === undefined || + lease.recordId !== expected.recordId || + lease.credentialId !== expected.credentialId || + lease.revokedAt !== null || + lease.expiresAtUnixMs <= now || + canonicalJson(lease) !== expected.recordSnapshot + ) { + return null; + } + return { + kind: "lease", + authKey: authKeyFromDigest(lease.authKeyDigest), + lease, + }; + } + + #authHello( + connection: PrpWebSocketConnection, + envelope: Record, + ): void { + if (connection.pendingChallenge !== null) { + connection.close(); + return; + } + const payload = envelope.payload as Record | undefined; + if (payload === undefined || typeof payload.clientNonce !== "string") { + connection.close(); + return; + } + const authorization = this.#authorizeHello(payload); + if (authorization === null) { + connection.close(); + return; + } + const serverNonce = randomUUID(); + const challengePayload: Record = { + credentialId: authorization.credentialId, + credentialKind: authorization.kind, + clientNonce: payload.clientNonce, + serverNonce, + runnerInstanceId: payload.runnerInstanceId, + environmentLeaseId: payload.environmentLeaseId, + runId: payload.runId, + normalizedSessionId: payload.normalizedSessionId, + turnId: payload.turnId, + itemId: payload.itemId, + runnerVersion: payload.runnerVersion, + runnerDigest: payload.runnerDigest, + selectedVersion: protocolVersion, + credentialLeaseId: + authorization.kind === "lease" ? authorization.leaseId : null, + credentialExpiresAt: authorization.expiresAt, + credentialExpiresAtUnixMs: authorization.expiresAtUnixMs, + revocationEpoch: + authorization.kind === "lease" ? authorization.revocationEpoch : 0, + }; + const canonicalChallenge = canonicalJson(challengePayload); + const serverProof = domainHmac( + authorization.authKey, + "paperclip-runner-server-proof-v1", + [Buffer.from(canonicalChallenge)], + ).toString("hex"); + connection.pendingChallenge = { + authorization, + deadlineUnixMs: Math.min( + authorization.expiresAtUnixMs, + Date.now() + authChallengeTtlMs, + ), + canonicalChallenge, + serverProof, + clientNonce: payload.clientNonce, + serverNonce, + }; + connection.sendJson({ + protocol, + version: protocolVersion, + kind: "auth_challenge", + payload: { ...challengePayload, serverProof }, + }); + } + + #authResponse( + connection: PrpWebSocketConnection, + envelope: Record, + ): void { + const pending = connection.pendingChallenge; + const payload = envelope.payload as Record | undefined; + if ( + pending === null || + payload === undefined || + payload.credentialId !== pending.authorization.credentialId || + payload.clientNonce !== pending.clientNonce || + payload.serverNonce !== pending.serverNonce + ) { + connection.close(); + return; + } + // WebSocket callbacks run synchronously on the mock core's event loop. Re-reading, + // validating, consuming, minting, and persisting here forms one state mutation + // boundary, so another proof cannot interleave with bootstrap consumption. + const authorization = this.#reauthorizePendingChallenge( + pending, + Date.now(), + ); + if (authorization === null) { + connection.close(); + return; + } + const expectedClientProof = domainHmac( + authorization.authKey, + "paperclip-runner-client-proof-v1", + [ + Buffer.from(pending.canonicalChallenge), + Buffer.from(pending.serverProof), + ], + ); + if (!proofMatches(expectedClientProof, payload.clientProof)) { + connection.close(); + return; + } + const clientProof = expectedClientProof.toString("hex"); + let leaseToken: string | null = null; + let lease: ConnectionLeaseRecord; + if (authorization.kind === "bootstrap") { + authorization.ticket.usedAt = new Date().toISOString(); + leaseToken = `lease_${randomUUID()}`; + const material = credentialMaterial(leaseToken); + const expiresAtUnixMs = Date.now() + this.#connectionLeaseTtlMs; + lease = { + recordId: `connection_lease_record_${randomUUID()}`, + credentialId: material.credentialId, + authKeyDigest: `sha256:${material.authKey.toString("hex")}`, + leaseId: `connection_lease_${randomUUID()}`, + identity: structuredClone(this.#identity), + protocolVersion, + expiresAt: new Date(expiresAtUnixMs).toISOString(), + expiresAtUnixMs, + revocationEpoch: 0, + revokedAt: null, + }; + this.#store.state.leases[material.credentialId] = lease; + this.#store.save(); + } else { + lease = authorization.lease; + } + connection.pendingChallenge = null; + connection.lease = lease; + connection.connectionId = `connection_${this.#store.state.connectionCount + 1}`; + connection.secureChannel = createSecureChannel( + authorization.authKey, + pending.canonicalChallenge, + pending.serverProof, + clientProof, + ); + for (const active of this.#connections) { + if (active !== connection && active.secureChannel !== null) + active.close(); + } + this.#welcome(connection, leaseToken); + } + + #welcome( + connection: PrpWebSocketConnection, + leaseToken: string | null, + ): void { + const lease = connection.lease; + if (lease === null || connection.connectionId === null) { + connection.close(); + return; + } + + this.#store.state.connectionCount += 1; + this.#store.state.lastLeaseId = lease.leaseId; + this.#store.state.lastLeaseExpiresAt = lease.expiresAt; + + const pending = this.#nextPendingCommand(); + for (const command of pending) { + this.#store.state.commandDeliveryCounts[command.commandId] = + (this.#store.state.commandDeliveryCounts[command.commandId] ?? 0) + 1; + } + this.#store.save(); + connection.sendJson({ + protocol, + version: protocolVersion, + envelopeId: `welcome_${this.#store.state.connectionCount}`, + kind: "welcome", + runnerInstanceId: this.#identity.runnerInstanceId, + environmentLeaseId: this.#identity.environmentLeaseId, + runId: this.#identity.runId, + normalizedSessionId: this.#identity.normalizedSessionId, + turnId: this.#identity.turnId, + itemId: this.#identity.itemId, + connectionId: connection.connectionId, + connectionLeaseId: lease.leaseId, + sentAt: new Date().toISOString(), + payload: { + selectedVersion: 1, + heartbeatIntervalMs: 250, + connectionLeaseId: lease.leaseId, + ...(leaseToken === null ? {} : { connectionLeaseToken: leaseToken }), + connectionLeaseExpiresAt: lease.expiresAt, + connectionLeaseExpiresAtUnixMs: lease.expiresAtUnixMs, + connectionLeaseRevocationEpoch: lease.revocationEpoch, + leaseBinding: { + runnerInstanceId: this.#identity.runnerInstanceId, + environmentLeaseId: this.#identity.environmentLeaseId, + runId: this.#identity.runId, + normalizedSessionId: this.#identity.normalizedSessionId, + protocolVersion, + }, + maxFrameBytes, + maxBatchEvents: 100, + ackedSourceSeq: this.#store.state.ackedSourceSeq, + pendingCommands: pending.map(this.#wireCommand), + }, + }); + } + + #wireCommand( + command: DurableRecoveryCoreCommand, + ): Omit { + const { status: _status, result: _result, ...wire } = command; + return wire; + } + + #nextPendingCommand(): DurableRecoveryCoreCommand[] { + const command = this.#store.state.commands.find( + (candidate) => candidate.status === "pending", + ); + return command === undefined ? [] : [command]; + } + + #controlEnvelope( + connection: PrpWebSocketConnection, + envelopeId: string, + kind: string, + payload: Record, + ): Record { + if (connection.lease === null || connection.connectionId === null) { + throw new Error( + "Cannot send control data before transport authentication.", + ); + } + return { + protocol, + version: protocolVersion, + envelopeId, + kind, + runnerInstanceId: this.#identity.runnerInstanceId, + environmentLeaseId: this.#identity.environmentLeaseId, + runId: this.#identity.runId, + normalizedSessionId: this.#identity.normalizedSessionId, + turnId: this.#identity.turnId, + itemId: this.#identity.itemId, + connectionId: connection.connectionId, + connectionLeaseId: connection.lease.leaseId, + sentAt: new Date().toISOString(), + payload, + }; + } + + #sendNextCommand(connection: PrpWebSocketConnection): void { + const [command] = this.#nextPendingCommand(); + if (command === undefined) return; + this.#store.state.commandDeliveryCounts[command.commandId] = + (this.#store.state.commandDeliveryCounts[command.commandId] ?? 0) + 1; + this.#store.save(); + connection.sendJson( + this.#controlEnvelope( + connection, + `command_${command.commandId}_${this.#store.state.commandDeliveryCounts[command.commandId]}`, + "command", + this.#wireCommand(command), + ), + ); + } + + #commandResult( + connection: PrpWebSocketConnection, + envelope: Record, + ): void { + const result = envelope.payload as Record | undefined; + const commandId = result?.commandId; + if (result === undefined || typeof commandId !== "string") { + connection.close(); + return; + } + const command = this.#store.state.commands.find( + (candidate) => candidate.commandId === commandId, + ); + if (command === undefined) { + connection.close(); + return; + } + const status = result.status; + if ( + status !== "completed" && + status !== "failed" && + status !== "rejected" + ) { + connection.close(); + return; + } + if (command.status !== "pending") { + if (canonicalJson(command.result) !== canonicalJson(result)) { + connection.close(); + return; + } + this.#store.state.duplicateCommandResults += 1; + this.#store.save(); + this.#sendNextCommand(connection); + return; + } + command.status = status; + command.result = structuredClone(result); + this.#store.save(); + this.#sendNextCommand(connection); + } + + async #event( + connection: PrpWebSocketConnection, + envelope: Record, + ): Promise { + const validated = validatePrpEvent(envelope.payload); + if (!validated.ok) { + connection.close(); + return; + } + const event = validated.event; + const sourceSeq = event?.sourceSeq; + const sourceEventId = event?.sourceEventId; + const eventType = event?.eventType; + const priority = event?.priority; + if ( + typeof sourceSeq !== "number" || + typeof sourceEventId !== "string" || + typeof eventType !== "string" || + (priority !== 0 && priority !== 1 && priority !== 2) || + event?.sourceInstanceId !== this.#identity.runnerInstanceId || + event.runId !== this.#identity.runId || + event.normalizedSessionId !== this.#identity.normalizedSessionId || + event.turnId !== this.#identity.turnId || + event.itemId !== this.#identity.itemId + ) { + connection.close(); + return; + } + const semantic = (event.payload as Record | undefined) + ?.semantic_tool as Record | undefined; + const semanticCorrelation = semantic?.correlation as + Record | undefined; + const isSemanticInput = + eventType === "semantic_tool.input" || eventType === "mcp_app.tool_input"; + if ( + isSemanticInput && + (this.#onSemanticToolInput === undefined || + semantic?.phase !== "input" || + typeof semantic.callId !== "string" || + typeof semantic.operationId !== "string" || + !Object.prototype.hasOwnProperty.call(semantic, "input") || + typeof semantic.content !== "object" || + semantic.content === null || + (semantic.content as Record).digest !== + digestPaperclipSemanticContent(semantic.input) || + semanticCorrelation?.runId !== this.#identity.runId || + semanticCorrelation.normalizedSessionId !== + this.#identity.normalizedSessionId || + semanticCorrelation.turnId !== this.#identity.turnId || + semanticCorrelation.itemId !== this.#identity.itemId) + ) { + connection.close(); + return; + } + const existing = this.#store.state.committedEvents.find( + (candidate) => candidate.sourceEventId === sourceEventId, + ); + if (existing !== undefined) { + if (canonicalJson(existing.envelope) !== canonicalJson(envelope)) { + connection.close(); + return; + } + } else if (sourceSeq !== this.#store.state.ackedSourceSeq + 1) { + connection.close(); + return; + } + + // The caller's durable commit is the acknowledgement authority. A crash + // after that idempotent commit but before the local cursor save is safe: + // the runner replays the event, the caller observes a duplicate, and only + // then do we advance the cumulative cursor. Reversing this order can make + // an uncommitted event disappear from the runner outbox permanently. + try { + await this.#onCommittedEvent?.(event); + } catch { + connection.close(); + return; + } + + if (existing !== undefined) { + existing.deliveryCount += 1; + this.#store.state.replayDeliveries += 1; + } else { + this.#store.state.committedEvents.push({ + sourceSeq, + sourceEventId, + eventType, + priority, + envelope: structuredClone(envelope), + deliveryCount: 1, + logicalEffectCount: 1, + }); + if (this.#store.state.committedEvents.length > maxCommittedEventWindow) { + this.#store.state.committedEvents.splice( + 0, + this.#store.state.committedEvents.length - maxCommittedEventWindow, + ); + } + this.#store.state.ackedSourceSeq = sourceSeq; + } + this.#store.save(); + + if ( + isSemanticInput && + this.#onSemanticToolInput && + semantic !== undefined && + typeof semantic.callId === "string" && + typeof semantic.operationId === "string" + ) { + const call = { + callId: semantic.callId, + operationId: semantic.operationId, + input: semantic.input, + sourceEventId, + sourceEventType: eventType, + correlation: { + runId: this.#identity.runId, + normalizedSessionId: this.#identity.normalizedSessionId, + turnId: this.#identity.turnId, + itemId: + typeof event.itemId === "string" + ? event.itemId + : this.#identity.itemId, + }, + }; + const commandId = `command_tool_${createHash("sha256") + .update(`${this.#identity.runId}\0${call.callId}`) + .digest("hex") + .slice(0, 32)}`; + const alreadyQueued = this.#store.state.commands.some( + (command) => command.commandId === commandId, + ); + if (!alreadyQueued && !this.#pendingSemanticCalls.has(commandId)) { + this.#pendingSemanticCalls.add(commandId); + const queueResult = (result: unknown, isError: boolean): void => { + try { + this.queueCommand( + "semantic_tool.result", + { ...call, result, isError }, + commandId, + true, + ); + } catch { + // A result that cannot fit the bounded durable journal cannot be + // acknowledged as a usable tool response. Force a reconnect so + // the caller can recover or terminate the run explicitly. + this.disconnectActiveRunner(); + } + }; + void this.#onSemanticToolInput(call) + .then((outcome) => + queueResult(outcome.result, outcome.isError === true), + ) + .catch(() => + queueResult({ code: "semantic_tool_bridge_failed" }, true), + ) + .finally(() => this.#pendingSemanticCalls.delete(commandId)); + } + } + + connection.sendJson( + this.#controlEnvelope( + connection, + `ack_${this.#store.state.ackedSourceSeq}`, + "ack", + { + ackedSourceSeq: this.#store.state.ackedSourceSeq, + }, + ), + ); + } +} diff --git a/packages/paperclip-runner/src/control-plane/prp-transport-types.ts b/packages/paperclip-runner/src/control-plane/prp-transport-types.ts new file mode 100644 index 00000000000..6a9920573af --- /dev/null +++ b/packages/paperclip-runner/src/control-plane/prp-transport-types.ts @@ -0,0 +1,29 @@ +export interface DurableRecoveryIdentity { + runnerInstanceId: string; + environmentLeaseId: string; + runId: string; + normalizedSessionId: string; + turnId: string; + itemId: string; +} + +export interface DurableRecoveryCoreCommand { + schema: "paperclip.prp.command.v1"; + commandId: string; + controllerSeq: number; + type: string; + issuedAt: string; + payload: Record; + status: "pending" | "completed" | "failed" | "rejected"; + result: Record | null; +} + +export interface DurableRecoveryCommittedEvent { + sourceSeq: number; + sourceEventId: string; + eventType: string; + priority: 0 | 1 | 2; + envelope: Record; + deliveryCount: number; + logicalEffectCount: number; +} diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts new file mode 100644 index 00000000000..8c8f4d86db8 --- /dev/null +++ b/packages/paperclip-runner/src/index.ts @@ -0,0 +1,13 @@ +export * from "./catalog/index.js"; +export * from "./contracts/completion-result.js"; +export * from "./contracts/question-set.js"; +export { + DurablePrpControlPlane, + type DurablePrpControlPlaneOptions, +} from "./control-plane/durable-prp-control-plane.js"; +export type { DurableRecoveryIdentity } from "./control-plane/prp-transport-types.js"; +export * from "./protocol/replay-contract.js"; +export * from "./protocol/result-normalization.js"; +export * from "./reducer/session-reducer.js"; +export * from "./semantic-tools/index.js"; +export * from "./tracer/replay.js"; diff --git a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts new file mode 100644 index 00000000000..37368bec6e4 --- /dev/null +++ b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts @@ -0,0 +1,3657 @@ +// Generated by scripts/generate-protocol-schema-module.mjs. Do not edit. + +export const identitySchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/identity.schema.json", + "title": "PRP identity graph", + "type": "object", + "required": [ + "schema", + "companyId", + "issueId", + "runId", + "environmentLeaseId", + "runnerInstanceId", + "normalizedSessionId", + "driverSessionId" + ], + "properties": { + "schema": { + "const": "paperclip.prp.identity.v1" + }, + "companyId": { + "$ref": "#/$defs/stableId" + }, + "issueId": { + "$ref": "#/$defs/stableId" + }, + "runId": { + "$ref": "#/$defs/stableId" + }, + "environmentLeaseId": { + "$ref": "#/$defs/stableId" + }, + "runnerInstanceId": { + "$ref": "#/$defs/stableId" + }, + "normalizedSessionId": { + "$ref": "#/$defs/stableId" + }, + "driverSessionId": { + "$ref": "#/$defs/stableId" + }, + "providerSessionId": { + "$ref": "#/$defs/stableId" + } + }, + "additionalProperties": true, + "$defs": { + "stableId": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + } + } +} as const; + +export const capabilitiesSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json", + "title": "PRP negotiated capabilities", + "type": "object", + "required": [ + "schema", + "sessionReusePolicy", + "driver", + "steer", + "interrupt", + "resume", + "runtimeRequests", + "structuredResult", + "typedEvents" + ], + "properties": { + "schema": { + "const": "paperclip.prp.capabilities.v1" + }, + "sessionReusePolicy": { + "enum": [ + "new_per_run", + "reuse_per_issue", + "reuse_per_workspace" + ] + }, + "driver": { + "type": "object", + "required": [ + "kind", + "version" + ], + "properties": { + "kind": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "additionalProperties": true + }, + "steer": { + "type": "boolean" + }, + "interrupt": { + "type": "boolean" + }, + "resume": { + "type": "boolean" + }, + "runtimeRequests": { + "type": "boolean" + }, + "structuredResult": { + "type": "boolean" + }, + "typedEvents": { + "type": "boolean" + }, + "typedEventFamilies": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "required": [ + "family", + "version", + "availability", + "detailLevel" + ], + "properties": { + "family": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_.-]*$" + }, + "version": { + "const": 1 + }, + "availability": { + "enum": [ + "available", + "unsupported", + "policy_disabled" + ] + }, + "detailLevel": { + "enum": [ + "summary", + "structured" + ] + } + }, + "additionalProperties": false + } + }, + "semanticTools": { + "type": "object", + "required": [ + "schema", + "schemaVersion", + "operations" + ], + "properties": { + "schema": { + "const": "paperclip.prp.semantic_tools.v1" + }, + "schemaVersion": { + "const": 1 + }, + "operations": { + "type": "array", + "items": { + "type": "object", + "required": [ + "operationId", + "version", + "availability", + "redactionDisposition" + ], + "properties": { + "operationId": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "version": { + "const": 1 + }, + "availability": { + "enum": [ + "available", + "denied", + "unsupported" + ] + }, + "redactionDisposition": { + "const": "digest_only" + }, + "requiredClaims": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "uniqueItems": true + }, + "reasonCode": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_.:-]*$" + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + }, + "unsupported": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + }, + "additionalProperties": true +} as const; + +export const commandSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/command.schema.json", + "title": "PRP runner command", + "type": "object", + "required": [ + "schema", + "commandId", + "controllerSeq", + "type", + "issuedAt", + "payload" + ], + "properties": { + "schema": { + "const": "paperclip.prp.command.v1" + }, + "commandId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "controllerSeq": { + "type": "integer", + "minimum": 1 + }, + "type": { + "enum": [ + "run.prepare", + "run.attach", + "session.open", + "turn.start", + "turn.steer", + "turn.interrupt", + "turn.stop", + "request.resolve", + "interaction.receipt", + "semantic_tool.result", + "session.snapshot", + "session.close", + "session.budget.increase", + "session.destroy", + "run.cancel", + "runner.drain", + "runner.suspend", + "runner.shutdown" + ] + }, + "issuedAt": { + "type": "string", + "format": "date-time" + }, + "deadlineAt": { + "type": "string", + "format": "date-time" + }, + "precondition": { + "type": "object", + "properties": { + "runnerState": { + "type": "array", + "items": { + "type": "string" + } + }, + "runState": { + "type": "array", + "items": { + "type": "string" + } + }, + "sessionState": { + "type": "array", + "items": { + "type": "string" + } + }, + "activeTurnId": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": true + }, + "payload": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true +} as const; + +export const providerDescriptorSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json", + "title": "PRP provider runtime descriptor", + "type": "object", + "required": [ + "provider", + "driver", + "model", + "executionKind", + "providerVersion" + ], + "properties": { + "provider": { + "const": "codex" + }, + "driver": { + "const": "codex_app_server" + }, + "model": { + "type": [ + "string", + "null" + ], + "maxLength": 240 + }, + "executionKind": { + "const": "local_process" + }, + "providerVersion": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "providerSessionId": { + "type": [ + "string", + "null" + ], + "maxLength": 240 + }, + "processId": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + } + }, + "additionalProperties": true +} as const; + +export const providerEventSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json", + "title": "Provider-neutral runner activity payload", + "oneOf": [ + { + "$ref": "#/$defs/plan" + }, + { + "$ref": "#/$defs/toolExecution" + }, + { + "$ref": "#/$defs/research" + }, + { + "$ref": "#/$defs/delegation" + }, + { + "$ref": "#/$defs/modelRoute" + }, + { + "$ref": "#/$defs/modelVerification" + }, + { + "$ref": "#/$defs/contextCompacted" + }, + { + "$ref": "#/$defs/artifactViewed" + }, + { + "$ref": "#/$defs/artifactGenerated" + }, + { + "$ref": "#/$defs/reviewMode" + }, + { + "$ref": "#/$defs/hook" + }, + { + "$ref": "#/$defs/memoryCitation" + }, + { + "$ref": "#/$defs/safetyReview" + }, + { + "$ref": "#/$defs/terminalInput" + }, + { + "$ref": "#/$defs/wait" + }, + { + "$ref": "#/$defs/providerNotice" + } + ], + "$defs": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "shortText": { + "type": "string", + "maxLength": 4000 + }, + "nullableShortText": { + "type": [ + "string", + "null" + ], + "maxLength": 4000 + }, + "safePath": { + "type": [ + "string", + "null" + ], + "maxLength": 4096, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$" + }, + "safeUrl": { + "type": [ + "string", + "null" + ], + "maxLength": 8192, + "pattern": "^https?://" + }, + "plan": { + "type": "object", + "required": [ + "schema", + "planId", + "revision", + "steps", + "complete", + "syncStatus" + ], + "properties": { + "schema": { + "const": "paperclip.plan.updated.v1" + }, + "planId": { + "$ref": "#/$defs/id" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "explanation": { + "$ref": "#/$defs/nullableShortText" + }, + "steps": { + "type": "array", + "maxItems": 256, + "items": { + "type": "object", + "required": [ + "stepId", + "body", + "status" + ], + "properties": { + "stepId": { + "$ref": "#/$defs/id" + }, + "body": { + "$ref": "#/$defs/shortText" + }, + "status": { + "enum": [ + "pending", + "in_progress", + "completed", + "blocked" + ] + } + }, + "additionalProperties": false + } + }, + "complete": { + "type": "boolean" + }, + "syncStatus": { + "enum": [ + "streaming", + "pending", + "synchronized", + "conflict", + "not_applicable" + ] + }, + "documentRevision": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + } + }, + "additionalProperties": false + }, + "toolExecution": { + "type": "object", + "required": [ + "schema", + "executionId", + "transport", + "operation", + "status", + "output", + "outputBytes", + "outputTruncated", + "outputDigest" + ], + "properties": { + "schema": { + "const": "paperclip.tool.execution.v1" + }, + "executionId": { + "$ref": "#/$defs/id" + }, + "transport": { + "enum": [ + "process", + "mcp", + "dynamic", + "builtin" + ] + }, + "operation": { + "enum": [ + "read", + "search", + "list", + "execute", + "edit", + "unknown" + ] + }, + "name": { + "$ref": "#/$defs/nullableShortText" + }, + "target": { + "$ref": "#/$defs/safePath" + }, + "namespace": { + "type": [ + "string", + "null" + ], + "maxLength": 240 + }, + "readOnly": { + "type": [ + "boolean", + "null" + ] + }, + "status": { + "enum": [ + "running", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "durationMs": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "exitCode": { + "type": [ + "integer", + "null" + ] + }, + "progress": { + "$ref": "#/$defs/nullableShortText" + }, + "output": { + "type": [ + "string", + "null" + ], + "maxLength": 65536 + }, + "outputBytes": { + "type": "integer", + "minimum": 0 + }, + "outputTruncated": { + "type": "boolean" + }, + "outputDigest": { + "type": [ + "string", + "null" + ], + "pattern": "^sha256:[a-f0-9]{64}$" + } + }, + "additionalProperties": false + }, + "research": { + "type": "object", + "required": [ + "schema", + "researchId", + "action", + "status", + "sources" + ], + "properties": { + "schema": { + "const": "paperclip.research.v1" + }, + "researchId": { + "$ref": "#/$defs/id" + }, + "action": { + "enum": [ + "search", + "open_page", + "find_in_page", + "other" + ] + }, + "status": { + "enum": [ + "running", + "completed", + "failed", + "cancelled" + ] + }, + "query": { + "$ref": "#/$defs/nullableShortText" + }, + "url": { + "$ref": "#/$defs/safeUrl" + }, + "pattern": { + "$ref": "#/$defs/nullableShortText" + }, + "sources": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "required": [ + "sourceId", + "title", + "url", + "snippet" + ], + "properties": { + "sourceId": { + "$ref": "#/$defs/id" + }, + "title": { + "$ref": "#/$defs/shortText" + }, + "url": { + "$ref": "#/$defs/safeUrl" + }, + "snippet": { + "$ref": "#/$defs/nullableShortText" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "delegation": { + "type": "object", + "required": [ + "schema", + "delegationId", + "action", + "status", + "children" + ], + "properties": { + "schema": { + "const": "paperclip.delegation.v1" + }, + "delegationId": { + "$ref": "#/$defs/id" + }, + "action": { + "enum": [ + "spawn", + "message", + "resume", + "wait", + "close" + ] + }, + "status": { + "enum": [ + "running", + "waiting", + "completed", + "failed", + "interrupted", + "closed" + ] + }, + "children": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "required": [ + "childId", + "role", + "model", + "status", + "summary", + "activitySummary" + ], + "properties": { + "childId": { + "$ref": "#/$defs/id" + }, + "role": { + "type": [ + "string", + "null" + ], + "maxLength": 160 + }, + "model": { + "type": [ + "string", + "null" + ], + "maxLength": 240 + }, + "status": { + "enum": [ + "running", + "waiting", + "completed", + "failed", + "interrupted", + "closed" + ] + }, + "summary": { + "$ref": "#/$defs/nullableShortText" + }, + "activitySummary": { + "$ref": "#/$defs/nullableShortText" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "modelRoute": { + "type": "object", + "required": [ + "schema", + "routeId", + "provider", + "requestedModel", + "fromModel", + "effectiveModel", + "reason" + ], + "properties": { + "schema": { + "const": "paperclip.model.route_changed.v1" + }, + "routeId": { + "$ref": "#/$defs/id" + }, + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "requestedModel": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "fromModel": { + "type": [ + "string", + "null" + ], + "maxLength": 240 + }, + "effectiveModel": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "reason": { + "$ref": "#/$defs/shortText" + } + }, + "additionalProperties": false + }, + "modelVerification": { + "type": "object", + "required": [ + "schema", + "verificationId", + "status", + "classes", + "buffering", + "summary" + ], + "properties": { + "schema": { + "const": "paperclip.model.verification.v1" + }, + "verificationId": { + "$ref": "#/$defs/id" + }, + "status": { + "enum": [ + "running", + "completed", + "failed" + ] + }, + "classes": { + "type": "array", + "maxItems": 32, + "items": { + "type": "string", + "maxLength": 160 + } + }, + "buffering": { + "type": "boolean" + }, + "summary": { + "$ref": "#/$defs/nullableShortText" + } + }, + "additionalProperties": false + }, + "contextCompacted": { + "type": "object", + "required": [ + "schema", + "compactionId", + "reason", + "preTokens", + "postTokens", + "sameSession" + ], + "properties": { + "schema": { + "const": "paperclip.context.compacted.v1" + }, + "compactionId": { + "$ref": "#/$defs/id" + }, + "reason": { + "enum": [ + "context_window", + "manual", + "provider", + "unknown" + ] + }, + "preTokens": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "postTokens": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "sameSession": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "artifactViewed": { + "type": "object", + "required": [ + "schema", + "artifactId", + "reference", + "mediaType", + "title" + ], + "properties": { + "schema": { + "const": "paperclip.artifact.viewed.v1" + }, + "artifactId": { + "$ref": "#/$defs/id" + }, + "reference": { + "$ref": "#/$defs/safePath" + }, + "mediaType": { + "type": [ + "string", + "null" + ], + "maxLength": 160 + }, + "title": { + "$ref": "#/$defs/nullableShortText" + } + }, + "additionalProperties": false + }, + "artifactGenerated": { + "type": "object", + "required": [ + "schema", + "artifactId", + "status", + "reference", + "mediaType", + "registered", + "failure" + ], + "properties": { + "schema": { + "const": "paperclip.artifact.generated.v1" + }, + "artifactId": { + "$ref": "#/$defs/id" + }, + "status": { + "enum": [ + "running", + "completed", + "failed" + ] + }, + "reference": { + "$ref": "#/$defs/safePath" + }, + "mediaType": { + "type": [ + "string", + "null" + ], + "maxLength": 160 + }, + "registered": { + "type": "boolean" + }, + "transparentBackground": { + "type": [ + "boolean", + "null" + ] + }, + "failure": { + "$ref": "#/$defs/nullableShortText" + } + }, + "additionalProperties": false + }, + "reviewMode": { + "type": "object", + "required": [ + "schema", + "reviewId", + "state", + "scope" + ], + "properties": { + "schema": { + "const": "paperclip.review.mode_changed.v1" + }, + "reviewId": { + "$ref": "#/$defs/id" + }, + "state": { + "enum": [ + "entered", + "exited" + ] + }, + "scope": { + "$ref": "#/$defs/nullableShortText" + } + }, + "additionalProperties": false + }, + "hook": { + "type": "object", + "required": [ + "schema", + "hookId", + "event", + "scope", + "status", + "blocking", + "durationMs", + "summary" + ], + "properties": { + "schema": { + "const": "paperclip.hook.v1" + }, + "hookId": { + "$ref": "#/$defs/id" + }, + "event": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "scope": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "status": { + "enum": [ + "running", + "completed", + "failed", + "cancelled" + ] + }, + "blocking": { + "type": "boolean" + }, + "durationMs": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "summary": { + "$ref": "#/$defs/nullableShortText" + } + }, + "additionalProperties": false + }, + "memoryCitation": { + "type": "object", + "required": [ + "schema", + "citationId", + "messageItemId", + "label", + "available", + "reference" + ], + "properties": { + "schema": { + "const": "paperclip.memory.citation.v1" + }, + "citationId": { + "$ref": "#/$defs/id" + }, + "messageItemId": { + "$ref": "#/$defs/id" + }, + "label": { + "$ref": "#/$defs/shortText" + }, + "available": { + "type": "boolean" + }, + "reference": { + "type": [ + "string", + "null" + ], + "maxLength": 4096 + } + }, + "additionalProperties": false + }, + "safetyReview": { + "type": "object", + "required": [ + "schema", + "reviewId", + "targetExecutionId", + "status", + "decision", + "summary" + ], + "properties": { + "schema": { + "const": "paperclip.safety.review.v1" + }, + "reviewId": { + "$ref": "#/$defs/id" + }, + "targetExecutionId": { + "anyOf": [ + { + "$ref": "#/$defs/id" + }, + { + "type": "null" + } + ] + }, + "status": { + "enum": [ + "running", + "completed", + "failed" + ] + }, + "decision": { + "enum": [ + "pending", + "allowed", + "denied", + "user_required", + "unknown" + ] + }, + "summary": { + "$ref": "#/$defs/nullableShortText" + } + }, + "additionalProperties": false + }, + "terminalInput": { + "type": "object", + "required": [ + "schema", + "executionId", + "origin", + "inputClass", + "byteCount" + ], + "properties": { + "schema": { + "const": "paperclip.terminal.input_sent.v1" + }, + "executionId": { + "$ref": "#/$defs/id" + }, + "origin": { + "enum": [ + "agent", + "user", + "system" + ] + }, + "inputClass": { + "enum": [ + "text", + "control", + "eof" + ] + }, + "byteCount": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "wait": { + "type": "object", + "required": [ + "schema", + "waitId", + "reason", + "status", + "plannedDurationMs", + "elapsedDurationMs" + ], + "properties": { + "schema": { + "const": "paperclip.wait.v1" + }, + "waitId": { + "$ref": "#/$defs/id" + }, + "reason": { + "enum": [ + "timer", + "provider", + "rate_limit", + "unknown" + ] + }, + "status": { + "enum": [ + "running", + "completed", + "interrupted", + "cancelled" + ] + }, + "plannedDurationMs": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "elapsedDurationMs": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "additionalProperties": false + }, + "providerNotice": { + "type": "object", + "required": [ + "schema", + "noticeId", + "severity", + "category", + "scope", + "recoverable", + "userActionable", + "summary" + ], + "properties": { + "schema": { + "const": "paperclip.provider.notice.v1" + }, + "noticeId": { + "$ref": "#/$defs/id" + }, + "severity": { + "enum": [ + "info", + "warning", + "error" + ] + }, + "category": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "scope": { + "enum": [ + "turn", + "session", + "environment", + "account" + ] + }, + "recoverable": { + "type": "boolean" + }, + "userActionable": { + "type": "boolean" + }, + "summary": { + "$ref": "#/$defs/shortText" + } + }, + "additionalProperties": false + } + } +} as const; + +export const workspaceDiffSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json", + "title": "Paperclip workspace diff", + "type": "object", + "required": [ + "schema", + "changeSetId", + "revision", + "source", + "complete", + "files", + "totals", + "patchArtifactRef" + ], + "properties": { + "schema": { + "const": "paperclip.workspace.diff.v1" + }, + "changeSetId": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "source": { + "enum": [ + "harness_reported", + "runner_verified" + ] + }, + "complete": { + "type": "boolean" + }, + "files": { + "type": "array", + "maxItems": 2000, + "items": { + "type": "object", + "required": [ + "path", + "operation", + "previousPath", + "additions", + "deletions", + "binary", + "diff" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" + }, + "operation": { + "enum": [ + "create", + "modify", + "delete", + "rename", + "mode_change" + ] + }, + "previousPath": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" + } + ] + }, + "additions": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "deletions": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "binary": { + "type": "boolean" + }, + "diff": { + "type": [ + "string", + "null" + ], + "maxLength": 262144 + } + }, + "additionalProperties": false + } + }, + "totals": { + "type": "object", + "required": [ + "files", + "additions", + "deletions" + ], + "properties": { + "files": { + "type": "integer", + "minimum": 0 + }, + "additions": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "deletions": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "additionalProperties": false + }, + "patchArtifactRef": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + } + }, + "additionalProperties": false +} as const; + +export const workspaceFileReferenceSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json", + "title": "Paperclip workspace file reference", + "type": "object", + "required": [ + "schema", + "referenceId", + "source", + "path", + "displayName", + "mediaType", + "presentation", + "line", + "preview", + "previewTruncated", + "contentDigest" + ], + "properties": { + "schema": { + "const": "paperclip.workspace.file_reference.v1" + }, + "referenceId": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "source": { + "enum": [ + "harness_reported", + "runner_verified" + ] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "mediaType": { + "type": [ + "string", + "null" + ], + "maxLength": 160 + }, + "presentation": { + "enum": [ + "document", + "code", + "image", + "generic" + ] + }, + "line": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "preview": { + "type": [ + "string", + "null" + ], + "maxLength": 262144 + }, + "previewTruncated": { + "type": "boolean" + }, + "contentDigest": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + ] + } + }, + "additionalProperties": false +} as const; + +export const semanticToolSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json", + "title": "PRP provider-neutral semantic tool envelope", + "type": "object", + "required": [ + "schema", + "schemaVersion", + "phase", + "operationId", + "callId", + "correlation", + "idempotencyKey", + "content" + ], + "properties": { + "schema": { + "const": "paperclip.prp.semantic_tool.v1" + }, + "schemaVersion": { + "const": 1 + }, + "phase": { + "enum": [ + "input", + "result" + ] + }, + "operationId": { + "$ref": "#/$defs/stableId" + }, + "callId": { + "$ref": "#/$defs/stableId" + }, + "correlation": { + "type": "object", + "required": [ + "runId", + "normalizedSessionId", + "turnId", + "itemId" + ], + "properties": { + "runId": { + "$ref": "#/$defs/stableId" + }, + "normalizedSessionId": { + "$ref": "#/$defs/stableId" + }, + "turnId": { + "$ref": "#/$defs/stableId" + }, + "itemId": { + "$ref": "#/$defs/stableId" + }, + "requestId": { + "$ref": "#/$defs/stableId" + } + }, + "additionalProperties": true + }, + "idempotencyKey": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 240 + }, + "content": { + "$ref": "#/$defs/safeContent" + }, + "outcome": { + "enum": [ + "succeeded", + "denied", + "conflict", + "duplicate", + "unavailable", + "failed" + ] + }, + "code": { + "$ref": "#/$defs/stableCode" + }, + "retryable": { + "type": "boolean" + }, + "authorizationBoundary": { + "enum": [ + "company", + "actor", + "active_task", + "grant", + "governed_action", + "lock", + "revision" + ] + }, + "operationReceiptId": { + "$ref": "#/$defs/stableId" + }, + "auditReceiptId": { + "$ref": "#/$defs/stableId" + }, + "currentRevision": { + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "#/$defs/stableId" + } + ] + }, + "duplicateOfReceiptId": { + "$ref": "#/$defs/stableId" + }, + "artifactRefs": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/$defs/safeReference" + }, + { + "type": "object", + "properties": { + "kind": { + "enum": [ + "artifact", + "work_product" + ] + } + } + } + ] + }, + "uniqueItems": true + }, + "targets": { + "type": "array", + "items": { + "$ref": "#/$defs/immutableTarget" + }, + "uniqueItems": true + }, + "causalRefs": { + "type": "array", + "items": { + "$ref": "#/$defs/safeReference" + }, + "uniqueItems": true + } + }, + "allOf": [ + { + "if": { + "properties": { + "phase": { + "const": "result" + } + } + }, + "then": { + "required": [ + "outcome", + "code", + "retryable", + "authorizationBoundary", + "operationReceiptId" + ] + } + } + ], + "additionalProperties": true, + "$defs": { + "stableId": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "stableCode": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_.:-]*$" + }, + "safeContent": { + "type": "object", + "required": [ + "digest", + "redactionDisposition", + "references" + ], + "properties": { + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "redactionDisposition": { + "enum": [ + "digest_only", + "allowlisted_references", + "redacted" + ] + }, + "references": { + "type": "array", + "items": { + "$ref": "#/$defs/safeReference" + }, + "uniqueItems": true + } + }, + "additionalProperties": true + }, + "safeReference": { + "type": "object", + "required": [ + "kind", + "id" + ], + "properties": { + "kind": { + "enum": [ + "task", + "document_revision", + "interaction", + "approval", + "decision", + "artifact", + "work_product", + "wake", + "monitor", + "audit", + "operation" + ] + }, + "id": { + "$ref": "#/$defs/stableId" + } + }, + "additionalProperties": true + }, + "immutableTarget": { + "type": "object", + "required": [ + "kind", + "id", + "immutable" + ], + "properties": { + "kind": { + "enum": [ + "document_revision", + "interaction", + "approval", + "decision" + ] + }, + "id": { + "$ref": "#/$defs/stableId" + }, + "immutable": { + "const": true + }, + "revisionId": { + "$ref": "#/$defs/stableId" + }, + "decisionId": { + "$ref": "#/$defs/stableId" + } + }, + "additionalProperties": true + } + } +} as const; + +export const usageSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/usage.schema.json", + "title": "PRP provider usage receipt", + "type": "object", + "required": [ + "provider", + "model", + "cumulative", + "runDelta" + ], + "properties": { + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "model": { + "type": [ + "string", + "null" + ], + "maxLength": 240 + }, + "providerSessionId": { + "type": [ + "string", + "null" + ], + "maxLength": 240 + }, + "providerRequestId": { + "type": [ + "string", + "null" + ], + "maxLength": 240 + }, + "cumulative": { + "$ref": "#/$defs/measurement" + }, + "runDelta": { + "$ref": "#/$defs/measurement" + } + }, + "additionalProperties": false, + "$defs": { + "measurement": { + "type": "object", + "required": [ + "inputTokens", + "outputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "activeSeconds", + "requests", + "providerCostUsd" + ], + "properties": { + "inputTokens": { + "type": "integer", + "minimum": 0 + }, + "outputTokens": { + "type": "integer", + "minimum": 0 + }, + "cacheReadTokens": { + "type": "integer", + "minimum": 0 + }, + "cacheWriteTokens": { + "type": "integer", + "minimum": 0 + }, + "activeSeconds": { + "type": "number", + "minimum": 0 + }, + "requests": { + "type": "integer", + "minimum": 0 + }, + "providerCostUsd": { + "type": "number", + "minimum": 0 + } + }, + "additionalProperties": false + } + } +} as const; + +export const stopReasonSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json", + "title": "PRP terminal stop reason receipt", + "type": "object", + "required": [ + "schema", + "schemaVersion", + "receiptId", + "kind", + "code", + "retryable", + "decisionId", + "limitClass", + "aggregate" + ], + "properties": { + "schema": { + "const": "paperclip.prp.stop_reason.v1" + }, + "schemaVersion": { + "const": 1 + }, + "receiptId": { + "$ref": "#/$defs/stableId" + }, + "kind": { + "enum": [ + "budget", + "cost" + ] + }, + "code": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_.:-]*$" + }, + "retryable": { + "type": "boolean" + }, + "decisionId": { + "$ref": "#/$defs/stableId" + }, + "limitClass": { + "enum": [ + "company_monthly", + "actor_monthly", + "project_monthly", + "run_cost", + "provider_quota" + ] + }, + "aggregate": { + "type": "object", + "required": [ + "unit", + "observed", + "limit", + "window" + ], + "properties": { + "unit": { + "enum": [ + "cents", + "usd_micros", + "tokens" + ] + }, + "observed": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 0 + }, + "window": { + "enum": [ + "run", + "monthly_utc", + "provider_window" + ] + } + }, + "additionalProperties": true + }, + "relatedReceiptIds": { + "type": "array", + "items": { + "$ref": "#/$defs/stableId" + }, + "uniqueItems": true + } + }, + "additionalProperties": true, + "$defs": { + "stableId": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + } + } +} as const; + +export const terminalSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/terminal.schema.json", + "title": "PRP run terminal state", + "type": "object", + "required": [ + "schema", + "turnTerminalState", + "runTerminalState", + "reportedWorkDisposition" + ], + "properties": { + "schema": { + "const": "paperclip.prp.terminal.v1" + }, + "turnTerminalState": { + "enum": [ + "completed", + "failed", + "interrupted", + "cancelled" + ] + }, + "runTerminalState": { + "enum": [ + "succeeded", + "failed", + "cancelled" + ] + }, + "reportedWorkDisposition": { + "enum": [ + "done", + "blocked", + "needs_review", + "yielded" + ] + }, + "workAssessmentId": { + "type": "string", + "minLength": 1 + }, + "statusDecisionId": { + "type": "string", + "minLength": 1 + }, + "stopReason": { + "$ref": "https://paperclip.dev/schemas/prp/v1/stop-reason.schema.json" + } + }, + "additionalProperties": true +} as const; + +export const questionSetSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json", + "title": "Paperclip question set", + "type": "object", + "required": [ + "schema", + "questions" + ], + "properties": { + "schema": { + "const": "paperclip.question_set.v1" + }, + "title": { + "type": "string", + "maxLength": 1000 + }, + "description": { + "type": "string", + "maxLength": 4000 + }, + "submitLabel": { + "type": "string", + "maxLength": 200 + }, + "questions": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": { + "$ref": "#/$defs/question" + } + } + }, + "$defs": { + "option": { + "type": "object", + "required": [ + "id", + "label" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "description": { + "type": "string", + "maxLength": 4000 + } + }, + "additionalProperties": false + }, + "customAnswer": { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "const": true + }, + "label": { + "type": "string", + "maxLength": 1000 + }, + "placeholder": { + "type": "string", + "maxLength": 1000 + } + }, + "additionalProperties": false + }, + "textValidation": { + "type": "object", + "properties": { + "minLength": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "maxLength": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "pattern": { + "type": "string", + "maxLength": 1000 + }, + "inputType": { + "enum": [ + "text", + "number", + "integer" + ] + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + } + }, + "additionalProperties": false + }, + "question": { + "type": "object", + "required": [ + "id", + "prompt", + "required", + "answerMode" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "header": { + "type": "string", + "maxLength": 1000 + }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "helpText": { + "type": "string", + "maxLength": 4000 + }, + "required": { + "type": "boolean" + }, + "answerMode": { + "enum": [ + "single_select", + "multi_select", + "text" + ] + }, + "options": { + "type": "array", + "maxItems": 128, + "items": { + "$ref": "#/$defs/option" + } + }, + "customAnswer": { + "$ref": "#/$defs/customAnswer" + }, + "textValidation": { + "$ref": "#/$defs/textValidation" + } + }, + "allOf": [ + { + "if": { + "properties": { + "answerMode": { + "const": "text" + } + }, + "required": [ + "answerMode" + ] + }, + "then": { + "properties": { + "options": { + "type": "array", + "maxItems": 0 + } + }, + "not": { + "required": [ + "customAnswer" + ] + } + } + }, + { + "if": { + "properties": { + "answerMode": { + "enum": [ + "single_select", + "multi_select" + ] + } + }, + "required": [ + "answerMode" + ] + }, + "then": { + "required": [ + "options" + ], + "properties": { + "options": { + "type": "array", + "minItems": 1 + } + } + } + } + ], + "additionalProperties": false + } + }, + "additionalProperties": false +} as const; + +export const questionResponseSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/question-response.schema.json", + "title": "Paperclip question response", + "type": "object", + "required": [ + "schema", + "answers" + ], + "properties": { + "schema": { + "const": "paperclip.question_response.v1" + }, + "answers": { + "type": "object", + "propertyNames": { + "minLength": 1, + "maxLength": 160 + }, + "additionalProperties": { + "type": "object", + "properties": { + "selectedOptionIds": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 160 + } + }, + "text": { + "type": "string", + "maxLength": 100000 + }, + "customText": { + "type": "string", + "maxLength": 100000 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} as const; + +export const questionAdapterFixtureSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/question-adapter-fixture.schema.json", + "title": "Harness question adapter conformance fixture", + "type": "object", + "required": [ + "schema", + "adapter", + "nativeRequest", + "canonicalQuestionSet", + "canonicalResponse", + "nativeResponse" + ], + "properties": { + "schema": { + "const": "paperclip.question_adapter_fixture.v1" + }, + "adapter": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "nativeRequest": {}, + "canonicalQuestionSet": { + "$ref": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json" + }, + "canonicalResponse": { + "$ref": "https://paperclip.dev/schemas/prp/v1/question-response.schema.json" + }, + "nativeResponse": {} + }, + "additionalProperties": false +} as const; + +export const requestSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/request.schema.json", + "title": "PRP request", + "oneOf": [ + { + "type": "object", + "required": [ + "schema", + "requestKind", + "requestId", + "type", + "status", + "prompt", + "input" + ], + "properties": { + "schema": { + "const": "paperclip.runtime_request.v2" + }, + "requestKind": { + "const": "runtime" + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "type": { + "const": "input" + }, + "status": { + "enum": [ + "pending", + "resolved", + "expired", + "cancelled" + ] + }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "input": { + "$ref": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json" + }, + "origin": { + "type": "object", + "required": [ + "adapter" + ], + "properties": { + "adapter": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "method": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + }, + "additionalProperties": false + }, + "turnId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "itemId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "schema", + "requestKind", + "requestId", + "type", + "status", + "prompt" + ], + "properties": { + "schema": { + "const": "paperclip.runtime_request.v1" + }, + "requestKind": { + "const": "runtime" + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "type": { + "enum": [ + "permission", + "input" + ] + }, + "status": { + "enum": [ + "pending", + "resolved", + "expired", + "cancelled" + ] + }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "choices": { + "type": "array", + "items": { + "type": "object", + "required": [ + "key", + "label" + ], + "properties": { + "key": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + }, + { + "type": "object", + "required": [ + "schema", + "requestKind", + "requestId", + "kind", + "blocking", + "payload" + ], + "properties": { + "schema": { + "const": "paperclip.interaction_request.v1" + }, + "requestKind": { + "const": "issue_thread" + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "kind": { + "enum": [ + "request_confirmation", + "request_checkbox_confirmation", + "request_item_verdicts", + "ask_user_questions", + "suggest_tasks" + ] + }, + "blocking": { + "type": "boolean" + }, + "payload": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + } + ] +} as const; + +export const resultSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/result.schema.json", + "title": "PRP structured run result", + "type": "object", + "required": [ + "schema", + "reportedWorkDisposition", + "summary", + "completionClaim", + "evidence", + "verification", + "attentionRequests", + "artifacts" + ], + "properties": { + "schema": { + "const": "paperclip.run_result.v1" + }, + "reportedWorkDisposition": { + "enum": [ + "done", + "blocked", + "needs_review", + "yielded" + ] + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 12000 + }, + "completionClaim": { + "type": "object", + "required": [ + "contractRevision", + "objectiveSatisfied", + "criteria", + "remainingWork" + ], + "properties": { + "contractRevision": { + "type": "string", + "minLength": 1 + }, + "objectiveSatisfied": { + "type": "boolean" + }, + "criteria": { + "type": "array", + "items": { + "type": "object", + "required": [ + "criterionId", + "status", + "evidenceRefs" + ], + "properties": { + "criterionId": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "satisfied", + "not_satisfied", + "unknown" + ] + }, + "evidenceRefs": { + "type": "array", + "items": { + "type": "string" + } + }, + "explanation": { + "type": "string" + } + }, + "additionalProperties": true + } + }, + "remainingWork": { + "type": "array", + "items": { + "type": "object", + "required": [ + "description", + "blocksCompletion" + ], + "properties": { + "description": { + "type": "string", + "minLength": 1 + }, + "blocksCompletion": { + "type": "boolean" + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + }, + "evidence": { + "type": "array", + "items": { + "type": "object" + } + }, + "verification": { + "type": "array", + "description": "Checks used to verify the work. Use failed only when the check actually ran and found a defect in the work. If a check could not run or complete because the environment, tool, dependency, permission, credential, policy, external service, or budget was unavailable, use not_run with reasonCode even when the attempted command exited non-zero.", + "items": { + "type": "object", + "required": [ + "commandOrCheck", + "status" + ], + "properties": { + "commandOrCheck": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "passed", + "failed", + "not_run" + ], + "description": "passed means the check ran and succeeded; failed means it ran to a meaningful verdict and found the work incorrect; not_run means no meaningful verdict was possible because the check was not attempted or could not complete." + }, + "reasonCode": { + "description": "Required for not_run; identify why the check had no meaningful verdict.", + "enum": [ + "environment_unavailable", + "tool_unavailable", + "dependency_missing", + "permission_denied", + "credential_missing", + "policy_restricted", + "external_service_unavailable", + "budget_exhausted", + "other" + ] + }, + "detail": { + "type": "string" + }, + "artifactRef": { + "type": "string" + } + }, + "additionalProperties": true + } + }, + "blocker": { + "type": "object", + "required": [ + "reasonCode", + "owner", + "unblockAction", + "scope" + ], + "properties": { + "reasonCode": { + "type": "string", + "minLength": 1 + }, + "owner": { + "type": "object", + "additionalProperties": true + }, + "unblockAction": { + "type": "string", + "minLength": 1 + }, + "scope": { + "enum": [ + "current_track", + "task_wide" + ] + } + }, + "additionalProperties": true + }, + "attentionRequests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "minLength": 1 + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "ownerClass": { + "enum": [ + "human", + "agent", + "external_system" + ] + }, + "targetAgentId": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + } + }, + "artifacts": { + "type": "array", + "items": { + "type": "object", + "required": [ + "kind", + "ref" + ], + "properties": { + "kind": { + "type": "string", + "minLength": 1 + }, + "ref": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + } + }, + "additionalProperties": true + } + }, + "continuation": { + "type": "object", + "required": [ + "kind", + "summary", + "idempotencyKey" + ], + "properties": { + "kind": { + "enum": [ + "same_agent", + "retry", + "delegated_issue", + "response_wake", + "monitor" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + } + }, + "allOf": [ + { + "if": { + "properties": { + "reportedWorkDisposition": { + "const": "blocked" + } + } + }, + "then": { + "required": [ + "blocker" + ] + } + }, + { + "if": { + "properties": { + "reportedWorkDisposition": { + "const": "yielded" + } + } + }, + "then": { + "required": [ + "continuation" + ] + } + } + ], + "additionalProperties": true +} as const; + +export const eventSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/event.schema.json", + "title": "PRP native event", + "type": "object", + "required": [ + "schema", + "sourceEventId", + "sourceSeq", + "sourceInstanceId", + "sourceKind", + "runId", + "eventType", + "schemaVersion", + "priority", + "emittedAt", + "payload" + ], + "properties": { + "schema": { + "const": "paperclip.prp.event.v1" + }, + "sourceEventId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "sourceSeq": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "sourceInstanceId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "sourceKind": { + "enum": [ + "runner", + "control_plane" + ] + }, + "runId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "normalizedSessionId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "turnId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "itemId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "eventType": { + "enum": [ + "runner.connected", + "runner.reconnected", + "runner.reconciled", + "runner.disconnected", + "runner.draining", + "runner.suspending", + "runner.suspended", + "runner.stopped", + "runner.diagnostic", + "runtime.phase.changed", + "sandbox.metric", + "workspace.ready", + "workspace.change.updated", + "workspace.diff.recorded", + "workspace.file.referenced", + "harness.starting", + "harness.ready", + "harness.exited", + "harness.diagnostic", + "plan.updated", + "tool.execution.started", + "tool.execution.progressed", + "tool.execution.completed", + "research.started", + "research.progressed", + "research.completed", + "delegation.started", + "delegation.updated", + "delegation.completed", + "model.route.changed", + "model.verification.updated", + "context.compacted", + "artifact.viewed", + "artifact.generated", + "review.mode.changed", + "hook.started", + "hook.completed", + "memory.citation.referenced", + "safety.review.started", + "safety.review.completed", + "terminal.input.sent", + "wait.started", + "wait.completed", + "provider.notice.recorded", + "session.starting", + "session.started", + "session.resuming", + "session.resumed", + "session.reconciled", + "session.updated", + "session.closed", + "session.failed", + "turn.submitted", + "turn.accepted", + "turn.started", + "turn.completed", + "turn.failed", + "turn.interrupted", + "turn.cancelled", + "item.started", + "item.delta", + "item.completed", + "item.failed", + "usage.reported", + "semantic_tool.input", + "semantic_tool.result", + "mcp_app.discovered", + "mcp_app.resource.resolved", + "mcp_app.initializing", + "mcp_app.ready", + "mcp_app.tool_input", + "mcp_app.tool_result", + "mcp_app.action.requested", + "mcp_app.action.resolved", + "mcp_app.host_context.changed", + "mcp_app.failed", + "mcp_app.teardown", + "runtime_request.created", + "runtime_request.resolved", + "runtime_request.expired", + "runtime_request.cancelled", + "interaction.request.proposed", + "interaction.request.materialized", + "interaction.request.rejected", + "interaction.response.progressed", + "interaction.response.resolved", + "interaction.response.delivered", + "run.attached", + "run.detached", + "run.result.proposed", + "run.result.accepted", + "run.result.rejected", + "attention.request.proposed", + "attention.request.routed", + "attention.request.resolved", + "attention.request.expired", + "attention.request.superseded", + "work.assessment.recorded", + "issue.status.decision.recorded", + "issue.status.decision.applied", + "issue.status.decision.rejected", + "issue.status.decision.superseded", + "run.terminal" + ] + }, + "schemaVersion": { + "const": 1 + }, + "priority": { + "enum": [ + 0, + 1, + 2 + ] + }, + "emittedAt": { + "type": "string", + "format": "date-time" + }, + "observedAt": { + "type": "string", + "format": "date-time" + }, + "payload": { + "type": "object", + "properties": { + "channel": { + "enum": [ + "progress", + "final", + "summary", + "detail", + "unknown" + ] + }, + "providerPhase": { + "type": "string" + }, + "providerMethod": { + "type": "string" + } + }, + "additionalProperties": true + }, + "debug": { + "type": "object", + "additionalProperties": true + } + }, + "allOf": [ + { + "if": { + "properties": { + "eventType": { + "const": "plan.updated" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/plan" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "enum": [ + "tool.execution.started", + "tool.execution.progressed", + "tool.execution.completed" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/toolExecution" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "enum": [ + "research.started", + "research.progressed", + "research.completed" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/research" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "enum": [ + "delegation.started", + "delegation.updated", + "delegation.completed" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/delegation" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "const": "model.route.changed" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/modelRoute" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "const": "model.verification.updated" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/modelVerification" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "const": "context.compacted" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/contextCompacted" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "const": "artifact.viewed" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/artifactViewed" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "const": "artifact.generated" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/artifactGenerated" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "const": "review.mode.changed" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/reviewMode" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "enum": [ + "hook.started", + "hook.completed" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/hook" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "const": "memory.citation.referenced" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/memoryCitation" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "enum": [ + "safety.review.started", + "safety.review.completed" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/safetyReview" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "const": "terminal.input.sent" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/terminalInput" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "enum": [ + "wait.started", + "wait.completed" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/wait" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "const": "provider.notice.recorded" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/providerNotice" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "const": "workspace.file.referenced" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "enum": [ + "workspace.change.updated", + "workspace.diff.recorded" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "const": "workspace.diff.recorded" + } + } + }, + "then": { + "properties": { + "payload": { + "allOf": [ + { + "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json" + }, + { + "type": "object", + "properties": { + "complete": { + "const": true + } + } + } + ] + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "enum": [ + "semantic_tool.input", + "mcp_app.tool_input" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "properties": { + "semantic_tool": { + "allOf": [ + { + "$ref": "https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json" + }, + { + "type": "object", + "properties": { + "phase": { + "const": "input" + } + } + } + ] + } + }, + "additionalProperties": true + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "enum": [ + "semantic_tool.result", + "mcp_app.tool_result" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "properties": { + "semantic_tool": { + "allOf": [ + { + "$ref": "https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json" + }, + { + "type": "object", + "properties": { + "phase": { + "const": "result" + } + } + } + ] + } + }, + "additionalProperties": true + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "const": "run.terminal" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/terminal.schema.json" + } + } + } + }, + { + "if": { + "properties": { + "eventType": { + "const": "run.result.proposed" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "https://paperclip.dev/schemas/prp/v1/result.schema.json" + } + } + } + } + ], + "additionalProperties": true +} as const; + +export const fixtureSchema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://paperclip.dev/schemas/prp/v1/fixture.schema.json", + "title": "PRP scripted replay fixture", + "type": "object", + "required": [ + "schema", + "fixtureVersion", + "protocolVersion", + "name", + "description", + "identity", + "capabilities", + "commands", + "events", + "result" + ], + "properties": { + "schema": { + "const": "paperclip.prp.fixture.v1" + }, + "fixtureVersion": { + "const": 1 + }, + "protocolVersion": { + "const": 1 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "identity": { + "$ref": "https://paperclip.dev/schemas/prp/v1/identity.schema.json" + }, + "capabilities": { + "$ref": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json" + }, + "commands": { + "type": "array", + "items": { + "$ref": "https://paperclip.dev/schemas/prp/v1/command.schema.json" + } + }, + "events": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "https://paperclip.dev/schemas/prp/v1/event.schema.json" + } + }, + "requests": { + "type": "array", + "items": { + "$ref": "https://paperclip.dev/schemas/prp/v1/request.schema.json" + } + }, + "result": { + "$ref": "https://paperclip.dev/schemas/prp/v1/result.schema.json" + } + }, + "additionalProperties": true +} as const; + +export const prpSchemaBundle = { + "identity": identitySchema, + "capabilities": capabilitiesSchema, + "command": commandSchema, + "provider-descriptor": providerDescriptorSchema, + "provider-event": providerEventSchema, + "workspace-diff": workspaceDiffSchema, + "workspace-file-reference": workspaceFileReferenceSchema, + "semantic-tool": semanticToolSchema, + "usage": usageSchema, + "stop-reason": stopReasonSchema, + "terminal": terminalSchema, + "question-set": questionSetSchema, + "question-response": questionResponseSchema, + "question-adapter-fixture": questionAdapterFixtureSchema, + "request": requestSchema, + "result": resultSchema, + "event": eventSchema, + "fixture": fixtureSchema, +} as const; diff --git a/packages/paperclip-runner/src/protocol/replay-contract.test.ts b/packages/paperclip-runner/src/protocol/replay-contract.test.ts new file mode 100644 index 00000000000..5cb2f31f41b --- /dev/null +++ b/packages/paperclip-runner/src/protocol/replay-contract.test.ts @@ -0,0 +1,172 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { + negotiateProtocolVersion, + parsePrpFixtureText, + PRP_PROTOCOL_VERSION, +} from "./replay-contract.js"; + +const fixtureDirectory = new URL( + "../../protocol/fixtures/replay/", + import.meta.url, +); +const validFixtures = [ + "happy-path.json", + "failed-run.json", + "interrupted-run.json", + "duplicate-event.json", + "source-gap.json", + "unknown-optional-fields.json", +]; + +async function readFixture( + name = "happy-path.json", +): Promise> { + return JSON.parse( + await readFile(new URL(name, fixtureDirectory), "utf8"), + ) as Record; +} + +describe("PRP v1 JSON Schema contract", () => { + for (const fixtureName of validFixtures) { + it(`validates ${fixtureName}`, async () => { + const result = parsePrpFixtureText( + await readFile(new URL(fixtureName, fixtureDirectory), "utf8"), + ); + expect(result.ok).toBe(true); + }); + } + + it("preserves unknown optional fields for forward compatibility", async () => { + const result = parsePrpFixtureText( + await readFile( + new URL("unknown-optional-fields.json", fixtureDirectory), + "utf8", + ), + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.fixture.futureFixtureHint).toEqual({ + producerVersion: "1.1-preview", + }); + expect(result.fixture.events[0]?.futureEnvelopeField).toBe(42); + } + }); + + it("fails closed on an unsupported required protocol version", async () => { + const result = parsePrpFixtureText( + await readFile( + new URL("unsupported-required-version.json", fixtureDirectory), + "utf8", + ), + ); + expect(result).toMatchObject({ + ok: false, + issues: [ + { + code: "unsupported_required_version", + path: "/protocolVersion", + }, + ], + }); + }); + + it("fails closed on unsupported nested required schema versions", async () => { + const fixture = await readFixture(); + const events = fixture.events as Array>; + events[0]!.schemaVersion = 2; + expect(parsePrpFixtureText(JSON.stringify(fixture))).toMatchObject({ + ok: false, + issues: [ + { + code: "unsupported_required_version", + path: "/events/0/schemaVersion", + }, + ], + }); + }); + + it("rejects source sequences that cannot be represented exactly", async () => { + const fixture = await readFixture(); + const events = fixture.events as Array>; + events[0]!.sourceSeq = Number.MAX_SAFE_INTEGER + 1; + expect(parsePrpFixtureText(JSON.stringify(fixture))).toMatchObject({ + ok: false, + issues: [ + { + code: "schema_validation", + path: "/events/0/sourceSeq", + }, + ], + }); + }); + + it("requires the declared result to match the replayed result event", async () => { + const fixture = await readFixture(); + const result = fixture.result as Record; + result.summary = "A contradictory expected result."; + expect(parsePrpFixtureText(JSON.stringify(fixture))).toMatchObject({ + ok: false, + issues: [ + { + code: "binding_mismatch", + path: "/result", + }, + ], + }); + }); + + it("rejects a duplicate event id carrying different content", async () => { + const fixture = await readFixture("duplicate-event.json"); + const events = fixture.events as Array>; + const payload = events[3]!.payload as Record; + payload.text = "A mutated duplicate."; + expect(parsePrpFixtureText(JSON.stringify(fixture))).toMatchObject({ + ok: false, + issues: [ + { + code: "binding_mismatch", + path: "/events/3/sourceEventId", + }, + ], + }); + }); + + it("requires exactly one unique terminal event", async () => { + const fixture = await readFixture(); + const events = fixture.events as Array>; + fixture.events = events.filter( + (event) => event.eventType !== "run.terminal", + ); + expect(parsePrpFixtureText(JSON.stringify(fixture))).toMatchObject({ + ok: false, + issues: [ + { + code: "binding_mismatch", + path: "/events", + }, + ], + }); + }); + + it("reports invalid JSON without throwing", () => { + expect(parsePrpFixtureText("{")).toMatchObject({ + ok: false, + issues: [{ code: "invalid_json", path: "/" }], + }); + }); + + it("selects only an overlapping supported protocol version", () => { + expect( + negotiateProtocolVersion( + { min: 1, max: PRP_PROTOCOL_VERSION }, + { min: 1, max: 2 }, + ), + ).toBe(1); + expect( + negotiateProtocolVersion({ min: 2, max: 3 }, { min: 1, max: 1 }), + ).toBeNull(); + }); +}); diff --git a/packages/paperclip-runner/src/protocol/replay-contract.ts b/packages/paperclip-runner/src/protocol/replay-contract.ts new file mode 100644 index 00000000000..2b3764ff752 --- /dev/null +++ b/packages/paperclip-runner/src/protocol/replay-contract.ts @@ -0,0 +1,457 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { ErrorObject, ValidateFunction } from "ajv/dist/2020.js"; +import type { FromSchema } from "json-schema-to-ts"; + +import { + capabilitiesSchema, + commandSchema, + eventSchema, + fixtureSchema, + identitySchema, + questionSetSchema, + requestSchema, + resultSchema, + semanticToolSchema, + stopReasonSchema, + terminalSchema, + prpSchemaBundle, +} from "./generated/schema-bundle.js"; +import { normalizeLegacyPrpStructuredRunResult } from "./result-normalization.js"; + +export const PRP_PROTOCOL_NAME = "paperclip.runner"; +export const PRP_PROTOCOL_VERSION = 1; +export const PRP_FIXTURE_SCHEMA = "paperclip.prp.fixture.v1"; + +type TerminalReferences = [typeof stopReasonSchema]; +type EventReferences = [ + typeof semanticToolSchema, + typeof stopReasonSchema, + typeof terminalSchema, + typeof resultSchema, +]; +export type PrpIdentity = FromSchema; +export type PrpCapabilities = FromSchema; +export type PrpCommand = FromSchema; +export type PrpSemanticToolEnvelope = FromSchema; +export type PrpStopReason = FromSchema; +export type PrpTerminalState = FromSchema< + typeof terminalSchema, + { references: TerminalReferences } +>; +type RequestReferences = [typeof questionSetSchema]; +export type PrpRequest = FromSchema< + typeof requestSchema, + { references: RequestReferences } +>; +export type PrpStructuredRunResult = FromSchema; +export type PrpEvent = FromSchema< + typeof eventSchema, + { references: EventReferences } +>; +/** Runtime-validated composition of the JSON-Schema-derived contract types. */ +export interface PrpFixture { + schema: typeof PRP_FIXTURE_SCHEMA; + fixtureVersion: 1; + protocolVersion: typeof PRP_PROTOCOL_VERSION; + name: string; + description: string; + identity: PrpIdentity; + capabilities: PrpCapabilities; + commands: PrpCommand[]; + events: PrpEvent[]; + requests?: PrpRequest[]; + result: PrpStructuredRunResult; + [key: string]: unknown; +} + +export type ProtocolValidationIssueCode = + | "invalid_json" + | "schema_validation" + | "unsupported_required_version" + | "binding_mismatch"; + +export interface ProtocolValidationIssue { + code: ProtocolValidationIssueCode; + path: string; + message: string; +} + +export type ProtocolValidationResult = + | { ok: true; fixture: PrpFixture; issues: [] } + | { ok: false; fixture: null; issues: ProtocolValidationIssue[] }; + +export interface ProtocolVersionRange { + min: number; + max: number; +} + +// Runtime validation compiles the same checked-in schemas used to generate +// the public TypeScript types. Browser/CSP-specific precompiled validators are +// intentionally deferred until the browser SDK package boundary is introduced. +const ajv = new Ajv2020({ + allErrors: true, + strict: true, + strictRequired: false, + formats: { + "date-time": /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/, + }, +}); +for (const schema of Object.values(prpSchemaBundle)) ajv.addSchema(schema); + +function validatorFor(schemaId: string): ValidateFunction { + const validator = ajv.getSchema(schemaId); + if (validator === undefined) { + throw new Error(`Missing generated PRP validator for ${schemaId}`); + } + return validator; +} + +const fixtureValidator = validatorFor(fixtureSchema.$id); +const eventValidator = validatorFor(eventSchema.$id); +const resultValidator = validatorFor(resultSchema.$id); + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + const object = asRecord(value); + if (object !== null) { + return `{${Object.keys(object) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "undefined"; +} + +function versionIssues(value: unknown): ProtocolValidationIssue[] { + const fixture = asRecord(value); + if (fixture === null) { + return []; + } + + const issues: ProtocolValidationIssue[] = []; + for (const [field, supported] of [ + ["fixtureVersion", 1], + ["protocolVersion", PRP_PROTOCOL_VERSION], + ] as const) { + const actual = fixture[field]; + if (typeof actual === "number" && actual !== supported) { + issues.push({ + code: "unsupported_required_version", + path: `/${field}`, + message: `${field} ${actual} is unsupported; this implementation requires ${supported}`, + }); + } + } + + if (Array.isArray(fixture.events)) { + fixture.events.forEach((entry, index) => { + const event = asRecord(entry); + const actual = event?.schemaVersion; + if (typeof actual === "number" && actual !== 1) { + issues.push({ + code: "unsupported_required_version", + path: `/events/${index}/schemaVersion`, + message: `event schemaVersion ${actual} is unsupported; this implementation requires 1`, + }); + } + const payload = asRecord(event?.payload); + const semanticTool = asRecord(payload?.semantic_tool); + const semanticToolVersion = semanticTool?.schemaVersion; + if ( + typeof semanticToolVersion === "number" && + semanticToolVersion !== 1 + ) { + issues.push({ + code: "unsupported_required_version", + path: `/events/${index}/payload/semantic_tool/schemaVersion`, + message: `semantic_tool schemaVersion ${semanticToolVersion} is unsupported; this implementation requires 1`, + }); + } + const stopReason = asRecord(payload?.stopReason); + const stopReasonVersion = stopReason?.schemaVersion; + if (typeof stopReasonVersion === "number" && stopReasonVersion !== 1) { + issues.push({ + code: "unsupported_required_version", + path: `/events/${index}/payload/stopReason/schemaVersion`, + message: `stopReason schemaVersion ${stopReasonVersion} is unsupported; this implementation requires 1`, + }); + } + }); + } + const capabilities = asRecord(fixture.capabilities); + const semanticTools = asRecord(capabilities?.semanticTools); + const semanticToolsVersion = semanticTools?.schemaVersion; + if (typeof semanticToolsVersion === "number" && semanticToolsVersion !== 1) { + issues.push({ + code: "unsupported_required_version", + path: "/capabilities/semanticTools/schemaVersion", + message: `semanticTools schemaVersion ${semanticToolsVersion} is unsupported; this implementation requires 1`, + }); + } + return issues; +} + +function ajvIssue(error: ErrorObject): ProtocolValidationIssue { + return { + code: "schema_validation", + path: error.instancePath || "/", + message: error.message ?? "does not match the PRP schema", + }; +} + +function bindingIssues(fixture: PrpFixture): ProtocolValidationIssue[] { + const issues: ProtocolValidationIssue[] = []; + const uniqueEvents = new Map(); + const semanticCalls = new Map< + string, + { + input?: { envelope: PrpSemanticToolEnvelope; index: number }; + result?: { envelope: PrpSemanticToolEnvelope; index: number }; + } + >(); + fixture.events.forEach((event, index) => { + if (event.runId !== fixture.identity.runId) { + issues.push({ + code: "binding_mismatch", + path: `/events/${index}/runId`, + message: "event runId must match identity.runId", + }); + } + if ( + event.normalizedSessionId !== undefined && + event.normalizedSessionId !== fixture.identity.normalizedSessionId + ) { + issues.push({ + code: "binding_mismatch", + path: `/events/${index}/normalizedSessionId`, + message: + "event normalizedSessionId must match identity.normalizedSessionId", + }); + } + const existing = uniqueEvents.get(event.sourceEventId); + if (existing === undefined) { + uniqueEvents.set(event.sourceEventId, event); + } else { + if (canonicalJson(existing) !== canonicalJson(event)) { + issues.push({ + code: "binding_mismatch", + path: `/events/${index}/sourceEventId`, + message: "duplicate sourceEventId deliveries must be byte-equivalent", + }); + } + // At-least-once source delivery is not a second semantic invocation. + return; + } + const payload = asRecord(event.payload); + const semanticTool = asRecord( + payload?.semantic_tool, + ) as PrpSemanticToolEnvelope | null; + if (semanticTool !== null) { + const correlation = asRecord(semanticTool.correlation); + for (const [field, actual, expected] of [ + ["runId", correlation?.runId, event.runId], + [ + "normalizedSessionId", + correlation?.normalizedSessionId, + event.normalizedSessionId, + ], + ["turnId", correlation?.turnId, event.turnId], + ["itemId", correlation?.itemId, event.itemId], + ] as const) { + if (actual !== expected) { + issues.push({ + code: "binding_mismatch", + path: `/events/${index}/payload/semantic_tool/correlation/${field}`, + message: `semantic_tool correlation ${field} must match the containing event`, + }); + } + } + const call = semanticCalls.get(semanticTool.callId) ?? {}; + const phase = semanticTool.phase; + if (call[phase] !== undefined) { + issues.push({ + code: "binding_mismatch", + path: `/events/${index}/payload/semantic_tool/callId`, + message: `semantic_tool call ${semanticTool.callId} must contain exactly one ${phase} envelope`, + }); + } else { + call[phase] = { envelope: semanticTool, index }; + semanticCalls.set(semanticTool.callId, call); + } + } + }); + + for (const [callId, call] of semanticCalls) { + if (call.input === undefined || call.result === undefined) { + const present = call.input ?? call.result; + issues.push({ + code: "binding_mismatch", + path: `/events/${present?.index ?? 0}/payload/semantic_tool/callId`, + message: `semantic_tool call ${callId} must contain one input and one result envelope`, + }); + continue; + } + for (const field of ["operationId", "idempotencyKey"] as const) { + if ( + canonicalJson(call.input.envelope[field]) !== + canonicalJson(call.result.envelope[field]) + ) { + issues.push({ + code: "binding_mismatch", + path: `/events/${call.result.index}/payload/semantic_tool/${field}`, + message: `semantic_tool result ${field} must match its input envelope`, + }); + } + } + } + + fixture.commands.forEach((command, index) => { + if (command.controllerSeq !== index + 1) { + issues.push({ + code: "binding_mismatch", + path: `/commands/${index}/controllerSeq`, + message: `controllerSeq must be ${index + 1} in a scripted fixture`, + }); + } + }); + + const distinctEvents = [...uniqueEvents.values()]; + const proposedResults = distinctEvents.filter( + (event) => event.eventType === "run.result.proposed", + ); + if (proposedResults.length !== 1) { + issues.push({ + code: "binding_mismatch", + path: "/events", + message: + "scripted fixtures must contain exactly one unique run.result.proposed event", + }); + } else if ( + canonicalJson(proposedResults[0]?.payload) !== canonicalJson(fixture.result) + ) { + issues.push({ + code: "binding_mismatch", + path: "/result", + message: + "fixture result must match the run.result.proposed event payload", + }); + } + + const terminalEvents = distinctEvents.filter( + (event) => event.eventType === "run.terminal", + ); + if (terminalEvents.length !== 1) { + issues.push({ + code: "binding_mismatch", + path: "/events", + message: + "scripted fixtures must contain exactly one unique run.terminal event", + }); + } + return issues; +} + +export function validatePrpFixture(value: unknown): ProtocolValidationResult { + const unsupported = versionIssues(value); + if (unsupported.length > 0) { + return { ok: false, fixture: null, issues: unsupported }; + } + if (!fixtureValidator(value)) { + return { + ok: false, + fixture: null, + issues: (fixtureValidator.errors ?? []).map(ajvIssue), + }; + } + + const bindings = bindingIssues(value); + return bindings.length === 0 + ? { ok: true, fixture: value, issues: [] } + : { ok: false, fixture: null, issues: bindings }; +} + +export function parsePrpFixtureText(text: string): ProtocolValidationResult { + try { + return validatePrpFixture(JSON.parse(text) as unknown); + } catch (error) { + return { + ok: false, + fixture: null, + issues: [ + { + code: "invalid_json", + path: "/", + message: + error instanceof Error + ? error.message + : "fixture is not valid JSON", + }, + ], + }; + } +} + +export type EventValidationResult = + | { ok: true; event: PrpEvent; issues: [] } + | { ok: false; event: null; issues: ProtocolValidationIssue[] }; + +export function validatePrpEvent(value: unknown): EventValidationResult { + const record = asRecord(value); + const schemaVersion = record?.schemaVersion; + if (typeof schemaVersion === "number" && schemaVersion !== 1) { + return { + ok: false, + event: null, + issues: [ + { + code: "unsupported_required_version", + path: "/schemaVersion", + message: `event schemaVersion ${schemaVersion} is unsupported; this implementation requires 1`, + }, + ], + }; + } + if (!eventValidator(value)) { + return { + ok: false, + event: null, + issues: (eventValidator.errors ?? []).map(ajvIssue), + }; + } + return { ok: true, event: value, issues: [] }; +} + +export type StructuredResultValidationResult = + | { ok: true; result: PrpStructuredRunResult; issues: [] } + | { ok: false; result: null; issues: ProtocolValidationIssue[] }; + +/** Validate a semantic completion independently from a complete replay fixture. */ +export function validatePrpStructuredRunResult( + value: unknown, +): StructuredResultValidationResult { + const normalized = normalizeLegacyPrpStructuredRunResult(value); + if (!resultValidator(normalized)) { + return { + ok: false, + result: null, + issues: (resultValidator.errors ?? []).map(ajvIssue), + }; + } + return { ok: true, result: normalized, issues: [] }; +} + +export function negotiateProtocolVersion( + runner: ProtocolVersionRange, + controller: ProtocolVersionRange, +): number | null { + const selected = Math.min(runner.max, controller.max); + return selected >= Math.max(runner.min, controller.min) ? selected : null; +} diff --git a/packages/paperclip-runner/src/protocol/replay-loader.ts b/packages/paperclip-runner/src/protocol/replay-loader.ts new file mode 100644 index 00000000000..4b5994c9f19 --- /dev/null +++ b/packages/paperclip-runner/src/protocol/replay-loader.ts @@ -0,0 +1,32 @@ +import { readFile } from "node:fs/promises"; + +import { + parsePrpFixtureText, + type ProtocolValidationIssue, + type PrpFixture, +} from "./replay-contract.js"; + +export const replayHappyFixtureUrl = new URL( + "../../protocol/fixtures/replay/happy-path.json", + import.meta.url, +); + +export class PrpFixtureValidationError extends Error { + readonly issues: ProtocolValidationIssue[]; + + constructor(issues: ProtocolValidationIssue[]) { + super(issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")); + this.name = "PrpFixtureValidationError"; + this.issues = issues; + } +} + +export async function loadPrpFixture( + url: URL = replayHappyFixtureUrl, +): Promise { + const result = parsePrpFixtureText(await readFile(url, "utf8")); + if (!result.ok) { + throw new PrpFixtureValidationError(result.issues); + } + return result.fixture; +} diff --git a/packages/paperclip-runner/src/protocol/result-normalization.test.ts b/packages/paperclip-runner/src/protocol/result-normalization.test.ts new file mode 100644 index 00000000000..cf68d2977de --- /dev/null +++ b/packages/paperclip-runner/src/protocol/result-normalization.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeLegacyPrpStructuredRunResult, + normalizePrpResultSignals, +} from "./result-normalization.js"; +import { validatePrpStructuredRunResult } from "./replay-contract.js"; + +describe("normalizePrpResultSignals", () => { + it("accepts legacy completion aliases as one canonical PRP result", () => { + const legacy = { + schema: "paperclip.run_result.v1", + reportedWorkDisposition: "completed", + summary: "Implemented and verified the utility.", + completionClaim: { + contractRevision: "1", + objectiveSatisfied: true, + criteria: [ + { + criterionId: "objective", + status: "satisfied", + evidenceRefs: ["file:a"], + }, + ], + remainingWork: [], + }, + evidence: [{ ref: "file:a" }], + verification: [ + { + command: "node --test", + cwd: "fixture", + status: "passed", + result: "4 passed", + }, + ], + }; + + expect(normalizeLegacyPrpStructuredRunResult(legacy)).toMatchObject({ + reportedWorkDisposition: "done", + attentionRequests: [], + artifacts: [], + verification: [ + { commandOrCheck: "node --test", status: "passed", detail: "4 passed" }, + ], + }); + expect(validatePrpStructuredRunResult(legacy)).toMatchObject({ + ok: true, + result: { reportedWorkDisposition: "done" }, + }); + }); + + it("restores the canonical schema discriminator omitted by a provider tool caller", () => { + const withoutSchema = { + reportedWorkDisposition: "done", + summary: "Completed the requested work.", + completionClaim: { + contractRevision: "1", + objectiveSatisfied: true, + criteria: [ + { criterionId: "objective", status: "satisfied", evidenceRefs: [] }, + ], + remainingWork: [], + }, + evidence: [], + verification: [], + }; + expect(validatePrpStructuredRunResult(withoutSchema)).toMatchObject({ + ok: true, + result: { schema: "paperclip.run_result.v1" }, + }); + }); + + it("normalizes unambiguous completion aliases emitted by smaller tool callers", () => { + const toolShaped = { + schema: "paperclip_paperclip_finish", + reportedWorkDisposition: "completed", + summary: "Answered the question.", + completionClaim: { + contractRevision: "1", + objectiveSatisfied: true, + criteria: [ + { criterionId: "objective", status: "passed", evidenceRefs: [] }, + ], + remainingWork: [], + }, + evidence: [], + verification: [], + }; + expect(validatePrpStructuredRunResult(toolShaped)).toMatchObject({ + ok: true, + result: { + schema: "paperclip.run_result.v1", + reportedWorkDisposition: "done", + completionClaim: { + criteria: [{ status: "satisfied" }], + }, + }, + }); + }); + + it("normalizes unambiguous verification aliases from smaller tool callers", () => { + expect( + validatePrpStructuredRunResult({ + reportedWorkDisposition: "completed", + summary: "Answered the question.", + completionClaim: { + contractRevision: "1", + objectiveSatisfied: true, + criteria: [ + { criterionId: "objective", status: "pass", evidenceRefs: [] }, + ], + remainingWork: [], + }, + evidence: [], + verification: [{ commandOrCheck: "model check", status: "success" }], + }), + ).toMatchObject({ + ok: true, + result: { + verification: [{ commandOrCheck: "model check", status: "passed" }], + }, + }); + }); + + it("turns legacy environment_constraint attention into a verification caveat", () => { + const normalized = normalizePrpResultSignals({ + verification: [ + { + commandOrCheck: "Run npm test", + status: "not_run", + }, + ], + attentionRequests: [ + { + kind: "environment_constraint", + summary: "Node and npm are unavailable in this sandbox.", + }, + ], + }); + + expect(normalized.actionableAttentionRequests).toEqual([]); + expect(normalized.verification).toEqual([ + expect.objectContaining({ + commandOrCheck: "Run npm test", + status: "not_run", + reasonCode: "tool_unavailable", + detail: "Node and npm are unavailable in this sandbox.", + }), + ]); + expect(normalized.ignoredAttentionRequests).toEqual([ + expect.objectContaining({ + sourceKind: "environment_constraint", + disposition: "verification_caveat", + reasonCode: "legacy_environment_constraint_normalized", + }), + ]); + + const canonical = normalizeLegacyPrpStructuredRunResult({ + schema: "paperclip.run_result.v1", + reportedWorkDisposition: "done", + summary: "Work is complete; local verification was unavailable.", + completionClaim: { + contractRevision: "1", + objectiveSatisfied: true, + criteria: [ + { criterionId: "objective", status: "satisfied", evidenceRefs: [] }, + ], + remainingWork: [], + }, + evidence: [], + verification: [ + { + command: "npm test", + status: "blocked", + result: "Node is unavailable.", + }, + ], + attentionRequests: [ + { + kind: "environment_constraint", + summary: "Node and npm are unavailable in this sandbox.", + }, + ], + }); + expect(canonical).toMatchObject({ + attentionRequests: [], + verification: [ + { + commandOrCheck: "npm test", + status: "not_run", + reasonCode: "tool_unavailable", + detail: + "Node is unavailable.\nNode and npm are unavailable in this sandbox.", + }, + ], + }); + expect(validatePrpStructuredRunResult(canonical)).toMatchObject({ + ok: true, + }); + }); + + it("normalizes an unavailable command reported as failed into a not-run caveat", () => { + const normalized = normalizePrpResultSignals({ + verification: [ + { + commandOrCheck: "tsc --noEmit", + status: "failed", + reasonCode: "dependency_missing", + detail: + "Repository dependencies are not installed, so the compiler could not start.", + }, + ], + }); + + expect(normalized.verification).toEqual([ + expect.objectContaining({ + commandOrCheck: "tsc --noEmit", + status: "not_run", + reasonCode: "dependency_missing", + }), + ]); + }); + + it("preserves typed actionable requests and legacy review requests", () => { + const normalized = normalizePrpResultSignals({ + attentionRequests: [ + { + kind: "approval", + summary: "Approve publication", + ownerClass: "human", + }, + { kind: "review", summary: "Review the result" }, + ], + }); + + expect(normalized.actionableAttentionRequests).toEqual([ + expect.objectContaining({ + kind: "approval", + ownerClass: "human", + legacy: false, + }), + expect.objectContaining({ + kind: "review", + ownerClass: "human", + legacy: true, + }), + ]); + }); + + it("records unknown and malformed attention as diagnostics instead of throwing", () => { + const normalized = normalizePrpResultSignals({ + attentionRequests: [ + { kind: "future_provider_value", summary: "Unknown request" }, + { kind: "approval" }, + null, + ], + }); + + expect(normalized.actionableAttentionRequests).toEqual([]); + expect(normalized.ignoredAttentionRequests).toEqual([ + expect.objectContaining({ + disposition: "ignored", + reasonCode: "attention_request_kind_unsupported", + }), + expect.objectContaining({ + disposition: "rejected", + reasonCode: "attention_request_malformed", + }), + expect.objectContaining({ + disposition: "rejected", + reasonCode: "attention_request_malformed", + }), + ]); + }); +}); diff --git a/packages/paperclip-runner/src/protocol/result-normalization.ts b/packages/paperclip-runner/src/protocol/result-normalization.ts new file mode 100644 index 00000000000..a172358c7ba --- /dev/null +++ b/packages/paperclip-runner/src/protocol/result-normalization.ts @@ -0,0 +1,410 @@ +import { + PRP_ACTIONABLE_ATTENTION_KINDS, + PRP_ATTENTION_OWNER_CLASSES, + PRP_VERIFICATION_REASON_CODES, +} from "../contracts/completion-result.js"; + +export type PrpVerificationReasonCode = + (typeof PRP_VERIFICATION_REASON_CODES)[number]; +export type PrpActionableAttentionKind = + (typeof PRP_ACTIONABLE_ATTENTION_KINDS)[number]; +export type PrpAttentionOwnerClass = + (typeof PRP_ATTENTION_OWNER_CLASSES)[number]; + +export interface PrpNormalizedVerification { + commandOrCheck: string; + status: "passed" | "failed" | "not_run"; + reasonCode: PrpVerificationReasonCode | null; + detail: string | null; + artifactRef: string | null; + sourceIndex: number; +} + +export interface PrpNormalizedAttentionRequest { + kind: PrpActionableAttentionKind; + summary: string; + ownerClass: PrpAttentionOwnerClass; + targetAgentId: string | null; + sourceIndex: number; + sourceKind: string; + legacy: boolean; +} + +export interface PrpIgnoredAttentionRequest { + sourceIndex: number; + sourceKind: string | null; + summary: string | null; + disposition: "verification_caveat" | "ignored" | "rejected"; + reasonCode: string; +} + +export interface PrpResultSignals { + verification: PrpNormalizedVerification[]; + actionableAttentionRequests: PrpNormalizedAttentionRequest[]; + ignoredAttentionRequests: PrpIgnoredAttentionRequest[]; +} + +const verificationReasonCodes = new Set(PRP_VERIFICATION_REASON_CODES); +const unavailableVerificationReasonCodes = new Set( + PRP_VERIFICATION_REASON_CODES.filter((reasonCode) => reasonCode !== "other"), +); +const attentionKinds = new Set(PRP_ACTIONABLE_ATTENTION_KINDS); +const ownerClasses = new Set(PRP_ATTENTION_OWNER_CLASSES); + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function inferVerificationReason(value: string): PrpVerificationReasonCode { + const lower = value.toLowerCase(); + if (/budget|quota|token limit/.test(lower)) return "budget_exhausted"; + if ( + /credential|api key|secret|login|auth(?:entication)? required/.test(lower) + ) + return "credential_missing"; + if (/permission|denied|forbidden|not allowed/.test(lower)) + return "permission_denied"; + if (/policy|governance|approval policy/.test(lower)) + return "policy_restricted"; + if (/external|service|upstream|network|offline|dns/.test(lower)) + return "external_service_unavailable"; + if (/dependency|package|module|library/.test(lower)) + return "dependency_missing"; + if (/tool|command|binary|node|npm|sandbox/.test(lower)) + return "tool_unavailable"; + if (/environment|runtime|platform/.test(lower)) + return "environment_unavailable"; + return "other"; +} + +function normalizeVerification( + result: Record, +): PrpNormalizedVerification[] { + const entries = Array.isArray(result.verification) ? result.verification : []; + return entries.map((value, sourceIndex) => { + const entry = record(value); + const rawStatus = String(entry.status); + const reportedStatus: PrpNormalizedVerification["status"] = [ + "pass", + "success", + "succeeded", + ].includes(rawStatus) + ? "passed" + : rawStatus === "fail" + ? "failed" + : ["passed", "failed", "not_run"].includes(rawStatus) + ? (rawStatus as PrpNormalizedVerification["status"]) + : "not_run"; + const detail = text(entry.detail) ?? text(entry.result); + const commandOrCheck = + text(entry.commandOrCheck) ?? + text(entry.command) ?? + `verification-${sourceIndex + 1}`; + const reportedReason = text(entry.reasonCode); + const normalizedReportedReason = + reportedReason && verificationReasonCodes.has(reportedReason) + ? (reportedReason as PrpVerificationReasonCode) + : null; + // Provider models sometimes report an unavailable command as `failed` + // because the shell returned non-zero, while also supplying a reason code + // that explicitly says no meaningful verification verdict was possible. + // The reason code is the stronger semantic signal: reserve `failed` for a + // check that actually ran and found the work incorrect. + const status = + reportedStatus === "failed" && + normalizedReportedReason && + unavailableVerificationReasonCodes.has(normalizedReportedReason) + ? "not_run" + : reportedStatus; + const reasonCode = + normalizedReportedReason ?? + (status === "not_run" + ? inferVerificationReason(`${commandOrCheck} ${detail ?? ""}`) + : null); + return { + commandOrCheck, + status, + reasonCode, + detail, + artifactRef: text(entry.artifactRef), + sourceIndex, + }; + }); +} + +/** Normalize accepted PRP v1 aliases before strict canonical validation. */ +export function normalizeLegacyPrpStructuredRunResult(value: unknown): unknown { + const source = record(value); + if (Object.keys(source).length === 0) return value; + const normalized: Record = { ...source }; + // Provider tool-callers do not consistently echo constant discriminator + // fields even when they are present in the advertised JSON schema. The + // transport already selected the semantic-result tool, so adding its + // canonical discriminator here is deterministic rather than inferential. + const reportedSchema = text(normalized.schema); + if ( + reportedSchema === null || + [ + "paperclip_finish", + "paperclip_paperclip_finish", + "paperclip_finish.v1", + ].includes(reportedSchema) + ) { + normalized.schema = "paperclip.run_result.v1"; + } + const signals = normalizePrpResultSignals(source); + if ( + ["complete", "completed"].includes( + String(normalized.reportedWorkDisposition), + ) + ) { + normalized.reportedWorkDisposition = "done"; + } + const completionClaim = record(normalized.completionClaim); + if (Array.isArray(completionClaim.criteria)) { + normalized.completionClaim = { + ...completionClaim, + criteria: completionClaim.criteria.map((value) => { + const criterion = record(value); + const reportedStatus = text(criterion.status); + const status = + reportedStatus && + ["pass", "passed", "complete", "completed"].includes(reportedStatus) + ? "satisfied" + : reportedStatus && + ["fail", "failed", "incomplete"].includes(reportedStatus) + ? "not_satisfied" + : criterion.status; + return { ...criterion, status }; + }), + }; + } + normalized.attentionRequests = signals.actionableAttentionRequests.map( + (entry) => ({ + kind: entry.kind, + summary: entry.summary, + ownerClass: entry.ownerClass, + ...(entry.targetAgentId ? { targetAgentId: entry.targetAgentId } : {}), + }), + ); + normalized.artifacts = Array.isArray(source.artifacts) + ? source.artifacts + : []; + normalized.verification = signals.verification.map((entry) => ({ + commandOrCheck: entry.commandOrCheck, + status: entry.status, + ...(entry.reasonCode ? { reasonCode: entry.reasonCode } : {}), + ...(entry.detail ? { detail: entry.detail } : {}), + ...(entry.artifactRef ? { artifactRef: entry.artifactRef } : {}), + })); + return normalized; +} + +function legacyAttentionRequest( + entry: Record, + sourceIndex: number, + sourceKind: string, + summary: string, +): PrpNormalizedAttentionRequest | null { + const target = record(entry.target); + const targetAgentId = text(entry.targetAgentId) ?? text(target.agentId); + const ownerClass = text(entry.ownerClass) ?? text(target.ownerClass); + const requiredAuthority = text(entry.requiredAuthority); + switch (sourceKind) { + case "credential_grant": + return { + kind: "credential", + summary, + ownerClass: "human", + targetAgentId: null, + sourceIndex, + sourceKind, + legacy: true, + }; + case "governed_approval": + return { + kind: "approval", + summary, + ownerClass: "human", + targetAgentId: null, + sourceIndex, + sourceKind, + legacy: true, + }; + case "subjective_decision": + return { + kind: "human_input", + summary, + ownerClass: "human", + targetAgentId: null, + sourceIndex, + sourceKind, + legacy: true, + }; + case "domain_expertise": + return targetAgentId + ? { + kind: "agent_handoff", + summary, + ownerClass: "agent", + targetAgentId, + sourceIndex, + sourceKind, + legacy: true, + } + : null; + case "external_action": + return { + kind: "external_action", + summary, + ownerClass: + ownerClass === "external_system" || requiredAuthority === "external" + ? "external_system" + : "human", + targetAgentId: null, + sourceIndex, + sourceKind, + legacy: true, + }; + default: + return null; + } +} + +/** + * Normalize model-authored result signals without rewriting the immutable PRP result. + * Unknown legacy attention is diagnostic data, never a finalization exception. + */ +export function normalizePrpResultSignals(value: unknown): PrpResultSignals { + const result = record(value); + const verification = normalizeVerification(result); + const actionableAttentionRequests: PrpNormalizedAttentionRequest[] = []; + const ignoredAttentionRequests: PrpIgnoredAttentionRequest[] = []; + const requests = Array.isArray(result.attentionRequests) + ? result.attentionRequests + : []; + + requests.forEach((value, sourceIndex) => { + const entry = record(value); + const sourceKind = text(entry.kind) ?? text(entry.requestedCapability); + const summary = text(entry.summary); + if (!sourceKind || !summary) { + ignoredAttentionRequests.push({ + sourceIndex, + sourceKind, + summary, + disposition: "rejected", + reasonCode: "attention_request_malformed", + }); + return; + } + + if (sourceKind === "environment_constraint") { + const target = verification.find( + (candidate) => candidate.status === "not_run", + ); + if (target) { + target.detail = target.detail + ? target.detail.includes(summary) + ? target.detail + : `${target.detail}\n${summary}` + : summary; + target.reasonCode = inferVerificationReason( + `${target.commandOrCheck} ${target.detail}`, + ); + } + ignoredAttentionRequests.push({ + sourceIndex, + sourceKind, + summary, + disposition: "verification_caveat", + reasonCode: target + ? "legacy_environment_constraint_normalized" + : "legacy_environment_constraint_unbound", + }); + return; + } + + if (attentionKinds.has(sourceKind) && text(entry.ownerClass)) { + const ownerClass = text(entry.ownerClass); + const targetAgentId = text(entry.targetAgentId); + if ( + !ownerClass || + !ownerClasses.has(ownerClass) || + (ownerClass === "agent" && !targetAgentId) + ) { + ignoredAttentionRequests.push({ + sourceIndex, + sourceKind, + summary, + disposition: "rejected", + reasonCode: "attention_request_owner_invalid", + }); + return; + } + actionableAttentionRequests.push({ + kind: sourceKind as PrpActionableAttentionKind, + summary, + ownerClass: ownerClass as PrpAttentionOwnerClass, + targetAgentId, + sourceIndex, + sourceKind, + legacy: false, + }); + return; + } + + if (sourceKind === "review") { + actionableAttentionRequests.push({ + kind: "review", + summary, + ownerClass: "human", + targetAgentId: null, + sourceIndex, + sourceKind, + legacy: true, + }); + return; + } + + const legacy = legacyAttentionRequest( + entry, + sourceIndex, + sourceKind, + summary, + ); + if (legacy) { + actionableAttentionRequests.push(legacy); + return; + } + + ignoredAttentionRequests.push({ + sourceIndex, + sourceKind, + summary, + disposition: "ignored", + reasonCode: [ + "context_lookup", + "retry", + "duplicate", + "alternate_track", + ].includes(sourceKind) + ? "attention_request_internal_capability_not_provider_actionable" + : "attention_request_kind_unsupported", + }); + }); + + return { + verification, + actionableAttentionRequests, + ignoredAttentionRequests, + }; +} diff --git a/packages/paperclip-runner/src/reducer/session-reducer.test.ts b/packages/paperclip-runner/src/reducer/session-reducer.test.ts new file mode 100644 index 00000000000..ed5c81807f2 --- /dev/null +++ b/packages/paperclip-runner/src/reducer/session-reducer.test.ts @@ -0,0 +1,147 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { + parsePrpFixtureText, + type PrpEvent, + type PrpFixture, +} from "../protocol/replay-contract.js"; +import { + createSessionSnapshot, + MAX_RECORDED_MISSING_SEQUENCES, + reducePrpFixture, + reduceSessionEvents, + type SessionSnapshot, +} from "./session-reducer.js"; + +const fixtureDirectory = new URL( + "../../protocol/fixtures/replay/", + import.meta.url, +); +const fixtureNames = [ + "happy-path", + "failed-run", + "interrupted-run", + "duplicate-event", + "source-gap", + "unknown-optional-fields", +]; + +async function loadFixture(name: string): Promise { + const result = parsePrpFixtureText( + await readFile(new URL(`${name}.json`, fixtureDirectory), "utf8"), + ); + if (!result.ok) { + throw new Error(result.issues.map((issue) => issue.message).join("; ")); + } + return result.fixture; +} + +async function loadGolden(name: string): Promise { + return JSON.parse( + await readFile( + new URL(`golden/${name}.snapshot.json`, fixtureDirectory), + "utf8", + ), + ) as SessionSnapshot; +} + +describe("deterministic PRP session reducer", () => { + for (const fixtureName of fixtureNames) { + it(`matches the ${fixtureName} golden snapshot`, async () => { + expect(reducePrpFixture(await loadFixture(fixtureName))).toEqual( + await loadGolden(fixtureName), + ); + }); + } + + it("is deterministic and idempotent when the same batch is replayed", async () => { + const fixture = await loadFixture("happy-path"); + const first = reducePrpFixture(fixture); + expect(reducePrpFixture(fixture)).toEqual(first); + expect(reduceSessionEvents(first, fixture.events)).toEqual(first); + }); + + it("deduplicates at-least-once delivery before projection effects", async () => { + const snapshot = reducePrpFixture(await loadFixture("duplicate-event")); + expect(snapshot.duplicateEventIds).toEqual(["event_duplicate_03"]); + expect(snapshot.items).toHaveLength(1); + expect(snapshot.timeline).toHaveLength(6); + }); + + it("records a source cursor gap without inventing the missing event", async () => { + const snapshot = reducePrpFixture(await loadFixture("source-gap")); + expect(snapshot.integrity).toBe("gap_detected"); + expect(snapshot.gaps).toEqual([ + { + sourceKey: "runner:runner_replay", + expected: 3, + received: 4, + missingCount: 1, + missing: [3], + truncated: false, + }, + ]); + }); + + it("bounds sequence-gap details for an untrusted large cursor jump", async () => { + const fixture = await loadFixture("source-gap"); + const event: PrpEvent = { + ...fixture.events[0]!, + sourceEventId: "event_large_gap", + sourceSeq: Number.MAX_SAFE_INTEGER, + }; + + const snapshot = reduceSessionEvents(createSessionSnapshot(fixture), [ + event, + ]); + + expect(snapshot.gaps).toHaveLength(1); + expect(snapshot.gaps[0]).toMatchObject({ + expected: 1, + received: Number.MAX_SAFE_INTEGER, + missingCount: Number.MAX_SAFE_INTEGER - 1, + truncated: true, + }); + expect(snapshot.gaps[0]?.missing).toHaveLength( + MAX_RECORDED_MISSING_SEQUENCES, + ); + expect(snapshot.gaps[0]?.missing.at(-1)).toBe( + MAX_RECORDED_MISSING_SEQUENCES, + ); + }); + + it("summarizes runtime request creation and resolution with type and prompt", async () => { + const fixture = await loadFixture("interrupted-run"); + const fixtureRequest = fixture.events.find( + (event) => event.eventType === "runtime_request.created", + ); + if (fixtureRequest === undefined) { + throw new Error("interrupted-run fixture must create a runtime request"); + } + const created: PrpEvent = { + ...fixtureRequest, + sourceEventId: "event_request_summary_01", + sourceSeq: 1, + }; + const resolved: PrpEvent = { + ...fixtureRequest, + sourceEventId: "event_request_summary_02", + sourceSeq: 2, + eventType: "runtime_request.resolved", + payload: { requestId: "request_interrupted_permission" }, + }; + + const snapshot = reduceSessionEvents(createSessionSnapshot(fixture), [ + created, + resolved, + ]); + + expect(snapshot.requests[0]).toMatchObject({ type: "permission" }); + expect(snapshot.timeline.map((entry) => entry.summary)).toEqual([ + "permission: Allow the fake command?", + "Resolved permission: Allow the fake command?", + ]); + }); +}); diff --git a/packages/paperclip-runner/src/reducer/session-reducer.ts b/packages/paperclip-runner/src/reducer/session-reducer.ts new file mode 100644 index 00000000000..88d7c1eb57a --- /dev/null +++ b/packages/paperclip-runner/src/reducer/session-reducer.ts @@ -0,0 +1,440 @@ +import type { + PrpCapabilities, + PrpEvent, + PrpFixture, + PrpIdentity, + PrpStructuredRunResult, + PrpTerminalState, +} from "../protocol/replay-contract.js"; + +export interface SessionTimelineEntry { + position: number; + sourceEventId: string; + sourceSeq: number; + eventType: PrpEvent["eventType"]; + emittedAt: string; + itemId?: string; + summary: string; +} + +export interface SessionItemSnapshot { + itemId: string; + kind: string; + status: "running" | "completed" | "failed"; + text: string; +} + +export interface SessionRequestSnapshot { + requestId: string; + requestKind: string; + type: string; + status: string; + prompt: string; +} + +export interface SequenceGap { + sourceKey: string; + expected: number; + received: number; + missingCount: number; + missing: number[]; + truncated: boolean; +} + +export const MAX_RECORDED_MISSING_SEQUENCES = 256; + +export interface SessionSnapshot { + schema: "paperclip.prp.session-snapshot.v1"; + fixtureName: string; + identity: PrpIdentity; + capabilities: PrpCapabilities; + runPhase: string; + sessionState: "not_started" | "running" | "closed" | "failed"; + turnState: + | "not_started" + | "running" + | "completed" + | "failed" + | "interrupted" + | "cancelled"; + activeTurnId: string | null; + items: SessionItemSnapshot[]; + requests: SessionRequestSnapshot[]; + proposedResult: PrpStructuredRunResult | null; + terminal: PrpTerminalState | null; + timeline: SessionTimelineEntry[]; + sourceCursors: Record; + processedEventIds: string[]; + duplicateEventIds: string[]; + outOfOrderEventIds: string[]; + gaps: SequenceGap[]; + integrity: "complete" | "gap_detected"; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function stringValue(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function sourceKey(event: PrpEvent): string { + return `${event.sourceKind}:${event.sourceInstanceId}`; +} + +function humanizeProtocolValue(value: string): string { + return value.replaceAll("_", " "); +} + +function sentenceCase(value: string): string { + return value.length === 0 + ? value + : `${value[0]?.toUpperCase()}${value.slice(1)}`; +} + +function requestSummary(type: string, prompt: string, action?: string): string { + const detail = prompt.length > 0 ? `${type}: ${prompt}` : `${type} request`; + return action === undefined ? detail : `${action} ${detail}`; +} + +function timelineSummary(snapshot: SessionSnapshot, event: PrpEvent): string { + const payload = record(event.payload); + if (event.eventType === "runtime.phase.changed") { + return `Phase changed to ${humanizeProtocolValue(stringValue(payload.phase, "unknown"))}`; + } + if (event.eventType === "item.started") { + return `Started ${stringValue(payload.kind, "item")}`; + } + if (event.eventType === "item.delta") { + return stringValue(payload.text, "Item updated"); + } + if (event.eventType === "item.completed") { + return `Completed ${stringValue(payload.kind, "item")}`; + } + if (event.eventType === "item.failed") { + return stringValue(payload.message, "Item failed"); + } + if (event.eventType === "run.result.proposed") { + return stringValue(payload.summary, "Structured result proposed"); + } + if (event.eventType === "run.terminal") { + return `Run ${stringValue(payload.runTerminalState, "terminal")}`; + } + if ( + event.eventType === "runner.diagnostic" || + event.eventType === "harness.diagnostic" + ) { + const message = stringValue(payload.message); + if (message.length > 0) { + return message; + } + const code = stringValue(payload.code); + if (code.length > 0) { + return sentenceCase(humanizeProtocolValue(code)); + } + } + if (event.eventType === "runtime_request.created") { + const request = record(payload.request ?? payload); + return requestSummary( + stringValue(request.type, "runtime"), + stringValue(request.prompt), + ); + } + if ( + event.eventType === "runtime_request.resolved" || + event.eventType === "runtime_request.expired" || + event.eventType === "runtime_request.cancelled" + ) { + const requestId = stringValue(payload.requestId); + const request = snapshot.requests.find( + (candidate) => candidate.requestId === requestId, + ); + const action = sentenceCase( + humanizeProtocolValue(event.eventType.split(".").at(-1) ?? "updated"), + ); + return request === undefined + ? `${action} runtime request ${requestId}`.trim() + : requestSummary(request.type, request.prompt, action); + } + return event.eventType.replaceAll(".", " "); +} + +function upsertItem( + items: SessionItemSnapshot[], + event: PrpEvent, + status: SessionItemSnapshot["status"], +): void { + if (event.itemId === undefined) { + return; + } + const payload = record(event.payload); + const existing = items.find((item) => item.itemId === event.itemId); + const text = stringValue(payload.text); + if (existing === undefined) { + items.push({ + itemId: event.itemId, + kind: stringValue(payload.kind, "diagnostic"), + status, + text, + }); + return; + } + existing.kind = stringValue(payload.kind, existing.kind); + existing.status = status; + if (event.eventType === "item.delta") { + existing.text += text; + } else if (text.length > 0) { + existing.text = text; + } +} + +function applyProjection(snapshot: SessionSnapshot, event: PrpEvent): void { + const payload = record(event.payload); + switch (event.eventType) { + case "runtime.phase.changed": + snapshot.runPhase = stringValue(payload.phase, snapshot.runPhase); + break; + case "session.started": + case "session.resumed": + snapshot.sessionState = "running"; + break; + case "session.closed": + snapshot.sessionState = "closed"; + break; + case "session.failed": + snapshot.sessionState = "failed"; + break; + case "turn.started": + snapshot.turnState = "running"; + snapshot.activeTurnId = event.turnId ?? null; + break; + case "turn.completed": + snapshot.turnState = "completed"; + snapshot.activeTurnId = null; + break; + case "turn.failed": + snapshot.turnState = "failed"; + snapshot.activeTurnId = null; + break; + case "turn.interrupted": + snapshot.turnState = "interrupted"; + snapshot.activeTurnId = null; + break; + case "turn.cancelled": + snapshot.turnState = "cancelled"; + snapshot.activeTurnId = null; + break; + case "item.started": + case "item.delta": + upsertItem(snapshot.items, event, "running"); + break; + case "item.completed": + upsertItem(snapshot.items, event, "completed"); + break; + case "item.failed": + upsertItem(snapshot.items, event, "failed"); + break; + case "runtime_request.created": { + const request = record(payload.request ?? payload); + const requestId = stringValue(request.requestId); + if ( + requestId.length > 0 && + !snapshot.requests.some( + (candidate) => candidate.requestId === requestId, + ) + ) { + snapshot.requests.push({ + requestId, + requestKind: stringValue(request.requestKind, "runtime"), + type: stringValue(request.type, "runtime"), + status: stringValue(request.status, "pending"), + prompt: stringValue(request.prompt), + }); + } + break; + } + case "runtime_request.resolved": + case "runtime_request.expired": + case "runtime_request.cancelled": { + const requestId = stringValue(payload.requestId); + const request = snapshot.requests.find( + (candidate) => candidate.requestId === requestId, + ); + if (request !== undefined) { + request.status = event.eventType.split(".").at(-1) ?? request.status; + } + break; + } + case "run.result.proposed": + snapshot.proposedResult = structuredClone( + event.payload as PrpStructuredRunResult, + ); + break; + case "run.terminal": + snapshot.terminal = structuredClone(event.payload as PrpTerminalState); + snapshot.runPhase = "terminal"; + break; + } +} + +function addGap( + snapshot: SessionSnapshot, + event: PrpEvent, + expected: number, +): void { + const key = sourceKey(event); + if ( + snapshot.gaps.some( + (gap) => + gap.sourceKey === key && + gap.expected === expected && + gap.received === event.sourceSeq, + ) + ) { + return; + } + const missingCount = event.sourceSeq - expected; + const recordedCount = Math.min(missingCount, MAX_RECORDED_MISSING_SEQUENCES); + snapshot.gaps.push({ + sourceKey: key, + expected, + received: event.sourceSeq, + missingCount, + missing: Array.from( + { length: recordedCount }, + (_, index) => expected + index, + ), + truncated: recordedCount < missingCount, + }); +} + +export function createSessionSnapshot(fixture: PrpFixture): SessionSnapshot { + return createSessionSnapshotFromMetadata({ + fixtureName: fixture.name, + identity: fixture.identity, + capabilities: fixture.capabilities, + }); +} + +export function createSessionSnapshotFromMetadata(input: { + fixtureName: string; + identity: PrpIdentity; + capabilities: PrpCapabilities; +}): SessionSnapshot { + return { + schema: "paperclip.prp.session-snapshot.v1", + fixtureName: input.fixtureName, + identity: structuredClone(input.identity), + capabilities: structuredClone(input.capabilities), + runPhase: "queued", + sessionState: "not_started", + turnState: "not_started", + activeTurnId: null, + items: [], + requests: [], + proposedResult: null, + terminal: null, + timeline: [], + sourceCursors: {}, + processedEventIds: [], + duplicateEventIds: [], + outOfOrderEventIds: [], + gaps: [], + integrity: "complete", + }; +} + +export function applyPrpEvent( + current: SessionSnapshot, + event: PrpEvent, +): SessionSnapshot { + if (current.processedEventIds.includes(event.sourceEventId)) { + return current; + } + + const snapshot = structuredClone(current); + const key = sourceKey(event); + const cursor = snapshot.sourceCursors[key] ?? 0; + const expected = cursor + 1; + if (event.sourceSeq <= cursor) { + if (!snapshot.outOfOrderEventIds.includes(event.sourceEventId)) { + snapshot.outOfOrderEventIds.push(event.sourceEventId); + } + snapshot.integrity = "gap_detected"; + return snapshot; + } + if (event.sourceSeq > expected) { + addGap(snapshot, event, expected); + } + + snapshot.sourceCursors[key] = event.sourceSeq; + snapshot.processedEventIds.push(event.sourceEventId); + snapshot.timeline.push({ + position: snapshot.timeline.length + 1, + sourceEventId: event.sourceEventId, + sourceSeq: event.sourceSeq, + eventType: event.eventType, + emittedAt: event.emittedAt, + ...(event.itemId === undefined ? {} : { itemId: event.itemId }), + summary: timelineSummary(snapshot, event), + }); + applyProjection(snapshot, event); + snapshot.integrity = + snapshot.gaps.length > 0 || snapshot.outOfOrderEventIds.length > 0 + ? "gap_detected" + : "complete"; + return snapshot; +} + +export function reduceSessionEvents( + current: SessionSnapshot, + events: readonly PrpEvent[], +): SessionSnapshot { + const counts = new Map(); + for (const event of events) { + counts.set(event.sourceEventId, (counts.get(event.sourceEventId) ?? 0) + 1); + } + + let snapshot = structuredClone(current); + for (const [eventId, count] of counts) { + if (count > 1 && !snapshot.duplicateEventIds.includes(eventId)) { + snapshot.duplicateEventIds.push(eventId); + } + } + snapshot.duplicateEventIds.sort(); + for (const event of events) { + snapshot = applyPrpEvent(snapshot, event); + } + return snapshot; +} + +export function reducePrpFixture(fixture: PrpFixture): SessionSnapshot { + return reduceSessionEvents(createSessionSnapshot(fixture), fixture.events); +} + +export interface ReplayParitySummary { + runId: string; + integrity: SessionSnapshot["integrity"]; + timelineCount: number; + duplicateEventIds: string[]; + gaps: SequenceGap[]; + turnTerminalState: string | null; + runTerminalState: string | null; +} + +export function replayParitySummary( + snapshot: SessionSnapshot, +): ReplayParitySummary { + return { + runId: snapshot.identity.runId, + integrity: snapshot.integrity, + timelineCount: snapshot.timeline.length, + duplicateEventIds: [...snapshot.duplicateEventIds], + gaps: structuredClone(snapshot.gaps), + turnTerminalState: snapshot.terminal?.turnTerminalState ?? null, + runTerminalState: snapshot.terminal?.runTerminalState ?? null, + }; +} diff --git a/packages/paperclip-runner/src/semantic-tools/authorization.ts b/packages/paperclip-runner/src/semantic-tools/authorization.ts new file mode 100644 index 00000000000..134076ed2cd --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/authorization.ts @@ -0,0 +1,226 @@ +import type { PaperclipSemanticActionDescriptor } from "../catalog/semantic-action-types.js"; +import type { + PaperclipSemanticAuthorizationDecision, + PaperclipSemanticAuthorizationPhase, + PaperclipSemanticDenialCode, + PaperclipSemanticRunContext, +} from "./types.js"; + +const TERMINAL_TASK_STATES = new Set(["done", "cancelled", "canceled"]); + +export function decidePaperclipSemanticAuthorization( + descriptor: PaperclipSemanticActionDescriptor, + context: PaperclipSemanticRunContext, + phase: PaperclipSemanticAuthorizationPhase, + requestedRunId: string, + input?: unknown, +): PaperclipSemanticAuthorizationDecision { + if (!validAuthorityContext(context, requestedRunId)) { + return { + allowed: false, + phase, + operationId: descriptor.operationId, + code: "authority_context_invalid", + reason: "The run authority context is malformed.", + effectiveClaims: [], + }; + } + const effectiveClaims = intersectClaims( + context.actor.claims, + context.delegatedClaims, + ); + const base = { + phase, + operationId: descriptor.operationId, + effectiveClaims, + } as const; + const deny = ( + code: PaperclipSemanticDenialCode, + reason: string, + ): PaperclipSemanticAuthorizationDecision => ({ + ...base, + allowed: false, + code, + reason, + }); + + if (context.runId !== requestedRunId) { + return deny( + "run_mismatch", + "The authority context belongs to another run.", + ); + } + if ( + context.actor.companyId !== context.companyId || + context.activeTask.companyId !== context.companyId + ) { + return deny( + "company_mismatch", + "The actor and active task must belong to the run company.", + ); + } + if (context.actor.status !== "active") { + return deny("actor_inactive", "The run actor is not active."); + } + if (context.activeTask.assigneeActorId !== context.actor.id) { + return deny( + "task_ownership_denied", + "The run actor no longer owns the active task.", + ); + } + if (context.activeTask.executionRunId !== context.runId) { + return deny( + "task_ownership_denied", + "The active task is no longer bound to this run.", + ); + } + if (!descriptor.allowedModes.includes(context.activeTask.workMode)) { + return deny( + "task_mode_denied", + "The action is unavailable in the active task mode.", + ); + } + if ( + descriptor.effect !== "read" && + TERMINAL_TASK_STATES.has(context.activeTask.status) + ) { + return deny("task_state_denied", "The active task is already terminal."); + } + if ( + context.policy?.deniedOperationIds?.includes(descriptor.operationId) === + true + ) { + return deny("policy_denied", "Run policy denies this action."); + } + if ( + descriptor.allowedRoles !== undefined && + !descriptor.allowedRoles + .map(normalize) + .includes(normalize(context.actor.role)) + ) { + return deny("actor_role_denied", "The actor role cannot use this action."); + } + if ( + descriptor.requiredClaims.some( + (required) => !effectiveClaims.includes(required), + ) + ) { + return deny( + "required_claim_missing", + "The run lacks an explicitly delegated actor claim required by this action.", + ); + } + if ( + phase === "invocation" && + descriptor.operationId === "request_human_input" && + context.policy?.allowedInteractionKinds !== undefined + ) { + const interactionKind = stringProperty(input, "interactionKind"); + if ( + interactionKind !== undefined && + !context.policy.allowedInteractionKinds.includes(interactionKind) + ) { + return deny( + "interaction_kind_denied", + "Run policy denies this interaction kind.", + ); + } + } + + return { + ...base, + allowed: true, + code: "allowed", + reason: "The current run authority allows this action.", + }; +} + +function validAuthorityContext( + context: unknown, + requestedRunId: string, +): context is PaperclipSemanticRunContext { + if ( + !isRecord(context) || + !isRecord(context.actor) || + !isRecord(context.activeTask) || + (context.policy !== undefined && !isRecord(context.policy)) + ) { + return false; + } + const actor = context.actor; + const task = context.activeTask; + const policy = context.policy; + const stableIds = [ + requestedRunId, + context.runId, + context.companyId, + actor.id, + actor.companyId, + task.id, + task.companyId, + ...(task.assigneeActorId === null ? [] : [task.assigneeActorId]), + ...(task.executionRunId === null ? [] : [task.executionRunId]), + ]; + return ( + stableIds.every(isStableId) && + isBoundedString(actor.status) && + isBoundedString(actor.role) && + isBoundedString(task.status) && + isBoundedString(task.workMode) && + validStringList(actor.claims) && + validStringList(context.delegatedClaims) && + (policy?.deniedOperationIds === undefined || + validStringList(policy.deniedOperationIds)) && + (policy?.allowedInteractionKinds === undefined || + validStringList(policy.allowedInteractionKinds)) + ); +} + +function isStableId(value: unknown): value is string { + return ( + typeof value === "string" && + value.length >= 1 && + value.length <= 240 && + /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value) + ); +} + +function validStringList(value: unknown): value is readonly string[] { + return ( + Array.isArray(value) && + value.length <= 1_000 && + value.every(isBoundedString) + ); +} + +function isBoundedString(value: unknown): value is string { + return typeof value === "string" && value.length >= 1 && value.length <= 240; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function intersectClaims( + actorClaims: readonly string[], + delegatedClaims: readonly string[], +): readonly string[] { + const actor = new Set(actorClaims); + return Object.freeze( + [...new Set(delegatedClaims)] + .filter((claim) => actor.has(claim)) + .sort((left, right) => left.localeCompare(right)), + ); +} + +function normalize(value: string): string { + return value.trim().toLowerCase(); +} + +function stringProperty(value: unknown, key: string): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const candidate = (value as Record)[key]; + return typeof candidate === "string" ? candidate : undefined; +} diff --git a/packages/paperclip-runner/src/semantic-tools/discovery.ts b/packages/paperclip-runner/src/semantic-tools/discovery.ts new file mode 100644 index 00000000000..cedc57fc577 --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/discovery.ts @@ -0,0 +1,179 @@ +import { PAPERCLIP_SEMANTIC_ACTION_CATALOG } from "../catalog/semantic-action-catalog.js"; +import type { + PaperclipSemanticActionDescriptor, + PaperclipSemanticActionId, +} from "../catalog/semantic-action-types.js"; +import { decidePaperclipSemanticAuthorization } from "./authorization.js"; +import type { + PaperclipSemanticDiscoveryResult, + PaperclipSemanticRunContext, + PaperclipSemanticToolDefinition, +} from "./types.js"; + +const NAMESPACE: Readonly> = + Object.freeze({ + get_task_context: "active_task", + get_task_history: "active_task", + list_documents: "documents", + read_document: "documents", + list_document_revisions: "documents", + report_progress: "active_task", + answer_status_question: "active_task", + write_document: "documents", + request_human_input: "documents", + register_deliverable: "documents", + finish_task: "active_task", + block_task: "active_task", + request_review: "active_task", + list_agents: "discovery", + get_agent: "discovery", + search_tasks: "discovery", + list_approvals: "governance", + get_approval: "governance", + get_approval_context: "governance", + get_workspace_runtime: "workspace", + control_workspace_service: "workspace", + set_dependencies: "delegation", + create_task: "delegation", + request_approval: "governance", + decide_approval: "governance", + comment_on_approval: "governance", + schedule_wake: "continuation", + }); + +export function paperclipSemanticActionNamespace( + operationId: PaperclipSemanticActionId, +): string { + return NAMESPACE[operationId]; +} + +export function projectPaperclipSemanticTools(input: { + readonly runId: string; + readonly context: PaperclipSemanticRunContext; + readonly boundOperationIds: ReadonlySet; + readonly placement?: PaperclipSemanticActionDescriptor["placement"]; +}): readonly PaperclipSemanticToolDefinition[] { + return deepFreeze(authorizedBoundDescriptors(input).map(toToolDefinition)); +} + +export function discoverPaperclipSemanticTools(input: { + readonly runId: string; + readonly context: PaperclipSemanticRunContext; + readonly boundOperationIds: ReadonlySet; + readonly query: string; + readonly namespace?: string; + readonly limit?: number; +}): PaperclipSemanticDiscoveryResult { + const normalized = input.query.trim().toLowerCase(); + if (normalized.length === 0 || normalized.length > 500) { + throw new Error("semantic_discovery_query_invalid"); + } + const namespace = input.namespace?.trim().toLowerCase(); + if (namespace !== undefined && !/^[a-z][a-z0-9_]{0,63}$/.test(namespace)) { + throw new Error("semantic_discovery_namespace_invalid"); + } + const limit = Math.max(1, Math.min(Math.floor(input.limit ?? 5), 8)); + const tokens = normalized + .split(/[^a-z0-9]+/) + .filter((token) => token.length > 1); + const candidates = authorizedBoundDescriptors({ + ...input, + placement: "optional", + }) + .filter( + (descriptor) => + namespace === undefined || + paperclipSemanticActionNamespace(descriptor.operationId) === namespace, + ) + .map((descriptor) => ({ + descriptor, + score: scoreDescriptor(descriptor, normalized, tokens), + })) + .filter((candidate) => candidate.score > 0) + .sort( + (left, right) => + right.score - left.score || + left.descriptor.operationId.localeCompare(right.descriptor.operationId), + ); + + return deepFreeze({ + schema: "paperclip.semantic-discovery.v1", + query: input.query, + namespace: namespace ?? null, + operations: candidates + .slice(0, limit) + .map(({ descriptor }) => toToolDefinition(descriptor)), + truncated: candidates.length > limit, + }); +} + +export function toPaperclipSemanticToolDefinition( + descriptor: PaperclipSemanticActionDescriptor, +): PaperclipSemanticToolDefinition { + return deepFreeze(toToolDefinition(descriptor)); +} + +function authorizedBoundDescriptors(input: { + readonly runId: string; + readonly context: PaperclipSemanticRunContext; + readonly boundOperationIds: ReadonlySet; + readonly placement?: PaperclipSemanticActionDescriptor["placement"]; +}): PaperclipSemanticActionDescriptor[] { + return PAPERCLIP_SEMANTIC_ACTION_CATALOG.filter( + (descriptor) => + input.boundOperationIds.has(descriptor.operationId) && + (input.placement === undefined || + descriptor.placement === input.placement) && + decidePaperclipSemanticAuthorization( + descriptor, + input.context, + "exposure", + input.runId, + ).allowed, + ); +} + +function scoreDescriptor( + descriptor: PaperclipSemanticActionDescriptor, + query: string, + tokens: readonly string[], +): number { + const operationId = descriptor.operationId.toLowerCase(); + const namespace = paperclipSemanticActionNamespace(descriptor.operationId); + const haystack = + `${operationId} ${descriptor.title} ${descriptor.description} ${namespace}`.toLowerCase(); + let score = operationId === query ? 100 : namespace === query ? 50 : 0; + for (const token of tokens) { + if (operationId.includes(token)) score += 12; + if (namespace.includes(token)) score += 8; + if (haystack.includes(token)) score += 3; + } + return score; +} + +function toToolDefinition( + descriptor: PaperclipSemanticActionDescriptor, +): PaperclipSemanticToolDefinition { + return { + name: descriptor.operationId, + description: descriptor.description, + inputSchema: descriptor.inputSchema, + outputSchema: descriptor.outputSchema, + annotations: { + semanticContract: descriptor.schema, + version: descriptor.version, + placement: descriptor.placement, + effect: descriptor.effect, + requiredClaims: descriptor.requiredClaims, + }, + }; +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) { + return value; + } + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} diff --git a/packages/paperclip-runner/src/semantic-tools/dispatcher.ts b/packages/paperclip-runner/src/semantic-tools/dispatcher.ts new file mode 100644 index 00000000000..a95713ab032 --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/dispatcher.ts @@ -0,0 +1,897 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { ValidateFunction } from "ajv/dist/2020.js"; + +import { + PAPERCLIP_SEMANTIC_ACTION_CATALOG, + paperclipSemanticAction, +} from "../catalog/semantic-action-catalog.js"; +import type { + PaperclipJsonValue, + PaperclipSemanticActionDescriptor, + PaperclipSemanticActionId, +} from "../catalog/semantic-action-types.js"; +import { decidePaperclipSemanticAuthorization } from "./authorization.js"; +import { + discoverPaperclipSemanticTools, + projectPaperclipSemanticTools, +} from "./discovery.js"; +import { + createPaperclipSemanticInputReceipt, + createPaperclipSemanticResultReceipt, + denialRetryable, + digestPaperclipSemanticContent, + isPaperclipSemanticStableId, + normalizePaperclipSemanticReferences, + paperclipSemanticAuthorizationBoundary, + paperclipSemanticOutcome, +} from "./receipts.js"; +import { + inspectPaperclipSemanticValue, + redactPaperclipSemanticValue, +} from "./redaction.js"; +import type { + PaperclipSemanticActionBinding, + PaperclipSemanticAuthorizationDecision, + PaperclipSemanticAuthorizationRecord, + PaperclipSemanticBindingResult, + PaperclipSemanticContextProvider, + PaperclipSemanticDenialCode, + PaperclipSemanticDiscoveryResult, + PaperclipSemanticIdempotencyClaim, + PaperclipSemanticIdempotencyStore, + PaperclipSemanticRunContext, + PaperclipSemanticStoredOutcome, + PaperclipSemanticToolCall, + PaperclipSemanticToolDefinition, + PaperclipSemanticToolDenial, + PaperclipSemanticToolResult, + PaperclipSemanticToolSuccess, +} from "./types.js"; + +export interface PaperclipSemanticDispatcherOptions { + readonly contextProvider: PaperclipSemanticContextProvider; + readonly bindings: readonly PaperclipSemanticActionBinding[]; + readonly idempotencyStore?: PaperclipSemanticIdempotencyStore; + readonly maxAuthorizationRecords?: number; +} + +interface ClaimedMutation { + readonly token: string; + readonly inputDigest: string; + readonly idempotencyKey: string; +} + +export class PaperclipSemanticDispatcher { + readonly #contextProvider: PaperclipSemanticContextProvider; + readonly #bindings = new Map< + PaperclipSemanticActionId, + PaperclipSemanticActionBinding + >(); + readonly #boundOperationIds: ReadonlySet; + readonly #inputValidators = new Map< + PaperclipSemanticActionId, + ValidateFunction + >(); + readonly #outputValidators = new Map< + PaperclipSemanticActionId, + ValidateFunction + >(); + readonly #idempotencyStore: PaperclipSemanticIdempotencyStore | undefined; + readonly #maxAuthorizationRecords: number; + readonly #authorizationRecords: PaperclipSemanticAuthorizationRecord[] = []; + #recordSequence = 0; + + constructor(options: PaperclipSemanticDispatcherOptions) { + this.#contextProvider = options.contextProvider; + this.#idempotencyStore = options.idempotencyStore; + this.#maxAuthorizationRecords = Math.max( + 1, + Math.min(Math.floor(options.maxAuthorizationRecords ?? 1_000), 10_000), + ); + for (const binding of options.bindings) { + if (this.#bindings.has(binding.operationId)) { + throw new Error( + `duplicate semantic action binding: ${binding.operationId}`, + ); + } + if (paperclipSemanticAction(binding.operationId) === undefined) { + throw new Error( + `unknown semantic action binding: ${binding.operationId}`, + ); + } + this.#bindings.set(binding.operationId, binding); + } + this.#boundOperationIds = new Set(this.#bindings.keys()); + + const ajv = new Ajv2020({ + allErrors: true, + allowUnionTypes: true, + strict: true, + }); + for (const descriptor of PAPERCLIP_SEMANTIC_ACTION_CATALOG) { + this.#inputValidators.set( + descriptor.operationId, + ajv.compile(descriptor.inputSchema), + ); + this.#outputValidators.set( + descriptor.operationId, + ajv.compile(descriptor.outputSchema), + ); + } + } + + async listAlwaysAvailableTools( + runId: string, + ): Promise { + const context = await this.#contextProvider(runId); + this.#recordExposureDecisions(runId, context, "always"); + return projectPaperclipSemanticTools({ + runId, + context, + boundOperationIds: this.#boundOperationIds, + placement: "always", + }); + } + + async discoverTools(input: { + readonly runId: string; + readonly query: string; + readonly namespace?: string; + readonly limit?: number; + }): Promise { + const context = await this.#contextProvider(input.runId); + this.#recordExposureDecisions(input.runId, context, "optional"); + return discoverPaperclipSemanticTools({ + ...input, + context, + boundOperationIds: this.#boundOperationIds, + }); + } + + authorizationRecords(): readonly PaperclipSemanticAuthorizationRecord[] { + return deepFreeze(structuredClone(this.#authorizationRecords)); + } + + async dispatch( + call: PaperclipSemanticToolCall, + ): Promise { + if (!validCallIdentity(call)) { + return this.#identityDenial(call); + } + const descriptor = paperclipSemanticAction(call.operationId); + const binding = descriptor && this.#bindings.get(descriptor.operationId); + if (descriptor === undefined || binding === undefined) { + return this.#denial(call, "operation_absent", null, null); + } + + let context: PaperclipSemanticRunContext; + try { + context = await this.#contextProvider(call.runId); + } catch { + return this.#denial(call, "binding_failed", descriptor, null); + } + let decision = decidePaperclipSemanticAuthorization( + descriptor, + context, + "invocation", + call.runId, + call.input, + ); + if (!decision.allowed) { + return this.#denial( + call, + denialCode(decision), + descriptor, + context, + decision, + ); + } + + const inputSafety = inspectPaperclipSemanticValue(call.input); + if (!inputSafety.withinBounds) { + decision = deniedDecision( + decision, + "input_invalid", + "Tool input exceeds safe bounds.", + ); + return this.#denial(call, "input_invalid", descriptor, context, decision); + } + if (inputSafety.containsProtectedData) { + decision = deniedDecision( + decision, + "protected_data_denied", + "Protected data is not accepted by semantic actions.", + ); + return this.#denial( + call, + "protected_data_denied", + descriptor, + context, + decision, + true, + ); + } + const inputValidator = this.#inputValidators.get(descriptor.operationId); + const idempotencyKey = stringProperty(call.input, "idempotencyKey"); + if (descriptor.effect !== "read" && idempotencyKey === undefined) { + decision = deniedDecision( + decision, + "idempotency_required", + "Mutation actions require an idempotency key.", + ); + return this.#denial( + call, + "idempotency_required", + descriptor, + context, + decision, + ); + } + if (inputValidator === undefined || !inputValidator(call.input)) { + decision = deniedDecision( + decision, + "input_invalid", + formatValidationError(inputValidator), + ); + return this.#denial(call, "input_invalid", descriptor, context, decision); + } + + const inputReceipt = createPaperclipSemanticInputReceipt({ + operationId: descriptor.operationId, + callId: call.callId, + correlation: call.correlation, + idempotencyKey: idempotencyKey ?? null, + content: call.input, + }); + const inputDigest = digestPaperclipSemanticContent(call.input); + let claim: ClaimedMutation | undefined; + if (descriptor.effect !== "read") { + if (this.#idempotencyStore === undefined) { + decision = deniedDecision( + decision, + "receipt_store_unavailable", + "No durable idempotency store is configured for mutation actions.", + ); + return this.#denial( + call, + "receipt_store_unavailable", + descriptor, + context, + decision, + ); + } + const scope = digestPaperclipSemanticContent([ + context.companyId, + call.runId, + descriptor.operationId, + idempotencyKey, + ]); + let claimed: PaperclipSemanticIdempotencyClaim; + try { + claimed = await this.#idempotencyStore.claim({ + scope, + operationId: descriptor.operationId, + inputDigest, + }); + } catch { + decision = deniedDecision( + decision, + "receipt_store_unavailable", + "The durable idempotency store is unavailable.", + ); + return this.#denial( + call, + "receipt_store_unavailable", + descriptor, + context, + decision, + ); + } + if (claimed.kind === "conflict") { + decision = deniedDecision( + decision, + "idempotency_conflict", + "The idempotency key was already used with different input.", + ); + return this.#denial( + call, + "idempotency_conflict", + descriptor, + context, + decision, + ); + } + if (claimed.kind === "in_progress") { + decision = deniedDecision( + decision, + "idempotency_in_progress", + "The original mutation is still in progress.", + ); + return this.#denial( + call, + "idempotency_in_progress", + descriptor, + context, + decision, + ); + } + if (claimed.kind === "duplicate") { + return this.#duplicate( + call, + descriptor, + context, + decision, + inputReceipt, + inputDigest, + claimed.outcome, + ); + } + claim = { + token: claimed.token, + inputDigest, + idempotencyKey: idempotencyKey!, + }; + } + + // Re-read authority after any durable claim and immediately before the + // application binding. A stale projection can never authorize execution. + let currentContext: PaperclipSemanticRunContext; + try { + currentContext = await this.#contextProvider(call.runId); + } catch { + if (claim !== undefined && !(await this.#releaseClaim(claim.token))) { + return this.#denial( + call, + "receipt_store_unavailable", + descriptor, + context, + ); + } + return this.#denial(call, "binding_failed", descriptor, context); + } + decision = decidePaperclipSemanticAuthorization( + descriptor, + currentContext, + "invocation", + call.runId, + call.input, + ); + if (!decision.allowed) { + if (claim !== undefined && !(await this.#releaseClaim(claim.token))) { + return this.#denial( + call, + "receipt_store_unavailable", + descriptor, + currentContext, + deniedDecision( + decision, + "receipt_store_unavailable", + "The unused mutation claim could not be released.", + ), + ); + } + return this.#denial( + call, + denialCode(decision), + descriptor, + currentContext, + decision, + ); + } + + let executed: PaperclipSemanticBindingResult; + try { + executed = await binding.execute({ + runId: call.runId, + companyId: currentContext.companyId, + actorId: currentContext.actor.id, + taskId: currentContext.activeTask.id, + callId: call.callId, + operationId: descriptor.operationId, + input: call.input as Readonly>, + }); + } catch { + // A mutation may have crossed the application boundary. Keep its claim + // reserved so an uncertain retry cannot execute it twice. + decision = deniedDecision( + decision, + "binding_failed", + "The action binding failed safely.", + ); + return this.#denial( + call, + "binding_failed", + descriptor, + currentContext, + decision, + ); + } + + if (!isBindingResult(executed)) { + decision = deniedDecision( + decision, + "binding_output_invalid", + "The action binding returned an invalid result.", + ); + return this.#denial( + call, + "binding_output_invalid", + descriptor, + currentContext, + decision, + ); + } + const outputSafety = inspectPaperclipSemanticValue(executed.value); + const safeValue = redactPaperclipSemanticValue(executed.value); + const outputValidator = this.#outputValidators.get(descriptor.operationId); + if ( + !outputSafety.withinBounds || + !validOptionalCode(executed.code) || + !validOptionalRevision(executed.stateRevision) || + !validOptionalStableId(executed.auditReceiptId) || + outputValidator === undefined || + !outputValidator(safeValue) + ) { + decision = deniedDecision( + decision, + "binding_output_invalid", + "The action binding returned an invalid result.", + ); + return this.#denial( + call, + "binding_output_invalid", + descriptor, + currentContext, + decision, + outputSafety.containsProtectedData, + ); + } + + const code = executed.code ?? "ok"; + const references = normalizePaperclipSemanticReferences( + executed.references, + ); + const resultReceipt = createPaperclipSemanticResultReceipt({ + operationId: descriptor.operationId, + callId: call.callId, + correlation: call.correlation, + idempotencyKey: idempotencyKey ?? null, + content: safeValue, + references, + redacted: outputSafety.containsProtectedData, + outcome: paperclipSemanticOutcome({ ok: true, code }), + code, + retryable: false, + authorizationBoundary: paperclipSemanticAuthorizationBoundary(code), + ...(validRevision(executed.stateRevision) + ? { currentRevision: executed.stateRevision } + : {}), + ...(executed.auditReceiptId !== undefined + ? { auditReceiptId: executed.auditReceiptId } + : {}), + }); + const operationReceiptId = String(resultReceipt.operationReceiptId); + + if (claim !== undefined) { + const stored: PaperclipSemanticStoredOutcome = { + operationId: descriptor.operationId, + inputDigest, + operationReceiptId, + value: safeValue, + code, + ...(validRevision(executed.stateRevision) + ? { stateRevision: executed.stateRevision } + : {}), + references, + ...(executed.auditReceiptId !== undefined + ? { auditReceiptId: executed.auditReceiptId } + : {}), + }; + try { + await this.#idempotencyStore!.complete(claim.token, stored); + } catch { + try { + await this.#idempotencyStore!.recover(claim.token, stored); + } catch { + // The application effect may have happened. Keep the claim reserved + // and stop automatic retries. The store's operator recovery path can + // commit the same sanitized outcome without repeating the effect. + decision = deniedDecision( + decision, + "receipt_recovery_failed", + "The mutation receipt needs operator recovery.", + ); + return this.#denial( + call, + "receipt_recovery_failed", + descriptor, + currentContext, + decision, + ); + } + } + } + + this.#record( + currentContext, + decision, + call.callId, + inputDigest, + operationReceiptId, + ); + return deepFreeze({ + ok: true, + operationId: descriptor.operationId, + callId: call.callId, + value: safeValue, + code, + duplicate: false, + ...(validRevision(executed.stateRevision) + ? { stateRevision: executed.stateRevision } + : {}), + inputReceipt, + resultReceipt, + } satisfies PaperclipSemanticToolSuccess); + } + + #recordExposureDecisions( + runId: string, + context: PaperclipSemanticRunContext, + placement: PaperclipSemanticActionDescriptor["placement"], + ): void { + for (const descriptor of PAPERCLIP_SEMANTIC_ACTION_CATALOG) { + if ( + descriptor.placement !== placement || + !this.#boundOperationIds.has(descriptor.operationId) + ) { + continue; + } + const decision = decidePaperclipSemanticAuthorization( + descriptor, + context, + "exposure", + runId, + ); + this.#record(context, decision, null, null, null); + } + } + + #duplicate( + call: PaperclipSemanticToolCall, + descriptor: PaperclipSemanticActionDescriptor, + context: PaperclipSemanticRunContext, + decision: PaperclipSemanticAuthorizationDecision, + inputReceipt: PaperclipSemanticToolSuccess["inputReceipt"], + inputDigest: string, + stored: PaperclipSemanticStoredOutcome, + ): PaperclipSemanticToolResult { + if (!isStoredOutcome(stored)) { + const denied = deniedDecision( + decision, + "binding_output_invalid", + "The stored mutation receipt is invalid.", + ); + return this.#denial( + call, + "binding_output_invalid", + descriptor, + context, + denied, + ); + } + const outputValidator = this.#outputValidators.get(descriptor.operationId); + const outputSafety = inspectPaperclipSemanticValue(stored.value); + const safeValue = redactPaperclipSemanticValue(stored.value); + if ( + stored.operationId !== descriptor.operationId || + stored.inputDigest !== inputDigest || + !isPaperclipSemanticStableId(stored.operationReceiptId) || + !validCode(stored.code) || + !validOptionalRevision(stored.stateRevision) || + !validOptionalStableId(stored.auditReceiptId) || + !outputSafety.withinBounds || + outputValidator === undefined || + !outputValidator(safeValue) + ) { + const denied = deniedDecision( + decision, + "binding_output_invalid", + "The stored mutation receipt is invalid.", + ); + return this.#denial( + call, + "binding_output_invalid", + descriptor, + context, + denied, + ); + } + const references = normalizePaperclipSemanticReferences(stored.references); + const resultReceipt = createPaperclipSemanticResultReceipt({ + operationId: descriptor.operationId, + callId: call.callId, + correlation: call.correlation, + idempotencyKey: stringProperty(call.input, "idempotencyKey") ?? null, + content: safeValue, + references, + redacted: outputSafety.containsProtectedData, + outcome: "duplicate", + code: stored.code, + retryable: false, + authorizationBoundary: paperclipSemanticAuthorizationBoundary( + stored.code, + ), + operationReceiptId: stored.operationReceiptId, + duplicateOfReceiptId: stored.operationReceiptId, + ...(validRevision(stored.stateRevision) + ? { currentRevision: stored.stateRevision } + : {}), + ...(stored.auditReceiptId !== undefined + ? { auditReceiptId: stored.auditReceiptId } + : {}), + }); + this.#record( + context, + decision, + call.callId, + inputDigest, + stored.operationReceiptId, + ); + return deepFreeze({ + ok: true, + operationId: descriptor.operationId, + callId: call.callId, + value: safeValue, + code: stored.code, + duplicate: true, + ...(validRevision(stored.stateRevision) + ? { stateRevision: stored.stateRevision } + : {}), + inputReceipt, + resultReceipt, + } satisfies PaperclipSemanticToolSuccess); + } + + async #releaseClaim(token: string): Promise { + try { + await this.#idempotencyStore!.release(token); + return true; + } catch { + return false; + } + } + + #identityDenial( + call: PaperclipSemanticToolCall, + ): PaperclipSemanticToolDenial { + return deepFreeze({ + ok: false, + operationId: safeIdentity(call.operationId), + callId: safeIdentity(call.callId), + error: { + code: "input_invalid", + message: "The semantic call identity is invalid.", + retryable: false, + }, + inputReceipt: null, + resultReceipt: null, + }); + } + + #denial( + call: PaperclipSemanticToolCall, + code: PaperclipSemanticDenialCode, + descriptor: PaperclipSemanticActionDescriptor | null, + context: PaperclipSemanticRunContext | null, + decision?: PaperclipSemanticAuthorizationDecision, + redacted = false, + ): PaperclipSemanticToolDenial { + const idempotencyKey = stringProperty(call.input, "idempotencyKey") ?? null; + const inputReceipt = createPaperclipSemanticInputReceipt({ + operationId: call.operationId, + callId: call.callId, + correlation: call.correlation, + idempotencyKey, + content: call.input, + redacted, + }); + const resultReceipt = createPaperclipSemanticResultReceipt({ + operationId: call.operationId, + callId: call.callId, + correlation: call.correlation, + idempotencyKey, + content: { code }, + redacted, + outcome: paperclipSemanticOutcome({ ok: false, code }), + code, + retryable: denialRetryable(code), + authorizationBoundary: paperclipSemanticAuthorizationBoundary(code), + }); + if (descriptor !== null && context !== null) { + const finalDecision = + decision ?? + deniedDecision( + decidePaperclipSemanticAuthorization( + descriptor, + context, + "invocation", + call.runId, + call.input, + ), + code, + denialMessage(code), + ); + this.#record( + context, + finalDecision, + call.callId, + digestPaperclipSemanticContent(call.input), + String(resultReceipt.operationReceiptId), + ); + } + return deepFreeze({ + ok: false, + operationId: call.operationId, + callId: call.callId, + error: { + code, + message: denialMessage(code), + retryable: denialRetryable(code), + }, + inputReceipt, + resultReceipt, + }); + } + + #record( + context: PaperclipSemanticRunContext, + decision: PaperclipSemanticAuthorizationDecision, + callId: string | null, + inputDigest: string | null, + operationReceiptId: string | null, + ): void { + if (decision.code === "authority_context_invalid") return; + this.#recordSequence += 1; + this.#authorizationRecords.push( + deepFreeze({ + schema: "paperclip.semantic-authorization-record.v1", + id: `semantic_auth:${String(this.#recordSequence).padStart(8, "0")}`, + runId: context.runId, + companyId: context.companyId, + actorId: context.actor.id, + taskId: context.activeTask.id, + callId, + ...decision, + inputDigest, + operationReceiptId, + }), + ); + if (this.#authorizationRecords.length > this.#maxAuthorizationRecords) { + this.#authorizationRecords.splice( + 0, + this.#authorizationRecords.length - this.#maxAuthorizationRecords, + ); + } + } +} + +function validCallIdentity(call: PaperclipSemanticToolCall): boolean { + return ( + call.correlation.runId === call.runId && + [ + call.runId, + call.callId, + call.operationId, + call.correlation.normalizedSessionId, + call.correlation.turnId, + call.correlation.itemId, + ...(call.correlation.requestId === undefined + ? [] + : [call.correlation.requestId]), + ].every(isPaperclipSemanticStableId) + ); +} + +function isBindingResult( + value: unknown, +): value is PaperclipSemanticBindingResult { + return typeof value === "object" && value !== null && "value" in value; +} + +function isStoredOutcome( + value: unknown, +): value is PaperclipSemanticStoredOutcome { + return ( + typeof value === "object" && + value !== null && + "operationId" in value && + "inputDigest" in value && + "operationReceiptId" in value && + "value" in value && + "code" in value && + Array.isArray((value as { references?: unknown }).references) + ); +} + +function deniedDecision( + decision: PaperclipSemanticAuthorizationDecision, + code: PaperclipSemanticDenialCode, + reason: string, +): PaperclipSemanticAuthorizationDecision { + return { ...decision, allowed: false, code, reason }; +} + +function denialCode( + decision: PaperclipSemanticAuthorizationDecision, +): PaperclipSemanticDenialCode { + if (decision.code === "allowed") { + throw new Error("allowed authorization decision cannot create a denial"); + } + return decision.code; +} + +function denialMessage(code: PaperclipSemanticDenialCode): string { + switch (code) { + case "operation_absent": + return "The requested semantic action is not available."; + case "idempotency_in_progress": + return "The original mutation is still in progress."; + case "binding_failed": + return "The semantic action could not complete safely."; + default: + return "The requested semantic action was not executed."; + } +} + +function formatValidationError( + validator: ValidateFunction | undefined, +): string { + const issue = validator?.errors?.[0]; + if (issue === undefined) + return "Tool input does not match its action schema."; + return `Tool input ${issue.instancePath || "/"} ${issue.message ?? "is invalid"}.`; +} + +function stringProperty(value: unknown, key: string): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const candidate = (value as Record)[key]; + return typeof candidate === "string" ? candidate : undefined; +} + +function validCode(value: unknown): value is string { + return typeof value === "string" && /^[a-z][a-z0-9_.:-]{0,159}$/.test(value); +} + +function validOptionalCode(value: unknown): value is string | undefined { + return value === undefined || validCode(value); +} + +function validRevision(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function validOptionalRevision(value: unknown): value is number | undefined { + return value === undefined || validRevision(value); +} + +function validOptionalStableId(value: unknown): value is string | undefined { + return ( + value === undefined || + (typeof value === "string" && isPaperclipSemanticStableId(value)) + ); +} + +function safeIdentity(value: string): string { + return isPaperclipSemanticStableId(value) ? value : "invalid"; +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) { + return value; + } + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} diff --git a/packages/paperclip-runner/src/semantic-tools/index.ts b/packages/paperclip-runner/src/semantic-tools/index.ts new file mode 100644 index 00000000000..a0d2159788f --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/index.ts @@ -0,0 +1,6 @@ +export * from "./authorization.js"; +export * from "./discovery.js"; +export * from "./dispatcher.js"; +export * from "./receipts.js"; +export * from "./redaction.js"; +export * from "./types.js"; diff --git a/packages/paperclip-runner/src/semantic-tools/receipts.ts b/packages/paperclip-runner/src/semantic-tools/receipts.ts new file mode 100644 index 00000000000..43a72e68d1b --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/receipts.ts @@ -0,0 +1,203 @@ +import { createHash } from "node:crypto"; + +import type { PrpSemanticToolEnvelope } from "../protocol/replay-contract.js"; +import { redactPaperclipSemanticValue } from "./redaction.js"; +import type { + PaperclipSemanticCorrelation, + PaperclipSemanticDenialCode, + PaperclipSemanticSafeReference, +} from "./types.js"; + +export type PaperclipSemanticAuthorizationBoundary = + | "company" + | "actor" + | "active_task" + | "grant" + | "governed_action" + | "lock" + | "revision"; + +export type PaperclipSemanticToolOutcome = + "succeeded" | "denied" | "conflict" | "duplicate" | "unavailable" | "failed"; + +interface SemanticReceiptBase { + readonly operationId: string; + readonly callId: string; + readonly correlation: PaperclipSemanticCorrelation; + readonly idempotencyKey?: string | null; + readonly content: unknown; + readonly references?: readonly PaperclipSemanticSafeReference[]; + readonly redacted?: boolean; +} + +interface SemanticResultReceiptInput extends SemanticReceiptBase { + readonly outcome: PaperclipSemanticToolOutcome; + readonly code: string; + readonly retryable: boolean; + readonly authorizationBoundary: PaperclipSemanticAuthorizationBoundary; + readonly operationReceiptId?: string; + readonly auditReceiptId?: string; + readonly currentRevision?: number | string; + readonly duplicateOfReceiptId?: string; +} + +export function createPaperclipSemanticInputReceipt( + input: SemanticReceiptBase, +): PrpSemanticToolEnvelope { + return { + schema: "paperclip.prp.semantic_tool.v1", + schemaVersion: 1, + phase: "input", + operationId: input.operationId, + callId: input.callId, + correlation: { ...input.correlation }, + idempotencyKey: input.idempotencyKey ?? null, + content: safeContent(input), + } as PrpSemanticToolEnvelope; +} + +export function createPaperclipSemanticResultReceipt( + input: SemanticResultReceiptInput, +): PrpSemanticToolEnvelope { + const operationReceiptId = + input.operationReceiptId ?? derivedOperationReceiptId(input); + return { + schema: "paperclip.prp.semantic_tool.v1", + schemaVersion: 1, + phase: "result", + operationId: input.operationId, + callId: input.callId, + correlation: { ...input.correlation }, + idempotencyKey: input.idempotencyKey ?? null, + content: safeContent(input), + outcome: input.outcome, + code: input.code, + retryable: input.retryable, + authorizationBoundary: input.authorizationBoundary, + operationReceiptId, + ...(input.auditReceiptId === undefined + ? {} + : { auditReceiptId: input.auditReceiptId }), + ...(input.currentRevision === undefined + ? {} + : { currentRevision: input.currentRevision }), + ...(input.duplicateOfReceiptId === undefined + ? {} + : { duplicateOfReceiptId: input.duplicateOfReceiptId }), + } as PrpSemanticToolEnvelope; +} + +export function digestPaperclipSemanticContent(value: unknown): string { + const safeValue = redactPaperclipSemanticValue(value); + return `sha256:${createHash("sha256").update(canonicalJson(safeValue)).digest("hex")}`; +} + +export function paperclipSemanticAuthorizationBoundary( + code: string, +): PaperclipSemanticAuthorizationBoundary { + if (code.includes("company")) return "company"; + if (code.includes("actor") || code.includes("role")) return "actor"; + if (code.includes("claim") || code.includes("absent")) return "grant"; + if ( + code.includes("idempotency") || + code.includes("revision") || + code.includes("receipt") + ) { + return "revision"; + } + if (code.includes("ownership") || code.includes("run_mismatch")) { + return "lock"; + } + if (code.includes("interaction") || code.includes("governance")) { + return "governed_action"; + } + return "active_task"; +} + +export function paperclipSemanticOutcome(input: { + readonly ok: boolean; + readonly code: string; + readonly duplicate?: boolean; +}): PaperclipSemanticToolOutcome { + if (input.ok) return input.duplicate === true ? "duplicate" : "succeeded"; + if (input.code.includes("conflict")) return "conflict"; + if (input.code === "operation_absent") return "unavailable"; + if (input.code.includes("binding") || input.code.includes("recovery")) { + return "failed"; + } + return "denied"; +} + +export function isPaperclipSemanticStableId(value: string): boolean { + return ( + value.length >= 1 && + value.length <= 240 && + /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value) + ); +} + +export function normalizePaperclipSemanticReferences( + references: readonly PaperclipSemanticSafeReference[] | undefined, +): readonly PaperclipSemanticSafeReference[] { + if (!Array.isArray(references)) return Object.freeze([]); + const allowedKinds = new Set([ + "task", + "document_revision", + "interaction", + "approval", + "decision", + "artifact", + "work_product", + "wake", + "monitor", + "audit", + "operation", + ]); + const unique = new Map(); + for (const reference of references.slice(0, 200)) { + if ( + typeof reference === "object" && + reference !== null && + allowedKinds.has(reference.kind) && + isPaperclipSemanticStableId(reference.id) + ) { + unique.set(`${reference.kind}:${reference.id}`, { + kind: reference.kind, + id: reference.id, + }); + } + } + return Object.freeze([...unique.values()]); +} + +function safeContent(input: SemanticReceiptBase) { + return { + digest: digestPaperclipSemanticContent(input.content), + redactionDisposition: input.redacted === true ? "redacted" : "digest_only", + references: [...normalizePaperclipSemanticReferences(input.references)], + }; +} + +function derivedOperationReceiptId(input: SemanticResultReceiptInput): string { + const identity = + input.idempotencyKey === undefined || input.idempotencyKey === null + ? `${input.correlation.runId}:${input.operationId}:${input.callId}` + : `${input.correlation.runId}:${input.operationId}:${input.idempotencyKey}`; + return `semantic_receipt:${createHash("sha256").update(identity).digest("hex").slice(0, 32)}`; +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value === "object" && value !== null) { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +export function denialRetryable(code: PaperclipSemanticDenialCode): boolean { + return code === "idempotency_in_progress" || code === "binding_failed"; +} diff --git a/packages/paperclip-runner/src/semantic-tools/redaction.ts b/packages/paperclip-runner/src/semantic-tools/redaction.ts new file mode 100644 index 00000000000..cc817725826 --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/redaction.ts @@ -0,0 +1,161 @@ +import type { PaperclipJsonValue } from "../catalog/semantic-action-types.js"; + +const SENSITIVE_KEY = + /(?:authorization|cookie|credential|password|passwd|private.?key|secret|token|api.?key|connection.?string)/i; +const SECRET_VALUE = + /(?:\bBearer\s+[A-Za-z0-9._~+/=-]{8,}|\b(?:sk|pk|pcgw|ghp|github_pat)_[A-Za-z0-9_-]{8,}|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/gi; +const SECRET_QUERY = + /([?&](?:code|key|secret|state|token|api[_-]?key|access[_-]?token)=)[^&#\s]+/gi; +const SECRET_QUERY_DETECT = + /[?&](?:code|key|secret|state|token|api[_-]?key|access[_-]?token)=[^&#\s]+/i; + +const MAX_DEPTH = 16; +const MAX_NODES = 10_000; +const MAX_ARRAY_ITEMS = 512; +const MAX_OBJECT_KEYS = 512; +const MAX_STRING_LENGTH = 200_000; + +export const PAPERCLIP_SEMANTIC_REDACTED = "[REDACTED]"; +export const PAPERCLIP_SEMANTIC_TRUNCATED = "[TRUNCATED]"; + +export interface PaperclipSemanticValueSafety { + readonly containsProtectedData: boolean; + readonly withinBounds: boolean; +} + +export function inspectPaperclipSemanticValue( + value: unknown, +): PaperclipSemanticValueSafety { + const state = { nodes: 0, protected: false, withinBounds: true }; + inspect(value, "", 0, state, new Set()); + return Object.freeze({ + containsProtectedData: state.protected, + withinBounds: state.withinBounds, + }); +} + +export function redactPaperclipSemanticValue( + value: unknown, +): PaperclipJsonValue { + const state = { nodes: 0 }; + return redact(value, "", 0, state, new Set()); +} + +function inspect( + value: unknown, + key: string, + depth: number, + state: { nodes: number; protected: boolean; withinBounds: boolean }, + ancestors: Set, +): void { + state.nodes += 1; + if (state.nodes > MAX_NODES || depth > MAX_DEPTH) { + state.withinBounds = false; + return; + } + if (SENSITIVE_KEY.test(key)) state.protected = true; + if (typeof value === "string") { + if (value.length > MAX_STRING_LENGTH) state.withinBounds = false; + SECRET_VALUE.lastIndex = 0; + if (SECRET_VALUE.test(value) || SECRET_QUERY_DETECT.test(value)) { + state.protected = true; + } + SECRET_VALUE.lastIndex = 0; + return; + } + if (Array.isArray(value)) { + if (value.length > MAX_ARRAY_ITEMS) state.withinBounds = false; + if (ancestors.has(value)) { + state.withinBounds = false; + return; + } + ancestors.add(value); + for (const child of value.slice(0, MAX_ARRAY_ITEMS)) { + inspect(child, "", depth + 1, state, ancestors); + } + ancestors.delete(value); + return; + } + if (typeof value === "object" && value !== null) { + if (ancestors.has(value)) { + state.withinBounds = false; + return; + } + const entries = Object.entries(value); + if (entries.length > MAX_OBJECT_KEYS) state.withinBounds = false; + ancestors.add(value); + for (const [childKey, child] of entries.slice(0, MAX_OBJECT_KEYS)) { + inspect(child, childKey, depth + 1, state, ancestors); + } + ancestors.delete(value); + return; + } + if ( + value !== null && + typeof value !== "number" && + typeof value !== "boolean" && + typeof value !== "undefined" + ) { + state.withinBounds = false; + } +} + +function redact( + value: unknown, + key: string, + depth: number, + state: { nodes: number }, + ancestors: Set, +): PaperclipJsonValue { + state.nodes += 1; + if (state.nodes > MAX_NODES || depth > MAX_DEPTH) { + return PAPERCLIP_SEMANTIC_TRUNCATED; + } + if (SENSITIVE_KEY.test(key)) return PAPERCLIP_SEMANTIC_REDACTED; + if (typeof value === "string") { + SECRET_VALUE.lastIndex = 0; + const redacted = value + .replace(SECRET_VALUE, PAPERCLIP_SEMANTIC_REDACTED) + .replace(SECRET_QUERY, `$1${PAPERCLIP_SEMANTIC_REDACTED}`); + SECRET_VALUE.lastIndex = 0; + return redacted.length <= MAX_STRING_LENGTH + ? redacted + : `${redacted.slice(0, MAX_STRING_LENGTH)}${PAPERCLIP_SEMANTIC_TRUNCATED}`; + } + if (Array.isArray(value)) { + if (ancestors.has(value)) return PAPERCLIP_SEMANTIC_TRUNCATED; + ancestors.add(value); + const result = value + .slice(0, MAX_ARRAY_ITEMS) + .map((child) => redact(child, "", depth + 1, state, ancestors)); + ancestors.delete(value); + if (value.length > MAX_ARRAY_ITEMS) { + result.push(PAPERCLIP_SEMANTIC_TRUNCATED); + } + return result; + } + if (typeof value === "object" && value !== null) { + if (ancestors.has(value)) return PAPERCLIP_SEMANTIC_TRUNCATED; + ancestors.add(value); + const entries = Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .slice(0, MAX_OBJECT_KEYS) + .map( + ([childKey, child]) => + [ + childKey, + redact(child, childKey, depth + 1, state, ancestors), + ] as const, + ); + ancestors.delete(value); + const result: Record = + Object.fromEntries(entries); + if (Object.keys(value).length > MAX_OBJECT_KEYS) { + result.__paperclip_truncated__ = PAPERCLIP_SEMANTIC_TRUNCATED; + } + return result; + } + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "boolean" || value === null) return value; + return PAPERCLIP_SEMANTIC_TRUNCATED; +} diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts new file mode 100644 index 00000000000..e8e4b45fa06 --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts @@ -0,0 +1,594 @@ +import { describe, expect, it } from "vitest"; + +import type { PaperclipSemanticActionBinding } from "./types.js"; +import { PaperclipSemanticDispatcher } from "./dispatcher.js"; +import type { + PaperclipSemanticIdempotencyClaim, + PaperclipSemanticIdempotencyStore, + PaperclipSemanticRunContext, + PaperclipSemanticStoredOutcome, + PaperclipSemanticToolCall, +} from "./types.js"; +import { PAPERCLIP_SEMANTIC_REDACTED } from "./redaction.js"; +import { + validatePrpEvent, + type PrpEvent, + type PrpSemanticToolEnvelope, +} from "../protocol/replay-contract.js"; + +const correlation = { + runId: "run_semantic_test", + normalizedSessionId: "session_semantic_test", + turnId: "turn_semantic_test", + itemId: "item_semantic_test", +}; + +describe("run-scoped semantic tool authority", () => { + it("projects only bound and currently authorized actions", async () => { + let context = runContext({ + actorClaims: ["discovery:tasks:read", "discovery:agents:read"], + delegatedClaims: ["discovery:tasks:read"], + }); + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => context, + bindings: [ + binding("get_task_context", { task: { id: "task_semantic_test" } }), + binding("search_tasks", { tasks: [] }), + ], + }); + + await expect( + dispatcher.listAlwaysAvailableTools(correlation.runId), + ).resolves.toMatchObject([{ name: "get_task_context" }]); + await expect( + dispatcher.discoverTools({ runId: correlation.runId, query: "tasks" }), + ).resolves.toMatchObject({ + operations: [{ name: "search_tasks" }], + truncated: false, + }); + + context = runContext({ + actorClaims: ["discovery:tasks:read"], + delegatedClaims: [], + }); + await expect( + dispatcher.discoverTools({ runId: correlation.runId, query: "tasks" }), + ).resolves.toMatchObject({ operations: [] }); + expect(JSON.stringify(dispatcher.authorizationRecords())).not.toContain( + "list_agents", + ); + }); + + it("rechecks ownership after projection and before invocation", async () => { + let context = runContext(); + let executions = 0; + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => context, + bindings: [ + { + operationId: "get_task_context", + execute: () => { + executions += 1; + return { value: { task: "visible" } }; + }, + }, + ], + }); + + expect( + await dispatcher.listAlwaysAvailableTools(correlation.runId), + ).toHaveLength(1); + context = runContext({ executionRunId: "run_replaced" }); + const result = await dispatcher.dispatch(call("get_task_context", {})); + + expect(result).toMatchObject({ + ok: false, + error: { code: "task_ownership_denied" }, + }); + expect(executions).toBe(0); + }); + + it("rejects forged scope and protected input before a binding executes", async () => { + let executions = 0; + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => + runContext({ + actorClaims: ["discovery:tasks:read"], + delegatedClaims: ["discovery:tasks:read"], + }), + bindings: [ + { + operationId: "search_tasks", + execute: () => { + executions += 1; + return { value: { tasks: [] } }; + }, + }, + ], + }); + + await expect( + dispatcher.dispatch(call("search_tasks", { companyId: "forged" })), + ).resolves.toMatchObject({ ok: false, error: { code: "input_invalid" } }); + await expect( + dispatcher.dispatch( + call("search_tasks", { query: "work", apiKey: "sk_not-for-a-tool" }), + ), + ).resolves.toMatchObject({ + ok: false, + error: { code: "protected_data_denied" }, + }); + expect(executions).toBe(0); + }); + + it("redacts binding output and emits schema-valid digest-only receipts", async () => { + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + bindings: [ + binding("get_task_context", { + task: { id: "task_semantic_test" }, + accessToken: "sk_should-never-cross", + }), + ], + }); + const result = await dispatcher.dispatch(call("get_task_context", {})); + + expect(result).toMatchObject({ + ok: true, + value: { accessToken: PAPERCLIP_SEMANTIC_REDACTED }, + duplicate: false, + }); + expect(JSON.stringify(result)).not.toContain("sk_should-never-cross"); + if (!result.ok) throw new Error("expected success"); + expect( + validatePrpEvent(event("mcp_app.tool_input", result.inputReceipt)).ok, + ).toBe(true); + expect( + validatePrpEvent(event("mcp_app.tool_result", result.resultReceipt)).ok, + ).toBe(true); + }); + + it("fails mutations closed when no idempotency store is configured", async () => { + let executions = 0; + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + bindings: [ + { + operationId: "write_document", + execute: () => { + executions += 1; + return { value: mutationReceipt("write-command") }; + }, + }, + ], + }); + + const result = await dispatcher.dispatch( + call("write_document", writeDocumentInput()), + ); + expect(result).toMatchObject({ + ok: false, + error: { code: "receipt_store_unavailable" }, + }); + expect(executions).toBe(0); + }); + + it("replays exact mutation retries and rejects key reuse with changed input", async () => { + let executions = 0; + const store = new MemoryIdempotencyStore(); + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + idempotencyStore: store, + bindings: [ + { + operationId: "write_document", + execute: () => { + executions += 1; + return { + value: mutationReceipt("write-command"), + code: "document_written", + stateRevision: 7, + references: [ + { kind: "document_revision", id: "revision_semantic_test" }, + ], + }; + }, + }, + ], + }); + + const first = await dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_write_first"), + ); + const retry = await dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_write_retry"), + ); + const conflict = await dispatcher.dispatch( + call( + "write_document", + { ...writeDocumentInput(), body: "Different body" }, + "call_write_conflict", + ), + ); + + expect(first).toMatchObject({ + ok: true, + duplicate: false, + stateRevision: 7, + }); + expect(retry).toMatchObject({ + ok: true, + duplicate: true, + stateRevision: 7, + }); + expect(conflict).toMatchObject({ + ok: false, + error: { code: "idempotency_conflict" }, + }); + expect(executions).toBe(1); + if (!first.ok || !retry.ok) throw new Error("expected successes"); + expect(retry.resultReceipt.operationReceiptId).toBe( + first.resultReceipt.operationReceiptId, + ); + expect(retry.resultReceipt.duplicateOfReceiptId).toBe( + first.resultReceipt.operationReceiptId, + ); + }); + + it("recovers a completed mutation when the primary receipt commit fails", async () => { + let executions = 0; + const store = new MemoryIdempotencyStore({ failCompleteOnce: true }); + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + idempotencyStore: store, + bindings: [ + { + operationId: "write_document", + execute: () => { + executions += 1; + return { value: mutationReceipt("write-recovered") }; + }, + }, + ], + }); + + const first = await dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_recovery_first"), + ); + const retry = await dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_recovery_retry"), + ); + + expect(first).toMatchObject({ ok: true, duplicate: false }); + expect(retry).toMatchObject({ ok: true, duplicate: true }); + expect(executions).toBe(1); + expect(store.recoveryCount).toBe(1); + }); + + it("reports an in-progress retry without running a concurrent mutation", async () => { + const store = new MemoryIdempotencyStore(); + let releaseExecution!: () => void; + const blocked = new Promise((resolve) => { + releaseExecution = resolve; + }); + let executions = 0; + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + idempotencyStore: store, + bindings: [ + { + operationId: "write_document", + execute: async () => { + executions += 1; + await blocked; + return { value: mutationReceipt("write-concurrent") }; + }, + }, + ], + }); + + const first = dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_concurrent_first"), + ); + await store.claimed; + const retry = await dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_concurrent_retry"), + ); + expect(retry).toMatchObject({ + ok: false, + error: { code: "idempotency_in_progress", retryable: true }, + }); + expect(executions).toBe(1); + releaseExecution(); + await expect(first).resolves.toMatchObject({ ok: true }); + }); + + it("releases an unused mutation claim when authority changes before execution", async () => { + const store = new MemoryIdempotencyStore(); + let lookups = 0; + let executions = 0; + const dispatcher = new PaperclipSemanticDispatcher({ + contextProvider: () => { + lookups += 1; + return runContext({ + executionRunId: lookups === 2 ? "run_reassigned" : correlation.runId, + }); + }, + idempotencyStore: store, + bindings: [ + { + operationId: "write_document", + execute: () => { + executions += 1; + return { value: mutationReceipt("write-after-recheck") }; + }, + }, + ], + }); + + await expect( + dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_stale_authority"), + ), + ).resolves.toMatchObject({ + ok: false, + error: { code: "task_ownership_denied" }, + }); + await expect( + dispatcher.dispatch( + call("write_document", writeDocumentInput(), "call_fresh_authority"), + ), + ).resolves.toMatchObject({ ok: true, duplicate: false }); + expect(executions).toBe(1); + }); + + it("fails closed when durable storage or binding metadata is invalid", async () => { + let executions = 0; + const unavailableStore = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + idempotencyStore: { + claim: () => { + throw new Error("store unavailable"); + }, + complete: () => undefined, + recover: () => undefined, + release: () => undefined, + }, + bindings: [ + { + operationId: "write_document", + execute: () => { + executions += 1; + return { value: mutationReceipt("must-not-run") }; + }, + }, + ], + }); + await expect( + unavailableStore.dispatch(call("write_document", writeDocumentInput())), + ).resolves.toMatchObject({ + ok: false, + error: { code: "receipt_store_unavailable" }, + }); + + const invalidBinding = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext(), + bindings: [ + { + operationId: "get_task_context", + execute: () => ({ value: {}, code: "invalid code" }) as never, + }, + ], + }); + await expect( + invalidBinding.dispatch(call("get_task_context", {})), + ).resolves.toMatchObject({ + ok: false, + error: { code: "binding_output_invalid" }, + }); + expect(executions).toBe(0); + }); + + it("denies cross-company contexts and malformed protocol identities", async () => { + const crossCompany = new PaperclipSemanticDispatcher({ + contextProvider: () => runContext({ actorCompanyId: "company_other" }), + bindings: [binding("get_task_context", {})], + }); + await expect( + crossCompany.listAlwaysAvailableTools(correlation.runId), + ).resolves.toEqual([]); + await expect( + crossCompany.dispatch(call("get_task_context", {})), + ).resolves.toMatchObject({ + ok: false, + error: { code: "company_mismatch" }, + }); + + const malformed = await crossCompany.dispatch({ + ...call("get_task_context", {}), + callId: "bad call id", + }); + expect(malformed).toMatchObject({ + ok: false, + error: { code: "input_invalid" }, + inputReceipt: null, + resultReceipt: null, + }); + + const malformedAuthority = new PaperclipSemanticDispatcher({ + contextProvider: () => + ({ ...runContext(), companyId: "bad company id" }) as never, + bindings: [binding("get_task_context", {})], + }); + await expect( + malformedAuthority.listAlwaysAvailableTools(correlation.runId), + ).resolves.toEqual([]); + await expect( + malformedAuthority.dispatch(call("get_task_context", {})), + ).resolves.toMatchObject({ + ok: false, + error: { code: "authority_context_invalid" }, + }); + }); +}); + +function runContext( + overrides: { + actorClaims?: readonly string[]; + delegatedClaims?: readonly string[]; + actorCompanyId?: string; + executionRunId?: string; + } = {}, +): PaperclipSemanticRunContext { + return { + runId: correlation.runId, + companyId: "company_semantic_test", + actor: { + id: "actor_semantic_test", + companyId: overrides.actorCompanyId ?? "company_semantic_test", + status: "active", + role: "engineer", + claims: overrides.actorClaims ?? [], + }, + activeTask: { + id: "task_semantic_test", + companyId: "company_semantic_test", + assigneeActorId: "actor_semantic_test", + executionRunId: overrides.executionRunId ?? correlation.runId, + status: "in_progress", + workMode: "standard", + }, + delegatedClaims: overrides.delegatedClaims ?? [], + }; +} + +function binding( + operationId: PaperclipSemanticActionBinding["operationId"], + value: Record, +): PaperclipSemanticActionBinding { + return { + operationId, + execute: () => ({ value: value as never }), + }; +} + +function call( + operationId: string, + input: unknown, + callId = `call_${operationId}`, +): PaperclipSemanticToolCall { + return { runId: correlation.runId, callId, operationId, correlation, input }; +} + +function writeDocumentInput() { + return { + idempotencyKey: "write_semantic_test", + key: "plan", + title: "Plan", + body: "Bounded body", + baseRevisionId: null, + }; +} + +function mutationReceipt(commandId: string) { + return { + commandId, + disposition: "applied", + stateRevision: 7, + entityRefs: ["task_semantic_test"], + scheduledWakeIds: [], + }; +} + +function event( + eventType: "mcp_app.tool_input" | "mcp_app.tool_result", + receipt: PrpSemanticToolEnvelope, +): PrpEvent { + return { + schema: "paperclip.prp.event.v1", + sourceEventId: `${eventType}:semantic_test`, + sourceSeq: eventType === "mcp_app.tool_input" ? 1 : 2, + sourceInstanceId: "runner_semantic_test", + sourceKind: "runner", + runId: correlation.runId, + normalizedSessionId: correlation.normalizedSessionId, + turnId: correlation.turnId, + itemId: correlation.itemId, + eventType, + schemaVersion: 1, + priority: 1, + emittedAt: "2026-08-24T12:00:00.000Z", + payload: { semantic_tool: receipt }, + } as PrpEvent; +} + +class MemoryIdempotencyStore implements PaperclipSemanticIdempotencyStore { + readonly #entries = new Map< + string, + { + digest: string; + token: string; + outcome?: PaperclipSemanticStoredOutcome; + } + >(); + readonly #tokenToScope = new Map(); + #sequence = 0; + #failCompleteOnce: boolean; + recoveryCount = 0; + readonly claimed: Promise; + #resolveClaimed!: () => void; + + constructor(options: { failCompleteOnce?: boolean } = {}) { + this.#failCompleteOnce = options.failCompleteOnce ?? false; + this.claimed = new Promise((resolve) => { + this.#resolveClaimed = resolve; + }); + } + + claim(input: { + scope: string; + operationId: PaperclipSemanticStoredOutcome["operationId"]; + inputDigest: string; + }): PaperclipSemanticIdempotencyClaim { + const existing = this.#entries.get(input.scope); + if (existing !== undefined) { + if (existing.digest !== input.inputDigest) return { kind: "conflict" }; + return existing.outcome === undefined + ? { kind: "in_progress" } + : { kind: "duplicate", outcome: structuredClone(existing.outcome) }; + } + const token = `claim:${++this.#sequence}`; + this.#entries.set(input.scope, { digest: input.inputDigest, token }); + this.#tokenToScope.set(token, input.scope); + this.#resolveClaimed(); + return { kind: "claimed", token }; + } + + complete(token: string, outcome: PaperclipSemanticStoredOutcome): void { + if (this.#failCompleteOnce) { + this.#failCompleteOnce = false; + throw new Error("primary receipt commit failed"); + } + this.#storeOutcome(token, outcome); + } + + recover(token: string, outcome: PaperclipSemanticStoredOutcome): void { + this.recoveryCount += 1; + this.#storeOutcome(token, outcome); + } + + #storeOutcome(token: string, outcome: PaperclipSemanticStoredOutcome): void { + const scope = this.#tokenToScope.get(token); + const entry = scope === undefined ? undefined : this.#entries.get(scope); + if (scope === undefined || entry?.token !== token) { + throw new Error("unknown claim token"); + } + entry.outcome = structuredClone(outcome); + } + + release(token: string): void { + const scope = this.#tokenToScope.get(token); + if (scope !== undefined) this.#entries.delete(scope); + this.#tokenToScope.delete(token); + } +} diff --git a/packages/paperclip-runner/src/semantic-tools/types.ts b/packages/paperclip-runner/src/semantic-tools/types.ts new file mode 100644 index 00000000000..ab39ca07251 --- /dev/null +++ b/packages/paperclip-runner/src/semantic-tools/types.ts @@ -0,0 +1,239 @@ +import type { + PaperclipJsonSchema, + PaperclipJsonValue, + PaperclipSemanticActionDescriptor, + PaperclipSemanticActionEffect, + PaperclipSemanticActionId, + PaperclipSemanticActionMode, +} from "../catalog/semantic-action-types.js"; +import type { PrpSemanticToolEnvelope } from "../protocol/replay-contract.js"; + +export interface PaperclipSemanticRunContext { + readonly runId: string; + readonly companyId: string; + readonly actor: { + readonly id: string; + readonly companyId: string; + readonly status: string; + readonly role: string; + readonly claims: readonly string[]; + }; + readonly activeTask: { + readonly id: string; + readonly companyId: string; + readonly assigneeActorId: string | null; + readonly executionRunId: string | null; + readonly status: string; + readonly workMode: PaperclipSemanticActionMode; + }; + /** Claims explicitly delegated to this run. Actor claims can only narrow them. */ + readonly delegatedClaims: readonly string[]; + readonly policy?: { + readonly deniedOperationIds?: readonly PaperclipSemanticActionId[]; + readonly allowedInteractionKinds?: readonly string[]; + }; +} + +export type PaperclipSemanticContextProvider = ( + runId: string, +) => PaperclipSemanticRunContext | Promise; + +export interface PaperclipSemanticToolDefinition { + readonly name: PaperclipSemanticActionId; + readonly description: string; + readonly inputSchema: PaperclipJsonSchema; + readonly outputSchema: PaperclipJsonSchema; + readonly annotations: { + readonly semanticContract: "paperclip.semantic-action.v1"; + readonly version: 1; + readonly placement: PaperclipSemanticActionDescriptor["placement"]; + readonly effect: PaperclipSemanticActionEffect; + readonly requiredClaims: readonly string[]; + }; +} + +export interface PaperclipSemanticDiscoveryResult { + readonly schema: "paperclip.semantic-discovery.v1"; + readonly query: string; + readonly namespace: string | null; + readonly operations: readonly PaperclipSemanticToolDefinition[]; + readonly truncated: boolean; +} + +export type PaperclipSemanticAuthorizationPhase = "exposure" | "invocation"; + +export type PaperclipSemanticDenialCode = + | "operation_absent" + | "authority_context_invalid" + | "run_mismatch" + | "company_mismatch" + | "actor_inactive" + | "task_mode_denied" + | "task_state_denied" + | "task_ownership_denied" + | "required_claim_missing" + | "actor_role_denied" + | "policy_denied" + | "interaction_kind_denied" + | "protected_data_denied" + | "input_invalid" + | "idempotency_required" + | "idempotency_conflict" + | "idempotency_in_progress" + | "receipt_store_unavailable" + | "receipt_recovery_failed" + | "binding_failed" + | "binding_output_invalid"; + +export interface PaperclipSemanticAuthorizationDecision { + readonly allowed: boolean; + readonly phase: PaperclipSemanticAuthorizationPhase; + readonly operationId: PaperclipSemanticActionId; + readonly code: "allowed" | PaperclipSemanticDenialCode; + readonly reason: string; + readonly effectiveClaims: readonly string[]; +} + +export interface PaperclipSemanticAuthorizationRecord extends PaperclipSemanticAuthorizationDecision { + readonly schema: "paperclip.semantic-authorization-record.v1"; + readonly id: string; + readonly runId: string; + readonly companyId: string; + readonly actorId: string; + readonly taskId: string; + readonly callId: string | null; + readonly inputDigest: string | null; + readonly operationReceiptId: string | null; +} + +export interface PaperclipSemanticSafeReference { + readonly kind: + | "task" + | "document_revision" + | "interaction" + | "approval" + | "decision" + | "artifact" + | "work_product" + | "wake" + | "monitor" + | "audit" + | "operation"; + readonly id: string; +} + +export interface PaperclipSemanticBindingResult { + readonly value: PaperclipJsonValue; + readonly code?: string; + readonly stateRevision?: number; + readonly references?: readonly PaperclipSemanticSafeReference[]; + readonly auditReceiptId?: string; +} + +export interface PaperclipAuthorizedSemanticInvocation { + readonly runId: string; + readonly companyId: string; + readonly actorId: string; + readonly taskId: string; + readonly callId: string; + readonly operationId: PaperclipSemanticActionId; + readonly input: Readonly>; +} + +export interface PaperclipSemanticActionBinding { + readonly operationId: PaperclipSemanticActionId; + execute( + invocation: PaperclipAuthorizedSemanticInvocation, + ): PaperclipSemanticBindingResult | Promise; +} + +export interface PaperclipSemanticCorrelation { + readonly runId: string; + readonly normalizedSessionId: string; + readonly turnId: string; + readonly itemId: string; + readonly requestId?: string; +} + +export interface PaperclipSemanticToolCall { + readonly runId: string; + readonly callId: string; + readonly operationId: string; + readonly correlation: PaperclipSemanticCorrelation; + readonly input: unknown; +} + +export interface PaperclipSemanticStoredOutcome { + readonly operationId: PaperclipSemanticActionId; + readonly inputDigest: string; + readonly operationReceiptId: string; + readonly value: PaperclipJsonValue; + readonly code: string; + readonly stateRevision?: number; + readonly references: readonly PaperclipSemanticSafeReference[]; + readonly auditReceiptId?: string; +} + +export type PaperclipSemanticIdempotencyClaim = + | { readonly kind: "claimed"; readonly token: string } + | { + readonly kind: "duplicate"; + readonly outcome: PaperclipSemanticStoredOutcome; + } + | { readonly kind: "conflict" } + | { readonly kind: "in_progress" }; + +/** + * The claim operation must be atomic. Production bindings must persist this + * store before they expose mutation actions. `complete` is the primary commit + * path. `recover` is a required, idempotent fallback that must durably resolve + * a claim to the same outcome when the primary commit reports an ambiguous or + * transient failure. A store without an independent recovery path cannot be + * used to expose mutation actions. + */ +export interface PaperclipSemanticIdempotencyStore { + claim(input: { + readonly scope: string; + readonly operationId: PaperclipSemanticActionId; + readonly inputDigest: string; + }): + | PaperclipSemanticIdempotencyClaim + | Promise; + complete( + token: string, + outcome: PaperclipSemanticStoredOutcome, + ): void | Promise; + recover( + token: string, + outcome: PaperclipSemanticStoredOutcome, + ): void | Promise; + release(token: string): void | Promise; +} + +export interface PaperclipSemanticToolSuccess { + readonly ok: true; + readonly operationId: PaperclipSemanticActionId; + readonly callId: string; + readonly value: PaperclipJsonValue; + readonly code: string; + readonly duplicate: boolean; + readonly stateRevision?: number; + readonly inputReceipt: PrpSemanticToolEnvelope; + readonly resultReceipt: PrpSemanticToolEnvelope; +} + +export interface PaperclipSemanticToolDenial { + readonly ok: false; + readonly operationId: string; + readonly callId: string; + readonly error: { + readonly code: PaperclipSemanticDenialCode; + readonly message: string; + readonly retryable: boolean; + }; + readonly inputReceipt: PrpSemanticToolEnvelope | null; + readonly resultReceipt: PrpSemanticToolEnvelope | null; +} + +export type PaperclipSemanticToolResult = + PaperclipSemanticToolSuccess | PaperclipSemanticToolDenial; diff --git a/packages/paperclip-runner/src/testing.ts b/packages/paperclip-runner/src/testing.ts new file mode 100644 index 00000000000..68ab11bfac9 --- /dev/null +++ b/packages/paperclip-runner/src/testing.ts @@ -0,0 +1,10 @@ +/** + * Public test-only surface for deterministic fixtures and conformance kits. + * + * Production consumers import the package root. Tests import this explicit + * subpath so Node-only fixture loading and comparison helpers cannot become an + * accidental production dependency. + */ +export * from "./index.js"; +export * from "./conformance/semantic-conformance.js"; +export * from "./protocol/replay-loader.js"; diff --git a/packages/paperclip-runner/src/tracer/replay.test.ts b/packages/paperclip-runner/src/tracer/replay.test.ts new file mode 100644 index 00000000000..0917e8a4719 --- /dev/null +++ b/packages/paperclip-runner/src/tracer/replay.test.ts @@ -0,0 +1,26 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { formatReplayResult, replayFixtureText } from "./replay.js"; + +describe("Replay tracer", () => { + it("formats the validated reducer snapshot for CLI consumers", async () => { + const source = await readFile( + new URL( + "../../protocol/fixtures/replay/happy-path.json", + import.meta.url, + ), + "utf8", + ); + const result = replayFixtureText(source); + expect(result.ok).toBe(true); + expect(JSON.parse(formatReplayResult(result))).toMatchObject({ + ok: true, + snapshot: { + integrity: "complete", + terminal: { runTerminalState: "succeeded" }, + }, + }); + }); +}); diff --git a/packages/paperclip-runner/src/tracer/replay.ts b/packages/paperclip-runner/src/tracer/replay.ts new file mode 100644 index 00000000000..5a16ced0240 --- /dev/null +++ b/packages/paperclip-runner/src/tracer/replay.ts @@ -0,0 +1,28 @@ +import { + parsePrpFixtureText, + type ProtocolValidationIssue, +} from "../protocol/replay-contract.js"; +import { + reducePrpFixture, + type SessionSnapshot, +} from "../reducer/session-reducer.js"; + +export type ReplayResult = + | { ok: true; snapshot: SessionSnapshot; issues: [] } + | { ok: false; snapshot: null; issues: ProtocolValidationIssue[] }; + +export function replayFixtureText(text: string): ReplayResult { + const validation = parsePrpFixtureText(text); + if (!validation.ok) { + return { ok: false, snapshot: null, issues: validation.issues }; + } + return { + ok: true, + snapshot: reducePrpFixture(validation.fixture), + issues: [], + }; +} + +export function formatReplayResult(result: ReplayResult): string { + return JSON.stringify(result, null, 2); +} diff --git a/packages/paperclip-runner/test/protocol-contract.test.mjs b/packages/paperclip-runner/test/protocol-contract.test.mjs new file mode 100644 index 00000000000..523b79f5acc --- /dev/null +++ b/packages/paperclip-runner/test/protocol-contract.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { buildProtocolManifest } from "../scripts/generate-protocol-manifest.mjs"; +import { + assertCodexQuestionFixture, + assertConformanceFixturePair, + assertReplayFixtureCompatibility, + assertSchemaInstance, + compileProtocolValidators, + loadSchemaCatalog, + readJson, +} from "../scripts/protocol-contract.mjs"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const protocolRoot = resolve(packageRoot, "protocol"); + +async function fixture(relativePath) { + return (await readJson(resolve(protocolRoot, "fixtures", relativePath))).value; +} + +test("all schema IDs are unique and all external references resolve", async () => { + const schemas = await loadSchemaCatalog(resolve(protocolRoot, "schemas")); + assert.equal(schemas.length, 20); + assert.doesNotThrow(() => compileProtocolValidators(schemas)); +}); + +test("the generated manifest matches all checked-in schemas and fixtures", async () => { + const expected = `${JSON.stringify(await buildProtocolManifest(), null, 2)}\n`; + const actual = await readFile(resolve(protocolRoot, "manifest.json"), "utf8"); + assert.equal(actual, expected); +}); + +test("canonical replay fixtures use supported required versions", async () => { + for (const name of [ + "duplicate-event.json", + "failed-run.json", + "happy-path.json", + "interrupted-run.json", + "source-gap.json", + ]) { + assert.equal(assertReplayFixtureCompatibility(await fixture(`replay/${name}`)).protocolVersion, 1); + } +}); + +test("unknown additive fields remain compatible with PRP v1", async () => { + const value = await fixture("replay/unknown-optional-fields.json"); + assert.equal(value.futureFixtureHint.producerVersion, "1.1-preview"); + assert.doesNotThrow(() => assertReplayFixtureCompatibility(value)); +}); + +test("unknown required versions and schemas fail closed", async () => { + const unsupported = await fixture("replay/unsupported-required-version.json"); + assert.throws( + () => assertReplayFixtureCompatibility(unsupported), + /unsupported_required_version: protocolVersion=2; supported=1/, + ); + + const eventVersion = structuredClone(await fixture("replay/happy-path.json")); + eventVersion.events[0].schemaVersion = 2; + assert.throws(() => assertReplayFixtureCompatibility(eventVersion), /unsupported_required_version/); + + const commandSchema = structuredClone(await fixture("replay/happy-path.json")); + commandSchema.commands[0].schema = "paperclip.prp.command.v2"; + assert.throws(() => assertReplayFixtureCompatibility(commandSchema), /unsupported_required_schema/); +}); + +test("accepted fixtures satisfy the complete JSON Schemas", async () => { + const schemas = await loadSchemaCatalog(resolve(protocolRoot, "schemas")); + const validators = compileProtocolValidators(schemas); + const happyPath = await fixture("replay/happy-path.json"); + assert.doesNotThrow(() => assertSchemaInstance(validators.fixture, happyPath, "happy-path")); + + const missingRequiredField = structuredClone(happyPath); + delete missingRequiredField.commands[0].commandId; + assert.throws( + () => assertSchemaInstance(validators.fixture, missingRequiredField, "missing-command-id"), + /schema_validation_failed: missing-command-id must be accepted: \/commands\/0 must have required property 'commandId'/, + ); + + const unsupported = await fixture("replay/unsupported-required-version.json"); + assert.doesNotThrow(() => assertSchemaInstance(validators.fixture, unsupported, "required-v2", false)); +}); + +test("the Codex question fixture uses stable provider-neutral IDs", async () => { + const value = await fixture("questions/codex.json"); + assert.doesNotThrow(() => assertCodexQuestionFixture(value)); + assert.deepEqual(Object.keys(value.canonicalResponse.answers), ["environment"]); +}); + +test("the cross-language conformance input and output have one stable identity", async () => { + const input = await fixture("conformance-minimal-run.json"); + const output = await fixture("conformance-expected-output.json"); + const schemas = await loadSchemaCatalog(resolve(protocolRoot, "schemas")); + const validators = compileProtocolValidators(schemas); + assert.doesNotThrow(() => assertSchemaInstance(validators.conformanceFixture, input, "conformance-input")); + assert.doesNotThrow(() => assertSchemaInstance(validators.conformanceOutput, output, "conformance-output")); + assert.doesNotThrow(() => assertConformanceFixturePair(input, output)); + assert.equal(input.result.summary, output.result.summary); + + const missingSessionId = structuredClone(output); + delete missingSessionId.runIdentity.sessionId; + assert.throws( + () => assertSchemaInstance(validators.conformanceOutput, missingSessionId, "missing-session-id"), + /schema_validation_failed: missing-session-id must be accepted: \/runIdentity must have required property 'sessionId'/, + ); +}); diff --git a/packages/paperclip-runner/tsconfig.json b/packages/paperclip-runner/tsconfig.json new file mode 100644 index 00000000000..fd2a166a3cc --- /dev/null +++ b/packages/paperclip-runner/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023"], + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/paperclip-runner/vitest.config.ts b/packages/paperclip-runner/vitest.config.ts new file mode 100644 index 00000000000..c1433e6ef38 --- /dev/null +++ b/packages/paperclip-runner/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/package.json b/packages/plugins/examples/plugin-authoring-smoke-example/package.json index 0813d363cf1..7c6a5702d28 100644 --- a/packages/plugins/examples/plugin-authoring-smoke-example/package.json +++ b/packages/plugins/examples/plugin-authoring-smoke-example/package.json @@ -33,7 +33,7 @@ "@rollup/plugin-typescript": "^12.1.2", "@types/node": "^24.0.0", "@types/react": "^19.2.18", - "esbuild": "^0.28.1", + "esbuild": "^0.28.2", "rollup": "^4.62.4", "tslib": "^2.8.1", "typescript": "^5.7.3", diff --git a/packages/plugins/examples/plugin-file-browser-example/package.json b/packages/plugins/examples/plugin-file-browser-example/package.json index 82da74ca01f..9b404cedc6a 100644 --- a/packages/plugins/examples/plugin-file-browser-example/package.json +++ b/packages/plugins/examples/plugin-file-browser-example/package.json @@ -22,7 +22,7 @@ "@codemirror/lang-javascript": "^6.2.5", "@codemirror/language": "^6.12.4", "@codemirror/state": "^6.7.1", - "@codemirror/view": "^6.43.8", + "@codemirror/view": "^6.43.9", "@lezer/highlight": "^1.2.1", "@paperclipai/plugin-sdk": "workspace:*", "codemirror": "^6.0.1" @@ -31,7 +31,7 @@ "@types/node": "^24.0.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "esbuild": "^0.28.1", + "esbuild": "^0.28.2", "react": "^19.2.8", "react-dom": "^19.2.8", "typescript": "^5.7.3" diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/package.json b/packages/plugins/examples/plugin-kitchen-sink-example/package.json index da4c65acc61..2b405c060ea 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/package.json +++ b/packages/plugins/examples/plugin-kitchen-sink-example/package.json @@ -23,7 +23,7 @@ "@paperclipai/shared": "workspace:*" }, "devDependencies": { - "esbuild": "^0.28.1", + "esbuild": "^0.28.2", "@types/node": "^24.0.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", diff --git a/packages/plugins/examples/plugin-orchestration-smoke-example/package.json b/packages/plugins/examples/plugin-orchestration-smoke-example/package.json index b8f161725c6..79e5635ecbc 100644 --- a/packages/plugins/examples/plugin-orchestration-smoke-example/package.json +++ b/packages/plugins/examples/plugin-orchestration-smoke-example/package.json @@ -34,7 +34,7 @@ "@rollup/plugin-typescript": "^12.1.2", "@types/node": "^24.0.0", "@types/react": "^19.2.18", - "esbuild": "^0.28.1", + "esbuild": "^0.28.2", "rollup": "^4.62.4", "tslib": "^2.8.1", "typescript": "^5.7.3", diff --git a/packages/plugins/plugin-llm-wiki/package.json b/packages/plugins/plugin-llm-wiki/package.json index 1ab7c00999a..147f3a66ba8 100644 --- a/packages/plugins/plugin-llm-wiki/package.json +++ b/packages/plugins/plugin-llm-wiki/package.json @@ -42,7 +42,7 @@ "@types/node": "^24.0.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "esbuild": "^0.28.1", + "esbuild": "^0.28.2", "react-dom": "^19.2.8", "rollup": "^4.62.4", "tslib": "^2.8.1", diff --git a/packages/plugins/plugin-workspace-diff/package.json b/packages/plugins/plugin-workspace-diff/package.json index 2631eba12ac..107353beb45 100644 --- a/packages/plugins/plugin-workspace-diff/package.json +++ b/packages/plugins/plugin-workspace-diff/package.json @@ -52,13 +52,13 @@ }, "dependencies": { "@paperclipai/plugin-sdk": "workspace:*", - "@pierre/diffs": "^1.2.11" + "@pierre/diffs": "^1.3.5" }, "devDependencies": { "@types/node": "^24.0.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "esbuild": "^0.28.1", + "esbuild": "^0.28.2", "react": "^19.2.8", "react-dom": "^19.2.8", "typescript": "^5.7.3", diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx index 203d6db0af8..f05cc909393 100644 --- a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx +++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx @@ -255,6 +255,9 @@ function WorkspacePatchDiff({ const { ref } = useFileDiffInstance({ fileDiff, options, + // This tab only shows a diff; it does not open an edit session. + edit: false, + editorOptions: undefined, metrics: undefined, lineAnnotations: undefined, selectedLines: undefined, diff --git a/packages/plugins/sandbox-providers/daytona/package.json b/packages/plugins/sandbox-providers/daytona/package.json index ddc5acd934f..666300e93cc 100644 --- a/packages/plugins/sandbox-providers/daytona/package.json +++ b/packages/plugins/sandbox-providers/daytona/package.json @@ -42,7 +42,7 @@ ], "scripts": { "prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps", - "build": "rm -rf dist && tsc", + "build": "rm -rf dist && tsc && mkdir -p dist/scripts && cp -R src/scripts/. dist/scripts/", "clean": "rm -rf dist", "typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit", "test": "vitest run --config vitest.config.ts", diff --git a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.live.test.ts b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.live.test.ts index c3a3308fea1..cd7be3098d7 100644 --- a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.live.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.live.test.ts @@ -24,6 +24,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { randomUUID } from "node:crypto"; +import http2 from "node:http2"; +import { Duplex } from "node:stream"; import { openDaytonaDuplexChannelSession, type DaytonaPtyProcess, @@ -117,7 +119,7 @@ describeLive("Daytona duplex channel (live)", () => { // The channel child still answers. A write returns as program output. const ping = `chan-${randomUUID()}`; - session.write(`${ping}\n`); + session.write(Buffer.from(`${ping}\n`)); await waitFor(readOutput, (text) => text.includes(ping), 30_000, "channel echo"); } finally { await session.close(); @@ -139,7 +141,7 @@ describeLive("Daytona duplex channel (live)", () => { const baseline = readOutput().length; const line = `PING-${randomUUID()}`; - session.write(`${line}\n`); + session.write(Buffer.from(`${line}\n`)); await waitFor( readOutput, (text) => text.slice(baseline).includes(line), @@ -214,7 +216,7 @@ describeLive("Daytona duplex channel (live)", () => { const line = `RTT-${index}-${randomUUID()}`; const baseline = readOutput().length; const start = Date.now(); - session.write(`${line}\n`); + session.write(Buffer.from(`${line}\n`)); await waitFor( readOutput, (text) => text.slice(baseline).includes(line), @@ -262,7 +264,7 @@ describeLive("Daytona duplex channel (live)", () => { // before its echo returns. The abrupt close models a lost provider channel. await new Promise((resolve) => setTimeout(resolve, 4_000)); const inFlight = `INFLIGHT-${randomUUID()}`; - session.write(`${inFlight}\n`); + session.write(Buffer.from(`${inFlight}\n`)); // Close at once, without waiting for the echo. The pending round trip never // settles through the stream; the channel tears down instead. await session.close(); @@ -288,4 +290,124 @@ describeLive("Daytona duplex channel (live)", () => { }, LIVE_TIMEOUT_MS, ); + + it( + "test_live_daytona_run_uses_http2_v1_end_to_end", + async () => { + // Phase 4 selects http2_v1 by running one Node HTTP/2 session directly + // on this same pseudo-terminal channel, right after the READY line. + // This package ships standalone (see the file header), so it cannot + // import the host readiness gate or the preface scan from + // `@paperclipai/adapter-utils`; this test reimplements the minimal, + // self-contained version of both, using only `node:http2`, so the + // proof runs against a real Daytona PTY end to end. + const live = sandbox!; + const nonce = randomUUID(); + + // The 24-octet HTTP/2 client connection preface (RFC 9113, Section 3.4). + const CLIENT_PREFACE = Buffer.from("505249202a20485454502f322e300d0a0d0a534d0d0a0d0a", "hex"); + + // The sandbox-side child: send the READY line, then hand stdin/stdout to + // a real HTTP/2 client session and dispatch one request — the same + // shape `runHttp2Gateway()` in `sandbox-callback-bridge.ts` runs. + const nodeScript = [ + `process.stdout.write(JSON.stringify({version:2,type:"ready",nonce:${JSON.stringify(nonce)}})+"\\n");`, + `const http2=require("node:http2");`, + `const {Duplex}=require("node:stream");`, + `const stdio=new Duplex({read(){},write(chunk,enc,cb){const ok=process.stdout.write(chunk);if(ok)cb();else process.stdout.once("drain",cb);}});`, + `process.stdin.on("data",c=>stdio.push(c));`, + `process.stdin.on("end",()=>stdio.push(null));`, + `const session=http2.connect("http://bridge.internal",{createConnection:()=>stdio});`, + `session.on("error",()=>process.exit(1));`, + `const stream=session.request({":method":"GET",":path":"/ping"});`, + `let body="";`, + `stream.on("data",c=>{body+=c;});`, + `stream.on("error",()=>process.exit(3));`, + `stream.on("end",()=>{session.close(()=>process.exit(body==="pong"?0:2));});`, + `stream.end();`, + ].join(""); + + const session = await openDaytonaDuplexChannelSession(live.process, ["node", "-e", nodeScript]); + try { + // Reads bytes from the channel until it finds one complete newline- + // terminated READY line, then hands every later byte — including any + // already-buffered suffix of the same chunk — to `onAfterReady`. + const readyAndAfter = await new Promise<{ nonce: string; afterReady: Buffer }>((resolve, reject) => { + let buffer = Buffer.alloc(0); + const timer = setTimeout( + () => reject(new Error(`Timed out waiting for the READY line. Bytes so far: ${buffer.length}`)), + 30_000, + ); + session.onData((chunk) => { + buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + const newlineIndex = buffer.indexOf(0x0a); + if (newlineIndex === -1) return; + clearTimeout(timer); + const line = buffer.subarray(0, newlineIndex).toString("utf8"); + const decoded = JSON.parse(line) as { type?: string; nonce?: string }; + resolve({ nonce: decoded.nonce ?? "", afterReady: Buffer.from(buffer.subarray(newlineIndex + 1)) }); + }); + }); + expect(readyAndAfter.nonce).toBe(nonce); + + // Scan the retained suffix for the client preface, the same rule + // `createHttp2PrefaceScanningChannel` in `execution-target.ts` applies: + // the scan window opens only on bytes after the accepted READY line. + let sawPreface = false; + let downstream: ((chunk: Buffer) => void) | null = null; + let pendingAfterPreface = Buffer.alloc(0); + let scanBuffer = readyAndAfter.afterReady; + function deliver(chunk: Buffer): void { + if (downstream) downstream(chunk); + else pendingAfterPreface = Buffer.concat([pendingAfterPreface, chunk]); + } + function handleChunk(chunk: Buffer): void { + if (sawPreface) { + deliver(chunk); + return; + } + scanBuffer = Buffer.concat([scanBuffer, chunk]); + const offset = scanBuffer.indexOf(CLIENT_PREFACE); + if (offset === -1) return; + sawPreface = true; + const fromPreface = Buffer.from(scanBuffer.subarray(offset)); + scanBuffer = Buffer.alloc(0); + deliver(fromPreface); + } + // The already-retained suffix might already hold the preface. + handleChunk(Buffer.alloc(0)); + session.onData((chunk) => handleChunk(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + + // Wrap the channel as a Node `Duplex` starting at the preface offset, + // and bind one plaintext HTTP/2 server session on it. + const boundDuplex: Duplex = new Duplex({ + read() {}, + write(chunk: unknown, _encoding, callback) { + session.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBufferLike)); + callback(); + }, + }); + downstream = (chunk) => boundDuplex.push(chunk); + if (pendingAfterPreface.length > 0) { + boundDuplex.push(pendingAfterPreface); + pendingAfterPreface = Buffer.alloc(0); + } + + const server = http2.createServer(); + server.on("stream", (stream) => { + stream.respond({ ":status": 200 }); + stream.end("pong"); + }); + server.emit("connection", boundDuplex); + + const exit = await session.wait(); + expect(exit.exitCode).toBe(0); + // eslint-disable-next-line no-console + console.log("[duplex-live] http2_v1: READY, then the real client preface, then one full HTTP/2 round trip, all over one live Daytona PTY."); + } finally { + await session.close(); + } + }, + LIVE_TIMEOUT_MS, + ); }); diff --git a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.test.ts b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.test.ts index 19339783f52..de12f7a6016 100644 --- a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.test.ts @@ -270,10 +270,10 @@ describe("openDaytonaDuplexChannelSession", () => { const received: string[] = []; const session = await openDaytonaDuplexChannelSession(process, GATEWAY); - session.onData((chunk) => received.push(chunk)); + session.onData((chunk) => received.push(new TextDecoder().decode(chunk))); // The launch wrapper is the first input; the host frame write is the second. - session.write('{"version":1,"type":"heartbeat"}\n'); + session.write(new TextEncoder().encode('{"version":1,"type":"heartbeat"}\n')); // The chunker awaits each send, so let the write settle before the assertion. await new Promise((resolve) => setImmediate(resolve)); expect(process.handle?.inputs[1]).toBe('{"version":1,"type":"heartbeat"}\n'); @@ -287,7 +287,7 @@ describe("openDaytonaDuplexChannelSession", () => { const received: string[] = []; const session = await openDaytonaDuplexChannelSession(process, GATEWAY); - session.onData((chunk) => received.push(chunk)); + session.onData((chunk) => received.push(new TextDecoder().decode(chunk))); process.handle?.emitText('{"version":1,"type":"ready","address":"127.0.0.1:8080"}\n'); process.handle?.emitText('{"version":1,"type":"heartbeat"}\n'); @@ -305,7 +305,7 @@ describe("openDaytonaDuplexChannelSession", () => { const received: string[] = []; const session = await openDaytonaDuplexChannelSession(process, GATEWAY); - session.onData((chunk) => received.push(chunk)); + session.onData((chunk) => received.push(new TextDecoder().decode(chunk))); const frame = '{"version":1,"type":"response","id":"r1","status":200,"headers":{},"body":"","outcome":"completed"}\n'; process.handle?.emitText(frame.slice(0, 20)); @@ -319,7 +319,7 @@ describe("openDaytonaDuplexChannelSession", () => { const received: string[] = []; const session = await openDaytonaDuplexChannelSession(process, GATEWAY); - session.onData((chunk) => received.push(chunk)); + session.onData((chunk) => received.push(new TextDecoder().decode(chunk))); const body = "x".repeat(500_000); const frame = `{"version":1,"type":"response","id":"big","status":200,"headers":{},"body":"${body}","outcome":"completed"}\n`; @@ -329,20 +329,41 @@ describe("openDaytonaDuplexChannelSession", () => { expect(received.join("").length).toBe(frame.length); }); - it("keeps a multibyte character whole across two output chunks", async () => { + it("test_stream_forwards_bytes_without_utf8_conversion", async () => { const process = createFakeProcess(); - const received: string[] = []; + const received: Uint8Array[] = []; const session = await openDaytonaDuplexChannelSession(process, GATEWAY); session.onData((chunk) => received.push(chunk)); - // The euro sign is three UTF-8 bytes. Split it across two chunks, so the - // stream decoder must join the bytes. + // The euro sign is three UTF-8 bytes. Split it across two chunks. The + // session no longer runs a UTF-8 stream decoder (see `duplex-command-stream.ts`), + // so it forwards each raw byte chunk unchanged; it does not wait for a whole + // character. The two chunks concatenate back to the exact original bytes — + // proof that no layer here converts the data to a string. const euro = new TextEncoder().encode("€"); process.handle?.emitBytes(euro.subarray(0, 2)); process.handle?.emitBytes(euro.subarray(2)); - expect(received.join("")).toBe("€"); + expect(received).toHaveLength(2); + expect(received[0]).toEqual(euro.subarray(0, 2)); + expect(received[1]).toEqual(euro.subarray(2)); + const joined = Buffer.concat(received); + expect(joined).toEqual(Buffer.from(euro)); + expect(new TextDecoder().decode(joined)).toBe("€"); + }); + + it("sends the full 256-value byte corpus through the channel unchanged", async () => { + const process = createFakeProcess(); + const received: Uint8Array[] = []; + + const session = await openDaytonaDuplexChannelSession(process, GATEWAY); + session.onData((chunk) => received.push(chunk)); + + const allByteValues = Uint8Array.from({ length: 256 }, (_, value) => value); + process.handle?.emitBytes(allByteValues); + + expect(Buffer.concat(received)).toEqual(Buffer.from(allByteValues)); }); it("buffers early output until the listener registers", async () => { @@ -355,7 +376,7 @@ describe("openDaytonaDuplexChannelSession", () => { process.handle?.emitText('"type":"heartbeat"}\n'); expect(received).toEqual([]); - session.onData((chunk) => received.push(chunk)); + session.onData((chunk) => received.push(new TextDecoder().decode(chunk))); // The session flushes the buffered output in order on registration. expect(received).toEqual(['{"version":1,"type":"heartbeat"}\n']); }); @@ -393,7 +414,7 @@ describe("openDaytonaDuplexChannelSession", () => { // raised chunk size above this size makes the write one send and fails this // test. The payload is ASCII, so the fake's per-send decode keeps each byte. const payload = "x".repeat(150_416); - session.write(payload); + session.write(new TextEncoder().encode(payload)); // The chunker awaits each send, so let the write settle before the assertion. await new Promise((resolve) => setImmediate(resolve)); @@ -414,7 +435,7 @@ describe("openDaytonaDuplexChannelSession", () => { if (!handle) throw new Error("The fake process opened no handle."); const before = handle.inputs.length; - session.write('{"version":1,"type":"heartbeat"}\n'); + session.write(new TextEncoder().encode('{"version":1,"type":"heartbeat"}\n')); // The chunker awaits each send, so let the write settle before the assertion. await new Promise((resolve) => setImmediate(resolve)); @@ -449,8 +470,8 @@ describe("openDaytonaDuplexChannelSession", () => { const first = "a".repeat(150_416); const second = "b".repeat(150_416); - session.write(first); - session.write(second); + session.write(new TextEncoder().encode(first)); + session.write(new TextEncoder().encode(second)); // Let both writes settle. await new Promise((resolve) => setImmediate(resolve)); @@ -514,7 +535,7 @@ describe("openDaytonaDuplexChannelSession", () => { onWriteError: (reason) => writeErrors.push(reason), }); - session.write("frame\n"); + session.write(new TextEncoder().encode("frame\n")); // Let the rejected write settle. await new Promise((resolve) => setImmediate(resolve)); @@ -553,8 +574,8 @@ describe("openDaytonaDuplexChannelSession", () => { onWriteError: (reason) => writeErrors.push(reason), }); - session.write("a"); - session.write("b"); + session.write(new TextEncoder().encode("a")); + session.write(new TextEncoder().encode("b")); await new Promise((resolve) => setImmediate(resolve)); expect(writeErrors).toEqual(["write_error"]); @@ -595,8 +616,8 @@ describe("openDaytonaDuplexChannelSession", () => { onWriteError: (reason) => writeErrors.push(reason), }); - session.write("first\n"); - session.write("second\n"); + session.write(new TextEncoder().encode("first\n")); + session.write(new TextEncoder().encode("second\n")); // Let the write chain settle both queued writes. await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); diff --git a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.ts b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.ts index 4507ba774e8..e96d5a9ef58 100644 --- a/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.ts +++ b/packages/plugins/sandbox-providers/daytona/src/duplex-command-stream.ts @@ -27,7 +27,7 @@ // Dependency boundary: this provider plugin ships standalone (the workspace // excludes `packages/plugins/sandbox-providers/**`). So the module imports no // workspace package. It reuses the narrow Daytona PTY surface that -// `setup-token-pty.ts` declares (`DaytonaPtyProcess`, `DaytonaPtyHandle`, +// `login-pty.ts` declares (`DaytonaPtyProcess`, `DaytonaPtyHandle`, // `DaytonaPtyCreateOptions`), so a caller passes `sandbox.process` with no // adapter. The narrow surface keeps the module unit-testable with a fake PTY. @@ -37,13 +37,13 @@ import type { DaytonaPtyCreateOptions, DaytonaPtyHandle, DaytonaPtyProcess, -} from "./setup-token-pty.js"; +} from "./login-pty.js"; export type { DaytonaPtyCreateOptions, DaytonaPtyHandle, DaytonaPtyProcess, -} from "./setup-token-pty.js"; +} from "./login-pty.js"; import { sendPtyInputInChunks } from "./pty-chunked-input.js"; @@ -54,10 +54,10 @@ import { sendPtyInputInChunks } from "./pty-chunked-input.js"; * worker forwards the data and the exit with no adapter. */ export interface DuplexChannelSession { - /** Registers the one data listener. The session streams each raw chunk in order. */ - onData(listener: (chunk: string) => void): void; + /** Registers the one data listener. The session streams each raw byte chunk in order. */ + onData(listener: (chunk: Uint8Array) => void): void; /** Writes raw input bytes to the pseudo-terminal. */ - write(data: string): void; + write(data: Uint8Array): void; /** * Resolves when the command ends or the transport closes. A numeric `exitCode` * is a real process exit. `transportClosed` is true when the pseudo-terminal @@ -169,18 +169,16 @@ export function buildDuplexChannelLaunchWrapper( * {@link DuplexChannelSession}. The session allocates a real pseudo-terminal in * raw mode, streams the raw output, accepts host input, and stops the child. * - * The function decodes the terminal bytes as a UTF-8 stream, so a multibyte - * character that splits across two output chunks stays whole. It buffers the - * output until the transport registers the listener, so no early chunk is lost. + * The function forwards the terminal bytes unchanged. It buffers the output + * until the transport registers the listener, so no early chunk is lost. */ export async function openDaytonaDuplexChannelSession( process: DaytonaPtyProcess, command: readonly string[], options?: DaytonaDuplexChannelOptions, ): Promise { - const decoder = new TextDecoder("utf-8"); - let listener: ((chunk: string) => void) | null = null; - let buffered = ""; + let listener: ((chunk: Uint8Array) => void) | null = null; + let buffered: Buffer = Buffer.alloc(0); const diagnosticsPath = options?.diagnosticsPath ?? `/tmp/paperclip-duplex-${randomUUID()}.log`; @@ -191,13 +189,12 @@ export async function openDaytonaDuplexChannelSession( cols: DUPLEX_CHANNEL_PTY_COLS, rows: DUPLEX_CHANNEL_PTY_ROWS, onData: (data: Uint8Array): void => { - // Decode the terminal bytes as a stream, so a split multibyte character - // stays whole across two chunks. Forward the raw text; the frame codec owns - // the newline-delimited JSON parsing. - const text = decoder.decode(data, { stream: true }); - if (text.length === 0) return; - if (listener) listener(text); - else buffered += text; + // Forward the raw bytes unchanged; the frame codec owns the + // newline-delimited JSON parsing and any multi-byte character + // reassembly on the read side. + if (data.byteLength === 0) return; + if (listener) listener(data); + else buffered = buffered.length === 0 ? Buffer.from(data) : Buffer.concat([buffered, data]); }, }); @@ -233,15 +230,15 @@ export async function openDaytonaDuplexChannelSession( let writeChain: Promise = Promise.resolve(); return { - onData(next: (chunk: string) => void): void { + onData(next: (chunk: Uint8Array) => void): void { listener = next; if (buffered.length > 0) { const pending = buffered; - buffered = ""; + buffered = Buffer.alloc(0); next(pending); } }, - write(data: string): void { + write(data: Uint8Array): void { // Send the input as byte-bounded chunks under the provider message cap. A // whole payload in one message can cross the cap and take the channel down, // so the chunker slices the payload and sends each chunk in order. The chain diff --git a/packages/plugins/sandbox-providers/daytona/src/login-pty.test.ts b/packages/plugins/sandbox-providers/daytona/src/login-pty.test.ts new file mode 100644 index 00000000000..398106dae33 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/login-pty.test.ts @@ -0,0 +1,645 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + composeLaunchLine, + createDaytonaLoginHomeFs, + createDaytonaLoginPtySessionOpener, + encodePosixShellArg, + openDaytonaLoginPtySession, + type DaytonaExecResult, + type DaytonaLoginHomeFs, + type DaytonaPtyCreateOptions, + type DaytonaPtyHandle, + type DaytonaPtyProcess, + type DaytonaSandboxExec, + type LoginHomeInspection, + type LoginPtyLaunchDescriptor, +} from "./login-pty.js"; + +// The Enter byte the terminal login UI reads to submit the browser code. +const ENTER = "\r"; + +const HOME = "/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555"; +const LOGIN_UID = 1000; + +const CLAUDE: LoginPtyLaunchDescriptor = { loginCommandKey: "claude", sessionHome: HOME }; +const CODEX: LoginPtyLaunchDescriptor = { loginCommandKey: "codex", sessionHome: HOME }; + +/** A directory entry in the virtual filesystem. */ +type FakeEntry = Omit; + +const ABSENT: LoginHomeInspection = { + exists: false, + isSymlink: false, + isDirectory: false, + mode: "", + ownerUid: null, +}; + +/** + * A fake login-home filesystem. It models one virtual path. `createDirectory` + * fails when the path exists and otherwise stores `createAs` (a clean 0700 + * directory owned by the login user by default). A test seeds a pre-existing + * entry, forces a bad created entry, or swaps the entry through `onCreatePty`, so + * a test drives each rejection and the pre-launch re-check. + */ +function createFakeHomeFs(config?: { + loginUid?: number; + seed?: FakeEntry; + createAs?: FakeEntry; + createShouldFail?: boolean; +}): DaytonaLoginHomeFs & { + created: Array<{ path: string; mode: string }>; + inspectCount: number; + setEntry: (entry: FakeEntry | null) => void; +} { + const loginUid = config?.loginUid ?? LOGIN_UID; + const cleanDir: FakeEntry = { + isSymlink: false, + isDirectory: true, + mode: "700", + ownerUid: loginUid, + }; + let entry: FakeEntry | null = config?.seed ?? null; + const created: Array<{ path: string; mode: string }> = []; + const state = { inspectCount: 0 }; + return { + created, + get inspectCount() { + return state.inspectCount; + }, + setEntry(next: FakeEntry | null): void { + entry = next; + }, + async loginUserId(): Promise { + return loginUid; + }, + async inspect(): Promise { + state.inspectCount += 1; + return entry ? { exists: true, ...entry } : ABSENT; + }, + async createDirectory(path: string, mode: string): Promise { + if (config?.createShouldFail || entry) { + throw new Error("LOGIN_PTY_HOME_REJECTED"); + } + created.push({ path, mode }); + entry = config?.createAs ?? cleanDir; + }, + }; +} + +/** + * A fake Daytona PTY handle. It records each input write, drives the output + * stream on demand, and records the kill and the disconnect. The tests use it in + * place of the real SDK `PtyHandle`, so the session runs with no sandbox. + */ +function createFakePtyHandle( + onData: (data: Uint8Array) => void | Promise, + onWaitForConnection?: () => void, +): DaytonaPtyHandle & { + inputs: string[]; + emitText: (text: string) => void; + emitBytes: (bytes: Uint8Array) => void; + finish: (exitCode: number | undefined) => void; + killed: number; + disconnected: number; +} { + const encoder = new TextEncoder(); + const inputs: string[] = []; + let resolveWait: ((value: { exitCode?: number; error?: string }) => void) | null = null; + const waitPromise = new Promise<{ exitCode?: number; error?: string }>((resolve) => { + resolveWait = resolve; + }); + let killed = 0; + let disconnected = 0; + return { + async waitForConnection(): Promise { + onWaitForConnection?.(); + }, + async sendInput(data: string | Uint8Array): Promise { + inputs.push(typeof data === "string" ? data : new TextDecoder().decode(data)); + }, + wait(): Promise<{ exitCode?: number; error?: string }> { + return waitPromise; + }, + async kill(): Promise { + killed += 1; + }, + async disconnect(): Promise { + disconnected += 1; + }, + emitText(text: string): void { + onData(encoder.encode(text)); + }, + emitBytes(bytes: Uint8Array): void { + onData(bytes); + }, + finish(exitCode: number | undefined): void { + resolveWait?.({ exitCode }); + }, + get inputs(): string[] { + return inputs; + }, + get killed(): number { + return killed; + }, + get disconnected(): number { + return disconnected; + }, + }; +} + +/** + * A fake Daytona process. It opens one fake PTY handle and records the create + * options, so a test asserts the terminal size and the launch line. `onCreatePty` + * runs when the session opens the terminal, so a test swaps a symlink in after + * the directory creation but before the launch input. + */ +function createFakeProcess(onCreatePty?: () => void): DaytonaPtyProcess & { + handle: ReturnType | null; + createOptions: DaytonaPtyCreateOptions | null; + createCount: number; +} { + const state: { + handle: ReturnType | null; + createOptions: DaytonaPtyCreateOptions | null; + createCount: number; + } = { handle: null, createOptions: null, createCount: 0 }; + return { + get handle() { + return state.handle; + }, + get createOptions() { + return state.createOptions; + }, + get createCount() { + return state.createCount; + }, + async createPty(options: DaytonaPtyCreateOptions): Promise { + state.createCount += 1; + state.createOptions = options; + onCreatePty?.(); + const handle = createFakePtyHandle(options.onData); + state.handle = handle; + return handle; + }, + }; +} + +describe("encodePosixShellArg", () => { + it("wraps a value in single quotes and neutralizes metacharacters", () => { + expect(encodePosixShellArg("/tmp/x")).toBe("'/tmp/x'"); + // A metacharacter, a space, and a semicolon stay literal inside the quotes. + expect(encodePosixShellArg("; rm -rf / #")).toBe("'; rm -rf / #'"); + // An embedded single quote closes, escapes, and reopens the quote. + expect(encodePosixShellArg("a'b")).toBe("'a'\\''b'"); + expect(encodePosixShellArg("$(id)")).toBe("'$(id)'"); + }); +}); + +describe("composeLaunchLine", () => { + it("composes the Claude line with no CODEX_HOME", () => { + const line = composeLaunchLine(CLAUDE); + expect(line).toBe("exec claude setup-token"); + expect(line).not.toContain("CODEX_HOME"); + }); + + it("composes the Codex line with exactly one encoded CODEX_HOME", () => { + const line = composeLaunchLine(CODEX); + expect(line).toBe(`exec env CODEX_HOME='${HOME}' codex login --device-auth`); + // Exactly one CODEX_HOME assignment, and the exact approved Codex command. + expect(line.match(/CODEX_HOME=/g)).toHaveLength(1); + expect(line.endsWith("codex login --device-auth")).toBe(true); + }); +}); + +describe("openDaytonaLoginPtySession — session home", () => { + it("creates the exact UUID directory as a new 0700 directory owned by the login user", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs(); + + await openDaytonaLoginPtySession(process, fs, CLAUDE); + + // The provider created the exact directory as a new 0700 directory. + expect(fs.created).toEqual([{ path: HOME, mode: "700" }]); + // The terminal opened and the launch line ran. + expect(process.createCount).toBe(1); + expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER); + }); + + it("sends the Codex launch line with the encoded CODEX_HOME", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs(); + + await openDaytonaLoginPtySession(process, fs, CODEX); + + expect(process.handle?.inputs[0]).toBe( + `exec env CODEX_HOME='${HOME}' codex login --device-auth` + ENTER, + ); + // The Claude line carries no CODEX_HOME; the Codex line carries exactly one. + expect((process.handle?.inputs[0]?.match(/CODEX_HOME=/g) ?? []).length).toBe(1); + }); + + it("rejects a pre-existing target before it creates the directory", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs({ + seed: { isSymlink: false, isDirectory: true, mode: "700", ownerUid: LOGIN_UID }, + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + // The provider never created the directory and never opened the terminal. + expect(fs.created).toEqual([]); + expect(process.createCount).toBe(0); + }); + + it("rejects a pre-existing symlink at the target", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs({ + seed: { isSymlink: true, isDirectory: false, mode: "777", ownerUid: LOGIN_UID }, + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + expect(process.createCount).toBe(0); + }); + + it("rejects a created target that is a symlink", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs({ + createAs: { isSymlink: true, isDirectory: false, mode: "700", ownerUid: LOGIN_UID }, + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + expect(process.createCount).toBe(0); + }); + + it("rejects a created target that is not a directory", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs({ + createAs: { isSymlink: false, isDirectory: false, mode: "700", ownerUid: LOGIN_UID }, + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + expect(process.createCount).toBe(0); + }); + + it("rejects a created directory with a wrong owner", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs({ + createAs: { isSymlink: false, isDirectory: true, mode: "700", ownerUid: LOGIN_UID + 1 }, + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + expect(process.createCount).toBe(0); + }); + + it("rejects a created directory with a wrong mode", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs({ + createAs: { isSymlink: false, isDirectory: true, mode: "755", ownerUid: LOGIN_UID }, + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + expect(process.createCount).toBe(0); + }); + + it("rejects a symlink swapped in after creation, before the launch input", async () => { + // The directory validates cleanly, then a symlink replaces it while the + // terminal opens. The no-symlink re-check before the launch input fails. + const fs = createFakeHomeFs(); + const process = createFakeProcess(() => { + fs.setEntry({ isSymlink: true, isDirectory: false, mode: "777", ownerUid: LOGIN_UID }); + }); + + await expect(openDaytonaLoginPtySession(process, fs, CLAUDE)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + // The terminal opened, but the launch input never ran. + expect(process.createCount).toBe(1); + expect(process.handle?.inputs).toEqual([]); + }); + + it("rejects a descriptor with a command key outside the closed set", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs(); + + await expect( + openDaytonaLoginPtySession(process, fs, { + loginCommandKey: "gemini" as unknown as "claude", + sessionHome: HOME, + }), + ).rejects.toThrow("LOGIN_PTY_DESCRIPTOR_REJECTED"); + // The provider never touched the filesystem or the terminal. + expect(fs.created).toEqual([]); + expect(process.createCount).toBe(0); + }); + + it("rejects a descriptor whose session home shape is wrong", async () => { + const process = createFakeProcess(); + const fs = createFakeHomeFs(); + + await expect( + openDaytonaLoginPtySession(process, fs, { + loginCommandKey: "codex", + sessionHome: "/tmp/paperclip-adapter-login/../etc", + }), + ).rejects.toThrow("LOGIN_PTY_DESCRIPTOR_REJECTED"); + expect(process.createCount).toBe(0); + }); +}); + +describe("openDaytonaLoginPtySession — session mechanics", () => { + it("starts the login command on a pseudo-terminal with a fixed size", async () => { + const process = createFakeProcess(); + + await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + + expect(process.createOptions?.cols).toBe(120); + expect(process.createOptions?.rows).toBe(30); + expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER); + }); + + it("returns incremental terminal output to the listener", async () => { + const process = createFakeProcess(); + const received: string[] = []; + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + session.onData((chunk) => received.push(chunk)); + + process.handle?.emitText("the url below to sign in\n"); + process.handle?.emitText("Paste code here if prompted\n"); + + expect(received).toEqual(["the url below to sign in\n", "Paste code here if prompted\n"]); + }); + + it("buffers early output until the listener registers", async () => { + const process = createFakeProcess(); + const received: string[] = []; + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + process.handle?.emitText("early output "); + process.handle?.emitText("more output"); + expect(received).toEqual([]); + + session.onData((chunk) => received.push(chunk)); + expect(received).toEqual(["early output more output"]); + }); + + it("delivers the browser code plus the Enter byte to the command", async () => { + const process = createFakeProcess(); + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + session.write("BROWSERCODE" + ENTER); + // The write runs through the serialized chunk chain, so it lands on a later + // microtask. Flush the chain before the assertion. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(process.handle?.inputs[1]).toBe("BROWSERCODE" + ENTER); + expect(process.handle?.inputs[1]?.endsWith(ENTER)).toBe(true); + }); + + it("keeps a multibyte character whole across two output chunks", async () => { + const process = createFakeProcess(); + const received: string[] = []; + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + session.onData((chunk) => received.push(chunk)); + + const euro = new TextEncoder().encode("€"); + process.handle?.emitBytes(euro.subarray(0, 2)); + process.handle?.emitBytes(euro.subarray(2)); + + expect(received.join("")).toBe("€"); + }); + + it("resolves wait with the command exit code", async () => { + const process = createFakeProcess(); + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + process.handle?.finish(9); + + await expect(session.wait()).resolves.toEqual({ exitCode: 9 }); + }); + + it("maps an absent exit code to null", async () => { + const process = createFakeProcess(); + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + process.handle?.finish(undefined); + + await expect(session.wait()).resolves.toEqual({ exitCode: null }); + }); + + it("kills the child and closes the session", async () => { + const process = createFakeProcess(); + + const session = await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE); + session.kill(); + expect(process.handle?.killed).toBe(1); + + await session.close(); + expect(process.handle?.disconnected).toBe(1); + }); + + it("passes the working directory to the pseudo-terminal", async () => { + const process = createFakeProcess(); + + await openDaytonaLoginPtySession(process, createFakeHomeFs(), CLAUDE, { cwd: "/workspace" }); + + expect(process.createOptions?.cwd).toBe("/workspace"); + }); +}); + +describe("openDaytonaLoginPtySession — ancestor symlink", () => { + it("starts no pseudo-terminal when the home filesystem check fails closed", async () => { + // A symlinked login-root ancestor makes the descriptor-relative inspection fail + // closed. The session opener must reject before it opens the terminal, so no + // pseudo-terminal starts and the login command never runs. + const process = createFakeProcess(); + let created = false; + const fs: DaytonaLoginHomeFs = { + async loginUserId(): Promise { + return LOGIN_UID; + }, + async inspect(): Promise { + throw new Error("LOGIN_PTY_HOME_REJECTED"); + }, + async createDirectory(): Promise { + created = true; + }, + }; + + await expect(openDaytonaLoginPtySession(process, fs, CODEX)).rejects.toThrow( + "LOGIN_PTY_HOME_REJECTED", + ); + // The provider never created the directory and never opened the terminal. + expect(created).toBe(false); + expect(process.createCount).toBe(0); + }); +}); + +describe("createDaytonaLoginHomeFs — login-profile preamble", () => { + // A capturing exec surface. It records each command and returns a fixed result, + // so a test reads the exact command string the helper runs. + function createCapturingExec(result: DaytonaExecResult): DaytonaSandboxExec & { + commands: string[]; + } { + const commands: string[] = []; + return { + commands, + async executeCommand(command: string): Promise { + commands.push(command); + // `id -u` resolves the login user id. Return a fixed uid so the caller + // continues to the node helper command under test. + if (command.startsWith("id -u")) { + return { exitCode: 0, result: "1000" }; + } + return result; + }, + }; + } + + // The command sources /etc/profile before it runs node. The Daytona image may + // expose node only through a login profile, so node must run after the profile + // source. This assertion proves the helper runtime and the login PTY capability + // stay consistent. + function expectProfileBeforeNode(command: string | undefined): void { + expect(command).toBeDefined(); + const profileIndex = command!.indexOf("/etc/profile"); + const nodeIndex = command!.indexOf("node -e"); + expect(profileIndex).toBeGreaterThanOrEqual(0); + expect(nodeIndex).toBeGreaterThanOrEqual(0); + expect(profileIndex).toBeLessThan(nodeIndex); + } + + it("sources the login profiles before it runs the node inspect helper", async () => { + const exec = createCapturingExec({ exitCode: 0, result: "ABSENT" }); + const fs = createDaytonaLoginHomeFs(exec); + await fs.inspect(HOME); + expectProfileBeforeNode(exec.commands.find((command) => command.includes("node -e"))); + }); + + it("sources the login profiles before it runs the node create helper", async () => { + const exec = createCapturingExec({ exitCode: 0, result: "" }); + const fs = createDaytonaLoginHomeFs(exec); + await fs.createDirectory(HOME, "700"); + expectProfileBeforeNode(exec.commands.find((command) => command.includes("node -e"))); + }); +}); + +// The real login-home filesystem runs a node helper on the sandbox. The helper +// walks the path with `/proc/self/fd`, which is Linux only. A non-Linux host skips +// these tests. The fake exec runs the helper locally, so the descriptor-relative +// walk is tested against a real filesystem. The login sandbox is Linux. +const describeLinux = process.platform === "linux" ? describe : describe.skip; + +describeLinux("createDaytonaLoginHomeFs — descriptor-relative walk", () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(path.join(tmpdir(), "daytona-login-home-")); + }); + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + // A local exec surface. It runs the composed command with `/bin/sh -c`, so the + // real node helper runs against the local filesystem. + function createLocalExec(): DaytonaSandboxExec { + return { + async executeCommand(command: string): Promise { + const result = spawnSync("/bin/sh", ["-c", command], { encoding: "utf8" }); + return { exitCode: result.status ?? undefined, result: result.stdout ?? "" }; + }, + }; + } + + const UUID = "11111111-2222-4333-8444-555555555555"; + + it("reports a not-yet-created target as absent", async () => { + const fs = createDaytonaLoginHomeFs(createLocalExec()); + const home = path.join(root, "absent-root", UUID); + const inspection = await fs.inspect(home); + expect(inspection.exists).toBe(false); + }); + + it("creates the login root and the session home relative to a trusted descriptor", async () => { + const fs = createDaytonaLoginHomeFs(createLocalExec()); + const home = path.join(root, "clean-root", UUID); + await fs.createDirectory(home, "700"); + const inspection = await fs.inspect(home); + expect(inspection.exists).toBe(true); + expect(inspection.isSymlink).toBe(false); + expect(inspection.isDirectory).toBe(true); + expect(inspection.mode).toBe("700"); + expect(inspection.ownerUid).toBe(process.getuid ? process.getuid() : inspection.ownerUid); + }); + + it("rejects a pre-existing session home target", async () => { + const fs = createDaytonaLoginHomeFs(createLocalExec()); + const home = path.join(root, "exists-root", UUID); + await fs.createDirectory(home, "700"); + // A second create of the same target fails, because the target exists. + await expect(fs.createDirectory(home, "700")).rejects.toThrow("LOGIN_PTY_HOME_REJECTED"); + }); + + it("fails the inspection closed when the login root is a symlink", async () => { + // Pre-place a symlink at the login-root ancestor. The descriptor-relative walk + // opens the root with a no-follow open, so the inspection fails closed. + const fs = createDaytonaLoginHomeFs(createLocalExec()); + const attacker = path.join(root, "inspect-attacker"); + mkdirSync(attacker, { mode: 0o700 }); + const rootLink = path.join(root, "inspect-symlink-root"); + symlinkSync(attacker, rootLink); + const home = path.join(rootLink, UUID); + await expect(fs.inspect(home)).rejects.toThrow("LOGIN_PTY_HOME_REJECTED"); + }); + + it("fails the creation closed when the login root is a pre-placed symlink", async () => { + // Pre-place a symlink at the login-root ancestor that points at an attacker + // tree. The creation opens the root with a no-follow open after the tolerant + // `mkdir`, so it fails closed and creates no directory in the attacker tree. + const fs = createDaytonaLoginHomeFs(createLocalExec()); + const attacker = path.join(root, "create-attacker"); + mkdirSync(attacker, { mode: 0o700 }); + const rootLink = path.join(root, "create-symlink-root"); + symlinkSync(attacker, rootLink); + const home = path.join(rootLink, UUID); + await expect(fs.createDirectory(home, "700")).rejects.toThrow("LOGIN_PTY_HOME_REJECTED"); + // The creation placed no session home inside the attacker tree. + expect(existsSync(path.join(attacker, UUID))).toBe(false); + }); +}); + +describe("createDaytonaLoginPtySessionOpener", () => { + it("opens a session for the descriptor on each call", async () => { + const process = createFakeProcess(); + const opener = createDaytonaLoginPtySessionOpener(process, createFakeHomeFs(), { + cwd: "/workspace", + }); + + const session = await opener(CLAUDE); + + expect(process.createOptions?.cwd).toBe("/workspace"); + expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER); + expect(typeof session.onData).toBe("function"); + expect(typeof session.write).toBe("function"); + expect(typeof session.wait).toBe("function"); + expect(typeof session.kill).toBe("function"); + expect(typeof session.close).toBe("function"); + }); +}); diff --git a/packages/plugins/sandbox-providers/daytona/src/login-pty.ts b/packages/plugins/sandbox-providers/daytona/src/login-pty.ts new file mode 100644 index 00000000000..803cafc729d --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/login-pty.ts @@ -0,0 +1,586 @@ +// The Daytona pseudo-terminal (PTY) session for the shared login flow. The login +// command needs a real pseudo-terminal: pipe stdio emits no login prompt. The +// Daytona SDK opens a PTY through `process.createPty`. This module binds that PTY +// to a small session that the login PTY transport consumes, so the login runner +// runs the command on a real terminal, streams the terminal output, and delivers +// the delayed browser code plus the Enter byte. +// +// Command contract: the host resolves a closed login command key and a validated +// session home, and it carries them in a launch descriptor. The descriptor holds +// no command string. This module maps the key to a compile-time command, so a +// caller cannot select or override the command. For the Codex key the module +// composes `exec env CODEX_HOME= `. For the +// Claude key it composes `exec ` with no CODEX_HOME. The +// module encodes the dynamic home with a POSIX shell-argument encoder, so the +// home cannot add a second shell token or a second command. +// +// Session home: the module revalidates the descriptor and the home shape, then +// creates the exact UUID directory as a NEW directory with mode 0700, owned by the +// login user. The inspection and the creation open the filesystem root, then walk +// each path component with a no-follow, directory-only open, so a symlink at any +// ancestor (the login root or a directory above it) fails closed. A single +// composite `stat` or `mkdir` follows a symlink at an ancestor; the per-component +// walk does not. The creation creates the login root if the root is absent and the +// UUID directory as a NEW directory, both relative to an open trusted descriptor. +// The module rejects a pre-existing target, a symlink, a non-directory, a wrong +// owner, and a wrong mode. It uses no recursive delete. It re-checks the directory +// with a no-symlink walk before it opens the terminal and again before it sends the +// launch input, so a symlink swapped in after creation fails before the launch. +// +// Dependency boundary: this provider plugin ships standalone (the workspace +// excludes `packages/plugins/sandbox-providers/**`). So the module imports no +// workspace package. It declares the small session shape locally through +// {@link LoginPtySession}. The shape matches the `LoginPtySession` interface in +// `@paperclipai/adapter-utils`, so the transport factory `createLoginPtyTransport` +// accepts the session opener from this module with no adapter. The runner drives +// the transport; the transport drives this session. +// +// SDK boundary: the module holds no Daytona SDK type import. It declares the small +// subset of the SDK PTY and filesystem surfaces that it uses through +// {@link DaytonaPtyProcess}, {@link DaytonaPtyHandle}, and {@link DaytonaSandboxExec}. +// The SDK `Process` and `PtyHandle` satisfy those subsets, so a caller passes +// `sandbox.process` with no adapter. The narrow surface keeps the module +// unit-testable with a fake PTY and a fake filesystem. +// +// Security (secret handling): the login runner delivers the browser code and the +// Enter byte through {@link DaytonaPtyHandle.sendInput}. The SDK sends the input +// over the PTY socket, so the code never rides a command line and never reaches a +// process argument list. The session forwards the raw terminal bytes with no ANSI +// or OSC 8 handling; the login parser owns that handling. + +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { sendPtyInputInChunks } from "./pty-chunked-input.js"; + +/** + * The closed set of login command identities. The worker maps each key to a + * compile-time command. A value outside this set fails closed before the module + * touches the filesystem. + */ +export type LoginCommandKey = "claude" | "codex"; + +/** + * The host-resolved launch descriptor. It carries the closed command key and the + * server-controlled session home. It holds no command string. + */ +export interface LoginPtyLaunchDescriptor { + /** The host-resolved fixed command identity. */ + loginCommandKey: LoginCommandKey; + /** + * The server-controlled session home. The shape is exact: + * `/tmp/paperclip-adapter-login/`. + */ + sessionHome: string; +} + +/** + * The compile-time command for each login command key. The Daytona plugin ships + * standalone, so it holds its own copy of the fixed commands. A future change to + * a command lands here and in the adapter constant together. + */ +const LOGIN_COMMAND_BY_KEY: Readonly> = { + claude: "claude setup-token", + codex: "codex login --device-auth", +}; + +/** The octal mode string for a new session home directory. */ +const LOGIN_HOME_MODE = "700"; + +/** The fixed root for a login session home. */ +const LOGIN_SESSION_HOME_ROOT = "/tmp/paperclip-adapter-login"; + +/** + * The exact absolute path shape for a login session home. The path is the fixed + * root, one slash, and one UUID. The anchors reject a relative path, a traversal, + * whitespace, a control character, and a shell metacharacter, because none of + * those match the UUID or the fixed root. + */ +const SESSION_HOME_PATTERN = + /^\/tmp\/paperclip-adapter-login\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** The fixed non-secret error a rejected launch descriptor or session home returns. */ +export const LOGIN_PTY_DESCRIPTOR_REJECTED = "LOGIN_PTY_DESCRIPTOR_REJECTED"; +/** The fixed non-secret error a rejected session home directory returns. */ +export const LOGIN_PTY_HOME_REJECTED = "LOGIN_PTY_HOME_REJECTED"; + +/** + * Encodes one value as a single POSIX shell argument. It wraps the value in + * single quotes and escapes each embedded single quote. The shell treats every + * other character inside single quotes as a literal, so an encoded value cannot + * add a second shell token or a second command. + */ +export function encodePosixShellArg(value: string): string { + return `'${value.split("'").join("'\\''")}'`; +} + +/** Reports whether a value is a member of the closed login command key set. */ +export function isLoginCommandKey(value: unknown): value is LoginCommandKey { + return value === "claude" || value === "codex"; +} + +/** + * Composes the launch line for the descriptor. For the Codex key it prefixes one + * safely encoded `CODEX_HOME` assignment with `env`. For the Claude key it adds no + * `CODEX_HOME`. It replaces the interactive shell with the command through `exec`, + * so the pseudo-terminal runs the command directly and its exit code becomes the + * PTY exit code. + */ +export function composeLaunchLine(descriptor: LoginPtyLaunchDescriptor): string { + const command = LOGIN_COMMAND_BY_KEY[descriptor.loginCommandKey]; + if (descriptor.loginCommandKey === "codex") { + const encodedHome = encodePosixShellArg(descriptor.sessionHome); + return `exec env CODEX_HOME=${encodedHome} ${command}`; + } + return `exec ${command}`; +} + +/** + * A live pseudo-terminal session for one login command. The session allocates a + * real pseudo-terminal, streams the raw terminal output, accepts delayed input, + * and stops the child. The shape matches the `LoginPtySession` interface in + * `@paperclipai/adapter-utils`, so the transport factory there accepts a session + * opener that returns this session. + */ +export interface LoginPtySession { + /** Registers the one output listener. The session streams each raw chunk in order. */ + onData(listener: (chunk: string) => void): void; + /** Writes raw input bytes to the pseudo-terminal. */ + write(data: string): void; + /** Resolves with the child exit code when the command ends. */ + wait(): Promise<{ exitCode: number | null }>; + /** Stops the child process. Safe to call more than one time. */ + kill(): void; + /** Releases the session resources. Safe to call more than one time. */ + close(): Promise; +} + +/** Opens a {@link LoginPtySession} for `descriptor`. The transport calls it one time. */ +export type LoginPtySessionOpener = ( + descriptor: LoginPtyLaunchDescriptor, +) => Promise; + +/** + * The subset of the Daytona SDK `PtyHandle` that the session uses. The SDK + * `PtyHandle` satisfies this interface, so a caller passes the real handle with + * no adapter. + */ +export interface DaytonaPtyHandle { + /** Resolves when the PTY socket connection is ready for input and output. */ + waitForConnection(): Promise; + /** Sends raw input bytes to the pseudo-terminal. */ + sendInput(data: string | Uint8Array): Promise; + /** Resolves with the exit result when the pseudo-terminal process ends. */ + wait(): Promise<{ exitCode?: number; error?: string }>; + /** Kills the pseudo-terminal process. */ + kill(): Promise; + /** Closes the PTY socket and releases the resources. */ + disconnect(): Promise; +} + +/** + * The options the session passes to `createPty`. The session sets a fixed + * terminal size and one output callback. It sets the working directory when the + * caller provides one. + */ +export interface DaytonaPtyCreateOptions { + id: string; + cwd?: string; + cols: number; + rows: number; + onData: (data: Uint8Array) => void | Promise; +} + +/** + * The subset of the Daytona SDK `Process` that the session uses. The SDK + * `Process` satisfies this interface, so a caller passes `sandbox.process`. + */ +export interface DaytonaPtyProcess { + createPty(options: DaytonaPtyCreateOptions): Promise; +} + +/** The result of one command run on the sandbox. */ +export interface DaytonaExecResult { + exitCode?: number; + result?: string; +} + +/** + * The subset of the Daytona SDK `Process` that the filesystem helper uses. The + * SDK `Process` satisfies this interface through `executeCommand`, so a caller + * passes `sandbox.process`. + */ +export interface DaytonaSandboxExec { + executeCommand( + command: string, + cwd?: string, + env?: Record, + timeout?: number, + ): Promise; +} + +/** One no-follow inspection of a filesystem path. */ +export interface LoginHomeInspection { + /** True when the path exists (as any type). */ + exists: boolean; + /** True when the path itself is a symbolic link. */ + isSymlink: boolean; + /** True when the path itself is a directory. */ + isDirectory: boolean; + /** The octal permission string, for example `700`. Empty when the path is absent. */ + mode: string; + /** The owner user id, or null when the path is absent. */ + ownerUid: number | null; +} + +/** + * The narrow filesystem surface the session home operations need. The production + * surface runs commands on the sandbox; a unit test injects a fake. The inspection + * and the creation walk each path component with a no-follow open, so a symlink at + * any ancestor fails closed and a symlink at the target reports the link. + */ +export interface DaytonaLoginHomeFs { + /** Resolves the user id of the login user that runs the pseudo-terminal. */ + loginUserId(): Promise; + /** + * Inspects `path` without following a symlink at the target or at any ancestor. + * It rejects (throws) when an ancestor is a symlink or a non-directory. + */ + inspect(path: string): Promise; + /** + * Creates `path` as a NEW directory with the octal `mode`. It creates the login + * root ancestor if the root is absent. It fails when the target path exists or + * when an ancestor is a symlink. It follows no composite pathname. + */ + createDirectory(path: string, mode: string): Promise; +} + +/** The options for the Daytona login PTY session. */ +export interface DaytonaLoginPtyOptions { + /** The working directory for the login PTY. Defaults to the sandbox default. */ + cwd?: string; +} + +// The terminal size for the login PTY. The login UI prints a short prompt, so a +// standard terminal size is enough. A fixed size keeps the output stable. +const LOGIN_PTY_COLS = 120; +const LOGIN_PTY_ROWS = 30; + +/** + * The input terminator that replaces the interactive shell with the login + * command. The `exec` builtin replaces the shell process image with the command, + * so the PTY runs the command directly. When the command ends, the PTY ends with + * the command exit code, and the delayed input reaches the command, not a shell. + * The terminal reads the carriage return as the Enter key. + */ +const PTY_COMMAND_TERMINATOR = "\r"; + +/** Normalizes an octal permission string to its numeric value for comparison. */ +function octalMode(value: string): number { + return Number.parseInt(value, 8); +} + +/** + * Revalidates the launch descriptor. It fails closed when the command key is + * outside the closed set or the session home shape is wrong. A unit test that + * bypasses the host boundary hits this check, so the provider never trusts an + * unvalidated descriptor. + */ +function assertDescriptor(descriptor: LoginPtyLaunchDescriptor): void { + if (!isLoginCommandKey(descriptor.loginCommandKey)) { + throw new Error(LOGIN_PTY_DESCRIPTOR_REJECTED); + } + if (!SESSION_HOME_PATTERN.test(descriptor.sessionHome)) { + throw new Error(LOGIN_PTY_DESCRIPTOR_REJECTED); + } +} + +/** + * Creates and validates the exact session home directory. It rejects a + * pre-existing target (including a symlink), creates the directory as a NEW + * directory with mode 0700, then verifies the directory type, the no-symlink + * state, the mode, and the login-user ownership. It uses no recursive delete. + */ +async function prepareSessionHome(fs: DaytonaLoginHomeFs, sessionHome: string): Promise { + const loginUid = await fs.loginUserId(); + + // Reject a pre-existing target. A no-follow inspection reports a symlink, a + // file, or a directory as present, so any pre-existing target fails here. + const before = await fs.inspect(sessionHome); + if (before.exists) { + throw new Error(LOGIN_PTY_HOME_REJECTED); + } + + // Create the exact directory as a NEW directory with mode 0700. The create + // fails when the path exists, so the create never follows a symlink. + await fs.createDirectory(sessionHome, LOGIN_HOME_MODE); + + // Verify the created directory. Reject a symlink, a non-directory, a wrong + // owner, and a wrong mode. Do not delete on a rejection; the sandbox is + // ephemeral and the provider uses no recursive delete. + const after = await fs.inspect(sessionHome); + if ( + !after.exists || + after.isSymlink || + !after.isDirectory || + after.ownerUid !== loginUid || + octalMode(after.mode) !== octalMode(LOGIN_HOME_MODE) + ) { + throw new Error(LOGIN_PTY_HOME_REJECTED); + } +} + +/** + * Re-checks the directory with a no-symlink check. It rejects a symlink and a + * non-directory, so a symlink swapped in after creation fails before the launch. + */ +async function assertHomeStillSafe(fs: DaytonaLoginHomeFs, sessionHome: string): Promise { + const now = await fs.inspect(sessionHome); + if (!now.exists || now.isSymlink || !now.isDirectory) { + throw new Error(LOGIN_PTY_HOME_REJECTED); + } +} + +/** + * Opens a Daytona PTY session for `descriptor` and returns it as a + * {@link LoginPtySession}. The function revalidates the descriptor and the home + * shape, creates and validates the session home, opens a real pseudo-terminal, + * re-checks the directory before the launch, composes the safe launch line, and + * sends it. The session streams the raw terminal output, delivers delayed input, + * and stops the child. + * + * The function decodes the terminal bytes as a UTF-8 stream, so a multibyte + * character that splits across two output chunks stays whole. It buffers the + * output until the transport registers the listener, so no early chunk is lost. + */ +export async function openDaytonaLoginPtySession( + process: DaytonaPtyProcess, + fs: DaytonaLoginHomeFs, + descriptor: LoginPtyLaunchDescriptor, + options?: DaytonaLoginPtyOptions, +): Promise { + assertDescriptor(descriptor); + // Create and validate the session home before the terminal opens. + await prepareSessionHome(fs, descriptor.sessionHome); + // Re-check the directory with a no-symlink check before `createPty`. + await assertHomeStillSafe(fs, descriptor.sessionHome); + + const decoder = new TextDecoder("utf-8"); + let listener: ((chunk: string) => void) | null = null; + let buffered = ""; + + const handle = await process.createPty({ + id: `paperclip-login-pty-${randomUUID()}`, + ...(options?.cwd ? { cwd: options.cwd } : {}), + cols: LOGIN_PTY_COLS, + rows: LOGIN_PTY_ROWS, + onData: (data: Uint8Array): void => { + // Decode the terminal bytes as a stream, so a split multibyte character + // stays whole across two chunks. Forward the raw text; the parser owns the + // ANSI and OSC 8 handling. + const text = decoder.decode(data, { stream: true }); + if (text.length === 0) return; + if (listener) listener(text); + else buffered += text; + }, + }); + + await handle.waitForConnection(); + // Re-check the directory with a no-symlink check immediately before the launch + // input, so a symlink swapped in after creation fails before `sendInput`. + await assertHomeStillSafe(fs, descriptor.sessionHome); + // Replace the interactive shell with the login command, so the pseudo-terminal + // runs the command directly. The runner then writes the delayed browser code + // to the command, not to a shell. + await handle.sendInput(composeLaunchLine(descriptor) + PTY_COMMAND_TERMINATOR); + + // The tail of the write chain. The chunker awaits each chunk, so one write can + // suspend between its chunks. The chain runs each write after the previous + // write ends, so the chunks of two writes never interleave on the wire. + let writeChain: Promise = Promise.resolve(); + + return { + onData(next: (chunk: string) => void): void { + listener = next; + if (buffered.length > 0) { + const pending = buffered; + buffered = ""; + next(pending); + } + }, + write(data: string): void { + // Send the input as byte-bounded chunks under the provider message cap. The + // login input is short today, so the latent defect never fires, but the same + // unbounded write goes through the one chunker. The chain runs this write + // after the previous write ends, so two writes keep their order and never + // interleave their chunks. A write error must not throw into the runner, so + // the runner's fixed status stays the single result path. + writeChain = writeChain.then(() => + sendPtyInputInChunks((chunk) => handle.sendInput(chunk), data), + ); + }, + async wait(): Promise<{ exitCode: number | null }> { + const result = await handle.wait(); + return { exitCode: typeof result.exitCode === "number" ? result.exitCode : null }; + }, + kill(): void { + void handle.kill().catch(() => undefined); + }, + async close(): Promise { + await handle.disconnect().catch(() => undefined); + }, + }; +} + +/** + * The inspection helper source. The provider reads it from its own script file and + * runs it on the sandbox as ` && node -e