okengine 0.21.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -4
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +15 -9
- package/manifest.v1.schema.json +54 -0
- package/package.json +53 -38
- package/site/content/docs/ai/mcp.mdx +13 -9
- package/site/content/docs/ai/skills.mdx +2 -2
- package/site/content/docs/client/calling.mdx +27 -19
- package/site/content/docs/client/index.mdx +1 -1
- package/site/content/docs/elements/ai/agents.mdx +61 -13
- package/site/content/docs/elements/ai/decide.mdx +536 -0
- package/site/content/docs/elements/ai/events.mdx +373 -0
- package/site/content/docs/elements/ai/index.mdx +9 -3
- package/site/content/docs/elements/ai/meta.json +1 -1
- package/site/content/docs/elements/ai/prompts.mdx +4 -2
- package/site/content/docs/elements/channel/email.mdx +1 -1
- package/site/content/docs/elements/channel/index.mdx +1 -1
- package/site/content/docs/elements/flow/http.mdx +4 -4
- package/site/content/docs/elements/flow/index.mdx +3 -3
- package/site/content/docs/elements/flow/meta.json +1 -1
- package/site/content/docs/elements/gate/auth.mdx +4 -1
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/store/index.mdx +1 -1
- package/site/content/docs/elements/store/kv.mdx +5 -5
- package/site/content/docs/elements/vault/rotation.mdx +15 -0
- package/site/content/docs/plugins/otp.mdx +31 -31
- package/site/content/docs/recipes/dragonfly.mdx +5 -5
- package/site/content/docs/recipes/index.mdx +6 -6
- package/site/content/docs/recipes/mailpit.mdx +1 -1
- package/site/content/docs/recipes/meilisearch.mdx +1 -1
- package/site/content/docs/recipes/pgdog.mdx +1 -1
- package/site/content/docs/recipes/postgres.mdx +12 -8
- package/site/content/docs/recipes/redis.mdx +4 -4
- package/site/content/docs/reference/cli.mdx +2 -1
- package/site/content/docs/reference/configuration.mdx +11 -9
- package/site/content/docs/reference/errors.mdx +12 -2
- package/site/content/docs/reference/fx.mdx +35 -21
- package/site/content/docs/reference/idempotency.mdx +192 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +2 -1
- package/site/content/docs/understand/meta.json +1 -1
- package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
- package/site/content/docs/understand/the-architecture.mdx +14 -12
- package/site/content/docs/understand/try-it.mdx +3 -3
- package/src/auth/api-key-sql.test.ts +44 -0
- package/src/auth/api-key-sql.ts +1 -0
- package/src/auth/api-keys.ts +30 -12
- package/src/auth/config.ts +6 -2
- package/src/auth/gate-auth.test.ts +20 -0
- package/src/cli/agents-md.test.ts +25 -0
- package/src/cli/ask-vault-gaps.test.ts +38 -1
- package/src/cli/ask-vault-gaps.ts +35 -2
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/decide.ts +174 -0
- package/src/cli/decision-lock-watch.test.ts +45 -0
- package/src/cli/decision-lock-watch.ts +51 -0
- package/src/cli/dev-app-runner.ts +34 -8
- package/src/cli/dev-auth-secret.test.ts +49 -0
- package/src/cli/dev-auth-secret.ts +49 -0
- package/src/cli/dev-hot.test.ts +41 -0
- package/src/cli/dev-hot.ts +48 -0
- package/src/cli/dev.test.ts +1 -1
- package/src/cli/dev.ts +24 -6
- package/src/cli/docker-cli.test.ts +1 -1
- package/src/cli/eval.ts +142 -1
- package/src/cli/index.ts +14 -1
- package/src/cli/load-config.images.test.ts +12 -12
- package/src/cli/load-config.ts +2 -2
- package/src/cli/mcp-from-console.ts +48 -0
- package/src/cli/registry.ts +35 -1
- package/src/cli/vault-cmd.test.ts +23 -0
- package/src/cli/vault-cmd.ts +7 -0
- package/src/client/agent.ts +307 -0
- package/src/client/create.ts +18 -1
- package/src/client/transport.test.ts +245 -0
- package/src/client/transport.ts +133 -19
- package/src/client/types.ts +29 -3
- package/src/client-react/index.ts +7 -1
- package/src/client-react/use-agent-run.test.ts +271 -0
- package/src/client-react/use-agent-run.ts +193 -0
- package/src/compiler/ai-approval.test.ts +36 -0
- package/src/compiler/ai-repair.test.ts +22 -0
- package/src/compiler/decisions.extract.test.ts +164 -0
- package/src/compiler/effects-infer.ts +753 -17
- package/src/compiler/extract.test.ts +37 -0
- package/src/compiler/extract.ts +381 -24
- package/src/compiler/fixtures/skyport.expected.json +24 -0
- package/src/compiler/fx-follow.test.ts +164 -0
- package/src/compiler/fx-index.ts +399 -0
- package/src/compiler/response.ts +35 -2
- package/src/config/index.ts +21 -3
- package/src/console/server/ai-runs-flows.ts +269 -0
- package/src/console/server/ai-runs.test.ts +323 -0
- package/src/console/server/ai-runs.ts +562 -0
- package/src/console/server/ai.ts +12 -1
- package/src/console/server/app.ts +10 -1
- package/src/console/server/decisions-flows.ts +157 -0
- package/src/console/server/decisions.test.ts +66 -0
- package/src/console/server/decisions.ts +229 -0
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/server/flows.ts +50 -2
- package/src/console/server/invoke-user-flow.ts +9 -0
- package/src/console/server/runs-ingest.ts +16 -0
- package/src/console/server/serve.ts +5 -0
- package/src/console/server/state.ts +25 -1
- package/src/console/server/store-stats.test.ts +47 -0
- package/src/console/server/store-stats.ts +92 -18
- package/src/console/ui-next/dist/assets/access-page-DDKkhT9v.js +4 -0
- package/src/console/ui-next/dist/assets/agent-disclosure-62Xts8Xi.js +1 -0
- package/src/console/ui-next/dist/assets/{cache-glyph-92uM5MO7.js → cache-glyph-DyeoKHKA.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DtGVPtcs.js → call-pii-button-DvfpLXsU.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-DN7l6zmC.js → collapsible-BY03SeCg.js} +1 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
- package/src/console/ui-next/dist/assets/decisions-page-CgkKlA56.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
- package/src/console/ui-next/dist/assets/duration-tone-Y6HLjyJ5.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-DLPfNy-_.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-BlAEVgNW.js → highlighted-json-D7nNzpgB.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BDf7OAHv.js → http-method-DdL19zzh.js} +1 -1
- package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
- package/src/console/ui-next/dist/assets/index-C0lc_s9d.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-CRlcyLCC.js +8 -0
- package/src/console/ui-next/dist/assets/{replica-lag-B3GLNVfF.js → replica-lag-Bb3FWUu9.js} +7 -7
- package/src/console/ui-next/dist/assets/request-meta-DYBMWhX8.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-CcE-SXC8.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CKMSEQ4s.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-QOfk0M0H.js +55 -0
- package/src/console/ui-next/dist/assets/units-page-DKC1CDDd.js +1 -0
- package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-B54v4Yit.js +2 -0
- package/src/console/ui-next/dist/index.html +4 -3
- package/src/console/ui-next/seed-parked-approval.ts +126 -0
- package/src/console/ui-next/src/client.ts +224 -1
- package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
- package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
- package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
- package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
- package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
- package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
- package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
- package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
- package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
- package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
- package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
- package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
- package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
- package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
- package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
- package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
- package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
- package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
- package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
- package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
- package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
- package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
- package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
- package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
- package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +15 -2
- package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
- package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
- package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
- package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
- package/src/console/ui-next/src/router.tsx +17 -0
- package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
- package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
- package/src/console/ui-next/vite.config.ts +9 -0
- package/src/docker/docker.test.ts +13 -13
- package/src/docker/images-config.test.ts +12 -12
- package/src/docker/recipes/dragonfly.ts +3 -2
- package/src/docker/recipes/redis.ts +1 -1
- package/src/docker/stack-id.test.ts +1 -1
- package/src/drivers/ai-anthropic.ts +212 -21
- package/src/drivers/ai-mock.ts +34 -1
- package/src/drivers/ai-openai-compatible.ts +67 -26
- package/src/drivers/ai-preconnect.ts +18 -0
- package/src/drivers/ai-stream.test.ts +62 -0
- package/src/drivers/ai-types.ts +20 -0
- package/src/drivers/journal-postgres.ts +455 -3
- package/src/drivers/meilisearch.ts +2 -0
- package/src/drivers/memory-ddl.test.ts +18 -0
- package/src/drivers/memory.ts +21 -1
- package/src/drivers/pg-rls.test.ts +31 -0
- package/src/drivers/pg-rls.ts +15 -0
- package/src/drivers/postgres.test.ts +9 -0
- package/src/drivers/postgres.ts +49 -11
- package/src/drivers/vault-builtin.test.ts +10 -0
- package/src/drivers/vault-builtin.ts +13 -1
- package/src/drivers/vault-remote-bag.ts +5 -1
- package/src/drivers/vault-types.ts +6 -1
- package/src/elements/ai/agent-event-slot.ts +27 -0
- package/src/elements/ai/approval-http.ts +207 -0
- package/src/elements/ai/approval.test.ts +741 -0
- package/src/elements/ai/approval.ts +228 -0
- package/src/elements/ai/decisions/bind.ts +163 -0
- package/src/elements/ai/decisions/certificate.test.ts +291 -0
- package/src/elements/ai/decisions/certificate.ts +377 -0
- package/src/elements/ai/decisions/certify.ts +314 -0
- package/src/elements/ai/decisions/decide.test.ts +953 -0
- package/src/elements/ai/decisions/e2e.test.ts +399 -0
- package/src/elements/ai/decisions/export.test.ts +120 -0
- package/src/elements/ai/decisions/export.ts +146 -0
- package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
- package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
- package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
- package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
- package/src/elements/ai/decisions/http.test.ts +154 -0
- package/src/elements/ai/decisions/http.ts +240 -0
- package/src/elements/ai/decisions/labels.test.ts +282 -0
- package/src/elements/ai/decisions/labels.ts +257 -0
- package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
- package/src/elements/ai/decisions/openrouter.ts +36 -0
- package/src/elements/ai/decisions/provider.ts +102 -0
- package/src/elements/ai/decisions/typesafe.ts +36 -0
- package/src/elements/ai/declare.ts +252 -1
- package/src/elements/ai/events.test.ts +379 -0
- package/src/elements/ai/events.ts +138 -0
- package/src/elements/ai/run-events.test.ts +461 -0
- package/src/elements/ai/run-events.ts +522 -0
- package/src/elements/ai/runtime.ts +1186 -105
- package/src/elements/ai/stream-turn.ts +214 -0
- package/src/elements/ai/stream.live.test.ts +76 -0
- package/src/elements/ai/subagent.test.ts +136 -0
- package/src/elements/ai.test.ts +497 -3
- package/src/elements/ai.ts +16 -1
- package/src/elements/clock/durable.ts +27 -1
- package/src/elements/store/emit-drizzle.ts +2 -2
- package/src/elements/store/live-default.test.ts +2 -0
- package/src/elements/store/live-http.test.ts +1 -0
- package/src/elements/store/resource-list-docs.test.ts +2 -2
- package/src/elements/store/resource.test.ts +3 -3
- package/src/elements/store/schema-decl.test.ts +1 -0
- package/src/elements/store/sql-condition.ts +24 -7
- package/src/elements/store/sql-session.test.ts +51 -0
- package/src/elements/store/sql-session.ts +111 -1
- package/src/elements/store/upsert-app.test.ts +1 -1
- package/src/elements/vault/audit.test.ts +137 -0
- package/src/elements/vault/audit.ts +106 -13
- package/src/elements/vault/boot-chain.ts +9 -1
- package/src/elements/vault/builtin-adapter.ts +33 -3
- package/src/i18n/catalogs/ar.ts +9 -0
- package/src/i18n/catalogs/en.ts +9 -0
- package/src/index.ts +2 -0
- package/src/kernel/abort-scope.ts +33 -2
- package/src/kernel/agent-event-store.ts +448 -0
- package/src/kernel/app.ts +294 -19
- package/src/kernel/auto-cache.test.ts +6 -6
- package/src/kernel/boot-bind/ai.ts +3 -0
- package/src/kernel/boot-bind/store.test.ts +26 -0
- package/src/kernel/boot-bind/store.ts +82 -6
- package/src/kernel/boot-bind/vault.ts +1 -0
- package/src/kernel/boot.ts +34 -5
- package/src/kernel/builtin-errors.ts +10 -0
- package/src/kernel/capability.ts +3 -0
- package/src/kernel/client-descriptor.ts +1 -0
- package/src/kernel/concurrency.test.ts +33 -0
- package/src/kernel/decision-label-store.ts +441 -0
- package/src/kernel/effects-stamping.test.ts +2 -2
- package/src/kernel/effects.test.ts +2 -1
- package/src/kernel/effects.ts +13 -1
- package/src/kernel/element-registries.ts +3 -0
- package/src/kernel/errors-compiler.ts +20 -0
- package/src/kernel/errors-text.ts +4 -0
- package/src/kernel/errors.ts +2 -0
- package/src/kernel/external-effects.test.ts +36 -0
- package/src/kernel/flow.test.ts +2 -3
- package/src/kernel/flow.ts +24 -0
- package/src/kernel/fx-decide.ts +761 -0
- package/src/kernel/fx-fetch.ts +5 -1
- package/src/kernel/fx.test.ts +12 -5
- package/src/kernel/fx.ts +316 -29
- package/src/kernel/http-frame.test.ts +94 -0
- package/src/kernel/http-frame.ts +203 -0
- package/src/kernel/idempotency-store.ts +578 -0
- package/src/kernel/idempotency.test.ts +556 -0
- package/src/kernel/idempotency.ts +304 -0
- package/src/kernel/index.ts +1 -0
- package/src/kernel/journal.ts +160 -0
- package/src/kernel/json-result.ts +10 -0
- package/src/kernel/pipeline.test.ts +45 -0
- package/src/kernel/sse-id.ts +32 -0
- package/src/manifest/diff.ts +1 -0
- package/src/manifest/types.ts +37 -2
- package/src/mcp/ai-tools.test.ts +202 -0
- package/src/mcp/authorization.ts +66 -0
- package/src/mcp/session.ts +3 -0
- package/src/mcp/tools.ts +168 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/otp.test.ts +65 -1
- package/src/plugins/otp.ts +59 -7
- package/src/release/absolute-regression.test.ts +63 -0
- package/src/release/http-graph.test.ts +27 -0
- package/src/release/http-graph.ts +95 -0
- package/src/release/index.ts +3 -0
- package/src/release/limits.ts +17 -2
- package/src/release/measure.ts +144 -28
- package/src/runs/collect.ts +4 -1
- package/src/runs/parquet.test.ts +27 -1
- package/src/runs/parquet.ts +19 -0
- package/src/runs/types.ts +31 -0
- package/src/runtime/json-code-block.test.ts +106 -47
- package/src/runtime/json-code-block.ts +1209 -46
- package/src/term.test.ts +13 -0
- package/src/term.ts +81 -49
- package/src/test/create-test-app.test.ts +3 -1
- package/src/test/create-test-app.ts +40 -0
- package/src/test/live-signals.test.ts +1 -0
- package/src/test/provisions.integration.test.ts +1 -0
- package/src/test/tenant-isolation.test.ts +1 -0
- package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
- package/src/console/ui-next/dist/assets/access-page-Bgt9bq2r.js +0 -4
- package/src/console/ui-next/dist/assets/agent-disclosure-B43CXZZR.js +0 -1
- package/src/console/ui-next/dist/assets/copy-inline-button-CAYD18cr.js +0 -1
- package/src/console/ui-next/dist/assets/detail-header-DVWNjWjg.js +0 -1
- package/src/console/ui-next/dist/assets/dropdown-menu-4h2LOVXM.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-BmIR9FV8.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-CJs5A-wm.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-BMHs-IzK.js +0 -1
- package/src/console/ui-next/dist/assets/index-BKpaes3n.js +0 -63
- package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
- package/src/console/ui-next/dist/assets/observability-page-WnVLI-0j.js +0 -4
- package/src/console/ui-next/dist/assets/request-meta-DMbnAe3f.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-KvFDingJ.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-Htk8Cm9t.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-CFMPWX4f.js +0 -55
- package/src/console/ui-next/dist/assets/units-page-C4NdNuxP.js +0 -1
- package/src/console/ui-next/dist/assets/use-vault-list-CT4-gajj.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-CBYl0LW_.js +0 -2
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fx.decide` — one provider request, journaled. Loaded with `lazyRequire`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { aiDecisionRegistry, gateRegistry } from "./element-registries.ts";
|
|
6
|
+
import type { GatePolicyContext } from "../elements/gate/declare.ts";
|
|
7
|
+
import type { AiDecisionDecl, AiDecisionQuestion } from "../elements/ai/declare.ts";
|
|
8
|
+
import {
|
|
9
|
+
DecisionConfigError,
|
|
10
|
+
DecisionOutageError,
|
|
11
|
+
type DecisionResponse,
|
|
12
|
+
type WireQuestion,
|
|
13
|
+
} from "../elements/ai/decisions/provider.ts";
|
|
14
|
+
import { parseDurationMs } from "../elements/clock/duration.ts";
|
|
15
|
+
import {
|
|
16
|
+
createOpenRouterDecisionProvider,
|
|
17
|
+
OPENROUTER_JEV_MODEL,
|
|
18
|
+
} from "../elements/ai/decisions/openrouter.ts";
|
|
19
|
+
import {
|
|
20
|
+
createTypesafeDecisionProvider,
|
|
21
|
+
TYPESAFE_JEV_MODEL,
|
|
22
|
+
} from "../elements/ai/decisions/typesafe.ts";
|
|
23
|
+
import {
|
|
24
|
+
applyTemperature,
|
|
25
|
+
calibrateBoolean,
|
|
26
|
+
decisionDriftSuspended,
|
|
27
|
+
getDecisionLock,
|
|
28
|
+
questionHash,
|
|
29
|
+
recordDecisionLabel,
|
|
30
|
+
type DecisionCertSlice,
|
|
31
|
+
type DecisionUncertainty,
|
|
32
|
+
} from "../elements/ai/decisions/certificate.ts";
|
|
33
|
+
import { flushDecisionLabels, persistDecisionLabel } from "../elements/ai/decisions/labels.ts";
|
|
34
|
+
import { decisionExportFields, maskDecisionInput } from "../elements/ai/decisions/export.ts";
|
|
35
|
+
import {
|
|
36
|
+
JOURNAL_DEFAULT_LEASE_MS,
|
|
37
|
+
type JournalEntry,
|
|
38
|
+
type JournalSession,
|
|
39
|
+
type JournalStore,
|
|
40
|
+
} from "./journal.ts";
|
|
41
|
+
import { leaseRetryAfterSeconds } from "../elements/ai/approval.ts";
|
|
42
|
+
|
|
43
|
+
/** Retry-After above this is an outage, not a wait. */
|
|
44
|
+
export const DECISION_RETRY_CAP_SECONDS = 60;
|
|
45
|
+
|
|
46
|
+
let decisionAllow:
|
|
47
|
+
| ((names: readonly string[], ctx: GatePolicyContext) => Promise<boolean>)
|
|
48
|
+
| undefined;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Install the booted gate runtime. Resolve uses the same `allow` path as approvals.
|
|
52
|
+
*
|
|
53
|
+
* @param allow - `gates.allow`, or undefined in tests that build a runtime from the registry
|
|
54
|
+
*/
|
|
55
|
+
export function setDecisionGateAllow(
|
|
56
|
+
allow: ((names: readonly string[], ctx: GatePolicyContext) => Promise<boolean>) | undefined,
|
|
57
|
+
): void {
|
|
58
|
+
decisionAllow = allow;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Capability gate used by {@link runFxDecide}. */
|
|
62
|
+
export type DecideGated = (
|
|
63
|
+
kind: "decide",
|
|
64
|
+
resource: string,
|
|
65
|
+
body: () => Promise<unknown>,
|
|
66
|
+
) => Promise<unknown>;
|
|
67
|
+
|
|
68
|
+
/** Inputs for one `fx.decide` call. */
|
|
69
|
+
export interface FxDecideInput {
|
|
70
|
+
readonly gated: DecideGated;
|
|
71
|
+
readonly journal?: JournalSession;
|
|
72
|
+
readonly signal: AbortSignal;
|
|
73
|
+
readonly now: () => number;
|
|
74
|
+
readonly runId?: string;
|
|
75
|
+
/** Tenant of the calling flow. Stamped on pending reviews. */
|
|
76
|
+
readonly tenantId?: string | null;
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a secret through the flow's secret capability.
|
|
79
|
+
* Missing or empty is a config error, not an outage.
|
|
80
|
+
*/
|
|
81
|
+
readonly getSecret?: (name: string) => Promise<string | undefined>;
|
|
82
|
+
readonly decision: { readonly name: string };
|
|
83
|
+
readonly input: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** How the value was produced. Audit is a flag, not a how. */
|
|
87
|
+
export type DecisionHow = "auto" | "reviewed" | "abstained";
|
|
88
|
+
|
|
89
|
+
/** Pending or resolved review stored on the journal step. */
|
|
90
|
+
export interface DecisionReviewRecord {
|
|
91
|
+
readonly status: "pending" | "reviewed";
|
|
92
|
+
readonly requestedAt: number;
|
|
93
|
+
readonly tenant: string | null;
|
|
94
|
+
readonly locale?: string;
|
|
95
|
+
readonly propensity: number;
|
|
96
|
+
readonly labelOnly: boolean;
|
|
97
|
+
readonly values?: Readonly<Record<string, unknown>>;
|
|
98
|
+
readonly reviewer?: string;
|
|
99
|
+
readonly reason?: DecisionUncertainty;
|
|
100
|
+
/** Question ids that are not auto. Resolve must answer each of them. */
|
|
101
|
+
readonly open?: readonly string[];
|
|
102
|
+
readonly model?: string;
|
|
103
|
+
readonly scores?: Readonly<Record<string, number>>;
|
|
104
|
+
readonly raws?: Readonly<Record<string, unknown>>;
|
|
105
|
+
readonly modelValues?: Readonly<Record<string, unknown>>;
|
|
106
|
+
/** Calling input. Label export reads this. */
|
|
107
|
+
readonly input?: unknown;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Result of {@link resolveDecisionReview}. */
|
|
111
|
+
export type DecisionReviewResult =
|
|
112
|
+
| { readonly ok: true }
|
|
113
|
+
| { readonly ok: false; readonly status: 404 }
|
|
114
|
+
| { readonly ok: false; readonly status: 403 }
|
|
115
|
+
| { readonly ok: false; readonly status: 422 }
|
|
116
|
+
| { readonly ok: false; readonly status: 503; readonly reason: "outage" }
|
|
117
|
+
| { readonly ok: false; readonly status: 409; readonly reason: "resolved" }
|
|
118
|
+
| {
|
|
119
|
+
readonly ok: false;
|
|
120
|
+
readonly status: 409;
|
|
121
|
+
readonly reason: "lease";
|
|
122
|
+
readonly retryAfterSeconds: number;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
type ProviderFn = (
|
|
126
|
+
decl: AiDecisionDecl,
|
|
127
|
+
input: unknown,
|
|
128
|
+
signal: AbortSignal,
|
|
129
|
+
) => Promise<DecisionResponse>;
|
|
130
|
+
|
|
131
|
+
let providerOverride: ProviderFn | undefined;
|
|
132
|
+
let providerCalls = 0;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Replace the provider. Tests use this so no key is required.
|
|
136
|
+
*
|
|
137
|
+
* @param fn - One-request evaluator, or undefined to use HTTP
|
|
138
|
+
*/
|
|
139
|
+
export function setDecisionProvider(fn: ProviderFn | undefined): void {
|
|
140
|
+
providerOverride = fn;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* How many provider calls this process has made since the last reset.
|
|
145
|
+
*/
|
|
146
|
+
export function decisionProviderCalls(): number {
|
|
147
|
+
return providerCalls;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Clear the provider override and the call counter.
|
|
152
|
+
*/
|
|
153
|
+
export function resetDecisionProvider(): void {
|
|
154
|
+
providerOverride = undefined;
|
|
155
|
+
providerCalls = 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Journal step prefix. Distinct from `ai-approval:`.
|
|
160
|
+
*
|
|
161
|
+
* @param id - Review id
|
|
162
|
+
* @param labelOnly - Audit queue, which does not park the caller
|
|
163
|
+
*/
|
|
164
|
+
export function decisionStepName(id: string, labelOnly = false): string {
|
|
165
|
+
return labelOnly ? `ai-decision-label:${id}` : `ai-decision:${id}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Run one declared decision.
|
|
170
|
+
*
|
|
171
|
+
* @param options - Gate, journal, and the decision handle
|
|
172
|
+
*/
|
|
173
|
+
export async function runFxDecide(options: FxDecideInput): Promise<unknown> {
|
|
174
|
+
const name = options.decision.name;
|
|
175
|
+
return options.gated("decide", name, () => executeDecide(options, name));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function executeDecide(options: FxDecideInput, name: string): Promise<unknown> {
|
|
179
|
+
const decl = aiDecisionRegistry.find((item) => item.name === name);
|
|
180
|
+
if (!decl) throw new Error(`fx.decide: unknown decision "${name}"`);
|
|
181
|
+
const ordinal = decideOrdinal(options.journal, name);
|
|
182
|
+
const slot = `${name}#${ordinal}`;
|
|
183
|
+
const recorded = options.journal
|
|
184
|
+
? await options.journal.effect("decide-provider", slot, () =>
|
|
185
|
+
callAndDraw(decl, options, ordinal),
|
|
186
|
+
)
|
|
187
|
+
: await callAndDraw(decl, options, ordinal);
|
|
188
|
+
const view = options.journal
|
|
189
|
+
? ((await options.journal.effect("decide-view", slot, () =>
|
|
190
|
+
project(decl, options.input, recorded),
|
|
191
|
+
)) as JournaledView)
|
|
192
|
+
: project(decl, options.input, recorded);
|
|
193
|
+
if (view.auto) {
|
|
194
|
+
if (view.audited && options.journal) {
|
|
195
|
+
const id = reviewId(options.journal.runId, ordinal, name);
|
|
196
|
+
await options.journal.step(decisionStepName(id, true), () =>
|
|
197
|
+
pendingRecord(options, view, true, decl),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return materialize(view);
|
|
201
|
+
}
|
|
202
|
+
if (decl.mode === "abstain") return materialize(view);
|
|
203
|
+
if (!options.journal) {
|
|
204
|
+
throw new Error(`fx.decide: "${name}" review requires a durable journal`);
|
|
205
|
+
}
|
|
206
|
+
const id = reviewId(options.journal.runId, ordinal, name);
|
|
207
|
+
const step = decisionStepName(id, false);
|
|
208
|
+
const stored = (await options.journal.step(step, () =>
|
|
209
|
+
pendingRecord(options, view, false, decl),
|
|
210
|
+
)) as DecisionReviewRecord;
|
|
211
|
+
if (stored.status === "pending") {
|
|
212
|
+
await options.journal.sleep(step, "876000h", () => 876000 * 60 * 60 * 1000);
|
|
213
|
+
}
|
|
214
|
+
const resolved = readStepRecord(options.journal, step) ?? stored;
|
|
215
|
+
return reviewed(view, resolved.values ?? {});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Completed outer `decide` effects for this name. The current call is not stored yet. */
|
|
219
|
+
function decideOrdinal(journal: JournalSession | undefined, name: string): number {
|
|
220
|
+
if (!journal) return 0;
|
|
221
|
+
return journal.run.entries.filter(
|
|
222
|
+
(entry) => entry.kind === "effect" && entry.effectKind === "decide" && entry.resource === name,
|
|
223
|
+
).length;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function readStepRecord(journal: JournalSession, step: string): DecisionReviewRecord | undefined {
|
|
227
|
+
const entry = journal.run.entries.find((item) => item.kind === "step" && item.name === step);
|
|
228
|
+
if (!entry || entry.kind !== "step") return undefined;
|
|
229
|
+
return entry.value as DecisionReviewRecord;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
interface RecordedCall {
|
|
233
|
+
readonly response?: DecisionResponse;
|
|
234
|
+
readonly outage: boolean;
|
|
235
|
+
readonly audited: boolean;
|
|
236
|
+
readonly propensity: number;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function callAndDraw(
|
|
240
|
+
decl: AiDecisionDecl,
|
|
241
|
+
options: FxDecideInput,
|
|
242
|
+
ordinal: number,
|
|
243
|
+
): Promise<RecordedCall> {
|
|
244
|
+
const rate = decl.autonomy?.audit ?? 0;
|
|
245
|
+
const run = options.runId ?? options.journal?.runId ?? "run";
|
|
246
|
+
const draw = auditDraw(`${run}:${decl.name}#${ordinal}`, rate);
|
|
247
|
+
try {
|
|
248
|
+
providerCalls += 1;
|
|
249
|
+
const response = await callProvider(decl, options);
|
|
250
|
+
return { response, outage: false, audited: draw.audited, propensity: draw.propensity };
|
|
251
|
+
} catch (err) {
|
|
252
|
+
if (err instanceof DecisionOutageError) {
|
|
253
|
+
return { outage: true, audited: false, propensity: draw.propensity };
|
|
254
|
+
}
|
|
255
|
+
throw err;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function callProvider(
|
|
260
|
+
decl: AiDecisionDecl,
|
|
261
|
+
options: FxDecideInput,
|
|
262
|
+
): Promise<DecisionResponse> {
|
|
263
|
+
if (providerOverride) return providerOverride(decl, options.input, options.signal);
|
|
264
|
+
const model =
|
|
265
|
+
decl.model ?? (decl.driverId === "typesafe" ? TYPESAFE_JEV_MODEL : OPENROUTER_JEV_MODEL);
|
|
266
|
+
const keyName = decl.driverId === "typesafe" ? "TYPESAFE_API_KEY" : "OPENROUTER_API_KEY";
|
|
267
|
+
const apiKey = await options.getSecret?.(keyName);
|
|
268
|
+
if (!apiKey) throw new DecisionConfigError(keyName);
|
|
269
|
+
const timeoutMs = decisionTimeoutMs(decl.timeout);
|
|
270
|
+
const provider =
|
|
271
|
+
decl.driverId === "typesafe"
|
|
272
|
+
? createTypesafeDecisionProvider(apiKey, timeoutMs)
|
|
273
|
+
: createOpenRouterDecisionProvider(apiKey, timeoutMs);
|
|
274
|
+
return provider.evaluate({
|
|
275
|
+
model,
|
|
276
|
+
state: options.input,
|
|
277
|
+
questions: wireQuestions(decl),
|
|
278
|
+
signal: options.signal,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function decisionTimeoutMs(timeout: AiDecisionDecl["timeout"]): number {
|
|
283
|
+
if (typeof timeout === "number" && timeout > 0) return timeout;
|
|
284
|
+
if (typeof timeout === "string") {
|
|
285
|
+
const parsed = parseDurationMs(timeout);
|
|
286
|
+
if (parsed > 0) return parsed;
|
|
287
|
+
}
|
|
288
|
+
return 30_000;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function wireQuestions(decl: AiDecisionDecl): Record<string, WireQuestion> {
|
|
292
|
+
const questions: Record<string, WireQuestion> = {};
|
|
293
|
+
for (const [id, question] of Object.entries(decl.ask)) {
|
|
294
|
+
questions[id] = wireQuestion(question);
|
|
295
|
+
}
|
|
296
|
+
return questions;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function wireQuestion(question: AiDecisionQuestion): WireQuestion {
|
|
300
|
+
if (question.kind === "choice") {
|
|
301
|
+
return {
|
|
302
|
+
type: "choice",
|
|
303
|
+
instructions: question.instructions,
|
|
304
|
+
criteria: { ...question.options, none_of_these: null },
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (question.kind === "score") {
|
|
308
|
+
return { type: "score", instructions: question.instructions, criteria: question.levels };
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
type: "noul",
|
|
312
|
+
instructions: question.instructions,
|
|
313
|
+
...(question.criteria !== undefined ? { criteria: question.criteria } : {}),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** One question as journaled. Replay returns this; it is not re-projected. */
|
|
318
|
+
interface JournaledQuestion {
|
|
319
|
+
readonly value: unknown;
|
|
320
|
+
readonly how: DecisionHow;
|
|
321
|
+
readonly p: number;
|
|
322
|
+
readonly raw: unknown;
|
|
323
|
+
readonly audited?: boolean;
|
|
324
|
+
/** True when this question did not clear autonomy. */
|
|
325
|
+
readonly uncertain: boolean;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Projection stored on the journal. The live lock is not consulted on replay. */
|
|
329
|
+
interface JournaledView {
|
|
330
|
+
readonly auto: boolean;
|
|
331
|
+
readonly reason?: DecisionUncertainty;
|
|
332
|
+
readonly locale?: string;
|
|
333
|
+
readonly lockModel?: string;
|
|
334
|
+
readonly audited: boolean;
|
|
335
|
+
readonly propensity: number;
|
|
336
|
+
readonly questions: Readonly<Record<string, JournaledQuestion>>;
|
|
337
|
+
readonly meta: {
|
|
338
|
+
readonly model?: string;
|
|
339
|
+
readonly provider: string;
|
|
340
|
+
readonly usage: DecisionResponse["usage"] | Record<string, never>;
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function project(decl: AiDecisionDecl, input: unknown, recorded: RecordedCall): JournaledView {
|
|
345
|
+
const locale = decl.locale?.(input);
|
|
346
|
+
let auto = !recorded.outage && !decisionDriftSuspended(decl.name);
|
|
347
|
+
let reason: DecisionUncertainty | undefined = recorded.outage
|
|
348
|
+
? "outage"
|
|
349
|
+
: decisionDriftSuspended(decl.name)
|
|
350
|
+
? "drift"
|
|
351
|
+
: undefined;
|
|
352
|
+
const lock = getDecisionLock()?.decisions[decl.name];
|
|
353
|
+
if (!recorded.outage && !decisionDriftSuspended(decl.name)) {
|
|
354
|
+
if (!lock || !decl.autonomy) {
|
|
355
|
+
auto = false;
|
|
356
|
+
reason = "missing-lock";
|
|
357
|
+
} else if (lock.model !== recorded.response?.model) {
|
|
358
|
+
auto = false;
|
|
359
|
+
reason = "version-mismatch";
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const questions: Record<string, JournaledQuestion> = {};
|
|
363
|
+
for (const [id, question] of Object.entries(decl.ask)) {
|
|
364
|
+
const slice = sliceFor(lock, id, locale);
|
|
365
|
+
const answer = recorded.response?.answers[id];
|
|
366
|
+
const calibrated = calibrateAnswer(question, answer, slice);
|
|
367
|
+
let questionAuto = auto && slice !== undefined && calibrated.p >= (slice?.threshold ?? 1);
|
|
368
|
+
if (calibrated.value === "none_of_these") {
|
|
369
|
+
questionAuto = false;
|
|
370
|
+
reason = reason ?? "low-confidence";
|
|
371
|
+
} else if (slice === undefined && !reason) {
|
|
372
|
+
questionAuto = false;
|
|
373
|
+
reason = locale ? "uncertified-locale" : "missing-lock";
|
|
374
|
+
} else if (slice && questionHash(question) !== slice.hash) {
|
|
375
|
+
questionAuto = false;
|
|
376
|
+
reason = reason ?? "stale-hash";
|
|
377
|
+
} else if (slice && calibrated.p < slice.threshold) {
|
|
378
|
+
questionAuto = false;
|
|
379
|
+
reason = reason ?? "low-confidence";
|
|
380
|
+
}
|
|
381
|
+
if (!questionAuto) auto = false;
|
|
382
|
+
const how: DecisionHow = questionAuto ? "auto" : decl.mode === "abstain" ? "abstained" : "auto";
|
|
383
|
+
questions[id] = {
|
|
384
|
+
value: questionAuto || decl.mode !== "abstain" ? calibrated.value : null,
|
|
385
|
+
how,
|
|
386
|
+
p: calibrated.p,
|
|
387
|
+
raw: calibrated.raw,
|
|
388
|
+
uncertain: !questionAuto,
|
|
389
|
+
...(recorded.audited && questionAuto ? { audited: true } : {}),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
auto,
|
|
394
|
+
...(reason !== undefined ? { reason } : {}),
|
|
395
|
+
...(locale !== undefined ? { locale } : {}),
|
|
396
|
+
...(lock?.model !== undefined ? { lockModel: lock.model } : {}),
|
|
397
|
+
audited: recorded.audited,
|
|
398
|
+
propensity: recorded.propensity,
|
|
399
|
+
questions,
|
|
400
|
+
meta: {
|
|
401
|
+
model: recorded.response?.model,
|
|
402
|
+
provider: recorded.response?.provider ?? decl.driverId,
|
|
403
|
+
usage: recorded.response?.usage ?? {},
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function materialize(view: JournaledView): Record<string, unknown> {
|
|
409
|
+
const dollar: Record<string, unknown> = { meta: view.meta };
|
|
410
|
+
const result: Record<string, unknown> = { $: dollar };
|
|
411
|
+
for (const [id, question] of Object.entries(view.questions)) {
|
|
412
|
+
result[id] = question.value;
|
|
413
|
+
dollar[id] = {
|
|
414
|
+
how: question.how,
|
|
415
|
+
p: question.p,
|
|
416
|
+
raw: question.raw,
|
|
417
|
+
...(question.audited ? { audited: true } : {}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
return result;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function sliceFor(
|
|
424
|
+
lock:
|
|
425
|
+
| { readonly questions: Readonly<Record<string, Readonly<Record<string, DecisionCertSlice>>>> }
|
|
426
|
+
| undefined,
|
|
427
|
+
question: string,
|
|
428
|
+
locale: string | undefined,
|
|
429
|
+
): DecisionCertSlice | undefined {
|
|
430
|
+
if (!lock) return undefined;
|
|
431
|
+
const slices = lock.questions[question];
|
|
432
|
+
if (!slices) return undefined;
|
|
433
|
+
return slices[locale ?? ""];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function calibrateAnswer(
|
|
437
|
+
question: AiDecisionQuestion,
|
|
438
|
+
answer: unknown,
|
|
439
|
+
slice: DecisionCertSlice | undefined,
|
|
440
|
+
): { readonly value: unknown; readonly p: number; readonly raw: unknown } {
|
|
441
|
+
const record = answer && typeof answer === "object" ? (answer as Record<string, unknown>) : {};
|
|
442
|
+
if (question.kind === "boolean") {
|
|
443
|
+
const noul = typeof record.noul === "number" ? record.noul : 0;
|
|
444
|
+
const calibrator =
|
|
445
|
+
slice?.calibrator.kind === "platt" || slice?.calibrator.kind === "beta"
|
|
446
|
+
? slice.calibrator
|
|
447
|
+
: { kind: "platt" as const, a: 1, b: 0 };
|
|
448
|
+
const probability = calibrateBoolean(noul, calibrator);
|
|
449
|
+
const p = Math.max(probability, 1 - probability);
|
|
450
|
+
return { value: probability >= 0.5, p, raw: { noul } };
|
|
451
|
+
}
|
|
452
|
+
const probs = probabilityList(question, record.probabilities);
|
|
453
|
+
const t = slice?.calibrator.kind === "temperature" ? slice.calibrator.t : 1;
|
|
454
|
+
const scaled = applyTemperature(probs.values, t);
|
|
455
|
+
let best = 0;
|
|
456
|
+
for (let i = 1; i < scaled.length; i++) {
|
|
457
|
+
if ((scaled[i] ?? 0) > (scaled[best] ?? 0)) best = i;
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
value: probs.keys[best],
|
|
461
|
+
p: scaled[best] ?? 0,
|
|
462
|
+
raw: record.probabilities ?? probs.values,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function probabilityList(
|
|
467
|
+
question: AiDecisionQuestion,
|
|
468
|
+
raw: unknown,
|
|
469
|
+
): { readonly keys: string[]; readonly values: number[] } {
|
|
470
|
+
const keys =
|
|
471
|
+
question.kind === "choice"
|
|
472
|
+
? [...Object.keys(question.options), "none_of_these"]
|
|
473
|
+
: question.kind === "score"
|
|
474
|
+
? [...question.levels]
|
|
475
|
+
: [];
|
|
476
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
477
|
+
const record = raw as Record<string, unknown>;
|
|
478
|
+
const named = keys.map((key) => (typeof record[key] === "number" ? record[key] : 0));
|
|
479
|
+
const namedHit = named.some((value) => value > 0);
|
|
480
|
+
if (!namedHit && question.kind === "score") {
|
|
481
|
+
const indexed = keys.map((_, index) => {
|
|
482
|
+
const value = record[String(index)];
|
|
483
|
+
return typeof value === "number" ? value : 0;
|
|
484
|
+
});
|
|
485
|
+
if (indexed.some((value) => value > 0)) return { keys, values: indexed };
|
|
486
|
+
}
|
|
487
|
+
return { keys, values: named };
|
|
488
|
+
}
|
|
489
|
+
if (Array.isArray(raw)) {
|
|
490
|
+
return { keys, values: keys.map((_, i) => (typeof raw[i] === "number" ? raw[i] : 0)) };
|
|
491
|
+
}
|
|
492
|
+
return { keys, values: keys.map(() => 0) };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function reviewed(
|
|
496
|
+
view: JournaledView,
|
|
497
|
+
values: Readonly<Record<string, unknown>>,
|
|
498
|
+
): Record<string, unknown> {
|
|
499
|
+
const next = materialize(view);
|
|
500
|
+
const dollar = next.$ as Record<string, unknown>;
|
|
501
|
+
for (const [id, value] of Object.entries(values)) {
|
|
502
|
+
next[id] = value;
|
|
503
|
+
const slot = dollar[id];
|
|
504
|
+
if (slot && typeof slot === "object") {
|
|
505
|
+
dollar[id] = { ...(slot as Record<string, unknown>), how: "reviewed" };
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return next;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function pendingRecord(
|
|
512
|
+
options: FxDecideInput,
|
|
513
|
+
view: JournaledView,
|
|
514
|
+
labelOnly: boolean,
|
|
515
|
+
decl: AiDecisionDecl,
|
|
516
|
+
): DecisionReviewRecord {
|
|
517
|
+
return {
|
|
518
|
+
status: "pending",
|
|
519
|
+
requestedAt: options.now(),
|
|
520
|
+
tenant: options.tenantId ?? null,
|
|
521
|
+
...(view.locale !== undefined ? { locale: view.locale } : {}),
|
|
522
|
+
propensity: labelOnly ? view.propensity : 1,
|
|
523
|
+
labelOnly,
|
|
524
|
+
open: Object.entries(view.questions)
|
|
525
|
+
.filter(([, question]) => question.uncertain)
|
|
526
|
+
.map(([id]) => id),
|
|
527
|
+
...(view.meta.model !== undefined ? { model: view.meta.model } : {}),
|
|
528
|
+
scores: Object.fromEntries(
|
|
529
|
+
Object.entries(view.questions).map(([id, question]) => [id, question.p]),
|
|
530
|
+
),
|
|
531
|
+
raws: Object.fromEntries(
|
|
532
|
+
Object.entries(view.questions).map(([id, question]) => [id, question.raw]),
|
|
533
|
+
),
|
|
534
|
+
modelValues: Object.fromEntries(
|
|
535
|
+
Object.entries(view.questions).map(([id, question]) => [id, question.value]),
|
|
536
|
+
),
|
|
537
|
+
...(view.reason !== undefined ? { reason: view.reason } : {}),
|
|
538
|
+
input: maskDecisionInput(options.input, decisionExportFields(decl.inputSchema)),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function reviewId(runId: string, ordinal: number, name: string): string {
|
|
543
|
+
return Buffer.from(`${runId}.${ordinal}.${name}`, "utf8").toString("base64url");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function auditDraw(
|
|
547
|
+
seed: string,
|
|
548
|
+
rate: number,
|
|
549
|
+
): { readonly audited: boolean; readonly propensity: number } {
|
|
550
|
+
if (rate <= 0) return { audited: false, propensity: 0 };
|
|
551
|
+
const digest = new Uint32Array(1);
|
|
552
|
+
let hash = 2166136261;
|
|
553
|
+
for (let i = 0; i < seed.length; i++) {
|
|
554
|
+
hash ^= seed.charCodeAt(i);
|
|
555
|
+
hash = Math.imul(hash, 16777619);
|
|
556
|
+
}
|
|
557
|
+
digest[0] = hash >>> 0;
|
|
558
|
+
const unit = (digest[0] ?? 0) / 0x1_0000_0000;
|
|
559
|
+
return { audited: unit < rate, propensity: rate };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Read one decision review from the journal.
|
|
564
|
+
*
|
|
565
|
+
* @param store - Durable journal
|
|
566
|
+
* @param id - Review id
|
|
567
|
+
* @param labelOnly - Audit queue row
|
|
568
|
+
*/
|
|
569
|
+
export async function readDecisionReview(
|
|
570
|
+
store: JournalStore,
|
|
571
|
+
id: string,
|
|
572
|
+
labelOnly = false,
|
|
573
|
+
): Promise<DecisionReviewRecord | undefined> {
|
|
574
|
+
const parsed = parseReviewId(id);
|
|
575
|
+
if (!parsed) return undefined;
|
|
576
|
+
const run = await store.get(parsed.runId);
|
|
577
|
+
if (!run) return undefined;
|
|
578
|
+
const name = decisionStepName(id, labelOnly);
|
|
579
|
+
const entry = run.entries.find((item) => item.kind === "step" && item.name === name);
|
|
580
|
+
if (!entry || entry.kind !== "step") return undefined;
|
|
581
|
+
return entry.value as DecisionReviewRecord;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Resolve a parked decision or an audit label row.
|
|
586
|
+
* A lost lease retries. A finished review is a conflict.
|
|
587
|
+
* The label is written here, including audit reviews.
|
|
588
|
+
*
|
|
589
|
+
* @param store - Durable journal
|
|
590
|
+
* @param id - Review id
|
|
591
|
+
* @param input - Reviewer value
|
|
592
|
+
* @param now - Clock
|
|
593
|
+
* @param labelOnly - Audit queue
|
|
594
|
+
*/
|
|
595
|
+
export async function resolveDecisionReview(
|
|
596
|
+
store: JournalStore,
|
|
597
|
+
id: string,
|
|
598
|
+
input: {
|
|
599
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
600
|
+
readonly reviewer: string;
|
|
601
|
+
readonly tenantId?: string | null;
|
|
602
|
+
/** Operator plane resolves every tenant. The row keeps its tenant stamp. */
|
|
603
|
+
readonly plane?: "user" | "operator";
|
|
604
|
+
/** Resolver auth. The review gate sees this, not an empty principal. */
|
|
605
|
+
readonly auth?: GatePolicyContext["auth"];
|
|
606
|
+
},
|
|
607
|
+
now: () => number = Date.now,
|
|
608
|
+
labelOnly = false,
|
|
609
|
+
): Promise<DecisionReviewResult> {
|
|
610
|
+
const parsed = parseReviewId(id);
|
|
611
|
+
if (!parsed || typeof store.cas !== "function") return { ok: false, status: 404 };
|
|
612
|
+
const decl = aiDecisionRegistry.find((item) => item.name === parsed.name);
|
|
613
|
+
if (!decl) return { ok: false, status: 404 };
|
|
614
|
+
if (!(await reviewGateAllows(decl.review, input))) return { ok: false, status: 403 };
|
|
615
|
+
const name = decisionStepName(id, labelOnly);
|
|
616
|
+
const token = crypto.randomUUID();
|
|
617
|
+
const at = now();
|
|
618
|
+
let stop: DecisionReviewResult | undefined;
|
|
619
|
+
const claimed = await store.cas(parsed.runId, token, at, JOURNAL_DEFAULT_LEASE_MS, (run) => {
|
|
620
|
+
const entry = run.entries.find((item) => item.kind === "step" && item.name === name);
|
|
621
|
+
if (!entry || entry.kind !== "step") {
|
|
622
|
+
stop = { ok: false, status: 404 };
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
const current = entry.value as DecisionReviewRecord;
|
|
626
|
+
if (current.status !== "pending") {
|
|
627
|
+
stop = { ok: false, status: 409, reason: "resolved" };
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
if (input.plane !== "operator" && (input.tenantId ?? null) !== current.tenant) {
|
|
631
|
+
stop = { ok: false, status: 403 };
|
|
632
|
+
return undefined;
|
|
633
|
+
}
|
|
634
|
+
if (!valuesMatchQuestions(decl, current, input.values)) {
|
|
635
|
+
stop = { ok: false, status: 422 };
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
const next: DecisionReviewRecord = {
|
|
639
|
+
...current,
|
|
640
|
+
status: "reviewed",
|
|
641
|
+
values: input.values,
|
|
642
|
+
reviewer: input.reviewer,
|
|
643
|
+
};
|
|
644
|
+
const entries: JournalEntry[] = run.entries.map((item) => {
|
|
645
|
+
if (item.kind === "step" && item.name === name) return { ...item, value: next };
|
|
646
|
+
if (item.kind === "sleep" && item.label === name) return { ...item, wakeAt: at };
|
|
647
|
+
return item;
|
|
648
|
+
});
|
|
649
|
+
return { ...run, entries, wakeAt: at };
|
|
650
|
+
});
|
|
651
|
+
if (claimed === "missing") return { ok: false, status: 404 };
|
|
652
|
+
if (typeof claimed === "object") {
|
|
653
|
+
const retryAfterSeconds = leaseRetryAfterSeconds(claimed.leaseExpiresAt, at);
|
|
654
|
+
if (retryAfterSeconds > DECISION_RETRY_CAP_SECONDS) {
|
|
655
|
+
return { ok: false, status: 503, reason: "outage" };
|
|
656
|
+
}
|
|
657
|
+
return { ok: false, status: 409, reason: "lease", retryAfterSeconds };
|
|
658
|
+
}
|
|
659
|
+
if (stop) {
|
|
660
|
+
await store.releaseLease?.(parsed.runId, token);
|
|
661
|
+
return stop;
|
|
662
|
+
}
|
|
663
|
+
try {
|
|
664
|
+
const run = await store.get(parsed.runId);
|
|
665
|
+
if (!run) return { ok: false, status: 404 };
|
|
666
|
+
const entry = run.entries.find((item) => item.kind === "step" && item.name === name);
|
|
667
|
+
if (!entry || entry.kind !== "step") return { ok: false, status: 404 };
|
|
668
|
+
const current = entry.value as DecisionReviewRecord;
|
|
669
|
+
const maskedInput =
|
|
670
|
+
current.input !== undefined
|
|
671
|
+
? maskDecisionInput(current.input, decisionExportFields(decl?.inputSchema))
|
|
672
|
+
: undefined;
|
|
673
|
+
for (const [question, value] of Object.entries(input.values)) {
|
|
674
|
+
const label = {
|
|
675
|
+
decision: parsed.name,
|
|
676
|
+
question,
|
|
677
|
+
value,
|
|
678
|
+
propensity: current.propensity,
|
|
679
|
+
reviewer: input.reviewer,
|
|
680
|
+
reviewId: id,
|
|
681
|
+
...(current.locale !== undefined ? { locale: current.locale } : {}),
|
|
682
|
+
...(current.tenant !== null ? { tenant: current.tenant } : {}),
|
|
683
|
+
...(current.model !== undefined ? { model: current.model } : {}),
|
|
684
|
+
...(current.scores?.[question] !== undefined ? { score: current.scores[question] } : {}),
|
|
685
|
+
...(current.raws?.[question] !== undefined ? { raw: current.raws[question] } : {}),
|
|
686
|
+
loss: current.modelValues?.[question] === value ? 0 : 1,
|
|
687
|
+
...(maskedInput !== undefined ? { input: maskedInput } : {}),
|
|
688
|
+
at,
|
|
689
|
+
};
|
|
690
|
+
recordDecisionLabel(label);
|
|
691
|
+
persistDecisionLabel(label, at);
|
|
692
|
+
}
|
|
693
|
+
await flushDecisionLabels();
|
|
694
|
+
return { ok: true };
|
|
695
|
+
} finally {
|
|
696
|
+
await store.releaseLease?.(parsed.runId, token);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function reviewGateAllows(
|
|
701
|
+
gateName: string | undefined,
|
|
702
|
+
input: {
|
|
703
|
+
readonly reviewer: string;
|
|
704
|
+
readonly auth?: GatePolicyContext["auth"];
|
|
705
|
+
readonly tenantId?: string | null;
|
|
706
|
+
},
|
|
707
|
+
): Promise<boolean> {
|
|
708
|
+
if (!gateName) return true;
|
|
709
|
+
const ctx: GatePolicyContext = {
|
|
710
|
+
auth: input.auth ?? { userId: null, scopes: new Set<string>() },
|
|
711
|
+
operator: { id: input.reviewer },
|
|
712
|
+
...(input.tenantId ? { meta: { tenant: input.tenantId } } : {}),
|
|
713
|
+
};
|
|
714
|
+
if (decisionAllow) return decisionAllow([gateName], ctx);
|
|
715
|
+
const { createGateRuntime } = await import("../elements/gate/runtime.ts");
|
|
716
|
+
return createGateRuntime({ gates: gateRegistry }).allow([gateName], ctx);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function valuesMatchQuestions(
|
|
720
|
+
decl: AiDecisionDecl,
|
|
721
|
+
current: DecisionReviewRecord,
|
|
722
|
+
values: Readonly<Record<string, unknown>>,
|
|
723
|
+
): boolean {
|
|
724
|
+
const open = current.labelOnly ? Object.keys(decl.ask) : (current.open ?? Object.keys(decl.ask));
|
|
725
|
+
const keys = Object.keys(values);
|
|
726
|
+
if (keys.length !== open.length) return false;
|
|
727
|
+
for (const key of keys) {
|
|
728
|
+
if (!open.includes(key)) return false;
|
|
729
|
+
const question = decl.ask[key];
|
|
730
|
+
if (!question || !valueMatchesQuestion(question, values[key])) return false;
|
|
731
|
+
}
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function valueMatchesQuestion(question: AiDecisionQuestion, value: unknown): boolean {
|
|
736
|
+
if (question.kind === "boolean") return typeof value === "boolean";
|
|
737
|
+
if (question.kind === "choice") {
|
|
738
|
+
return (
|
|
739
|
+
typeof value === "string" &&
|
|
740
|
+
(value === "none_of_these" || Object.prototype.hasOwnProperty.call(question.options, value))
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
return typeof value === "string" && question.levels.includes(value);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function parseReviewId(
|
|
747
|
+
id: string,
|
|
748
|
+
): { readonly runId: string; readonly ordinal: number; readonly name: string } | undefined {
|
|
749
|
+
try {
|
|
750
|
+
const decoded = Buffer.from(id, "base64url").toString("utf8");
|
|
751
|
+
const first = decoded.indexOf(".");
|
|
752
|
+
const second = first >= 0 ? decoded.indexOf(".", first + 1) : -1;
|
|
753
|
+
if (first <= 0 || second <= first + 1) return undefined;
|
|
754
|
+
const ordinal = Number(decoded.slice(first + 1, second));
|
|
755
|
+
const name = decoded.slice(second + 1);
|
|
756
|
+
if (!Number.isInteger(ordinal) || ordinal < 0 || !name) return undefined;
|
|
757
|
+
return { runId: decoded.slice(0, first), ordinal, name };
|
|
758
|
+
} catch {
|
|
759
|
+
return undefined;
|
|
760
|
+
}
|
|
761
|
+
}
|