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,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse AG-UI agent events from JSON or SSE.
|
|
3
|
+
*
|
|
4
|
+
* Import from `okengine/client/agent`. This stays off the core `okengine/client`
|
|
5
|
+
* graph. Wire `CUSTOM` events stay `CUSTOM`. The three OKE subagent names are
|
|
6
|
+
* also returned as typed variants.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AgUiEvent } from "../elements/ai/events.ts";
|
|
10
|
+
|
|
11
|
+
/** Typed subagent notice parsed from `CUSTOM`. */
|
|
12
|
+
export type ParsedSubagentEvent =
|
|
13
|
+
| {
|
|
14
|
+
readonly type: "subagent.started";
|
|
15
|
+
readonly runId: string;
|
|
16
|
+
readonly parentToolCallId?: string;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
readonly type: "subagent.finished";
|
|
20
|
+
readonly runId: string;
|
|
21
|
+
readonly parentToolCallId?: string;
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
readonly type: "subagent.error";
|
|
25
|
+
readonly runId: string;
|
|
26
|
+
readonly parentToolCallId?: string;
|
|
27
|
+
readonly message?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** One event from {@link parseAgentEvent}. */
|
|
31
|
+
export type ParsedAgentEvent = AgUiEvent | ParsedSubagentEvent;
|
|
32
|
+
|
|
33
|
+
const SUBAGENT_NAMES = {
|
|
34
|
+
"oke.subagent.started": "subagent.started",
|
|
35
|
+
"oke.subagent.finished": "subagent.finished",
|
|
36
|
+
"oke.subagent.error": "subagent.error",
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse one JSON event. Unknown objects return undefined.
|
|
41
|
+
*
|
|
42
|
+
* @param data - Parsed JSON value
|
|
43
|
+
*/
|
|
44
|
+
export function parseAgentEvent(data: unknown): ParsedAgentEvent | undefined {
|
|
45
|
+
if (typeof data !== "object" || data === null || !("type" in data)) return undefined;
|
|
46
|
+
const event = data as AgUiEvent;
|
|
47
|
+
if (event.type !== "CUSTOM") return event;
|
|
48
|
+
const kind = SUBAGENT_NAMES[event.name as keyof typeof SUBAGENT_NAMES];
|
|
49
|
+
if (!kind) return event;
|
|
50
|
+
const value =
|
|
51
|
+
typeof event.value === "object" && event.value !== null
|
|
52
|
+
? (event.value as { runId?: unknown; parentToolCallId?: unknown; message?: unknown })
|
|
53
|
+
: {};
|
|
54
|
+
const runId = typeof value.runId === "string" ? value.runId : "";
|
|
55
|
+
const parentToolCallId =
|
|
56
|
+
typeof value.parentToolCallId === "string" ? value.parentToolCallId : undefined;
|
|
57
|
+
if (kind === "subagent.error") {
|
|
58
|
+
return {
|
|
59
|
+
type: kind,
|
|
60
|
+
runId,
|
|
61
|
+
...(parentToolCallId !== undefined ? { parentToolCallId } : {}),
|
|
62
|
+
...(typeof value.message === "string" ? { message: value.message } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
type: kind,
|
|
67
|
+
runId,
|
|
68
|
+
...(parentToolCallId !== undefined ? { parentToolCallId } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read `data:` frames from an SSE response body.
|
|
74
|
+
*
|
|
75
|
+
* Pass a URL to follow a run: the generator reconnects and sends
|
|
76
|
+
* `Last-Event-ID` from the last frame it yielded.
|
|
77
|
+
*
|
|
78
|
+
* @param source - One response, or the follow URL
|
|
79
|
+
* @param init - Fetch, abort, and a starting event id
|
|
80
|
+
*/
|
|
81
|
+
export async function* readAgentEvents(
|
|
82
|
+
source: Response | string | URL,
|
|
83
|
+
init?: AgentFollowInit,
|
|
84
|
+
): AsyncIterable<ParsedAgentEvent> {
|
|
85
|
+
if (source instanceof Response) {
|
|
86
|
+
yield* readAgentEventResponse(source, init);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const fetcher = init?.fetch ?? fetch;
|
|
90
|
+
let lastEventId = init?.lastEventId;
|
|
91
|
+
let delayMs = 250;
|
|
92
|
+
for (;;) {
|
|
93
|
+
if (init?.signal?.aborted) return;
|
|
94
|
+
const headers = new Headers(init?.headers);
|
|
95
|
+
if (lastEventId) headers.set("last-event-id", lastEventId);
|
|
96
|
+
let response: Response;
|
|
97
|
+
try {
|
|
98
|
+
response = await fetcher(source, { headers, signal: init?.signal });
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (init?.signal?.aborted) return;
|
|
101
|
+
init?.trace?.(err instanceof Error ? err.message : "agent events: network error");
|
|
102
|
+
await wait(delayMs, init?.signal);
|
|
103
|
+
delayMs = Math.min(delayMs * 2, 10_000);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
delayMs = 250;
|
|
107
|
+
if (!response.ok) throw new Error(`agent events: HTTP ${response.status}`);
|
|
108
|
+
let sawFrame = false;
|
|
109
|
+
try {
|
|
110
|
+
for await (const frame of readAgentEventFrames(response, init?.trace)) {
|
|
111
|
+
sawFrame = true;
|
|
112
|
+
if (frame.id) {
|
|
113
|
+
lastEventId = frame.id;
|
|
114
|
+
init?.onId?.(frame.id);
|
|
115
|
+
}
|
|
116
|
+
if (frame.event) yield frame.event;
|
|
117
|
+
if (frame.event?.type === "RUN_ERROR") return;
|
|
118
|
+
if (frame.event?.type === "RUN_FINISHED" && frame.event.outcome?.type !== "interrupt")
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (init?.signal?.aborted) return;
|
|
123
|
+
init?.trace?.(err instanceof Error ? err.message : "agent events: network error");
|
|
124
|
+
await wait(delayMs, init?.signal);
|
|
125
|
+
delayMs = Math.min(delayMs * 2, 10_000);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!sawFrame || init?.signal?.aborted) return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Options for {@link approve} and {@link deny}. */
|
|
133
|
+
export interface AgentDecisionInit {
|
|
134
|
+
readonly fetch?: typeof fetch;
|
|
135
|
+
/** Cap on lease retries. Default 5. */
|
|
136
|
+
readonly attempts?: number;
|
|
137
|
+
readonly headers?: Record<string, string>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Approve a parked tool. Retries `JournalLeaseBusy`. Throws on `Conflict`.
|
|
142
|
+
*
|
|
143
|
+
* @param url - Approve route (`/agent/approvals/approve`)
|
|
144
|
+
* @param id - Approval id
|
|
145
|
+
* @param args - Replacement tool input
|
|
146
|
+
* @param init - Fetch override
|
|
147
|
+
*/
|
|
148
|
+
export async function approve(
|
|
149
|
+
url: string,
|
|
150
|
+
id: string,
|
|
151
|
+
args?: unknown,
|
|
152
|
+
init?: AgentDecisionInit,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
await postDecision(url, { id, ...(args !== undefined ? { args } : {}) }, init);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Deny a parked tool. Retries `JournalLeaseBusy`. Throws on `Conflict`.
|
|
159
|
+
*
|
|
160
|
+
* @param url - Deny route (`/agent/approvals/deny`)
|
|
161
|
+
* @param id - Approval id
|
|
162
|
+
* @param reason - Text the model sees
|
|
163
|
+
* @param init - Fetch override
|
|
164
|
+
*/
|
|
165
|
+
export async function deny(
|
|
166
|
+
url: string,
|
|
167
|
+
id: string,
|
|
168
|
+
reason?: string,
|
|
169
|
+
init?: AgentDecisionInit,
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
await postDecision(url, { id, ...(reason !== undefined ? { reason } : {}) }, init);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Follow options for {@link readAgentEvents}. */
|
|
175
|
+
export interface AgentFollowInit {
|
|
176
|
+
readonly fetch?: typeof fetch;
|
|
177
|
+
readonly headers?: Record<string, string>;
|
|
178
|
+
readonly signal?: AbortSignal;
|
|
179
|
+
readonly lastEventId?: string;
|
|
180
|
+
/** Called with each SSE id as it is read. */
|
|
181
|
+
onId?(id: string): void;
|
|
182
|
+
/** Called when a frame cannot be parsed. The frame is skipped. */
|
|
183
|
+
trace?(message: string): void;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function* readAgentEventResponse(
|
|
187
|
+
response: Response,
|
|
188
|
+
init?: AgentFollowInit,
|
|
189
|
+
): AsyncIterable<ParsedAgentEvent> {
|
|
190
|
+
for await (const frame of readAgentEventFrames(response, init?.trace)) {
|
|
191
|
+
if (frame.id) init?.onId?.(frame.id);
|
|
192
|
+
if (frame.event) yield frame.event;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function* readAgentEventFrames(
|
|
197
|
+
response: Response,
|
|
198
|
+
trace?: (message: string) => void,
|
|
199
|
+
): AsyncIterable<{ id?: string; event?: ParsedAgentEvent }> {
|
|
200
|
+
const body = response.body;
|
|
201
|
+
if (!body) return;
|
|
202
|
+
const reader = body.getReader();
|
|
203
|
+
const decoder = new TextDecoder();
|
|
204
|
+
let buffer = "";
|
|
205
|
+
for (;;) {
|
|
206
|
+
const chunk = await reader.read();
|
|
207
|
+
if (chunk.done) break;
|
|
208
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
209
|
+
const frames = buffer.split("\n\n");
|
|
210
|
+
buffer = frames.pop() ?? "";
|
|
211
|
+
for (const frame of frames) {
|
|
212
|
+
const lines = frame.split("\n");
|
|
213
|
+
const idLine = lines.find((line) => line.startsWith("id:"));
|
|
214
|
+
const data = lines
|
|
215
|
+
.filter((line) => line.startsWith("data:"))
|
|
216
|
+
.map((line) => line.slice(5).trim())
|
|
217
|
+
.join("\n");
|
|
218
|
+
if (!data || data === "[DONE]") continue;
|
|
219
|
+
let parsed: ParsedAgentEvent | undefined;
|
|
220
|
+
try {
|
|
221
|
+
parsed = parseAgentEvent(JSON.parse(data) as unknown);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
trace?.(err instanceof Error ? err.message : "agent events: malformed frame");
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
yield {
|
|
227
|
+
...(idLine ? { id: idLine.slice(3).trim() } : {}),
|
|
228
|
+
...(parsed ? { event: parsed } : {}),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function postDecision(
|
|
235
|
+
url: string,
|
|
236
|
+
body: { id: string; args?: unknown; reason?: string },
|
|
237
|
+
init?: AgentDecisionInit,
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
const fetcher = init?.fetch ?? fetch;
|
|
240
|
+
const attempts = init?.attempts ?? 5;
|
|
241
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
242
|
+
const response = await fetcher(url, {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: { "content-type": "application/json", ...headersOf(init?.headers) },
|
|
245
|
+
body: JSON.stringify(body),
|
|
246
|
+
});
|
|
247
|
+
if (response.ok) return;
|
|
248
|
+
const payload = (await response.json().catch(() => ({}))) as {
|
|
249
|
+
error?: { code?: string };
|
|
250
|
+
};
|
|
251
|
+
const code = payload.error?.code;
|
|
252
|
+
if (response.status === 409 && code === "JournalLeaseBusy") {
|
|
253
|
+
const retryAfter = retryAfterMs(response.headers.get("retry-after"));
|
|
254
|
+
await new Promise((resolve) => setTimeout(resolve, retryAfter));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (response.status === 409 && code === "Conflict") {
|
|
258
|
+
const error = new Error("That value is already in use.");
|
|
259
|
+
error.name = "Conflict";
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
const error = new Error(code ?? `agent decision: HTTP ${response.status}`);
|
|
263
|
+
error.name = code ?? "Error";
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
const error = new Error("This run is locked by another worker. Retry after the given delay.");
|
|
267
|
+
error.name = "JournalLeaseBusy";
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* `Retry-After` as delta-seconds or an HTTP-date.
|
|
273
|
+
*
|
|
274
|
+
* @param header - Header value
|
|
275
|
+
*/
|
|
276
|
+
function retryAfterMs(header: string | null): number {
|
|
277
|
+
if (!header) return 0;
|
|
278
|
+
const seconds = Number(header);
|
|
279
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds) * 1000;
|
|
280
|
+
const at = Date.parse(header);
|
|
281
|
+
if (Number.isNaN(at)) return 0;
|
|
282
|
+
return Math.max(0, at - Date.now());
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function headersOf(headers: Record<string, string> | undefined): Record<string, string> {
|
|
286
|
+
return headers ?? {};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Wait, or return early when `signal` aborts.
|
|
291
|
+
*
|
|
292
|
+
* @param ms - Delay
|
|
293
|
+
* @param signal - Abort
|
|
294
|
+
*/
|
|
295
|
+
function wait(ms: number, signal?: AbortSignal): Promise<void> {
|
|
296
|
+
return new Promise((resolve) => {
|
|
297
|
+
const timer = setTimeout(resolve, ms);
|
|
298
|
+
signal?.addEventListener(
|
|
299
|
+
"abort",
|
|
300
|
+
() => {
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
resolve();
|
|
303
|
+
},
|
|
304
|
+
{ once: true },
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
}
|
package/src/client/create.ts
CHANGED
|
@@ -338,11 +338,28 @@ function isCallOpts(value: unknown): value is CallOpts {
|
|
|
338
338
|
if ("signal" in v) {
|
|
339
339
|
return v.signal === undefined || v.signal instanceof AbortSignal;
|
|
340
340
|
}
|
|
341
|
+
const keyOk =
|
|
342
|
+
"idempotencyKey" in v && (typeof v.idempotencyKey === "string" || v.idempotencyKey === false);
|
|
343
|
+
if (("retry" in v && typeof v.retry === "boolean") || keyOk) {
|
|
344
|
+
return Object.keys(v).every(
|
|
345
|
+
(k) => k === "response" || k === "signal" || k === "retry" || k === "idempotencyKey",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
341
348
|
return false;
|
|
342
349
|
}
|
|
343
350
|
|
|
344
|
-
/** Per-call options on a Flow invoke (binary decode
|
|
351
|
+
/** Per-call options on a Flow invoke (binary decode, abort, retry opt-in). */
|
|
345
352
|
export interface CallOpts {
|
|
346
353
|
readonly response?: "json" | "blob" | "arrayBuffer";
|
|
347
354
|
readonly signal?: AbortSignal;
|
|
355
|
+
/**
|
|
356
|
+
* Repeat this call under the client `retry` policy even when it sends
|
|
357
|
+
* no idempotency key.
|
|
358
|
+
*/
|
|
359
|
+
readonly retry?: boolean;
|
|
360
|
+
/**
|
|
361
|
+
* Stable `Idempotency-Key`, or `false` to send none.
|
|
362
|
+
* See {@link ClientCallOpts.idempotencyKey}.
|
|
363
|
+
*/
|
|
364
|
+
readonly idempotencyKey?: string | false;
|
|
348
365
|
}
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { OKID_ALPHABET } from "../okid-shared.ts";
|
|
6
7
|
import { createClient } from "./create.ts";
|
|
8
|
+
import { IDEMPOTENCY_KEY_ALPHABET } from "./transport.ts";
|
|
7
9
|
import type { AppOf } from "./types.ts";
|
|
8
10
|
|
|
9
11
|
type PingApp = AppOf<{
|
|
@@ -15,11 +17,16 @@ type PingApp = AppOf<{
|
|
|
15
17
|
};
|
|
16
18
|
}>;
|
|
17
19
|
|
|
20
|
+
const getPing = {
|
|
21
|
+
"sys.ping": { method: "GET", path: "/ping" },
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
18
24
|
describe("transport — retry", () => {
|
|
19
25
|
test("retries 5xx then succeeds", async () => {
|
|
20
26
|
let n = 0;
|
|
21
27
|
const api = createClient<PingApp>("http://app.test", {
|
|
22
28
|
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
29
|
+
routes: getPing,
|
|
23
30
|
fetch: async () => {
|
|
24
31
|
n += 1;
|
|
25
32
|
if (n < 3) return new Response("nope", { status: 503 });
|
|
@@ -37,6 +44,7 @@ describe("transport — retry", () => {
|
|
|
37
44
|
let n = 0;
|
|
38
45
|
const api = createClient<PingApp>("http://app.test", {
|
|
39
46
|
retry: { retries: 1, delay: 1, backoff: 1 },
|
|
47
|
+
routes: getPing,
|
|
40
48
|
fetch: async () => {
|
|
41
49
|
n += 1;
|
|
42
50
|
return new Response("nope", { status: 502 });
|
|
@@ -73,6 +81,243 @@ describe("transport — retry", () => {
|
|
|
73
81
|
expect(error?.code).toBe("InternalError");
|
|
74
82
|
expect(error?.message).toMatch(/password policy/i);
|
|
75
83
|
});
|
|
84
|
+
|
|
85
|
+
test("POST retries when it carries an idempotency key", async () => {
|
|
86
|
+
let handled = 0;
|
|
87
|
+
const keys: string[] = [];
|
|
88
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
89
|
+
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
90
|
+
fetch: async (_input, init) => {
|
|
91
|
+
handled += 1;
|
|
92
|
+
keys.push(new Headers(init?.headers).get("idempotency-key") ?? "");
|
|
93
|
+
throw new TypeError("Failed to fetch");
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const lost = await api.sys.ping();
|
|
98
|
+
expect(lost.error?.code).toBe("TransportError");
|
|
99
|
+
expect(handled).toBe(3);
|
|
100
|
+
expect(keys[0]).toBe(keys[1]);
|
|
101
|
+
expect(keys[1]).toBe(keys[2]);
|
|
102
|
+
expect(keys[0]!.length).toBeGreaterThanOrEqual(16);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("idempotencyKey false runs once unless retry is opted in", async () => {
|
|
106
|
+
let handled = 0;
|
|
107
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
108
|
+
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
109
|
+
fetch: async (_input, init) => {
|
|
110
|
+
handled += 1;
|
|
111
|
+
expect(new Headers(init?.headers).get("idempotency-key")).toBeNull();
|
|
112
|
+
throw new TypeError("Failed to fetch");
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const lost = await api.sys.ping({ idempotencyKey: false });
|
|
117
|
+
expect(lost.error?.code).toBe("TransportError");
|
|
118
|
+
expect(handled).toBe(1);
|
|
119
|
+
|
|
120
|
+
handled = 0;
|
|
121
|
+
const opted = await api.sys.ping({ idempotencyKey: false, retry: true });
|
|
122
|
+
expect(opted.error?.code).toBe("TransportError");
|
|
123
|
+
expect(handled).toBe(3);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("two calls mint two keys; a string key is reused", async () => {
|
|
127
|
+
const keys: string[] = [];
|
|
128
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
129
|
+
fetch: async (_input, init) => {
|
|
130
|
+
keys.push(new Headers(init?.headers).get("idempotency-key") ?? "");
|
|
131
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
await api.sys.ping();
|
|
135
|
+
await api.sys.ping();
|
|
136
|
+
expect(keys[0]).not.toBe(keys[1]);
|
|
137
|
+
keys.length = 0;
|
|
138
|
+
await api.sys.ping({ idempotencyKey: "form-key-0123456789" });
|
|
139
|
+
expect(keys).toEqual(["form-key-0123456789"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("inlined idempotency alphabet matches OKID_ALPHABET", () => {
|
|
143
|
+
expect(IDEMPOTENCY_KEY_ALPHABET).toBe(OKID_ALPHABET);
|
|
144
|
+
expect(IDEMPOTENCY_KEY_ALPHABET).toHaveLength(64);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("missing crypto sends no idempotency key", async () => {
|
|
148
|
+
const original = globalThis.crypto;
|
|
149
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
150
|
+
value: undefined,
|
|
151
|
+
configurable: true,
|
|
152
|
+
writable: true,
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
156
|
+
fetch: async (_input, init) => {
|
|
157
|
+
expect(new Headers(init?.headers).get("idempotency-key")).toBeNull();
|
|
158
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const { error } = await api.sys.ping();
|
|
162
|
+
expect(error).toBeNull();
|
|
163
|
+
} finally {
|
|
164
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
165
|
+
value: original,
|
|
166
|
+
configurable: true,
|
|
167
|
+
writable: true,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("GET sends no idempotency key", async () => {
|
|
173
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
174
|
+
routes: getPing,
|
|
175
|
+
fetch: async (_input, init) => {
|
|
176
|
+
expect(new Headers(init?.headers).get("idempotency-key")).toBeNull();
|
|
177
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const { error } = await api.sys.ping();
|
|
181
|
+
expect(error).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("401 resend reuses the idempotency key", async () => {
|
|
185
|
+
const keys: string[] = [];
|
|
186
|
+
let token = "old";
|
|
187
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
188
|
+
auth: {
|
|
189
|
+
getToken: () => token,
|
|
190
|
+
refresh: async () => {
|
|
191
|
+
token = "new";
|
|
192
|
+
return token;
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
fetch: async (_input, init) => {
|
|
196
|
+
const headers = new Headers(init?.headers);
|
|
197
|
+
keys.push(headers.get("idempotency-key") ?? "");
|
|
198
|
+
if (headers.get("authorization") === "Bearer old") {
|
|
199
|
+
return new Response("unauthorized", { status: 401 });
|
|
200
|
+
}
|
|
201
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
const { error } = await api.sys.ping();
|
|
205
|
+
expect(error).toBeNull();
|
|
206
|
+
expect(keys).toHaveLength(2);
|
|
207
|
+
expect(keys[0]).toBe(keys[1]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("IdempotencyInProgress waits for Retry-After then replays", async () => {
|
|
211
|
+
let n = 0;
|
|
212
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
213
|
+
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
214
|
+
timeout: 30,
|
|
215
|
+
fetch: async (_input, init) => {
|
|
216
|
+
n += 1;
|
|
217
|
+
const signal = init?.signal;
|
|
218
|
+
if (n === 1) {
|
|
219
|
+
await new Promise<void>((_resolve, reject) => {
|
|
220
|
+
if (signal?.aborted) {
|
|
221
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
signal?.addEventListener("abort", () => {
|
|
225
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (n === 2) {
|
|
230
|
+
return Response.json(
|
|
231
|
+
{ data: null, error: { code: "IdempotencyInProgress", data: {} } },
|
|
232
|
+
{ status: 409, headers: { "retry-after": "0.01" } },
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return Response.json(
|
|
236
|
+
{ data: { ok: true }, error: null },
|
|
237
|
+
{ headers: { "idempotent-replayed": "true" } },
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const result = await api.sys.ping();
|
|
243
|
+
expect(result.error).toBeNull();
|
|
244
|
+
expect(result.data).toEqual({ ok: true });
|
|
245
|
+
expect(result.meta?.idempotentReplayed).toBe(true);
|
|
246
|
+
expect(n).toBe(3);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("JournalLeaseBusy waits for Retry-After", async () => {
|
|
250
|
+
let n = 0;
|
|
251
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
252
|
+
retry: { retries: 1, delay: 1, backoff: 1 },
|
|
253
|
+
fetch: async () => {
|
|
254
|
+
n += 1;
|
|
255
|
+
if (n === 1) {
|
|
256
|
+
return Response.json(
|
|
257
|
+
{ data: null, error: { code: "JournalLeaseBusy", data: { retryAfter: 1 } } },
|
|
258
|
+
{ status: 409, headers: { "retry-after": "0.01" } },
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
const result = await api.sys.ping();
|
|
265
|
+
expect(result.error).toBeNull();
|
|
266
|
+
expect(n).toBe(2);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("other 409 envelopes are not retried", async () => {
|
|
270
|
+
let n = 0;
|
|
271
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
272
|
+
retry: { retries: 2, delay: 1, backoff: 1 },
|
|
273
|
+
fetch: async () => {
|
|
274
|
+
n += 1;
|
|
275
|
+
return Response.json(
|
|
276
|
+
{ data: null, error: { code: "Conflict", data: {} } },
|
|
277
|
+
{ status: 409 },
|
|
278
|
+
);
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
const { error } = await api.sys.ping();
|
|
282
|
+
expect(error?.code).toBe("Conflict");
|
|
283
|
+
expect(n).toBe(1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("GET still retries a network error", async () => {
|
|
287
|
+
let n = 0;
|
|
288
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
289
|
+
retry: { retries: 1, delay: 1, backoff: 1 },
|
|
290
|
+
routes: getPing,
|
|
291
|
+
fetch: async () => {
|
|
292
|
+
n += 1;
|
|
293
|
+
if (n < 2) throw new TypeError("Failed to fetch");
|
|
294
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const { data, error } = await api.sys.ping();
|
|
299
|
+
expect(error).toBeNull();
|
|
300
|
+
expect(data).toEqual({ ok: true });
|
|
301
|
+
expect(n).toBe(2);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("QUERY still retries a network error", async () => {
|
|
305
|
+
let n = 0;
|
|
306
|
+
const api = createClient<PingApp>("http://app.test", {
|
|
307
|
+
retry: { retries: 1, delay: 1, backoff: 1 },
|
|
308
|
+
routes: { "sys.ping": { method: "QUERY", path: "/ping" } },
|
|
309
|
+
fetch: async () => {
|
|
310
|
+
n += 1;
|
|
311
|
+
if (n < 2) throw new TypeError("Failed to fetch");
|
|
312
|
+
return Response.json({ data: { ok: true }, error: null });
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const { data, error } = await api.sys.ping();
|
|
317
|
+
expect(error).toBeNull();
|
|
318
|
+
expect(data).toEqual({ ok: true });
|
|
319
|
+
expect(n).toBe(2);
|
|
320
|
+
});
|
|
76
321
|
});
|
|
77
322
|
|
|
78
323
|
describe("transport — timeout", () => {
|