From 85555305aebb9e2b0e4634e99e31d72cb600acbd Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Wed, 11 Nov 2026 08:11:20 -0800 Subject: [PATCH 01/38] orchestrator: pin the built-in definition hashes and list the files that name a provider Two tests that hold while the provider role seams land. The hash test keeps the PR review and Slack threads definitions byte-identical, because a changed hash makes the boot seed stop every active run. The guard test lists the 48 files outside src/providers that name GitHub, Slack, or OpenRouter. The list only shrinks. Co-Authored-By: Claude Fable 5.2 --- .../src/__tests__/provider-literals.test.ts | 98 +++++++++++++++++++ .../__tests__/definition-hashes.test.ts | 19 ++++ 2 files changed, 117 insertions(+) create mode 100644 orchestrator/src/__tests__/provider-literals.test.ts create mode 100644 orchestrator/src/automations/builtins/__tests__/definition-hashes.test.ts diff --git a/orchestrator/src/__tests__/provider-literals.test.ts b/orchestrator/src/__tests__/provider-literals.test.ts new file mode 100644 index 000000000..3dc9b91e4 --- /dev/null +++ b/orchestrator/src/__tests__/provider-literals.test.ts @@ -0,0 +1,98 @@ +/** Product code names no provider. + * + * A provider's name and its modules belong to `src/providers//`. Other + * code reaches a provider through its role (git forge, chat, model router). + * ALLOWED lists the files that still break the rule. It only shrinks: a file + * that stops offending must leave the list, and no file may join it. + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +import { expect, test } from "bun:test"; + +const SRC = join(import.meta.dir, ".."); +const PROVIDERS = "github|slack|openrouter"; + +/** A quoted provider id, e.g. `"slack"`. */ +const LITERAL = new RegExp(`["'\`](${PROVIDERS})["'\`]`); +/** An import of a module named for a provider, e.g. `../integrations/slack-policy.ts`. */ +const IMPORT = new RegExp(`from\\s+"[^"]*/(${PROVIDERS})[^"/]*"`); +/** A module named for a provider, e.g. `routes/github-events.ts`. */ +const FILENAME = new RegExp(`(^|/)(${PROVIDERS})[^/]*\\.ts$`); + +const ALLOWED: string[] = [ + "automations/actions/builtin.ts", + "automations/builtins/pr-review.ts", + "automations/builtins/seed.ts", + "automations/builtins/slack-brain.ts", + "automations/draft-catalog.ts", + "automations/engine/blocks/agent.ts", + "automations/engine/blocks/decide.ts", + "automations/engine/blocks/index.ts", + "automations/engine/blocks/relay-close.ts", + "automations/engine/blocks/relay.ts", + "automations/engine/blocks/resolve-user.ts", + "automations/engine/blocks/review.ts", + "automations/engine/blocks/slack-choice.ts", + "automations/engine/deps.ts", + "automations/handles.ts", + "automations/profile-cards.ts", + "automations/retire-provider-webhooks.ts", + "automations/session-relay.ts", + "db/connectors.ts", + "db/schema.ts", + "index.ts", + "integrations/github-webhook.ts", + "integrations/github.ts", + "integrations/openrouter-decisions.ts", + "integrations/openrouter.ts", + "integrations/slack-blocks.ts", + "integrations/slack-identity.ts", + "integrations/slack-message-text.ts", + "integrations/slack-policy.ts", + "integrations/slack.ts", + "listeners/pr-link-consumer.ts", + "listeners/title-consumer.ts", + "log.ts", + "model-routers/catalog.ts", + "model-routers/openrouter.ts", + "model-routers/registry.ts", + "reviews/automation-review.ts", + "reviews/control-plane.ts", + "reviews/github-review.ts", + "reviews/target-hydrator.ts", + "routes/github-events.ts", + "routes/slack-events.ts", + "routes/slack-interactivity.ts", + "rpc/automations.ts", + "rpc/integration.ts", + "specs/projection.ts", + "tools/automation-draft.ts", + "workflows/automation-run.ts", +]; + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + return entry.name === "__tests__" ? [] : sourceFiles(path); + } + return entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts") ? [path] : []; + }); +} + +function offenders(): string[] { + return sourceFiles(SRC) + .map((path) => relative(SRC, path)) + .filter((file) => !file.startsWith("providers/") && !file.startsWith("gen/")) + .filter((file) => { + const text = readFileSync(join(SRC, file), "utf8"); + return FILENAME.test(file) || LITERAL.test(text) || IMPORT.test(text); + }) + .sort(); +} + +test("only the listed files name a provider outside src/providers", () => { + expect(offenders()).toEqual([...ALLOWED].sort()); +}); diff --git a/orchestrator/src/automations/builtins/__tests__/definition-hashes.test.ts b/orchestrator/src/automations/builtins/__tests__/definition-hashes.test.ts new file mode 100644 index 000000000..2ad7ca3da --- /dev/null +++ b/orchestrator/src/automations/builtins/__tests__/definition-hashes.test.ts @@ -0,0 +1,19 @@ +/** The shipped built-in definitions keep their content hash. + * + * A changed hash makes the boot seed insert a new version and stop every + * active run of the old one. Change a hash here only when that is intended. + */ + +import { expect, test } from "bun:test"; + +import { PR_REVIEW_DEFINITION } from "../pr-review.ts"; +import { definitionContentHash } from "../seed.ts"; +import { SLACK_BRAIN_DEFINITION } from "../slack-brain.ts"; + +test("the PR review definition hash is unchanged", () => { + expect(definitionContentHash(PR_REVIEW_DEFINITION)).toBe("c11862e4e9faedf8d6727d752f55ad5816f8aca449f4ba2592ec945725b66313"); +}); + +test("the Slack threads definition hash is unchanged", () => { + expect(definitionContentHash(SLACK_BRAIN_DEFINITION)).toBe("756c76430aec0afecefddf12051c68a27f71b3afa14e6e5674c13e91f4f3c54f"); +}); From a1426c16057c57f228d4813e565cb802e37bc60e Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Wed, 11 Nov 2026 08:16:48 -0800 Subject: [PATCH 02/38] orchestrator: one event route and one signature verifier for every provider POST /api/v1/integrations/:provider/events replaces the four per-provider route files at the same URLs. The connector's declared webhook ingress (scheme and sealed secret) now selects the verifier; before, the declaration was read by nothing and each route wired its own. A provider supplies only what a verified delivery means, under src/providers//events.ts. The private secret caches and HMAC code for GitHub and Slack are deleted in favour of the shared resolver in automations/verify.ts, and the Slack interactivity route verifies through it too. An event path is exempt from the IAP bridge when its connector declares an ingress. This adds the Vanta path, which was mounted but never exempt, so its deliveries could not reach the route on an IAP deployment. Review pass: removed history comments from the ingress spine and the IAP bridge, dropped the retry-number debug log, and a source whose connector declares no ingress is now a startup error. Co-Authored-By: Claude Fable 5.2 --- .../src/__tests__/github-events-route.test.ts | 26 +- .../__tests__/github-iap-public-path.test.ts | 6 + orchestrator/src/__tests__/github.test.ts | 25 -- .../src/__tests__/integration-ingress.test.ts | 13 +- .../src/__tests__/linear-events-route.test.ts | 6 +- .../src/__tests__/provider-literals.test.ts | 5 +- .../src/__tests__/slack-events-route.test.ts | 14 +- .../src/__tests__/slack-integration.test.ts | 9 +- .../slack-interactivity-route.test.ts | 2 +- .../src/__tests__/vanta-events-route.test.ts | 9 +- orchestrator/src/auth/iap-bridge.ts | 36 +-- .../src/automations/__tests__/verify.test.ts | 67 +++++- .../src/automations/integration-ingress.ts | 42 ++-- orchestrator/src/automations/verify.ts | 80 +++++-- orchestrator/src/connectors/registry.ts | 9 + orchestrator/src/index.ts | 17 +- orchestrator/src/integrations/github.ts | 57 ----- orchestrator/src/integrations/slack.ts | 36 --- orchestrator/src/providers/github/events.ts | 167 +++++++++++++ orchestrator/src/providers/linear/events.ts | 49 ++++ orchestrator/src/providers/slack/events.ts | 60 +++++ orchestrator/src/providers/vanta/events.ts | 34 +++ orchestrator/src/routes/github-events.ts | 224 ------------------ orchestrator/src/routes/integration-events.ts | 82 +++++++ orchestrator/src/routes/linear-events.ts | 121 ---------- orchestrator/src/routes/slack-events.ts | 122 ---------- .../src/routes/slack-interactivity.ts | 36 +-- orchestrator/src/routes/vanta-events.ts | 106 --------- 28 files changed, 648 insertions(+), 812 deletions(-) delete mode 100644 orchestrator/src/__tests__/github.test.ts delete mode 100644 orchestrator/src/integrations/github.ts create mode 100644 orchestrator/src/providers/github/events.ts create mode 100644 orchestrator/src/providers/linear/events.ts create mode 100644 orchestrator/src/providers/slack/events.ts create mode 100644 orchestrator/src/providers/vanta/events.ts delete mode 100644 orchestrator/src/routes/github-events.ts create mode 100644 orchestrator/src/routes/integration-events.ts delete mode 100644 orchestrator/src/routes/linear-events.ts delete mode 100644 orchestrator/src/routes/slack-events.ts delete mode 100644 orchestrator/src/routes/vanta-events.ts diff --git a/orchestrator/src/__tests__/github-events-route.test.ts b/orchestrator/src/__tests__/github-events-route.test.ts index 12f9f27ce..795bcebf1 100644 --- a/orchestrator/src/__tests__/github-events-route.test.ts +++ b/orchestrator/src/__tests__/github-events-route.test.ts @@ -3,14 +3,36 @@ import { describe, expect, test } from "bun:test"; import type { UpsertReviewTargetInput } from "../db/reviews.ts"; import { dispatchIntegrationEvent, type DispatchWebhookInput } from "../automations/dispatch.ts"; -import type { IntegrationEventDispatchInput } from "../automations/integration-ingress.ts"; +import type { + HandleDeliveryDeps, + IntegrationEventDispatchInput, +} from "../automations/integration-ingress.ts"; import type { IntegrationEventStore, RecordIntegrationEventInput, } from "../db/integration-events.ts"; -import { makeGithubEventsRoute } from "../routes/github-events.ts"; +import { makeIntegrationEventsRoute } from "../routes/integration-events.ts"; +import { githubEventSource, type GithubEventsDeps } from "../providers/github/events.ts"; const SECRET = "github-route-secret"; + +function makeGithubEventsRoute({ + webhookSecret, + ingress, + now, + ...source +}: GithubEventsDeps & { + webhookSecret: () => Promise; + ingress: HandleDeliveryDeps; + now?: () => Date; +}) { + return makeIntegrationEventsRoute({ + sources: [githubEventSource(source)], + secrets: { resolve: webhookSecret }, + ingress, + ...(now ? { now } : {}), + }); +} const PATH = "/api/v1/integrations/github/events"; const enrollment = { repo: "openai/engrams" }; diff --git a/orchestrator/src/__tests__/github-iap-public-path.test.ts b/orchestrator/src/__tests__/github-iap-public-path.test.ts index 72a2e464f..ec7ade440 100644 --- a/orchestrator/src/__tests__/github-iap-public-path.test.ts +++ b/orchestrator/src/__tests__/github-iap-public-path.test.ts @@ -22,6 +22,12 @@ describe("GitHub webhook IAP exemption", () => { expect(isIapPublicPath("/api/v1/integrations/linear")).toBe(false); }); + test("an event path is public only for a connector that declares an ingress", () => { + expect(isIapPublicPath("/api/v1/integrations/vanta/events", "POST")).toBe(true); + expect(isIapPublicPath("/api/v1/integrations/gitlab/events", "POST")).toBe(false); + expect(isIapPublicPath("/api/v1/integrations/unknown/events", "POST")).toBe(false); + }); + test("dynamic hooks require POST plus exactly one valid registration slug", () => { expect(isIapPublicPath("/api/v1/hooks/my-hook?delivery=1", "POST")).toBe(true); expect(isIapPublicPath("/api/v1/hooks/my-hook", "GET")).toBe(false); diff --git a/orchestrator/src/__tests__/github.test.ts b/orchestrator/src/__tests__/github.test.ts deleted file mode 100644 index ceb745d88..000000000 --- a/orchestrator/src/__tests__/github.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createHmac } from "node:crypto"; -import { describe, expect, test } from "bun:test"; - -import { verifyGithubSignature } from "../integrations/github.ts"; - -describe("verifyGithubSignature", () => { - const secret = "correct horse battery staple"; - const body = JSON.stringify({ zen: "Keep it logically awesome." }); - const signature = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; - - test("accepts the documented sha256 HMAC", () => { - expect(verifyGithubSignature(secret, body, signature)).toBe(true); - }); - - test("rejects wrong secrets, tampering, missing headers, and wrong lengths", () => { - expect(verifyGithubSignature("wrong", body, signature)).toBe(false); - expect(verifyGithubSignature(secret, `${body}x`, signature)).toBe(false); - expect(verifyGithubSignature(secret, body, undefined)).toBe(false); - expect(verifyGithubSignature(secret, body, "sha256=short")).toBe(false); - }); - - test("malformed signatures never throw", () => { - expect(() => verifyGithubSignature(secret, body, "💥")).not.toThrow(); - }); -}); diff --git a/orchestrator/src/__tests__/integration-ingress.test.ts b/orchestrator/src/__tests__/integration-ingress.test.ts index 37cc21f46..9d82027ea 100644 --- a/orchestrator/src/__tests__/integration-ingress.test.ts +++ b/orchestrator/src/__tests__/integration-ingress.test.ts @@ -19,8 +19,9 @@ import type { IntegrationEventStore, RecordIntegrationEventInput, } from "../db/integration-events.ts"; -import { makeLinearEventsRoute } from "../routes/linear-events.ts"; -import { makeSlackEventsRoute } from "../routes/slack-events.ts"; +import { makeIntegrationEventsRoute } from "../routes/integration-events.ts"; +import { linearEventSource } from "../providers/linear/events.ts"; +import { slackEventSource } from "../providers/slack/events.ts"; function fakeStore() { const recorded: RecordIntegrationEventInput[] = []; @@ -216,7 +217,8 @@ function linearHeaders(body: string, overrides: Record = {}) { describe("POST /api/v1/integrations/linear/events", () => { function linearApp() { const s = seams(); - const app = makeLinearEventsRoute({ + const app = makeIntegrationEventsRoute({ + sources: [linearEventSource], secrets: { resolve: async () => LINEAR_SECRET }, ingress: s.deps, }); @@ -310,8 +312,9 @@ function slackSigned(body: string) { describe("slack events through the spine", () => { function slackApp() { const s = seams(); - const app = makeSlackEventsRoute({ - signingSecret: async () => SLACK_SECRET, + const app = makeIntegrationEventsRoute({ + sources: [slackEventSource], + secrets: { resolve: async () => SLACK_SECRET }, ingress: s.deps, }); return { ...s, app }; diff --git a/orchestrator/src/__tests__/linear-events-route.test.ts b/orchestrator/src/__tests__/linear-events-route.test.ts index 40355d11c..d9135b496 100644 --- a/orchestrator/src/__tests__/linear-events-route.test.ts +++ b/orchestrator/src/__tests__/linear-events-route.test.ts @@ -11,7 +11,8 @@ import { createHmac } from "node:crypto"; import { describe, expect, test } from "bun:test"; -import { makeLinearEventsRoute } from "../routes/linear-events.ts"; +import { makeIntegrationEventsRoute } from "../routes/integration-events.ts"; +import { linearEventSource } from "../providers/linear/events.ts"; import type { RecordIntegrationEventInput } from "../db/integration-events.ts"; const SECRET = "linear-test-signing-secret"; @@ -21,7 +22,8 @@ const NOW = new Date("2026-08-31T17:06:35.000Z"); async function deliver(payload: Record): Promise { const recorded: RecordIntegrationEventInput[] = []; const body = JSON.stringify({ ...payload, webhookTimestamp: NOW.getTime() }); - const app = makeLinearEventsRoute({ + const app = makeIntegrationEventsRoute({ + sources: [linearEventSource], secrets: { resolve: async () => SECRET }, now: () => NOW, ingress: { diff --git a/orchestrator/src/__tests__/provider-literals.test.ts b/orchestrator/src/__tests__/provider-literals.test.ts index 3dc9b91e4..c96e2b4c7 100644 --- a/orchestrator/src/__tests__/provider-literals.test.ts +++ b/orchestrator/src/__tests__/provider-literals.test.ts @@ -18,7 +18,7 @@ const PROVIDERS = "github|slack|openrouter"; const LITERAL = new RegExp(`["'\`](${PROVIDERS})["'\`]`); /** An import of a module named for a provider, e.g. `../integrations/slack-policy.ts`. */ const IMPORT = new RegExp(`from\\s+"[^"]*/(${PROVIDERS})[^"/]*"`); -/** A module named for a provider, e.g. `routes/github-events.ts`. */ +/** A module named for a provider, e.g. `reviews/github-review.ts`. */ const FILENAME = new RegExp(`(^|/)(${PROVIDERS})[^/]*\\.ts$`); const ALLOWED: string[] = [ @@ -44,7 +44,6 @@ const ALLOWED: string[] = [ "db/schema.ts", "index.ts", "integrations/github-webhook.ts", - "integrations/github.ts", "integrations/openrouter-decisions.ts", "integrations/openrouter.ts", "integrations/slack-blocks.ts", @@ -62,8 +61,6 @@ const ALLOWED: string[] = [ "reviews/control-plane.ts", "reviews/github-review.ts", "reviews/target-hydrator.ts", - "routes/github-events.ts", - "routes/slack-events.ts", "routes/slack-interactivity.ts", "rpc/automations.ts", "rpc/integration.ts", diff --git a/orchestrator/src/__tests__/slack-events-route.test.ts b/orchestrator/src/__tests__/slack-events-route.test.ts index e0c49fba7..da1fbb403 100644 --- a/orchestrator/src/__tests__/slack-events-route.test.ts +++ b/orchestrator/src/__tests__/slack-events-route.test.ts @@ -9,12 +9,14 @@ import { expect, test, describe } from "bun:test"; import { createHmac } from "node:crypto"; -import { makeSlackEventsRoute } from "../routes/slack-events.ts"; +import { makeIntegrationEventsRoute } from "../routes/integration-events.ts"; +import { slackEventSource } from "../providers/slack/events.ts"; import { fakeIngress } from "./github-events-route.test.ts"; const SECRET = "test-signing-secret"; const PATH = "/api/v1/integrations/slack/events"; -const app = makeSlackEventsRoute({ signingSecret: async () => SECRET }); +const secrets = { resolve: async () => SECRET }; +const app = makeIntegrationEventsRoute({ sources: [slackEventSource], secrets }); function signed(body: string, ts = String(Math.floor(Date.now() / 1000))) { const sig = "v0=" + createHmac("sha256", SECRET).update(`v0:${ts}:${body}`).digest("hex"); @@ -63,14 +65,14 @@ describe("the ingress spine", () => { }); } - async function post(app: ReturnType, channel: string) { + async function post(app: ReturnType, channel: string) { const body = mentionBody(channel); return app.request(PATH, { method: "POST", body, headers: signed(body) }); } test("an app_mention is ledgered, handed to the dispatch seam, and acked 200", async () => { const ingress = fakeIngress(); - const app = makeSlackEventsRoute({ signingSecret: async () => SECRET, ingress: ingress.deps }); + const app = makeIntegrationEventsRoute({ sources: [slackEventSource], secrets, ingress: ingress.deps }); const res = await post(app, "C1"); expect(res.status).toBe(200); expect(ingress.dispatched.map((d) => [d.eventKey, d.scopeValue])).toEqual([["app_mention", "C1"]]); @@ -78,7 +80,7 @@ describe("the ingress spine", () => { test("a message in a group direct message is ledgered with its conversation as the scope", async () => { const ingress = fakeIngress(); - const app = makeSlackEventsRoute({ signingSecret: async () => SECRET, ingress: ingress.deps }); + const app = makeIntegrationEventsRoute({ sources: [slackEventSource], secrets, ingress: ingress.deps }); const body = JSON.stringify({ type: "event_callback", team_id: "T1", @@ -92,7 +94,7 @@ describe("the ingress spine", () => { test("a bot message subtype is skipped by the spine and still acked 200", async () => { const ingress = fakeIngress(); - const app = makeSlackEventsRoute({ signingSecret: async () => SECRET, ingress: ingress.deps }); + const app = makeIntegrationEventsRoute({ sources: [slackEventSource], secrets, ingress: ingress.deps }); const body = JSON.stringify({ type: "event_callback", team_id: "T1", diff --git a/orchestrator/src/__tests__/slack-integration.test.ts b/orchestrator/src/__tests__/slack-integration.test.ts index c03b2013b..63a22a346 100644 --- a/orchestrator/src/__tests__/slack-integration.test.ts +++ b/orchestrator/src/__tests__/slack-integration.test.ts @@ -10,7 +10,7 @@ import { create } from "@bufbuild/protobuf"; import { parseConnector, connectorRegistry, invalidateRegistry } from "../connectors/registry.ts"; import { makeIntegrationOauthRoute } from "../routes/integration-oauth.ts"; -import { getSlackClient, SLACK_SIGNING_SECRET_REF } from "../integrations/slack.ts"; +import { getSlackClient } from "../integrations/slack.ts"; import { invalidateIntegrationClient } from "../integrations/clients.ts"; import { ResolveIntegrationCredentialResponseSchema, @@ -215,11 +215,10 @@ describe("oauth facet parse", () => { expect(() => parseConnector({ ...base, oauth: partial }, "x")).toThrow(/clientSecretRef/); }); - test("the built-in slack seed declares the signing-secret ref (ADR 0060 triggers)", () => { - // The webhook verifier (getSlackSigningSecret) reads this ref; the connect UI - // seals it. Both MUST agree on the name — guard against drift here. + test("the slack seed seals and verifies with the same signing-secret ref", () => { const slack = connectorRegistry().get("slack"); - expect(slack?.oauth?.signingSecretRef).toBe(SLACK_SIGNING_SECRET_REF); + expect(slack?.oauth?.signingSecretRef).toBe("slack.signing_secret"); + expect(slack?.webhook?.ingress?.secretRef).toBe(slack?.oauth?.signingSecretRef); }); test("parses an optional signingSecretRef on the oauth facet", () => { diff --git a/orchestrator/src/__tests__/slack-interactivity-route.test.ts b/orchestrator/src/__tests__/slack-interactivity-route.test.ts index 8389aaaf9..c375b8961 100644 --- a/orchestrator/src/__tests__/slack-interactivity-route.test.ts +++ b/orchestrator/src/__tests__/slack-interactivity-route.test.ts @@ -20,7 +20,7 @@ const ROUTE = { team: "T1", channel: "C1", threadRoot: "100.0" }; function makeApp() { const calls: { answers: unknown[]; modals: unknown[] } = { answers: [], modals: [] }; const app = makeSlackInteractivityRoute({ - signingSecret: async () => SECRET, + secrets: { resolve: async () => SECRET }, deliverAnswer: async (route, answer) => void calls.answers.push({ route, answer }), openModal: async (triggerId, view) => void calls.modals.push({ triggerId, view }), }); diff --git a/orchestrator/src/__tests__/vanta-events-route.test.ts b/orchestrator/src/__tests__/vanta-events-route.test.ts index 1228d0ad9..3b249c11a 100644 --- a/orchestrator/src/__tests__/vanta-events-route.test.ts +++ b/orchestrator/src/__tests__/vanta-events-route.test.ts @@ -6,7 +6,8 @@ import { createHmac } from "node:crypto"; import { describe, expect, test } from "bun:test"; -import { makeVantaEventsRoute, vantaEventKey } from "../routes/vanta-events.ts"; +import { makeIntegrationEventsRoute } from "../routes/integration-events.ts"; +import { vantaEventKey, vantaEventSource } from "../providers/vanta/events.ts"; import type { RecordIntegrationEventInput } from "../db/integration-events.ts"; const RAW_SECRET = Buffer.from("vanta-test-signing-secret-bytes"); @@ -28,7 +29,8 @@ async function deliver( ): Promise { const recorded: RecordIntegrationEventInput[] = []; const body = JSON.stringify(payload); - const app = makeVantaEventsRoute({ + const app = makeIntegrationEventsRoute({ + sources: [vantaEventSource], secrets: { resolve: async () => SECRET }, now: () => NOW, ingress: { @@ -119,7 +121,8 @@ describe("vanta ingress", () => { const body = JSON.stringify({ type: "v1.evidence.created", evidence: { id: "e1" } }); const id = "msg_multi"; const timestamp = String(Math.floor(NOW.getTime() / 1000)); - const app = makeVantaEventsRoute({ + const app = makeIntegrationEventsRoute({ + sources: [vantaEventSource], secrets: { resolve: async () => SECRET }, now: () => NOW, ingress: { diff --git a/orchestrator/src/auth/iap-bridge.ts b/orchestrator/src/auth/iap-bridge.ts index f7f349b44..71d6e9fff 100644 --- a/orchestrator/src/auth/iap-bridge.ts +++ b/orchestrator/src/auth/iap-bridge.ts @@ -116,6 +116,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; import { auth } from "./better-auth.ts"; import { config } from "../config.ts"; +import { declaredIngress } from "../connectors/registry.ts"; import { extractApiKey } from "./api-key-header.ts"; // --------------------------------------------------------------------------- @@ -134,26 +135,15 @@ import { extractApiKey } from "./api-key-header.ts"; * (401), which would mark the pod / backend perpetually unhealthy. * /healthz only reports {ok, db}, so it is unauthenticated by design. * - * 2. Inbound provider webhooks (ADR 0060/0100/0119). Slack, GitHub and - * Linear POSTs carry no IAP assertion and no session cookie — in prod - * they reach the orchestrator through a no-IAP GCLB backend (see - * deploy/helm values-iap overlay), so the fail-closed path would 401 - * them before the handler runs. These routes do NOT rely on the bridge - * for auth: each verifies the provider's own signature (Slack signing - * secret + timestamp window, GitHub's X-Hub-Signature-256 HMAC, or - * Linear's hex Linear-Signature HMAC plus a webhookTimestamp replay - * window) and rejects a bad signature with 401 itself. The bridge would - * only get in the way, so we exempt the exact webhook paths. Dynamic - * automation hooks additionally require POST plus exactly one validated - * registration slug; the load balancer's broader Prefix rule is not - * trusted as the application authorization boundary. - * The strings must stay in lockstep with the routes' `app.post(...)` - * paths in orchestrator/src/routes/{slack-events,slack-interactivity, - * github-events,linear-events,hooks}.ts — AND with the ingress path list - * in the deploy repo's values. A path exempted in only one of the two - * places stays closed: the GCLB answers "Invalid IAP credentials: empty - * token" before the pod is ever reached, which is how the Linear route - * shipped dark (prod 2026-08-29: zero Linear deliveries ever ledgered). + * 2. Inbound provider webhooks. They carry no IAP assertion and no + * session cookie, and reach the orchestrator through a no-IAP load + * balancer backend. Each route verifies the provider's own signature and + * answers 401 itself. A provider's event path is public when its + * connector declares a webhook ingress. Dynamic automation hooks require + * POST plus exactly one validated registration slug; the load balancer's + * broader prefix rule is not the authorization boundary. A path must + * also be in the deployment's ingress path list: exempted in only one + * place, it stays closed at the load balancer. * * 3. The device-authorization CLI endpoints (`engrams auth login`). The CLI * requests a code and polls for the token from a bare terminal — no IAP @@ -168,10 +158,7 @@ import { extractApiKey } from "./api-key-header.ts"; */ const PUBLIC_PATHS: ReadonlySet = new Set([ "/healthz", - "/api/v1/integrations/slack/events", "/api/v1/integrations/slack/interactivity", - "/api/v1/integrations/github/events", - "/api/v1/integrations/linear/events", "/api/auth/device/code", "/api/auth/device/token", ]); @@ -181,12 +168,15 @@ const PUBLIC_GET_PATHS: ReadonlySet = new Set([ "/api/v1/integrations/google-cloud/oidc/jwks", ]); +const EVENT_PATH_RE = /^\/api\/v1\/integrations\/([a-z0-9_]+)\/events$/; const HOOK_PATH_RE = /^\/api\/v1\/hooks\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; /** Pure seam for exact-path public ingress tests (query strings are ignored). */ export function isIapPublicPath(url: string, method = "GET"): boolean { const path = url.split("?", 1)[0] ?? "/"; + const eventProvider = EVENT_PATH_RE.exec(path)?.[1]; return PUBLIC_PATHS.has(path) || + (eventProvider !== undefined && declaredIngress(eventProvider) !== undefined) || (method === "GET" && PUBLIC_GET_PATHS.has(path)) || (method === "POST" && HOOK_PATH_RE.test(path)); } diff --git a/orchestrator/src/automations/__tests__/verify.test.ts b/orchestrator/src/automations/__tests__/verify.test.ts index e1373a16f..6f4d0c6e1 100644 --- a/orchestrator/src/automations/__tests__/verify.test.ts +++ b/orchestrator/src/automations/__tests__/verify.test.ts @@ -10,6 +10,7 @@ import { import { makeVerificationSecretResolver, resetVerificationSecretCache, + verifyIngress, verifyWebhook, } from "../verify.ts"; @@ -30,9 +31,6 @@ function digest(value: string | Uint8Array): string { } describe("webhook verification strategies", () => { - // Only the generic scheme survives ADR 0119 D5; the provider schemes - // (github_hmac_sha256, slack_v0) are verified on the integration ingress - // routes and can no longer be stored on a registration. test("generic_hmac_sha256 accepts a valid signature", () => { expect(verify("generic_hmac_sha256", { "x-engrams-signature-256": `sha256=${digest(BODY)}`, @@ -53,6 +51,69 @@ describe("webhook verification strategies", () => { }); }); +describe("provider ingress verification", () => { + const NOW = new Date("2026-08-21T12:00:00Z"); + const seconds = String(Math.floor(NOW.getTime() / 1000)); + + function ingress( + scheme: "github_hmac_sha256" | "slack_v0" | "linear_hmac_sha256" | "svix_hmac_sha256", + headers: Record, + secret = SECRET, + ): Promise { + return verifyIngress({ + provider: "example", + ingress: { scheme, secretRef: "example.webhook_secret" }, + headers: new Headers(headers), + rawBody: BODY, + secrets: { resolve: async () => secret }, + now: NOW, + }); + } + + test("github_hmac_sha256 reads the prefixed X-Hub-Signature-256", async () => { + const header = "x-hub-signature-256"; + expect(await ingress("github_hmac_sha256", { [header]: `sha256=${digest(BODY)}` })).toBe(true); + expect(await ingress("github_hmac_sha256", { [header]: `sha256=${digest("other")}` })).toBe(false); + expect(await ingress("github_hmac_sha256", { [header]: "sha256=short" })).toBe(false); + expect(await ingress("github_hmac_sha256", { [header]: encodeURI("💥") })).toBe(false); + expect(await ingress("github_hmac_sha256", {})).toBe(false); + }); + + test("linear_hmac_sha256 reads the bare hex Linear-Signature", async () => { + expect(await ingress("linear_hmac_sha256", { "linear-signature": digest(BODY) })).toBe(true); + expect(await ingress("linear_hmac_sha256", { "linear-signature": `sha256=${digest(BODY)}` })) + .toBe(false); + expect(await ingress("linear_hmac_sha256", {})).toBe(false); + }); + + test("slack_v0 signs the timestamp with the body and refuses a stale one", async () => { + const sign = (ts: string) => ({ + "x-slack-request-timestamp": ts, + "x-slack-signature": `v0=${digest(Buffer.concat([Buffer.from(`v0:${ts}:`), BODY]))}`, + }); + expect(await ingress("slack_v0", sign(seconds))).toBe(true); + expect(await ingress("slack_v0", sign(String(Number(seconds) - 3600)))).toBe(false); + expect(await ingress("slack_v0", {})).toBe(false); + }); + + test("svix_hmac_sha256 signs id, timestamp, and body under the decoded secret", async () => { + const key = Buffer.from("svix-test-key"); + const secret = `whsec_${key.toString("base64")}`; + const sign = (ts: string) => ({ + "svix-id": "msg_1", + "svix-timestamp": ts, + "svix-signature": `v1,${createHmac("sha256", key) + .update(`msg_1.${ts}.`) + .update(BODY) + .digest("base64")}`, + }); + expect(await ingress("svix_hmac_sha256", sign(seconds), secret)).toBe(true); + expect(await ingress("svix_hmac_sha256", sign(String(Number(seconds) - 3600)), secret)) + .toBe(false); + expect(await ingress("svix_hmac_sha256", {}, secret)).toBe(false); + }); +}); + describe("webhook verification secret resolution", () => { beforeEach(() => resetVerificationSecretCache()); diff --git a/orchestrator/src/automations/integration-ingress.ts b/orchestrator/src/automations/integration-ingress.ts index 7086d9d13..52eeb3a33 100644 --- a/orchestrator/src/automations/integration-ingress.ts +++ b/orchestrator/src/automations/integration-ingress.ts @@ -1,15 +1,10 @@ -/** Per-provider integration-event ingress spine (ADR 0119 D5). +/** The ingress spine every provider event delivery passes through: bounded + * body → verify → parse → extract → redact → ledger → dispatch. * - * One shared pipeline for every provider event route: bounded body → verify → - * parse → extract → redact → ledger → dispatch. Verification lives with the - * integration (each route supplies its provider's verifier and secret); the - * spine owns the ledger write and the dispatch hand-off. Dispatch to - * automations is an injectable seam that stays a no-op until trigger matching - * lands (stack item 2.C) — `setIntegrationEventDispatch` installs it. - * - * The spine never swallows a provider's legacy behavior: routes receive the - * raw body and parsed payload back and keep running their existing paths - * (PR-review classifier, Slack thread brain) unchanged. + * The caller supplies the verifier and the provider's extract step. The spine + * owns the ledger write and the dispatch hand-off, and returns the raw body + * and parsed payload so the provider can do its own work on the delivery. + * `setIntegrationEventDispatch` installs the trigger dispatcher at boot. */ import { makeIntegrationConnectionStore } from "../db/integration-connections.ts"; @@ -50,6 +45,19 @@ export interface IntegrationEventRoute { extract(headers: Headers, payload: Record): IngressOutcome; } +/** A provider's part of the event route: what a verified delivery means. + * The route verifies it against the connector's declared ingress. */ +export interface IntegrationEventSource { + provider: string; + extract(headers: Headers, payload: Record, now: Date): IngressOutcome; + /** Provider work on a verified delivery, after the ledger write and the + * dispatch. A returned response replaces the route's 200. */ + afterDelivery?( + delivery: Extract, + headers: Headers, + ): Promise; +} + export interface IntegrationEventDispatchInput { provider: string; connectionId: string; @@ -65,8 +73,7 @@ export type IntegrationEventDispatch = ( input: IntegrationEventDispatchInput, ) => Promise; -/** The 2.C seam: trigger matching installs itself here at boot. Until then a - * verified delivery is ledgered and goes nowhere else. */ +/** Until the dispatcher is installed a verified delivery is only ledgered. */ let integrationEventDispatch: IntegrationEventDispatch = async () => undefined; export function setIntegrationEventDispatch(dispatch: IntegrationEventDispatch): void { @@ -89,7 +96,7 @@ export type DeliveryResult = | { kind: "rejected"; response: Response } /** Provider handshake answered — return the response as-is. */ | { kind: "responded"; response: Response } - /** Verified but not ledgered; the route continues its legacy paths. */ + /** Verified but not ledgered. */ | { kind: "skipped"; reason: string; rawBody: Uint8Array; payload?: Record } /** Verified, redacted, ledgered, dispatched. */ | { @@ -101,9 +108,7 @@ export type DeliveryResult = event: ExtractedIntegrationEvent; connectionId: string; /** What the trigger dispatcher did with the delivery (undefined until - * the dispatcher is installed at boot). A legacy route decides its - * own fallback from THIS — the dispatcher's fresh read — so the two - * brains can never disagree on who owns the delivery. */ + * the dispatcher is installed at boot). */ dispatch: DispatchIntegrationResult | undefined; }; @@ -152,8 +157,7 @@ export async function handleIntegrationDelivery( payload = parseWebhookPayload(rawBody); } catch (error) { if (!(error instanceof WebhookEventError)) throw error; - // Signed but unparseable: tolerate, exactly like the legacy GitHub path — - // the route's own classifier decides what a non-JSON body means. + // Signed but unparseable: the provider decides what a non-JSON body means. return { kind: "skipped", reason: `unparseable payload: ${error.message}`, rawBody }; } diff --git a/orchestrator/src/automations/verify.ts b/orchestrator/src/automations/verify.ts index 33b9f01cf..bd8007763 100644 --- a/orchestrator/src/automations/verify.ts +++ b/orchestrator/src/automations/verify.ts @@ -1,7 +1,10 @@ -/** Webhook verification strategies and sealed-secret resolution (ADR 0102). */ +/** Webhook signature schemes and sealed-secret resolution. */ import { createHmac, timingSafeEqual } from "node:crypto"; +import { isValidSlackRequest } from "@slack/bolt"; + +import type { WebhookIngressSpec } from "../connectors/registry.ts"; import { integrationOp as defaultIntegrationOp } from "../control-plane/client.ts"; import type { WebhookVerification } from "../db/schema.ts"; import { asBearer } from "../integrations/clients.ts"; @@ -87,17 +90,58 @@ function sha256Signature(secret: string, body: Uint8Array): string { return createHmac("sha256", secret).update(body).digest("hex"); } -/** Bare hex-encoded HMAC-SHA256 of the raw body (no scheme prefix) — the - * Linear-Signature format (ADR 0119 D5). Timing-safe with a length pre-check. */ -export function verifyHexHmacSha256( - secret: string, - rawBody: Uint8Array, - actual: string | null, -): boolean { - return timingSafeHeader(sha256Signature(secret, rawBody), actual); +/** Svix's documented replay window. */ +const SVIX_TOLERANCE_MS = 5 * 60_000; + +export interface VerifyIngressInput { + provider: string; + ingress: WebhookIngressSpec; + headers: Headers; + rawBody: Uint8Array; + secrets: VerificationSecretResolver; + now: Date; +} + +/** Verify a delivery on a provider's event route against the scheme and + * sealed secret its connector declares. Throws when the secret cannot be read. */ +export async function verifyIngress(input: VerifyIngressInput): Promise { + const { headers, rawBody } = input; + const secret = await input.secrets.resolve({ + provider: input.provider, + secretRef: input.ingress.secretRef, + }); + switch (input.ingress.scheme) { + case "github_hmac_sha256": + return timingSafeHeader( + `sha256=${sha256Signature(secret, rawBody)}`, + headers.get("x-hub-signature-256"), + ); + case "linear_hmac_sha256": + return timingSafeHeader(sha256Signature(secret, rawBody), headers.get("linear-signature")); + case "slack_v0": + return isValidSlackRequest({ + signingSecret: secret, + body: new TextDecoder().decode(rawBody), + headers: { + "x-slack-signature": headers.get("x-slack-signature") ?? "", + "x-slack-request-timestamp": Number(headers.get("x-slack-request-timestamp") ?? 0), + }, + nowMilliseconds: input.now.getTime(), + }); + case "svix_hmac_sha256": + return verifySvixSignature({ + secret, + rawBody, + id: headers.get("svix-id"), + timestamp: headers.get("svix-timestamp"), + signature: headers.get("svix-signature"), + now: input.now.getTime(), + toleranceMs: SVIX_TOLERANCE_MS, + }); + } } -export interface SvixSignatureInput { +interface SvixSignatureInput { /** The endpoint secret as the dashboard shows it (`whsec_`); a bare * base64 secret is accepted too. */ secret: string; @@ -112,12 +156,11 @@ export interface SvixSignatureInput { toleranceMs: number; } -/** The Svix webhook signature (Vanta): HMAC-SHA256 over - * `${id}.${timestamp}.${body}` under the base64-decoded secret, base64 - * encoded, compared against every `v1,` entry of the header (a rotation - * sends the old and the new). A timestamp outside the tolerance is a - * replay. Timing-safe per entry. */ -export function verifySvixSignature(input: SvixSignatureInput): boolean { +/** The Svix webhook signature: HMAC-SHA256 over `${id}.${timestamp}.${body}` + * under the base64-decoded secret, base64 encoded, compared against every + * `v1,` entry of the header (a rotation sends the old and the new). A + * timestamp outside the tolerance is a replay. */ +function verifySvixSignature(input: SvixSignatureInput): boolean { if (!input.id || !input.timestamp || !input.signature) return false; if (!/^\d{1,12}$/.test(input.timestamp)) return false; const sentMs = Number(input.timestamp) * 1000; @@ -137,9 +180,8 @@ export function verifySvixSignature(input: SvixSignatureInput): boolean { return false; } -/** Custom-registration verification: the generic scheme only. Provider - * schemes retired with ADR 0119 D5 — GitHub/Slack/Linear deliveries arrive on - * the integration ingress routes and verify with the connection's secret. */ +/** Verify a delivery to a custom webhook registration. Provider deliveries + * use {@link verifyIngress}. */ export function verifyWebhook(input: VerifyWebhookInput): boolean { switch (input.verification.scheme) { case "generic_hmac_sha256": diff --git a/orchestrator/src/connectors/registry.ts b/orchestrator/src/connectors/registry.ts index 985deb4ca..bbd499889 100644 --- a/orchestrator/src/connectors/registry.ts +++ b/orchestrator/src/connectors/registry.ts @@ -2535,6 +2535,15 @@ export function connectorRegistry(): Map { return cachedRegistry; } +/** The ingress a built-in connector declares, if any. */ +export function declaredIngress( + provider: string, +): { displayName: string; ingress: WebhookIngressSpec } | undefined { + const connector = connectorRegistry().get(provider); + const ingress = connector?.webhook?.ingress; + return connector && ingress ? { displayName: connector.display.name, ingress } : undefined; +} + // --------------------------------------------------------------------------- // Full registry = built-in seeds ∪ admin-authored (DB) connectors (ADR 0057 C1) // --------------------------------------------------------------------------- diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index f964f5c50..bcf1e425a 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -15,11 +15,8 @@ import meRoute from "./routes/me.ts"; import adminRoute from "./routes/admin.ts"; import integrationOpRoute from "./routes/integration-op.ts"; import integrationOauthRoute from "./routes/integration-oauth.ts"; -import slackEventsRoute from "./routes/slack-events.ts"; +import integrationEventsRoute from "./routes/integration-events.ts"; import slackInteractivityRoute from "./routes/slack-interactivity.ts"; -import githubEventsRoute from "./routes/github-events.ts"; -import linearEventsRoute from "./routes/linear-events.ts"; -import vantaEventsRoute from "./routes/vanta-events.ts"; import hooksRoute from "./routes/hooks.ts"; import reviewsDispatchRoute from "./routes/reviews-dispatch.ts"; import { makeGoogleOidcRoute } from "./routes/google-oidc.ts"; @@ -441,17 +438,9 @@ app.route("/", adminRoute); app.route("/", integrationOpRoute); // OAuth acquisition for connectors with an `oauth` facet (e.g. Slack "Add to Slack"). app.route("/", integrationOauthRoute); -// ADR 0060: Slack external-trigger webhooks (events + interactivity). Both -// verify every request with the SDK against the slack.signing_secret org secret. -app.route("/", slackEventsRoute); +// Provider event webhooks, verified against each connector's declared ingress. +app.route("/", integrationEventsRoute); app.route("/", slackInteractivityRoute); -// ADR 0100: GitHub's signed webhook and the bearer-authenticated CI trigger -// converge on the same durable per-PR workflow. -app.route("/", githubEventsRoute); -// Linear webhook ingress (ADR 0119 D5) — new with the integration-event ledger. -app.route("/", linearEventsRoute); -// Vanta webhook ingress: Svix-signed deliveries. -app.route("/", vantaEventsRoute); // ADR 0102: dynamically registered webhooks verify their own registration // secret and dispatch one-shot AutomationRunWorkflow occurrences. app.route("/", hooksRoute); diff --git a/orchestrator/src/integrations/github.ts b/orchestrator/src/integrations/github.ts deleted file mode 100644 index d8dee3824..000000000 --- a/orchestrator/src/integrations/github.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** GitHub App webhook credential resolution and signature verification. */ - -import { createHmac, timingSafeEqual } from "node:crypto"; - -import { integrationOp as defaultIntegrationOp } from "../control-plane/client.ts"; -import { asBearer } from "./clients.ts"; -import type { RunOpDeps } from "./run-op.ts"; - -export const GITHUB_PROVIDER = "github"; -export const GITHUB_WEBHOOK_SECRET_REF = "github.webhook_secret"; - -const SIGNING_TTL_MS = 5 * 60_000; -let signingCache: { value: string; expiresAt: number } | undefined; - -export async function getGithubWebhookSecret(deps?: RunOpDeps): Promise { - const now = Date.now(); - if (signingCache && signingCache.expiresAt > now) return signingCache.value; - const client = deps?.integrationOp ?? defaultIntegrationOp; - const resp = await client.resolveIntegrationCredential({ - provider: GITHUB_PROVIDER, - credential: { - source: "inject", - mintProvider: "", - injects: [{ - header: "x-github-webhook-signing", - template: "{}", - secretRef: GITHUB_WEBHOOK_SECRET_REF, - }], - }, - }); - if (!resp.credential) { - throw new Error("github.webhook_secret is not configured"); - } - const value = asBearer(resp.credential); - signingCache = { value, expiresAt: now + SIGNING_TTL_MS }; - return value; -} - -/** Test seam: drop the cached webhook secret. */ -export function __resetGithubWebhookSecretCache(): void { - signingCache = undefined; -} - -/** GitHub's documented X-Hub-Signature-256 HMAC verification scheme. */ -export function verifyGithubSignature( - secret: string, - rawBody: string | Uint8Array, - signatureHeader: string | undefined, -): boolean { - if (signatureHeader == null) return false; - const expected = Buffer.from( - `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`, - ); - const actual = Buffer.from(signatureHeader); - if (actual.length !== expected.length) return false; - return timingSafeEqual(actual, expected); -} diff --git a/orchestrator/src/integrations/slack.ts b/orchestrator/src/integrations/slack.ts index 6b43f25bc..0d5e36d3f 100644 --- a/orchestrator/src/integrations/slack.ts +++ b/orchestrator/src/integrations/slack.ts @@ -11,7 +11,6 @@ import { WebClient } from "@slack/web-api"; import { registerIntegrationClient, asBearer, getIntegrationClient } from "./clients.ts"; -import { integrationOp as defaultIntegrationOp } from "../control-plane/client.ts"; import type { RunOpDeps } from "./run-op.ts"; export const SLACK_PROVIDER = "slack"; @@ -23,38 +22,3 @@ registerIntegrationClient(SLACK_PROVIDER, (cred) => new WebClient(asBearer(cred) export function getSlackClient(deps?: RunOpDeps): Promise { return getIntegrationClient(SLACK_PROVIDER, deps); } - -/** - * The Slack request-signing secret (ADR 0060) — an org secret - * `slack.signing_secret`, set through the SAME integration settings as the bot - * token / client creds. Resolved coordinator-side via the credential path (the - * only orchestrator secret-read path; org secrets are otherwise write-only), - * then cached: it's stable, and the events/interactivity webhooks verify every - * request against it. Not a bespoke env var. - */ -export const SLACK_SIGNING_SECRET_REF = "slack.signing_secret"; -const SIGNING_TTL_MS = 5 * 60_000; -let signingCache: { value: string; expiresAt: number } | undefined; - -export async function getSlackSigningSecret(deps?: RunOpDeps): Promise { - const now = Date.now(); - if (signingCache && signingCache.expiresAt > now) return signingCache.value; - const client = deps?.integrationOp ?? defaultIntegrationOp; - const resp = await client.resolveIntegrationCredential({ - provider: SLACK_PROVIDER, - credential: { - source: "inject", - mintProvider: "", - injects: [{ header: "x-slack-signing", template: "{}", secretRef: SLACK_SIGNING_SECRET_REF }], - }, - }); - if (!resp.credential) throw new Error("slack.signing_secret is not configured"); - const value = asBearer(resp.credential); - signingCache = { value, expiresAt: now + SIGNING_TTL_MS }; - return value; -} - -/** Test seam: drop the cached signing secret. */ -export function __resetSlackSigningSecretCache(): void { - signingCache = undefined; -} diff --git a/orchestrator/src/providers/github/events.ts b/orchestrator/src/providers/github/events.ts new file mode 100644 index 000000000..0fe1ca15a --- /dev/null +++ b/orchestrator/src/providers/github/events.ts @@ -0,0 +1,167 @@ +/** GitHub App webhook deliveries. + * + * The event route ledgers each delivery and dispatches it to automations (the + * PR review built-in among them). This module adds the two things that are + * not an automation: it keeps the dossier of an already-reviewed PR fresh, + * and it runs the `stop` mention command. + */ + +import { config } from "../../config.ts"; +import { + makeReviewStore, + type ReviewStore, + type UpsertReviewTargetInput, +} from "../../db/reviews.ts"; +import { + classifyGithubEvent, + parseReviewCommand, +} from "../../integrations/github-webhook.ts"; +import type { IntegrationEventSource } from "../../automations/integration-ingress.ts"; +import { ownPath } from "../../automations/paths.ts"; +import { log as rootLog } from "../../log.ts"; +import { + stopAutomationReview, + type ReviewCoordinate, + type StopAutomationReviewResult, +} from "../../reviews/automation-review.ts"; + +const log = rootLog.child({ component: "github-webhook" }); +const AUTHORIZED_COMMENT_ASSOCIATIONS: ReadonlySet = new Set([ + "OWNER", + "MEMBER", + "COLLABORATOR", +]); + +export interface GithubEventsDeps { + /** Halt the built-in run behind the PR's active pass. The built-in's own + * admission filters the stop comment out, so this reaches the run directly. */ + stopAutomationReview?: (input: ReviewCoordinate) => Promise; + /** Refresh a target only when it already exists: a PR action must never + * create a dossier for a pull request engrams has not reviewed. */ + refreshTarget?: (input: UpsertReviewTargetInput) => Promise; + /** The review App's @-mention handle (its slug). Blank disables mention commands. */ + mentionHandle?: string; +} + +async function refreshExistingTarget( + reviews: ReviewStore, + input: UpsertReviewTargetInput, +): Promise { + const existing = await reviews.getTargetForRefresh(input); + if (!existing) return false; + if (existing.providerId === null) { + await reviews.claimTargetId({ + provider: input.provider, + providerId: input.providerId, + repo: input.repo, + number: input.number, + }); + } + await reviews.upsertTarget(input); + return true; +} + +const ok = (): Response => new Response(null, { status: 200 }); + +export function githubEventSource(deps: GithubEventsDeps = {}): IntegrationEventSource { + const mentionHandle = deps.mentionHandle ?? config.githubAppLogin; + const stopAutomation = deps.stopAutomationReview ?? stopAutomationReview; + let reviewStore: ReviewStore | undefined; + const refreshTarget = + deps.refreshTarget ?? + ((input) => refreshExistingTarget((reviewStore ??= makeReviewStore()), input)); + + return { + provider: "github", + // Tolerant (skip, never reject): `afterDelivery` classifies every signed + // request itself. + extract: (headers, payload) => { + const base = headers.get("x-github-event"); + if (!base) return { kind: "skip", reason: "missing x-github-event" }; + const action = payload["action"]; + if (action !== undefined && (typeof action !== "string" || action === "")) { + return { kind: "skip", reason: "non-string payload action" }; + } + const deliveryId = headers.get("x-github-delivery"); + if (!deliveryId) return { kind: "skip", reason: "missing x-github-delivery" }; + const repo = ownPath(payload, "repository.full_name"); + return { + kind: "event", + event: { + eventKey: action === undefined ? base : `${base}.${action}`, + deliveryId, + ...(typeof repo === "string" ? { scopeValue: repo.toLowerCase() } : {}), + }, + }; + }, + + afterDelivery: async (delivery, headers) => { + const eventName = headers.get("x-github-event") ?? ""; + const event = classifyGithubEvent(eventName, new TextDecoder().decode(delivery.rawBody)); + if (event.kind === "ping" || event.kind === "ignore") return ok(); + + // Run ids derive from the delivery id, and DBOS dedupes notifications + // on the message id alone: an empty id would swallow every later send. + if (!headers.get("x-github-delivery")) { + log.warn({ event: eventName }, "github delivery carries no delivery id"); + return Response.json({ error: "missing x-github-delivery" }, { status: 400 }); + } + + if (event.kind === "pull_request") { + if (event.pr.providerId === null) { + log.warn( + { repo: event.repo, prNumber: event.prNumber, action: event.action }, + "github PR target refresh skipped because the payload had no provider id", + ); + return ok(); + } + const refreshed = await refreshTarget({ + provider: "github", + providerId: event.pr.providerId, + repo: event.repo, + number: event.prNumber, + title: event.pr.title, + author: event.pr.author, + state: event.pr.state, + url: event.pr.url, + providerUpdatedAt: event.pr.providerUpdatedAt, + }); + log.info( + { repo: event.repo, prNumber: event.prNumber, action: event.action, refreshed }, + "github PR action refreshed the review target", + ); + return ok(); + } + + const command = parseReviewCommand(event.body, mentionHandle); + if (!command) return ok(); + const where = { repo: event.repo, prNumber: event.prNumber, commentId: event.commentId }; + if (event.senderType === "Bot") { + log.info(where, "github command ignored from bot sender"); + return ok(); + } + if (!AUTHORIZED_COMMENT_ASSOCIATIONS.has(event.authorAssociation)) { + log.info( + { ...where, authorAssociation: event.authorAssociation }, + "github command ignored from unauthorized commenter", + ); + return ok(); + } + + switch (command.kind) { + case "review": + // The built-in's filter admits the review comment from the dispatch. + break; + case "stop": { + const result = await stopAutomation({ repo: event.repo, prNumber: event.prNumber }); + log.info({ ...where, ...result }, "github stop command halted the built-in run"); + break; + } + case "fix": + log.info(where, "github fix command is not supported yet"); + break; + } + return ok(); + }, + }; +} diff --git a/orchestrator/src/providers/linear/events.ts b/orchestrator/src/providers/linear/events.ts new file mode 100644 index 000000000..52494cef9 --- /dev/null +++ b/orchestrator/src/providers/linear/events.ts @@ -0,0 +1,49 @@ +/** Linear webhook deliveries. + * + * Event keys are `.` (issue.create, comment.create), + * matching the connector catalog. Linear's docs say to reject a delivery + * whose `webhookTimestamp` (unix ms) is more than a minute old. + */ + +import type { IntegrationEventSource } from "../../automations/integration-ingress.ts"; +import { ownPath } from "../../automations/paths.ts"; + +const TIMESTAMP_SKEW_MS = 60_000; + +export const linearEventSource: IntegrationEventSource = { + provider: "linear", + extract: (headers, payload, now) => { + const timestamp = payload["webhookTimestamp"]; + if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) { + return { kind: "reject", status: 401, error: "missing webhookTimestamp" }; + } + if (Math.abs(now.getTime() - timestamp) > TIMESTAMP_SKEW_MS) { + return { kind: "reject", status: 401, error: "stale webhookTimestamp" }; + } + // Run ids derive from the delivery id, and DBOS dedupes notifications on + // the message id alone, so an invented id would swallow later deliveries. + const deliveryId = headers.get("linear-delivery"); + if (!deliveryId) { + return { kind: "reject", status: 400, error: "missing Linear-Delivery" }; + } + const type = payload["type"]; + const action = payload["action"]; + if (typeof type !== "string" || type === "" || typeof action !== "string" || action === "") { + return { kind: "skip", reason: "payload without type/action" }; + } + // An issue carries its team at `data.team`. A child entity that embeds + // the issue (a comment) carries it at `data.issue.team`. `attachment.*` + // has only an `issueId` and `issuelabel.*` has no team, so both stay + // unscoped and a team-scoped trigger declines them. + const team = + ownPath(payload, "data.team.key") ?? ownPath(payload, "data.issue.team.key"); + return { + kind: "event", + event: { + eventKey: `${type.toLowerCase()}.${action}`, + deliveryId, + ...(typeof team === "string" ? { scopeValue: team } : {}), + }, + }; + }, +}; diff --git a/orchestrator/src/providers/slack/events.ts b/orchestrator/src/providers/slack/events.ts new file mode 100644 index 000000000..1e35ce4fb --- /dev/null +++ b/orchestrator/src/providers/slack/events.ts @@ -0,0 +1,60 @@ +/** Slack Events API deliveries. + * + * Slack retries a delivery that is not acknowledged within 3 seconds; the + * retry carries the same `event_id`, so the ledger write dedupes it. + */ + +import type { IntegrationEventSource } from "../../automations/integration-ingress.ts"; +import { ownPath } from "../../automations/paths.ts"; + +/** Inner event types the ledger accepts (the declared catalog keys). */ +const LEDGERED_EVENT_TYPES = new Set(["app_mention", "message", "reaction_added"]); + +export const slackEventSource: IntegrationEventSource = { + provider: "slack", + extract: (_headers, payload) => { + if (payload["type"] === "url_verification") { + const challenge = payload["challenge"]; + if (typeof challenge !== "string") { + return { kind: "reject", status: 400, error: "url_verification without challenge" }; + } + return { + kind: "respond", + response: new Response(challenge, { + status: 200, + headers: { "content-type": "text/plain" }, + }), + }; + } + if (payload["type"] !== "event_callback") { + return { kind: "skip", reason: "not an event_callback" }; + } + const event = payload["event"]; + if (typeof event !== "object" || event === null || Array.isArray(event)) { + return { kind: "skip", reason: "event_callback without event object" }; + } + const inner = event as Record; + const type = inner["type"]; + if (typeof type !== "string" || !LEDGERED_EVENT_TYPES.has(type)) { + return { kind: "skip", reason: `inner type ${String(type)} not ledgered` }; + } + // A plain user message has no subtype and no bot_id; edits, bot posts, + // and system messages do, and no trigger wants them. + if (type === "message" && (inner["subtype"] !== undefined || inner["bot_id"] !== undefined)) { + return { kind: "skip", reason: "message subtype/bot" }; + } + const deliveryId = payload["event_id"]; + if (typeof deliveryId !== "string" || deliveryId === "") { + return { kind: "skip", reason: "missing event_id" }; + } + const scope = ownPath(payload, "event.channel") ?? ownPath(payload, "event.item.channel"); + return { + kind: "event", + event: { + eventKey: type, + deliveryId, + ...(typeof scope === "string" ? { scopeValue: scope } : {}), + }, + }; + }, +}; diff --git a/orchestrator/src/providers/vanta/events.ts b/orchestrator/src/providers/vanta/events.ts new file mode 100644 index 000000000..99b854137 --- /dev/null +++ b/orchestrator/src/providers/vanta/events.ts @@ -0,0 +1,34 @@ +/** Vanta webhook deliveries, sent through Svix. + * + * `svix-id` is the delivery id; deliveries are at-least-once and dedupe on + * it. Vanta names an event `v1...` in the body's + * `type` (or `eventType`) field; the catalog key drops the `v1.` prefix. + */ + +import type { IntegrationEventSource } from "../../automations/integration-ingress.ts"; + +/** The catalog key for a Vanta event name: `v1.questionnaire.created` → + * `questionnaire.created`. Null when the name is not one Vanta would send. */ +export function vantaEventKey(name: unknown): string | null { + if (typeof name !== "string") return null; + const key = name.replace(/^v1\./, "").toLowerCase(); + return /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/.test(key) ? key : null; +} + +export const vantaEventSource: IntegrationEventSource = { + provider: "vanta", + extract: (headers, payload) => { + const deliveryId = headers.get("svix-id"); + if (!deliveryId) { + return { kind: "reject", status: 400, error: "missing svix-id" }; + } + const eventKey = vantaEventKey(payload["type"] ?? payload["eventType"]); + if (eventKey === null) { + return { + kind: "skip", + reason: `delivery without an event type (keys: ${Object.keys(payload).join(", ")})`, + }; + } + return { kind: "event", event: { eventKey, deliveryId } }; + }, +}; diff --git a/orchestrator/src/routes/github-events.ts b/orchestrator/src/routes/github-events.ts deleted file mode 100644 index fa03fdeb4..000000000 --- a/orchestrator/src/routes/github-events.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** GitHub App webhook edge: verify, classify, and durably dispatch (ADR 0100). */ - -import { Hono } from "hono"; - -import { config } from "../config.ts"; -import { - makeReviewStore, - type ReviewStore, - type UpsertReviewTargetInput, -} from "../db/reviews.ts"; -import { - classifyGithubEvent, - parseReviewCommand, -} from "../integrations/github-webhook.ts"; -import { - getGithubWebhookSecret, - verifyGithubSignature, -} from "../integrations/github.ts"; -import { - handleIntegrationDelivery, - type HandleDeliveryDeps, - type IntegrationEventRoute, -} from "../automations/integration-ingress.ts"; -import { ownPath } from "../automations/paths.ts"; -import { log as rootLog } from "../log.ts"; -import { - stopAutomationReview, - type ReviewCoordinate, - type StopAutomationReviewResult, -} from "../reviews/automation-review.ts"; - -const log = rootLog.child({ component: "github-webhook" }); -const AUTHORIZED_COMMENT_ASSOCIATIONS: ReadonlySet = new Set([ - "OWNER", - "MEMBER", - "COLLABORATOR", -]); - -export interface GithubEventsDeps { - webhookSecret?: () => Promise; - /** `@engrams stop`: halt the built-in run behind the PR's active pass (the - * built-in's own admission filters the stop comment out, so the route - * must reach the run directly). */ - stopAutomationReview?: (input: ReviewCoordinate) => Promise; - /** Refresh a target only when it already exists. Non-reviewing PR actions - * must never create dossiers for pull requests engrams has never reviewed. */ - refreshTarget?: (input: UpsertReviewTargetInput) => Promise; - /** Ingress-spine seams (ledger store, new-trigger dispatch, connection). */ - ingress?: HandleDeliveryDeps; - now?: () => Date; - /** The review App's @-mention handle (its slug). Defaults to the deployment's - * GITHUB_APP_LOGIN; blank disables mention commands. */ - mentionHandle?: string; -} - -export function makeGithubEventsRoute(deps: GithubEventsDeps = {}): Hono { - const webhookSecret = deps.webhookSecret ?? getGithubWebhookSecret; - const mentionHandle = deps.mentionHandle ?? config.githubAppLogin; - const stopAutomation = deps.stopAutomationReview ?? stopAutomationReview; - let reviewStore: ReviewStore | undefined; - const reviews = (): ReviewStore => (reviewStore ??= makeReviewStore()); - const refreshTarget = deps.refreshTarget ?? (async (input) => { - const existing = await reviews().getTargetForRefresh(input); - if (!existing) return false; - if (existing.providerId === null) { - await reviews().claimTargetId({ - provider: input.provider, - providerId: input.providerId, - repo: input.repo, - number: input.number, - }); - } - await reviews().upsertTarget(input); - return true; - }); - const now = deps.now ?? (() => new Date()); - const ingressDeps: HandleDeliveryDeps = { ...(deps.ingress ?? {}), now }; - - // The ingress spine (ADR 0119 D5): verify → ledger → new-trigger dispatch. - // Verification is the same App-webhook-secret HMAC as before; extraction is - // deliberately tolerant (skip, never reject) so the PR-review classifier - // below keeps its exact behavior for every signed request. - const ingressRoute: IntegrationEventRoute = { - provider: "github", - displayName: "GitHub (default)", - verify: async (headers, rawBody) => - verifyGithubSignature( - await webhookSecret(), - rawBody, - headers.get("x-hub-signature-256") ?? undefined, - ), - extract: (headers, payload) => { - const base = headers.get("x-github-event"); - if (!base) return { kind: "skip", reason: "missing x-github-event" }; - const action = payload["action"]; - if (action !== undefined && (typeof action !== "string" || action === "")) { - return { kind: "skip", reason: "non-string payload action" }; - } - const deliveryId = headers.get("x-github-delivery"); - if (!deliveryId) return { kind: "skip", reason: "missing x-github-delivery" }; - const repo = ownPath(payload, "repository.full_name"); - return { - kind: "event", - event: { - eventKey: action === undefined ? base : `${base}.${action}`, - deliveryId, - ...(typeof repo === "string" ? { scopeValue: repo.toLowerCase() } : {}), - }, - }; - }, - }; - - const app = new Hono(); - - app.post("/api/v1/integrations/github/events", async (c) => { - const delivery = await handleIntegrationDelivery(ingressRoute, c.req.raw, ingressDeps); - if (delivery.kind === "rejected" || delivery.kind === "responded") { - return delivery.response; - } - // The spine (ledger + integration-trigger dispatch) has run; the PR-review - // classifier below consumes the same verified body. - const rawBody = new TextDecoder().decode(delivery.rawBody); - - const event = classifyGithubEvent( - c.req.header("x-github-event") ?? "", - rawBody, - ); - if (event.kind === "ping") return c.body(null, 200); - if (event.kind === "ignore") return c.body(null, 200); - - // `X-GitHub-Delivery` is part of GitHub's documented webhook contract, and the - // signature check above already passed, so a request without one is malformed - // rather than merely unusual. Refuse it instead of inventing a key: there is - // nothing stable to deduplicate a redelivery against, and an empty key is - // actively destructive downstream — DBOS takes it as a real message id and its - // notifications table conflicts on that id ALONE, so the first empty-key send - // silently swallows every later one, for every review, until the row is - // deleted by hand. - const idempotencyKey = c.req.header("x-github-delivery"); - if (!idempotencyKey) { - log.warn( - { event: c.req.header("x-github-event") ?? "" }, - "github delivery carries no delivery id", - ); - return c.json({ error: "missing x-github-delivery" }, 400); - } - - if (event.kind === "pull_request") { - // The spine dispatched the delivery to the PR-review built-in already; - // its admission decides whether this action starts a pass. The route - // only keeps the dossier target fresh for a PR engrams has reviewed. - if (event.pr.providerId === null) { - log.warn( - { repo: event.repo, prNumber: event.prNumber, action: event.action }, - "github PR target refresh skipped because the payload had no provider id", - ); - return c.body(null, 200); - } - const refreshed = await refreshTarget({ - provider: "github", - providerId: event.pr.providerId, - repo: event.repo, - number: event.prNumber, - title: event.pr.title, - author: event.pr.author, - state: event.pr.state, - url: event.pr.url, - providerUpdatedAt: event.pr.providerUpdatedAt, - }); - log.info( - { repo: event.repo, prNumber: event.prNumber, action: event.action, refreshed }, - "github PR action refreshed the review target", - ); - return c.body(null, 200); - } - - const command = parseReviewCommand(event.body, mentionHandle); - if (!command) return c.body(null, 200); - if (event.senderType === "Bot") { - log.info( - { repo: event.repo, prNumber: event.prNumber, commentId: event.commentId }, - "github command ignored from bot sender", - ); - return c.body(null, 200); - } - if (!AUTHORIZED_COMMENT_ASSOCIATIONS.has(event.authorAssociation)) { - log.info( - { - repo: event.repo, - prNumber: event.prNumber, - commentId: event.commentId, - authorAssociation: event.authorAssociation, - }, - "github command ignored from unauthorized commenter", - ); - return c.body(null, 200); - } - - if (command.kind === "review") { - // The @mention delivery was ledgered and dispatched to the built-in, - // whose filter admits the `^@engrams review` comment. Nothing to start. - log.info( - { repo: event.repo, prNumber: event.prNumber }, - "github review command dispatched to the built-in by the spine", - ); - } else if (command.kind === "stop") { - const result = await stopAutomation({ repo: event.repo, prNumber: event.prNumber }); - log.info( - { repo: event.repo, prNumber: event.prNumber, ...result }, - "github stop command halted the built-in run", - ); - } else if (command.kind === "fix") { - log.info( - { repo: event.repo, prNumber: event.prNumber, commentId: event.commentId }, - "github fix command queued for a later PR", - ); - } - return c.body(null, 200); - }); - - return app; -} - -export default makeGithubEventsRoute(); diff --git a/orchestrator/src/routes/integration-events.ts b/orchestrator/src/routes/integration-events.ts new file mode 100644 index 000000000..23ba8446a --- /dev/null +++ b/orchestrator/src/routes/integration-events.ts @@ -0,0 +1,82 @@ +/** Provider event webhooks. + * + * POST /api/v1/integrations/:provider/events + * + * One route for every provider. The connector's `webhook.ingress` declares + * how a delivery is signed; the provider's event source says what a verified + * delivery means. The route answers 200 after the ledger write and the + * dispatch commit, so a crash before the answer is covered by the provider's + * retry and the delivery id dedupes it. + */ + +import { Hono } from "hono"; + +import { + handleIntegrationDelivery, + type HandleDeliveryDeps, + type IntegrationEventSource, +} from "../automations/integration-ingress.ts"; +import { + makeVerificationSecretResolver, + verifyIngress, + type VerificationSecretResolver, +} from "../automations/verify.ts"; +import { declaredIngress } from "../connectors/registry.ts"; +import { githubEventSource } from "../providers/github/events.ts"; +import { linearEventSource } from "../providers/linear/events.ts"; +import { slackEventSource } from "../providers/slack/events.ts"; +import { vantaEventSource } from "../providers/vanta/events.ts"; + +export interface IntegrationEventsDeps { + sources?: IntegrationEventSource[]; + secrets?: VerificationSecretResolver; + /** Ingress-spine seams (ledger store, trigger dispatch, connection). */ + ingress?: HandleDeliveryDeps; + now?: () => Date; +} + +export function makeIntegrationEventsRoute(deps: IntegrationEventsDeps = {}): Hono { + const secrets = deps.secrets ?? makeVerificationSecretResolver(); + const now = deps.now ?? (() => new Date()); + const ingressDeps: HandleDeliveryDeps = { now, ...deps.ingress }; + + const sources = new Map( + (deps.sources ?? [githubEventSource(), linearEventSource, slackEventSource, vantaEventSource]) + .map((source) => { + const declared = declaredIngress(source.provider); + if (!declared) { + throw new Error(`event source ${source.provider}: its connector declares no webhook ingress`); + } + return [source.provider, { source, ...declared }] as const; + }), + ); + + const app = new Hono(); + + app.post("/api/v1/integrations/:provider/events", async (c) => { + const entry = sources.get(c.req.param("provider")); + if (!entry) return c.notFound(); + const { source, displayName, ingress } = entry; + const headers = c.req.raw.headers; + + const delivery = await handleIntegrationDelivery( + { + provider: source.provider, + displayName: `${displayName} (default)`, + verify: (h, rawBody) => + verifyIngress({ provider: source.provider, ingress, headers: h, rawBody, secrets, now: now() }), + extract: (h, payload) => source.extract(h, payload, now()), + }, + c.req.raw, + ingressDeps, + ); + if (delivery.kind === "rejected" || delivery.kind === "responded") { + return delivery.response; + } + return (await source.afterDelivery?.(delivery, headers)) ?? c.body(null, 200); + }); + + return app; +} + +export default makeIntegrationEventsRoute(); diff --git a/orchestrator/src/routes/linear-events.ts b/orchestrator/src/routes/linear-events.ts deleted file mode 100644 index d23e0cc94..000000000 --- a/orchestrator/src/routes/linear-events.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Linear webhook endpoint (ADR 0119 D5). - * - * POST /api/v1/integrations/linear/events - * - * Linear signs each delivery with `Linear-Signature`: a bare hex HMAC-SHA256 - * of the raw body under the webhook signing secret (org secret - * `linear.webhook_secret` — the admin seals it when creating the webhook in - * Linear's UI). The payload carries `webhookTimestamp` (unix ms) and Linear's - * docs say to reject deliveries more than a minute old — replay guard. - * `Linear-Delivery` (a UUID) is the documented delivery id; a request without - * one is malformed, and an invented key would be actively destructive - * downstream (DBOS notifications dedupe on the message id alone), so it is - * refused — the same reasoning as the GitHub route's X-GitHub-Delivery check. - * - * Event keys are `.` (issue.create, issue.update, - * comment.create), matching the connector catalog. There is no legacy path - * below the spine — Linear ingress is new with this route. - */ - -import { Hono } from "hono"; - -import { - handleIntegrationDelivery, - type HandleDeliveryDeps, - type IntegrationEventRoute, -} from "../automations/integration-ingress.ts"; -import { ownPath } from "../automations/paths.ts"; -import { - makeVerificationSecretResolver, - verifyHexHmacSha256, - type VerificationSecretResolver, -} from "../automations/verify.ts"; - -export const LINEAR_WEBHOOK_SECRET_REF = "linear.webhook_secret"; -export const LINEAR_TIMESTAMP_SKEW_MS = 60_000; - -export interface LinearEventsDeps { - /** The webhook signing secret resolver (default = the sealed org-secret path). */ - secrets?: VerificationSecretResolver; - /** Ingress-spine seams (ledger store, new-trigger dispatch, connection). */ - ingress?: HandleDeliveryDeps; - now?: () => Date; -} - -export function makeLinearEventsRoute(deps: LinearEventsDeps = {}): Hono { - const secrets = deps.secrets ?? makeVerificationSecretResolver(); - const now = deps.now ?? (() => new Date()); - const ingressDeps: HandleDeliveryDeps = { ...(deps.ingress ?? {}), now }; - - const ingressRoute: IntegrationEventRoute = { - provider: "linear", - displayName: "Linear (default)", - verify: async (headers, rawBody) => { - const secret = await secrets.resolve({ - provider: "linear", - secretRef: LINEAR_WEBHOOK_SECRET_REF, - }); - return verifyHexHmacSha256(secret, rawBody, headers.get("linear-signature")); - }, - extract: (headers, payload) => { - // Replay guard per Linear's docs: webhookTimestamp is unix ms at the - // payload root; reject anything more than a minute from now. - const timestamp = payload["webhookTimestamp"]; - if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) { - return { kind: "reject", status: 401, error: "missing webhookTimestamp" }; - } - if (Math.abs(now().getTime() - timestamp) > LINEAR_TIMESTAMP_SKEW_MS) { - return { kind: "reject", status: 401, error: "stale webhookTimestamp" }; - } - const deliveryId = headers.get("linear-delivery"); - if (!deliveryId) { - return { kind: "reject", status: 400, error: "missing Linear-Delivery" }; - } - const type = payload["type"]; - const action = payload["action"]; - if (typeof type !== "string" || type === "" || typeof action !== "string" || action === "") { - return { kind: "skip", reason: "payload without type/action" }; - } - // Where the team lives depends on the entity. An issue payload carries - // it at `data.team`; a CHILD entity (a comment, and the other - // issue-scoped types) has no team on `data` at all and nests it under - // the issue instead. Reading only `data.team.key` left every comment - // delivery with NO scope, so a team-scoped trigger could never match one - // — it is dropped by the `event.scopeValue === undefined` arm of - // triggerMatches (dispatch.ts). Prod 2026-08-31: a CD-495 comment - // ledgered with an empty scope_value and started no run. - // - // Deliberate boundary: this covers the child entities that EMBED the - // issue (comments do). It does not cover `attachment.*`, which carries - // only an `issueId`, nor `issuelabel.*`, which is organization-level and - // has no team at all. Scoping an attachment would need an API lookup - // inside the 3s ack budget; those stay unscoped, so only a SCOPED - // trigger declines them. - const team = - ownPath(payload, "data.team.key") ?? ownPath(payload, "data.issue.team.key"); - return { - kind: "event", - event: { - eventKey: `${type.toLowerCase()}.${action}`, - deliveryId, - ...(typeof team === "string" ? { scopeValue: team } : {}), - }, - }; - }, - }; - - const app = new Hono(); - - app.post("/api/v1/integrations/linear/events", async (c) => { - const delivery = await handleIntegrationDelivery(ingressRoute, c.req.raw, ingressDeps); - if (delivery.kind === "rejected" || delivery.kind === "responded") { - return delivery.response; - } - return c.body(null, 200); - }); - - return app; -} - -export default makeLinearEventsRoute(); diff --git a/orchestrator/src/routes/slack-events.ts b/orchestrator/src/routes/slack-events.ts deleted file mode 100644 index 26c7c903c..000000000 --- a/orchestrator/src/routes/slack-events.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Slack Events API endpoint (ADR 0060 P2.8; ingress spine ADR 0119 D5). - * - * POST /api/v1/integrations/slack/events - * - * Verifies every request with the Slack SDK's standalone `isValidSlackRequest` - * (HMAC + staleness — no hand-rolled crypto). The ingress spine answers the - * url_verification challenge, ledgers eligible events (app_mention, plain - * message, reaction_added), and hands them to the trigger dispatch seam — the - * Slack thread built-in (and any automation on a Slack trigger) takes it from - * there. The route acks 200 once the ledger write and the dispatch commit - * (Invariant 3); a crash before the ack is covered by Slack's retry, whose - * event_id makes the ledger write a no-op — X-Slack-Retry-Num needs no - * special handling. No Slack API calls here, so the handler is trivially - * under the 3 s ack budget. - */ - -import { Hono } from "hono"; -import { isValidSlackRequest } from "@slack/bolt"; - -import { log as rootLog } from "../log.ts"; -import { getSlackSigningSecret } from "../integrations/slack.ts"; -import { - handleIntegrationDelivery, - type HandleDeliveryDeps, - type IntegrationEventRoute, -} from "../automations/integration-ingress.ts"; -import { ownPath } from "../automations/paths.ts"; - -/** Inner event types the ledger accepts (the declared catalog keys). */ -const LEDGERED_EVENT_TYPES = new Set(["app_mention", "message", "reaction_added"]); - -export interface SlackEventsDeps { - /** The Slack signing secret resolver (default = the org-secret resolver). */ - signingSecret?: () => Promise; - /** Ingress-spine seams (ledger store, new-trigger dispatch, connection). */ - ingress?: HandleDeliveryDeps; -} - -const log = rootLog.child({ component: "slack" }); - -export function makeSlackEventsRoute(deps: SlackEventsDeps = {}): Hono { - const signingSecret = deps.signingSecret ?? getSlackSigningSecret; - const ingressDeps: HandleDeliveryDeps = deps.ingress ?? {}; - - const ingressRoute: IntegrationEventRoute = { - provider: "slack", - displayName: "Slack (default)", - verify: async (headers, rawBody) => - isValidSlackRequest({ - signingSecret: await signingSecret(), - body: new TextDecoder().decode(rawBody), - headers: { - "x-slack-signature": headers.get("x-slack-signature") ?? "", - "x-slack-request-timestamp": Number(headers.get("x-slack-request-timestamp") ?? 0), - }, - }), - extract: (_headers, payload) => { - if (payload["type"] === "url_verification") { - const challenge = payload["challenge"]; - if (typeof challenge !== "string") { - return { kind: "reject", status: 400, error: "url_verification without challenge" }; - } - return { - kind: "respond", - response: new Response(challenge, { - status: 200, - headers: { "content-type": "text/plain" }, - }), - }; - } - if (payload["type"] !== "event_callback") { - return { kind: "skip", reason: "not an event_callback" }; - } - const event = payload["event"]; - if (typeof event !== "object" || event === null || Array.isArray(event)) { - return { kind: "skip", reason: "event_callback without event object" }; - } - const inner = event as Record; - const type = inner["type"]; - if (typeof type !== "string" || !LEDGERED_EVENT_TYPES.has(type)) { - return { kind: "skip", reason: `inner type ${String(type)} not ledgered` }; - } - // Edited/bot/system message subtypes are noise for triggers; the plain - // user message has no subtype and no bot_id. - if (type === "message" && (inner["subtype"] !== undefined || inner["bot_id"] !== undefined)) { - return { kind: "skip", reason: "message subtype/bot" }; - } - const deliveryId = payload["event_id"]; - if (typeof deliveryId !== "string" || deliveryId === "") { - return { kind: "skip", reason: "missing event_id" }; - } - const scope = ownPath(payload, "event.channel") ?? ownPath(payload, "event.item.channel"); - return { - kind: "event", - event: { - eventKey: type, - deliveryId, - ...(typeof scope === "string" ? { scopeValue: scope } : {}), - }, - }; - }, - }; - - const app = new Hono(); - - app.post("/api/v1/integrations/slack/events", async (c) => { - const retryNum = c.req.header("x-slack-retry-num"); - if (retryNum !== undefined) { - log.debug({ retryNum }, "slack: redelivery (event_id dedupes the ledger)"); - } - const delivery = await handleIntegrationDelivery(ingressRoute, c.req.raw, ingressDeps); - if (delivery.kind === "rejected" || delivery.kind === "responded") { - return delivery.response; - } - return c.body(null, 200); - }); - - return app; -} - -export default makeSlackEventsRoute(); diff --git a/orchestrator/src/routes/slack-interactivity.ts b/orchestrator/src/routes/slack-interactivity.ts index d9da8e75a..0a285b862 100644 --- a/orchestrator/src/routes/slack-interactivity.ts +++ b/orchestrator/src/routes/slack-interactivity.ts @@ -5,8 +5,7 @@ * * The single inbound surface for AskUserQuestion answers: an inline option * click, an "Answer…" click (→ open the answer modal), or the modal submission. - * Verifies every request with the SDK's `isValidSlackRequest` over the raw form - * body, classifies the `payload` field via the Block Kit contract, and: + * Verifies every request against the connector's declared ingress, classifies the `payload` field via the Block Kit contract, and: * - answer to a session's question (the route carries `sessionId`, contract * 5) → completes the session's tool call through the session relay; * - answer to a run's `slack_choice` card (the route carries `runId`) → a @@ -21,11 +20,10 @@ import { Hono } from "hono"; import { DBOS } from "@dbos-inc/dbos-sdk"; -import { isValidSlackRequest } from "@slack/bolt"; import type { ModalView } from "@slack/types"; import { log as rootLog } from "../log.ts"; -import { getSlackClient, getSlackSigningSecret } from "../integrations/slack.ts"; +import { getSlackClient } from "../integrations/slack.ts"; import { parseInteractivity, type ThreadRoute } from "../integrations/slack-blocks.ts"; import type { SourceAnswer } from "../workflows/communication-policy.ts"; import { @@ -33,10 +31,16 @@ import { assertIdempotencyKey, type AutomationInbox, } from "../automations/engine/inbox.ts"; +import { + makeVerificationSecretResolver, + verifyIngress, + type VerificationSecretResolver, +} from "../automations/verify.ts"; +import { declaredIngress } from "../connectors/registry.ts"; import { CHOICE_ID_PREFIX, SLACK_ANSWER_SIGNAL, deliverSessionAnswer } from "../automations/session-relay.ts"; export interface SlackInteractivityDeps { - signingSecret?: () => Promise; + secrets?: VerificationSecretResolver; /** Deliver an answer to the run whose relay posted the question. Default = * DBOS.send of a `slack_answer` signal (idempotent on tool_call_id). */ deliverAnswer?: (route: ThreadRoute, answer: SourceAnswer) => Promise; @@ -113,24 +117,26 @@ async function defaultOpenModal(triggerId: string, view: ModalView): Promise { - const rawBody = await c.req.text(); - const valid = isValidSlackRequest({ - signingSecret: await signingSecret(), - body: rawBody, - headers: { - "x-slack-signature": c.req.header("x-slack-signature") ?? "", - "x-slack-request-timestamp": Number(c.req.header("x-slack-request-timestamp") ?? 0), - }, + const rawBody = new Uint8Array(await c.req.arrayBuffer()); + const valid = await verifyIngress({ + provider: "slack", + ingress: declared.ingress, + headers: c.req.raw.headers, + rawBody, + secrets, + now: new Date(), }); if (!valid) return c.json({ error: "invalid signature" }, 401); - const payload = new URLSearchParams(rawBody).get("payload") ?? ""; + const payload = new URLSearchParams(new TextDecoder().decode(rawBody)).get("payload") ?? ""; const action = parseInteractivity(payload); switch (action.kind) { case "answer": diff --git a/orchestrator/src/routes/vanta-events.ts b/orchestrator/src/routes/vanta-events.ts deleted file mode 100644 index b0bcd7ddb..000000000 --- a/orchestrator/src/routes/vanta-events.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Vanta webhook endpoint. - * - * POST /api/v1/integrations/vanta/events - * - * Vanta delivers webhooks through Svix. Each delivery carries `svix-id` (the - * delivery id; deliveries are at-least-once and dedupe on it), - * `svix-timestamp` (unix seconds), and `svix-signature` (one or more - * space-separated `v1,` entries): an HMAC-SHA256 over - * `${id}.${timestamp}.${rawBody}` under the endpoint's signing secret, which - * the admin copies from Vanta's webhook dashboard into the org secret - * `vanta.webhook_secret`. Deliveries older than five minutes are replays and - * refused. An endpoint must answer 2xx within 15 seconds or Vanta retries. - * - * Vanta names an event `v1...`; the catalog key is - * that name without the `v1.` prefix (`questionnaire.status-changed`). The - * name is read from the delivery body's `type` (or `eventType`) field; a - * delivery that carries none cannot be routed and is skipped with its - * top-level keys in the reason so the gap shows in the log. - */ - -import { Hono } from "hono"; - -import { - handleIntegrationDelivery, - type HandleDeliveryDeps, - type IntegrationEventRoute, -} from "../automations/integration-ingress.ts"; -import { - makeVerificationSecretResolver, - verifySvixSignature, - type VerificationSecretResolver, -} from "../automations/verify.ts"; - -export const VANTA_WEBHOOK_SECRET_REF = "vanta.webhook_secret"; -export const VANTA_TIMESTAMP_SKEW_MS = 5 * 60_000; - -export interface VantaEventsDeps { - /** The webhook signing secret resolver (default = the sealed org-secret path). */ - secrets?: VerificationSecretResolver; - /** Ingress-spine seams (ledger store, new-trigger dispatch, connection). */ - ingress?: HandleDeliveryDeps; - now?: () => Date; -} - -/** The catalog key for a Vanta event name: `v1.questionnaire.created` → - * `questionnaire.created`. Null when the name is not one Vanta would send. */ -export function vantaEventKey(name: unknown): string | null { - if (typeof name !== "string") return null; - const key = name.replace(/^v1\./, "").toLowerCase(); - return /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/.test(key) ? key : null; -} - -export function makeVantaEventsRoute(deps: VantaEventsDeps = {}): Hono { - const secrets = deps.secrets ?? makeVerificationSecretResolver(); - const now = deps.now ?? (() => new Date()); - const ingressDeps: HandleDeliveryDeps = { ...(deps.ingress ?? {}), now }; - - const ingressRoute: IntegrationEventRoute = { - provider: "vanta", - displayName: "Vanta (default)", - verify: async (headers, rawBody) => { - const secret = await secrets.resolve({ - provider: "vanta", - secretRef: VANTA_WEBHOOK_SECRET_REF, - }); - return verifySvixSignature({ - secret, - rawBody, - id: headers.get("svix-id"), - timestamp: headers.get("svix-timestamp"), - signature: headers.get("svix-signature"), - now: now().getTime(), - toleranceMs: VANTA_TIMESTAMP_SKEW_MS, - }); - }, - extract: (headers, payload) => { - const deliveryId = headers.get("svix-id"); - if (!deliveryId) { - return { kind: "reject", status: 400, error: "missing svix-id" }; - } - const eventKey = vantaEventKey(payload["type"] ?? payload["eventType"]); - if (eventKey === null) { - return { - kind: "skip", - reason: `delivery without an event type (keys: ${Object.keys(payload).join(", ")})`, - }; - } - return { kind: "event", event: { eventKey, deliveryId } }; - }, - }; - - const app = new Hono(); - - app.post("/api/v1/integrations/vanta/events", async (c) => { - const delivery = await handleIntegrationDelivery(ingressRoute, c.req.raw, ingressDeps); - if (delivery.kind === "rejected" || delivery.kind === "responded") { - return delivery.response; - } - return c.body(null, 200); - }); - - return app; -} - -export default makeVantaEventsRoute(); From 058c4b883b79e11c90a5b0d5f6bef1145cbb5533 Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Wed, 11 Nov 2026 08:25:48 -0800 Subject: [PATCH 03/38] orchestrator: one model-router seam; OpenRouter is one implementation A provider declares a ModelRouterSpec: its connector, protocols, default and title models, catalog parser, model page URL, chat client, and an optional native decisions client. The registry derives the key's secret name from the connector. OpenRouter moves to providers/openrouter/ and is the only spec for now. Chokepoints, each replacing copies: - resolveRouterSelection checks router, harness protocol, and model for the profile, automation, and task-create RPCs. - connectedRouters is the only reader of "which routers have a key"; the inline secret listings in task-create and the model-routers RPC are gone. - model-routers/client.ts makes every in-process model call (task titles, the decide block) on the first connected router. No caller names a router. Behaviour that moved, not changed: - The decisions default model lives in the OpenRouter client, not in the decide block. The wire request is the same. - The decide block makes one call. The runtime returns null when no connected router answers, which replaces the separate connected() probe and its second listing of secret names. - The title consumer gets null from the model seam when no router is connected, which replaces its own connection check. The review pass removed the two duplicate secret-name client interfaces, an `as unknown as` cast on the org-secret client, and the hard-coded model page URL in the RPC. The guard list drops ten files. Co-Authored-By: Claude Fable 5.2 --- .../src/__tests__/model-routers.test.ts | 2 +- .../__tests__/openrouter-decisions.test.ts | 19 +++- .../src/__tests__/openrouter-region.test.ts | 10 +- .../src/__tests__/provider-literals.test.ts | 10 -- .../__tests__/slack-brain-interpreter.test.ts | 4 +- orchestrator/src/automations/draft-catalog.ts | 16 ++- .../engine/__tests__/decide-block.test.ts | 7 +- .../src/automations/engine/blocks/decide.ts | 17 ++- .../src/automations/engine/blocks/registry.ts | 9 +- orchestrator/src/automations/engine/deps.ts | 11 +- orchestrator/src/integrations/openrouter.ts | 52 --------- .../__tests__/title-consumer.test.ts | 12 +- orchestrator/src/listeners/title-consumer.ts | 27 ++--- orchestrator/src/model-routers/catalog.ts | 8 +- orchestrator/src/model-routers/client.ts | 78 +++++++++++++ orchestrator/src/model-routers/connected.ts | 66 +++++------ orchestrator/src/model-routers/decisions.ts | 69 +++++++++++ orchestrator/src/model-routers/registry.ts | 107 ++++++++++++++---- .../openrouter/decisions.ts} | 83 +++----------- .../openrouter/router.ts} | 37 +++++- orchestrator/src/rpc/automations.ts | 30 ++--- orchestrator/src/rpc/model-routers.ts | 68 +++++------ orchestrator/src/rpc/profiles.ts | 25 ++-- orchestrator/src/rpc/task-create.ts | 53 ++++----- orchestrator/src/rpc/tasks.ts | 5 +- .../tools/__tests__/automation-draft.test.ts | 14 +-- orchestrator/src/tools/automation-draft.ts | 10 +- orchestrator/src/workflows/automation-run.ts | 8 +- 28 files changed, 466 insertions(+), 391 deletions(-) delete mode 100644 orchestrator/src/integrations/openrouter.ts create mode 100644 orchestrator/src/model-routers/client.ts create mode 100644 orchestrator/src/model-routers/decisions.ts rename orchestrator/src/{integrations/openrouter-decisions.ts => providers/openrouter/decisions.ts} (52%) rename orchestrator/src/{model-routers/openrouter.ts => providers/openrouter/router.ts} (55%) diff --git a/orchestrator/src/__tests__/model-routers.test.ts b/orchestrator/src/__tests__/model-routers.test.ts index 602edccb5..252c9a6bf 100644 --- a/orchestrator/src/__tests__/model-routers.test.ts +++ b/orchestrator/src/__tests__/model-routers.test.ts @@ -3,7 +3,7 @@ import { Code, ConnectError, createClient, createRouterTransport } from "@connec import type { ModelRouterStore, RouterModelInput } from "../db/model-routers.ts"; import { refreshRouterCatalog } from "../model-routers/catalog.ts"; -import { parseOpenRouterCatalog } from "../model-routers/openrouter.ts"; +import { parseOpenRouterCatalog } from "../providers/openrouter/router.ts"; import { registerModelRouters } from "../rpc/model-routers.ts"; import { ModelRouterService, diff --git a/orchestrator/src/__tests__/openrouter-decisions.test.ts b/orchestrator/src/__tests__/openrouter-decisions.test.ts index ed289e77f..c3dcd9903 100644 --- a/orchestrator/src/__tests__/openrouter-decisions.test.ts +++ b/orchestrator/src/__tests__/openrouter-decisions.test.ts @@ -1,10 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { - DecisionsApiError, - makeDecisionsClient, - type DecisionsRequest, -} from "../integrations/openrouter-decisions.ts"; +import { DecisionsApiError, type DecisionsRequest } from "../model-routers/decisions.ts"; +import { makeDecisionsClient } from "../providers/openrouter/decisions.ts"; const REQUEST: DecisionsRequest = { model: "typesafe/jev-1.13", @@ -54,6 +51,18 @@ describe("decisions client", () => { expect(await seen[0]!.json()).toEqual(REQUEST); }); + test("a request without a model uses the pinned default", async () => { + const seen: Request[] = []; + const body = JSON.stringify({ + model: "typesafe/jev-1.13", + answers: {}, + usage: { input_tokens: 1, output_tokens: 1 }, + }); + const { model: _model, ...request } = REQUEST; + await clientReturning(200, body, seen).decide(request); + expect(((await seen[0]!.json()) as { model: string }).model).toBe("typesafe/jev-1.13"); + }); + test("rate limits and provider failures are retryable", async () => { for (const status of [429, 502, 503, 524, 529]) { const error = await failure(clientReturning(status, "{}").decide(REQUEST)); diff --git a/orchestrator/src/__tests__/openrouter-region.test.ts b/orchestrator/src/__tests__/openrouter-region.test.ts index 973b3ea4f..39117b95c 100644 --- a/orchestrator/src/__tests__/openrouter-region.test.ts +++ b/orchestrator/src/__tests__/openrouter-region.test.ts @@ -1,8 +1,8 @@ import { afterEach, expect, spyOn, test } from "bun:test"; import { create } from "@bufbuild/protobuf"; -import type { OpenRouterProvider } from "@openrouter/ai-sdk-provider"; -import type { DecisionsClient } from "../integrations/openrouter-decisions.ts"; -import "../integrations/openrouter.ts"; +import type { LanguageModelV3 } from "@ai-sdk/provider"; +import type { DecisionsClient } from "../model-routers/decisions.ts"; +import "../model-routers/client.ts"; import { getIntegrationClient, invalidateIntegrationClient } from "../integrations/clients.ts"; import { runIntegrationOp, type RunOpDeps } from "../integrations/run-op.ts"; import { compileIntegrationPolicy, connectorRegistry } from "../connectors/registry.ts"; @@ -66,8 +66,8 @@ test("SDK and Decisions requests follow a region change while the credential is try { for (const configured of [undefined, "us.openrouter.ai", "eu.openrouter.ai"]) { host = configured; - const clients = await getIntegrationClient<{ sdk: OpenRouterProvider; decisions: DecisionsClient }>("openrouter", deps); - await clients.sdk.chat("test-model").doGenerate({ + const clients = await getIntegrationClient<{ chat(modelId: string): LanguageModelV3; decisions: DecisionsClient }>("openrouter", deps); + await clients.chat("test-model").doGenerate({ prompt: [{ role: "user", content: [{ type: "text", text: "hello" }] }], }); await clients.decisions.decide({ model: "test-model", state: "hello", questions: {} }); diff --git a/orchestrator/src/__tests__/provider-literals.test.ts b/orchestrator/src/__tests__/provider-literals.test.ts index c96e2b4c7..d21dd3f33 100644 --- a/orchestrator/src/__tests__/provider-literals.test.ts +++ b/orchestrator/src/__tests__/provider-literals.test.ts @@ -28,14 +28,12 @@ const ALLOWED: string[] = [ "automations/builtins/slack-brain.ts", "automations/draft-catalog.ts", "automations/engine/blocks/agent.ts", - "automations/engine/blocks/decide.ts", "automations/engine/blocks/index.ts", "automations/engine/blocks/relay-close.ts", "automations/engine/blocks/relay.ts", "automations/engine/blocks/resolve-user.ts", "automations/engine/blocks/review.ts", "automations/engine/blocks/slack-choice.ts", - "automations/engine/deps.ts", "automations/handles.ts", "automations/profile-cards.ts", "automations/retire-provider-webhooks.ts", @@ -44,19 +42,13 @@ const ALLOWED: string[] = [ "db/schema.ts", "index.ts", "integrations/github-webhook.ts", - "integrations/openrouter-decisions.ts", - "integrations/openrouter.ts", "integrations/slack-blocks.ts", "integrations/slack-identity.ts", "integrations/slack-message-text.ts", "integrations/slack-policy.ts", "integrations/slack.ts", "listeners/pr-link-consumer.ts", - "listeners/title-consumer.ts", "log.ts", - "model-routers/catalog.ts", - "model-routers/openrouter.ts", - "model-routers/registry.ts", "reviews/automation-review.ts", "reviews/control-plane.ts", "reviews/github-review.ts", @@ -65,8 +57,6 @@ const ALLOWED: string[] = [ "rpc/automations.ts", "rpc/integration.ts", "specs/projection.ts", - "tools/automation-draft.ts", - "workflows/automation-run.ts", ]; function sourceFiles(dir: string): string[] { diff --git a/orchestrator/src/automations/builtins/__tests__/slack-brain-interpreter.test.ts b/orchestrator/src/automations/builtins/__tests__/slack-brain-interpreter.test.ts index 6e49ae9d5..ddfd33475 100644 --- a/orchestrator/src/automations/builtins/__tests__/slack-brain-interpreter.test.ts +++ b/orchestrator/src/automations/builtins/__tests__/slack-brain-interpreter.test.ts @@ -22,7 +22,7 @@ import { interpretAutomation } from "../../engine/interpreter.ts"; import type { CommunicationPolicy } from "../../../workflows/communication-policy.ts"; import { config } from "../../../config.ts"; import { SLACK_BRAIN_DEFINITION } from "../slack-brain.ts"; -import type { DecisionsRequest, DecisionsResponse } from "../../../integrations/openrouter-decisions.ts"; +import type { DecisionsRequest, DecisionsResponse } from "../../../model-routers/decisions.ts"; import type { ProfileCard } from "../../profile-cards.ts"; const card = (id: string, name: string, description: string): ProfileCard => ({ @@ -279,8 +279,8 @@ function harness(options: { clock: { nowMs: () => (clock += options.clockStepMs ?? 1000) }, code: makeCodeBlockRuntime(), decisions: { - connected: async () => options.decisions?.connected ?? true, async decide(request) { + if (options.decisions?.connected === false) return null; decisionRequests.push(request); const next = decisionReplies.shift(); if (!next) throw new Error("fake decisions exhausted"); diff --git a/orchestrator/src/automations/draft-catalog.ts b/orchestrator/src/automations/draft-catalog.ts index 9a8a678e1..0070ad81e 100644 --- a/orchestrator/src/automations/draft-catalog.ts +++ b/orchestrator/src/automations/draft-catalog.ts @@ -44,10 +44,9 @@ function jsonSchemaOf(schema: z.ZodType): unknown { } } -/** The block + definition catalog. Pure: derived from code, no I/O. - * `connectedRouters` are the model routers with a key; a block that needs - * another router is left out, so the agent never drafts one that cannot - * decide anything. */ +/** The block + definition catalog. Pure: derived from code, no I/O. Without + * `decisionsAvailable` the decide block is left out, so the agent never + * drafts one that cannot decide anything. */ /** The condition group a filter or a branch takes. Its zod schema is an * opaque refinement, so the JSON schema says nothing; this note does. */ const CONDITIONS_NOTE = @@ -55,15 +54,14 @@ const CONDITIONS_NOTE = 'path is a scope path ("event.raw.pull_request.user.type", "steps.facts.value.ok"); op is one of equals, not_equals, contains, matches, in, is_empty, gt, lt, is_true, is_false. ' + 'Example: {"mode":"all","conditions":[{"path":"event.raw.pull_request.user.type","op":"not_equals","value":"Bot"}]}.'; -export function draftBlockCatalog(connectedRouters: ReadonlySet = new Set()): Record { +export function draftBlockCatalog(decisionsAvailable = false): Record { registerEngineBlocks(); const blocks = listBlockTypes() .filter((type) => { // The agent is a part of the definition, never a block in a list; // its close step comes from a way in's `close` role. if (type === AGENT_BLOCK_TYPE || type === AGENT_CLOSE_BLOCK_TYPE) return false; - const router = getBlock(type)!.requiresRouter; - return router === undefined || connectedRouters.has(router); + return decisionsAvailable || !getBlock(type)!.requiresDecisions; }) .map((type) => { const executor = getBlock(type)!; @@ -112,9 +110,9 @@ export function draftBlockCatalog(connectedRouters: ReadonlySet = new Se 'Prose fields render Liquid with ${{ }}; structured values use {"$ref": "steps.."}.', 'Missing variables are render errors. `x | default: y` tolerates a missing x but NOT a missing y (the argument is strict); to pick the first present of several paths use `${{ event.raw | coalesce: "pull_request.number", "issue.number" }}`.', "At most one cron trigger per automation; extra entrypoints take integration, cron, or manual triggers.", - ...(connectedRouters.has("openrouter") + ...(decisionsAvailable ? [ - 'decide asks a decision model typed questions in one call; gate on its answers with a branch (`steps..answers..confidence`, `.value`, `.yes`). It outputs decided: false when the org has no OpenRouter key, so give the graph a fallback. list_profiles.options feeds a choice question directly (value = profile id).', + 'decide asks a decision model typed questions in one call; gate on its answers with a branch (`steps..answers..confidence`, `.value`, `.yes`). It outputs decided: false when no connected model router can answer, so give the graph a fallback. list_profiles.options feeds a choice question directly (value = profile id).', ] : []), 'create_session.profileId takes a profile id. Profile ids differ per org, so a portable definition reads it from a string input (`${{ inputs.profile }}`) the org fills in; an empty value fails the block.', diff --git a/orchestrator/src/automations/engine/__tests__/decide-block.test.ts b/orchestrator/src/automations/engine/__tests__/decide-block.test.ts index 636e8a167..beafaa85c 100644 --- a/orchestrator/src/automations/engine/__tests__/decide-block.test.ts +++ b/orchestrator/src/automations/engine/__tests__/decide-block.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { DecisionsApiError, type DecisionsRequest, type DecisionsResponse } from "../../../integrations/openrouter-decisions.ts"; +import { DecisionsApiError, type DecisionsRequest, type DecisionsResponse } from "../../../model-routers/decisions.ts"; import type { ProfileCard } from "../../profile-cards.ts"; import { profileOptions } from "../../profile-cards.ts"; import type { RunSnapshot } from "../context.ts"; @@ -103,8 +103,8 @@ function harness( const requests: DecisionsRequest[] = []; const replies = [...(opts.replies ?? [])]; const decisions: DecisionsRuntime = { - connected: async () => opts.connected ?? true, async decide(request) { + if (opts.connected === false) return null; requests.push(request); const next = replies.shift(); if (next === undefined) throw new Error("fake decisions exhausted"); @@ -177,7 +177,8 @@ describe("decide block", () => { expect(result.status).toBe("completed"); expect(h.requests).toHaveLength(1); const wire = h.requests[0]!; - expect(wire.model).toBe("typesafe/jev-1.13"); + // No model in the block: the router applies its own default. + expect(wire.model).toBeUndefined(); expect(wire.state).toEqual({ message: "the deploy pipeline is stuck on terraform apply" }); expect(wire.questions["profile"]).toEqual({ type: "choice", diff --git a/orchestrator/src/automations/engine/blocks/decide.ts b/orchestrator/src/automations/engine/blocks/decide.ts index 1c3603c65..6d24ce241 100644 --- a/orchestrator/src/automations/engine/blocks/decide.ts +++ b/orchestrator/src/automations/engine/blocks/decide.ts @@ -36,13 +36,10 @@ import type { DecisionQuestion, DecisionsResponse, DecisionText, -} from "../../../integrations/openrouter-decisions.ts"; +} from "../../../model-routers/decisions.ts"; import { registerBlock, type BlockOutcome } from "./registry.ts"; import { errorMessage } from "../errors.ts"; -/** Pinned so a model release never silently changes a graph's thresholds; - * an author opts into a newer one through `model`. */ -export const DEFAULT_DECISION_MODEL = "typesafe/jev-1.13"; export const MAX_DECIDE_QUESTIONS = 16; export const MAX_CHOICE_OPTIONS = 255; @@ -259,7 +256,7 @@ function message(error: unknown): string { export function registerDecideBlock(): void { registerBlock({ type: "decide", - requiresRouter: "openrouter", + requiresDecisions: true, outputs: ["decided", "reason", "model", "answers", "cost"], configSchema: decideConfigSchema, async execute(config, ctx): Promise { @@ -276,15 +273,12 @@ export function registerDecideBlock(): void { if (!prepared.ok) { return { kind: "error", code: "decide_config_invalid", message: prepared.error, retryable: false }; } - if (!(await runtime.connected())) { - return { kind: "ok", outputs: { decided: false, reason: "router_not_connected" } }; - } const request = { - model: config.model ?? DEFAULT_DECISION_MODEL, + ...(config.model !== undefined ? { model: config.model } : {}), state: config.state, questions: prepared.value.questions, }; - let response: DecisionsResponse; + let response: DecisionsResponse | null; try { try { response = await runtime.decide(request); @@ -303,6 +297,9 @@ export function registerDecideBlock(): void { retryable: isRetryable(error), }; } + if (!response) { + return { kind: "ok", outputs: { decided: false, reason: "router_not_connected" } }; + } const answers = mapAnswers(prepared.value, response.answers); if (!answers.ok) { if (config.onError === "undecided") { diff --git a/orchestrator/src/automations/engine/blocks/registry.ts b/orchestrator/src/automations/engine/blocks/registry.ts index 84eab5e55..87b2153c3 100644 --- a/orchestrator/src/automations/engine/blocks/registry.ts +++ b/orchestrator/src/automations/engine/blocks/registry.ts @@ -47,11 +47,10 @@ export interface BlockExecutor { refusesDryRun?: boolean; /** Documented for the UI/catalog; not enforced at runtime. */ outputs?: readonly string[]; - /** The model router (registry id) the block calls. Authoring surfaces - * (the composer catalog, the builder palette) offer the block only while - * that router has a key; the engine still runs it either way, so a saved - * graph keeps working when the key goes (the block degrades itself). */ - requiresRouter?: string; + /** The block asks a model router for a decision. Authoring surfaces offer + * it only while a connected router can answer; the engine runs it either + * way, and the block reports `decided: false` when none can. */ + requiresDecisions?: true; configSchema: z.ZodType; /** Data half: runs inside one DBOS step. Absent for pure waits. */ execute?(config: C, ctx: RunContext): Promise; diff --git a/orchestrator/src/automations/engine/deps.ts b/orchestrator/src/automations/engine/deps.ts index 5c09353d1..28a0d6b3e 100644 --- a/orchestrator/src/automations/engine/deps.ts +++ b/orchestrator/src/automations/engine/deps.ts @@ -10,7 +10,7 @@ import type { AutomationInbox } from "./inbox.ts"; import type { RunSnapshot } from "./context.ts"; import type { SessionRelayStore } from "../../db/session-relays.ts"; import type { SourceMention } from "../../workflows/communication-policy.ts"; -import type { DecisionsRequest, DecisionsResponse } from "../../integrations/openrouter-decisions.ts"; +import type { DecisionsRequest, DecisionsResponse } from "../../model-routers/decisions.ts"; import type { ProfileCard } from "../profile-cards.ts"; /** DBOS.runStep, injected. Retries are an ENGINE loop (attempt-scoped step @@ -261,12 +261,11 @@ export interface EnginePrRefLookup { } | null>; } -/** The decide block's seam: the OpenRouter Decisions API in production, a - * fake in tests and the simulator. */ +/** The decide block's seam: a connected model router in production, a fake + * in tests and the simulator. */ export interface DecisionsRuntime { - /** Whether the org has an OpenRouter key (isRouterConnected). */ - connected(): Promise; - decide(request: DecisionsRequest): Promise; + /** Null when no connected model router can answer. */ + decide(request: DecisionsRequest): Promise; } export interface EngineDeps { diff --git a/orchestrator/src/integrations/openrouter.ts b/orchestrator/src/integrations/openrouter.ts deleted file mode 100644 index da29d19fe..000000000 --- a/orchestrator/src/integrations/openrouter.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * OpenRouter Mode B client (ADR 0106): the coordinator unseals the org secret - * `openrouter.api_key` (declared in connectors/openrouter.json) and this - * adapter maps it onto the Vercel AI SDK provider (chat models) and the - * Decisions API client (System One models). clients.ts caches both and picks - * up a key rotation without a restart. - */ - -import { Code, ConnectError } from "@connectrpc/connect"; -import { createOpenRouter, type OpenRouterProvider } from "@openrouter/ai-sdk-provider"; - -import { asBearer, getIntegrationClient, registerIntegrationClient } from "./clients.ts"; -import { DecisionsApiError, makeDecisionsClient, type DecisionsClient } from "./openrouter-decisions.ts"; - -interface OpenRouterClients { - sdk: OpenRouterProvider; - decisions: DecisionsClient; -} - -registerIntegrationClient("openrouter", (cred, host): OpenRouterClients => { - const apiKey = asBearer(cred); - return { sdk: createOpenRouter({ apiKey, baseURL: `https://${host}/api/v1` }), decisions: makeDecisionsClient({ apiKey, host }) }; -}); - -/** A ready OpenRouter AI-SDK provider, authenticated with the org key. */ -export async function getOpenRouterClient(): Promise { - return (await getIntegrationClient("openrouter")).sdk; -} - -/** Control-plane codes a credential lookup can recover from on a retry. */ -const TRANSIENT_LOOKUP_CODES = new Set([ - Code.Unavailable, - Code.DeadlineExceeded, - Code.ResourceExhausted, - Code.Aborted, - Code.Internal, -]); - -/** A ready Decisions API client, authenticated with the org key. A - * transient failure to resolve the key (the control plane briefly - * unreachable) is a retryable DecisionsApiError, so the decide block's - * retry applies; a missing or rejected key stays permanent. */ -export async function getOpenRouterDecisions(): Promise { - try { - return (await getIntegrationClient("openrouter")).decisions; - } catch (error) { - if (error instanceof ConnectError && TRANSIENT_LOOKUP_CODES.has(error.code)) { - throw new DecisionsApiError(`resolve the OpenRouter key: ${error.rawMessage}`, null, true); - } - throw error; - } -} diff --git a/orchestrator/src/listeners/__tests__/title-consumer.test.ts b/orchestrator/src/listeners/__tests__/title-consumer.test.ts index 872cfd63b..d4421f2f6 100644 --- a/orchestrator/src/listeners/__tests__/title-consumer.test.ts +++ b/orchestrator/src/listeners/__tests__/title-consumer.test.ts @@ -27,7 +27,6 @@ function harness( const generated: string[] = []; const saved: Array<{ taskId: string; title: string }> = []; const consumer = makeTitleConsumer({ - routerConnected: async () => true, findTitleableTask: async () => task, generateTitle: async (prompt) => { generated.push(prompt); @@ -90,16 +89,11 @@ describe("title consumer", () => { expect(h.saved).toEqual([]); }); - test("no connected router: the raw title stands and the model is never called", async () => { - const generated: string[] = []; + test("no connected router: the raw title stands", async () => { const saved: string[] = []; const consumer = makeTitleConsumer({ - routerConnected: async () => false, findTitleableTask: async () => untitled, - generateTitle: async (prompt) => { - generated.push(prompt); - return "never"; - }, + generateTitle: async () => null, saveSuggestedTitle: async (_taskId, title) => { saved.push(title); }, @@ -108,14 +102,12 @@ describe("title consumer", () => { // Must not throw either — a closed gate is a quiet skip, not a // retryable failure the pump would redeliver. await consumer.handle(agentMessage("user", "fix the login button"), ctx); - expect(generated).toHaveLength(0); expect(saved).toHaveLength(0); }); test("ignores assistant messages without touching the DB or the model", async () => { let lookups = 0; const consumer = makeTitleConsumer({ - routerConnected: async () => true, findTitleableTask: async () => { lookups += 1; return untitled; diff --git a/orchestrator/src/listeners/title-consumer.ts b/orchestrator/src/listeners/title-consumer.ts index 139282f6d..9e06538cc 100644 --- a/orchestrator/src/listeners/title-consumer.ts +++ b/orchestrator/src/listeners/title-consumer.ts @@ -28,14 +28,12 @@ import { z } from "zod"; import type { CuratedEvent } from "../control-plane/session-events.ts"; import { getDb } from "../db/client.ts"; import { task as taskTable, taskSession as taskSessionTable } from "../db/schema.ts"; -import { getOpenRouterClient } from "../integrations/openrouter.ts"; -import { isRouterConnected } from "../model-routers/connected.ts"; +import { titleModel } from "../model-routers/client.ts"; import { log as rootLog } from "../log.ts"; import type { SessionConsumer } from "./consumer.ts"; const log = rootLog.child({ component: "title-consumer" }); -const TITLE_MODEL = "deepseek/deepseek-v4-flash"; /** Bound on the prompt text sent to the titling model. */ const PROMPT_SLICE_CHARS = 2000; @@ -81,12 +79,9 @@ export interface TitleConsumerDeps { * Null for child tasks, review workers, ownerless automations, and * unattributed sessions. */ findTitleableTask(sessionId: string): Promise; - /** Whether the model router behind generateTitle is connected. A - * false skips title generation entirely — the raw prompt title - * stands (fresh deployments have no router key). */ - routerConnected(): Promise; - /** Ask the model for the title. May throw. */ - generateTitle(prompt: string): Promise; + /** Ask the model for the title. Null when no model router is connected: + * the raw prompt title stands. May throw. */ + generateTitle(prompt: string): Promise; /** Write the title iff `suggested_title` is still NULL. */ saveSuggestedTitle(taskId: string, title: string): Promise; /** True for an error a retry cannot fix (bad key, bad model id). */ @@ -113,12 +108,7 @@ export function makeTitleConsumer(deps: TitleConsumerDeps): SessionConsumer { // must see a title written by an earlier event or a user rename. const task = await deps.findTitleableTask(ctx.sessionId); if (task === null || task.suggestedTitle != null || task.customTitle != null) return; - // No connected model router -> no title model to call. Keep the raw - // prompt title instead of walking generateTitle into a credential - // resolution that cannot succeed (a fresh deployment hits this on - // its very first task). - if (!(await deps.routerConnected())) return; - let title: string; + let title: string | null; try { title = await deps.generateTitle(prompt); } catch (err) { @@ -136,6 +126,7 @@ export function makeTitleConsumer(deps: TitleConsumerDeps): SessionConsumer { ); return; } + if (title === null) return; await deps.saveSuggestedTitle(task.taskId, title); log.info( { sessionId: ctx.sessionId, taskId: task.taskId, title }, @@ -171,11 +162,11 @@ export function makeProductionTitleConsumer(): SessionConsumer { ? { taskId: row.taskId, suggestedTitle: row.suggestedTitle, customTitle: row.customTitle } : null; }, - routerConnected: () => isRouterConnected("openrouter"), async generateTitle(prompt) { - const openrouter = await getOpenRouterClient(); + const model = await titleModel(); + if (model === null) return null; const { object } = await generateObject({ - model: openrouter.chat(TITLE_MODEL), + model, schema: titleSchema, system: TITLE_SYSTEM_PROMPT, prompt: prompt.slice(0, PROMPT_SLICE_CHARS), diff --git a/orchestrator/src/model-routers/catalog.ts b/orchestrator/src/model-routers/catalog.ts index 9d6e1c5f2..95e2962cc 100644 --- a/orchestrator/src/model-routers/catalog.ts +++ b/orchestrator/src/model-routers/catalog.ts @@ -3,7 +3,6 @@ import { runIntegrationOp } from "../integrations/run-op.ts"; import { errorMessage, log } from "../log.ts"; import { getModelRouterDefinition, listModelRouterDefinitions } from "./registry.ts"; import { isRouterConnected } from "./connected.ts"; -import { parseOpenRouterCatalog } from "./openrouter.ts"; export interface RouterCatalogRefreshResult { discovered: number; @@ -25,7 +24,7 @@ export async function refreshRouterCatalog( if (!router) throw new Error(`unknown model router "${routerId}"`); const now = (deps.now ?? (() => new Date()))(); try { - const response = await (deps.fetch ?? runIntegrationOp)(router.catalog.provider, { + const response = await (deps.fetch ?? runIntegrationOp)(router.connector, { method: "GET", path: router.catalog.path, }); @@ -33,7 +32,7 @@ export async function refreshRouterCatalog( throw new Error(`catalog returned HTTP ${response.status}`); } if (response.truncated) throw new Error("catalog response was truncated"); - const models = parseOpenRouterCatalog(response.body); + const models = router.catalog.parse(response.body).map((model) => ({ ...model, routerId })); if (models.length === 0) throw new Error("catalog contained no eligible models"); const { markedUnavailable } = await deps.store.replaceCatalog(routerId, models, now); log.info( @@ -57,9 +56,6 @@ export class ModelRouterCatalogRefresher { async runOnce(): Promise { await Promise.allSettled( listModelRouterDefinitions().map(async (router) => { - // A router without its credential is invisible to the product — - // refreshing its catalog would only hammer an API we cannot - // authenticate to and log failures nobody can act on. if (!(await isRouterConnected(router.id))) return; return refreshRouterCatalog(router.id, this.deps); }), diff --git a/orchestrator/src/model-routers/client.ts b/orchestrator/src/model-routers/client.ts new file mode 100644 index 000000000..4d504f170 --- /dev/null +++ b/orchestrator/src/model-routers/client.ts @@ -0,0 +1,78 @@ +/** Model calls the orchestrator makes itself, through a connected router. + * + * The coordinator unseals the router's key; `integrations/clients.ts` caches + * the client built from it and picks up a key rotation or a host change + * without a restart. + */ + +import { Code, ConnectError } from "@connectrpc/connect"; +import type { LanguageModel } from "ai"; + +import { asBearer, getIntegrationClient, registerIntegrationClient } from "../integrations/clients.ts"; +import { firstConnectedRouter } from "./connected.ts"; +import { + DecisionsApiError, + type DecisionsClient, + type DecisionsRequest, + type DecisionsResponse, +} from "./decisions.ts"; +import { listModelRouterDefinitions, type ModelRouterDefinition } from "./registry.ts"; + +interface RouterClient { + chat(modelId: string): LanguageModel; + decisions?: DecisionsClient; +} + +for (const router of listModelRouterDefinitions()) { + registerIntegrationClient(router.connector, (cred, host): RouterClient => { + const apiKey = asBearer(cred); + const decisions = router.decisions?.(apiKey, host); + return { chat: router.chat(apiKey, host), ...(decisions ? { decisions } : {}) }; + }); +} + +function clientFor(router: ModelRouterDefinition): Promise { + return getIntegrationClient(router.connector); +} + +/** The model that titles a task, on the first connected router; null when no + * router is connected. */ +export async function titleModel(): Promise { + const router = await firstConnectedRouter(); + return router ? (await clientFor(router)).chat(router.titleModel) : null; +} + +/** The first connected router that can answer a decisions request. */ +export function decisionsRouter(): Promise { + return firstConnectedRouter((router) => router.decisions !== undefined); +} + +/** Control-plane codes a key lookup can recover from on a retry. */ +const TRANSIENT_LOOKUP_CODES = new Set([ + Code.Unavailable, + Code.DeadlineExceeded, + Code.ResourceExhausted, + Code.Aborted, + Code.Internal, +]); + +/** Answer a decisions request on the first connected router that can; null + * when there is none. A transient failure to resolve the key is a retryable + * DecisionsApiError; a missing or rejected key stays permanent. */ +export async function decide(request: DecisionsRequest): Promise { + const router = await decisionsRouter(); + if (!router) return null; + let client: RouterClient; + try { + client = await clientFor(router); + } catch (error) { + if (error instanceof ConnectError && TRANSIENT_LOOKUP_CODES.has(error.code)) { + throw new DecisionsApiError(`resolve the ${router.label} key: ${error.rawMessage}`, null, true); + } + throw error; + } + if (!client.decisions) { + throw new DecisionsApiError(`${router.label} has no decisions endpoint`, null, false); + } + return client.decisions.decide(request); +} diff --git a/orchestrator/src/model-routers/connected.ts b/orchestrator/src/model-routers/connected.ts index d7ac71235..92f2fef49 100644 --- a/orchestrator/src/model-routers/connected.ts +++ b/orchestrator/src/model-routers/connected.ts @@ -1,28 +1,18 @@ -/** - * "Is this model router connected?" — the single predicate every - * OpenRouter-adjacent surface gates on. +/** Which model routers are connected. * - * A router is connected when its credential org secret (e.g. - * `openrouter.api_key`) exists in the org-secret store. Until an admin - * saves that key, the router must be invisible to the product: no Route - * option in the composer or profile editor, no catalog refresh hitting - * the router's API, no title generation or profile picking through it — - * a fresh deployment runs entirely on direct harness credentials. - * (Found on the first fresh-deployment walk: a router surface that - * assumed the key existed put a consumer into an infinite retry against - * a secret nobody had created.) + * A router is connected when its key's org secret exists. Until an admin + * saves that key the router is invisible to the product: no route option in + * the composer or the profile editor, no catalog refresh, no title + * generation or decisions through it. * - * Deliberately uncached: the callers are low-frequency (a task's first - * prompt, a Slack routing decision, a six-hourly catalog refresh) and - * the check is one control-plane NAME listing. A cache here would need - * cross-replica invalidation to avoid serving a stale "not connected" - * right after an admin saves the key — complexity the call volume does - * not buy back. + * Uncached on purpose: the callers are low-frequency and the check is one + * listing of secret names. A cache would need cross-replica invalidation to + * avoid a stale "not connected" right after an admin saves the key. */ import { orgSecret as defaultOrgSecret } from "../control-plane/client.ts"; -import { getModelRouterDefinition } from "./registry.ts"; import { log as rootLog } from "../log.ts"; +import { listModelRouterDefinitions, type ModelRouterDefinition } from "./registry.ts"; const log = rootLog.child({ component: "model-routers" }); @@ -30,21 +20,33 @@ export interface SecretNameClient { listSecrets(req: Record): Promise<{ secrets: Array<{ name: string }> }>; } -/** Whether `routerId`'s credential secret exists. Unknown routers are - * never connected. Fails OPEN to false: if the control plane is - * unreachable the product behaves as "no routers connected", which - * every caller treats as a graceful degrade — never a crash loop. */ -export async function isRouterConnected( - routerId: string, +/** The connected routers, in registry order. Throws when the secret names + * cannot be listed. */ +export async function connectedRouters( client: SecretNameClient = defaultOrgSecret, -): Promise { - const definition = getModelRouterDefinition(routerId); - if (!definition) return false; +): Promise { + const names = new Set((await client.listSecrets({})).secrets.map((secret) => secret.name)); + return listModelRouterDefinitions().filter((router) => names.has(router.credentialSecret)); +} + +/** The first connected router that `wanted` accepts, or null. An unreachable + * control plane reads as "none connected": callers degrade, they do not + * retry against a key that may not exist. */ +export async function firstConnectedRouter( + wanted: (router: ModelRouterDefinition) => boolean = () => true, + client?: SecretNameClient, +): Promise { try { - const res = await client.listSecrets({}); - return res.secrets.some((secret) => secret.name === definition.credentialSecret); + return (await connectedRouters(client)).find(wanted) ?? null; } catch (err) { - log.warn({ err, routerId }, "org-secret listing failed; treating the router as not connected"); - return false; + log.warn({ err }, "org-secret listing failed; treating every router as not connected"); + return null; } } + +export async function isRouterConnected( + routerId: string, + client?: SecretNameClient, +): Promise { + return (await firstConnectedRouter((router) => router.id === routerId, client)) !== null; +} diff --git a/orchestrator/src/model-routers/decisions.ts b/orchestrator/src/model-routers/decisions.ts new file mode 100644 index 000000000..de2d42f17 --- /dev/null +++ b/orchestrator/src/model-routers/decisions.ts @@ -0,0 +1,69 @@ +/** The decisions contract: a `state` plus typed `questions` in, calibrated + * typed answers out. A router answers it through a native endpoint when it + * has one. + */ + +import { z } from "zod"; + +/** A JSON value the API accepts as `state`, `instructions` or a criterion. */ +export type DecisionText = string | Record | unknown[]; + +export type DecisionQuestion = + | { type: "noul"; instructions: DecisionText; criteria?: { true: DecisionText; false: DecisionText } } + | { type: "choice"; instructions: DecisionText; criteria: Record } + | { type: "score"; instructions: DecisionText; criteria: DecisionText[] }; + +export interface DecisionsRequest { + /** Default: the router's decision model. */ + model?: string; + state: DecisionText; + questions: Record; +} + +const answerSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("noul"), noul: z.number() }), + z.object({ + type: z.literal("choice"), + choice: z.string(), + confidence: z.number().optional(), + probabilities: z.record(z.string(), z.number()).optional(), + }), + z.object({ + type: z.literal("score"), + score: z.number(), + confidence: z.number().optional(), + probabilities: z.record(z.string(), z.number()).optional(), + legend: z.record(z.string(), z.unknown()).optional(), + }), +]); +export type DecisionAnswer = z.infer; + +export const decisionsResponseSchema = z.object({ + id: z.string().optional(), + model: z.string(), + answers: z.record(z.string(), answerSchema), + usage: z.object({ + input_tokens: z.number(), + output_tokens: z.number(), + cost: z.number().optional(), + }), +}); +export type DecisionsResponse = z.infer; + +/** A failed decisions call. `retryable` follows the documented status codes: + * rate limits, upstream/provider failures and timeouts are worth a retry; a + * bad request, a bad key, no credits or a too-large payload are not. */ +export class DecisionsApiError extends Error { + constructor( + message: string, + readonly status: number | null, + readonly retryable: boolean, + ) { + super(message); + this.name = "DecisionsApiError"; + } +} + +export interface DecisionsClient { + decide(request: DecisionsRequest): Promise; +} diff --git a/orchestrator/src/model-routers/registry.ts b/orchestrator/src/model-routers/registry.ts index 16e3327f8..9edf4a23c 100644 --- a/orchestrator/src/model-routers/registry.ts +++ b/orchestrator/src/model-routers/registry.ts @@ -1,38 +1,58 @@ +/** The model routers a session can launch through. */ + +import type { LanguageModel } from "ai"; + +import { connectorRegistry } from "../connectors/registry.ts"; +import type { RouterModelInput } from "../db/model-routers.ts"; +import { OPENROUTER } from "../providers/openrouter/router.ts"; +import type { DecisionsClient } from "./decisions.ts"; + export const ROUTER_PROTOCOLS = ["anthropic_messages", "openai_responses"] as const; export type RouterProtocol = (typeof ROUTER_PROTOCOLS)[number]; -export interface ModelRouterDefinition { +export type CatalogModel = Omit; + +/** What a provider declares to be a model router. */ +export interface ModelRouterSpec { id: string; label: string; description: string; - credentialSecret: string; + /** The connector that holds the router's key and host. */ + connector: string; protocols: Readonly>; + /** The model a routed launch uses when neither the task nor the profile names one. */ defaultModel: string; + /** The model that titles a task from its first prompt. */ + titleModel: string; catalog: { - provider: string; path: string; refreshIntervalMs: number; + parse(body: Uint8Array): CatalogModel[]; }; + /** The public page for a model. */ + modelUrl(modelId: string): string; + /** Chat models for calls the orchestrator makes itself. */ + chat(apiKey: string, host: string): (modelId: string) => LanguageModel; + /** Present when the router has a native decisions endpoint. */ + decisions?(apiKey: string, host: string): DecisionsClient; } -const OPENROUTER: ModelRouterDefinition = { - id: "openrouter", - label: "OpenRouter", - description: "Shared access to tool-capable models through OpenRouter.", - credentialSecret: "openrouter.api_key", - protocols: { - anthropic_messages: { basePath: "/api" }, - openai_responses: { basePath: "/api/v1" }, - }, - defaultModel: "deepseek/deepseek-v4-pro-0813", - catalog: { - provider: "openrouter", - path: "/api/v1/models/user?output_modalities=text&supported_parameters=tools&sort=newest", - refreshIntervalMs: 6 * 60 * 60 * 1000, - }, -}; +export interface ModelRouterDefinition extends ModelRouterSpec { + /** The org secret that holds the key: the connector's one injected secret. */ + credentialSecret: string; +} + +function define(spec: ModelRouterSpec): ModelRouterDefinition { + const credential = connectorRegistry().get(spec.connector)?.credential; + const injects = credential?.source === "inject" ? credential.injects : []; + const secretRef = injects.length === 1 ? injects[0]?.secretRef : undefined; + if (!secretRef) { + throw new Error(`model router ${spec.id}: connector ${spec.connector} must inject exactly one secret`); + } + return { ...spec, credentialSecret: secretRef }; +} -const REGISTRY = new Map([[OPENROUTER.id, OPENROUTER]]); +const REGISTRY = new Map([OPENROUTER].map((spec) => [spec.id, define(spec)])); export function listModelRouterDefinitions(): ModelRouterDefinition[] { return [...REGISTRY.values()]; @@ -50,3 +70,50 @@ export function selectRouterProtocol( (protocol) => protocol in router.protocols && harnessProtocols.includes(protocol), ) ?? null; } + +export type RouterSelection = + | { ok: true; router: ModelRouterDefinition; protocol: RouterProtocol } + | { + ok: false; + field: "modelRouter" | "model"; + code: "unknown_router" | "unsupported_router" | "unknown_model"; + message: string; + }; + +/** Check that `routerId` is registered, that the harness can speak to it, + * and that `model`, when given, is in the router's catalog. */ +export async function resolveRouterSelection(input: { + routerId: string; + harness: string; + harnessProtocols: readonly string[]; + /** A model to look up in the router's catalog. */ + model?: { id: string; exists(routerId: string, modelId: string): Promise }; +}): Promise { + const router = getModelRouterDefinition(input.routerId); + if (!router) { + return { + ok: false, + field: "modelRouter", + code: "unknown_router", + message: `model router "${input.routerId}" is not registered`, + }; + } + const protocol = selectRouterProtocol(router, input.harnessProtocols); + if (!protocol) { + return { + ok: false, + field: "modelRouter", + code: "unsupported_router", + message: `harness "${input.harness}" does not support model router "${router.id}"`, + }; + } + if (input.model && !(await input.model.exists(router.id, input.model.id))) { + return { + ok: false, + field: "model", + code: "unknown_model", + message: `model "${input.model.id}" is not in router "${router.id}"`, + }; + } + return { ok: true, router, protocol }; +} diff --git a/orchestrator/src/integrations/openrouter-decisions.ts b/orchestrator/src/providers/openrouter/decisions.ts similarity index 52% rename from orchestrator/src/integrations/openrouter-decisions.ts rename to orchestrator/src/providers/openrouter/decisions.ts index bccfc8a77..b5a63d567 100644 --- a/orchestrator/src/integrations/openrouter-decisions.ts +++ b/orchestrator/src/providers/openrouter/decisions.ts @@ -1,80 +1,23 @@ -/** - * OpenRouter Decisions API client (`POST /api/alpha/decisions`). +/** OpenRouter Decisions API client (`POST /api/alpha/decisions`). * - * The Decisions API serves System One models (TypeSafe's Jev): a `state` - * plus typed `questions` in, calibrated typed answers out. It is NOT a chat - * model, so the AI SDK provider in ./openrouter.ts cannot call it; this is a - * thin `fetch` client on the same `openrouter.api_key` credential. + * The API serves System One models, which are not chat models, so the AI SDK + * provider cannot call it. * * Wire reference: openrouter.ai/docs/api/api-reference/alphadecisions. */ -import { z } from "zod"; +import { + DecisionsApiError, + decisionsResponseSchema, + type DecisionsClient, +} from "../../model-routers/decisions.ts"; -/** A JSON value the API accepts as `state`, `instructions` or a criterion. */ -export type DecisionText = string | Record | unknown[]; - -export type DecisionQuestion = - | { type: "noul"; instructions: DecisionText; criteria?: { true: DecisionText; false: DecisionText } } - | { type: "choice"; instructions: DecisionText; criteria: Record } - | { type: "score"; instructions: DecisionText; criteria: DecisionText[] }; - -export interface DecisionsRequest { - model: string; - state: DecisionText; - questions: Record; -} - -const answerSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("noul"), noul: z.number() }), - z.object({ - type: z.literal("choice"), - choice: z.string(), - confidence: z.number().optional(), - probabilities: z.record(z.string(), z.number()).optional(), - }), - z.object({ - type: z.literal("score"), - score: z.number(), - confidence: z.number().optional(), - probabilities: z.record(z.string(), z.number()).optional(), - legend: z.record(z.string(), z.unknown()).optional(), - }), -]); -export type DecisionAnswer = z.infer; - -const responseSchema = z.object({ - id: z.string().optional(), - model: z.string(), - answers: z.record(z.string(), answerSchema), - usage: z.object({ - input_tokens: z.number(), - output_tokens: z.number(), - cost: z.number().optional(), - }), -}); -export type DecisionsResponse = z.infer; - -/** A failed decisions call. `retryable` follows the documented status codes: - * rate limits, upstream/provider failures and timeouts are worth a retry; a - * bad request, a bad key, no credits or a too-large payload are not. */ -export class DecisionsApiError extends Error { - constructor( - message: string, - readonly status: number | null, - readonly retryable: boolean, - ) { - super(message); - this.name = "DecisionsApiError"; - } -} +/** Pinned so a model release never changes a graph's thresholds; an author + * opts into a newer one through the request's `model`. */ +const DEFAULT_MODEL = "typesafe/jev-1.13"; const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 524, 529]); -export interface DecisionsClient { - decide(request: DecisionsRequest): Promise; -} - export interface DecisionsClientOptions { apiKey: string; host: string; @@ -101,7 +44,7 @@ export function makeDecisionsClient(options: DecisionsClientOptions): DecisionsC Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json", }, - body: JSON.stringify(request), + body: JSON.stringify({ ...request, model: request.model ?? DEFAULT_MODEL }), signal: AbortSignal.timeout(timeoutMs), }); // The body read is part of the transport: a reset or the abort after @@ -130,7 +73,7 @@ export function makeDecisionsClient(options: DecisionsClientOptions): DecisionsC // fault, not a bad request. throw new DecisionsApiError("decisions API returned a non-JSON body", res.status, true); } - const parsed = responseSchema.safeParse(body); + const parsed = decisionsResponseSchema.safeParse(body); if (!parsed.success) { const issue = parsed.error.issues[0]; throw new DecisionsApiError( diff --git a/orchestrator/src/model-routers/openrouter.ts b/orchestrator/src/providers/openrouter/router.ts similarity index 55% rename from orchestrator/src/model-routers/openrouter.ts rename to orchestrator/src/providers/openrouter/router.ts index e505b1c97..1cc06aef6 100644 --- a/orchestrator/src/model-routers/openrouter.ts +++ b/orchestrator/src/providers/openrouter/router.ts @@ -1,6 +1,10 @@ +/** OpenRouter as a model router. */ + +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { z } from "zod"; -import type { RouterModelInput } from "../db/model-routers.ts"; +import type { CatalogModel, ModelRouterSpec } from "../../model-routers/registry.ts"; +import { makeDecisionsClient } from "./decisions.ts"; const OpenRouterModelSchema = z.object({ id: z.string(), @@ -16,16 +20,16 @@ const OpenRouterModelSchema = z.object({ const OpenRouterCatalogSchema = z.object({ data: z.array(OpenRouterModelSchema) }); -export function parseOpenRouterCatalog(body: Uint8Array): RouterModelInput[] { +/** Keeps the models a coding agent can use: text output, tool calls, not batch. */ +export function parseOpenRouterCatalog(body: Uint8Array): CatalogModel[] { const decoded: unknown = JSON.parse(new TextDecoder().decode(body)); const parsed = OpenRouterCatalogSchema.parse(decoded); - const byId = new Map(); + const byId = new Map(); for (const raw of parsed.data) { const output = raw.architecture?.output_modalities ?? []; const parameters = raw.supported_parameters ?? []; if (raw.id.endsWith(":batch") || !output.includes("text") || !parameters.includes("tools")) continue; byId.set(raw.id, { - routerId: "openrouter", modelId: raw.id, canonicalSlug: raw.canonical_slug ?? raw.id, name: raw.name ?? raw.id, @@ -43,3 +47,28 @@ export function parseOpenRouterCatalog(body: Uint8Array): RouterModelInput[] { } return [...byId.values()]; } + +export const OPENROUTER: ModelRouterSpec = { + id: "openrouter", + label: "OpenRouter", + description: "Shared access to tool-capable models through OpenRouter.", + connector: "openrouter", + protocols: { + anthropic_messages: { basePath: "/api" }, + openai_responses: { basePath: "/api/v1" }, + }, + defaultModel: "deepseek/deepseek-v4-pro-0813", + titleModel: "deepseek/deepseek-v4-flash", + catalog: { + path: "/api/v1/models/user?output_modalities=text&supported_parameters=tools&sort=newest", + refreshIntervalMs: 6 * 60 * 60 * 1000, + parse: parseOpenRouterCatalog, + }, + // Model pages are on the public website, whatever the API region. + modelUrl: (modelId) => `https://openrouter.ai/${modelId}`, + chat: (apiKey, host) => { + const provider = createOpenRouter({ apiKey, baseURL: `https://${host}/api/v1` }); + return (modelId) => provider.chat(modelId); + }, + decisions: (apiKey, host) => makeDecisionsClient({ apiKey, host }), +}; diff --git a/orchestrator/src/rpc/automations.ts b/orchestrator/src/rpc/automations.ts index f15641264..cd4549730 100644 --- a/orchestrator/src/rpc/automations.ts +++ b/orchestrator/src/rpc/automations.ts @@ -48,7 +48,7 @@ import { inputErrorField, validateInputValues } from "../automations/inputs.ts"; import { makeConnectorStore } from "../db/connectors.ts"; import { makeProfileStore, type ProfileStore } from "../db/profiles.ts"; import { makeModelRouterStore, type ModelRouterStore } from "../db/model-routers.ts"; -import { getModelRouterDefinition, selectRouterProtocol } from "../model-routers/registry.ts"; +import { resolveRouterSelection } from "../model-routers/registry.ts"; import { harnessCatalog as defaultHarnessCatalog } from "../control-plane/client.ts"; import { log as rootLog } from "../log.ts"; import type { HarnessCatalogClient } from "./task-create.ts"; @@ -740,25 +740,17 @@ export function registerAutomations(router: ConnectRouter, deps?: AutomationDeps : undefined; const effort = typeof config["effort"] === "string" ? config["effort"] : undefined; if (routerId !== undefined) { - const router = getModelRouterDefinition(routerId); - if (!router) { + const selection = await resolveRouterSelection({ + routerId, + harness, + harnessProtocols: descriptor.routerProtocols ?? [], + ...(model !== undefined + ? { model: { id: model, exists: async (r, m) => (await modelRouters().getModel(r, m)) !== null } } + : {}), + }); + if (!selection.ok) { throw new BlockValidationError([ - blockError(where, "modelRouter", "unknown_router", `model router "${routerId}" is not registered`), - ]); - } - if (!selectRouterProtocol(router, descriptor.routerProtocols ?? [])) { - throw new BlockValidationError([ - blockError( - where, - "modelRouter", - "unsupported_router", - `harness "${harness}" does not support model router "${router.id}"`, - ), - ]); - } - if (model !== undefined && !(await modelRouters().getModel(router.id, model))) { - throw new BlockValidationError([ - blockError(where, "model", "unknown_model", `model "${model}" is not in router "${router.id}"`), + blockError(where, selection.field, selection.code, selection.message), ]); } } else if (model !== undefined && !(descriptor.models ?? []).some((m) => m.id === model)) { diff --git a/orchestrator/src/rpc/model-routers.ts b/orchestrator/src/rpc/model-routers.ts index 45910fd3c..8dfe7f73b 100644 --- a/orchestrator/src/rpc/model-routers.ts +++ b/orchestrator/src/rpc/model-routers.ts @@ -9,28 +9,28 @@ import { type RouterModelRow, } from "../db/model-routers.ts"; import { getSessionFromHeaders } from "../auth/session.ts"; -import { orgSecret as defaultOrgSecret } from "../control-plane/client.ts"; import { ModelRouterService, RouterModelAudience, } from "../gen/engram/app/v1/model_router_pb.ts"; import { refreshRouterCatalog, type RouterCatalogDeps } from "../model-routers/catalog.ts"; -import { getModelRouterDefinition, listModelRouterDefinitions } from "../model-routers/registry.ts"; +import { connectedRouters, type SecretNameClient } from "../model-routers/connected.ts"; +import { + getModelRouterDefinition, + listModelRouterDefinitions, + type ModelRouterDefinition, +} from "../model-routers/registry.ts"; import { requireAdmin, requireUser, type GetSession } from "./require.ts"; -interface SecretMetadataClient { - listSecrets(req: Record): Promise<{ secrets: Array<{ name: string }> }>; -} - export interface ModelRouterServiceDeps { getSession?: GetSession; integration?: RunOpDeps; store?: ModelRouterStore; - orgSecret?: SecretMetadataClient; + orgSecret?: SecretNameClient; refresh?: (routerId: string, deps: RouterCatalogDeps) => ReturnType; } -function modelToProto(row: RouterModelRow) { +function modelToProto(router: ModelRouterDefinition, row: RouterModelRow) { return { routerId: row.routerId, id: row.modelId, @@ -48,8 +48,7 @@ function modelToProto(row: RouterModelRow) { available: row.available, enabled: row.enabled, userEnabled: row.userEnabled, - // Model pages use the public website, independent of the API region. - upstreamUrl: `https://openrouter.ai/${row.modelId}`, + upstreamUrl: router.modelUrl(row.modelId), updatedAt: row.updatedAt.toISOString(), supportsReasoning: row.supportedParameters.includes("reasoning") || @@ -60,7 +59,8 @@ function modelToProto(row: RouterModelRow) { export function registerModelRouters(router: ConnectRouter, deps?: ModelRouterServiceDeps): void { const getSession = deps?.getSession ?? getSessionFromHeaders; const store = deps?.store ?? makeModelRouterStore(getDb()); - const secrets = deps?.orgSecret ?? (defaultOrgSecret as unknown as SecretMetadataClient); + const connected = async (): Promise> => + new Set((await connectedRouters(deps?.orgSecret)).map((router) => router.id)); const refresh = deps?.refresh ?? refreshRouterCatalog; router.service(ModelRouterService, { @@ -70,19 +70,16 @@ export function registerModelRouters(router: ConnectRouter, deps?: ModelRouterSe // secret material. Non-admins only ever see connected routers // (filtered below); admins see the rest too, because the // settings panel is where the key gets saved in the first place. - const configured = new Set( - (await secrets.listSecrets({})).secrets.map((secret) => secret.name), - ); + const configured = await connected(); const visible = listModelRouterDefinitions().filter( - (definition) => - user.role === "admin" || configured.has(definition.credentialSecret), + (definition) => user.role === "admin" || configured.has(definition.id), ); const routers = await Promise.all( visible.map(async (definition) => { const [models, state, host] = await Promise.all([ store.listModels(definition.id, "admin"), store.getSyncState(definition.id), - resolveIntegrationHost(definition.catalog.provider, deps?.integration), + resolveIntegrationHost(definition.connector, deps?.integration), ]); return { id: definition.id, @@ -91,7 +88,7 @@ export function registerModelRouters(router: ConnectRouter, deps?: ModelRouterSe protocols: Object.keys(definition.protocols), credentialSecret: user.role === "admin" ? definition.credentialSecret : "", egressHosts: [host], - credentialConfigured: configured.has(definition.credentialSecret), + credentialConfigured: configured.has(definition.id), lastSuccessfulSyncAt: state?.lastSuccessfulSyncAt?.toISOString(), lastSyncError: state?.lastError ?? undefined, modelCount: models.length, @@ -113,13 +110,8 @@ export function registerModelRouters(router: ConnectRouter, deps?: ModelRouterSe // A router without its credential is invisible to non-admins — // same boundary as listModelRouters, held here too so a stale // client cannot browse a catalog it can never launch against. - if (user.role !== "admin") { - const configured = new Set( - (await secrets.listSecrets({})).secrets.map((secret) => secret.name), - ); - if (!configured.has(definition.credentialSecret)) { - throw new ConnectError("model router not found", Code.NotFound); - } + if (user.role !== "admin" && !(await connected()).has(definition.id)) { + throw new ConnectError("model router not found", Code.NotFound); } let audience: StoreAudience; switch (req.audience) { @@ -138,7 +130,10 @@ export function registerModelRouters(router: ConnectRouter, deps?: ModelRouterSe default: throw new ConnectError("invalid audience", Code.InvalidArgument); } - return { models: (await store.listModels(req.routerId, audience, req.search)).map(modelToProto) }; + return { + models: (await store.listModels(req.routerId, audience, req.search)) + .map((row) => modelToProto(definition, row)), + }; }, async refreshRouterModels(req, ctx) { @@ -147,16 +142,11 @@ export function registerModelRouters(router: ConnectRouter, deps?: ModelRouterSe if (!definition) { throw new ConnectError("model router not found", Code.NotFound); } - { - const configured = new Set( - (await secrets.listSecrets({})).secrets.map((secret) => secret.name), + if (!(await connected()).has(definition.id)) { + throw new ConnectError( + `save the ${definition.label} key first — the catalog cannot be fetched without it`, + Code.FailedPrecondition, ); - if (!configured.has(definition.credentialSecret)) { - throw new ConnectError( - `save the ${definition.label} key first — the catalog cannot be fetched without it`, - Code.FailedPrecondition, - ); - } } try { return await refresh(req.routerId, { store }); @@ -173,9 +163,11 @@ export function registerModelRouters(router: ConnectRouter, deps?: ModelRouterSe if (req.userEnabled && !req.enabled) { throw new ConnectError("available to users requires enabled", Code.InvalidArgument); } - const model = await store.updatePolicy(req.routerId, req.modelId, req.enabled, req.userEnabled); - if (!model) throw new ConnectError("router model not found", Code.NotFound); - return { model: modelToProto(model) }; + const definition = getModelRouterDefinition(req.routerId); + const model = definition + && await store.updatePolicy(req.routerId, req.modelId, req.enabled, req.userEnabled); + if (!definition || !model) throw new ConnectError("router model not found", Code.NotFound); + return { model: modelToProto(definition, model) }; }, }); } diff --git a/orchestrator/src/rpc/profiles.ts b/orchestrator/src/rpc/profiles.ts index d9ff7a070..b51089917 100644 --- a/orchestrator/src/rpc/profiles.ts +++ b/orchestrator/src/rpc/profiles.ts @@ -71,7 +71,7 @@ import { } from "./profile-discover.ts"; import { sessions as defaultSessions } from "../control-plane/client.ts"; import { makeModelRouterStore, type ModelRouterStore } from "../db/model-routers.ts"; -import { getModelRouterDefinition, selectRouterProtocol } from "../model-routers/registry.ts"; +import { resolveRouterSelection } from "../model-routers/registry.ts"; /** Subset of ImageService client used here (catalog validation). */ export interface ImagesClient { @@ -349,20 +349,15 @@ export function registerProfiles(router: ConnectRouter, deps?: ProfileDeps): voi throw new ConnectError(`harness "${harness}" is not in the catalog`, Code.InvalidArgument); } if (modelRouter) { - const router = getModelRouterDefinition(modelRouter); - if (!router) throw new ConnectError(`model router "${modelRouter}" is not registered`, Code.InvalidArgument); - if (!selectRouterProtocol(router, descriptor.routerProtocols ?? [])) { - throw new ConnectError( - `harness "${harness}" does not support model router "${modelRouter}"`, - Code.InvalidArgument, - ); - } - if (model != null && !(await modelRouters().getModel(modelRouter, model))) { - throw new ConnectError( - `model "${model}" is not in router "${modelRouter}"`, - Code.InvalidArgument, - ); - } + const selection = await resolveRouterSelection({ + routerId: modelRouter, + harness, + harnessProtocols: descriptor.routerProtocols ?? [], + ...(model != null + ? { model: { id: model, exists: async (r, m) => (await modelRouters().getModel(r, m)) !== null } } + : {}), + }); + if (!selection.ok) throw new ConnectError(selection.message, Code.InvalidArgument); } else if (model != null && !(descriptor.models ?? []).some((m) => m.id === model)) { throw new ConnectError( `model "${model}" is not valid for harness "${harness}"`, diff --git a/orchestrator/src/rpc/task-create.ts b/orchestrator/src/rpc/task-create.ts index de0b22549..8ac05b12a 100644 --- a/orchestrator/src/rpc/task-create.ts +++ b/orchestrator/src/rpc/task-create.ts @@ -59,7 +59,6 @@ import type { SpecPromptContext } from "../prompts/spec-mode.ts"; import { OauthSubjectKind } from "../gen/engram/app/v1/oauth_pb.ts"; import { oauthCredential as defaultOAuthCredential, - orgSecret as defaultOrgSecret, } from "../control-plane/client.ts"; import { makeIntegrationConnectionStore, @@ -80,10 +79,8 @@ import { providerGuestServices, } from "../integrations/providers/index.ts"; import { makeModelRouterStore, type ModelRouterStore } from "../db/model-routers.ts"; -import { - getModelRouterDefinition, - selectRouterProtocol, -} from "../model-routers/registry.ts"; +import { connectedRouters, type SecretNameClient } from "../model-routers/connected.ts"; +import { resolveRouterSelection } from "../model-routers/registry.ts"; const log = rootLog.child({ component: "task" }); @@ -192,11 +189,6 @@ export interface HarnessCatalogClient { }>; } -/** Name-only slice of OrgSecretService. Secret values never cross this seam. */ -export interface OrgSecretNameClient { - listSecrets(req: Record): Promise<{ secrets: Array<{ name: string }> }>; -} - export interface SessionCompileDeps { images: ImagesClient; connectors: CustomConnectorSource; @@ -214,7 +206,7 @@ export interface SessionCompileDeps { resolveAllUserTokens: () => Promise>; /** Name-only org-secret lookup used to reject unresolved descriptor refs. * Production uses the control-plane client; tests may inject a fake. */ - orgSecret?: OrgSecretNameClient; + orgSecret?: SecretNameClient; /** Resolve whether the human owner has a live provider connection. */ hasOAuthCredential?: (provider: string) => Promise; oauthSubject?: { kind: OauthSubjectKind; id: string }; @@ -333,22 +325,26 @@ export async function compileSessionCreateInput( if (!descriptor) { throw new ConnectError(`harness \`${selectedHarness}\` is not in the catalog`, Code.FailedPrecondition); } - const router = selectedRouterId ? getModelRouterDefinition(selectedRouterId) : null; - if (selectedRouterId && !router) { - throw new ConnectError(`model router \`${selectedRouterId}\` is not registered`, Code.InvalidArgument); + const selection = selectedRouterId + ? await resolveRouterSelection({ + routerId: selectedRouterId, + harness: selectedHarness, + harnessProtocols: descriptor.routerProtocols ?? [], + }) + : null; + if (selection && !selection.ok) { + throw new ConnectError( + selection.message, + selection.code === "unknown_router" ? Code.InvalidArgument : Code.FailedPrecondition, + ); } - const routerHost = router ? await resolveIntegrationHost(router.catalog.provider, { + const router = selection?.router ?? null; + const routerProtocol = selection?.protocol ?? null; + const routerHost = router ? await resolveIntegrationHost(router.connector, { connectors: deps.connectors, settingsFor: async (provider) => storedSettingsOf((await deps.connections.getDefault(provider))?.config), }) : undefined; - const routerProtocol = router ? selectRouterProtocol(router, descriptor.routerProtocols ?? []) : null; - if (router && !routerProtocol) { - throw new ConnectError( - `harness \`${selectedHarness}\` has no protocol adapter for model router \`${router.id}\``, - Code.FailedPrecondition, - ); - } const modelId = opts.model ?? profile.model ?? @@ -364,14 +360,9 @@ export async function compileSessionCreateInput( let routedModel; if (router) { if (!modelId) throw new ConnectError("a routed launch requires a model", Code.InvalidArgument); - // Independent reads (DB + control plane); the policy checks below - // keep their original order over the resolved results. - const secretClient: OrgSecretNameClient = deps.orgSecret ?? defaultOrgSecret; - const [routed, availableSecrets] = await Promise.all([ + const [routed, connected] = await Promise.all([ (deps.modelRouters ?? makeModelRouterStore()).getModel(router.id, modelId), - secretClient - .listSecrets({}) - .then((r) => new Set(r.secrets.map((secret) => secret.name))), + connectedRouters(deps.orgSecret), ]); routedModel = routed; if (!routedModel || !routedModel.available) { @@ -384,7 +375,7 @@ export async function compileSessionCreateInput( if (!routedModel.supportedParameters.includes("reasoning") && !routedModel.supportedParameters.includes("reasoning_effort")) { effortId = undefined; } - if (!availableSecrets.has(router.credentialSecret)) { + if (!connected.some((candidate) => candidate.id === router.id)) { throw new ConnectError( `${router.label} is not connected — an admin must save the ${router.credentialSecret} key under Settings before routed launches work`, Code.FailedPrecondition, @@ -798,7 +789,7 @@ export interface CreateTaskDeps { users?: UserIdentityStore; connections?: IntegrationConnectionStore; modelRouters?: ModelRouterStore; - orgSecret?: OrgSecretNameClient; + orgSecret?: SecretNameClient; } export interface CreateTaskParams { diff --git a/orchestrator/src/rpc/tasks.ts b/orchestrator/src/rpc/tasks.ts index 4cd920488..3ffed5289 100644 --- a/orchestrator/src/rpc/tasks.ts +++ b/orchestrator/src/rpc/tasks.ts @@ -80,7 +80,8 @@ import { makePendingToolCallStore, type PendingToolCallStore, } from "../tools/pending-tool-calls.ts"; -import { createTaskWithSession, type Db, type HarnessCatalogClient, type OrgSecretNameClient } from "./task-create.ts"; +import { createTaskWithSession, type Db, type HarnessCatalogClient } from "./task-create.ts"; +import type { SecretNameClient } from "../model-routers/connected.ts"; import { makeConnectorStore } from "../db/connectors.ts"; import type { IntegrationConnectionStore } from "../db/integration-connections.ts"; import type { ModelRouterStore } from "../db/model-routers.ts"; @@ -159,7 +160,7 @@ export interface TaskDeps { users?: UserIdentityStore; connections?: IntegrationConnectionStore; modelRouters?: ModelRouterStore; - orgSecret?: OrgSecretNameClient; + orgSecret?: SecretNameClient; /** Register a session for stream-listener scanner discovery. */ db?: Db; /** ADR 0107: the pending-tool-call ledger, for the awaiting_review derivation. */ diff --git a/orchestrator/src/tools/__tests__/automation-draft.test.ts b/orchestrator/src/tools/__tests__/automation-draft.test.ts index ffde5a1e5..ff25b502a 100644 --- a/orchestrator/src/tools/__tests__/automation-draft.test.ts +++ b/orchestrator/src/tools/__tests__/automation-draft.test.ts @@ -85,7 +85,7 @@ function harness(current: AutomationRow | null, options: { raceToVersion?: numbe const saved: Harness["saved"] = []; const meta: Harness["meta"] = []; const deps: AutomationDraftToolDeps = { - routerConnected: async () => true, + decisionsAvailable: async () => true, store: { async getByDraftSession(sessionId) { return current && current.draftSessionId === sessionId ? current : null; @@ -366,21 +366,21 @@ describe("automation_read patterns", () => { }); describe("draftBlockCatalog", () => { - test("a router-backed block is offered only while its router has a key", () => { + test("the decide block is offered only while a router can answer", () => { registerEngineBlocks(); - const types = (routers: string[]) => - (draftBlockCatalog(new Set(routers)) as { blocks: Array<{ type: string }>; notes: string[] }).blocks.map( + const types = (decisionsAvailable: boolean) => + (draftBlockCatalog(decisionsAvailable) as { blocks: Array<{ type: string }>; notes: string[] }).blocks.map( (b) => b.type, ); - expect(types([])).not.toContain("decide"); - expect(types(["openrouter"])).toContain("decide"); + expect(types(false)).not.toContain("decide"); + expect(types(true)).toContain("decide"); const notes = (draftBlockCatalog() as { notes: string[] }).notes; expect(notes.some((n) => n.startsWith("decide "))).toBe(false); }); test("golden: every user block type projects with a real JSON schema", () => { registerEngineBlocks(); - const catalog = draftBlockCatalog(new Set(["openrouter"])) as { + const catalog = draftBlockCatalog(true) as { blocks: Array<{ type: string; config_schema: unknown }>; }; // A registry change (new block, renamed type) must be a visible diff here. diff --git a/orchestrator/src/tools/automation-draft.ts b/orchestrator/src/tools/automation-draft.ts index 2e774c2f6..a838897bb 100644 --- a/orchestrator/src/tools/automation-draft.ts +++ b/orchestrator/src/tools/automation-draft.ts @@ -11,7 +11,7 @@ * reject the WHOLE tools/list. */ -import { isRouterConnected } from "../model-routers/connected.ts"; +import { decisionsRouter } from "../model-routers/client.ts"; import { z } from "zod"; import { definitionOf, effectiveDefinition, SaveVersionConflictError } from "../db/automations.ts"; @@ -47,9 +47,9 @@ export interface AutomationDraftToolDeps { >; profiles: Pick; events: DraftEventCatalogDeps; - /** Whether a model router has a key (default: isRouterConnected). The - * catalog offers router-backed blocks only when it does. */ - routerConnected?: (routerId: string) => Promise; + /** Whether a connected model router can answer a decision. The catalog + * offers the decide block only when one can. */ + decisionsAvailable?: () => Promise; now?: () => Date; } @@ -190,7 +190,7 @@ export function registerAutomationDraftTools( return { part: args.part, content: draftBlockCatalog( - new Set((await (deps.routerConnected ?? isRouterConnected)("openrouter")) ? ["openrouter"] : []), + await (deps.decisionsAvailable ?? (async () => (await decisionsRouter()) !== null))(), ), }; case "patterns": diff --git a/orchestrator/src/workflows/automation-run.ts b/orchestrator/src/workflows/automation-run.ts index c685c2821..9ab602e0d 100644 --- a/orchestrator/src/workflows/automation-run.ts +++ b/orchestrator/src/workflows/automation-run.ts @@ -50,8 +50,7 @@ import { makeIntegrationActionRuntime } from "../automations/actions/runtime.ts" import { makeAutomationInstanceStore } from "../db/automation-instances.ts"; import { makeSessionRelayStore } from "../db/session-relays.ts"; import { sessionRelayDeps } from "../automations/session-relay.ts"; -import { isRouterConnected } from "../model-routers/connected.ts"; -import { getOpenRouterDecisions } from "../integrations/openrouter.ts"; +import { decide } from "../model-routers/client.ts"; import { loadProfileCards } from "../automations/profile-cards.ts"; export interface AutomationRunWorkflowInput { @@ -290,10 +289,7 @@ function productionEngineDeps(): EngineDeps { }, }, integrationActions: makeIntegrationActionRuntime(), - decisions: { - connected: () => isRouterConnected("openrouter"), - decide: async (request) => (await getOpenRouterDecisions()).decide(request), - }, + decisions: { decide }, profileCards: () => loadProfileCards(), instances: { closeInstance: (input) => makeAutomationInstanceStore().closeInstance(input), From 1ee3f5077a3e60f6549db06de96fe746b64c7fed Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Wed, 11 Nov 2026 08:28:04 -0800 Subject: [PATCH 04/38] orchestrator: the event route has one clock and passes an unknown provider on Fixes from the review of the shared event route: - An unknown provider falls through to the next handler, so a later route on the same path pattern can still answer. It was a 404 from this route. - The route owns the clock. The ingress seams no longer take a second `now`, which could disagree with the one used to verify and extract. - The interactivity route takes the same injected clock. - The header comment said the route always answers 200; a provider's post-delivery hook can answer otherwise. New tests: the default sources equal the connectors that declare a webhook ingress; an unknown provider is 404; a wrong secret and an unreadable secret are both 401 and write nothing to the ledger. Co-Authored-By: Claude Fable 5.2 --- .../integration-events-route.test.ts | 93 +++++++++++++++++++ .../src/__tests__/linear-events-route.test.ts | 1 - .../src/__tests__/vanta-events-route.test.ts | 2 - orchestrator/src/routes/integration-events.ts | 35 ++++--- .../src/routes/slack-interactivity.ts | 7 +- 5 files changed, 118 insertions(+), 20 deletions(-) create mode 100644 orchestrator/src/__tests__/integration-events-route.test.ts diff --git a/orchestrator/src/__tests__/integration-events-route.test.ts b/orchestrator/src/__tests__/integration-events-route.test.ts new file mode 100644 index 000000000..d6a2c1a41 --- /dev/null +++ b/orchestrator/src/__tests__/integration-events-route.test.ts @@ -0,0 +1,93 @@ +/** The shared provider event route: which providers it serves and how it + * refuses a delivery it cannot verify. */ + +import { createHmac } from "node:crypto"; +import { describe, expect, test } from "bun:test"; + +import { connectorRegistry } from "../connectors/registry.ts"; +import type { RecordIntegrationEventInput } from "../db/integration-events.ts"; +import { linearEventSource } from "../providers/linear/events.ts"; +import { defaultEventSources, makeIntegrationEventsRoute } from "../routes/integration-events.ts"; + +const SECRET = "test-signing-secret"; +const NOW = new Date("2026-08-31T17:06:35.000Z"); +const BODY = JSON.stringify({ type: "Issue", action: "create", webhookTimestamp: NOW.getTime() }); + +function route(resolve: () => Promise, recorded: RecordIntegrationEventInput[] = []) { + return makeIntegrationEventsRoute({ + sources: [linearEventSource], + secrets: { resolve }, + now: () => NOW, + ingress: { + connectionIdFor: async () => "conn-1", + store: { + record: async (input) => { + recorded.push(input); + return { recorded: true, id: "evt-1" }; + }, + sweep: async () => 0, + list: async () => [], + getLatest: async () => null, + listObservedEventKeys: async () => [], + getById: async () => null, + listObservedScopeValues: async () => [], + }, + dispatch: async () => ({ + matched: 0, started: 0, joined: 0, queued: 0, skipped: 0, filtered: 0, dropped: 0, + suppressed: [], failed: 0, builtins: {}, + }), + }, + }); +} + +function post(app: ReturnType, provider: string, signingSecret = SECRET) { + return app.request(`/api/v1/integrations/${provider}/events`, { + method: "POST", + headers: { + "content-type": "application/json", + "linear-delivery": "d-1", + "linear-signature": createHmac("sha256", signingSecret).update(BODY).digest("hex"), + }, + body: BODY, + }); +} + +describe("provider event route", () => { + test("the default sources are the connectors that declare a webhook ingress", () => { + const declared = [...connectorRegistry().values()] + .filter((connector) => connector.webhook?.ingress) + .map((connector) => connector.provider); + expect(defaultEventSources().map((source) => source.provider).sort()).toEqual(declared.sort()); + }); + + test("a signed delivery reaches the ledger", async () => { + const recorded: RecordIntegrationEventInput[] = []; + const res = await post(route(async () => SECRET, recorded), "linear"); + expect(res.status).toBe(200); + expect(recorded).toHaveLength(1); + }); + + test("a provider with no event source is not found", async () => { + const recorded: RecordIntegrationEventInput[] = []; + const res = await post(route(async () => SECRET, recorded), "github"); + expect(res.status).toBe(404); + expect(recorded).toHaveLength(0); + }); + + test("a delivery signed with another secret is refused", async () => { + const recorded: RecordIntegrationEventInput[] = []; + const res = await post(route(async () => SECRET, recorded), "linear", "wrong-secret"); + expect(res.status).toBe(401); + expect(recorded).toHaveLength(0); + }); + + test("an unreadable signing secret is a refusal, not a server fault", async () => { + const recorded: RecordIntegrationEventInput[] = []; + const app = route(async () => { + throw new Error("secret not sealed"); + }, recorded); + const res = await post(app, "linear"); + expect(res.status).toBe(401); + expect(recorded).toHaveLength(0); + }); +}); diff --git a/orchestrator/src/__tests__/linear-events-route.test.ts b/orchestrator/src/__tests__/linear-events-route.test.ts index d9135b496..f67b126b0 100644 --- a/orchestrator/src/__tests__/linear-events-route.test.ts +++ b/orchestrator/src/__tests__/linear-events-route.test.ts @@ -27,7 +27,6 @@ async function deliver(payload: Record): Promise SECRET }, now: () => NOW, ingress: { - now: () => NOW, connectionIdFor: async () => "conn-linear", store: { record: async (input) => { diff --git a/orchestrator/src/__tests__/vanta-events-route.test.ts b/orchestrator/src/__tests__/vanta-events-route.test.ts index 3b249c11a..953d6c82d 100644 --- a/orchestrator/src/__tests__/vanta-events-route.test.ts +++ b/orchestrator/src/__tests__/vanta-events-route.test.ts @@ -34,7 +34,6 @@ async function deliver( secrets: { resolve: async () => SECRET }, now: () => NOW, ingress: { - now: () => NOW, connectionIdFor: async () => "conn-vanta", store: { record: async (input) => { @@ -126,7 +125,6 @@ describe("vanta ingress", () => { secrets: { resolve: async () => SECRET }, now: () => NOW, ingress: { - now: () => NOW, connectionIdFor: async () => "conn-vanta", store: { record: async (input) => { diff --git a/orchestrator/src/routes/integration-events.ts b/orchestrator/src/routes/integration-events.ts index 23ba8446a..9606b6cab 100644 --- a/orchestrator/src/routes/integration-events.ts +++ b/orchestrator/src/routes/integration-events.ts @@ -4,9 +4,9 @@ * * One route for every provider. The connector's `webhook.ingress` declares * how a delivery is signed; the provider's event source says what a verified - * delivery means. The route answers 200 after the ledger write and the - * dispatch commit, so a crash before the answer is covered by the provider's - * retry and the delivery id dedupes it. + * delivery means. The route answers after the ledger write and the dispatch + * commit, so a crash before the answer is covered by the provider's retry + * and the delivery id dedupes it. */ import { Hono } from "hono"; @@ -31,31 +31,36 @@ export interface IntegrationEventsDeps { sources?: IntegrationEventSource[]; secrets?: VerificationSecretResolver; /** Ingress-spine seams (ledger store, trigger dispatch, connection). */ - ingress?: HandleDeliveryDeps; + ingress?: Omit; + /** The one clock for verification, extraction, and the ledger. */ now?: () => Date; } +/** One source per connector that declares a webhook ingress. */ +export function defaultEventSources(): IntegrationEventSource[] { + return [githubEventSource(), linearEventSource, slackEventSource, vantaEventSource]; +} + export function makeIntegrationEventsRoute(deps: IntegrationEventsDeps = {}): Hono { const secrets = deps.secrets ?? makeVerificationSecretResolver(); const now = deps.now ?? (() => new Date()); - const ingressDeps: HandleDeliveryDeps = { now, ...deps.ingress }; + const ingressDeps: HandleDeliveryDeps = { ...deps.ingress, now }; const sources = new Map( - (deps.sources ?? [githubEventSource(), linearEventSource, slackEventSource, vantaEventSource]) - .map((source) => { - const declared = declaredIngress(source.provider); - if (!declared) { - throw new Error(`event source ${source.provider}: its connector declares no webhook ingress`); - } - return [source.provider, { source, ...declared }] as const; - }), + (deps.sources ?? defaultEventSources()).map((source) => { + const declared = declaredIngress(source.provider); + if (!declared) { + throw new Error(`event source ${source.provider}: its connector declares no webhook ingress`); + } + return [source.provider, { source, ...declared }] as const; + }), ); const app = new Hono(); - app.post("/api/v1/integrations/:provider/events", async (c) => { + app.post("/api/v1/integrations/:provider/events", async (c, next) => { const entry = sources.get(c.req.param("provider")); - if (!entry) return c.notFound(); + if (!entry) return next(); const { source, displayName, ingress } = entry; const headers = c.req.raw.headers; diff --git a/orchestrator/src/routes/slack-interactivity.ts b/orchestrator/src/routes/slack-interactivity.ts index 0a285b862..d653c84e6 100644 --- a/orchestrator/src/routes/slack-interactivity.ts +++ b/orchestrator/src/routes/slack-interactivity.ts @@ -5,7 +5,8 @@ * * The single inbound surface for AskUserQuestion answers: an inline option * click, an "Answer…" click (→ open the answer modal), or the modal submission. - * Verifies every request against the connector's declared ingress, classifies the `payload` field via the Block Kit contract, and: + * Verifies every request against the connector's declared ingress, + * classifies the `payload` field via the Block Kit contract, and: * - answer to a session's question (the route carries `sessionId`, contract * 5) → completes the session's tool call through the session relay; * - answer to a run's `slack_choice` card (the route carries `runId`) → a @@ -41,6 +42,7 @@ import { CHOICE_ID_PREFIX, SLACK_ANSWER_SIGNAL, deliverSessionAnswer } from "../ export interface SlackInteractivityDeps { secrets?: VerificationSecretResolver; + now?: () => Date; /** Deliver an answer to the run whose relay posted the question. Default = * DBOS.send of a `slack_answer` signal (idempotent on tool_call_id). */ deliverAnswer?: (route: ThreadRoute, answer: SourceAnswer) => Promise; @@ -122,6 +124,7 @@ export function makeSlackInteractivityRoute(deps: SlackInteractivityDeps = {}): if (!declared) throw new Error("the slack connector declares no webhook ingress"); const deliverAnswer = deps.deliverAnswer ?? defaultDeliverAnswer; const openModal = deps.openModal ?? defaultOpenModal; + const now = deps.now ?? (() => new Date()); const app = new Hono(); app.post("/api/v1/integrations/slack/interactivity", async (c) => { @@ -132,7 +135,7 @@ export function makeSlackInteractivityRoute(deps: SlackInteractivityDeps = {}): headers: c.req.raw.headers, rawBody, secrets, - now: new Date(), + now: now(), }); if (!valid) return c.json({ error: "invalid signature" }, 401); From ea50f7aab8caca4408e8495fc41a1079e9c1378a Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Wed, 11 Nov 2026 08:28:57 -0800 Subject: [PATCH 05/38] orchestrator: the catalog refresher lists secret names once per pass Fixes from the review of the model-router seam: - The catalog refresher listed every org-secret name once per router. It now reads the connected routers once per pass. `isRouterConnected` had no other caller and is deleted. - `selectRouterProtocol` and `DecisionsClientOptions` are private; nothing outside their files used them. - The policy RPC returns early for an unknown router. - Two comments are fixed: the `draftBlockCatalog` doc sat above another item, and the `orgSecret` dep described a use it no longer has. Co-Authored-By: Claude Fable 5.2 --- orchestrator/src/automations/draft-catalog.ts | 6 +++--- orchestrator/src/model-routers/catalog.ts | 13 ++++++------- orchestrator/src/model-routers/connected.ts | 7 ------- orchestrator/src/model-routers/registry.ts | 2 +- orchestrator/src/providers/openrouter/decisions.ts | 2 +- orchestrator/src/rpc/model-routers.ts | 6 +++--- orchestrator/src/rpc/task-create.ts | 3 +-- 7 files changed, 15 insertions(+), 24 deletions(-) diff --git a/orchestrator/src/automations/draft-catalog.ts b/orchestrator/src/automations/draft-catalog.ts index 0070ad81e..1bdad679a 100644 --- a/orchestrator/src/automations/draft-catalog.ts +++ b/orchestrator/src/automations/draft-catalog.ts @@ -44,9 +44,6 @@ function jsonSchemaOf(schema: z.ZodType): unknown { } } -/** The block + definition catalog. Pure: derived from code, no I/O. Without - * `decisionsAvailable` the decide block is left out, so the agent never - * drafts one that cannot decide anything. */ /** The condition group a filter or a branch takes. Its zod schema is an * opaque refinement, so the JSON schema says nothing; this note does. */ const CONDITIONS_NOTE = @@ -54,6 +51,9 @@ const CONDITIONS_NOTE = 'path is a scope path ("event.raw.pull_request.user.type", "steps.facts.value.ok"); op is one of equals, not_equals, contains, matches, in, is_empty, gt, lt, is_true, is_false. ' + 'Example: {"mode":"all","conditions":[{"path":"event.raw.pull_request.user.type","op":"not_equals","value":"Bot"}]}.'; +/** The block + definition catalog. Pure: derived from code, no I/O. Without + * `decisionsAvailable` the decide block is left out, so the agent never + * drafts one that cannot decide anything. */ export function draftBlockCatalog(decisionsAvailable = false): Record { registerEngineBlocks(); const blocks = listBlockTypes() diff --git a/orchestrator/src/model-routers/catalog.ts b/orchestrator/src/model-routers/catalog.ts index 95e2962cc..aeabaacd7 100644 --- a/orchestrator/src/model-routers/catalog.ts +++ b/orchestrator/src/model-routers/catalog.ts @@ -2,7 +2,7 @@ import type { ModelRouterStore } from "../db/model-routers.ts"; import { runIntegrationOp } from "../integrations/run-op.ts"; import { errorMessage, log } from "../log.ts"; import { getModelRouterDefinition, listModelRouterDefinitions } from "./registry.ts"; -import { isRouterConnected } from "./connected.ts"; +import { connectedRouters } from "./connected.ts"; export interface RouterCatalogRefreshResult { discovered: number; @@ -54,12 +54,11 @@ export class ModelRouterCatalogRefresher { constructor(private readonly deps: RouterCatalogDeps) {} async runOnce(): Promise { - await Promise.allSettled( - listModelRouterDefinitions().map(async (router) => { - if (!(await isRouterConnected(router.id))) return; - return refreshRouterCatalog(router.id, this.deps); - }), - ); + const routers = await connectedRouters().catch((err: unknown) => { + log.warn({ err }, "org-secret listing failed; no catalog refreshed"); + return []; + }); + await Promise.allSettled(routers.map((router) => refreshRouterCatalog(router.id, this.deps))); } start(): void { diff --git a/orchestrator/src/model-routers/connected.ts b/orchestrator/src/model-routers/connected.ts index 92f2fef49..bf65355db 100644 --- a/orchestrator/src/model-routers/connected.ts +++ b/orchestrator/src/model-routers/connected.ts @@ -43,10 +43,3 @@ export async function firstConnectedRouter( return null; } } - -export async function isRouterConnected( - routerId: string, - client?: SecretNameClient, -): Promise { - return (await firstConnectedRouter((router) => router.id === routerId, client)) !== null; -} diff --git a/orchestrator/src/model-routers/registry.ts b/orchestrator/src/model-routers/registry.ts index 9edf4a23c..600298989 100644 --- a/orchestrator/src/model-routers/registry.ts +++ b/orchestrator/src/model-routers/registry.ts @@ -62,7 +62,7 @@ export function getModelRouterDefinition(id: string): ModelRouterDefinition | nu return REGISTRY.get(id) ?? null; } -export function selectRouterProtocol( +function selectRouterProtocol( router: ModelRouterDefinition, harnessProtocols: readonly string[], ): RouterProtocol | null { diff --git a/orchestrator/src/providers/openrouter/decisions.ts b/orchestrator/src/providers/openrouter/decisions.ts index b5a63d567..8c3fbc383 100644 --- a/orchestrator/src/providers/openrouter/decisions.ts +++ b/orchestrator/src/providers/openrouter/decisions.ts @@ -18,7 +18,7 @@ const DEFAULT_MODEL = "typesafe/jev-1.13"; const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 524, 529]); -export interface DecisionsClientOptions { +interface DecisionsClientOptions { apiKey: string; host: string; /** Per-call abort. The model answers in well under a second; a call that diff --git a/orchestrator/src/rpc/model-routers.ts b/orchestrator/src/rpc/model-routers.ts index 8dfe7f73b..5fdd499b3 100644 --- a/orchestrator/src/rpc/model-routers.ts +++ b/orchestrator/src/rpc/model-routers.ts @@ -164,9 +164,9 @@ export function registerModelRouters(router: ConnectRouter, deps?: ModelRouterSe throw new ConnectError("available to users requires enabled", Code.InvalidArgument); } const definition = getModelRouterDefinition(req.routerId); - const model = definition - && await store.updatePolicy(req.routerId, req.modelId, req.enabled, req.userEnabled); - if (!definition || !model) throw new ConnectError("router model not found", Code.NotFound); + if (!definition) throw new ConnectError("router model not found", Code.NotFound); + const model = await store.updatePolicy(req.routerId, req.modelId, req.enabled, req.userEnabled); + if (!model) throw new ConnectError("router model not found", Code.NotFound); return { model: modelToProto(definition, model) }; }, }); diff --git a/orchestrator/src/rpc/task-create.ts b/orchestrator/src/rpc/task-create.ts index 8ac05b12a..be9e087f9 100644 --- a/orchestrator/src/rpc/task-create.ts +++ b/orchestrator/src/rpc/task-create.ts @@ -204,8 +204,7 @@ export interface SessionCompileDeps { * only when the profile sets includeUserTokens, to additionally carry the * user's OTHER credentials into the sandbox. */ resolveAllUserTokens: () => Promise>; - /** Name-only org-secret lookup used to reject unresolved descriptor refs. - * Production uses the control-plane client; tests may inject a fake. */ + /** Lists org-secret names, to check that the selected router has a key. */ orgSecret?: SecretNameClient; /** Resolve whether the human owner has a live provider connection. */ hasOAuthCredential?: (provider: string) => Promise; From 1dd6733cfae6aae6f160188af86d2cea11a6116e Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Wed, 11 Nov 2026 08:40:06 -0800 Subject: [PATCH 06/38] orchestrator: LiteLLM as a second model router A LiteLLM proxy is a model router: sessions launch through it on both harness protocols, the catalog comes from its model list, and titles and decide blocks can run on its models. What the seam needed for a router whose models are the admin's own: - A model per role. A router declares built-in models for `default`, `title`, and `decision`; LiteLLM declares none. The new `router_settings` table holds an admin's choice per role, and `UpdateModelRouterSettings` sets it. `routerModels` is the one reader: the admin's choice, else the built-in model. OpenRouter's pinned decision model moves from its client into its spec, so an admin can change it the same way. - Decisions without a native endpoint. `decideByChat` answers the same contract with one structured chat completion. Its numbers are the model's own estimates, not calibrated probabilities. - In-process calls use the first connected router that has a model for the role, so an org with only LiteLLM gets titles and decisions once an admin picks the models. - A routed launch with no model and no default fails with a message that says so. It was "a routed launch requires a model". - The Codex adapter names its provider "router", not "openrouter". The name is local to the config file the adapter writes at each launch. Limits, by design of the platform and not of this seam: the proxy must answer over HTTPS on port 443 at the root path, because the egress proxy intercepts only that. A scheme, a port, or a path prefix needs an egress change first. The migration adds one table and changes no existing one. On a rollback, a profile that names `litellm` fails closed as an unknown router. `@ai-sdk/openai-compatible` is pinned to the release built against the same provider package as `ai`; a newer one fails the type check. Not verified against a live proxy: the `/v1/models` body shape and the Codex base path follow the OpenAI API, which LiteLLM documents as compatible. The dev-stack check for this PR covers them. Co-Authored-By: Claude Fable 5.2 --- crates/engram-harness-codex/src/main.rs | 9 +- .../proto/engram/app/v1/model_router.proto | 14 + orchestrator/bun.lock | 3 + orchestrator/drizzle/0101_router_settings.sql | 7 + orchestrator/drizzle/meta/0101_snapshot.json | 7552 +++++++++++++++++ orchestrator/drizzle/meta/_journal.json | 7 + orchestrator/package.json | 1 + .../src/__tests__/chat-decisions.test.ts | 116 + .../__tests__/model-routers-gating.test.ts | 35 +- .../src/__tests__/model-routers.test.ts | 2 + .../__tests__/openrouter-decisions.test.ts | 18 +- .../src/__tests__/task-create.test.ts | 51 + .../src/automations/engine/blocks/decide.ts | 11 +- orchestrator/src/automations/engine/deps.ts | 2 +- orchestrator/src/connectors/litellm.json | 37 + orchestrator/src/db/model-routers.ts | 20 + orchestrator/src/db/schema.ts | 10 + .../src/gen/engram/app/v1/model_router_pb.ts | 74 +- orchestrator/src/model-routers/catalog.ts | 7 +- .../src/model-routers/chat-decisions.ts | 85 + orchestrator/src/model-routers/client.ts | 54 +- orchestrator/src/model-routers/connected.ts | 14 +- orchestrator/src/model-routers/decisions.ts | 8 +- orchestrator/src/model-routers/models.ts | 19 + orchestrator/src/model-routers/registry.ts | 20 +- orchestrator/src/providers/litellm/router.ts | 67 + .../src/providers/openrouter/decisions.ts | 6 +- .../src/providers/openrouter/router.ts | 8 +- orchestrator/src/rpc/model-routers.ts | 80 +- orchestrator/src/rpc/task-create.ts | 14 +- orchestrator/src/tools/automation-draft.ts | 4 +- ..._router-ModelRouterService_connectquery.ts | 5 + web/src/gen/engram/app/v1/model_router_pb.ts | 74 +- 33 files changed, 8318 insertions(+), 116 deletions(-) create mode 100644 orchestrator/drizzle/0101_router_settings.sql create mode 100644 orchestrator/drizzle/meta/0101_snapshot.json create mode 100644 orchestrator/src/__tests__/chat-decisions.test.ts create mode 100644 orchestrator/src/connectors/litellm.json create mode 100644 orchestrator/src/model-routers/chat-decisions.ts create mode 100644 orchestrator/src/model-routers/models.ts create mode 100644 orchestrator/src/providers/litellm/router.ts diff --git a/crates/engram-harness-codex/src/main.rs b/crates/engram-harness-codex/src/main.rs index dad0c4e00..d7469ca2b 100644 --- a/crates/engram-harness-codex/src/main.rs +++ b/crates/engram-harness-codex/src/main.rs @@ -2501,7 +2501,7 @@ async fn write_router_config(runtime_home: &Path) -> Result<(), String> { fn router_config(model: &str, base_url: &str, helper: &str) -> String { let quote = |value: &str| serde_json::to_string(value).expect("string JSON cannot fail"); format!( - "model = {}\nmodel_provider = \"openrouter\"\n\n[model_providers.openrouter]\nname = \"OpenRouter\"\nbase_url = {}\nwire_api = \"responses\"\n\n[model_providers.openrouter.auth]\ncommand = {}\nargs = [\"__model-router-token\"]\ntimeout_ms = 5000\nrefresh_interval_ms = 0\n", + "model = {}\nmodel_provider = \"router\"\n\n[model_providers.router]\nname = \"Model router\"\nbase_url = {}\nwire_api = \"responses\"\n\n[model_providers.router.auth]\ncommand = {}\nargs = [\"__model-router-token\"]\ntimeout_ms = 5000\nrefresh_interval_ms = 0\n", quote(model), quote(base_url), quote(helper), @@ -2533,14 +2533,13 @@ mod tests { #[test] fn routed_provider_config_uses_responses_and_command_auth() { - for host in ["openrouter.ai", "us.openrouter.ai", "eu.openrouter.ai"] { - let base_url = format!("https://{host}/api/v1"); + for base_url in ["https://openrouter.ai/api/v1", "https://llm.example.com/v1"] { let config = router_config( "deepseek/deepseek-v4-pro-0813", - &base_url, + base_url, "/sbin/engram-harness-codex", ); - assert!(config.contains("model_provider = \"openrouter\"")); + assert!(config.contains("model_provider = \"router\"")); assert!(config.contains("wire_api = \"responses\"")); assert!(config.contains("command = \"/sbin/engram-harness-codex\"")); assert!(config.contains("args = [\"__model-router-token\"]")); diff --git a/crates/engram-protocol/proto/engram/app/v1/model_router.proto b/crates/engram-protocol/proto/engram/app/v1/model_router.proto index 9a3239eb8..80654552c 100644 --- a/crates/engram-protocol/proto/engram/app/v1/model_router.proto +++ b/crates/engram-protocol/proto/engram/app/v1/model_router.proto @@ -8,6 +8,7 @@ service ModelRouterService { rpc ListRouterModels(ListRouterModelsRequest) returns (ListRouterModelsResponse); rpc RefreshRouterModels(RefreshRouterModelsRequest) returns (RefreshRouterModelsResponse); rpc UpdateRouterModelPolicy(UpdateRouterModelPolicyRequest) returns (UpdateRouterModelPolicyResponse); + rpc UpdateModelRouterSettings(UpdateModelRouterSettingsRequest) returns (UpdateModelRouterSettingsResponse); } enum RouterModelAudience { @@ -30,7 +31,11 @@ message ModelRouter { uint32 model_count = 10; uint32 available_model_count = 11; uint32 enabled_model_count = 12; + // The model in use for each role: the admin's choice, else the router's + // built-in model. Empty when the role has no model. string default_model = 13; + string title_model = 14; + string decision_model = 15; } message RouterModel { @@ -79,3 +84,12 @@ message UpdateRouterModelPolicyRequest { bool user_enabled = 4; } message UpdateRouterModelPolicyResponse { RouterModel model = 1; } + +// An empty model clears the admin's choice for that role. +message UpdateModelRouterSettingsRequest { + string router_id = 1; + string default_model = 2; + string title_model = 3; + string decision_model = 4; +} +message UpdateModelRouterSettingsResponse { ModelRouter router = 1; } diff --git a/orchestrator/bun.lock b/orchestrator/bun.lock index ff21b5838..4e3bf1289 100644 --- a/orchestrator/bun.lock +++ b/orchestrator/bun.lock @@ -5,6 +5,7 @@ "": { "name": "engrams-orchestrator", "dependencies": { + "@ai-sdk/openai-compatible": "3.0.22", "@better-auth/api-key": "1.6.16", "@bufbuild/protobuf": "^2.12.0", "@casl/ability": "^7.0.0", @@ -50,6 +51,8 @@ "packages": { "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "4.0.5", "@ai-sdk/provider-utils": "5.0.20", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-poNySlk+zSe04M4v0wgJKt+mzY6Vk6abcQid58YZnFUBJYo6VwfsFUaW7oINoR1vYYB7Ne6eqY1doSxYKt0KDg=="], + "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "4.0.5", "@ai-sdk/provider-utils": "5.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KHohs5/BZWEw1q16sXdxr/jvG+DsoPlB5j98yQd+GFWfYIqB2qGtxnbvzpvGXLaNH9zZPUzF+qH6AiWvkjNjAQ=="], + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.5", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-9WAerTaPU2jcVi8dh8x27tySEuLur1Kfg7Go74GuCmsC/2MDSvrTkrK8ZrXWjryEevKfcPPmO1enveE7eqlzoA=="], "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.20", "", { "dependencies": { "@ai-sdk/provider": "4.0.5", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9W8c2mXiGgbo9sBBT4ybEWF8znxla2CO8VTim8t5oCCLP4aDH4o6RvkHfwL/iLC6mEbO4a6LpS2FBbQHccU+ig=="], diff --git a/orchestrator/drizzle/0101_router_settings.sql b/orchestrator/drizzle/0101_router_settings.sql new file mode 100644 index 000000000..9149d3045 --- /dev/null +++ b/orchestrator/drizzle/0101_router_settings.sql @@ -0,0 +1,7 @@ +CREATE TABLE "router_settings" ( + "router_id" text PRIMARY KEY NOT NULL, + "default_model" text, + "title_model" text, + "decision_model" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/orchestrator/drizzle/meta/0101_snapshot.json b/orchestrator/drizzle/meta/0101_snapshot.json new file mode 100644 index 000000000..28684e4f9 --- /dev/null +++ b/orchestrator/drizzle/meta/0101_snapshot.json @@ -0,0 +1,7552 @@ +{ + "id": "d438dfa9-d1e8-48b6-b9c6-464d1a8a7376", + "prevId": "785b7a9a-c956-4d05-81af-fdf4b2849339", + "version": "7", + "dialect": "postgresql", + "tables": { + "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", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "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", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apikey_reference_id_user_id_fk": { + "name": "apikey_reference_id_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "current_version": { + "name": "current_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artifact_owner_idx": { + "name": "artifact_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "artifact_visibility_idx": { + "name": "artifact_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artifact_owner_user_id_user_id_fk": { + "name": "artifact_owner_user_id_user_id_fk", + "tableFrom": "artifact", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact_version": { + "name": "artifact_version", + "schema": "", + "columns": { + "artifact_id": { + "name": "artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coord_artifact_id": { + "name": "coord_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artifact_version_session_idx": { + "name": "artifact_version_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artifact_version_artifact_id_artifact_id_fk": { + "name": "artifact_version_artifact_id_artifact_id_fk", + "tableFrom": "artifact_version", + "tableTo": "artifact", + "columnsFrom": [ + "artifact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "artifact_version_artifact_id_version_pk": { + "name": "artifact_version_artifact_id_version_pk", + "columns": [ + "artifact_id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation": { + "name": "automation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "builtin_key": { + "name": "builtin_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_version": { + "name": "current_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "block_overrides": { + "name": "block_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "end_sessions_on_finish": { + "name": "end_sessions_on_finish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_fire_at": { + "name": "next_fire_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 + }, + "draft_session_id": { + "name": "draft_session_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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "automation_due_idx": { + "name": "automation_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_fire_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_draft_session_idx": { + "name": "automation_draft_session_idx", + "columns": [ + { + "expression": "draft_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_builtin_key_unique": { + "name": "automation_builtin_key_unique", + "columns": [ + { + "expression": "builtin_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "builtin_key is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_concurrency_claim": { + "name": "automation_concurrency_claim", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "concurrency_key": { + "name": "concurrency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "automation_concurrency_claim_automation_id_automation_id_fk": { + "name": "automation_concurrency_claim_automation_id_automation_id_fk", + "tableFrom": "automation_concurrency_claim", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_concurrency_claim_automation_id_concurrency_key_pk": { + "name": "automation_concurrency_claim_automation_id_concurrency_key_pk", + "columns": [ + "automation_id", + "concurrency_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_drop": { + "name": "automation_drop", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entrypoint_id": { + "name": "entrypoint_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "dropped_at": { + "name": "dropped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_drop_automation_idx": { + "name": "automation_drop_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_drop_automation_id_automation_id_fk": { + "name": "automation_drop_automation_id_automation_id_fk", + "tableFrom": "automation_drop", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_instance": { + "name": "automation_instance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "opened_by": { + "name": "opened_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "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 + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "automation_instance_open_key_unique": { + "name": "automation_instance_open_key_unique", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_instance_automation_idx": { + "name": "automation_instance_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_instance_automation_id_automation_id_fk": { + "name": "automation_instance_automation_id_automation_id_fk", + "tableFrom": "automation_instance", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_instance_handle": { + "name": "automation_instance_handle", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "written_by": { + "name": "written_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_instance_handle_instance_idx": { + "name": "automation_instance_handle_instance_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_instance_handle_automation_id_automation_id_fk": { + "name": "automation_instance_handle_automation_id_automation_id_fk", + "tableFrom": "automation_instance_handle", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_instance_handle_instance_id_automation_instance_id_fk": { + "name": "automation_instance_handle_instance_id_automation_instance_id_fk", + "tableFrom": "automation_instance_handle", + "tableTo": "automation_instance", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_instance_handle_automation_id_handle_pk": { + "name": "automation_instance_handle_automation_id_handle_pk", + "columns": [ + "automation_id", + "handle" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_run": { + "name": "automation_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "trigger": { + "name": "trigger", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "delivery_key": { + "name": "delivery_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "concurrency_key": { + "name": "concurrency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rendered_prompt": { + "name": "rendered_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rendered_title": { + "name": "rendered_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "entrypoint_id": { + "name": "entrypoint_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "details_pruned_at": { + "name": "details_pruned_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": { + "automation_run_retention_idx": { + "name": "automation_run_retention_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "ended_at is not null and details_pruned_at is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_run_occurrence_unique": { + "name": "automation_run_occurrence_unique", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entrypoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "scheduled_for is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_run_delivery_unique": { + "name": "automation_run_delivery_unique", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entrypoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "delivery_key is not null and scheduled_for is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_run_automation_created_idx": { + "name": "automation_run_automation_created_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_run_lease_idx": { + "name": "automation_run_lease_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_run_concurrency_idx": { + "name": "automation_run_concurrency_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "concurrency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_run_automation_id_automation_id_fk": { + "name": "automation_run_automation_id_automation_id_fk", + "tableFrom": "automation_run", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_run_task_id_task_id_fk": { + "name": "automation_run_task_id_task_id_fk", + "tableFrom": "automation_run", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_session": { + "name": "automation_session", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'primary'" + }, + "keep": { + "name": "keep", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_session_run_idx": { + "name": "automation_session_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_session_run_id_automation_run_id_fk": { + "name": "automation_session_run_id_automation_run_id_fk", + "tableFrom": "automation_session", + "tableTo": "automation_run", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_session_relay": { + "name": "automation_session_relay", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "bigint", + "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": {}, + "foreignKeys": { + "automation_session_relay_session_id_automation_session_session_id_fk": { + "name": "automation_session_relay_session_id_automation_session_session_id_fk", + "tableFrom": "automation_session_relay", + "tableTo": "automation_session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_session_relay_automation_id_automation_id_fk": { + "name": "automation_session_relay_automation_id_automation_id_fk", + "tableFrom": "automation_session_relay", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_state": { + "name": "automation_state", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "writer": { + "name": "writer", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "automation_state_automation_id_automation_id_fk": { + "name": "automation_state_automation_id_automation_id_fk", + "tableFrom": "automation_state", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_state_automation_id_key_pk": { + "name": "automation_state_automation_id_key_pk", + "columns": [ + "automation_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_step_run": { + "name": "automation_step_run", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "automation_step_run_run_id_automation_run_id_fk": { + "name": "automation_step_run_run_id_automation_run_id_fk", + "tableFrom": "automation_step_run", + "tableTo": "automation_run", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_step_run_run_id_block_id_attempt_pk": { + "name": "automation_step_run_run_id_block_id_attempt_pk", + "columns": [ + "run_id", + "block_id", + "attempt" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_version": { + "name": "automation_version", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "blocks": { + "name": "blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "entrypoints": { + "name": "entrypoints", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "agent": { + "name": "agent", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_template": { + "name": "prompt_template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs_schema": { + "name": "inputs_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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": {}, + "foreignKeys": { + "automation_version_automation_id_automation_id_fk": { + "name": "automation_version_automation_id_automation_id_fk", + "tableFrom": "automation_version", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_version_automation_id_version_pk": { + "name": "automation_version_automation_id_version_pk", + "columns": [ + "automation_id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector": { + "name": "connector", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_logo": { + "name": "connector_logo", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.consumer_cursors": { + "name": "consumer_cursors", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumer": { + "name": "consumer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_idx": { + "name": "last_idx", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "consumer_cursors_session_id_consumer_pk": { + "name": "consumer_cursors_session_id_consumer_pk", + "columns": [ + "session_id", + "consumer" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.coordination_operation": { + "name": "coordination_operation", + "schema": "", + "columns": { + "caller_session_id": { + "name": "caller_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "reserved_task_id": { + "name": "reserved_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reserved_session_id": { + "name": "reserved_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "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": { + "coordination_operation_reserved_session_idx": { + "name": "coordination_operation_reserved_session_idx", + "columns": [ + { + "expression": "reserved_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "coordination_operation_caller_session_id_operation_idempotency_key_pk": { + "name": "coordination_operation_caller_session_id_operation_idempotency_key_pk", + "columns": [ + "caller_session_id", + "operation", + "idempotency_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dbos_sweep_lease": { + "name": "dbos_sweep_lease", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dbos_sweep_ledger": { + "name": "dbos_sweep_ledger", + "schema": "", + "columns": { + "workflow_uuid": { + "name": "workflow_uuid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_name": { + "name": "workflow_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sweep_count": { + "name": "sweep_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_swept_at": { + "name": "first_swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_swept_at": { + "name": "last_swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suppressed": { + "name": "suppressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "alerted_at": { + "name": "alerted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terminal_alerted_at": { + "name": "terminal_alerted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dbos_version_heartbeats": { + "name": "dbos_version_heartbeats", + "schema": "", + "columns": { + "application_version": { + "name": "application_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pod_name": { + "name": "pod_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "dbos_version_heartbeats_application_version_pod_name_pk": { + "name": "dbos_version_heartbeats_application_version_pod_name_pk", + "columns": [ + "application_version", + "pod_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_code_device_code_idx": { + "name": "device_code_device_code_idx", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_user_code_idx": { + "name": "device_code_user_code_idx", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connection": { + "name": "integration_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tested_at": { + "name": "tested_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": { + "integration_connection_provider_idx": { + "name": "integration_connection_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connection_provider_default_unique": { + "name": "integration_connection_provider_default_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_connection_alias_unique": { + "name": "integration_connection_alias_unique", + "nullsNotDistinct": false, + "columns": [ + "alias" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_event": { + "name": "integration_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "scope_value": { + "name": "scope_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_event_delivery_unique": { + "name": "integration_event_delivery_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_event_conn_key_idx": { + "name": "integration_event_conn_key_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_event_connection_id_integration_connection_id_fk": { + "name": "integration_event_connection_id_integration_connection_id_fk", + "tableFrom": "integration_event", + "tableTo": "integration_connection", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_oidc_key": { + "name": "integration_oidc_key", + "schema": "", + "columns": { + "kid": { + "name": "kid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_jwk": { + "name": "public_jwk", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "wrapped_dek": { + "name": "wrapped_dek", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publish_until": { + "name": "publish_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "integration_oidc_key_active_unique": { + "name": "integration_oidc_key_active_unique", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "state = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_setting": { + "name": "org_setting", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.papercuts": { + "name": "papercuts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "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()" + } + }, + "indexes": { + "papercuts_session_tool_call_unique": { + "name": "papercuts_session_tool_call_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "papercuts_created_at_idx": { + "name": "papercuts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "papercuts_archived_at_idx": { + "name": "papercuts_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_tool_calls": { + "name": "pending_tool_calls", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handling": { + "name": "handling", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pending_tool_calls_session_tool_call_unique": { + "name": "pending_tool_calls_session_tool_call_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_tool_calls_session_idx": { + "name": "pending_tool_calls_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_tool_calls_awaiting_idx": { + "name": "pending_tool_calls_awaiting_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"pending_tool_calls\".\"handling\" = 'session' AND \"pending_tool_calls\".\"submitted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_ref": { + "name": "pr_ref", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "authoring_task_id": { + "name": "authoring_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pr_ref_repo_pr_number_unique": { + "name": "pr_ref_repo_pr_number_unique", + "columns": [ + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_ref_authoring_task_idx": { + "name": "pr_ref_authoring_task_idx", + "columns": [ + { + "expression": "authoring_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_ref_session_idx": { + "name": "pr_ref_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_ref_authoring_task_id_task_id_fk": { + "name": "pr_ref_authoring_task_id_task_id_fk", + "tableFrom": "pr_ref", + "tableTo": "task", + "columnsFrom": [ + "authoring_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Bot'" + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_router": { + "name": "model_router", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "programmatic_only": { + "name": "programmatic_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_user_tokens": { + "name": "include_user_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "env_vars": { + "name": "env_vars", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "integration_grants": { + "name": "integration_grants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "network": { + "name": "network", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"default\":\"deny\",\"allowHosts\":[],\"allowHostPatterns\":[]}'::jsonb" + }, + "secrets": { + "name": "secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "repos": { + "name": "repos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "apps": { + "name": "apps", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review": { + "name": "review", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_sha": { + "name": "base_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "github_review_id": { + "name": "github_review_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finder_session_id": { + "name": "finder_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verifier_session_id": { + "name": "verifier_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "automation_run_id": { + "name": "automation_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_md": { + "name": "summary_md", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "changed_files": { + "name": "changed_files", + "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": { + "review_target_idx": { + "name": "review_target_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_task_idx": { + "name": "review_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_one_active_per_target_idx": { + "name": "review_one_active_per_target_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"review\".\"status\" IN ('queued', 'finding', 'verifying')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_finder_session_idx": { + "name": "review_finder_session_idx", + "columns": [ + { + "expression": "finder_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_verifier_session_idx": { + "name": "review_verifier_session_idx", + "columns": [ + { + "expression": "verifier_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_target_id_review_target_id_fk": { + "name": "review_target_id_review_target_id_fk", + "tableFrom": "review", + "tableTo": "review_target", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_task_id_task_id_fk": { + "name": "review_task_id_task_id_fk", + "tableFrom": "review", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_event": { + "name": "review_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_id": { + "name": "review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_event_review_idx": { + "name": "review_event_review_idx", + "columns": [ + { + "expression": "review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_event_review_id_review_id_fk": { + "name": "review_event_review_id_review_id_fk", + "tableFrom": "review_event", + "tableTo": "review", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_finding": { + "name": "review_finding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_id": { + "name": "review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_line": { + "name": "start_line", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "end_line": { + "name": "end_line", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_md": { + "name": "body_md", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_fix": { + "name": "suggested_fix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "evidence": { + "name": "evidence", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'candidate'" + }, + "verdict_reason": { + "name": "verdict_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_thread_id": { + "name": "github_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_finding_session_tool_call_unique": { + "name": "review_finding_session_tool_call_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_finding_review_idx": { + "name": "review_finding_review_idx", + "columns": [ + { + "expression": "review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_finding_review_id_review_id_fk": { + "name": "review_finding_review_id_review_id_fk", + "tableFrom": "review_finding", + "tableTo": "review", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_target": { + "name": "review_target", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_updated_at": { + "name": "provider_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hydration_failed_at": { + "name": "hydration_failed_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": { + "review_target_provider_id_unique": { + "name": "review_target_provider_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_target_coordinate_idx": { + "name": "review_target_coordinate_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_verdict": { + "name": "review_verdict", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_verdict_session_tool_call_unique": { + "name": "review_verdict_session_tool_call_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_verdict_finding_unique": { + "name": "review_verdict_finding_unique", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_verdict_finding_id_review_finding_id_fk": { + "name": "review_verdict_finding_id_review_finding_id_fk", + "tableFrom": "review_verdict", + "tableTo": "review_finding", + "columnsFrom": [ + "finding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.router_model": { + "name": "router_model", + "schema": "", + "columns": { + "router_id": { + "name": "router_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_slug": { + "name": "canonical_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_length": { + "name": "context_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "prompt_price": { + "name": "prompt_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completion_price": { + "name": "completion_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_modalities": { + "name": "input_modalities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "output_modalities": { + "name": "output_modalities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "supported_parameters": { + "name": "supported_parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "hugging_face_id": { + "name": "hugging_face_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "upstream": { + "name": "upstream", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "available": { + "name": "available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "router_model_available_idx": { + "name": "router_model_available_idx", + "columns": [ + { + "expression": "router_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "router_model_router_id_model_id_pk": { + "name": "router_model_router_id_model_id_pk", + "columns": [ + "router_id", + "model_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.router_model_policy": { + "name": "router_model_policy", + "schema": "", + "columns": { + "router_id": { + "name": "router_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "user_enabled": { + "name": "user_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "router_model_policy_router_id_model_id_router_model_router_id_model_id_fk": { + "name": "router_model_policy_router_id_model_id_router_model_router_id_model_id_fk", + "tableFrom": "router_model_policy", + "tableTo": "router_model", + "columnsFrom": [ + "router_id", + "model_id" + ], + "columnsTo": [ + "router_id", + "model_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "router_model_policy_router_id_model_id_pk": { + "name": "router_model_policy_router_id_model_id_pk", + "columns": [ + "router_id", + "model_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "router_model_policy_user_implies_enabled": { + "name": "router_model_policy_user_implies_enabled", + "value": "NOT \"router_model_policy\".\"user_enabled\" OR \"router_model_policy\".\"enabled\"" + } + }, + "isRLSEnabled": false + }, + "public.router_settings": { + "name": "router_settings", + "schema": "", + "columns": { + "router_id": { + "name": "router_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title_model": { + "name": "title_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decision_model": { + "name": "decision_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.router_sync_state": { + "name": "router_sync_state", + "schema": "", + "columns": { + "router_id": { + "name": "router_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_successful_sync_at": { + "name": "last_successful_sync_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 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "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 + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_app": { + "name": "session_app", + "schema": "", + "columns": { + "host_label": { + "name": "host_label", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'org'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_app_session_name_idx": { + "name": "session_app_session_name_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_app_session_port_idx": { + "name": "session_app_session_port_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "port", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_app_session_idx": { + "name": "session_app_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_app_owner_idx": { + "name": "session_app_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_app_owner_user_id_user_id_fk": { + "name": "session_app_owner_user_id_user_id_fk", + "tableFrom": "session_app", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_listeners": { + "name": "session_listeners", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dormant_at": { + "name": "dormant_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "woken_at": { + "name": "woken_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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec": { + "name": "spec", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_doc_seq": { + "name": "current_doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "current_semantic_doc_seq": { + "name": "current_semantic_doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "published_checkpoint_id": { + "name": "published_checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_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": { + "spec_org_updated_idx": { + "name": "spec_org_updated_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "spec_owner_idx": { + "name": "spec_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "spec_session_idx": { + "name": "spec_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_owner_user_id_user_id_fk": { + "name": "spec_owner_user_id_user_id_fk", + "tableFrom": "spec", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "spec_template_id_spec_template_id_fk": { + "name": "spec_template_id_spec_template_id_fk", + "tableFrom": "spec", + "tableTo": "spec_template", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "spec_published_by_user_id_fk": { + "name": "spec_published_by_user_id_fk", + "tableFrom": "spec", + "tableTo": "user", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_chat_message": { + "name": "spec_chat_message", + "schema": "", + "columns": { + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "spec_chat_message_spec_created_idx": { + "name": "spec_chat_message_spec_created_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_chat_message_spec_id_spec_id_fk": { + "name": "spec_chat_message_spec_id_spec_id_fk", + "tableFrom": "spec_chat_message", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_chat_message_author_user_id_user_id_fk": { + "name": "spec_chat_message_author_user_id_user_id_fk", + "tableFrom": "spec_chat_message", + "tableTo": "user", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_checkpoint": { + "name": "spec_checkpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "state_vector": { + "name": "state_vector", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "rendered_markdown": { + "name": "rendered_markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "doc_seq": { + "name": "doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "spec_checkpoint_spec_created_idx": { + "name": "spec_checkpoint_spec_created_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_checkpoint_spec_id_spec_id_fk": { + "name": "spec_checkpoint_spec_id_spec_id_fk", + "tableFrom": "spec_checkpoint", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_checkpoint_author_user_id_user_id_fk": { + "name": "spec_checkpoint_author_user_id_user_id_fk", + "tableFrom": "spec_checkpoint", + "tableTo": "user", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_drafting_seed": { + "name": "spec_drafting_seed", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spec_drafting_seed_due_idx": { + "name": "spec_drafting_seed_due_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"spec_drafting_seed\".\"state\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_drafting_seed_spec_id_spec_id_fk": { + "name": "spec_drafting_seed_spec_id_spec_id_fk", + "tableFrom": "spec_drafting_seed", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "spec_drafting_seed_prompt_id_unique": { + "name": "spec_drafting_seed_prompt_id_unique", + "nullsNotDistinct": false, + "columns": [ + "prompt_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_gap_check_finding": { + "name": "spec_gap_check_finding", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "finding_id": { + "name": "finding_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "layer_key": { + "name": "layer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "section_title": { + "name": "section_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requirement_id": { + "name": "requirement_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_diff": { + "name": "proposed_diff", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "open_question_id": { + "name": "open_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "disposed_by": { + "name": "disposed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disposed_at": { + "name": "disposed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "spec_gap_check_finding_run_id_spec_gap_check_run_id_fk": { + "name": "spec_gap_check_finding_run_id_spec_gap_check_run_id_fk", + "tableFrom": "spec_gap_check_finding", + "tableTo": "spec_gap_check_run", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_gap_check_finding_disposed_by_user_id_fk": { + "name": "spec_gap_check_finding_disposed_by_user_id_fk", + "tableFrom": "spec_gap_check_finding", + "tableTo": "user", + "columnsFrom": [ + "disposed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "spec_gap_check_finding_run_id_finding_id_pk": { + "name": "spec_gap_check_finding_run_id_finding_id_pk", + "columns": [ + "run_id", + "finding_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_gap_check_run": { + "name": "spec_gap_check_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "semantic_doc_seq": { + "name": "semantic_doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "stopped_at_layer_key": { + "name": "stopped_at_layer_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suppressed_count": { + "name": "suppressed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "matrix": { + "name": "matrix", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "started_by": { + "name": "started_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "spec_gap_check_run_fingerprint_idx": { + "name": "spec_gap_check_run_fingerprint_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "spec_gap_check_run_spec_created_idx": { + "name": "spec_gap_check_run_spec_created_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_gap_check_run_spec_id_spec_id_fk": { + "name": "spec_gap_check_run_spec_id_spec_id_fk", + "tableFrom": "spec_gap_check_run", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_gap_check_run_started_by_user_id_fk": { + "name": "spec_gap_check_run_started_by_user_id_fk", + "tableFrom": "spec_gap_check_run", + "tableTo": "user", + "columnsFrom": [ + "started_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_open_question": { + "name": "spec_open_question", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opened_by": { + "name": "opened_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "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()" + } + }, + "indexes": { + "spec_open_question_spec_section_idx": { + "name": "spec_open_question_spec_section_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "section_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_open_question_spec_id_spec_id_fk": { + "name": "spec_open_question_spec_id_spec_id_fk", + "tableFrom": "spec_open_question", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_open_question_opened_by_user_id_fk": { + "name": "spec_open_question_opened_by_user_id_fk", + "tableFrom": "spec_open_question", + "tableTo": "user", + "columnsFrom": [ + "opened_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "spec_open_question_resolved_by_user_id_fk": { + "name": "spec_open_question_resolved_by_user_id_fk", + "tableFrom": "spec_open_question", + "tableTo": "user", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_participant": { + "name": "spec_participant", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_epoch": { + "name": "connection_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "spec_participant_live_idx": { + "name": "spec_participant_live_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_participant_spec_id_spec_id_fk": { + "name": "spec_participant_spec_id_spec_id_fk", + "tableFrom": "spec_participant", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_participant_user_id_user_id_fk": { + "name": "spec_participant_user_id_user_id_fk", + "tableFrom": "spec_participant", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "spec_participant_spec_id_client_id_pk": { + "name": "spec_participant_spec_id_client_id_pk", + "columns": [ + "spec_id", + "client_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_projection": { + "name": "spec_projection", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rev": { + "name": "rev", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "doc_seq": { + "name": "doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "semantic_doc_seq": { + "name": "semantic_doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendered": { + "name": "rendered", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "document_state": { + "name": "document_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "digest": { + "name": "digest", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "digest_sha256": { + "name": "digest_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "staging_path": { + "name": "staging_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_source": { + "name": "requested_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discard_notice": { + "name": "discard_notice", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pushed_at": { + "name": "pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "spec_projection_session_state_idx": { + "name": "spec_projection_session_state_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rev", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_projection_spec_id_spec_id_fk": { + "name": "spec_projection_spec_id_spec_id_fk", + "tableFrom": "spec_projection", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "spec_projection_spec_id_rev_pk": { + "name": "spec_projection_spec_id_rev_pk", + "columns": [ + "spec_id", + "rev" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_publish": { + "name": "spec_publish", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "artifact_id": { + "name": "artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifact_version": { + "name": "artifact_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "acknowledged_question_count": { + "name": "acknowledged_question_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "acknowledged_question_ids": { + "name": "acknowledged_question_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "gap_check_run_id": { + "name": "gap_check_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spec_publish_due_idx": { + "name": "spec_publish_due_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"spec_publish\".\"state\" <> 'complete'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_publish_spec_id_spec_id_fk": { + "name": "spec_publish_spec_id_spec_id_fk", + "tableFrom": "spec_publish", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_publish_requested_by_user_id_fk": { + "name": "spec_publish_requested_by_user_id_fk", + "tableFrom": "spec_publish", + "tableTo": "user", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_section_state": { + "name": "spec_section_state", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "na_reason": { + "name": "na_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settled_by": { + "name": "settled_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "spec_section_state_spec_id_spec_id_fk": { + "name": "spec_section_state_spec_id_spec_id_fk", + "tableFrom": "spec_section_state", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_section_state_settled_by_user_id_fk": { + "name": "spec_section_state_settled_by_user_id_fk", + "tableFrom": "spec_section_state", + "tableTo": "user", + "columnsFrom": [ + "settled_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "spec_section_state_spec_id_section_id_pk": { + "name": "spec_section_state_spec_id_section_id_pk", + "columns": [ + "spec_id", + "section_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_snapshot": { + "name": "spec_snapshot", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "state_vector": { + "name": "state_vector", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "covered_seq": { + "name": "covered_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "covered_semantic_doc_seq": { + "name": "covered_semantic_doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "spec_snapshot_spec_id_spec_id_fk": { + "name": "spec_snapshot_spec_id_spec_id_fk", + "tableFrom": "spec_snapshot", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_template": { + "name": "spec_template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "layers": { + "name": "layers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sections": { + "name": "sections", + "type": "jsonb", + "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": { + "spec_template_org_idx": { + "name": "spec_template_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_ticket_draft": { + "name": "spec_ticket_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "sync_state": { + "name": "sync_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "linear_id": { + "name": "linear_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spec_ticket_draft_tree_idx": { + "name": "spec_ticket_draft_tree_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_ticket_draft_spec_id_spec_id_fk": { + "name": "spec_ticket_draft_spec_id_spec_id_fk", + "tableFrom": "spec_ticket_draft", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_ticket_draft_parent_id_fk": { + "name": "spec_ticket_draft_parent_id_fk", + "tableFrom": "spec_ticket_draft", + "tableTo": "spec_ticket_draft", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_ticket_sync_config": { + "name": "spec_ticket_sync_config", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label_ids": { + "name": "label_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "label_names": { + "name": "label_names", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "spec_ticket_sync_config_spec_id_spec_id_fk": { + "name": "spec_ticket_sync_config_spec_id_spec_id_fk", + "tableFrom": "spec_ticket_sync_config", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_ticket_sync_operation": { + "name": "spec_ticket_sync_operation", + "schema": "", + "columns": { + "caller_spec_id": { + "name": "caller_spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "reserved_ticket_id": { + "name": "reserved_ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reserved_external_id": { + "name": "reserved_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "result": { + "name": "result", + "type": "jsonb", + "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": { + "spec_ticket_sync_operation_ticket_idx": { + "name": "spec_ticket_sync_operation_ticket_idx", + "columns": [ + { + "expression": "reserved_ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_ticket_sync_operation_caller_spec_id_spec_id_fk": { + "name": "spec_ticket_sync_operation_caller_spec_id_spec_id_fk", + "tableFrom": "spec_ticket_sync_operation", + "tableTo": "spec", + "columnsFrom": [ + "caller_spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "spec_ticket_sync_operation_caller_spec_id_operation_idempotency_key_pk": { + "name": "spec_ticket_sync_operation_caller_spec_id_operation_idempotency_key_pk", + "columns": [ + "caller_spec_id", + "operation", + "idempotency_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_transcript_action": { + "name": "spec_transcript_action", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chip": { + "name": "chip", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spec_transcript_action_pending_idx": { + "name": "spec_transcript_action_pending_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"spec_transcript_action\".\"delivered_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_transcript_action_spec_id_spec_id_fk": { + "name": "spec_transcript_action_spec_id_spec_id_fk", + "tableFrom": "spec_transcript_action", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_transcript_action_actor_user_id_user_id_fk": { + "name": "spec_transcript_action_actor_user_id_user_id_fk", + "tableFrom": "spec_transcript_action", + "tableTo": "user", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_update_log": { + "name": "spec_update_log", + "schema": "", + "columns": { + "seq": { + "name": "seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "update": { + "name": "update", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "semantic_doc_seq": { + "name": "semantic_doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changed_section_ids": { + "name": "changed_section_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "spec_update_log_spec_id_spec_id_fk": { + "name": "spec_update_log_spec_id_spec_id_fk", + "tableFrom": "spec_update_log", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "spec_update_log_spec_id_seq_pk": { + "name": "spec_update_log_spec_id_seq_pk", + "columns": [ + "spec_id", + "seq" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task": { + "name": "task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_title": { + "name": "suggested_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_router": { + "name": "model_router", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_task_id": { + "name": "parent_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_task_id": { + "name": "root_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_task_name": { + "name": "local_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_task_name": { + "name": "canonical_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spawning_session_id": { + "name": "spawning_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_policy": { + "name": "launch_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_slack_channel_created_idx": { + "name": "task_slack_channel_created_idx", + "columns": [ + { + "expression": "(source->>'team')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "(source->>'channel')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "source->>'provider' = 'slack'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_parent_idx": { + "name": "task_parent_idx", + "columns": [ + { + "expression": "parent_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_root_idx": { + "name": "task_root_idx", + "columns": [ + { + "expression": "root_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_root_canonical_name_unique": { + "name": "task_root_canonical_name_unique", + "columns": [ + { + "expression": "root_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "canonical_task_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "canonical_task_name IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_session": { + "name": "task_session", + "schema": "", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "integration_grants": { + "name": "integration_grants", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "integration_connections": { + "name": "integration_connections", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "integration_principal_id": { + "name": "integration_principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "integration_snapshot_hash": { + "name": "integration_snapshot_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_session_session_idx": { + "name": "task_session_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_session_task_id_task_id_fk": { + "name": "task_session_task_id_task_id_fk", + "tableFrom": "task_session", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_session_profile_id_profile_id_fk": { + "name": "task_session_profile_id_profile_id_fk", + "tableFrom": "task_session", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "task_session_task_id_session_id_pk": { + "name": "task_session_task_id_session_id_pk", + "columns": [ + "task_id", + "session_id" + ] + } + }, + "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", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_session_secrets": { + "name": "user_session_secrets", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env_var_name": { + "name": "env_var_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wrapped_dek": { + "name": "wrapped_dek", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_session_secrets_user_id_user_id_fk": { + "name": "user_session_secrets_user_id_user_id_fk", + "tableFrom": "user_session_secrets", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_session_secrets_user_id_env_var_name_pk": { + "name": "user_session_secrets_user_id_env_var_name_pk", + "columns": [ + "user_id", + "env_var_name" + ] + } + }, + "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", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_registration": { + "name": "webhook_registration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verification": { + "name": "verification", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "provider_hint": { + "name": "provider_hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "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": { + "webhook_registration_provider_idx": { + "name": "webhook_registration_provider_idx", + "columns": [ + { + "expression": "provider_hint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_sample": { + "name": "webhook_sample", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "registration_id": { + "name": "registration_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_sample_registration_event_idx": { + "name": "webhook_sample_registration_event_idx", + "columns": [ + { + "expression": "registration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_sample_registration_id_webhook_registration_id_fk": { + "name": "webhook_sample_registration_id_webhook_registration_id_fk", + "tableFrom": "webhook_sample", + "tableTo": "webhook_registration", + "columnsFrom": [ + "registration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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/orchestrator/drizzle/meta/_journal.json b/orchestrator/drizzle/meta/_journal.json index 7288b7157..e140e2c43 100644 --- a/orchestrator/drizzle/meta/_journal.json +++ b/orchestrator/drizzle/meta/_journal.json @@ -708,6 +708,13 @@ "when": 1793241391817, "tag": "0100_profile_programmatic_only", "breakpoints": true + }, + { + "idx": 101, + "version": "7", + "when": 1794414748842, + "tag": "0101_router_settings", + "breakpoints": true } ] } \ No newline at end of file diff --git a/orchestrator/package.json b/orchestrator/package.json index 181f1270c..3e54cd8c3 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -12,6 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@ai-sdk/openai-compatible": "3.0.22", "@better-auth/api-key": "1.6.16", "@bufbuild/protobuf": "^2.12.0", "@casl/ability": "^7.0.0", diff --git a/orchestrator/src/__tests__/chat-decisions.test.ts b/orchestrator/src/__tests__/chat-decisions.test.ts new file mode 100644 index 000000000..2605583d5 --- /dev/null +++ b/orchestrator/src/__tests__/chat-decisions.test.ts @@ -0,0 +1,116 @@ +/** A decisions request answered by a structured chat completion, and the + * LiteLLM model list. */ + +import { describe, expect, test } from "bun:test"; +import { APICallError } from "ai"; +import { MockLanguageModelV4 } from "ai/test"; + +import { decideByChat } from "../model-routers/chat-decisions.ts"; +import { DecisionsApiError } from "../model-routers/decisions.ts"; +import { parseLiteLlmCatalog } from "../providers/litellm/router.ts"; + +const REQUEST = { + model: "gpt-mini", + state: "the build is red on main", + questions: { + urgent: { type: "noul" as const, instructions: "Is this urgent?" }, + team: { + type: "choice" as const, + instructions: "Which team owns it?", + criteria: { infra: "build and deploy", web: null }, + }, + risk: { type: "score" as const, instructions: "How risky?", criteria: ["low", "medium", "high"] }, + }, +}; + +function modelAnswering(answer: unknown): MockLanguageModelV4 { + return new MockLanguageModelV4({ + doGenerate: { + content: [{ type: "text", text: JSON.stringify(answer) }], + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { total: 40, noCache: 40, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 12, text: 12, reasoning: undefined }, + }, + warnings: [], + }, + }); +} + +async function failure(promise: Promise): Promise { + try { + await promise; + } catch (error) { + expect(error).toBeInstanceOf(DecisionsApiError); + return error as DecisionsApiError; + } + throw new Error("expected a failure"); +} + +describe("decision by chat completion", () => { + test("maps the model's object onto typed answers and clamps the numbers", async () => { + const model = modelAnswering({ + urgent: { noul: 1.4 }, + team: { choice: "infra", confidence: 0.8 }, + risk: { score: 7, confidence: -1 }, + }); + const res = await decideByChat(model, REQUEST); + expect(res.model).toBe("gpt-mini"); + expect(res.answers).toEqual({ + urgent: { type: "noul", noul: 1 }, + team: { type: "choice", choice: "infra", confidence: 0.8 }, + risk: { type: "score", score: 2, confidence: 0 }, + }); + expect(res.usage).toEqual({ input_tokens: 40, output_tokens: 12 }); + // The model reads the state and the questions as sent. + expect(JSON.stringify(model.doGenerateCalls[0]!.prompt)).toContain("the build is red on main"); + }); + + test("a choice outside the options is a retryable failure", async () => { + const model = modelAnswering({ + urgent: { noul: 0.5 }, + team: { choice: "sales", confidence: 0.9 }, + risk: { score: 1, confidence: 0.5 }, + }); + expect((await failure(decideByChat(model, REQUEST))).retryable).toBe(true); + }); + + test("an API rejection that a retry cannot fix is permanent", async () => { + const model = new MockLanguageModelV4({ + doGenerate: async () => { + throw new APICallError({ + message: "unknown model", + url: "https://llm.example.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 404, + isRetryable: false, + }); + }, + }); + const error = await failure(decideByChat(model, REQUEST)); + expect(error.retryable).toBe(false); + expect(error.status).toBe(404); + }); +}); + +describe("LiteLLM model list", () => { + test("keeps each model id once, with its owner", () => { + const body = new TextEncoder().encode(JSON.stringify({ + object: "list", + data: [ + { id: "claude-sonnet", object: "model", owned_by: "anthropic" }, + { id: "gpt-mini", object: "model" }, + { id: "claude-sonnet", object: "model", owned_by: "anthropic" }, + ], + })); + const models = parseLiteLlmCatalog(body); + expect(models.map((model) => [model.modelId, model.author])).toEqual([ + ["claude-sonnet", "anthropic"], + ["gpt-mini", null], + ]); + }); + + test("a body that is not a model list is an error", () => { + expect(() => parseLiteLlmCatalog(new TextEncoder().encode('{"error":"nope"}'))).toThrow(); + }); +}); diff --git a/orchestrator/src/__tests__/model-routers-gating.test.ts b/orchestrator/src/__tests__/model-routers-gating.test.ts index 052e7a763..683caf74c 100644 --- a/orchestrator/src/__tests__/model-routers-gating.test.ts +++ b/orchestrator/src/__tests__/model-routers-gating.test.ts @@ -16,11 +16,12 @@ import { describe, expect, test } from "bun:test"; import { Code, ConnectError, createClient, createRouterTransport } from "@connectrpc/connect"; -import type { ModelRouterStore } from "../db/model-routers.ts"; +import type { ModelRouterStore, RouterSettings } from "../db/model-routers.ts"; import { registerModelRouters } from "../rpc/model-routers.ts"; import { ModelRouterService, RouterModelAudience } from "../gen/engram/app/v1/model_router_pb.ts"; function fakeStore(): ModelRouterStore { + const settings = new Map(); return { listModels: async () => [], getModel: async () => null, @@ -28,6 +29,10 @@ function fakeStore(): ModelRouterStore { updatePolicy: async () => null, getSyncState: async () => null, recordFailure: async () => {}, + getSettings: async (routerId) => settings.get(routerId) ?? null, + setSettings: async (routerId, value) => { + settings.set(routerId, value); + }, }; } @@ -55,8 +60,34 @@ describe("router connection gating", () => { test("admin with no key: router listed as not configured (the setup surface)", async () => { const api = client("admin", []); const result = await api.listModelRouters({}); - expect(result.routers.map((r) => r.id)).toEqual(["openrouter"]); + expect(result.routers.map((r) => r.id)).toEqual(["openrouter", "litellm"]); expect(result.routers[0].credentialConfigured).toBe(false); + // No proxy host is set yet, so the router has no egress host. + expect(result.routers[1].egressHosts).toEqual([]); + }); + + test("an admin's model per role replaces the built-in one; empty restores it", async () => { + const api = client("admin", ["openrouter.api_key", "litellm.api_key"]); + const [openrouter, litellm] = (await api.listModelRouters({})).routers; + expect(openrouter.decisionModel).toBe("typesafe/jev-1.13"); + expect(litellm).toMatchObject({ defaultModel: "", titleModel: "", decisionModel: "" }); + + const set = await api.updateModelRouterSettings({ + routerId: "litellm", + defaultModel: "claude-sonnet", + titleModel: " gpt-mini ", + decisionModel: "", + }); + expect(set.router).toMatchObject({ defaultModel: "claude-sonnet", titleModel: "gpt-mini", decisionModel: "" }); + + await api.updateModelRouterSettings({ routerId: "openrouter", titleModel: "other/title" }); + const cleared = await api.updateModelRouterSettings({ routerId: "openrouter" }); + expect(cleared.router?.titleModel).toBe("deepseek/deepseek-v4-flash"); + }); + + test("only an admin sets a router's models", async () => { + const api = client("user", ["litellm.api_key"]); + await expect(api.updateModelRouterSettings({ routerId: "litellm", defaultModel: "m" })).rejects.toThrow(); }); test("non-admin with the key: router listed AND credentialConfigured=true", async () => { diff --git a/orchestrator/src/__tests__/model-routers.test.ts b/orchestrator/src/__tests__/model-routers.test.ts index 252c9a6bf..a8db2eb02 100644 --- a/orchestrator/src/__tests__/model-routers.test.ts +++ b/orchestrator/src/__tests__/model-routers.test.ts @@ -45,6 +45,8 @@ function store() { updatePolicy: async () => null, getSyncState: async () => null, recordFailure: async (_routerId, error) => { calls.failures.push(error); }, + getSettings: async () => null, + setSettings: async () => {}, }; return { value, calls }; } diff --git a/orchestrator/src/__tests__/openrouter-decisions.test.ts b/orchestrator/src/__tests__/openrouter-decisions.test.ts index c3dcd9903..748a3fdaa 100644 --- a/orchestrator/src/__tests__/openrouter-decisions.test.ts +++ b/orchestrator/src/__tests__/openrouter-decisions.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { DecisionsApiError, type DecisionsRequest } from "../model-routers/decisions.ts"; +import { DecisionsApiError } from "../model-routers/decisions.ts"; import { makeDecisionsClient } from "../providers/openrouter/decisions.ts"; -const REQUEST: DecisionsRequest = { +const REQUEST = { model: "typesafe/jev-1.13", state: "the build is red on main", - questions: { urgent: { type: "noul", instructions: "Is this urgent?" } }, + questions: { urgent: { type: "noul" as const, instructions: "Is this urgent?" } }, }; function clientReturning(status: number, body: string, seen?: Request[]) { @@ -51,18 +51,6 @@ describe("decisions client", () => { expect(await seen[0]!.json()).toEqual(REQUEST); }); - test("a request without a model uses the pinned default", async () => { - const seen: Request[] = []; - const body = JSON.stringify({ - model: "typesafe/jev-1.13", - answers: {}, - usage: { input_tokens: 1, output_tokens: 1 }, - }); - const { model: _model, ...request } = REQUEST; - await clientReturning(200, body, seen).decide(request); - expect(((await seen[0]!.json()) as { model: string }).model).toBe("typesafe/jev-1.13"); - }); - test("rate limits and provider failures are retryable", async () => { for (const status of [429, 502, 503, 524, 529]) { const error = await failure(clientReturning(status, "{}").decide(REQUEST)); diff --git a/orchestrator/src/__tests__/task-create.test.ts b/orchestrator/src/__tests__/task-create.test.ts index b5deeba5c..09d3412c0 100644 --- a/orchestrator/src/__tests__/task-create.test.ts +++ b/orchestrator/src/__tests__/task-create.test.ts @@ -149,6 +149,8 @@ const fakeModelRouters = (row = routedModel()): ModelRouterStore => ({ updatePolicy: async () => row, getSyncState: async () => null, recordFailure: async () => {}, + getSettings: async () => null, + setSettings: async () => {}, }); const fakeConnections = (): IntegrationConnectionStore => { @@ -786,6 +788,55 @@ describe("compileSessionCreateInput", () => { } }); + test("a second router launches on its own host and base paths", async () => { + const connections = fakeConnections(); + const row = (await connections.getDefault("litellm"))!; + connections.getDefault = async () => ({ + ...row, + config: { settings: { api_host: "llm.example.com" } }, + }); + const launch = (protocol: string, model: string | null) => + compileSessionCreateInput(profile({ modelRouter: "litellm", model }), { + ...deps(), + connections, + harnessCatalog: { + listHarnesses: async () => ({ + harnesses: [{ name: "claude", descriptor: { models: [], effort: [], routerProtocols: [protocol] } }], + }), + }, + orgSecret: { listSecrets: async () => ({ secrets: [{ name: "litellm.api_key" }] }) }, + modelRouters: fakeModelRouters(routedModel({ routerId: "litellm", modelId: "claude-sonnet" })), + }); + + for (const [protocol, baseUrl] of [ + ["anthropic_messages", "https://llm.example.com"], + ["openai_responses", "https://llm.example.com/v1"], + ] as const) { + const inp = await launch(protocol, "claude-sonnet"); + expect(inp.harnessEnv).toMatchObject({ + ENGRAM_MODEL_ROUTER_ID: "litellm", + ENGRAM_MODEL_ROUTER_BASE_URL: baseUrl, + ENGRAM_MODEL_ROUTER_MODEL: "claude-sonnet", + }); + const policy = JSON.parse(inp.integrationPolicyJson!); + expect(policy.secrets[0]).toMatchObject({ + secret_ref: "litellm.api_key", + allow_hosts: ["llm.example.com"], + }); + } + + // The router has no built-in default model and the admin chose none. + try { + await launch("anthropic_messages", null); + throw new Error("expected a missing-model failure"); + } catch (error) { + expect(error).toBeInstanceOf(ConnectError); + if (!(error instanceof ConnectError)) throw error; + expect(error.code).toBe(Code.FailedPrecondition); + expect(error.message).toContain("no default model"); + } + }); + test("fails before create when a routed model key is missing", async () => { const providerDeps: SessionCompileDeps = { ...deps(), diff --git a/orchestrator/src/automations/engine/blocks/decide.ts b/orchestrator/src/automations/engine/blocks/decide.ts index 6d24ce241..d0b408e1f 100644 --- a/orchestrator/src/automations/engine/blocks/decide.ts +++ b/orchestrator/src/automations/engine/blocks/decide.ts @@ -1,5 +1,6 @@ -/** The decide block: typed, calibrated decisions from a System One model - * (TypeSafe's Jev) through the OpenRouter Decisions API. +/** The decide block: typed decisions from a connected model router. A router + * with a native decisions endpoint answers with calibrated probabilities; any + * other router answers through a structured chat completion. * * One block asks one or more questions about one `state` in a single call — * the questions are answered in parallel, so a second question adds almost @@ -16,13 +17,13 @@ * into a label, rate a PR's risk or an incident's severity, decide "is this * noise?" before an expensive session. * - * An org without OpenRouter gets `{ decided: false }` — a normal output, so a - * graph falls back deterministically and the org keeps today's behavior. + * An org with no router that has a decision model gets `{ decided: false }` + * — a normal output, so a graph falls back deterministically. * * The call runs inside the block's checkpointed step: a call whose result was * recorded is never made again on replay. The guarantee is at-least-once, not * exactly-once — a crash after the provider answered and before the step was - * recorded repeats the call (the Decisions API has no idempotency key). + * recorded repeats the call (a decision has no idempotency key). * * An editor dry run makes the call for real: the block is read-only and * costs fractions of a cent, and a dry run is how an author tries their diff --git a/orchestrator/src/automations/engine/deps.ts b/orchestrator/src/automations/engine/deps.ts index 28a0d6b3e..8f895bc29 100644 --- a/orchestrator/src/automations/engine/deps.ts +++ b/orchestrator/src/automations/engine/deps.ts @@ -264,7 +264,7 @@ export interface EnginePrRefLookup { /** The decide block's seam: a connected model router in production, a fake * in tests and the simulator. */ export interface DecisionsRuntime { - /** Null when no connected model router can answer. */ + /** Null when no connected model router has a decision model. */ decide(request: DecisionsRequest): Promise; } diff --git a/orchestrator/src/connectors/litellm.json b/orchestrator/src/connectors/litellm.json new file mode 100644 index 000000000..c35002b55 --- /dev/null +++ b/orchestrator/src/connectors/litellm.json @@ -0,0 +1,37 @@ +{ + "provider": "litellm", + "protocol": "http", + "display": { + "name": "LiteLLM", + "category": "AI", + "blurb": "LLM inference through your own LiteLLM proxy. Routes sessions to the proxy's models and powers orchestrator-side titles and decisions; grantable to sessions for chat completions.", + "icon": { "mono": "LL", "color": "#2f6f4f" } + }, + "credential": { + "source": "inject", + "injects": [ + { "header": "Authorization", "template": "Bearer {}", "secretRef": "litellm.api_key" } + ] + }, + "hosts": [], + "settings": [ + { + "name": "api_host", + "label": "Proxy host", + "kind": "host", + "options": [], + "custom": { + "label": "Your proxy", + "hint": "The hostname your LiteLLM proxy answers on over HTTPS, for example llm.example.com. No scheme, port, or path." + }, + "hint": "Sessions and the orchestrator reach this host on port 443." + } + ], + "test": { "path": "/v1/models" }, + "operations": [ + { + "grants": ["chat:write"], + "match": { "method": "POST", "path": "/v1/chat/completions" } + } + ] +} diff --git a/orchestrator/src/db/model-routers.ts b/orchestrator/src/db/model-routers.ts index 527606bf0..b370875ad 100644 --- a/orchestrator/src/db/model-routers.ts +++ b/orchestrator/src/db/model-routers.ts @@ -4,6 +4,7 @@ import { getDb } from "./client.ts"; import { routerModel, routerModelPolicy, + routerSettings, routerSyncState, } from "./schema.ts"; @@ -40,6 +41,13 @@ export interface RouterSyncStateRow { lastError: string | null; } +/** An admin's model per role; null uses the router's built-in model. */ +export interface RouterSettings { + defaultModel: string | null; + titleModel: string | null; + decisionModel: string | null; +} + export interface ModelRouterStore { listModels(routerId: string, audience: RouterModelAudience, search?: string): Promise; getModel(routerId: string, modelId: string): Promise; @@ -47,6 +55,8 @@ export interface ModelRouterStore { updatePolicy(routerId: string, modelId: string, enabled: boolean, userEnabled: boolean): Promise; getSyncState(routerId: string): Promise; recordFailure(routerId: string, error: string, now: Date): Promise; + getSettings(routerId: string): Promise; + setSettings(routerId: string, settings: RouterSettings, now: Date): Promise; } export function makeModelRouterStore(db: ReturnType = getDb()): ModelRouterStore { @@ -158,5 +168,15 @@ export function makeModelRouterStore(db: ReturnType = getDb()): Mo set: { lastAttemptAt: now, lastError: error }, }); }, + async getSettings(routerId) { + const rows = await db.select().from(routerSettings).where(eq(routerSettings.routerId, routerId)).limit(1); + return rows[0] ?? null; + }, + async setSettings(routerId, settings, now) { + await db.insert(routerSettings).values({ routerId, ...settings, updatedAt: now }).onConflictDoUpdate({ + target: routerSettings.routerId, + set: { ...settings, updatedAt: now }, + }); + }, }; } diff --git a/orchestrator/src/db/schema.ts b/orchestrator/src/db/schema.ts index 116a24488..0da0a77a3 100644 --- a/orchestrator/src/db/schema.ts +++ b/orchestrator/src/db/schema.ts @@ -1327,6 +1327,16 @@ export const routerSyncState = pgTable("router_sync_state", { lastError: text("last_error"), }); +/** The models an admin chose for a router's roles. A null column uses the + * router's built-in model for that role, when it has one. */ +export const routerSettings = pgTable("router_settings", { + routerId: text("router_id").primaryKey(), + defaultModel: text("default_model"), + titleModel: text("title_model"), + decisionModel: text("decision_model"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + /** Custom webhook registrations verify with the generic scheme only. The * provider schemes (github_hmac_sha256, slack_v0) retired with ADR 0119 D5: * provider events arrive on the integration ingress routes, verified by the diff --git a/orchestrator/src/gen/engram/app/v1/model_router_pb.ts b/orchestrator/src/gen/engram/app/v1/model_router_pb.ts index 834be2e55..603f93b40 100644 --- a/orchestrator/src/gen/engram/app/v1/model_router_pb.ts +++ b/orchestrator/src/gen/engram/app/v1/model_router_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file engram/app/v1/model_router.proto. */ export const file_engram_app_v1_model_router: GenFile = /*@__PURE__*/ - fileDesc("CiBlbmdyYW0vYXBwL3YxL21vZGVsX3JvdXRlci5wcm90bxINZW5ncmFtLmFwcC52MSL8AgoLTW9kZWxSb3V0ZXISCgoCaWQYASABKAkSDQoFbGFiZWwYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSEQoJcHJvdG9jb2xzGAQgAygJEhkKEWNyZWRlbnRpYWxfc2VjcmV0GAUgASgJEhQKDGVncmVzc19ob3N0cxgGIAMoCRIdChVjcmVkZW50aWFsX2NvbmZpZ3VyZWQYByABKAgSJAoXbGFzdF9zdWNjZXNzZnVsX3N5bmNfYXQYCCABKAlIAIgBARIcCg9sYXN0X3N5bmNfZXJyb3IYCSABKAlIAYgBARITCgttb2RlbF9jb3VudBgKIAEoDRIdChVhdmFpbGFibGVfbW9kZWxfY291bnQYCyABKA0SGwoTZW5hYmxlZF9tb2RlbF9jb3VudBgMIAEoDRIVCg1kZWZhdWx0X21vZGVsGA0gASgJQhoKGF9sYXN0X3N1Y2Nlc3NmdWxfc3luY19hdEISChBfbGFzdF9zeW5jX2Vycm9yIpkECgtSb3V0ZXJNb2RlbBIRCglyb3V0ZXJfaWQYASABKAkSCgoCaWQYAiABKAkSFgoOY2Fub25pY2FsX3NsdWcYAyABKAkSDAoEbmFtZRgEIAEoCRITCgZhdXRob3IYBSABKAlIAIgBARIYCgtkZXNjcmlwdGlvbhgGIAEoCUgBiAEBEhYKDmNvbnRleHRfbGVuZ3RoGAcgASgEEhkKDHByb21wdF9wcmljZRgIIAEoCUgCiAEBEh0KEGNvbXBsZXRpb25fcHJpY2UYCSABKAlIA4gBARIYChBpbnB1dF9tb2RhbGl0aWVzGAogAygJEhkKEW91dHB1dF9tb2RhbGl0aWVzGAsgAygJEhwKFHN1cHBvcnRlZF9wYXJhbWV0ZXJzGAwgAygJEhwKD2h1Z2dpbmdfZmFjZV9pZBgNIAEoCUgEiAEBEhEKCWF2YWlsYWJsZRgOIAEoCBIPCgdlbmFibGVkGA8gASgIEhQKDHVzZXJfZW5hYmxlZBgQIAEoCBIUCgx1cHN0cmVhbV91cmwYESABKAkSEgoKdXBkYXRlZF9hdBgSIAEoCRIaChJzdXBwb3J0c19yZWFzb25pbmcYEyABKAhCCQoHX2F1dGhvckIOCgxfZGVzY3JpcHRpb25CDwoNX3Byb21wdF9wcmljZUITChFfY29tcGxldGlvbl9wcmljZUISChBfaHVnZ2luZ19mYWNlX2lkIhkKF0xpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0IkcKGExpc3RNb2RlbFJvdXRlcnNSZXNwb25zZRIrCgdyb3V0ZXJzGAEgAygLMhouZW5ncmFtLmFwcC52MS5Nb2RlbFJvdXRlciJyChdMaXN0Um91dGVyTW9kZWxzUmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSNAoIYXVkaWVuY2UYAiABKA4yIi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsQXVkaWVuY2USDgoGc2VhcmNoGAMgASgJIkYKGExpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRIqCgZtb2RlbHMYASADKAsyGi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsIi8KGlJlZnJlc2hSb3V0ZXJNb2RlbHNSZXF1ZXN0EhEKCXJvdXRlcl9pZBgBIAEoCSJgChtSZWZyZXNoUm91dGVyTW9kZWxzUmVzcG9uc2USEgoKZGlzY292ZXJlZBgBIAEoDRIRCglhdmFpbGFibGUYAiABKA0SGgoSbWFya2VkX3VuYXZhaWxhYmxlGAMgASgNImwKHlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSEAoIbW9kZWxfaWQYAiABKAkSDwoHZW5hYmxlZBgDIAEoCBIUCgx1c2VyX2VuYWJsZWQYBCABKAgiTAofVXBkYXRlUm91dGVyTW9kZWxQb2xpY3lSZXNwb25zZRIpCgVtb2RlbBgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuUm91dGVyTW9kZWwqrQEKE1JvdXRlck1vZGVsQXVkaWVuY2USJQohUk9VVEVSX01PREVMX0FVRElFTkNFX1VOU1BFQ0lGSUVEEAASHgoaUk9VVEVSX01PREVMX0FVRElFTkNFX1VTRVIQARImCiJST1VURVJfTU9ERUxfQVVESUVOQ0VfUFJPR1JBTU1BVElDEAISJwojUk9VVEVSX01PREVMX0FVRElFTkNFX0FETUlOX0NBVEFMT0cQAzLGAwoSTW9kZWxSb3V0ZXJTZXJ2aWNlEmMKEExpc3RNb2RlbFJvdXRlcnMSJi5lbmdyYW0uYXBwLnYxLkxpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0GicuZW5ncmFtLmFwcC52MS5MaXN0TW9kZWxSb3V0ZXJzUmVzcG9uc2USYwoQTGlzdFJvdXRlck1vZGVscxImLmVuZ3JhbS5hcHAudjEuTGlzdFJvdXRlck1vZGVsc1JlcXVlc3QaJy5lbmdyYW0uYXBwLnYxLkxpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRJsChNSZWZyZXNoUm91dGVyTW9kZWxzEikuZW5ncmFtLmFwcC52MS5SZWZyZXNoUm91dGVyTW9kZWxzUmVxdWVzdBoqLmVuZ3JhbS5hcHAudjEuUmVmcmVzaFJvdXRlck1vZGVsc1Jlc3BvbnNlEngKF1VwZGF0ZVJvdXRlck1vZGVsUG9saWN5Ei0uZW5ncmFtLmFwcC52MS5VcGRhdGVSb3V0ZXJNb2RlbFBvbGljeVJlcXVlc3QaLi5lbmdyYW0uYXBwLnYxLlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVzcG9uc2ViBnByb3RvMw"); + fileDesc("CiBlbmdyYW0vYXBwL3YxL21vZGVsX3JvdXRlci5wcm90bxINZW5ncmFtLmFwcC52MSKpAwoLTW9kZWxSb3V0ZXISCgoCaWQYASABKAkSDQoFbGFiZWwYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSEQoJcHJvdG9jb2xzGAQgAygJEhkKEWNyZWRlbnRpYWxfc2VjcmV0GAUgASgJEhQKDGVncmVzc19ob3N0cxgGIAMoCRIdChVjcmVkZW50aWFsX2NvbmZpZ3VyZWQYByABKAgSJAoXbGFzdF9zdWNjZXNzZnVsX3N5bmNfYXQYCCABKAlIAIgBARIcCg9sYXN0X3N5bmNfZXJyb3IYCSABKAlIAYgBARITCgttb2RlbF9jb3VudBgKIAEoDRIdChVhdmFpbGFibGVfbW9kZWxfY291bnQYCyABKA0SGwoTZW5hYmxlZF9tb2RlbF9jb3VudBgMIAEoDRIVCg1kZWZhdWx0X21vZGVsGA0gASgJEhMKC3RpdGxlX21vZGVsGA4gASgJEhYKDmRlY2lzaW9uX21vZGVsGA8gASgJQhoKGF9sYXN0X3N1Y2Nlc3NmdWxfc3luY19hdEISChBfbGFzdF9zeW5jX2Vycm9yIpkECgtSb3V0ZXJNb2RlbBIRCglyb3V0ZXJfaWQYASABKAkSCgoCaWQYAiABKAkSFgoOY2Fub25pY2FsX3NsdWcYAyABKAkSDAoEbmFtZRgEIAEoCRITCgZhdXRob3IYBSABKAlIAIgBARIYCgtkZXNjcmlwdGlvbhgGIAEoCUgBiAEBEhYKDmNvbnRleHRfbGVuZ3RoGAcgASgEEhkKDHByb21wdF9wcmljZRgIIAEoCUgCiAEBEh0KEGNvbXBsZXRpb25fcHJpY2UYCSABKAlIA4gBARIYChBpbnB1dF9tb2RhbGl0aWVzGAogAygJEhkKEW91dHB1dF9tb2RhbGl0aWVzGAsgAygJEhwKFHN1cHBvcnRlZF9wYXJhbWV0ZXJzGAwgAygJEhwKD2h1Z2dpbmdfZmFjZV9pZBgNIAEoCUgEiAEBEhEKCWF2YWlsYWJsZRgOIAEoCBIPCgdlbmFibGVkGA8gASgIEhQKDHVzZXJfZW5hYmxlZBgQIAEoCBIUCgx1cHN0cmVhbV91cmwYESABKAkSEgoKdXBkYXRlZF9hdBgSIAEoCRIaChJzdXBwb3J0c19yZWFzb25pbmcYEyABKAhCCQoHX2F1dGhvckIOCgxfZGVzY3JpcHRpb25CDwoNX3Byb21wdF9wcmljZUITChFfY29tcGxldGlvbl9wcmljZUISChBfaHVnZ2luZ19mYWNlX2lkIhkKF0xpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0IkcKGExpc3RNb2RlbFJvdXRlcnNSZXNwb25zZRIrCgdyb3V0ZXJzGAEgAygLMhouZW5ncmFtLmFwcC52MS5Nb2RlbFJvdXRlciJyChdMaXN0Um91dGVyTW9kZWxzUmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSNAoIYXVkaWVuY2UYAiABKA4yIi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsQXVkaWVuY2USDgoGc2VhcmNoGAMgASgJIkYKGExpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRIqCgZtb2RlbHMYASADKAsyGi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsIi8KGlJlZnJlc2hSb3V0ZXJNb2RlbHNSZXF1ZXN0EhEKCXJvdXRlcl9pZBgBIAEoCSJgChtSZWZyZXNoUm91dGVyTW9kZWxzUmVzcG9uc2USEgoKZGlzY292ZXJlZBgBIAEoDRIRCglhdmFpbGFibGUYAiABKA0SGgoSbWFya2VkX3VuYXZhaWxhYmxlGAMgASgNImwKHlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSEAoIbW9kZWxfaWQYAiABKAkSDwoHZW5hYmxlZBgDIAEoCBIUCgx1c2VyX2VuYWJsZWQYBCABKAgiTAofVXBkYXRlUm91dGVyTW9kZWxQb2xpY3lSZXNwb25zZRIpCgVtb2RlbBgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuUm91dGVyTW9kZWwieQogVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1JlcXVlc3QSEQoJcm91dGVyX2lkGAEgASgJEhUKDWRlZmF1bHRfbW9kZWwYAiABKAkSEwoLdGl0bGVfbW9kZWwYAyABKAkSFgoOZGVjaXNpb25fbW9kZWwYBCABKAkiTwohVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1Jlc3BvbnNlEioKBnJvdXRlchgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuTW9kZWxSb3V0ZXIqrQEKE1JvdXRlck1vZGVsQXVkaWVuY2USJQohUk9VVEVSX01PREVMX0FVRElFTkNFX1VOU1BFQ0lGSUVEEAASHgoaUk9VVEVSX01PREVMX0FVRElFTkNFX1VTRVIQARImCiJST1VURVJfTU9ERUxfQVVESUVOQ0VfUFJPR1JBTU1BVElDEAISJwojUk9VVEVSX01PREVMX0FVRElFTkNFX0FETUlOX0NBVEFMT0cQAzLGBAoSTW9kZWxSb3V0ZXJTZXJ2aWNlEmMKEExpc3RNb2RlbFJvdXRlcnMSJi5lbmdyYW0uYXBwLnYxLkxpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0GicuZW5ncmFtLmFwcC52MS5MaXN0TW9kZWxSb3V0ZXJzUmVzcG9uc2USYwoQTGlzdFJvdXRlck1vZGVscxImLmVuZ3JhbS5hcHAudjEuTGlzdFJvdXRlck1vZGVsc1JlcXVlc3QaJy5lbmdyYW0uYXBwLnYxLkxpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRJsChNSZWZyZXNoUm91dGVyTW9kZWxzEikuZW5ncmFtLmFwcC52MS5SZWZyZXNoUm91dGVyTW9kZWxzUmVxdWVzdBoqLmVuZ3JhbS5hcHAudjEuUmVmcmVzaFJvdXRlck1vZGVsc1Jlc3BvbnNlEngKF1VwZGF0ZVJvdXRlck1vZGVsUG9saWN5Ei0uZW5ncmFtLmFwcC52MS5VcGRhdGVSb3V0ZXJNb2RlbFBvbGljeVJlcXVlc3QaLi5lbmdyYW0uYXBwLnYxLlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVzcG9uc2USfgoZVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5ncxIvLmVuZ3JhbS5hcHAudjEuVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1JlcXVlc3QaMC5lbmdyYW0uYXBwLnYxLlVwZGF0ZU1vZGVsUm91dGVyU2V0dGluZ3NSZXNwb25zZWIGcHJvdG8z"); /** * @generated from message engram.app.v1.ModelRouter @@ -77,9 +77,22 @@ export type ModelRouter = Message<"engram.app.v1.ModelRouter"> & { enabledModelCount: number; /** + * The model in use for each role: the admin's choice, else the router's + * built-in model. Empty when the role has no model. + * * @generated from field: string default_model = 13; */ defaultModel: string; + + /** + * @generated from field: string title_model = 14; + */ + titleModel: string; + + /** + * @generated from field: string decision_model = 15; + */ + decisionModel: string; }; /** @@ -363,6 +376,57 @@ export type UpdateRouterModelPolicyResponse = Message<"engram.app.v1.UpdateRoute export const UpdateRouterModelPolicyResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_engram_app_v1_model_router, 9); +/** + * An empty model clears the admin's choice for that role. + * + * @generated from message engram.app.v1.UpdateModelRouterSettingsRequest + */ +export type UpdateModelRouterSettingsRequest = Message<"engram.app.v1.UpdateModelRouterSettingsRequest"> & { + /** + * @generated from field: string router_id = 1; + */ + routerId: string; + + /** + * @generated from field: string default_model = 2; + */ + defaultModel: string; + + /** + * @generated from field: string title_model = 3; + */ + titleModel: string; + + /** + * @generated from field: string decision_model = 4; + */ + decisionModel: string; +}; + +/** + * Describes the message engram.app.v1.UpdateModelRouterSettingsRequest. + * Use `create(UpdateModelRouterSettingsRequestSchema)` to create a new message. + */ +export const UpdateModelRouterSettingsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_engram_app_v1_model_router, 10); + +/** + * @generated from message engram.app.v1.UpdateModelRouterSettingsResponse + */ +export type UpdateModelRouterSettingsResponse = Message<"engram.app.v1.UpdateModelRouterSettingsResponse"> & { + /** + * @generated from field: engram.app.v1.ModelRouter router = 1; + */ + router?: ModelRouter; +}; + +/** + * Describes the message engram.app.v1.UpdateModelRouterSettingsResponse. + * Use `create(UpdateModelRouterSettingsResponseSchema)` to create a new message. + */ +export const UpdateModelRouterSettingsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_engram_app_v1_model_router, 11); + /** * @generated from enum engram.app.v1.RouterModelAudience */ @@ -432,6 +496,14 @@ export const ModelRouterService: GenService<{ input: typeof UpdateRouterModelPolicyRequestSchema; output: typeof UpdateRouterModelPolicyResponseSchema; }, + /** + * @generated from rpc engram.app.v1.ModelRouterService.UpdateModelRouterSettings + */ + updateModelRouterSettings: { + methodKind: "unary"; + input: typeof UpdateModelRouterSettingsRequestSchema; + output: typeof UpdateModelRouterSettingsResponseSchema; + }, }> = /*@__PURE__*/ serviceDesc(file_engram_app_v1_model_router, 0); diff --git a/orchestrator/src/model-routers/catalog.ts b/orchestrator/src/model-routers/catalog.ts index aeabaacd7..6a001d41c 100644 --- a/orchestrator/src/model-routers/catalog.ts +++ b/orchestrator/src/model-routers/catalog.ts @@ -2,7 +2,7 @@ import type { ModelRouterStore } from "../db/model-routers.ts"; import { runIntegrationOp } from "../integrations/run-op.ts"; import { errorMessage, log } from "../log.ts"; import { getModelRouterDefinition, listModelRouterDefinitions } from "./registry.ts"; -import { connectedRouters } from "./connected.ts"; +import { connectedRoutersOrNone } from "./connected.ts"; export interface RouterCatalogRefreshResult { discovered: number; @@ -54,10 +54,7 @@ export class ModelRouterCatalogRefresher { constructor(private readonly deps: RouterCatalogDeps) {} async runOnce(): Promise { - const routers = await connectedRouters().catch((err: unknown) => { - log.warn({ err }, "org-secret listing failed; no catalog refreshed"); - return []; - }); + const routers = await connectedRoutersOrNone(); await Promise.allSettled(routers.map((router) => refreshRouterCatalog(router.id, this.deps))); } diff --git a/orchestrator/src/model-routers/chat-decisions.ts b/orchestrator/src/model-routers/chat-decisions.ts new file mode 100644 index 000000000..663f2c924 --- /dev/null +++ b/orchestrator/src/model-routers/chat-decisions.ts @@ -0,0 +1,85 @@ +/** Answers a decisions request with one structured chat completion, for a + * router with no native decisions endpoint. + * + * The numbers are the model's own estimates, not calibrated probabilities: + * a graph that gates on a threshold needs it tuned for the chosen model. + */ + +import { APICallError, generateObject, RetryError, type LanguageModel } from "ai"; +import { z } from "zod"; + +import { + DecisionsApiError, + type DecisionAnswer, + type DecisionQuestion, + type DecisionsRequest, + type DecisionsResponse, +} from "./decisions.ts"; + +const SYSTEM_PROMPT = [ + "You answer questions about the given state. Return one answer per question id.", + "A noul question: `noul` is the probability from 0 to 1 that the answer is yes.", + "A choice question: `choice` is exactly one of the option labels in `criteria`.", + "A score question: `score` is the zero-based index of the level in `criteria` that fits best; the levels go from lowest to highest.", + "`confidence` is your probability from 0 to 1 that the answer is right.", +].join("\n"); + +const unit = (value: number): number => Math.max(0, Math.min(1, value)); + +function answerSchema(question: DecisionQuestion) { + switch (question.type) { + case "noul": + return z.object({ noul: z.number() }); + case "choice": + return z.object({ choice: z.enum(Object.keys(question.criteria)), confidence: z.number() }); + case "score": + return z.object({ score: z.number(), confidence: z.number() }); + } +} + +function toAnswer(question: DecisionQuestion, raw: Record): DecisionAnswer { + switch (question.type) { + case "noul": + return { type: "noul", noul: unit(raw["noul"] as number) }; + case "choice": + return { type: "choice", choice: raw["choice"] as string, confidence: unit(raw["confidence"] as number) }; + case "score": + return { + type: "score", + score: Math.max(0, Math.min(question.criteria.length - 1, raw["score"] as number)), + confidence: unit(raw["confidence"] as number), + }; + } +} + +export async function decideByChat( + model: LanguageModel, + request: DecisionsRequest & { model: string }, +): Promise { + const questions = Object.entries(request.questions); + try { + const { object, usage } = await generateObject({ + model, + schema: z.object(Object.fromEntries(questions.map(([id, question]) => [id, answerSchema(question)]))), + system: SYSTEM_PROMPT, + prompt: JSON.stringify({ state: request.state, questions: request.questions }), + }); + const answers = object as Record>; + return { + model: request.model, + answers: Object.fromEntries(questions.map(([id, question]) => [id, toAnswer(question, answers[id]!)])), + usage: { input_tokens: usage.inputTokens ?? 0, output_tokens: usage.outputTokens ?? 0 }, + }; + } catch (error) { + // An API rejection the SDK marks non-retryable (a bad key, an unknown + // model) is permanent. A network fault, a rate limit, or an answer that + // does not fit the schema is worth one more call. + const cause = RetryError.isInstance(error) ? error.lastError : error; + const permanent = APICallError.isInstance(cause) && !cause.isRetryable; + throw new DecisionsApiError( + `decision by chat completion failed: ${error instanceof Error ? error.message : String(error)}`, + APICallError.isInstance(cause) ? (cause.statusCode ?? null) : null, + !permanent, + ); + } +} diff --git a/orchestrator/src/model-routers/client.ts b/orchestrator/src/model-routers/client.ts index 4d504f170..1be220de1 100644 --- a/orchestrator/src/model-routers/client.ts +++ b/orchestrator/src/model-routers/client.ts @@ -9,14 +9,16 @@ import { Code, ConnectError } from "@connectrpc/connect"; import type { LanguageModel } from "ai"; import { asBearer, getIntegrationClient, registerIntegrationClient } from "../integrations/clients.ts"; -import { firstConnectedRouter } from "./connected.ts"; +import { decideByChat } from "./chat-decisions.ts"; +import { connectedRoutersOrNone } from "./connected.ts"; import { DecisionsApiError, type DecisionsClient, type DecisionsRequest, type DecisionsResponse, } from "./decisions.ts"; -import { listModelRouterDefinitions, type ModelRouterDefinition } from "./registry.ts"; +import { routerModels } from "./models.ts"; +import { listModelRouterDefinitions, type ModelRouterDefinition, type RouterRole } from "./registry.ts"; interface RouterClient { chat(modelId: string): LanguageModel; @@ -31,20 +33,27 @@ for (const router of listModelRouterDefinitions()) { }); } -function clientFor(router: ModelRouterDefinition): Promise { - return getIntegrationClient(router.connector); +/** The first connected router that has a model for `role`, with that model. */ +async function routerFor( + role: RouterRole, +): Promise<{ router: ModelRouterDefinition; model: string } | null> { + for (const router of await connectedRoutersOrNone()) { + const model = (await routerModels(router))[role]; + if (model) return { router, model }; + } + return null; } -/** The model that titles a task, on the first connected router; null when no - * router is connected. */ +/** The model that titles a task; null when no connected router has one. */ export async function titleModel(): Promise { - const router = await firstConnectedRouter(); - return router ? (await clientFor(router)).chat(router.titleModel) : null; + const chosen = await routerFor("title"); + if (!chosen) return null; + return (await getIntegrationClient(chosen.router.connector)).chat(chosen.model); } -/** The first connected router that can answer a decisions request. */ -export function decisionsRouter(): Promise { - return firstConnectedRouter((router) => router.decisions !== undefined); +/** Whether a connected router can answer a decisions request. */ +export async function canDecide(): Promise { + return (await routerFor("decision")) !== null; } /** Control-plane codes a key lookup can recover from on a retry. */ @@ -56,23 +65,24 @@ const TRANSIENT_LOOKUP_CODES = new Set([ Code.Internal, ]); -/** Answer a decisions request on the first connected router that can; null - * when there is none. A transient failure to resolve the key is a retryable - * DecisionsApiError; a missing or rejected key stays permanent. */ +/** Answer a decisions request on the first connected router that has a + * decision model; null when there is none. The request's own `model` + * replaces the router's. A transient failure to resolve the key is a + * retryable DecisionsApiError; a missing or rejected key stays permanent. */ export async function decide(request: DecisionsRequest): Promise { - const router = await decisionsRouter(); - if (!router) return null; + const chosen = await routerFor("decision"); + if (!chosen) return null; let client: RouterClient; try { - client = await clientFor(router); + client = await getIntegrationClient(chosen.router.connector); } catch (error) { if (error instanceof ConnectError && TRANSIENT_LOOKUP_CODES.has(error.code)) { - throw new DecisionsApiError(`resolve the ${router.label} key: ${error.rawMessage}`, null, true); + throw new DecisionsApiError(`resolve the ${chosen.router.label} key: ${error.rawMessage}`, null, true); } throw error; } - if (!client.decisions) { - throw new DecisionsApiError(`${router.label} has no decisions endpoint`, null, false); - } - return client.decisions.decide(request); + const resolved = { ...request, model: request.model ?? chosen.model }; + return client.decisions + ? client.decisions.decide(resolved) + : decideByChat(client.chat(resolved.model), resolved); } diff --git a/orchestrator/src/model-routers/connected.ts b/orchestrator/src/model-routers/connected.ts index bf65355db..0d49e7bae 100644 --- a/orchestrator/src/model-routers/connected.ts +++ b/orchestrator/src/model-routers/connected.ts @@ -29,17 +29,15 @@ export async function connectedRouters( return listModelRouterDefinitions().filter((router) => names.has(router.credentialSecret)); } -/** The first connected router that `wanted` accepts, or null. An unreachable - * control plane reads as "none connected": callers degrade, they do not - * retry against a key that may not exist. */ -export async function firstConnectedRouter( - wanted: (router: ModelRouterDefinition) => boolean = () => true, +/** The connected routers, or none when the secret names cannot be listed: + * callers degrade, they do not retry against a key that may not exist. */ +export async function connectedRoutersOrNone( client?: SecretNameClient, -): Promise { +): Promise { try { - return (await connectedRouters(client)).find(wanted) ?? null; + return await connectedRouters(client); } catch (err) { log.warn({ err }, "org-secret listing failed; treating every router as not connected"); - return null; + return []; } } diff --git a/orchestrator/src/model-routers/decisions.ts b/orchestrator/src/model-routers/decisions.ts index de2d42f17..d397f8560 100644 --- a/orchestrator/src/model-routers/decisions.ts +++ b/orchestrator/src/model-routers/decisions.ts @@ -1,6 +1,6 @@ -/** The decisions contract: a `state` plus typed `questions` in, calibrated - * typed answers out. A router answers it through a native endpoint when it - * has one. +/** The decisions contract: a `state` plus typed `questions` in, typed + * answers out. A router answers it through a native endpoint when it has + * one, and through a structured chat completion when it does not. */ import { z } from "zod"; @@ -65,5 +65,5 @@ export class DecisionsApiError extends Error { } export interface DecisionsClient { - decide(request: DecisionsRequest): Promise; + decide(request: DecisionsRequest & { model: string }): Promise; } diff --git a/orchestrator/src/model-routers/models.ts b/orchestrator/src/model-routers/models.ts new file mode 100644 index 000000000..5266b141b --- /dev/null +++ b/orchestrator/src/model-routers/models.ts @@ -0,0 +1,19 @@ +/** The model a router uses for each role: an admin's choice, else the + * router's built-in model, else none. */ + +import { makeModelRouterStore, type ModelRouterStore } from "../db/model-routers.ts"; +import type { ModelRouterDefinition, RouterRole } from "./registry.ts"; + +export type RouterModels = Record; + +export async function routerModels( + router: ModelRouterDefinition, + store: Pick = makeModelRouterStore(), +): Promise { + const chosen = await store.getSettings(router.id); + return { + default: chosen?.defaultModel ?? router.models.default ?? null, + title: chosen?.titleModel ?? router.models.title ?? null, + decision: chosen?.decisionModel ?? router.models.decision ?? null, + }; +} diff --git a/orchestrator/src/model-routers/registry.ts b/orchestrator/src/model-routers/registry.ts index 600298989..958fc26bc 100644 --- a/orchestrator/src/model-routers/registry.ts +++ b/orchestrator/src/model-routers/registry.ts @@ -4,6 +4,7 @@ import type { LanguageModel } from "ai"; import { connectorRegistry } from "../connectors/registry.ts"; import type { RouterModelInput } from "../db/model-routers.ts"; +import { LITELLM } from "../providers/litellm/router.ts"; import { OPENROUTER } from "../providers/openrouter/router.ts"; import type { DecisionsClient } from "./decisions.ts"; @@ -12,6 +13,10 @@ export type RouterProtocol = (typeof ROUTER_PROTOCOLS)[number]; export type CatalogModel = Omit; +/** What a router's model is used for: `default` runs a routed launch that + * names no model, `title` titles a task, `decision` answers a decide block. */ +export type RouterRole = "default" | "title" | "decision"; + /** What a provider declares to be a model router. */ export interface ModelRouterSpec { id: string; @@ -20,20 +25,19 @@ export interface ModelRouterSpec { /** The connector that holds the router's key and host. */ connector: string; protocols: Readonly>; - /** The model a routed launch uses when neither the task nor the profile names one. */ - defaultModel: string; - /** The model that titles a task from its first prompt. */ - titleModel: string; + /** Built-in model per role. A role without one needs an admin's choice. */ + models: Partial>; catalog: { path: string; refreshIntervalMs: number; parse(body: Uint8Array): CatalogModel[]; }; - /** The public page for a model. */ - modelUrl(modelId: string): string; + /** The public page for a model, when the router has one. */ + modelUrl?(modelId: string): string; /** Chat models for calls the orchestrator makes itself. */ chat(apiKey: string, host: string): (modelId: string) => LanguageModel; - /** Present when the router has a native decisions endpoint. */ + /** Present when the router has a native decisions endpoint. Without one, + * a decision is a structured chat completion. */ decisions?(apiKey: string, host: string): DecisionsClient; } @@ -52,7 +56,7 @@ function define(spec: ModelRouterSpec): ModelRouterDefinition { return { ...spec, credentialSecret: secretRef }; } -const REGISTRY = new Map([OPENROUTER].map((spec) => [spec.id, define(spec)])); +const REGISTRY = new Map([OPENROUTER, LITELLM].map((spec) => [spec.id, define(spec)])); export function listModelRouterDefinitions(): ModelRouterDefinition[] { return [...REGISTRY.values()]; diff --git a/orchestrator/src/providers/litellm/router.ts b/orchestrator/src/providers/litellm/router.ts new file mode 100644 index 000000000..cf1d16017 --- /dev/null +++ b/orchestrator/src/providers/litellm/router.ts @@ -0,0 +1,67 @@ +/** A LiteLLM proxy as a model router. + * + * The proxy answers the Anthropic Messages API at `/v1/messages`, and the + * OpenAI Responses, Chat Completions and model-list APIs under `/v1`, with + * the proxy key as a bearer token. The admin sets the host; sessions reach + * it over HTTPS on port 443. The admin also picks the model for each role, + * because the proxy's models are the admin's own. + */ + +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { z } from "zod"; + +import type { CatalogModel, ModelRouterSpec } from "../../model-routers/registry.ts"; + +const ModelListSchema = z.object({ + data: z.array(z.object({ id: z.string().min(1), owned_by: z.string().nullish() }).passthrough()), +}); + +/** The OpenAI model list carries an id and an owner only. */ +export function parseLiteLlmCatalog(body: Uint8Array): CatalogModel[] { + const parsed = ModelListSchema.parse(JSON.parse(new TextDecoder().decode(body))); + const byId = new Map(); + for (const raw of parsed.data) { + byId.set(raw.id, { + modelId: raw.id, + canonicalSlug: raw.id, + name: raw.id, + author: raw.owned_by ?? null, + description: null, + contextLength: 0, + promptPrice: null, + completionPrice: null, + inputModalities: [], + outputModalities: [], + supportedParameters: [], + huggingFaceId: null, + upstream: raw as Record, + }); + } + return [...byId.values()]; +} + +export const LITELLM: ModelRouterSpec = { + id: "litellm", + label: "LiteLLM", + description: "The models behind your own LiteLLM proxy.", + connector: "litellm", + protocols: { + anthropic_messages: { basePath: "" }, + openai_responses: { basePath: "/v1" }, + }, + models: {}, + catalog: { + path: "/v1/models", + refreshIntervalMs: 60 * 60 * 1000, + parse: parseLiteLlmCatalog, + }, + chat: (apiKey, host) => { + const provider = createOpenAICompatible({ + name: "litellm", + apiKey, + baseURL: `https://${host}/v1`, + supportsStructuredOutputs: true, + }); + return (modelId) => provider.chatModel(modelId); + }, +}; diff --git a/orchestrator/src/providers/openrouter/decisions.ts b/orchestrator/src/providers/openrouter/decisions.ts index 8c3fbc383..7f18874a2 100644 --- a/orchestrator/src/providers/openrouter/decisions.ts +++ b/orchestrator/src/providers/openrouter/decisions.ts @@ -12,10 +12,6 @@ import { type DecisionsClient, } from "../../model-routers/decisions.ts"; -/** Pinned so a model release never changes a graph's thresholds; an author - * opts into a newer one through the request's `model`. */ -const DEFAULT_MODEL = "typesafe/jev-1.13"; - const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 524, 529]); interface DecisionsClientOptions { @@ -44,7 +40,7 @@ export function makeDecisionsClient(options: DecisionsClientOptions): DecisionsC Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json", }, - body: JSON.stringify({ ...request, model: request.model ?? DEFAULT_MODEL }), + body: JSON.stringify(request), signal: AbortSignal.timeout(timeoutMs), }); // The body read is part of the transport: a reset or the abort after diff --git a/orchestrator/src/providers/openrouter/router.ts b/orchestrator/src/providers/openrouter/router.ts index 1cc06aef6..4617d483c 100644 --- a/orchestrator/src/providers/openrouter/router.ts +++ b/orchestrator/src/providers/openrouter/router.ts @@ -57,8 +57,12 @@ export const OPENROUTER: ModelRouterSpec = { anthropic_messages: { basePath: "/api" }, openai_responses: { basePath: "/api/v1" }, }, - defaultModel: "deepseek/deepseek-v4-pro-0813", - titleModel: "deepseek/deepseek-v4-flash", + models: { + default: "deepseek/deepseek-v4-pro-0813", + title: "deepseek/deepseek-v4-flash", + // Pinned so a model release never changes a graph's thresholds. + decision: "typesafe/jev-1.13", + }, catalog: { path: "/api/v1/models/user?output_modalities=text&supported_parameters=tools&sort=newest", refreshIntervalMs: 6 * 60 * 60 * 1000, diff --git a/orchestrator/src/rpc/model-routers.ts b/orchestrator/src/rpc/model-routers.ts index 5fdd499b3..8b7a6e0ca 100644 --- a/orchestrator/src/rpc/model-routers.ts +++ b/orchestrator/src/rpc/model-routers.ts @@ -15,6 +15,7 @@ import { } from "../gen/engram/app/v1/model_router_pb.ts"; import { refreshRouterCatalog, type RouterCatalogDeps } from "../model-routers/catalog.ts"; import { connectedRouters, type SecretNameClient } from "../model-routers/connected.ts"; +import { routerModels } from "../model-routers/models.ts"; import { getModelRouterDefinition, listModelRouterDefinitions, @@ -28,6 +29,7 @@ export interface ModelRouterServiceDeps { store?: ModelRouterStore; orgSecret?: SecretNameClient; refresh?: (routerId: string, deps: RouterCatalogDeps) => ReturnType; + now?: () => Date; } function modelToProto(router: ModelRouterDefinition, row: RouterModelRow) { @@ -48,7 +50,7 @@ function modelToProto(router: ModelRouterDefinition, row: RouterModelRow) { available: row.available, enabled: row.enabled, userEnabled: row.userEnabled, - upstreamUrl: router.modelUrl(row.modelId), + upstreamUrl: router.modelUrl?.(row.modelId) ?? "", updatedAt: row.updatedAt.toISOString(), supportsReasoning: row.supportedParameters.includes("reasoning") || @@ -62,6 +64,34 @@ export function registerModelRouters(router: ConnectRouter, deps?: ModelRouterSe const connected = async (): Promise> => new Set((await connectedRouters(deps?.orgSecret)).map((router) => router.id)); const refresh = deps?.refresh ?? refreshRouterCatalog; + const now = deps?.now ?? (() => new Date()); + + async function routerToProto(definition: ModelRouterDefinition, admin: boolean, configured: Set) { + const [models, state, roles, host] = await Promise.all([ + store.listModels(definition.id, "admin"), + store.getSyncState(definition.id), + routerModels(definition, store), + // A router whose host an admin has not set yet has no egress host. + resolveIntegrationHost(definition.connector, deps?.integration).catch(() => null), + ]); + return { + id: definition.id, + label: definition.label, + description: definition.description, + protocols: Object.keys(definition.protocols), + credentialSecret: admin ? definition.credentialSecret : "", + egressHosts: host ? [host] : [], + credentialConfigured: configured.has(definition.id), + lastSuccessfulSyncAt: state?.lastSuccessfulSyncAt?.toISOString(), + lastSyncError: state?.lastError ?? undefined, + modelCount: models.length, + availableModelCount: models.filter((model) => model.available).length, + enabledModelCount: models.filter((model) => model.enabled).length, + defaultModel: roles.default ?? "", + titleModel: roles.title ?? "", + decisionModel: roles.decision ?? "", + }; + } router.service(ModelRouterService, { async listModelRouters(_req, ctx) { @@ -74,31 +104,11 @@ export function registerModelRouters(router: ConnectRouter, deps?: ModelRouterSe const visible = listModelRouterDefinitions().filter( (definition) => user.role === "admin" || configured.has(definition.id), ); - const routers = await Promise.all( - visible.map(async (definition) => { - const [models, state, host] = await Promise.all([ - store.listModels(definition.id, "admin"), - store.getSyncState(definition.id), - resolveIntegrationHost(definition.connector, deps?.integration), - ]); - return { - id: definition.id, - label: definition.label, - description: definition.description, - protocols: Object.keys(definition.protocols), - credentialSecret: user.role === "admin" ? definition.credentialSecret : "", - egressHosts: [host], - credentialConfigured: configured.has(definition.id), - lastSuccessfulSyncAt: state?.lastSuccessfulSyncAt?.toISOString(), - lastSyncError: state?.lastError ?? undefined, - modelCount: models.length, - availableModelCount: models.filter((model) => model.available).length, - enabledModelCount: models.filter((model) => model.enabled).length, - defaultModel: definition.defaultModel, - }; - }), - ); - return { routers }; + return { + routers: await Promise.all( + visible.map((definition) => routerToProto(definition, user.role === "admin", configured)), + ), + }; }, async listRouterModels(req, ctx) { @@ -169,5 +179,23 @@ export function registerModelRouters(router: ConnectRouter, deps?: ModelRouterSe if (!model) throw new ConnectError("router model not found", Code.NotFound); return { model: modelToProto(definition, model) }; }, + + async updateModelRouterSettings(req, ctx) { + await requireAdmin(ctx, getSession); + const definition = getModelRouterDefinition(req.routerId); + if (!definition) throw new ConnectError("model router not found", Code.NotFound); + // An empty field clears the admin's choice for that role. + const chosen = (model: string): string | null => model.trim() || null; + await store.setSettings( + definition.id, + { + defaultModel: chosen(req.defaultModel), + titleModel: chosen(req.titleModel), + decisionModel: chosen(req.decisionModel), + }, + now(), + ); + return { router: await routerToProto(definition, true, await connected()) }; + }, }); } diff --git a/orchestrator/src/rpc/task-create.ts b/orchestrator/src/rpc/task-create.ts index be9e087f9..49f5e73f0 100644 --- a/orchestrator/src/rpc/task-create.ts +++ b/orchestrator/src/rpc/task-create.ts @@ -80,6 +80,7 @@ import { } from "../integrations/providers/index.ts"; import { makeModelRouterStore, type ModelRouterStore } from "../db/model-routers.ts"; import { connectedRouters, type SecretNameClient } from "../model-routers/connected.ts"; +import { routerModels } from "../model-routers/models.ts"; import { resolveRouterSelection } from "../model-routers/registry.ts"; const log = rootLog.child({ component: "task" }); @@ -347,9 +348,9 @@ export async function compileSessionCreateInput( const modelId = opts.model ?? profile.model ?? - router?.defaultModel ?? - descriptor.models.find((m) => m.default)?.id ?? - descriptor.models[0]?.id; + (router + ? (await routerModels(router, deps.modelRouters)).default ?? undefined + : descriptor.models.find((m) => m.default)?.id ?? descriptor.models[0]?.id); let effortId: string | undefined = opts.effort ?? profile.effort ?? @@ -358,7 +359,12 @@ export async function compileSessionCreateInput( const modelOption = router ? undefined : descriptor.models.find((m) => m.id === modelId); let routedModel; if (router) { - if (!modelId) throw new ConnectError("a routed launch requires a model", Code.InvalidArgument); + if (!modelId) { + throw new ConnectError( + `${router.label} has no default model — choose a model, or ask an admin to set the default`, + Code.FailedPrecondition, + ); + } const [routed, connected] = await Promise.all([ (deps.modelRouters ?? makeModelRouterStore()).getModel(router.id, modelId), connectedRouters(deps.orgSecret), diff --git a/orchestrator/src/tools/automation-draft.ts b/orchestrator/src/tools/automation-draft.ts index a838897bb..d83beb1f0 100644 --- a/orchestrator/src/tools/automation-draft.ts +++ b/orchestrator/src/tools/automation-draft.ts @@ -11,7 +11,7 @@ * reject the WHOLE tools/list. */ -import { decisionsRouter } from "../model-routers/client.ts"; +import { canDecide } from "../model-routers/client.ts"; import { z } from "zod"; import { definitionOf, effectiveDefinition, SaveVersionConflictError } from "../db/automations.ts"; @@ -190,7 +190,7 @@ export function registerAutomationDraftTools( return { part: args.part, content: draftBlockCatalog( - await (deps.decisionsAvailable ?? (async () => (await decisionsRouter()) !== null))(), + await (deps.decisionsAvailable ?? canDecide)(), ), }; case "patterns": diff --git a/web/src/gen/engram/app/v1/model_router-ModelRouterService_connectquery.ts b/web/src/gen/engram/app/v1/model_router-ModelRouterService_connectquery.ts index bfdf959a4..f08e5de4f 100644 --- a/web/src/gen/engram/app/v1/model_router-ModelRouterService_connectquery.ts +++ b/web/src/gen/engram/app/v1/model_router-ModelRouterService_connectquery.ts @@ -23,3 +23,8 @@ export const refreshRouterModels = ModelRouterService.method.refreshRouterModels * @generated from rpc engram.app.v1.ModelRouterService.UpdateRouterModelPolicy */ export const updateRouterModelPolicy = ModelRouterService.method.updateRouterModelPolicy; + +/** + * @generated from rpc engram.app.v1.ModelRouterService.UpdateModelRouterSettings + */ +export const updateModelRouterSettings = ModelRouterService.method.updateModelRouterSettings; diff --git a/web/src/gen/engram/app/v1/model_router_pb.ts b/web/src/gen/engram/app/v1/model_router_pb.ts index 834be2e55..603f93b40 100644 --- a/web/src/gen/engram/app/v1/model_router_pb.ts +++ b/web/src/gen/engram/app/v1/model_router_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file engram/app/v1/model_router.proto. */ export const file_engram_app_v1_model_router: GenFile = /*@__PURE__*/ - fileDesc("CiBlbmdyYW0vYXBwL3YxL21vZGVsX3JvdXRlci5wcm90bxINZW5ncmFtLmFwcC52MSL8AgoLTW9kZWxSb3V0ZXISCgoCaWQYASABKAkSDQoFbGFiZWwYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSEQoJcHJvdG9jb2xzGAQgAygJEhkKEWNyZWRlbnRpYWxfc2VjcmV0GAUgASgJEhQKDGVncmVzc19ob3N0cxgGIAMoCRIdChVjcmVkZW50aWFsX2NvbmZpZ3VyZWQYByABKAgSJAoXbGFzdF9zdWNjZXNzZnVsX3N5bmNfYXQYCCABKAlIAIgBARIcCg9sYXN0X3N5bmNfZXJyb3IYCSABKAlIAYgBARITCgttb2RlbF9jb3VudBgKIAEoDRIdChVhdmFpbGFibGVfbW9kZWxfY291bnQYCyABKA0SGwoTZW5hYmxlZF9tb2RlbF9jb3VudBgMIAEoDRIVCg1kZWZhdWx0X21vZGVsGA0gASgJQhoKGF9sYXN0X3N1Y2Nlc3NmdWxfc3luY19hdEISChBfbGFzdF9zeW5jX2Vycm9yIpkECgtSb3V0ZXJNb2RlbBIRCglyb3V0ZXJfaWQYASABKAkSCgoCaWQYAiABKAkSFgoOY2Fub25pY2FsX3NsdWcYAyABKAkSDAoEbmFtZRgEIAEoCRITCgZhdXRob3IYBSABKAlIAIgBARIYCgtkZXNjcmlwdGlvbhgGIAEoCUgBiAEBEhYKDmNvbnRleHRfbGVuZ3RoGAcgASgEEhkKDHByb21wdF9wcmljZRgIIAEoCUgCiAEBEh0KEGNvbXBsZXRpb25fcHJpY2UYCSABKAlIA4gBARIYChBpbnB1dF9tb2RhbGl0aWVzGAogAygJEhkKEW91dHB1dF9tb2RhbGl0aWVzGAsgAygJEhwKFHN1cHBvcnRlZF9wYXJhbWV0ZXJzGAwgAygJEhwKD2h1Z2dpbmdfZmFjZV9pZBgNIAEoCUgEiAEBEhEKCWF2YWlsYWJsZRgOIAEoCBIPCgdlbmFibGVkGA8gASgIEhQKDHVzZXJfZW5hYmxlZBgQIAEoCBIUCgx1cHN0cmVhbV91cmwYESABKAkSEgoKdXBkYXRlZF9hdBgSIAEoCRIaChJzdXBwb3J0c19yZWFzb25pbmcYEyABKAhCCQoHX2F1dGhvckIOCgxfZGVzY3JpcHRpb25CDwoNX3Byb21wdF9wcmljZUITChFfY29tcGxldGlvbl9wcmljZUISChBfaHVnZ2luZ19mYWNlX2lkIhkKF0xpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0IkcKGExpc3RNb2RlbFJvdXRlcnNSZXNwb25zZRIrCgdyb3V0ZXJzGAEgAygLMhouZW5ncmFtLmFwcC52MS5Nb2RlbFJvdXRlciJyChdMaXN0Um91dGVyTW9kZWxzUmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSNAoIYXVkaWVuY2UYAiABKA4yIi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsQXVkaWVuY2USDgoGc2VhcmNoGAMgASgJIkYKGExpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRIqCgZtb2RlbHMYASADKAsyGi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsIi8KGlJlZnJlc2hSb3V0ZXJNb2RlbHNSZXF1ZXN0EhEKCXJvdXRlcl9pZBgBIAEoCSJgChtSZWZyZXNoUm91dGVyTW9kZWxzUmVzcG9uc2USEgoKZGlzY292ZXJlZBgBIAEoDRIRCglhdmFpbGFibGUYAiABKA0SGgoSbWFya2VkX3VuYXZhaWxhYmxlGAMgASgNImwKHlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSEAoIbW9kZWxfaWQYAiABKAkSDwoHZW5hYmxlZBgDIAEoCBIUCgx1c2VyX2VuYWJsZWQYBCABKAgiTAofVXBkYXRlUm91dGVyTW9kZWxQb2xpY3lSZXNwb25zZRIpCgVtb2RlbBgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuUm91dGVyTW9kZWwqrQEKE1JvdXRlck1vZGVsQXVkaWVuY2USJQohUk9VVEVSX01PREVMX0FVRElFTkNFX1VOU1BFQ0lGSUVEEAASHgoaUk9VVEVSX01PREVMX0FVRElFTkNFX1VTRVIQARImCiJST1VURVJfTU9ERUxfQVVESUVOQ0VfUFJPR1JBTU1BVElDEAISJwojUk9VVEVSX01PREVMX0FVRElFTkNFX0FETUlOX0NBVEFMT0cQAzLGAwoSTW9kZWxSb3V0ZXJTZXJ2aWNlEmMKEExpc3RNb2RlbFJvdXRlcnMSJi5lbmdyYW0uYXBwLnYxLkxpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0GicuZW5ncmFtLmFwcC52MS5MaXN0TW9kZWxSb3V0ZXJzUmVzcG9uc2USYwoQTGlzdFJvdXRlck1vZGVscxImLmVuZ3JhbS5hcHAudjEuTGlzdFJvdXRlck1vZGVsc1JlcXVlc3QaJy5lbmdyYW0uYXBwLnYxLkxpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRJsChNSZWZyZXNoUm91dGVyTW9kZWxzEikuZW5ncmFtLmFwcC52MS5SZWZyZXNoUm91dGVyTW9kZWxzUmVxdWVzdBoqLmVuZ3JhbS5hcHAudjEuUmVmcmVzaFJvdXRlck1vZGVsc1Jlc3BvbnNlEngKF1VwZGF0ZVJvdXRlck1vZGVsUG9saWN5Ei0uZW5ncmFtLmFwcC52MS5VcGRhdGVSb3V0ZXJNb2RlbFBvbGljeVJlcXVlc3QaLi5lbmdyYW0uYXBwLnYxLlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVzcG9uc2ViBnByb3RvMw"); + fileDesc("CiBlbmdyYW0vYXBwL3YxL21vZGVsX3JvdXRlci5wcm90bxINZW5ncmFtLmFwcC52MSKpAwoLTW9kZWxSb3V0ZXISCgoCaWQYASABKAkSDQoFbGFiZWwYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSEQoJcHJvdG9jb2xzGAQgAygJEhkKEWNyZWRlbnRpYWxfc2VjcmV0GAUgASgJEhQKDGVncmVzc19ob3N0cxgGIAMoCRIdChVjcmVkZW50aWFsX2NvbmZpZ3VyZWQYByABKAgSJAoXbGFzdF9zdWNjZXNzZnVsX3N5bmNfYXQYCCABKAlIAIgBARIcCg9sYXN0X3N5bmNfZXJyb3IYCSABKAlIAYgBARITCgttb2RlbF9jb3VudBgKIAEoDRIdChVhdmFpbGFibGVfbW9kZWxfY291bnQYCyABKA0SGwoTZW5hYmxlZF9tb2RlbF9jb3VudBgMIAEoDRIVCg1kZWZhdWx0X21vZGVsGA0gASgJEhMKC3RpdGxlX21vZGVsGA4gASgJEhYKDmRlY2lzaW9uX21vZGVsGA8gASgJQhoKGF9sYXN0X3N1Y2Nlc3NmdWxfc3luY19hdEISChBfbGFzdF9zeW5jX2Vycm9yIpkECgtSb3V0ZXJNb2RlbBIRCglyb3V0ZXJfaWQYASABKAkSCgoCaWQYAiABKAkSFgoOY2Fub25pY2FsX3NsdWcYAyABKAkSDAoEbmFtZRgEIAEoCRITCgZhdXRob3IYBSABKAlIAIgBARIYCgtkZXNjcmlwdGlvbhgGIAEoCUgBiAEBEhYKDmNvbnRleHRfbGVuZ3RoGAcgASgEEhkKDHByb21wdF9wcmljZRgIIAEoCUgCiAEBEh0KEGNvbXBsZXRpb25fcHJpY2UYCSABKAlIA4gBARIYChBpbnB1dF9tb2RhbGl0aWVzGAogAygJEhkKEW91dHB1dF9tb2RhbGl0aWVzGAsgAygJEhwKFHN1cHBvcnRlZF9wYXJhbWV0ZXJzGAwgAygJEhwKD2h1Z2dpbmdfZmFjZV9pZBgNIAEoCUgEiAEBEhEKCWF2YWlsYWJsZRgOIAEoCBIPCgdlbmFibGVkGA8gASgIEhQKDHVzZXJfZW5hYmxlZBgQIAEoCBIUCgx1cHN0cmVhbV91cmwYESABKAkSEgoKdXBkYXRlZF9hdBgSIAEoCRIaChJzdXBwb3J0c19yZWFzb25pbmcYEyABKAhCCQoHX2F1dGhvckIOCgxfZGVzY3JpcHRpb25CDwoNX3Byb21wdF9wcmljZUITChFfY29tcGxldGlvbl9wcmljZUISChBfaHVnZ2luZ19mYWNlX2lkIhkKF0xpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0IkcKGExpc3RNb2RlbFJvdXRlcnNSZXNwb25zZRIrCgdyb3V0ZXJzGAEgAygLMhouZW5ncmFtLmFwcC52MS5Nb2RlbFJvdXRlciJyChdMaXN0Um91dGVyTW9kZWxzUmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSNAoIYXVkaWVuY2UYAiABKA4yIi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsQXVkaWVuY2USDgoGc2VhcmNoGAMgASgJIkYKGExpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRIqCgZtb2RlbHMYASADKAsyGi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsIi8KGlJlZnJlc2hSb3V0ZXJNb2RlbHNSZXF1ZXN0EhEKCXJvdXRlcl9pZBgBIAEoCSJgChtSZWZyZXNoUm91dGVyTW9kZWxzUmVzcG9uc2USEgoKZGlzY292ZXJlZBgBIAEoDRIRCglhdmFpbGFibGUYAiABKA0SGgoSbWFya2VkX3VuYXZhaWxhYmxlGAMgASgNImwKHlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSEAoIbW9kZWxfaWQYAiABKAkSDwoHZW5hYmxlZBgDIAEoCBIUCgx1c2VyX2VuYWJsZWQYBCABKAgiTAofVXBkYXRlUm91dGVyTW9kZWxQb2xpY3lSZXNwb25zZRIpCgVtb2RlbBgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuUm91dGVyTW9kZWwieQogVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1JlcXVlc3QSEQoJcm91dGVyX2lkGAEgASgJEhUKDWRlZmF1bHRfbW9kZWwYAiABKAkSEwoLdGl0bGVfbW9kZWwYAyABKAkSFgoOZGVjaXNpb25fbW9kZWwYBCABKAkiTwohVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1Jlc3BvbnNlEioKBnJvdXRlchgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuTW9kZWxSb3V0ZXIqrQEKE1JvdXRlck1vZGVsQXVkaWVuY2USJQohUk9VVEVSX01PREVMX0FVRElFTkNFX1VOU1BFQ0lGSUVEEAASHgoaUk9VVEVSX01PREVMX0FVRElFTkNFX1VTRVIQARImCiJST1VURVJfTU9ERUxfQVVESUVOQ0VfUFJPR1JBTU1BVElDEAISJwojUk9VVEVSX01PREVMX0FVRElFTkNFX0FETUlOX0NBVEFMT0cQAzLGBAoSTW9kZWxSb3V0ZXJTZXJ2aWNlEmMKEExpc3RNb2RlbFJvdXRlcnMSJi5lbmdyYW0uYXBwLnYxLkxpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0GicuZW5ncmFtLmFwcC52MS5MaXN0TW9kZWxSb3V0ZXJzUmVzcG9uc2USYwoQTGlzdFJvdXRlck1vZGVscxImLmVuZ3JhbS5hcHAudjEuTGlzdFJvdXRlck1vZGVsc1JlcXVlc3QaJy5lbmdyYW0uYXBwLnYxLkxpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRJsChNSZWZyZXNoUm91dGVyTW9kZWxzEikuZW5ncmFtLmFwcC52MS5SZWZyZXNoUm91dGVyTW9kZWxzUmVxdWVzdBoqLmVuZ3JhbS5hcHAudjEuUmVmcmVzaFJvdXRlck1vZGVsc1Jlc3BvbnNlEngKF1VwZGF0ZVJvdXRlck1vZGVsUG9saWN5Ei0uZW5ncmFtLmFwcC52MS5VcGRhdGVSb3V0ZXJNb2RlbFBvbGljeVJlcXVlc3QaLi5lbmdyYW0uYXBwLnYxLlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVzcG9uc2USfgoZVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5ncxIvLmVuZ3JhbS5hcHAudjEuVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1JlcXVlc3QaMC5lbmdyYW0uYXBwLnYxLlVwZGF0ZU1vZGVsUm91dGVyU2V0dGluZ3NSZXNwb25zZWIGcHJvdG8z"); /** * @generated from message engram.app.v1.ModelRouter @@ -77,9 +77,22 @@ export type ModelRouter = Message<"engram.app.v1.ModelRouter"> & { enabledModelCount: number; /** + * The model in use for each role: the admin's choice, else the router's + * built-in model. Empty when the role has no model. + * * @generated from field: string default_model = 13; */ defaultModel: string; + + /** + * @generated from field: string title_model = 14; + */ + titleModel: string; + + /** + * @generated from field: string decision_model = 15; + */ + decisionModel: string; }; /** @@ -363,6 +376,57 @@ export type UpdateRouterModelPolicyResponse = Message<"engram.app.v1.UpdateRoute export const UpdateRouterModelPolicyResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_engram_app_v1_model_router, 9); +/** + * An empty model clears the admin's choice for that role. + * + * @generated from message engram.app.v1.UpdateModelRouterSettingsRequest + */ +export type UpdateModelRouterSettingsRequest = Message<"engram.app.v1.UpdateModelRouterSettingsRequest"> & { + /** + * @generated from field: string router_id = 1; + */ + routerId: string; + + /** + * @generated from field: string default_model = 2; + */ + defaultModel: string; + + /** + * @generated from field: string title_model = 3; + */ + titleModel: string; + + /** + * @generated from field: string decision_model = 4; + */ + decisionModel: string; +}; + +/** + * Describes the message engram.app.v1.UpdateModelRouterSettingsRequest. + * Use `create(UpdateModelRouterSettingsRequestSchema)` to create a new message. + */ +export const UpdateModelRouterSettingsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_engram_app_v1_model_router, 10); + +/** + * @generated from message engram.app.v1.UpdateModelRouterSettingsResponse + */ +export type UpdateModelRouterSettingsResponse = Message<"engram.app.v1.UpdateModelRouterSettingsResponse"> & { + /** + * @generated from field: engram.app.v1.ModelRouter router = 1; + */ + router?: ModelRouter; +}; + +/** + * Describes the message engram.app.v1.UpdateModelRouterSettingsResponse. + * Use `create(UpdateModelRouterSettingsResponseSchema)` to create a new message. + */ +export const UpdateModelRouterSettingsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_engram_app_v1_model_router, 11); + /** * @generated from enum engram.app.v1.RouterModelAudience */ @@ -432,6 +496,14 @@ export const ModelRouterService: GenService<{ input: typeof UpdateRouterModelPolicyRequestSchema; output: typeof UpdateRouterModelPolicyResponseSchema; }, + /** + * @generated from rpc engram.app.v1.ModelRouterService.UpdateModelRouterSettings + */ + updateModelRouterSettings: { + methodKind: "unary"; + input: typeof UpdateModelRouterSettingsRequestSchema; + output: typeof UpdateModelRouterSettingsResponseSchema; + }, }> = /*@__PURE__*/ serviceDesc(file_engram_app_v1_model_router, 0); From decb58accd122c15c1f8eaa22ef4a4dcdf50d4ff Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Wed, 11 Nov 2026 08:44:51 -0800 Subject: [PATCH 07/38] orchestrator: bound the chat decision call and keep unset router settings Fixes from the review of the LiteLLM router: - A decide block that names its own model goes to the first connected router. It returned "not decided" when that router had no decision model. - A failure to read the decision model is a retryable error. It was a plain error, which failed the block for good on a short database fault. - The chat decision call has a 20 s timeout and no SDK retries; the decide block makes the one retry. It had no timeout and up to three attempts. - A choice answer is a plain string in the schema. An enum of every option label can pass the limit a strict schema accepts; the decide block already rejects a label that is not an option. - `UpdateModelRouterSettings` keeps a role whose field is absent and clears one whose field is empty. It replaced all three roles. - A chosen model must be in the router's catalog. A decision model on a router with a native decisions endpoint is exempt, because that endpoint serves models the chat catalog does not list. - A router with no host set is a typed error. The router list shows no egress host for it and passes any other failure on; a routed launch answers FailedPrecondition with what to set. - The permanent-error check for a model call has one definition, shared by the title consumer and the chat decision. Not covered by a test: the router choice inside `decide`, which reads the default control-plane and database clients. Co-Authored-By: Claude Fable 5.2 --- .../proto/engram/app/v1/model_router.proto | 9 ++-- .../src/__tests__/chat-decisions.test.ts | 13 ++++-- .../__tests__/model-routers-gating.test.ts | 33 ++++++++------ .../src/gen/engram/app/v1/model_router_pb.ts | 17 ++++---- orchestrator/src/integrations/run-op.ts | 10 ++++- orchestrator/src/listeners/title-consumer.ts | 15 ++----- .../src/model-routers/chat-decisions.ts | 19 +++++--- orchestrator/src/model-routers/client.ts | 29 ++++++++++--- orchestrator/src/model-routers/errors.ts | 10 +++++ orchestrator/src/rpc/model-routers.ts | 43 +++++++++++++------ orchestrator/src/rpc/task-create.ts | 8 +++- web/src/gen/engram/app/v1/model_router_pb.ts | 17 ++++---- 12 files changed, 147 insertions(+), 76 deletions(-) create mode 100644 orchestrator/src/model-routers/errors.ts diff --git a/crates/engram-protocol/proto/engram/app/v1/model_router.proto b/crates/engram-protocol/proto/engram/app/v1/model_router.proto index 80654552c..465d7959e 100644 --- a/crates/engram-protocol/proto/engram/app/v1/model_router.proto +++ b/crates/engram-protocol/proto/engram/app/v1/model_router.proto @@ -85,11 +85,12 @@ message UpdateRouterModelPolicyRequest { } message UpdateRouterModelPolicyResponse { RouterModel model = 1; } -// An empty model clears the admin's choice for that role. +// An absent model keeps the admin's choice for that role; an empty one +// clears it. message UpdateModelRouterSettingsRequest { string router_id = 1; - string default_model = 2; - string title_model = 3; - string decision_model = 4; + optional string default_model = 2; + optional string title_model = 3; + optional string decision_model = 4; } message UpdateModelRouterSettingsResponse { ModelRouter router = 1; } diff --git a/orchestrator/src/__tests__/chat-decisions.test.ts b/orchestrator/src/__tests__/chat-decisions.test.ts index 2605583d5..0ea7b09d8 100644 --- a/orchestrator/src/__tests__/chat-decisions.test.ts +++ b/orchestrator/src/__tests__/chat-decisions.test.ts @@ -66,13 +66,20 @@ describe("decision by chat completion", () => { expect(JSON.stringify(model.doGenerateCalls[0]!.prompt)).toContain("the build is red on main"); }); - test("a choice outside the options is a retryable failure", async () => { + test("an answer that does not fit the schema is a retryable failure", async () => { + const model = modelAnswering({ urgent: { noul: "yes" } }); + expect((await failure(decideByChat(model, REQUEST))).retryable).toBe(true); + }); + + test("the call makes no retries of its own and carries a timeout", async () => { const model = modelAnswering({ urgent: { noul: 0.5 }, - team: { choice: "sales", confidence: 0.9 }, + team: { choice: "web", confidence: 0.9 }, risk: { score: 1, confidence: 0.5 }, }); - expect((await failure(decideByChat(model, REQUEST))).retryable).toBe(true); + await decideByChat(model, REQUEST); + expect(model.doGenerateCalls).toHaveLength(1); + expect(model.doGenerateCalls[0]!.abortSignal).toBeInstanceOf(AbortSignal); }); test("an API rejection that a retry cannot fix is permanent", async () => { diff --git a/orchestrator/src/__tests__/model-routers-gating.test.ts b/orchestrator/src/__tests__/model-routers-gating.test.ts index 683caf74c..e8d4b1b8c 100644 --- a/orchestrator/src/__tests__/model-routers-gating.test.ts +++ b/orchestrator/src/__tests__/model-routers-gating.test.ts @@ -16,7 +16,7 @@ import { describe, expect, test } from "bun:test"; import { Code, ConnectError, createClient, createRouterTransport } from "@connectrpc/connect"; -import type { ModelRouterStore, RouterSettings } from "../db/model-routers.ts"; +import type { ModelRouterStore, RouterModelRow, RouterSettings } from "../db/model-routers.ts"; import { registerModelRouters } from "../rpc/model-routers.ts"; import { ModelRouterService, RouterModelAudience } from "../gen/engram/app/v1/model_router_pb.ts"; @@ -24,7 +24,8 @@ function fakeStore(): ModelRouterStore { const settings = new Map(); return { listModels: async () => [], - getModel: async () => null, + getModel: async (_routerId, modelId) => + modelId.startsWith("known/") ? ({ available: true } as RouterModelRow) : null, replaceCatalog: async () => ({ markedUnavailable: 0 }), updatePolicy: async () => null, getSyncState: async () => null, @@ -72,22 +73,30 @@ describe("router connection gating", () => { expect(openrouter.decisionModel).toBe("typesafe/jev-1.13"); expect(litellm).toMatchObject({ defaultModel: "", titleModel: "", decisionModel: "" }); - const set = await api.updateModelRouterSettings({ - routerId: "litellm", - defaultModel: "claude-sonnet", - titleModel: " gpt-mini ", - decisionModel: "", - }); - expect(set.router).toMatchObject({ defaultModel: "claude-sonnet", titleModel: "gpt-mini", decisionModel: "" }); + await api.updateModelRouterSettings({ routerId: "litellm", defaultModel: "known/sonnet" }); + // A field that is absent keeps its role's model. + const set = await api.updateModelRouterSettings({ routerId: "litellm", titleModel: " known/mini " }); + expect(set.router).toMatchObject({ defaultModel: "known/sonnet", titleModel: "known/mini", decisionModel: "" }); - await api.updateModelRouterSettings({ routerId: "openrouter", titleModel: "other/title" }); - const cleared = await api.updateModelRouterSettings({ routerId: "openrouter" }); + await api.updateModelRouterSettings({ routerId: "openrouter", titleModel: "known/title" }); + const cleared = await api.updateModelRouterSettings({ routerId: "openrouter", titleModel: "" }); expect(cleared.router?.titleModel).toBe("deepseek/deepseek-v4-flash"); }); + test("a chosen model must be in the router's catalog", async () => { + const api = client("admin", ["openrouter.api_key", "litellm.api_key"]); + await expect(api.updateModelRouterSettings({ routerId: "litellm", titleModel: "typo" })).rejects.toThrow( + /not in the LiteLLM catalog/, + ); + await expect(api.updateModelRouterSettings({ routerId: "litellm", decisionModel: "typo" })).rejects.toThrow(); + // A native decisions endpoint serves models the chat catalog does not list. + const set = await api.updateModelRouterSettings({ routerId: "openrouter", decisionModel: "typesafe/jev-2" }); + expect(set.router?.decisionModel).toBe("typesafe/jev-2"); + }); + test("only an admin sets a router's models", async () => { const api = client("user", ["litellm.api_key"]); - await expect(api.updateModelRouterSettings({ routerId: "litellm", defaultModel: "m" })).rejects.toThrow(); + await expect(api.updateModelRouterSettings({ routerId: "litellm", defaultModel: "known/m" })).rejects.toThrow(); }); test("non-admin with the key: router listed AND credentialConfigured=true", async () => { diff --git a/orchestrator/src/gen/engram/app/v1/model_router_pb.ts b/orchestrator/src/gen/engram/app/v1/model_router_pb.ts index 603f93b40..40931c72f 100644 --- a/orchestrator/src/gen/engram/app/v1/model_router_pb.ts +++ b/orchestrator/src/gen/engram/app/v1/model_router_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file engram/app/v1/model_router.proto. */ export const file_engram_app_v1_model_router: GenFile = /*@__PURE__*/ - fileDesc("CiBlbmdyYW0vYXBwL3YxL21vZGVsX3JvdXRlci5wcm90bxINZW5ncmFtLmFwcC52MSKpAwoLTW9kZWxSb3V0ZXISCgoCaWQYASABKAkSDQoFbGFiZWwYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSEQoJcHJvdG9jb2xzGAQgAygJEhkKEWNyZWRlbnRpYWxfc2VjcmV0GAUgASgJEhQKDGVncmVzc19ob3N0cxgGIAMoCRIdChVjcmVkZW50aWFsX2NvbmZpZ3VyZWQYByABKAgSJAoXbGFzdF9zdWNjZXNzZnVsX3N5bmNfYXQYCCABKAlIAIgBARIcCg9sYXN0X3N5bmNfZXJyb3IYCSABKAlIAYgBARITCgttb2RlbF9jb3VudBgKIAEoDRIdChVhdmFpbGFibGVfbW9kZWxfY291bnQYCyABKA0SGwoTZW5hYmxlZF9tb2RlbF9jb3VudBgMIAEoDRIVCg1kZWZhdWx0X21vZGVsGA0gASgJEhMKC3RpdGxlX21vZGVsGA4gASgJEhYKDmRlY2lzaW9uX21vZGVsGA8gASgJQhoKGF9sYXN0X3N1Y2Nlc3NmdWxfc3luY19hdEISChBfbGFzdF9zeW5jX2Vycm9yIpkECgtSb3V0ZXJNb2RlbBIRCglyb3V0ZXJfaWQYASABKAkSCgoCaWQYAiABKAkSFgoOY2Fub25pY2FsX3NsdWcYAyABKAkSDAoEbmFtZRgEIAEoCRITCgZhdXRob3IYBSABKAlIAIgBARIYCgtkZXNjcmlwdGlvbhgGIAEoCUgBiAEBEhYKDmNvbnRleHRfbGVuZ3RoGAcgASgEEhkKDHByb21wdF9wcmljZRgIIAEoCUgCiAEBEh0KEGNvbXBsZXRpb25fcHJpY2UYCSABKAlIA4gBARIYChBpbnB1dF9tb2RhbGl0aWVzGAogAygJEhkKEW91dHB1dF9tb2RhbGl0aWVzGAsgAygJEhwKFHN1cHBvcnRlZF9wYXJhbWV0ZXJzGAwgAygJEhwKD2h1Z2dpbmdfZmFjZV9pZBgNIAEoCUgEiAEBEhEKCWF2YWlsYWJsZRgOIAEoCBIPCgdlbmFibGVkGA8gASgIEhQKDHVzZXJfZW5hYmxlZBgQIAEoCBIUCgx1cHN0cmVhbV91cmwYESABKAkSEgoKdXBkYXRlZF9hdBgSIAEoCRIaChJzdXBwb3J0c19yZWFzb25pbmcYEyABKAhCCQoHX2F1dGhvckIOCgxfZGVzY3JpcHRpb25CDwoNX3Byb21wdF9wcmljZUITChFfY29tcGxldGlvbl9wcmljZUISChBfaHVnZ2luZ19mYWNlX2lkIhkKF0xpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0IkcKGExpc3RNb2RlbFJvdXRlcnNSZXNwb25zZRIrCgdyb3V0ZXJzGAEgAygLMhouZW5ncmFtLmFwcC52MS5Nb2RlbFJvdXRlciJyChdMaXN0Um91dGVyTW9kZWxzUmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSNAoIYXVkaWVuY2UYAiABKA4yIi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsQXVkaWVuY2USDgoGc2VhcmNoGAMgASgJIkYKGExpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRIqCgZtb2RlbHMYASADKAsyGi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsIi8KGlJlZnJlc2hSb3V0ZXJNb2RlbHNSZXF1ZXN0EhEKCXJvdXRlcl9pZBgBIAEoCSJgChtSZWZyZXNoUm91dGVyTW9kZWxzUmVzcG9uc2USEgoKZGlzY292ZXJlZBgBIAEoDRIRCglhdmFpbGFibGUYAiABKA0SGgoSbWFya2VkX3VuYXZhaWxhYmxlGAMgASgNImwKHlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSEAoIbW9kZWxfaWQYAiABKAkSDwoHZW5hYmxlZBgDIAEoCBIUCgx1c2VyX2VuYWJsZWQYBCABKAgiTAofVXBkYXRlUm91dGVyTW9kZWxQb2xpY3lSZXNwb25zZRIpCgVtb2RlbBgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuUm91dGVyTW9kZWwieQogVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1JlcXVlc3QSEQoJcm91dGVyX2lkGAEgASgJEhUKDWRlZmF1bHRfbW9kZWwYAiABKAkSEwoLdGl0bGVfbW9kZWwYAyABKAkSFgoOZGVjaXNpb25fbW9kZWwYBCABKAkiTwohVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1Jlc3BvbnNlEioKBnJvdXRlchgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuTW9kZWxSb3V0ZXIqrQEKE1JvdXRlck1vZGVsQXVkaWVuY2USJQohUk9VVEVSX01PREVMX0FVRElFTkNFX1VOU1BFQ0lGSUVEEAASHgoaUk9VVEVSX01PREVMX0FVRElFTkNFX1VTRVIQARImCiJST1VURVJfTU9ERUxfQVVESUVOQ0VfUFJPR1JBTU1BVElDEAISJwojUk9VVEVSX01PREVMX0FVRElFTkNFX0FETUlOX0NBVEFMT0cQAzLGBAoSTW9kZWxSb3V0ZXJTZXJ2aWNlEmMKEExpc3RNb2RlbFJvdXRlcnMSJi5lbmdyYW0uYXBwLnYxLkxpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0GicuZW5ncmFtLmFwcC52MS5MaXN0TW9kZWxSb3V0ZXJzUmVzcG9uc2USYwoQTGlzdFJvdXRlck1vZGVscxImLmVuZ3JhbS5hcHAudjEuTGlzdFJvdXRlck1vZGVsc1JlcXVlc3QaJy5lbmdyYW0uYXBwLnYxLkxpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRJsChNSZWZyZXNoUm91dGVyTW9kZWxzEikuZW5ncmFtLmFwcC52MS5SZWZyZXNoUm91dGVyTW9kZWxzUmVxdWVzdBoqLmVuZ3JhbS5hcHAudjEuUmVmcmVzaFJvdXRlck1vZGVsc1Jlc3BvbnNlEngKF1VwZGF0ZVJvdXRlck1vZGVsUG9saWN5Ei0uZW5ncmFtLmFwcC52MS5VcGRhdGVSb3V0ZXJNb2RlbFBvbGljeVJlcXVlc3QaLi5lbmdyYW0uYXBwLnYxLlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVzcG9uc2USfgoZVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5ncxIvLmVuZ3JhbS5hcHAudjEuVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1JlcXVlc3QaMC5lbmdyYW0uYXBwLnYxLlVwZGF0ZU1vZGVsUm91dGVyU2V0dGluZ3NSZXNwb25zZWIGcHJvdG8z"); + fileDesc("CiBlbmdyYW0vYXBwL3YxL21vZGVsX3JvdXRlci5wcm90bxINZW5ncmFtLmFwcC52MSKpAwoLTW9kZWxSb3V0ZXISCgoCaWQYASABKAkSDQoFbGFiZWwYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSEQoJcHJvdG9jb2xzGAQgAygJEhkKEWNyZWRlbnRpYWxfc2VjcmV0GAUgASgJEhQKDGVncmVzc19ob3N0cxgGIAMoCRIdChVjcmVkZW50aWFsX2NvbmZpZ3VyZWQYByABKAgSJAoXbGFzdF9zdWNjZXNzZnVsX3N5bmNfYXQYCCABKAlIAIgBARIcCg9sYXN0X3N5bmNfZXJyb3IYCSABKAlIAYgBARITCgttb2RlbF9jb3VudBgKIAEoDRIdChVhdmFpbGFibGVfbW9kZWxfY291bnQYCyABKA0SGwoTZW5hYmxlZF9tb2RlbF9jb3VudBgMIAEoDRIVCg1kZWZhdWx0X21vZGVsGA0gASgJEhMKC3RpdGxlX21vZGVsGA4gASgJEhYKDmRlY2lzaW9uX21vZGVsGA8gASgJQhoKGF9sYXN0X3N1Y2Nlc3NmdWxfc3luY19hdEISChBfbGFzdF9zeW5jX2Vycm9yIpkECgtSb3V0ZXJNb2RlbBIRCglyb3V0ZXJfaWQYASABKAkSCgoCaWQYAiABKAkSFgoOY2Fub25pY2FsX3NsdWcYAyABKAkSDAoEbmFtZRgEIAEoCRITCgZhdXRob3IYBSABKAlIAIgBARIYCgtkZXNjcmlwdGlvbhgGIAEoCUgBiAEBEhYKDmNvbnRleHRfbGVuZ3RoGAcgASgEEhkKDHByb21wdF9wcmljZRgIIAEoCUgCiAEBEh0KEGNvbXBsZXRpb25fcHJpY2UYCSABKAlIA4gBARIYChBpbnB1dF9tb2RhbGl0aWVzGAogAygJEhkKEW91dHB1dF9tb2RhbGl0aWVzGAsgAygJEhwKFHN1cHBvcnRlZF9wYXJhbWV0ZXJzGAwgAygJEhwKD2h1Z2dpbmdfZmFjZV9pZBgNIAEoCUgEiAEBEhEKCWF2YWlsYWJsZRgOIAEoCBIPCgdlbmFibGVkGA8gASgIEhQKDHVzZXJfZW5hYmxlZBgQIAEoCBIUCgx1cHN0cmVhbV91cmwYESABKAkSEgoKdXBkYXRlZF9hdBgSIAEoCRIaChJzdXBwb3J0c19yZWFzb25pbmcYEyABKAhCCQoHX2F1dGhvckIOCgxfZGVzY3JpcHRpb25CDwoNX3Byb21wdF9wcmljZUITChFfY29tcGxldGlvbl9wcmljZUISChBfaHVnZ2luZ19mYWNlX2lkIhkKF0xpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0IkcKGExpc3RNb2RlbFJvdXRlcnNSZXNwb25zZRIrCgdyb3V0ZXJzGAEgAygLMhouZW5ncmFtLmFwcC52MS5Nb2RlbFJvdXRlciJyChdMaXN0Um91dGVyTW9kZWxzUmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSNAoIYXVkaWVuY2UYAiABKA4yIi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsQXVkaWVuY2USDgoGc2VhcmNoGAMgASgJIkYKGExpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRIqCgZtb2RlbHMYASADKAsyGi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsIi8KGlJlZnJlc2hSb3V0ZXJNb2RlbHNSZXF1ZXN0EhEKCXJvdXRlcl9pZBgBIAEoCSJgChtSZWZyZXNoUm91dGVyTW9kZWxzUmVzcG9uc2USEgoKZGlzY292ZXJlZBgBIAEoDRIRCglhdmFpbGFibGUYAiABKA0SGgoSbWFya2VkX3VuYXZhaWxhYmxlGAMgASgNImwKHlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSEAoIbW9kZWxfaWQYAiABKAkSDwoHZW5hYmxlZBgDIAEoCBIUCgx1c2VyX2VuYWJsZWQYBCABKAgiTAofVXBkYXRlUm91dGVyTW9kZWxQb2xpY3lSZXNwb25zZRIpCgVtb2RlbBgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuUm91dGVyTW9kZWwivQEKIFVwZGF0ZU1vZGVsUm91dGVyU2V0dGluZ3NSZXF1ZXN0EhEKCXJvdXRlcl9pZBgBIAEoCRIaCg1kZWZhdWx0X21vZGVsGAIgASgJSACIAQESGAoLdGl0bGVfbW9kZWwYAyABKAlIAYgBARIbCg5kZWNpc2lvbl9tb2RlbBgEIAEoCUgCiAEBQhAKDl9kZWZhdWx0X21vZGVsQg4KDF90aXRsZV9tb2RlbEIRCg9fZGVjaXNpb25fbW9kZWwiTwohVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1Jlc3BvbnNlEioKBnJvdXRlchgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuTW9kZWxSb3V0ZXIqrQEKE1JvdXRlck1vZGVsQXVkaWVuY2USJQohUk9VVEVSX01PREVMX0FVRElFTkNFX1VOU1BFQ0lGSUVEEAASHgoaUk9VVEVSX01PREVMX0FVRElFTkNFX1VTRVIQARImCiJST1VURVJfTU9ERUxfQVVESUVOQ0VfUFJPR1JBTU1BVElDEAISJwojUk9VVEVSX01PREVMX0FVRElFTkNFX0FETUlOX0NBVEFMT0cQAzLGBAoSTW9kZWxSb3V0ZXJTZXJ2aWNlEmMKEExpc3RNb2RlbFJvdXRlcnMSJi5lbmdyYW0uYXBwLnYxLkxpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0GicuZW5ncmFtLmFwcC52MS5MaXN0TW9kZWxSb3V0ZXJzUmVzcG9uc2USYwoQTGlzdFJvdXRlck1vZGVscxImLmVuZ3JhbS5hcHAudjEuTGlzdFJvdXRlck1vZGVsc1JlcXVlc3QaJy5lbmdyYW0uYXBwLnYxLkxpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRJsChNSZWZyZXNoUm91dGVyTW9kZWxzEikuZW5ncmFtLmFwcC52MS5SZWZyZXNoUm91dGVyTW9kZWxzUmVxdWVzdBoqLmVuZ3JhbS5hcHAudjEuUmVmcmVzaFJvdXRlck1vZGVsc1Jlc3BvbnNlEngKF1VwZGF0ZVJvdXRlck1vZGVsUG9saWN5Ei0uZW5ncmFtLmFwcC52MS5VcGRhdGVSb3V0ZXJNb2RlbFBvbGljeVJlcXVlc3QaLi5lbmdyYW0uYXBwLnYxLlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVzcG9uc2USfgoZVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5ncxIvLmVuZ3JhbS5hcHAudjEuVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1JlcXVlc3QaMC5lbmdyYW0uYXBwLnYxLlVwZGF0ZU1vZGVsUm91dGVyU2V0dGluZ3NSZXNwb25zZWIGcHJvdG8z"); /** * @generated from message engram.app.v1.ModelRouter @@ -377,7 +377,8 @@ export const UpdateRouterModelPolicyResponseSchema: GenMessage { const source = deps?.connectors ?? makeConnectorStore(getDb()); const registry = await loadRegistry(source); @@ -121,7 +129,7 @@ async function connectorHost(c: Connector, deps?: RunOpDeps): Promise { const settingsFor = deps?.settingsFor ?? defaultSettingsFor; const stored = (c.settings ?? []).length > 0 ? await settingsFor(c.provider) : {}; const host = effectiveHosts(c, resolveSettings(c, stored))[0]; - if (!host) throw new Error(`connector "${c.provider}" has no host`); + if (!host) throw new ConnectorHostUnsetError(c.provider); return host; } diff --git a/orchestrator/src/listeners/title-consumer.ts b/orchestrator/src/listeners/title-consumer.ts index 9e06538cc..6305da36d 100644 --- a/orchestrator/src/listeners/title-consumer.ts +++ b/orchestrator/src/listeners/title-consumer.ts @@ -22,13 +22,14 @@ */ import { and, eq, isNull, ne } from "drizzle-orm"; -import { APICallError, generateObject, RetryError } from "ai"; +import { generateObject } from "ai"; import { z } from "zod"; import type { CuratedEvent } from "../control-plane/session-events.ts"; import { getDb } from "../db/client.ts"; import { task as taskTable, taskSession as taskSessionTable } from "../db/schema.ts"; import { titleModel } from "../model-routers/client.ts"; +import { isPermanentModelError } from "../model-routers/errors.ts"; import { log as rootLog } from "../log.ts"; import type { SessionConsumer } from "./consumer.ts"; @@ -179,16 +180,6 @@ export function makeProductionTitleConsumer(): SessionConsumer { .set({ suggestedTitle: title }) .where(and(eq(taskTable.id, taskId), isNull(taskTable.suggestedTitle))); }, - isPermanentError, + isPermanentError: isPermanentModelError, }); } - -/** Permanent = an API rejection a retry cannot fix: an `APICallError` the SDK - * itself marks non-retryable (401 bad key, 400/404 bad model id). The SDK's - * internal retries surface as a `RetryError`; classify by its last cause. - * Everything else (429/5xx, network, an unreachable coordinator) is worth the - * pump's backoff — the attempt cap bounds the spend either way. */ -export function isPermanentError(err: unknown): boolean { - const cause = RetryError.isInstance(err) ? err.lastError : err; - return APICallError.isInstance(cause) && !cause.isRetryable; -} diff --git a/orchestrator/src/model-routers/chat-decisions.ts b/orchestrator/src/model-routers/chat-decisions.ts index 663f2c924..93ceebd96 100644 --- a/orchestrator/src/model-routers/chat-decisions.ts +++ b/orchestrator/src/model-routers/chat-decisions.ts @@ -15,6 +15,7 @@ import { type DecisionsRequest, type DecisionsResponse, } from "./decisions.ts"; +import { isPermanentModelError } from "./errors.ts"; const SYSTEM_PROMPT = [ "You answer questions about the given state. Return one answer per question id.", @@ -24,6 +25,9 @@ const SYSTEM_PROMPT = [ "`confidence` is your probability from 0 to 1 that the answer is right.", ].join("\n"); +/** One call, no SDK retries: the decide block makes the one retry. */ +const TIMEOUT_MS = 20_000; + const unit = (value: number): number => Math.max(0, Math.min(1, value)); function answerSchema(question: DecisionQuestion) { @@ -31,7 +35,10 @@ function answerSchema(question: DecisionQuestion) { case "noul": return z.object({ noul: z.number() }); case "choice": - return z.object({ choice: z.enum(Object.keys(question.criteria)), confidence: z.number() }); + // A plain string: the labels are in the prompt, and a strict schema + // limits the enum values it accepts. The decide block rejects a label + // that is not an option. + return z.object({ choice: z.string(), confidence: z.number() }); case "score": return z.object({ score: z.number(), confidence: z.number() }); } @@ -63,6 +70,8 @@ export async function decideByChat( schema: z.object(Object.fromEntries(questions.map(([id, question]) => [id, answerSchema(question)]))), system: SYSTEM_PROMPT, prompt: JSON.stringify({ state: request.state, questions: request.questions }), + maxRetries: 0, + abortSignal: AbortSignal.timeout(TIMEOUT_MS), }); const answers = object as Record>; return { @@ -71,15 +80,13 @@ export async function decideByChat( usage: { input_tokens: usage.inputTokens ?? 0, output_tokens: usage.outputTokens ?? 0 }, }; } catch (error) { - // An API rejection the SDK marks non-retryable (a bad key, an unknown - // model) is permanent. A network fault, a rate limit, or an answer that - // does not fit the schema is worth one more call. + // A network fault, a timeout, a rate limit, or an answer that does not + // fit the schema is worth one more call. const cause = RetryError.isInstance(error) ? error.lastError : error; - const permanent = APICallError.isInstance(cause) && !cause.isRetryable; throw new DecisionsApiError( `decision by chat completion failed: ${error instanceof Error ? error.message : String(error)}`, APICallError.isInstance(cause) ? (cause.statusCode ?? null) : null, - !permanent, + !isPermanentModelError(error), ); } } diff --git a/orchestrator/src/model-routers/client.ts b/orchestrator/src/model-routers/client.ts index 1be220de1..bac8021dd 100644 --- a/orchestrator/src/model-routers/client.ts +++ b/orchestrator/src/model-routers/client.ts @@ -51,7 +51,7 @@ export async function titleModel(): Promise { return (await getIntegrationClient(chosen.router.connector)).chat(chosen.model); } -/** Whether a connected router can answer a decisions request. */ +/** Whether a connected router has a decision model. */ export async function canDecide(): Promise { return (await routerFor("decision")) !== null; } @@ -65,12 +65,27 @@ const TRANSIENT_LOOKUP_CODES = new Set([ Code.Internal, ]); -/** Answer a decisions request on the first connected router that has a - * decision model; null when there is none. The request's own `model` - * replaces the router's. A transient failure to resolve the key is a - * retryable DecisionsApiError; a missing or rejected key stays permanent. */ +/** Answer a decisions request; null when no connected router can. A request + * that names its `model` goes to the first connected router; any other goes + * to the first one that has a decision model. A transient failure to resolve + * the key is a retryable DecisionsApiError; a missing or rejected key stays + * permanent. */ export async function decide(request: DecisionsRequest): Promise { - const chosen = await routerFor("decision"); + let chosen: { router: ModelRouterDefinition; model: string } | null; + if (request.model) { + const [router] = await connectedRoutersOrNone(); + chosen = router ? { router, model: request.model } : null; + } else { + try { + chosen = await routerFor("decision"); + } catch (error) { + throw new DecisionsApiError( + `read the decision model: ${error instanceof Error ? error.message : String(error)}`, + null, + true, + ); + } + } if (!chosen) return null; let client: RouterClient; try { @@ -81,7 +96,7 @@ export async function decide(request: DecisionsRequest): Promise null), + resolveIntegrationHost(definition.connector, deps?.integration).catch((error: unknown) => { + if (error instanceof ConnectorHostUnsetError) return null; + throw error; + }), ]); return { id: definition.id, @@ -184,17 +186,30 @@ export function registerModelRouters(router: ConnectRouter, deps?: ModelRouterSe await requireAdmin(ctx, getSession); const definition = getModelRouterDefinition(req.routerId); if (!definition) throw new ConnectError("model router not found", Code.NotFound); - // An empty field clears the admin's choice for that role. - const chosen = (model: string): string | null => model.trim() || null; - await store.setSettings( - definition.id, - { - defaultModel: chosen(req.defaultModel), - titleModel: chosen(req.titleModel), - decisionModel: chosen(req.decisionModel), - }, - now(), - ); + // An absent field keeps the stored choice; an empty one clears it. + const stored = await store.getSettings(definition.id); + const chosen = (model: string | undefined, kept: string | null | undefined): string | null => + model === undefined ? (kept ?? null) : model.trim() || null; + const settings = { + defaultModel: chosen(req.defaultModel, stored?.defaultModel), + titleModel: chosen(req.titleModel, stored?.titleModel), + decisionModel: chosen(req.decisionModel, stored?.decisionModel), + }; + // A native decisions endpoint serves models the chat catalog does not list. + const inCatalog = [ + settings.defaultModel, + settings.titleModel, + definition.decisions ? null : settings.decisionModel, + ]; + for (const model of inCatalog) { + if (model && !(await store.getModel(definition.id, model))?.available) { + throw new ConnectError( + `model "${model}" is not in the ${definition.label} catalog`, + Code.InvalidArgument, + ); + } + } + await store.setSettings(definition.id, settings, now()); return { router: await routerToProto(definition, true, await connected()) }; }, }); diff --git a/orchestrator/src/rpc/task-create.ts b/orchestrator/src/rpc/task-create.ts index 49f5e73f0..b20dd9d66 100644 --- a/orchestrator/src/rpc/task-create.ts +++ b/orchestrator/src/rpc/task-create.ts @@ -17,7 +17,7 @@ * policy. */ -import { resolveIntegrationHost } from "../integrations/run-op.ts"; +import { ConnectorHostUnsetError, resolveIntegrationHost } from "../integrations/run-op.ts"; import { ConnectError, Code } from "@connectrpc/connect"; import { and, eq, isNull } from "drizzle-orm"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; @@ -344,6 +344,12 @@ export async function compileSessionCreateInput( connectors: deps.connectors, settingsFor: async (provider) => storedSettingsOf((await deps.connections.getDefault(provider))?.config), + }).catch((error: unknown) => { + if (!(error instanceof ConnectorHostUnsetError)) throw error; + throw new ConnectError( + `${router.label} has no host — an admin must set it under Settings before routed launches work`, + Code.FailedPrecondition, + ); }) : undefined; const modelId = opts.model ?? diff --git a/web/src/gen/engram/app/v1/model_router_pb.ts b/web/src/gen/engram/app/v1/model_router_pb.ts index 603f93b40..40931c72f 100644 --- a/web/src/gen/engram/app/v1/model_router_pb.ts +++ b/web/src/gen/engram/app/v1/model_router_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file engram/app/v1/model_router.proto. */ export const file_engram_app_v1_model_router: GenFile = /*@__PURE__*/ - fileDesc("CiBlbmdyYW0vYXBwL3YxL21vZGVsX3JvdXRlci5wcm90bxINZW5ncmFtLmFwcC52MSKpAwoLTW9kZWxSb3V0ZXISCgoCaWQYASABKAkSDQoFbGFiZWwYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSEQoJcHJvdG9jb2xzGAQgAygJEhkKEWNyZWRlbnRpYWxfc2VjcmV0GAUgASgJEhQKDGVncmVzc19ob3N0cxgGIAMoCRIdChVjcmVkZW50aWFsX2NvbmZpZ3VyZWQYByABKAgSJAoXbGFzdF9zdWNjZXNzZnVsX3N5bmNfYXQYCCABKAlIAIgBARIcCg9sYXN0X3N5bmNfZXJyb3IYCSABKAlIAYgBARITCgttb2RlbF9jb3VudBgKIAEoDRIdChVhdmFpbGFibGVfbW9kZWxfY291bnQYCyABKA0SGwoTZW5hYmxlZF9tb2RlbF9jb3VudBgMIAEoDRIVCg1kZWZhdWx0X21vZGVsGA0gASgJEhMKC3RpdGxlX21vZGVsGA4gASgJEhYKDmRlY2lzaW9uX21vZGVsGA8gASgJQhoKGF9sYXN0X3N1Y2Nlc3NmdWxfc3luY19hdEISChBfbGFzdF9zeW5jX2Vycm9yIpkECgtSb3V0ZXJNb2RlbBIRCglyb3V0ZXJfaWQYASABKAkSCgoCaWQYAiABKAkSFgoOY2Fub25pY2FsX3NsdWcYAyABKAkSDAoEbmFtZRgEIAEoCRITCgZhdXRob3IYBSABKAlIAIgBARIYCgtkZXNjcmlwdGlvbhgGIAEoCUgBiAEBEhYKDmNvbnRleHRfbGVuZ3RoGAcgASgEEhkKDHByb21wdF9wcmljZRgIIAEoCUgCiAEBEh0KEGNvbXBsZXRpb25fcHJpY2UYCSABKAlIA4gBARIYChBpbnB1dF9tb2RhbGl0aWVzGAogAygJEhkKEW91dHB1dF9tb2RhbGl0aWVzGAsgAygJEhwKFHN1cHBvcnRlZF9wYXJhbWV0ZXJzGAwgAygJEhwKD2h1Z2dpbmdfZmFjZV9pZBgNIAEoCUgEiAEBEhEKCWF2YWlsYWJsZRgOIAEoCBIPCgdlbmFibGVkGA8gASgIEhQKDHVzZXJfZW5hYmxlZBgQIAEoCBIUCgx1cHN0cmVhbV91cmwYESABKAkSEgoKdXBkYXRlZF9hdBgSIAEoCRIaChJzdXBwb3J0c19yZWFzb25pbmcYEyABKAhCCQoHX2F1dGhvckIOCgxfZGVzY3JpcHRpb25CDwoNX3Byb21wdF9wcmljZUITChFfY29tcGxldGlvbl9wcmljZUISChBfaHVnZ2luZ19mYWNlX2lkIhkKF0xpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0IkcKGExpc3RNb2RlbFJvdXRlcnNSZXNwb25zZRIrCgdyb3V0ZXJzGAEgAygLMhouZW5ncmFtLmFwcC52MS5Nb2RlbFJvdXRlciJyChdMaXN0Um91dGVyTW9kZWxzUmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSNAoIYXVkaWVuY2UYAiABKA4yIi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsQXVkaWVuY2USDgoGc2VhcmNoGAMgASgJIkYKGExpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRIqCgZtb2RlbHMYASADKAsyGi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsIi8KGlJlZnJlc2hSb3V0ZXJNb2RlbHNSZXF1ZXN0EhEKCXJvdXRlcl9pZBgBIAEoCSJgChtSZWZyZXNoUm91dGVyTW9kZWxzUmVzcG9uc2USEgoKZGlzY292ZXJlZBgBIAEoDRIRCglhdmFpbGFibGUYAiABKA0SGgoSbWFya2VkX3VuYXZhaWxhYmxlGAMgASgNImwKHlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSEAoIbW9kZWxfaWQYAiABKAkSDwoHZW5hYmxlZBgDIAEoCBIUCgx1c2VyX2VuYWJsZWQYBCABKAgiTAofVXBkYXRlUm91dGVyTW9kZWxQb2xpY3lSZXNwb25zZRIpCgVtb2RlbBgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuUm91dGVyTW9kZWwieQogVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1JlcXVlc3QSEQoJcm91dGVyX2lkGAEgASgJEhUKDWRlZmF1bHRfbW9kZWwYAiABKAkSEwoLdGl0bGVfbW9kZWwYAyABKAkSFgoOZGVjaXNpb25fbW9kZWwYBCABKAkiTwohVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1Jlc3BvbnNlEioKBnJvdXRlchgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuTW9kZWxSb3V0ZXIqrQEKE1JvdXRlck1vZGVsQXVkaWVuY2USJQohUk9VVEVSX01PREVMX0FVRElFTkNFX1VOU1BFQ0lGSUVEEAASHgoaUk9VVEVSX01PREVMX0FVRElFTkNFX1VTRVIQARImCiJST1VURVJfTU9ERUxfQVVESUVOQ0VfUFJPR1JBTU1BVElDEAISJwojUk9VVEVSX01PREVMX0FVRElFTkNFX0FETUlOX0NBVEFMT0cQAzLGBAoSTW9kZWxSb3V0ZXJTZXJ2aWNlEmMKEExpc3RNb2RlbFJvdXRlcnMSJi5lbmdyYW0uYXBwLnYxLkxpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0GicuZW5ncmFtLmFwcC52MS5MaXN0TW9kZWxSb3V0ZXJzUmVzcG9uc2USYwoQTGlzdFJvdXRlck1vZGVscxImLmVuZ3JhbS5hcHAudjEuTGlzdFJvdXRlck1vZGVsc1JlcXVlc3QaJy5lbmdyYW0uYXBwLnYxLkxpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRJsChNSZWZyZXNoUm91dGVyTW9kZWxzEikuZW5ncmFtLmFwcC52MS5SZWZyZXNoUm91dGVyTW9kZWxzUmVxdWVzdBoqLmVuZ3JhbS5hcHAudjEuUmVmcmVzaFJvdXRlck1vZGVsc1Jlc3BvbnNlEngKF1VwZGF0ZVJvdXRlck1vZGVsUG9saWN5Ei0uZW5ncmFtLmFwcC52MS5VcGRhdGVSb3V0ZXJNb2RlbFBvbGljeVJlcXVlc3QaLi5lbmdyYW0uYXBwLnYxLlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVzcG9uc2USfgoZVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5ncxIvLmVuZ3JhbS5hcHAudjEuVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1JlcXVlc3QaMC5lbmdyYW0uYXBwLnYxLlVwZGF0ZU1vZGVsUm91dGVyU2V0dGluZ3NSZXNwb25zZWIGcHJvdG8z"); + fileDesc("CiBlbmdyYW0vYXBwL3YxL21vZGVsX3JvdXRlci5wcm90bxINZW5ncmFtLmFwcC52MSKpAwoLTW9kZWxSb3V0ZXISCgoCaWQYASABKAkSDQoFbGFiZWwYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSEQoJcHJvdG9jb2xzGAQgAygJEhkKEWNyZWRlbnRpYWxfc2VjcmV0GAUgASgJEhQKDGVncmVzc19ob3N0cxgGIAMoCRIdChVjcmVkZW50aWFsX2NvbmZpZ3VyZWQYByABKAgSJAoXbGFzdF9zdWNjZXNzZnVsX3N5bmNfYXQYCCABKAlIAIgBARIcCg9sYXN0X3N5bmNfZXJyb3IYCSABKAlIAYgBARITCgttb2RlbF9jb3VudBgKIAEoDRIdChVhdmFpbGFibGVfbW9kZWxfY291bnQYCyABKA0SGwoTZW5hYmxlZF9tb2RlbF9jb3VudBgMIAEoDRIVCg1kZWZhdWx0X21vZGVsGA0gASgJEhMKC3RpdGxlX21vZGVsGA4gASgJEhYKDmRlY2lzaW9uX21vZGVsGA8gASgJQhoKGF9sYXN0X3N1Y2Nlc3NmdWxfc3luY19hdEISChBfbGFzdF9zeW5jX2Vycm9yIpkECgtSb3V0ZXJNb2RlbBIRCglyb3V0ZXJfaWQYASABKAkSCgoCaWQYAiABKAkSFgoOY2Fub25pY2FsX3NsdWcYAyABKAkSDAoEbmFtZRgEIAEoCRITCgZhdXRob3IYBSABKAlIAIgBARIYCgtkZXNjcmlwdGlvbhgGIAEoCUgBiAEBEhYKDmNvbnRleHRfbGVuZ3RoGAcgASgEEhkKDHByb21wdF9wcmljZRgIIAEoCUgCiAEBEh0KEGNvbXBsZXRpb25fcHJpY2UYCSABKAlIA4gBARIYChBpbnB1dF9tb2RhbGl0aWVzGAogAygJEhkKEW91dHB1dF9tb2RhbGl0aWVzGAsgAygJEhwKFHN1cHBvcnRlZF9wYXJhbWV0ZXJzGAwgAygJEhwKD2h1Z2dpbmdfZmFjZV9pZBgNIAEoCUgEiAEBEhEKCWF2YWlsYWJsZRgOIAEoCBIPCgdlbmFibGVkGA8gASgIEhQKDHVzZXJfZW5hYmxlZBgQIAEoCBIUCgx1cHN0cmVhbV91cmwYESABKAkSEgoKdXBkYXRlZF9hdBgSIAEoCRIaChJzdXBwb3J0c19yZWFzb25pbmcYEyABKAhCCQoHX2F1dGhvckIOCgxfZGVzY3JpcHRpb25CDwoNX3Byb21wdF9wcmljZUITChFfY29tcGxldGlvbl9wcmljZUISChBfaHVnZ2luZ19mYWNlX2lkIhkKF0xpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0IkcKGExpc3RNb2RlbFJvdXRlcnNSZXNwb25zZRIrCgdyb3V0ZXJzGAEgAygLMhouZW5ncmFtLmFwcC52MS5Nb2RlbFJvdXRlciJyChdMaXN0Um91dGVyTW9kZWxzUmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSNAoIYXVkaWVuY2UYAiABKA4yIi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsQXVkaWVuY2USDgoGc2VhcmNoGAMgASgJIkYKGExpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRIqCgZtb2RlbHMYASADKAsyGi5lbmdyYW0uYXBwLnYxLlJvdXRlck1vZGVsIi8KGlJlZnJlc2hSb3V0ZXJNb2RlbHNSZXF1ZXN0EhEKCXJvdXRlcl9pZBgBIAEoCSJgChtSZWZyZXNoUm91dGVyTW9kZWxzUmVzcG9uc2USEgoKZGlzY292ZXJlZBgBIAEoDRIRCglhdmFpbGFibGUYAiABKA0SGgoSbWFya2VkX3VuYXZhaWxhYmxlGAMgASgNImwKHlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVxdWVzdBIRCglyb3V0ZXJfaWQYASABKAkSEAoIbW9kZWxfaWQYAiABKAkSDwoHZW5hYmxlZBgDIAEoCBIUCgx1c2VyX2VuYWJsZWQYBCABKAgiTAofVXBkYXRlUm91dGVyTW9kZWxQb2xpY3lSZXNwb25zZRIpCgVtb2RlbBgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuUm91dGVyTW9kZWwivQEKIFVwZGF0ZU1vZGVsUm91dGVyU2V0dGluZ3NSZXF1ZXN0EhEKCXJvdXRlcl9pZBgBIAEoCRIaCg1kZWZhdWx0X21vZGVsGAIgASgJSACIAQESGAoLdGl0bGVfbW9kZWwYAyABKAlIAYgBARIbCg5kZWNpc2lvbl9tb2RlbBgEIAEoCUgCiAEBQhAKDl9kZWZhdWx0X21vZGVsQg4KDF90aXRsZV9tb2RlbEIRCg9fZGVjaXNpb25fbW9kZWwiTwohVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1Jlc3BvbnNlEioKBnJvdXRlchgBIAEoCzIaLmVuZ3JhbS5hcHAudjEuTW9kZWxSb3V0ZXIqrQEKE1JvdXRlck1vZGVsQXVkaWVuY2USJQohUk9VVEVSX01PREVMX0FVRElFTkNFX1VOU1BFQ0lGSUVEEAASHgoaUk9VVEVSX01PREVMX0FVRElFTkNFX1VTRVIQARImCiJST1VURVJfTU9ERUxfQVVESUVOQ0VfUFJPR1JBTU1BVElDEAISJwojUk9VVEVSX01PREVMX0FVRElFTkNFX0FETUlOX0NBVEFMT0cQAzLGBAoSTW9kZWxSb3V0ZXJTZXJ2aWNlEmMKEExpc3RNb2RlbFJvdXRlcnMSJi5lbmdyYW0uYXBwLnYxLkxpc3RNb2RlbFJvdXRlcnNSZXF1ZXN0GicuZW5ncmFtLmFwcC52MS5MaXN0TW9kZWxSb3V0ZXJzUmVzcG9uc2USYwoQTGlzdFJvdXRlck1vZGVscxImLmVuZ3JhbS5hcHAudjEuTGlzdFJvdXRlck1vZGVsc1JlcXVlc3QaJy5lbmdyYW0uYXBwLnYxLkxpc3RSb3V0ZXJNb2RlbHNSZXNwb25zZRJsChNSZWZyZXNoUm91dGVyTW9kZWxzEikuZW5ncmFtLmFwcC52MS5SZWZyZXNoUm91dGVyTW9kZWxzUmVxdWVzdBoqLmVuZ3JhbS5hcHAudjEuUmVmcmVzaFJvdXRlck1vZGVsc1Jlc3BvbnNlEngKF1VwZGF0ZVJvdXRlck1vZGVsUG9saWN5Ei0uZW5ncmFtLmFwcC52MS5VcGRhdGVSb3V0ZXJNb2RlbFBvbGljeVJlcXVlc3QaLi5lbmdyYW0uYXBwLnYxLlVwZGF0ZVJvdXRlck1vZGVsUG9saWN5UmVzcG9uc2USfgoZVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5ncxIvLmVuZ3JhbS5hcHAudjEuVXBkYXRlTW9kZWxSb3V0ZXJTZXR0aW5nc1JlcXVlc3QaMC5lbmdyYW0uYXBwLnYxLlVwZGF0ZU1vZGVsUm91dGVyU2V0dGluZ3NSZXNwb25zZWIGcHJvdG8z"); /** * @generated from message engram.app.v1.ModelRouter @@ -377,7 +377,8 @@ export const UpdateRouterModelPolicyResponseSchema: GenMessage Date: Wed, 11 Nov 2026 08:57:40 -0800 Subject: [PATCH 08/38] forge: git credentials follow the session's capabilities; GitLab git through the proxy Two delivery paths, one rule: a forge that mints a short-lived credential serves it to the guest's askpass helper; a forge with a static token gets it from the egress proxy as a header, so the token never enters the guest. Coordinator: - `fetch_git_credential` is the one place that picks a forge and mints. It replaces two copies of `resolve("github")` and two copies of the Basic check. The forge is the provider among the session's capabilities that serves the requested host. - This closes a gap: a session with a broker token and no capability for the forge could ask for a default-scoped credential. It now gets none. A failed capability read is an error; it was read as "no capabilities". - `session_forges` is the one definition of "the session's forges", used by the credential path and by the forge env injection. Header templates: - `render_header_template` in engram-core replaces four copies of `template.replace("{}", secret)` in the proxy and the coordinator. It adds `{basic:USER}`, the HTTP Basic credential base64(USER:secret), for a server that takes a token only as a Basic password. - A connector operation may carry its own `template`, which replaces the credential's on that operation's path. One validator checks every template in a connector; it replaces two copies. GitLab: - Clone, fetch and push over HTTPS work with `repository:read` and `repository:write`. The proxy adds the Basic header on the four smart HTTP paths; every other path on the host stays gated as before. - The host is a setting, default gitlab.com, so a self-managed instance works. A connection with no stored setting keeps gitlab.com. - The reach of a static token is the token's own: the grant gates the operation, not the repository. Not changed: the guest still defaults the askpass host to github.com in three places. That is correct while GitHub is the only forge that mints; a second one needs the host passed to the guest. Not verified against a live GitLab: that the proxy-added Basic header is enough for git with no credential helper. The dev-stack check covers it. Co-Authored-By: Claude Fable 5.2 --- crates/engram-coordinator/src/api/forge.rs | 183 ++++++++---------- crates/engram-coordinator/src/api/sessions.rs | 32 +-- .../engram-coordinator/src/integration_ops.rs | 8 +- crates/engram-coordinator/tests/api.rs | 31 +++ crates/engram-core/src/traits/integration.rs | 67 ++++++- crates/engram-core/src/traits/mod.rs | 4 +- crates/engram-egress-proxy/src/inject.rs | 17 +- crates/engram-egress-proxy/src/registry.rs | 2 +- crates/engram-git-dev/src/lib.rs | 4 + orchestrator/src/__tests__/connectors.test.ts | 32 +++ orchestrator/src/connectors/gitlab.json | 39 +++- orchestrator/src/connectors/registry.ts | 43 ++-- 12 files changed, 318 insertions(+), 144 deletions(-) diff --git a/crates/engram-coordinator/src/api/forge.rs b/crates/engram-coordinator/src/api/forge.rs index db4b7a1af..367ae9747 100644 --- a/crates/engram-coordinator/src/api/forge.rs +++ b/crates/engram-coordinator/src/api/forge.rs @@ -1,8 +1,8 @@ //! ADRs 0023 and 0106 in-session credential-control seam. //! -//! One operation the in-guest `GIT_ASKPASS` helper invokes — mint a fresh -//! git clone/push credential — reachable over two transports that share the -//! same auth + dispatch core: +//! The in-guest `GIT_ASKPASS` helper asks for a fresh git clone/push +//! credential, and the harness asks for its OAuth credential, over two +//! transports that share one auth + dispatch core: //! //! - **HTTP** (`GET /sessions/:id/git-credential`): the ProcessBackend / //! `--mode=all` loopback path. Mounted OUTSIDE the deployment bearer @@ -15,100 +15,107 @@ //! deployment token. OAuth operations select no subject/provider from guest //! input: the coordinator resolves the durable session binding. //! -//! ADR 0056 P3 folded PR-open onto the egress inject+observe plane, so this -//! seam now carries ONLY the git credential — the one delivery the egress -//! interceptor can't header-inject (git speaks a credential-helper protocol, -//! not a header-authed HTTP API). +//! Only a forge whose integration mints a short-lived credential is served +//! here. A forge with a static token gets it from the egress proxy as a +//! header, so the token never enters the guest. +use std::collections::BTreeSet; use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::http::HeaderMap; use axum::Json; +use chrono::{DateTime, Utc}; use engram_core::traits::{CredentialHint, HarnessByteStream, Integration, ScopedCredential}; use engram_core::types::ids::SessionId; +use engram_core::types::Capability; use engram_harness_proto::{read_msg, write_msg, ForgeOp, ForgeRequest, ForgeResponse}; use serde::{Deserialize, Serialize}; use crate::error::ApiError; use crate::state::SharedState; -// ---- shared auth + dispatch core (HTTP + vsock) ------------------------ +// ---- shared core (HTTP + vsock) ----------------------------------------- -/// Why a forge request was denied. Maps onto an HTTP status (HTTP path) -/// or a `ForgeResponse::Error` message (vsock path). -enum Denied { - NoForge, - BadToken, +/// A git forge the session may use: a provider among the session's +/// capabilities whose integration serves a git host. +pub(crate) struct SessionForge { + pub integration: Arc, + pub host: String, } -impl Denied { - fn message(&self) -> &'static str { - match self { - Self::NoForge => "no git forge is configured", - Self::BadToken => "invalid or missing forge token", +/// The session's forges, one per capability provider that serves a git host. +pub(crate) async fn session_forges(state: &SharedState, caps: &[Capability]) -> Vec { + let providers: BTreeSet<&str> = caps.iter().map(|c| c.provider.as_str()).collect(); + let mut forges = Vec::new(); + for provider in providers { + let Some(integration) = state + .integrations + .resolve(provider, &state.services.secrets) + .await + else { + continue; + }; + if let Some(host) = integration.git_forge_host() { + forges.push(SessionForge { integration, host }); } } + forges } -impl From for ApiError { - fn from(d: Denied) -> Self { - match d { - Denied::NoForge => ApiError::Unsupported(d.message().into()), - Denied::BadToken => ApiError::Unauthorized(d.message().into()), - } - } +/// A git credential: the username and password git sends as HTTP Basic. +struct GitCredential { + username: String, + password: String, + expires_at: DateTime, } -/// Verify the per-session broker token (constant-time) and return the -/// configured forge. Layers the forge-configured check on top of the -/// shared [`crate::api::session_auth::authorize_broker_token`]. -async fn authorize( - state: &SharedState, - session: SessionId, - token: &str, -) -> Result, Denied> { - let integration = state - .integrations - .resolve("github", &state.services.secrets) - .await - .ok_or(Denied::NoForge)?; - if crate::api::session_auth::authorize_broker_token(state, session, token).await { - Ok(integration) - } else { - Err(Denied::BadToken) - } -} - -/// Mint a credential, rejecting a host that doesn't match the forge. -/// -/// ADR 0056 (Plane A): the token is scoped to the session's bound capabilities -/// — read them from PG and hand them to the mint, which clamps the GitHub App -/// permissions to exactly what the profile granted (filtering to its own -/// provider). An empty set falls back to the provider's default scopes. -async fn op_fetch_credential( +/// Mint the session's git credential for `host`. The forge is the one among +/// the session's capability providers that serves `host`; with no `host`, the +/// session's only forge. The mint clamps the credential to the capabilities, +/// so a session with no capability for a forge gets no credential from it. +async fn fetch_git_credential( state: &SharedState, session: SessionId, - integration: &Arc, host: Option, owner: Option, -) -> Result { +) -> Result { + let host = host.filter(|s| !s.is_empty()); let caps = state .services .meta .get_session_capabilities(session) .await - .unwrap_or_default(); - // The git remote host (e.g. github.com); the integration validates it against - // its own served host. This is the one caller that legitimately sets it. + .map_err(|e| format!("read session capabilities: {e}"))?; + let mut forges = session_forges(state, &caps).await; + if let Some(host) = &host { + forges.retain(|forge| forge.host.eq_ignore_ascii_case(host)); + } + let forge = match forges.as_slice() { + [forge] => forge, + [] => return Err("this session has no git forge for that host".into()), + _ => return Err("this session has more than one git forge; name the host".into()), + }; let hint = CredentialHint { - served_host: host.filter(|s| !s.is_empty()), + served_host: host, owner: owner.filter(|s| !s.is_empty()), }; - integration - .mint_credential(&caps, &hint) - .await - .map_err(|e| format!("mint git credential: {e}")) + match forge.integration.mint_credential(&caps, &hint).await { + Ok(ScopedCredential::Basic { + username, + password, + expires_at, + }) => Ok(GitCredential { + username, + password, + expires_at, + }), + Ok(_) => Err(format!( + "{} minted a credential git cannot send as HTTP Basic", + forge.integration.provider() + )), + Err(e) => Err(format!("mint git credential: {e}")), + } } use crate::api::session_auth::bearer; @@ -130,9 +137,8 @@ pub struct GitCredentialResponse { pub expires_at: String, } -/// `GET /sessions/:id/git-credential` — mint a fresh installation -/// credential. The guest fetches per git op, so token expiry is -/// invisible in-guest. +/// `GET /sessions/:id/git-credential` — mint a fresh git credential. The +/// guest fetches one per git operation, so it never sees a token expire. pub async fn git_credential( State(state): State, Path(id): Path, @@ -141,24 +147,18 @@ pub async fn git_credential( ) -> Result, ApiError> { let token = bearer(&headers).ok_or_else(|| ApiError::Unauthorized("missing forge token".into()))?; - let integration = authorize(&state, id, &token).await?; - let cred = op_fetch_credential(&state, id, &integration, q.host, q.owner) + if !crate::api::session_auth::authorize_broker_token(&state, id, &token).await { + return Err(ApiError::Unauthorized( + "invalid or missing forge token".into(), + )); + } + let cred = fetch_git_credential(&state, id, q.host, q.owner) .await .map_err(ApiError::Internal)?; - let ScopedCredential::Basic { - username, - password, - expires_at, - } = cred - else { - return Err(ApiError::Internal( - "github mint returned a non-basic credential".into(), - )); - }; Ok(Json(GitCredentialResponse { - username, - password, - expires_at: expires_at.to_rfc3339(), + username: cred.username, + password: cred.password, + expires_at: cred.expires_at.to_rfc3339(), })) } @@ -232,25 +232,10 @@ async fn process_request(state: &SharedState, req: ForgeRequest) -> ForgeRespons } match req.op { ForgeOp::FetchCredential { host, owner } => { - let integration = match state - .integrations - .resolve("github", &state.services.secrets) - .await - { - Some(integration) => integration, - None => { - return ForgeResponse::Error { - message: Denied::NoForge.message().into(), - } - } - }; - match op_fetch_credential(state, req.session_id, &integration, Some(host), owner).await - { - Ok(ScopedCredential::Basic { - username, password, .. - }) => ForgeResponse::Credential { username, password }, - Ok(_) => ForgeResponse::Error { - message: "github mint returned a non-basic credential".into(), + match fetch_git_credential(state, req.session_id, Some(host), owner).await { + Ok(cred) => ForgeResponse::Credential { + username: cred.username, + password: cred.password, }, Err(message) => ForgeResponse::Error { message }, } diff --git a/crates/engram-coordinator/src/api/sessions.rs b/crates/engram-coordinator/src/api/sessions.rs index 531ac09cc..2df8fc169 100644 --- a/crates/engram-coordinator/src/api/sessions.rs +++ b/crates/engram-coordinator/src/api/sessions.rs @@ -1809,33 +1809,21 @@ pub(crate) async fn inject_forge_env( session_id: SessionId, env: &mut HashMap, ) -> Result<(), ApiError> { - // ADR 0056 P2: forge env is injected when the session holds a capability for a - // provider that (a) resolves a mint engine AND (b) declares a git-forge host - // (`Integration::git_forge_host`). De-hardcodes the old `"github"` literal — any - // git-forge integration (github today; gitlab/gitea later) gets the askpass env. - // The manifest `[git]` block is retired — git binding rides the profile's caps. + // The askpass env goes to a session that holds a capability for a git forge. let caps = state .services .meta .get_session_capabilities(session_id) .await?; - // The session's bound caps that belong to a git-forge provider. - let mut git_caps: Vec<&engram_core::types::Capability> = Vec::new(); - let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); - for provider in caps.iter().map(|c| c.provider.as_str()) { - if !seen.insert(provider) { - continue; - } - let is_git_forge = state - .integrations - .resolve(provider, &state.services.secrets) - .await - .and_then(|e| e.git_forge_host()) - .is_some(); - if is_git_forge { - git_caps.extend(caps.iter().filter(|c| c.provider == provider)); - } - } + let forges = crate::api::forge::session_forges(state, &caps).await; + let git_caps: Vec<&engram_core::types::Capability> = caps + .iter() + .filter(|c| { + forges + .iter() + .any(|f| f.integration.provider() == c.provider) + }) + .collect(); if git_caps.is_empty() { return Ok(()); } diff --git a/crates/engram-coordinator/src/integration_ops.rs b/crates/engram-coordinator/src/integration_ops.rs index 0556cdcf1..1ceac2590 100644 --- a/crates/engram-coordinator/src/integration_ops.rs +++ b/crates/engram-coordinator/src/integration_ops.rs @@ -14,7 +14,9 @@ use std::collections::HashMap; use std::time::Duration; -use engram_core::traits::{CredentialHint, ScopedCredential, SecretContext}; +use engram_core::traits::{ + render_header_template, CredentialHint, ScopedCredential, SecretContext, +}; use engram_core::types::SecretSchema; use engram_protocol::app; @@ -205,7 +207,7 @@ async fn apply_credential( } else { template }; - return Ok(builder.header(header_name, template.replace("{}", &token.secret))); + return Ok(builder.header(header_name, render_header_template(template, &token.secret))); } if spec.source == "mint" { // Draft fields (the connect sheet's pre-save "Test") build a one-shot @@ -257,7 +259,7 @@ async fn apply_credential( } else { inj.template.as_str() }; - b = b.header(header_name, template.replace("{}", &value)); + b = b.header(header_name, render_header_template(template, &value)); } Ok(b) } diff --git a/crates/engram-coordinator/tests/api.rs b/crates/engram-coordinator/tests/api.rs index 87060fedd..82ba4a481 100644 --- a/crates/engram-coordinator/tests/api.rs +++ b/crates/engram-coordinator/tests/api.rs @@ -102,6 +102,12 @@ async fn build_forge_app() -> ( }) .await .expect("seed session"); + meta.bind_session_capabilities( + session_id, + &[engram_core::types::Capability::parse("github:contents:read@acme/app").unwrap()], + ) + .await + .expect("bind capabilities"); let sandbox_dir = tempfile::tempdir().expect("sandbox tempdir").keep(); let forge = Arc::new(engram_git_dev::StaticGitHubIntegration::github( "ghs_test_xyz", @@ -187,6 +193,31 @@ async fn forge_git_credential_gated_by_broker_token() { assert_eq!(v["password"], "ghs_test_xyz"); } +#[tokio::test] +async fn forge_git_credential_needs_a_capability_for_the_host() { + let (app, sid, forge) = build_forge_app().await; + let get = |uri: String| { + app.clone().oneshot( + Request::get(uri) + .header("authorization", "Bearer broker-tok-123") + .body(Body::empty()) + .unwrap(), + ) + }; + let uri = format!("/api/v1/sessions/{sid}/git-credential"); + + // The session's forge serves github.com: a named host must match it. + let resp = get(format!("{uri}?host=GitHub.com")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let resp = get(format!("{uri}?host=gitlab.com")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + + // The mint is clamped to the session's own capabilities. + let minted = forge.recorded_mint_caps(); + assert_eq!(minted.len(), 1); + assert_eq!(minted[0][0].action, "contents:read"); +} + #[tokio::test] async fn forge_forward_runs_the_core_for_split_hosts() { // ADR 0023 split-mode forwarding: an FC host can't run the forge sink diff --git a/crates/engram-core/src/traits/integration.rs b/crates/engram-core/src/traits/integration.rs index cd917c04d..9bd54eb14 100644 --- a/crates/engram-core/src/traits/integration.rs +++ b/crates/engram-core/src/traits/integration.rs @@ -161,8 +161,42 @@ pub fn default_inject_header(cred: &ScopedCredential) -> Option { } } -/// Minimal standard base64 (RFC 4648, `+/`, `=` padding) — avoids a crate dep for -/// the one place engram-core needs it (the Basic `inject_header` default). +/// Render a connector's header template with the resolved secret. `{}` is the +/// secret itself. `{basic:USER}` is the HTTP Basic credential +/// base64(`USER:secret`), for a server that takes a token only as a Basic +/// password (git over HTTPS). The secret's own text is never scanned for a +/// placeholder. +pub fn render_header_template(template: &str, secret: &str) -> String { + const BASIC: &str = "{basic:"; + let mut out = String::with_capacity(template.len() + secret.len() * 2); + let mut rest = template; + loop { + let raw = rest.find("{}"); + let basic = rest + .find(BASIC) + .and_then(|at| Some((at, at + rest[at..].find('}')?))); + match (raw, basic) { + (Some(raw), basic) if basic.is_none_or(|(at, _)| raw < at) => { + out.push_str(&rest[..raw]); + out.push_str(secret); + rest = &rest[raw + 2..]; + } + (_, Some((at, end))) => { + let user = &rest[at + BASIC.len()..end]; + out.push_str(&rest[..at]); + out.push_str(&base64_std(format!("{user}:{secret}").as_bytes())); + rest = &rest[end + 1..]; + } + _ => { + out.push_str(rest); + return out; + } + } + } +} + +/// Standard base64 (RFC 4648, `+/`, `=` padding), so engram-core needs no +/// crate for the Basic credential. fn base64_std(input: &[u8]) -> String { const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::with_capacity(input.len().div_ceil(3) * 4); @@ -242,6 +276,35 @@ mod inject_header_tests { use super::*; use chrono::Utc; + #[test] + fn header_template_substitutes_the_secret() { + assert_eq!(render_header_template("Bearer {}", "tok"), "Bearer tok"); + assert_eq!(render_header_template("{}", "tok"), "tok"); + assert_eq!(render_header_template("static", "tok"), "static"); + } + + #[test] + fn header_template_renders_http_basic() { + // base64("oauth2:tok") and base64(":tok"). + assert_eq!( + render_header_template("Basic {basic:oauth2}", "tok"), + "Basic b2F1dGgyOnRvaw==" + ); + assert_eq!( + render_header_template("Basic {basic:}", "tok"), + "Basic OnRvaw==" + ); + } + + #[test] + fn header_template_never_scans_the_secret() { + assert_eq!( + render_header_template("{} {}", "{basic:x}"), + "{basic:x} {basic:x}" + ); + assert_eq!(render_header_template("{basic:u", "tok"), "{basic:u"); + } + #[test] fn base64_std_matches_rfc4648() { assert_eq!(base64_std(b""), ""); diff --git a/crates/engram-core/src/traits/mod.rs b/crates/engram-core/src/traits/mod.rs index 252440b7f..bb960a17c 100644 --- a/crates/engram-core/src/traits/mod.rs +++ b/crates/engram-core/src/traits/mod.rs @@ -27,8 +27,8 @@ pub use clock::{Clock, Entropy, OsEntropy, SystemClock}; pub use cloud::CloudBackend; pub use host_client::{HostClient, SessionFence}; pub use integration::{ - default_inject_header, CredentialHint, InjectHeader, Integration, MintFieldKind, - MintFieldSchema, MintKindDescriptor, ResolvedFields, ScopedCredential, + default_inject_header, render_header_template, CredentialHint, InjectHeader, Integration, + MintFieldKind, MintFieldSchema, MintKindDescriptor, ResolvedFields, ScopedCredential, }; pub use metadata::{ CreateDisposition, DisableEnabledImageOutcome, ExecLifecycleEventKind, ExecOutputStream, diff --git a/crates/engram-egress-proxy/src/inject.rs b/crates/engram-egress-proxy/src/inject.rs index 3ff89ec1b..1c687a141 100644 --- a/crates/engram-egress-proxy/src/inject.rs +++ b/crates/engram-egress-proxy/src/inject.rs @@ -7,6 +7,8 @@ //! secret the guest never sees. (A request to an inject-gated host whose //! shape matches no entry is rejected by the caller.) +use engram_core::traits::render_header_template; + use crate::registry::InjectEntry; #[derive(Debug, PartialEq, Eq)] @@ -45,7 +47,7 @@ pub fn request_line(prefix: &[u8]) -> Option<(String, String)> { Some((method.to_string(), path.to_string())) } -/// Add each entry's `header_name: