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,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP idempotency policy.
|
|
3
|
+
*
|
|
4
|
+
* A flow honors `Idempotency-Key` when it is reached over HTTP as REST
|
|
5
|
+
* non-GET or RPC, its inferred effects mutate or it uses `fx.raw`, and it
|
|
6
|
+
* is not a stream, live feed, or binary response. The IETF draft this header
|
|
7
|
+
* follows expired without becoming an RFC.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { parseDurationMs } from "../elements/clock/duration.ts";
|
|
13
|
+
import type { Effects } from "../manifest/types.ts";
|
|
14
|
+
import { fail } from "./errors.ts";
|
|
15
|
+
import type { EffectKind } from "./effects.ts";
|
|
16
|
+
import type { IdempotencyRow, StoredResponse } from "./idempotency-store.ts";
|
|
17
|
+
|
|
18
|
+
/** Request header. */
|
|
19
|
+
export const IDEMPOTENCY_HEADER = "idempotency-key";
|
|
20
|
+
|
|
21
|
+
/** Set on a replayed response. */
|
|
22
|
+
export const REPLAYED_HEADER = "idempotent-replayed";
|
|
23
|
+
|
|
24
|
+
/** Default TTL stamped when the flow omits one. */
|
|
25
|
+
export const IDEMPOTENCY_DEFAULT_TTL = "24h";
|
|
26
|
+
|
|
27
|
+
/** Author option on `flow({ idempotency })`. */
|
|
28
|
+
export type FlowIdempotencyOption =
|
|
29
|
+
| false
|
|
30
|
+
| "required"
|
|
31
|
+
| { readonly required?: boolean; readonly ttl?: string };
|
|
32
|
+
|
|
33
|
+
/** Manifest / client stamp. */
|
|
34
|
+
export interface ResolvedIdempotency {
|
|
35
|
+
readonly mode: "off" | "auto" | "required";
|
|
36
|
+
readonly ttl: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MUTATING: ReadonlySet<EffectKind> = new Set([
|
|
40
|
+
"write",
|
|
41
|
+
"emit",
|
|
42
|
+
"send",
|
|
43
|
+
"call",
|
|
44
|
+
"fetch",
|
|
45
|
+
"ask",
|
|
46
|
+
"embed",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const KEY_PATTERN = /^[\x20-\x7E]{16,255}$/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when inferred effects include anything besides reads and secrets.
|
|
53
|
+
*
|
|
54
|
+
* @param effects - Declared or inferred effect set
|
|
55
|
+
*/
|
|
56
|
+
export function hasMutatingEffects(effects: Effects | undefined): boolean {
|
|
57
|
+
if (effects === undefined) return false;
|
|
58
|
+
return (
|
|
59
|
+
(effects.writes?.length ?? 0) > 0 ||
|
|
60
|
+
(effects.emits?.length ?? 0) > 0 ||
|
|
61
|
+
(effects.sends?.length ?? 0) > 0 ||
|
|
62
|
+
(effects.calls?.length ?? 0) > 0 ||
|
|
63
|
+
(effects.fetches?.length ?? 0) > 0 ||
|
|
64
|
+
(effects.asks?.length ?? 0) > 0 ||
|
|
65
|
+
(effects.embeds?.length ?? 0) > 0
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True when this HTTP entry should look at `Idempotency-Key`.
|
|
71
|
+
*
|
|
72
|
+
* @param args - Trigger shape and inferred effects
|
|
73
|
+
*/
|
|
74
|
+
export function idempotencyEligible(args: {
|
|
75
|
+
readonly hasRequest: boolean;
|
|
76
|
+
/** HTTP method. RPC is `POST`. */
|
|
77
|
+
readonly method: string | undefined;
|
|
78
|
+
readonly stream: boolean;
|
|
79
|
+
readonly live: boolean;
|
|
80
|
+
readonly effects: Effects | undefined;
|
|
81
|
+
readonly usesRaw: boolean;
|
|
82
|
+
}): boolean {
|
|
83
|
+
if (!args.hasRequest || args.stream || args.live) return false;
|
|
84
|
+
const method = (args.method ?? "POST").toUpperCase();
|
|
85
|
+
if (method === "GET") return false;
|
|
86
|
+
return args.usesRaw || hasMutatingEffects(args.effects);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What this request should do with the header. */
|
|
90
|
+
export interface IdempotencyAttempt {
|
|
91
|
+
readonly claiming: boolean;
|
|
92
|
+
readonly mode: "off" | "auto" | "required";
|
|
93
|
+
readonly ttlMs: number;
|
|
94
|
+
readonly usesRaw: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Decide whether this request claims a key.
|
|
99
|
+
*
|
|
100
|
+
* A stamped `required` flow still ignores the header when this entry is not
|
|
101
|
+
* an eligible HTTP call (signal delivery, GET, stream, live, read-only).
|
|
102
|
+
*
|
|
103
|
+
* @param args - Flow stamp plus this request
|
|
104
|
+
*/
|
|
105
|
+
export function prepareIdempotencyAttempt(args: {
|
|
106
|
+
readonly hasRequest: boolean;
|
|
107
|
+
readonly method: string | undefined;
|
|
108
|
+
readonly stream: boolean;
|
|
109
|
+
readonly live: boolean;
|
|
110
|
+
readonly effects: Effects | undefined;
|
|
111
|
+
readonly usesRaw: boolean;
|
|
112
|
+
readonly option: FlowIdempotencyOption | undefined;
|
|
113
|
+
readonly resolved: ResolvedIdempotency | undefined;
|
|
114
|
+
readonly header: string | null;
|
|
115
|
+
}): IdempotencyAttempt {
|
|
116
|
+
const eligible = idempotencyEligible(args);
|
|
117
|
+
const resolved = args.resolved ?? resolveIdempotencyMode(args.option, eligible);
|
|
118
|
+
const mode = eligible ? resolved.mode : "off";
|
|
119
|
+
const headerPresent = args.header !== null && args.header.trim().length > 0;
|
|
120
|
+
const ttlMs = idempotencyTtlMs(resolved.ttl);
|
|
121
|
+
return {
|
|
122
|
+
claiming: mode === "required" || (mode === "auto" && headerPresent),
|
|
123
|
+
mode,
|
|
124
|
+
ttlMs: ttlMs > 0 ? ttlMs : 24 * 60 * 60 * 1000,
|
|
125
|
+
usesRaw: args.usesRaw,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve `off | auto | required` and the TTL string.
|
|
131
|
+
*
|
|
132
|
+
* Ineligible flows are `off` even when the option says required. Callers
|
|
133
|
+
* that parse source should reject that combination before calling this.
|
|
134
|
+
*
|
|
135
|
+
* @param option - Author option
|
|
136
|
+
* @param eligible - {@link idempotencyEligible}
|
|
137
|
+
*/
|
|
138
|
+
export function resolveIdempotencyMode(
|
|
139
|
+
option: FlowIdempotencyOption | undefined,
|
|
140
|
+
eligible: boolean,
|
|
141
|
+
): ResolvedIdempotency {
|
|
142
|
+
const ttl = ttlOf(option);
|
|
143
|
+
if (!eligible || option === false) return { mode: "off", ttl };
|
|
144
|
+
if (option === "required") return { mode: "required", ttl };
|
|
145
|
+
if (typeof option === "object" && option.required === true) return { mode: "required", ttl };
|
|
146
|
+
return { mode: "auto", ttl };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parse a TTL string. `0` means the string is not a duration.
|
|
151
|
+
*
|
|
152
|
+
* @param ttl - Duration such as `24h`
|
|
153
|
+
*/
|
|
154
|
+
export function idempotencyTtlMs(ttl: string): number {
|
|
155
|
+
return parseDurationMs(ttl);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `user:<id>`, else `apikey:<id>`, else `anon`.
|
|
160
|
+
*
|
|
161
|
+
* @param auth - Principal after the gate
|
|
162
|
+
*/
|
|
163
|
+
export function idempotencyPrincipal(auth: {
|
|
164
|
+
readonly userId: string | null;
|
|
165
|
+
readonly apiKeyId?: string | null;
|
|
166
|
+
}): string {
|
|
167
|
+
if (auth.userId !== null && auth.userId.length > 0) return `user:${auth.userId}`;
|
|
168
|
+
if (auth.apiKeyId !== null && auth.apiKeyId !== undefined && auth.apiKeyId.length > 0) {
|
|
169
|
+
return `apikey:${auth.apiKeyId}`;
|
|
170
|
+
}
|
|
171
|
+
return "anon";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* SHA-256 of canonical JSON `{ flow, input }`. Computed once at claim.
|
|
176
|
+
*
|
|
177
|
+
* @param flow - Flow name
|
|
178
|
+
* @param input - Validated input
|
|
179
|
+
*/
|
|
180
|
+
export function idempotencyFingerprint(flow: string, input: unknown): string {
|
|
181
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
182
|
+
hasher.update(canonicalJson({ flow, input }));
|
|
183
|
+
return hasher.digest("hex");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Classify a raw header value.
|
|
188
|
+
*
|
|
189
|
+
* @param header - Header value, or null when absent
|
|
190
|
+
*/
|
|
191
|
+
export function classifyIdempotencyKey(header: string | null): "missing" | "invalid" | string {
|
|
192
|
+
if (header === null) return "missing";
|
|
193
|
+
const key = header.trim();
|
|
194
|
+
if (!KEY_PATTERN.test(key)) return "invalid";
|
|
195
|
+
return key;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* True when this attempt recorded a non-read effect. Secrets do not count.
|
|
200
|
+
* Failed attempts stay on the ledger.
|
|
201
|
+
*
|
|
202
|
+
* @param entries - This run's ledger
|
|
203
|
+
*/
|
|
204
|
+
export function attemptedMutation(entries: readonly { readonly kind: EffectKind }[]): boolean {
|
|
205
|
+
return entries.some((entry) => MUTATING.has(entry.kind));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Replay a stored response and mark it `Idempotent-Replayed`.
|
|
210
|
+
*
|
|
211
|
+
* @param row - Completed row
|
|
212
|
+
*/
|
|
213
|
+
export function replayResponse(row: IdempotencyRow): Response {
|
|
214
|
+
const headers = new Headers();
|
|
215
|
+
const contentType = row.responseHeaders?.contentType;
|
|
216
|
+
const location = row.responseHeaders?.location;
|
|
217
|
+
if (contentType !== undefined && contentType.length > 0) headers.set("content-type", contentType);
|
|
218
|
+
if (location !== undefined && location.length > 0) headers.set("location", location);
|
|
219
|
+
headers.set(REPLAYED_HEADER, "true");
|
|
220
|
+
const status = row.responseStatus ?? 200;
|
|
221
|
+
if (status === 204) return new Response(null, { status, headers });
|
|
222
|
+
return new Response(row.responseBody ?? "", { status, headers });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Protocol failure. `IdempotencyInProgress` also sets `Retry-After`.
|
|
227
|
+
*
|
|
228
|
+
* @param code - Builtin idempotency code
|
|
229
|
+
* @param retryAfterSeconds - Seconds until the lease expires, for 409
|
|
230
|
+
*/
|
|
231
|
+
export function idempotencyErrorResponse(
|
|
232
|
+
code:
|
|
233
|
+
| "IdempotencyKeyMissing"
|
|
234
|
+
| "IdempotencyKeyInvalid"
|
|
235
|
+
| "IdempotencyKeyReused"
|
|
236
|
+
| "IdempotencyInProgress",
|
|
237
|
+
retryAfterSeconds?: number,
|
|
238
|
+
): Response {
|
|
239
|
+
const failure = fail(
|
|
240
|
+
code,
|
|
241
|
+
code === "IdempotencyInProgress" ? { retryAfter: retryAfterSeconds } : {},
|
|
242
|
+
);
|
|
243
|
+
const headers = new Headers();
|
|
244
|
+
if (code === "IdempotencyInProgress") {
|
|
245
|
+
headers.set("retry-after", String(Math.max(1, retryAfterSeconds ?? 1)));
|
|
246
|
+
}
|
|
247
|
+
const status =
|
|
248
|
+
code === "IdempotencyKeyReused" ? 422 : code === "IdempotencyInProgress" ? 409 : 400;
|
|
249
|
+
return Response.json({ data: null, error: failure.error }, { status, headers });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Buffer a JSON envelope for storage. Streams and non-JSON bodies return
|
|
254
|
+
* undefined so the caller deletes the in-progress row.
|
|
255
|
+
*
|
|
256
|
+
* @param res - Encoded response (not the live stream)
|
|
257
|
+
*/
|
|
258
|
+
export async function storedFromResponse(res: {
|
|
259
|
+
readonly status: number;
|
|
260
|
+
readonly headers: { get(name: string): string | null };
|
|
261
|
+
text(): Promise<string>;
|
|
262
|
+
}): Promise<StoredResponse | undefined> {
|
|
263
|
+
const contentType = res.headers.get("content-type") ?? undefined;
|
|
264
|
+
if (contentType?.includes("text/event-stream")) return undefined;
|
|
265
|
+
if (
|
|
266
|
+
contentType !== undefined &&
|
|
267
|
+
!contentType.includes("json") &&
|
|
268
|
+
!contentType.includes("text/") &&
|
|
269
|
+
res.status !== 204
|
|
270
|
+
) {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
const location = res.headers.get("location") ?? undefined;
|
|
274
|
+
const body = res.status === 204 ? "" : await res.text();
|
|
275
|
+
return {
|
|
276
|
+
status: res.status,
|
|
277
|
+
body,
|
|
278
|
+
...(contentType !== undefined ? { contentType } : {}),
|
|
279
|
+
...(location !== undefined ? { location } : {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Author TTL, or {@link IDEMPOTENCY_DEFAULT_TTL}.
|
|
285
|
+
*
|
|
286
|
+
* @param option - Author option
|
|
287
|
+
*/
|
|
288
|
+
export function ttlOf(option: FlowIdempotencyOption | undefined): string {
|
|
289
|
+
if (typeof option === "object" && option !== null && typeof option.ttl === "string") {
|
|
290
|
+
return option.ttl;
|
|
291
|
+
}
|
|
292
|
+
return IDEMPOTENCY_DEFAULT_TTL;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function canonicalJson(value: unknown): string {
|
|
296
|
+
if (value === undefined) return "null";
|
|
297
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
298
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
299
|
+
const obj = value as Record<string, unknown>;
|
|
300
|
+
const keys = Object.keys(obj)
|
|
301
|
+
.filter((key) => obj[key] !== undefined)
|
|
302
|
+
.sort();
|
|
303
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(obj[key])}`).join(",")}}`;
|
|
304
|
+
}
|
package/src/kernel/index.ts
CHANGED
package/src/kernel/journal.ts
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
import { mkdir } from "node:fs/promises";
|
|
11
11
|
import { dirname } from "node:path";
|
|
12
12
|
import { okid } from "../okid.ts";
|
|
13
|
+
import type { DecisionLabelStore } from "./decision-label-store.ts";
|
|
14
|
+
import type { IdempotencyStore } from "./idempotency-store.ts";
|
|
13
15
|
import { JournalSuspend } from "./journal-suspend.ts";
|
|
16
|
+
import { lazyRequire } from "./lazy-require.ts";
|
|
14
17
|
|
|
15
18
|
export { JournalSuspend, isJournalSuspend } from "./journal-suspend.ts";
|
|
16
19
|
|
|
@@ -126,6 +129,24 @@ export interface JournalLeaseStore {
|
|
|
126
129
|
* @param leaseMs - Lease duration
|
|
127
130
|
*/
|
|
128
131
|
acquireLease(runId: string, instanceId: string, now: number, leaseMs: number): Promise<boolean>;
|
|
132
|
+
/**
|
|
133
|
+
* Take the lease and write `update(run)` in that same hold.
|
|
134
|
+
* `undefined` from `update` keeps the previous entries and still holds the lease.
|
|
135
|
+
* `"lease"` means another holder won. `"missing"` means no run.
|
|
136
|
+
*
|
|
137
|
+
* @param runId - Run id
|
|
138
|
+
* @param instanceId - Claimant instance
|
|
139
|
+
* @param now - Epoch-ms
|
|
140
|
+
* @param leaseMs - Lease duration
|
|
141
|
+
* @param update - Next snapshot, or undefined to leave entries
|
|
142
|
+
*/
|
|
143
|
+
cas(
|
|
144
|
+
runId: string,
|
|
145
|
+
instanceId: string,
|
|
146
|
+
now: number,
|
|
147
|
+
leaseMs: number,
|
|
148
|
+
update: (run: JournalRun) => JournalRun | undefined,
|
|
149
|
+
): Promise<"ok" | "missing" | { readonly lease: true; readonly leaseExpiresAt?: number }>;
|
|
129
150
|
/**
|
|
130
151
|
* Release a lease held by `instanceId` (no-op for other holders).
|
|
131
152
|
*
|
|
@@ -151,8 +172,45 @@ export interface JournalLeaseStore {
|
|
|
151
172
|
listOrphans(now: number): Promise<readonly JournalRun[]>;
|
|
152
173
|
}
|
|
153
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Load the idempotency table implementation without a static import.
|
|
177
|
+
* Computed stem so the edge profile does not inline it.
|
|
178
|
+
*/
|
|
179
|
+
function loadIdempotencyStore(): typeof import("./idempotency-store.ts") {
|
|
180
|
+
return lazyRequire(import.meta.dir, ["idempotency", "store"].join("-"));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Load the decision-label table without a static import.
|
|
185
|
+
*/
|
|
186
|
+
function loadDecisionLabelStore(): typeof import("./decision-label-store.ts") {
|
|
187
|
+
return lazyRequire(import.meta.dir, ["decision", "label", "store"].join("-"));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Load the agent-event table without a static import.
|
|
192
|
+
*/
|
|
193
|
+
function loadAgentEventStore(): typeof import("./agent-event-store.ts") {
|
|
194
|
+
return lazyRequire(import.meta.dir, ["agent", "event", "store"].join("-"));
|
|
195
|
+
}
|
|
196
|
+
|
|
154
197
|
/** Persistence backend for journal runs. */
|
|
155
198
|
export interface JournalStore extends Partial<JournalLeaseStore> {
|
|
199
|
+
/**
|
|
200
|
+
* Idempotency records on this same driver. Absent on a custom store that
|
|
201
|
+
* only implements run `get` / `put`.
|
|
202
|
+
*/
|
|
203
|
+
readonly idempotency?: IdempotencyStore;
|
|
204
|
+
/**
|
|
205
|
+
* Decision labels and the drift flag on this same driver. Opened only when
|
|
206
|
+
* the app declares decisions.
|
|
207
|
+
*/
|
|
208
|
+
readonly decisions?: DecisionLabelStore;
|
|
209
|
+
/**
|
|
210
|
+
* Agent-run event log on this same driver. Followers resume from these rows
|
|
211
|
+
* after a restart and from another instance.
|
|
212
|
+
*/
|
|
213
|
+
readonly agentEvents?: import("./agent-event-store.ts").AgentEventStore;
|
|
156
214
|
/**
|
|
157
215
|
* Load a run by id.
|
|
158
216
|
*
|
|
@@ -219,6 +277,7 @@ function leaseMethods(
|
|
|
219
277
|
load: () => Promise<Map<string, JournalRun>>,
|
|
220
278
|
flush?: (map: Map<string, JournalRun>) => Promise<void>,
|
|
221
279
|
): JournalLeaseStore {
|
|
280
|
+
let gate: Promise<void> = Promise.resolve();
|
|
222
281
|
return {
|
|
223
282
|
async acquireLease(runId, instanceId, now, leaseMs) {
|
|
224
283
|
const map = await load();
|
|
@@ -229,6 +288,31 @@ function leaseMethods(
|
|
|
229
288
|
await flush?.(map);
|
|
230
289
|
return true;
|
|
231
290
|
},
|
|
291
|
+
async cas(runId, instanceId, now, leaseMs, update) {
|
|
292
|
+
let release!: () => void;
|
|
293
|
+
const slot = new Promise<void>((resolve) => {
|
|
294
|
+
release = resolve;
|
|
295
|
+
});
|
|
296
|
+
const prev = gate;
|
|
297
|
+
gate = slot;
|
|
298
|
+
await prev;
|
|
299
|
+
try {
|
|
300
|
+
const map = await load();
|
|
301
|
+
const run = map.get(runId);
|
|
302
|
+
if (!run) return "missing";
|
|
303
|
+
if (!claimable(run, instanceId, now)) {
|
|
304
|
+
return { lease: true, leaseExpiresAt: run.leaseExpiresAt };
|
|
305
|
+
}
|
|
306
|
+
const next = update(cloneRun(run)) ?? run;
|
|
307
|
+
next.lockedBy = instanceId;
|
|
308
|
+
next.leaseExpiresAt = now + leaseMs;
|
|
309
|
+
map.set(runId, next);
|
|
310
|
+
await flush?.(map);
|
|
311
|
+
return "ok";
|
|
312
|
+
} finally {
|
|
313
|
+
release();
|
|
314
|
+
}
|
|
315
|
+
},
|
|
232
316
|
async releaseLease(runId, instanceId) {
|
|
233
317
|
const map = await load();
|
|
234
318
|
const run = map.get(runId);
|
|
@@ -286,6 +370,21 @@ export function createMemoryJournalStore(seed?: readonly JournalRun[]): JournalS
|
|
|
286
370
|
return [...runs.values()].map(cloneRun);
|
|
287
371
|
},
|
|
288
372
|
...leaseMethods(load),
|
|
373
|
+
get idempotency(): IdempotencyStore {
|
|
374
|
+
const created = loadIdempotencyStore().createMemoryIdempotencyStore();
|
|
375
|
+
Object.defineProperty(this, "idempotency", { value: created });
|
|
376
|
+
return created;
|
|
377
|
+
},
|
|
378
|
+
get decisions(): DecisionLabelStore {
|
|
379
|
+
const created = loadDecisionLabelStore().createMemoryDecisionLabelStore();
|
|
380
|
+
Object.defineProperty(this, "decisions", { value: created });
|
|
381
|
+
return created;
|
|
382
|
+
},
|
|
383
|
+
get agentEvents(): import("./agent-event-store.ts").AgentEventStore {
|
|
384
|
+
const created = loadAgentEventStore().createMemoryAgentEventStore();
|
|
385
|
+
Object.defineProperty(this, "agentEvents", { value: created });
|
|
386
|
+
return created;
|
|
387
|
+
},
|
|
289
388
|
};
|
|
290
389
|
}
|
|
291
390
|
|
|
@@ -332,6 +431,27 @@ export function createFileJournalStore(path: string): JournalStore {
|
|
|
332
431
|
},
|
|
333
432
|
// Single-host file: leases coordinate same-machine processes only.
|
|
334
433
|
...leaseMethods(load, flush),
|
|
434
|
+
get idempotency(): IdempotencyStore {
|
|
435
|
+
const created = loadIdempotencyStore().createFileIdempotencyStore(
|
|
436
|
+
`${dirname(path)}/idempotency.json`,
|
|
437
|
+
);
|
|
438
|
+
Object.defineProperty(this, "idempotency", { value: created });
|
|
439
|
+
return created;
|
|
440
|
+
},
|
|
441
|
+
get decisions(): DecisionLabelStore {
|
|
442
|
+
const created = loadDecisionLabelStore().createFileDecisionLabelStore(
|
|
443
|
+
`${dirname(path)}/decision-labels.json`,
|
|
444
|
+
);
|
|
445
|
+
Object.defineProperty(this, "decisions", { value: created });
|
|
446
|
+
return created;
|
|
447
|
+
},
|
|
448
|
+
get agentEvents(): import("./agent-event-store.ts").AgentEventStore {
|
|
449
|
+
const created = loadAgentEventStore().createFileAgentEventStore(
|
|
450
|
+
`${dirname(path)}/agent-events`,
|
|
451
|
+
);
|
|
452
|
+
Object.defineProperty(this, "agentEvents", { value: created });
|
|
453
|
+
return created;
|
|
454
|
+
},
|
|
335
455
|
};
|
|
336
456
|
}
|
|
337
457
|
|
|
@@ -405,6 +525,11 @@ export interface JournalSession {
|
|
|
405
525
|
* instead of treating the cursor as past them. Clears the in-memory undo stack
|
|
406
526
|
* so frames re-bind on the next walk.
|
|
407
527
|
*/
|
|
528
|
+
/**
|
|
529
|
+
* Increments on {@link JournalSession.rewind}. Agent run ids key off this
|
|
530
|
+
* so `flow.retry` replays the same id.
|
|
531
|
+
*/
|
|
532
|
+
readonly epoch: number;
|
|
408
533
|
rewind(): void;
|
|
409
534
|
/** Registered per-step undos in persist/replay order (LIFO compensate). */
|
|
410
535
|
undoStack(): readonly JournalUndoFrame[];
|
|
@@ -428,6 +553,36 @@ export interface JournalSession {
|
|
|
428
553
|
): Promise<void>;
|
|
429
554
|
}
|
|
430
555
|
|
|
556
|
+
/**
|
|
557
|
+
* A journal session that forwards to a real session once one is assigned.
|
|
558
|
+
*
|
|
559
|
+
* Idempotent durable flows start the journal only after the claim, which is
|
|
560
|
+
* after `fx` has already been created.
|
|
561
|
+
*/
|
|
562
|
+
export function createJournalSlot(): {
|
|
563
|
+
readonly slot: { session?: JournalSession };
|
|
564
|
+
readonly facade: JournalSession;
|
|
565
|
+
} {
|
|
566
|
+
const slot: { session?: JournalSession } = {};
|
|
567
|
+
const facade = new Proxy({} as JournalSession, {
|
|
568
|
+
get(_target, prop, receiver) {
|
|
569
|
+
const session = slot.session;
|
|
570
|
+
if (!session) {
|
|
571
|
+
if (prop === "runId") return "";
|
|
572
|
+
if (prop === "epoch") return 0;
|
|
573
|
+
if (prop === "stampTenant") return async () => undefined;
|
|
574
|
+
if (prop === "rewind") return () => undefined;
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
const value = Reflect.get(session, prop, receiver);
|
|
578
|
+
return typeof value === "function"
|
|
579
|
+
? (value as (...args: unknown[]) => unknown).bind(session)
|
|
580
|
+
: value;
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
return { slot, facade };
|
|
584
|
+
}
|
|
585
|
+
|
|
431
586
|
/** Journal facade. */
|
|
432
587
|
export interface Journal {
|
|
433
588
|
readonly store: JournalStore;
|
|
@@ -460,6 +615,7 @@ export function createJournal(options: CreateJournalOptions): Journal {
|
|
|
460
615
|
function openSession(run: JournalRun, leased: boolean): JournalSession {
|
|
461
616
|
/** Next entry index to consume on replay. */
|
|
462
617
|
let cursor = 0;
|
|
618
|
+
let epoch = 0;
|
|
463
619
|
let leaseHeld = leased;
|
|
464
620
|
let registrationPass = false;
|
|
465
621
|
let undoExecution = false;
|
|
@@ -609,9 +765,13 @@ export function createJournal(options: CreateJournalOptions): Journal {
|
|
|
609
765
|
await persist();
|
|
610
766
|
return value;
|
|
611
767
|
},
|
|
768
|
+
get epoch() {
|
|
769
|
+
return epoch;
|
|
770
|
+
},
|
|
612
771
|
rewind() {
|
|
613
772
|
cursor = 0;
|
|
614
773
|
undos.length = 0;
|
|
774
|
+
epoch += 1;
|
|
615
775
|
},
|
|
616
776
|
undoStack() {
|
|
617
777
|
return undos;
|
|
@@ -28,6 +28,16 @@ export interface JsonStreamResult {
|
|
|
28
28
|
ready?: () => Promise<void>;
|
|
29
29
|
/** Set by the kernel to commit journal / Runs after the stream settles. */
|
|
30
30
|
finalize?: () => Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Agent result once a streamed `fx.run` settles.
|
|
33
|
+
* Absent for text and live streams.
|
|
34
|
+
*/
|
|
35
|
+
result?: Promise<unknown>;
|
|
36
|
+
/**
|
|
37
|
+
* Set when iteration parked a durable run, after `[DONE]` is queued.
|
|
38
|
+
* Finalize commits the journal as sleeping from this.
|
|
39
|
+
*/
|
|
40
|
+
parked?: { wakeAt: number; label: string };
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
/**
|
|
@@ -14,6 +14,7 @@ import { createClockRuntime } from "../elements/clock.ts";
|
|
|
14
14
|
import { gate } from "../elements/gate.ts";
|
|
15
15
|
import { createRunsRuntime, memoryRunsDriver } from "../runs/index.ts";
|
|
16
16
|
import { oke } from "./app.ts";
|
|
17
|
+
import { HTTP_FRAME_REDACTED } from "./http-frame.ts";
|
|
17
18
|
import { flow, resetFlowSeq } from "./flow.ts";
|
|
18
19
|
import { on, resetBindings } from "./on.ts";
|
|
19
20
|
import { http } from "./triggers.ts";
|
|
@@ -196,6 +197,50 @@ describe("pipeline — Unauthorized for anonymous", () => {
|
|
|
196
197
|
});
|
|
197
198
|
});
|
|
198
199
|
|
|
200
|
+
describe("pipeline — HTTP wire frame", () => {
|
|
201
|
+
test("fetch stores query, headers, and status beside the body", async () => {
|
|
202
|
+
resetBindings();
|
|
203
|
+
resetFlowSeq();
|
|
204
|
+
|
|
205
|
+
on(
|
|
206
|
+
http.get("/tasks", { out: z.object({ ok: z.literal(true) }) }).public(),
|
|
207
|
+
flow("tasks.list", {
|
|
208
|
+
do: () => ({ ok: true as const }),
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const runs = createRunsRuntime({ driver: memoryRunsDriver });
|
|
213
|
+
await runs.open();
|
|
214
|
+
const app = oke({
|
|
215
|
+
name: "http-frame",
|
|
216
|
+
gate: { policies: [gate.public] },
|
|
217
|
+
runs,
|
|
218
|
+
env: "test",
|
|
219
|
+
});
|
|
220
|
+
await app.boot({ env: "test", gates: [gate.public], runs });
|
|
221
|
+
|
|
222
|
+
const res = await app.fetch(
|
|
223
|
+
new Request("http://localhost/tasks?limit=20", {
|
|
224
|
+
headers: { accept: "application/json", cookie: "sid=secret" },
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
expect(res.status).toBe(200);
|
|
228
|
+
|
|
229
|
+
const event = (await runs.all()).find((row) => row.flow === "tasks.list");
|
|
230
|
+
expect(event?.input).toBeUndefined();
|
|
231
|
+
expect(event?.http).toMatchObject({
|
|
232
|
+
request: {
|
|
233
|
+
method: "GET",
|
|
234
|
+
path: "/tasks",
|
|
235
|
+
query: { limit: "20" },
|
|
236
|
+
headers: { cookie: HTTP_FRAME_REDACTED, accept: "application/json" },
|
|
237
|
+
},
|
|
238
|
+
response: { status: 200 },
|
|
239
|
+
});
|
|
240
|
+
await app.bootResult?.close();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
199
244
|
describe("pipeline — evaluated gates on the run", () => {
|
|
200
245
|
test("recorded run contains evaluated chain, not just declared names", async () => {
|
|
201
246
|
resetBindings();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE id carried on a live agent event without changing its JSON payload.
|
|
3
|
+
*
|
|
4
|
+
* The follow log stores a seq. The live `fx.run` frame for that row uses the
|
|
5
|
+
* same seq as `id:` so a client can resume with `Last-Event-ID`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const sseIdBrand: unique symbol = Symbol.for("oke.sse.id");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Copy `value` and attach an SSE id the encoder writes as `id:`.
|
|
12
|
+
* The id is not enumerable, so it is not part of the JSON payload.
|
|
13
|
+
*
|
|
14
|
+
* @param value - Event object
|
|
15
|
+
* @param id - SSE id (the stored log seq)
|
|
16
|
+
*/
|
|
17
|
+
export function withSseId<T extends object>(value: T, id: string): T {
|
|
18
|
+
const copy: T = { ...value };
|
|
19
|
+
Object.defineProperty(copy, sseIdBrand, { value: id, enumerable: false });
|
|
20
|
+
return copy;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* SSE id attached by {@link withSseId}, if any.
|
|
25
|
+
*
|
|
26
|
+
* @param value - Stream chunk
|
|
27
|
+
*/
|
|
28
|
+
export function readSseId(value: unknown): string | undefined {
|
|
29
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
30
|
+
const id = (value as { readonly [sseIdBrand]?: unknown })[sseIdBrand];
|
|
31
|
+
return typeof id === "string" ? id : undefined;
|
|
32
|
+
}
|