@pikku/core 0.12.78 → 0.12.80
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/CHANGELOG.md +234 -0
- package/dist/dev/hot-reload.js +1 -6
- package/dist/ecosystem.d.ts +27 -0
- package/dist/ecosystem.js +26 -0
- package/dist/errors/error-handler.js +4 -2
- package/dist/function/function-runner.js +20 -42
- package/dist/function/functions.types.d.ts +9 -5
- package/dist/index.d.ts +4 -9
- package/dist/index.js +3 -8
- package/dist/middleware/auth-cookie.d.ts +0 -4
- package/dist/middleware/auth-cookie.js +38 -2
- package/dist/middleware/cors.js +1 -0
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/remote-auth.js +14 -3
- package/dist/permissions.d.ts +8 -10
- package/dist/permissions.js +0 -11
- package/dist/pikku-state.js +12 -5
- package/dist/schema.js +35 -1
- package/dist/services/ai-run-state-service.d.ts +7 -1
- package/dist/services/in-memory-ai-run-state-service.d.ts +1 -1
- package/dist/services/in-memory-ai-run-state-service.js +2 -1
- package/dist/services/in-memory-workflow-service.d.ts +10 -0
- package/dist/services/in-memory-workflow-service.js +25 -0
- package/dist/services/local-content-request-handler.d.ts +21 -0
- package/dist/services/local-content-request-handler.js +72 -53
- package/dist/services/local-content.d.ts +6 -0
- package/dist/services/local-content.js +14 -1
- package/dist/services/workflow-service.d.ts +4 -2
- package/dist/testing/service-tests/agent-run-service-tests.d.ts +10 -0
- package/dist/testing/service-tests/agent-run-service-tests.js +72 -0
- package/dist/testing/service-tests/ai-storage-service-tests.d.ts +3 -0
- package/dist/testing/service-tests/ai-storage-service-tests.js +226 -0
- package/dist/testing/service-tests/channel-store-tests.d.ts +3 -0
- package/dist/testing/service-tests/channel-store-tests.js +72 -0
- package/dist/testing/service-tests/credential-service-tests.d.ts +3 -0
- package/dist/testing/service-tests/credential-service-tests.js +109 -0
- package/dist/testing/service-tests/deployment-service-tests.d.ts +3 -0
- package/dist/testing/service-tests/deployment-service-tests.js +21 -0
- package/dist/testing/service-tests/event-hub-store-tests.d.ts +3 -0
- package/dist/testing/service-tests/event-hub-store-tests.js +34 -0
- package/dist/testing/service-tests/secret-service-tests.d.ts +3 -0
- package/dist/testing/service-tests/secret-service-tests.js +80 -0
- package/dist/testing/service-tests/session-store-tests.d.ts +3 -0
- package/dist/testing/service-tests/session-store-tests.js +43 -0
- package/dist/testing/service-tests/workflow-run-service-tests.d.ts +3 -0
- package/dist/testing/service-tests/workflow-run-service-tests.js +42 -0
- package/dist/testing/service-tests/workflow-service-tests.d.ts +3 -0
- package/dist/testing/service-tests/workflow-service-tests.js +150 -0
- package/dist/testing/service-tests.d.ts +6 -0
- package/dist/testing/service-tests.js +26 -791
- package/dist/types/core.types.d.ts +11 -1
- package/dist/types/state.types.d.ts +1 -1
- package/dist/utils/node-host-resolver.d.ts +12 -0
- package/dist/utils/node-host-resolver.js +16 -0
- package/dist/utils/safe-fetch.d.ts +18 -0
- package/dist/utils/safe-fetch.js +167 -29
- package/dist/wirings/actor-flow/run-conversation.js +1 -6
- package/dist/wirings/ai-agent/agent-rpc.d.ts +15 -0
- package/dist/wirings/ai-agent/agent-rpc.js +53 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +1 -5
- package/dist/wirings/ai-agent/ai-agent-memory.js +3 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +3 -9
- package/dist/wirings/ai-agent/ai-agent-runner.js +28 -107
- package/dist/wirings/ai-agent/ai-agent-stream.js +20 -61
- package/dist/wirings/ai-agent/ai-agent-turn.d.ts +56 -0
- package/dist/wirings/ai-agent/ai-agent-turn.js +81 -0
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +1 -1
- package/dist/wirings/ai-agent/voice-input.js +1 -6
- package/dist/wirings/ai-agent/voice-output.js +2 -12
- package/dist/wirings/channel/channel-common.js +1 -0
- package/dist/wirings/channel/channel-handler.js +3 -5
- package/dist/wirings/channel/channel-rpc-service.d.ts +0 -6
- package/dist/wirings/channel/channel-rpc-service.js +0 -8
- package/dist/wirings/channel/channel-rpc.types.d.ts +6 -0
- package/dist/wirings/channel/channel-rpc.types.js +8 -0
- package/dist/wirings/channel/channel-runner.d.ts +1 -3
- package/dist/wirings/channel/channel-runner.js +16 -8
- package/dist/wirings/channel/channel.types.d.ts +2 -0
- package/dist/wirings/channel/pikku-abstract-channel-handler.js +1 -0
- package/dist/wirings/channel/serverless/serverless-channel-runner.js +3 -0
- package/dist/wirings/cli/channel/cli-channel-runner.js +2 -0
- package/dist/wirings/cli/cli-runner.js +4 -2
- package/dist/wirings/cli/cli.types.d.ts +0 -8
- package/dist/wirings/cli/command-parser.js +13 -0
- package/dist/wirings/gateway/gateway-runner.js +41 -5
- package/dist/wirings/http/http-routes.js +2 -0
- package/dist/wirings/http/http-runner.d.ts +0 -10
- package/dist/wirings/http/http-runner.js +6 -13
- package/dist/wirings/http/http.types.d.ts +0 -10
- package/dist/wirings/http/index.d.ts +1 -1
- package/dist/wirings/http/index.js +1 -1
- package/dist/wirings/mcp/mcp-runner.d.ts +0 -7
- package/dist/wirings/mcp/mcp-runner.js +0 -6
- package/dist/wirings/rpc/rpc-runner.d.ts +8 -51
- package/dist/wirings/rpc/rpc-runner.js +53 -86
- package/dist/wirings/rpc/rpc-types.d.ts +3 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/trigger/pikku-trigger-service.d.ts +0 -4
- package/dist/wirings/trigger/trigger-runner.js +1 -0
- package/dist/wirings/virtual-user/run-virtual-user.js +11 -11
- package/dist/wirings/virtual-user/virtual-user-derive.js +4 -25
- package/dist/wirings/virtual-user/virtual-user-dispositions.js +1 -4
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +29 -2
- package/dist/wirings/workflow/feature.js +7 -4
- package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -2
- package/dist/wirings/workflow/graph/graph-runner.js +8 -7
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +0 -4
- package/dist/wirings/workflow/index.d.ts +7 -2
- package/dist/wirings/workflow/index.js +5 -1
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +17 -3
- package/dist/wirings/workflow/pikku-scenario-service.js +38 -10
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +44 -111
- package/dist/wirings/workflow/pikku-workflow-service.js +86 -404
- package/dist/wirings/workflow/workflow-approval.d.ts +39 -0
- package/dist/wirings/workflow/workflow-approval.js +114 -0
- package/dist/wirings/workflow/workflow-constants.d.ts +26 -0
- package/dist/wirings/workflow/workflow-constants.js +35 -0
- package/dist/wirings/workflow/workflow-errors.d.ts +58 -0
- package/dist/wirings/workflow/workflow-errors.js +112 -0
- package/dist/wirings/workflow/workflow-meta-resolver.d.ts +11 -0
- package/dist/wirings/workflow/workflow-meta-resolver.js +31 -0
- package/dist/wirings/workflow/workflow-queue-routing.d.ts +8 -0
- package/dist/wirings/workflow/workflow-queue-routing.js +38 -0
- package/dist/wirings/workflow/workflow-queue-wiring.d.ts +20 -0
- package/dist/wirings/workflow/workflow-queue-wiring.js +79 -0
- package/dist/wirings/workflow/workflow-recovery.d.ts +68 -0
- package/dist/wirings/workflow/workflow-recovery.js +101 -0
- package/dist/wirings/workflow/workflow-run-engine.types.d.ts +54 -0
- package/dist/wirings/workflow/workflow-run-engine.types.js +1 -0
- package/dist/wirings/workflow/workflow-run-ownership.d.ts +16 -0
- package/dist/wirings/workflow/workflow-run-ownership.js +29 -0
- package/dist/wirings/workflow/workflow-suspend.d.ts +12 -0
- package/dist/wirings/workflow/workflow-suspend.js +33 -0
- package/dist/wirings/workflow/workflow.types.d.ts +7 -0
- package/knowledge/decisions/internals/a-non-streaming-agent-run-registers-with-airunstate-too.md +22 -0
- package/knowledge/decisions/internals/a-resumed-agent-turn-is-as-interruptible-as-the-first.md +20 -0
- package/knowledge/decisions/internals/a-scenario-step-template-is-offered-unfilled.md +21 -0
- package/knowledge/decisions/internals/a-virtual-user-decides-whether-to-trust-memory-once-per-turn.md +21 -0
- package/knowledge/decisions/internals/a-wall-clock-threshold-is-a-load-test-in-disguise.md +46 -0
- package/knowledge/decisions/internals/a-workflow-wire-is-built-from-the-run-not-from-the-rpc-service.md +45 -0
- package/knowledge/decisions/internals/agent-context-waits-for-a-tool-result-still-being-written.md +21 -0
- package/knowledge/decisions/internals/agent-speech-travels-as-a-custom-agui-event.md +22 -0
- package/knowledge/decisions/internals/an-agent-interrupt-is-not-a-failure.md +23 -0
- package/knowledge/decisions/internals/an-agent-run-owned-by-another-instance-says-so.md +25 -0
- package/knowledge/decisions/internals/an-agent-stream-send-must-return-the-inner-sends-promise.md +22 -0
- package/knowledge/decisions/internals/an-empty-text-part-is-omitted-from-an-agent-message.md +20 -0
- package/knowledge/decisions/internals/an-empty-transcript-is-not-recorded.md +22 -0
- package/knowledge/decisions/internals/an-unref-d-timer-cannot-be-awaited-under-node-test.md +66 -0
- package/knowledge/decisions/internals/channel-state-accessors-are-unsound-generics-that-every-implementation-asserts.md +33 -0
- package/knowledge/decisions/internals/gateway-listener-middleware-runs-without-an-rpc-on-the-wire.md +43 -0
- package/knowledge/decisions/internals/hot-reload-writes-into-the-function-map-captured-at-startup.md +26 -0
- package/knowledge/decisions/internals/index.md +38 -26
- package/knowledge/decisions/internals/only-exposed-functions-enter-a-virtual-user-catalogue.md +21 -0
- package/knowledge/decisions/internals/scenario-given-and-when-are-sugar-but-then-is-not.md +24 -0
- package/knowledge/decisions/internals/side-effects-are-an-allowlist-not-a-boolean.md +34 -0
- package/knowledge/decisions/internals/speech-synthesis-picks-a-voice-per-sentence-and-warns-once.md +24 -0
- package/knowledge/decisions/internals/the-actor-prompt-says-json-because-of-json-object-mode.md +22 -0
- package/knowledge/decisions/internals/the-agent-done-event-goes-through-the-middleware-and-is-awaited.md +26 -0
- package/knowledge/decisions/internals/the-api-report-pins-members-not-just-names.md +43 -0
- package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +58 -0
- package/knowledge/decisions/internals/the-middleware-resolution-cache-is-deliberately-unbounded.md +40 -0
- package/knowledge/decisions/internals/the-per-invocation-rpc-view-is-a-class.md +40 -0
- package/knowledge/decisions/internals/the-persona-runtime-is-exported-from-the-persona-entry-point.md +28 -0
- package/knowledge/decisions/internals/the-transcript-event-is-sent-ahead-of-the-run.md +25 -0
- package/knowledge/decisions/internals/the-virtual-user-catalogue-is-the-only-gate-on-what-may-be-called.md +21 -0
- package/knowledge/decisions/internals/the-worker-disposition-is-the-one-that-is-not-testing.md +22 -0
- package/knowledge/decisions/internals/thread-history-records-the-transcript-not-the-audio.md +25 -0
- package/knowledge/decisions/internals/virtual-user-step-order-comes-from-insertion-order.md +21 -0
- package/knowledge/decisions/internals/voice-output-speaks-unless-voice-input-explicitly-says-otherwise.md +24 -0
- package/knowledge/decisions/internals/wiring-registries-erase-the-generics-their-wire-functions-capture.md +35 -0
- package/knowledge/decisions/security/a-dropped-audit-write-is-always-logged.md +4 -2
- package/knowledge/decisions/security/a-graph-run-starts-at-an-entry-node-the-graph-declared.md +33 -0
- package/knowledge/decisions/security/a-permission-gets-a-wire-it-cannot-reply-on.md +33 -0
- package/knowledge/decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md +37 -0
- package/knowledge/decisions/security/a-virtual-user-is-never-offered-a-step-that-would-forge-its-own-oracle.md +28 -0
- package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +34 -0
- package/knowledge/decisions/security/an-agent-approval-is-claimed-before-the-tool-runs.md +33 -0
- package/knowledge/decisions/security/an-upload-is-counted-as-it-arrives-not-buffered-then-measured.md +24 -0
- package/knowledge/decisions/security/gateway-handlers-run-through-the-function-runner-gate.md +13 -3
- package/knowledge/decisions/security/index.md +7 -0
- package/knowledge/questions/channel-middleware-accepts-bare-factories-that-nothing-resolves.md +35 -0
- package/knowledge/questions/index.md +2 -1
- package/knowledge/questions/unauthorized-channel-replies-escape-the-declared-out-type.md +44 -0
- package/package.json +14 -3
- package/scripts/generate-api-report.d.mts +1 -0
- package/scripts/generate-api-report.mjs +89 -0
- package/scripts/generate-api-report.mts +218 -0
- package/src/api-report.test.ts +69 -0
- package/src/column-form.ts +1 -4
- package/src/crypto-utils.test.ts +15 -2
- package/src/dev/hot-reload.ts +2 -7
- package/src/ecosystem.ts +40 -0
- package/src/errors/error-handler.ts +5 -3
- package/src/errors/error.test.ts +4 -1
- package/src/function/function-runner.test.ts +1 -1
- package/src/function/function-runner.ts +28 -39
- package/src/function/functions.types.ts +15 -9
- package/src/handle-error.ts +1 -1
- package/src/index.ts +3 -22
- package/src/middleware/auth-cookie.test.ts +50 -0
- package/src/middleware/auth-cookie.ts +38 -2
- package/src/middleware/cors.ts +2 -1
- package/src/middleware/index.ts +1 -1
- package/src/middleware/remote-auth.test.ts +43 -0
- package/src/middleware/remote-auth.ts +17 -3
- package/src/middleware-runner.ts +4 -2
- package/src/no-any-casts.test.ts +57 -0
- package/src/permissions.test.ts +3 -1
- package/src/permissions.ts +22 -24
- package/src/pikku-state.ts +17 -6
- package/src/public-surface.json +578 -0
- package/src/public-surface.json.README +25 -0
- package/src/public-surface.test.ts +105 -0
- package/src/removed-legacy-exports.test.ts +62 -0
- package/src/schema.test.ts +78 -0
- package/src/schema.ts +36 -1
- package/src/services/ai-run-state-service.ts +7 -1
- package/src/services/audit-service.ts +2 -2
- package/src/services/in-memory-ai-run-state-service.ts +3 -2
- package/src/services/in-memory-workflow-service.ts +25 -0
- package/src/services/index.ts +1 -4
- package/src/services/local-content-request-handler.test.ts +43 -5
- package/src/services/local-content-request-handler.ts +103 -74
- package/src/services/local-content.ts +15 -1
- package/src/services/local-email-service.ts +5 -1
- package/src/services/system-role-guard.test.ts +4 -1
- package/src/services/workflow-service.ts +9 -2
- package/src/side-effects-are-declared.test.ts +84 -0
- package/src/source-files-stay-composable.test.ts +41 -0
- package/src/testing/service-tests/agent-run-service-tests.ts +98 -0
- package/src/testing/service-tests/ai-storage-service-tests.ts +286 -0
- package/src/testing/service-tests/channel-store-tests.ts +98 -0
- package/src/testing/service-tests/credential-service-tests.ts +143 -0
- package/src/testing/service-tests/deployment-service-tests.ts +33 -0
- package/src/testing/service-tests/event-hub-store-tests.ts +48 -0
- package/src/testing/service-tests/secret-service-tests.ts +105 -0
- package/src/testing/service-tests/session-store-tests.ts +62 -0
- package/src/testing/service-tests/workflow-run-service-tests.ts +59 -0
- package/src/testing/service-tests/workflow-service-tests.ts +308 -0
- package/src/testing/service-tests.ts +31 -1111
- package/src/types/core.types.ts +15 -1
- package/src/types/state.types.ts +1 -1
- package/src/utils/node-host-resolver.ts +20 -0
- package/src/utils/safe-fetch.test.ts +143 -1
- package/src/utils/safe-fetch.ts +200 -25
- package/src/wirings/actor-flow/run-conversation.ts +1 -6
- package/src/wirings/ai-agent/agent-rpc.ts +120 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +1 -5
- package/src/wirings/ai-agent/ai-agent-interrupt.test.ts +2 -1
- package/src/wirings/ai-agent/ai-agent-memory.ts +8 -1
- package/src/wirings/ai-agent/ai-agent-prepare.ts +10 -12
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +1 -0
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +82 -2
- package/src/wirings/ai-agent/ai-agent-runner.ts +49 -120
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +2 -0
- package/src/wirings/ai-agent/ai-agent-stream.ts +36 -63
- package/src/wirings/ai-agent/ai-agent-thread-ownership.test.ts +1 -1
- package/src/wirings/ai-agent/ai-agent-turn.ts +121 -0
- package/src/wirings/ai-agent/ai-agent.types.ts +1 -1
- package/src/wirings/ai-agent/voice-input.ts +1 -6
- package/src/wirings/ai-agent/voice-output.ts +2 -12
- package/src/wirings/channel/channel-common.ts +5 -2
- package/src/wirings/channel/channel-handler-shapes.test.ts +72 -0
- package/src/wirings/channel/channel-handler.ts +7 -7
- package/src/wirings/channel/channel-rpc-service.ts +0 -14
- package/src/wirings/channel/channel-rpc.test.ts +25 -4
- package/src/wirings/channel/channel-rpc.types.ts +14 -0
- package/src/wirings/channel/channel-runner.ts +33 -20
- package/src/wirings/channel/channel.types.ts +2 -0
- package/src/wirings/channel/local/local-channel-runner.ts +1 -1
- package/src/wirings/channel/pikku-abstract-channel-handler.ts +2 -1
- package/src/wirings/channel/serverless/serverless-channel-runner.ts +6 -3
- package/src/wirings/cli/channel/cli-channel-runner.ts +3 -1
- package/src/wirings/cli/channel/cli-raw-client-runner.ts +4 -1
- package/src/wirings/cli/cli-runner.ts +7 -4
- package/src/wirings/cli/cli.types.ts +0 -39
- package/src/wirings/cli/command-parser.test.ts +19 -0
- package/src/wirings/cli/command-parser.ts +17 -1
- package/src/wirings/gateway/gateway-authorization.test.ts +131 -0
- package/src/wirings/gateway/gateway-channel-meta.test.ts +44 -0
- package/src/wirings/gateway/gateway-runner.ts +62 -22
- package/src/wirings/http/http-routes.ts +4 -1
- package/src/wirings/http/http-runner.ts +11 -26
- package/src/wirings/http/http.types.ts +0 -15
- package/src/wirings/http/index.ts +1 -7
- package/src/wirings/http/pikku-fetch-http-request.ts +2 -2
- package/src/wirings/http/web-request.ts +1 -1
- package/src/wirings/mcp/mcp-runner.ts +2 -16
- package/src/wirings/persona/persona-environments.test.ts +14 -3
- package/src/wirings/persona/persona.test.ts +13 -3
- package/src/wirings/persona/validate-personas.ts +5 -1
- package/src/wirings/queue/queue-runner.ts +1 -1
- package/src/wirings/rpc/addon-auth-tags.test.ts +1 -5
- package/src/wirings/rpc/rpc-runner.ts +58 -136
- package/src/wirings/rpc/rpc-types.ts +7 -0
- package/src/wirings/rpc/wire-addon.ts +3 -1
- package/src/wirings/secret/validate-secret-definitions.test.ts +69 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/trigger/pikku-trigger-service.ts +0 -5
- package/src/wirings/trigger/trigger-runner.ts +7 -5
- package/src/wirings/virtual-user/run-virtual-user.test.ts +18 -9
- package/src/wirings/virtual-user/run-virtual-user.ts +28 -15
- package/src/wirings/virtual-user/virtual-user-agents.test.ts +4 -1
- package/src/wirings/virtual-user/virtual-user-derive.ts +4 -25
- package/src/wirings/virtual-user/virtual-user-dispositions.test.ts +7 -2
- package/src/wirings/virtual-user/virtual-user-dispositions.ts +1 -4
- package/src/wirings/virtual-user/virtual-user-intents.test.ts +13 -3
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +35 -2
- package/src/wirings/workflow/feature.ts +7 -4
- package/src/wirings/workflow/graph/graph-node.ts +1 -1
- package/src/wirings/workflow/graph/graph-runner.test.ts +4 -2
- package/src/wirings/workflow/graph/graph-runner.ts +13 -16
- package/src/wirings/workflow/graph/workflow-graph.types.ts +0 -5
- package/src/wirings/workflow/index.ts +12 -4
- package/src/wirings/workflow/pikku-scenario-service.ts +54 -25
- package/src/wirings/workflow/pikku-workflow-service.test.ts +2 -2
- package/src/wirings/workflow/pikku-workflow-service.ts +208 -630
- package/src/wirings/workflow/scenario-hooks.test.ts +51 -0
- package/src/wirings/workflow/workflow-approval.ts +185 -0
- package/src/wirings/workflow/workflow-child-run-session.test.ts +89 -0
- package/src/wirings/workflow/workflow-constants.ts +47 -0
- package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
- package/src/wirings/workflow/workflow-dispatch-relay.test.ts +128 -0
- package/src/wirings/workflow/workflow-errors.ts +128 -0
- package/src/wirings/workflow/workflow-inline-authority.test.ts +12 -6
- package/src/wirings/workflow/workflow-meta-resolver.ts +45 -0
- package/src/wirings/workflow/workflow-missing-meta.test.ts +92 -0
- package/src/wirings/workflow/workflow-queue-routing.ts +85 -0
- package/src/wirings/workflow/workflow-queue-wiring.ts +149 -0
- package/src/wirings/workflow/workflow-recovery.ts +162 -0
- package/src/wirings/workflow/workflow-retry-policy.test.ts +1 -1
- package/src/wirings/workflow/workflow-run-authority.test.ts +215 -0
- package/src/wirings/workflow/workflow-run-engine.types.ts +91 -0
- package/src/wirings/workflow/workflow-run-ownership.ts +36 -0
- package/src/wirings/workflow/workflow-suspend.ts +61 -0
- package/src/wirings/workflow/workflow.types.ts +7 -0
- package/src/wirings-stay-decoupled.test.ts +123 -0
- package/tsconfig.json +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/src/internal.ts +0 -10
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { DEFAULT_STALLED_RUN_LIMIT, DEFAULT_STALLED_RUN_MS, DEFAULT_UNDISPATCHED_STEP_LIMIT, DEFAULT_UNDISPATCHED_STEP_MS, REDISPATCH_BACKOFF_MAX_ENTRIES, REDISPATCH_BACKOFF_MAX_MS, REDISPATCH_BACKOFF_MS, } from './workflow-constants.js';
|
|
2
|
+
/**
|
|
3
|
+
* Per-process, advisory record of when a run may next be re-dispatched.
|
|
4
|
+
*
|
|
5
|
+
* Keyed by run rather than by step because the run is the unit of re-drive:
|
|
6
|
+
* `resumeWorkflow` replays the whole run and re-dispatches every step still
|
|
7
|
+
* owed a job, so holding off a single step while resuming its run would
|
|
8
|
+
* suppress nothing.
|
|
9
|
+
*
|
|
10
|
+
* Losing this on restart costs extra dispatches, never correctness.
|
|
11
|
+
*/
|
|
12
|
+
export class RedispatchBackoff {
|
|
13
|
+
eligibleAt = new Map();
|
|
14
|
+
delays = new Map();
|
|
15
|
+
isEligible(runId, now) {
|
|
16
|
+
const at = this.eligibleAt.get(runId);
|
|
17
|
+
return at === undefined || at <= now;
|
|
18
|
+
}
|
|
19
|
+
note(runId, now) {
|
|
20
|
+
const previous = this.delays.get(runId);
|
|
21
|
+
const delay = Math.min(previous === undefined ? REDISPATCH_BACKOFF_MS : previous * 2, REDISPATCH_BACKOFF_MAX_MS);
|
|
22
|
+
// A run that settles is never returned again, so entries are only evicted
|
|
23
|
+
// by this bound — oldest first, which is also least recently re-dispatched.
|
|
24
|
+
if (this.eligibleAt.size >= REDISPATCH_BACKOFF_MAX_ENTRIES) {
|
|
25
|
+
const oldest = this.eligibleAt.keys().next();
|
|
26
|
+
if (!oldest.done) {
|
|
27
|
+
this.eligibleAt.delete(oldest.value);
|
|
28
|
+
this.delays.delete(oldest.value);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
this.delays.set(runId, delay);
|
|
32
|
+
this.eligibleAt.set(runId, now + delay);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const resumeEach = async (runIds, { resume, logger }, failure) => {
|
|
36
|
+
const succeeded = [];
|
|
37
|
+
for (const runId of runIds) {
|
|
38
|
+
try {
|
|
39
|
+
await resume(runId);
|
|
40
|
+
succeeded.push(runId);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
// One unresumable run must not stop the sweep from recovering the rest.
|
|
44
|
+
logger?.error(failure(runId, err instanceof Error ? err.message : String(err)));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return succeeded;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Re-drive runs whose next move was lost, and report which were resumed.
|
|
51
|
+
*
|
|
52
|
+
* Arming a step is two writes to two systems — the step row, then the queue or
|
|
53
|
+
* scheduler job — so a process that dies between them leaves a run that is
|
|
54
|
+
* `running` with nothing in flight. Nothing notices: the run parks on a step
|
|
55
|
+
* that will never complete and never error, so it neither finishes nor fails.
|
|
56
|
+
* (Seen on a `workflow.sleep()`: a deploy restart landed between the sleep
|
|
57
|
+
* step's insert and its timer, parking the run permanently.)
|
|
58
|
+
*
|
|
59
|
+
* Replay is the recovery — `resumeWorkflow` re-orchestrates from persisted step
|
|
60
|
+
* state, and every settled step is memoized, so resuming a run that was not
|
|
61
|
+
* actually stuck costs an orchestration pass and changes nothing. That
|
|
62
|
+
* idempotence is what makes an idle-time heuristic safe here; a run that is
|
|
63
|
+
* legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
|
|
64
|
+
*/
|
|
65
|
+
export const sweepStalledRuns = async (findStalledRunIds, options, deps) => {
|
|
66
|
+
const before = new Date(Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS));
|
|
67
|
+
const runIds = await findStalledRunIds(before, options?.limit ?? DEFAULT_STALLED_RUN_LIMIT);
|
|
68
|
+
return {
|
|
69
|
+
resumed: await resumeEach(runIds, deps, (runId, detail) => `Failed to resume stalled workflow run ${runId}: ${detail}`),
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Re-drive steps whose dispatch was lost, and report which runs were nudged.
|
|
74
|
+
*
|
|
75
|
+
* The step row is the outbox record and this is the relay. Age is the only
|
|
76
|
+
* signal available — a step `pending` because its dispatch was lost is
|
|
77
|
+
* indistinguishable from one whose job is merely still queued — so a step past
|
|
78
|
+
* `undispatchedAfterMs` is re-dispatched regardless, and correctness rests on
|
|
79
|
+
* the claim in `executeWorkflowStepInner` rather than on the guess being right.
|
|
80
|
+
* A redundant dispatch costs one queue message: the loser reads `running` and
|
|
81
|
+
* returns without invoking anything.
|
|
82
|
+
*
|
|
83
|
+
* Re-dispatches back off per run (doubling from 30s, capped at 10m) so a
|
|
84
|
+
* genuine queue backlog is not amplified by a tick that keeps firing at the
|
|
85
|
+
* steps the backlog is already delaying.
|
|
86
|
+
*/
|
|
87
|
+
export const sweepUndispatchedSteps = async (findUndispatchedSteps, backoff, options, deps) => {
|
|
88
|
+
const before = new Date(Date.now() - (options?.undispatchedAfterMs ?? DEFAULT_UNDISPATCHED_STEP_MS));
|
|
89
|
+
const steps = await findUndispatchedSteps(before, options?.limit ?? DEFAULT_UNDISPATCHED_STEP_LIMIT);
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const runIds = new Set();
|
|
92
|
+
for (const { runId } of steps) {
|
|
93
|
+
if (!backoff.isEligible(runId, now))
|
|
94
|
+
continue;
|
|
95
|
+
backoff.note(runId, now);
|
|
96
|
+
runIds.add(runId);
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
redispatched: await resumeEach(runIds, deps, (runId, detail) => `Failed to re-dispatch workflow run ${runId}: ${detail}`),
|
|
100
|
+
};
|
|
101
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { PikkuRawWire, SerializedError } from '../../types/core.types.js';
|
|
2
|
+
import type { CoreWorkflow, PikkuWorkflowWire, StepState, WorkflowRun, WorkflowStatus, WorkflowStepOptions } from './workflow.types.js';
|
|
3
|
+
export interface RunLifecycleContext {
|
|
4
|
+
runId: string;
|
|
5
|
+
run: WorkflowRun;
|
|
6
|
+
workflowMeta: any;
|
|
7
|
+
workflow: CoreWorkflow;
|
|
8
|
+
wire: PikkuRawWire;
|
|
9
|
+
packageName: string | null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The subset of the workflow service a step executor is allowed to call back
|
|
13
|
+
* into.
|
|
14
|
+
*/
|
|
15
|
+
export interface WorkflowRunEngine {
|
|
16
|
+
inlineStep(runId: string, logicalStepName: string, fn: Function, stepOptions?: WorkflowStepOptions, data?: any, funcName?: string): Promise<any>;
|
|
17
|
+
updateRunStatus(runId: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
|
|
18
|
+
onChildWorkflowFailed(run: WorkflowRun, error: unknown): Promise<void>;
|
|
19
|
+
verifyStepName(stepName: unknown): void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Hooks a host may install to decorate runs it did not start. Used by the
|
|
23
|
+
* scenario service to attach its own per-run state.
|
|
24
|
+
*/
|
|
25
|
+
export interface WorkflowRunExtension {
|
|
26
|
+
attachRunContext(runId: string, workflowMeta: any, options?: Record<string, any>): Promise<void>;
|
|
27
|
+
detachRunContext(runId: string): void;
|
|
28
|
+
decorateRunWire(wire: PikkuRawWire, context: {
|
|
29
|
+
runId: string;
|
|
30
|
+
workflowMeta: any;
|
|
31
|
+
workflowWire: PikkuWorkflowWire;
|
|
32
|
+
}): void;
|
|
33
|
+
decorateWorkflowWire(workflowWire: PikkuWorkflowWire, context: {
|
|
34
|
+
name: string;
|
|
35
|
+
runId: string;
|
|
36
|
+
rpcService: any;
|
|
37
|
+
addonNamespace?: string | null;
|
|
38
|
+
}): void;
|
|
39
|
+
onBeforeRunFunc(context: RunLifecycleContext): Promise<void>;
|
|
40
|
+
onAfterRunFunc(context: RunLifecycleContext, outcome: 'completed' | 'failed' | 'interrupted', failure: unknown): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Per-run bookkeeping held only for the lifetime of an in-process execution.
|
|
44
|
+
*/
|
|
45
|
+
export type RunContext = {
|
|
46
|
+
activeExecutions: number;
|
|
47
|
+
inline?: boolean;
|
|
48
|
+
ordinals: Map<string, number>;
|
|
49
|
+
lastStep?: string;
|
|
50
|
+
replay?: {
|
|
51
|
+
steps?: Map<string, StepState>;
|
|
52
|
+
run?: WorkflowRun;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ForbiddenError } from '../../errors/errors.js';
|
|
2
|
+
import type { CoreUserSession } from '../../types/core.types.js';
|
|
3
|
+
import type { WorkflowRunWire } from './workflow.types.js';
|
|
4
|
+
export declare class WorkflowRunForbiddenError extends ForbiddenError {
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A run started through a session records that session's user as its owner, and
|
|
9
|
+
* only that user may read it or answer its approval gates.
|
|
10
|
+
*
|
|
11
|
+
* A run with no recorded owner — started by a trigger, a scheduler, or a route
|
|
12
|
+
* wired without auth — has nobody to compare a caller against, so ownership is
|
|
13
|
+
* not a control that exists for it. Gate those with `auth` or `permissions` on
|
|
14
|
+
* the entrypoint instead.
|
|
15
|
+
*/
|
|
16
|
+
export declare const assertWorkflowRunOwner: (wire: WorkflowRunWire | undefined, session: CoreUserSession | undefined) => void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ForbiddenError } from '../../errors/errors.js';
|
|
2
|
+
import { addError } from '../../errors/error-handler.js';
|
|
3
|
+
export class WorkflowRunForbiddenError extends ForbiddenError {
|
|
4
|
+
constructor() {
|
|
5
|
+
super('Not authorized to access this workflow run');
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
addError(WorkflowRunForbiddenError, {
|
|
9
|
+
status: 403,
|
|
10
|
+
message: 'Not authorized to access this workflow run.',
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* A run started through a session records that session's user as its owner, and
|
|
14
|
+
* only that user may read it or answer its approval gates.
|
|
15
|
+
*
|
|
16
|
+
* A run with no recorded owner — started by a trigger, a scheduler, or a route
|
|
17
|
+
* wired without auth — has nobody to compare a caller against, so ownership is
|
|
18
|
+
* not a control that exists for it. Gate those with `auth` or `permissions` on
|
|
19
|
+
* the entrypoint instead.
|
|
20
|
+
*/
|
|
21
|
+
export const assertWorkflowRunOwner = (wire, session) => {
|
|
22
|
+
const owner = wire?.pikkuUserId;
|
|
23
|
+
if (!owner) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (!session?.userId || session.userId !== owner) {
|
|
27
|
+
throw new WorkflowRunForbiddenError();
|
|
28
|
+
}
|
|
29
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ApprovalStore } from './workflow-approval.js';
|
|
2
|
+
/** The durable step name a suspension point is recorded under. */
|
|
3
|
+
export declare const suspendStepNameFor: (reason: string) => string;
|
|
4
|
+
/** What the suspend gate needs from the workflow service. */
|
|
5
|
+
export type SuspendStore = Pick<ApprovalStore, 'getStepState' | 'insertStepState' | 'setStepRunning' | 'setStepResult'>;
|
|
6
|
+
/**
|
|
7
|
+
* Record a suspension point and unwind the run.
|
|
8
|
+
*
|
|
9
|
+
* A suspension that has already succeeded returns instead of throwing, so a
|
|
10
|
+
* replay walks past a gate the run has already passed through.
|
|
11
|
+
*/
|
|
12
|
+
export declare const recordSuspension: (store: SuspendStore, runId: string, reason: string, stepName: string, fromStepName: string | undefined) => Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { WorkflowSuspendedException } from './workflow-errors.js';
|
|
2
|
+
/** The durable step name a suspension point is recorded under. */
|
|
3
|
+
export const suspendStepNameFor = (reason) => `__workflow_suspend:${reason}`;
|
|
4
|
+
/**
|
|
5
|
+
* Record a suspension point and unwind the run.
|
|
6
|
+
*
|
|
7
|
+
* A suspension that has already succeeded returns instead of throwing, so a
|
|
8
|
+
* replay walks past a gate the run has already passed through.
|
|
9
|
+
*/
|
|
10
|
+
export const recordSuspension = async (store, runId, reason, stepName, fromStepName) => {
|
|
11
|
+
const insert = () => store.insertStepState(runId, stepName, 'pikkuWorkflowSuspend', { reason }, undefined, fromStepName);
|
|
12
|
+
let stepState;
|
|
13
|
+
try {
|
|
14
|
+
stepState = await store.getStepState(runId, stepName);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
stepState = await insert();
|
|
18
|
+
}
|
|
19
|
+
if (!stepState.stepId) {
|
|
20
|
+
stepState = await insert();
|
|
21
|
+
}
|
|
22
|
+
if (stepState.status === 'succeeded') {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (stepState.status === 'pending') {
|
|
26
|
+
await store.setStepRunning(stepState.stepId);
|
|
27
|
+
}
|
|
28
|
+
await store.setStepResult(stepState.stepId, {
|
|
29
|
+
reason,
|
|
30
|
+
suspendedAt: new Date().toISOString(),
|
|
31
|
+
});
|
|
32
|
+
throw new WorkflowSuspendedException(runId, reason);
|
|
33
|
+
};
|
|
@@ -49,6 +49,13 @@ export interface WorkflowRun {
|
|
|
49
49
|
export interface StepState {
|
|
50
50
|
stepId: string;
|
|
51
51
|
status: StepStatus;
|
|
52
|
+
/**
|
|
53
|
+
* The function the workflow dispatched this step with, recorded so a worker
|
|
54
|
+
* can reject a queue message naming anything else. `null` is a step with no
|
|
55
|
+
* function of its own (inline work); `undefined` is a store that never
|
|
56
|
+
* recorded one, and cannot be compared against.
|
|
57
|
+
*/
|
|
58
|
+
rpcName?: string | null;
|
|
52
59
|
result?: any;
|
|
53
60
|
error?: SerializedError;
|
|
54
61
|
attemptCount: number;
|
package/knowledge/decisions/internals/a-non-streaming-agent-run-registers-with-airunstate-too.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: A non-streaming agent run registers with aiRunState on the same terms as a streaming one
|
|
4
|
+
description: Otherwise interruptAIAgent finds the run, passes the ownership check, then cannot stop it — and reports that as if the run were on another host
|
|
5
|
+
tags: core, ai-agent
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A non-streaming agent run registers with `aiRunState` too
|
|
9
|
+
|
|
10
|
+
`runAIAgent` registers its run with `aiRunState` exactly as `streamAIAgent`
|
|
11
|
+
does, even though nothing is streaming and there is no channel to interrupt.
|
|
12
|
+
|
|
13
|
+
`interruptAIAgent` resolves a run through `aiRunState` first, checks ownership,
|
|
14
|
+
then looks for a local abort handle. A run that skipped registration is invisible
|
|
15
|
+
at the first step. A run that registered but has no handle is visible, passes the
|
|
16
|
+
ownership check, and then cannot be stopped — which the interrupt path reports as
|
|
17
|
+
"running on another instance". That message would be wrong and actively
|
|
18
|
+
misleading: the run is right here, and the deployment is single-instance.
|
|
19
|
+
|
|
20
|
+
**What this rules out:** treating registration as a streaming concern. It is an
|
|
21
|
+
addressability concern, and the two paths have to be addressable the same way for
|
|
22
|
+
the interrupt path's diagnosis to mean anything.
|
package/knowledge/decisions/internals/a-resumed-agent-turn-is-as-interruptible-as-the-first.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: A resumed turn is as interruptible as the first one
|
|
4
|
+
description: It is the same person listening to the same voice, and after an approval it is where most of the reply actually gets spoken
|
|
5
|
+
tags: core, ai-agent
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A resumed turn is as interruptible as the first one
|
|
9
|
+
|
|
10
|
+
Resuming after an approval registers for interruption on the same terms as the
|
|
11
|
+
original turn.
|
|
12
|
+
|
|
13
|
+
It is the same person listening to the same voice, so the reason to allow
|
|
14
|
+
interruption has not changed. And after an approval is precisely where most of
|
|
15
|
+
the reply gets spoken: an approved delete is followed by the agent describing
|
|
16
|
+
what it did, which is a normal thing for a listener to talk over.
|
|
17
|
+
|
|
18
|
+
**What this rules out:** treating the resume as a short continuation not worth
|
|
19
|
+
wiring for interruption. By volume of speech it is usually the larger half of
|
|
20
|
+
the exchange.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: A scenario step's prose template is offered to a virtual user unfilled
|
|
4
|
+
description: A reporter fills placeholders from a run that happened; there is no run yet, and the filled form would answer the question the user is there to answer
|
|
5
|
+
tags: core, virtual-user
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A scenario step's prose template is offered unfilled
|
|
9
|
+
|
|
10
|
+
When a scenario step becomes a catalogue entry, its prose template goes in with
|
|
11
|
+
its placeholders intact — braces and all.
|
|
12
|
+
|
|
13
|
+
A reporter fills those placeholders from a run that already happened. There is
|
|
14
|
+
no run yet at derivation time, so there is nothing to fill them from. More
|
|
15
|
+
importantly, filling them would answer the wrong question: "invites {email}"
|
|
16
|
+
tells the user to choose someone, where "invites ada@example.com" tells it whom
|
|
17
|
+
— and *whom* is the scenario author's answer, not something the virtual user
|
|
18
|
+
worked out.
|
|
19
|
+
|
|
20
|
+
**What this rules out:** substituting example or fixture values to make the
|
|
21
|
+
catalogue read more naturally. It reads better and tests less.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: A virtual user decides whether to trust its notes once per turn, by one roll
|
|
4
|
+
description: The difference between the stale, newcomer and auditor dispositions is expressed as a single probability rather than as prose in each prompt
|
|
5
|
+
tags: core, virtual-user
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A virtual user decides whether to trust its notes once per turn
|
|
9
|
+
|
|
10
|
+
Each turn the run makes one weighted decision: does this user act on what it
|
|
11
|
+
already recorded, or go and look again.
|
|
12
|
+
|
|
13
|
+
That single roll is where the dispositions differ. `stale` almost always trusts
|
|
14
|
+
its notes — that is what makes it stale. A `newcomer` has none to trust. An
|
|
15
|
+
`auditor` re-checks nearly everything, which is the entire point of an auditor.
|
|
16
|
+
Expressing it as one probability keeps the difference between those runs in one
|
|
17
|
+
readable place instead of spread through three prompts as English that drifts.
|
|
18
|
+
|
|
19
|
+
**What this rules out:** encoding "you are suspicious of your own notes" into
|
|
20
|
+
each disposition's prompt text, where the behaviour becomes a property of how
|
|
21
|
+
the model reads prose rather than something the run controls and can report.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: A wall-clock threshold is a load test in disguise
|
|
4
|
+
description: The KEK derivation test asserted a fixed 50ms budget for work that took 10ms, which went red about one run in five once the suite was large enough to compete for the machine
|
|
5
|
+
tags: core, testing, crypto
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A wall-clock threshold is a load test in disguise
|
|
9
|
+
|
|
10
|
+
`crypto-utils.test.ts` guards a real property: unwrapping N secrets must not
|
|
11
|
+
cost N KEK derivations. Deriving a KEK is a deliberately slow KDF, so doing it
|
|
12
|
+
per secret turns a 10ms operation into a 4-second one.
|
|
13
|
+
|
|
14
|
+
It guarded it with `assert.ok(elapsed < 50)`. Measured on this machine:
|
|
15
|
+
|
|
16
|
+
| | |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| one `deriveKEK` | ~86ms |
|
|
19
|
+
| 50 `envelopeDecrypt` | 10.8ms |
|
|
20
|
+
| the old 50ms threshold | 4.6x headroom |
|
|
21
|
+
| the failure it guards against | ~4281ms — a **396x** separation |
|
|
22
|
+
|
|
23
|
+
So the assertion had 4.6x of margin to detect a 396x regression. Everything
|
|
24
|
+
between those two numbers was noise, and once core's suite reached ~2000 tests
|
|
25
|
+
competing for the same cores, a 10ms window drifting past 50ms became routine:
|
|
26
|
+
roughly one run in five went red, always on a machine where nothing was wrong.
|
|
27
|
+
|
|
28
|
+
**Calibrate against a measurement taken in the same run.** The test already
|
|
29
|
+
derives a KEK, so timing that call is free, and the assertion becomes
|
|
30
|
+
`elapsed < oneDerivation` — unwrapping all fifty must cost less than deriving
|
|
31
|
+
once. Under load both sides slow down together, so the ratio holds.
|
|
32
|
+
|
|
33
|
+
**What this rules out:** raising the constant. 100ms or 200ms buys a smaller
|
|
34
|
+
flake rate and the same class of bug, and it drifts again the next time the
|
|
35
|
+
suite grows or CI moves to a noisier runner. Any assertion of the form
|
|
36
|
+
"operation X takes less than N milliseconds" has this problem; express it as a
|
|
37
|
+
ratio against something measured alongside it.
|
|
38
|
+
|
|
39
|
+
Worth knowing: `envelopeDecrypt(kek: CryptoKey, …)` takes the derived key as a
|
|
40
|
+
parameter, so it *cannot* derive one — the property is already enforced by the
|
|
41
|
+
signature, and [[the-api-report-pins-members-not-just-names]] would catch a
|
|
42
|
+
change to it. The test is defence in depth, which is a reason to make it cheap
|
|
43
|
+
and quiet rather than to delete it.
|
|
44
|
+
|
|
45
|
+
Related: [[an-unref-d-timer-cannot-be-awaited-under-node-test]], the other
|
|
46
|
+
source of nondeterminism found in the same pass.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: A workflow's wire is built from the run record, not from the RPC service
|
|
4
|
+
description: The RPC service exposes no wire, so every rpcService.wire read was undefined; the run record is the only thing that carries the caller across a step boundary
|
|
5
|
+
tags: core, workflow
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A workflow's wire is built from the run record, not from the RPC service
|
|
9
|
+
|
|
10
|
+
`PikkuWorkflowService` builds a fresh wire each time it runs a workflow body or
|
|
11
|
+
starts a child. For a while it filled parts of that wire from the RPC service it
|
|
12
|
+
had been handed:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
session: rpcService?.wire?.session,
|
|
16
|
+
rpc: rpcService?.wire?.rpc,
|
|
17
|
+
pikkuUserId: rpcService.wire?.pikkuUserId,
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`PikkuRPC` has no `wire`. Neither does the object `getContextRPCService`
|
|
21
|
+
actually returns — `ContextAwareRPCService` holds its wire *privately* and
|
|
22
|
+
exposes `invoke`, `remote`, `exposed`, `startWorkflow`, `agent` and
|
|
23
|
+
`rpcWithWire`, and nothing else. Every one of those reads was `undefined`, and
|
|
24
|
+
the `rpcService: any` parameter type is what kept the compiler quiet about it.
|
|
25
|
+
|
|
26
|
+
The consequence was silent and one-directional: a child workflow started from a
|
|
27
|
+
step never inherited the `pikkuUserId` its parent was running as, so a queued
|
|
28
|
+
child ran as nobody. Nothing failed — the field was simply absent, and the run
|
|
29
|
+
proceeded.
|
|
30
|
+
|
|
31
|
+
**The run record is the carrier.** `WorkflowRun.wire` is durable, is written
|
|
32
|
+
when the run is created, and survives the process boundary a queued step
|
|
33
|
+
crosses, which is exactly what a live service reference cannot do. Both the
|
|
34
|
+
run-body wire and the child-run wire now read `run.wire?.pikkuUserId`.
|
|
35
|
+
|
|
36
|
+
`session` and `rpc` are not copied at all. `runPikkuFunc` attaches `rpc` lazily
|
|
37
|
+
for the duration of an invocation and restores the previous descriptor
|
|
38
|
+
afterwards, and it resolves the session from the session store using
|
|
39
|
+
`pikkuUserId` — so both were being overwritten moments later anyway. The wire
|
|
40
|
+
these paths construct is a `PikkuRawWire` for that reason: it genuinely has no
|
|
41
|
+
`rpc` yet, and saying so is what let the dead reads be found.
|
|
42
|
+
|
|
43
|
+
**What this rules out:** reaching for the RPC service to answer "who is this
|
|
44
|
+
running as". It cannot answer, and the shape of the question hides that.
|
|
45
|
+
Anything a step needs to know about its caller has to be on the run.
|
package/knowledge/decisions/internals/agent-context-waits-for-a-tool-result-still-being-written.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: Loading agent context waits for a tool result that may still be landing
|
|
4
|
+
description: An interrupted run's tool can still be writing to the thread, and in voice the next turn arrives within seconds — soon enough to load context missing what it is about to be asked about
|
|
5
|
+
tags: core, ai-agent
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Loading agent context waits for a tool result that may still be landing
|
|
9
|
+
|
|
10
|
+
Before a turn loads its thread, it settles any tool whose run was interrupted
|
|
11
|
+
but whose result is still being written to that thread.
|
|
12
|
+
|
|
13
|
+
The window is small and, on a typed interface, usually irrelevant — a person
|
|
14
|
+
takes seconds to write the next message. In voice it is not: the next turn lands
|
|
15
|
+
within a second or two of the last one. Without the wait, the model loads a
|
|
16
|
+
thread that is missing the very result the user is about to ask about, and
|
|
17
|
+
answers as though the tool never ran.
|
|
18
|
+
|
|
19
|
+
**What this rules out:** treating the settle as belt-and-braces and dropping it
|
|
20
|
+
to save a round trip. The failure it prevents is a confidently wrong answer, not
|
|
21
|
+
an error, and it only appears on the fastest transport.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: Agent speech travels as a CUSTOM AG-UI event rather than being dropped
|
|
4
|
+
description: AG-UI has no speech event, and dropping it makes a voice agent reached over HTTP silently inaudible while the provider still bills for the audio
|
|
5
|
+
tags: core, ai-agent
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Agent speech travels as a CUSTOM AG-UI event
|
|
9
|
+
|
|
10
|
+
The AG-UI protocol has no event type for synthesized speech, so the bridge
|
|
11
|
+
forwards it as `CUSTOM`, the same way it forwards the other pikku-specific
|
|
12
|
+
events.
|
|
13
|
+
|
|
14
|
+
The alternative was tried: the mapper dropped the event, on the reasoning that a
|
|
15
|
+
protocol without a speech event has no way to carry one. The result is a voice
|
|
16
|
+
agent reached over HTTP that is completely silent — `voiceOutput` synthesizes
|
|
17
|
+
every sentence, the provider bills for every one of them, and none of it gets
|
|
18
|
+
past the mapper. Nothing errors, so there is nothing to find.
|
|
19
|
+
|
|
20
|
+
**What this rules out:** filtering unknown event types at the AG-UI boundary as
|
|
21
|
+
a tidiness measure. `CUSTOM` exists precisely so a protocol gap degrades to
|
|
22
|
+
"the client ignores it" rather than "the server threw the work away".
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: An interrupt is not a failure, and the non-streaming path throws rather than returning
|
|
4
|
+
description: It skips the onError hooks and never becomes an errorMessage; with no partial reply to hand back, a typed throw is what distinguishes it from a provider outage
|
|
5
|
+
tags: core, ai-agent
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# An interrupt is not a failure
|
|
9
|
+
|
|
10
|
+
Interrupting a run skips the `onError` hooks entirely and never becomes an
|
|
11
|
+
`errorMessage`. Someone asked the agent to stop and it stopped; that is the
|
|
12
|
+
feature working, and running failure handlers over it would report an incident
|
|
13
|
+
that did not happen.
|
|
14
|
+
|
|
15
|
+
The streaming and non-streaming paths then diverge, because what they can hand
|
|
16
|
+
back differs. A stream has already delivered part of the reply, so it returns
|
|
17
|
+
that fragment. `runAIAgent` has delivered nothing — there is no partial answer to
|
|
18
|
+
return — so it throws a typed error instead. A caller can tell that apart from a
|
|
19
|
+
provider outage, which returning an empty result would not allow.
|
|
20
|
+
|
|
21
|
+
**What this rules out:** unifying the two paths on "return whatever you have".
|
|
22
|
+
For the non-streaming path that is an empty string, and an empty string is
|
|
23
|
+
indistinguishable from a model that answered with nothing.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: An interrupt for a run owned by another instance says so, rather than returning false
|
|
4
|
+
description: A bare false is indistinguishable from "already finished", which is the one deployment shape the in-process registry cannot cover
|
|
5
|
+
tags: core, ai-agent
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# An interrupt for a run owned by another instance says so
|
|
9
|
+
|
|
10
|
+
`interruptAIAgent` resolves a run through `aiRunState`, then tries to abort it
|
|
11
|
+
via the in-process registry. A run still marked `running` that this process has
|
|
12
|
+
no abort handle for is executing on another instance.
|
|
13
|
+
|
|
14
|
+
Returning a bare `false` there is indistinguishable from "the run already
|
|
15
|
+
finished" — the ordinary, uninteresting outcome. So the single deployment shape
|
|
16
|
+
the in-process registry does not cover, multi-instance, fails silently: an agent
|
|
17
|
+
that will not stop talking, and nothing in the logs to say why. The call reports
|
|
18
|
+
the condition instead.
|
|
19
|
+
|
|
20
|
+
**What this rules out:** collapsing the two outcomes into one boolean because
|
|
21
|
+
the caller "only cares whether it stopped". The caller cares a great deal about
|
|
22
|
+
the difference between *stopped* and *cannot be stopped from here*.
|
|
23
|
+
|
|
24
|
+
The fix for the underlying gap is `signalRunInterrupt`, which fans the interrupt
|
|
25
|
+
out over `eventHub` so every instance tries locally.
|
package/knowledge/decisions/internals/an-agent-stream-send-must-return-the-inner-sends-promise.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: A wrapped agent-stream send must return the inner send's promise
|
|
4
|
+
description: Middleware runs asynchronously, so dropping the returned promise turns every awaited channel.send upstream into a no-op
|
|
5
|
+
tags: core, ai-agent
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A wrapped agent-stream send must return the inner send's promise
|
|
9
|
+
|
|
10
|
+
`streamAIAgent` wraps the caller's channel so every event passes through the
|
|
11
|
+
stream middleware. That wrapper's `send` returns whatever the inner `send`
|
|
12
|
+
returns, and it must: the middleware chain is asynchronous, so a wrapper that
|
|
13
|
+
calls the inner `send` and returns `undefined` resolves immediately while the
|
|
14
|
+
event is still in flight.
|
|
15
|
+
|
|
16
|
+
Every `await channel.send(...)` upstream then becomes a no-op that resolves
|
|
17
|
+
before the thing it is waiting for has happened. The one that matters is the
|
|
18
|
+
final flush — a buffering hook such as `voiceOutput` is still synthesizing audio
|
|
19
|
+
when the awaited send resolves, and the `close()` that follows discards it.
|
|
20
|
+
|
|
21
|
+
**What this rules out:** writing the wrapper as a fire-and-forget `(msg) => {
|
|
22
|
+
inner.send(msg) }`, which reads as equivalent and is not.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: A message with nothing to say carries no text part at all
|
|
4
|
+
description: An attachment on its own is a real turn, and providers are entitled to reject an empty text part sitting beside it
|
|
5
|
+
tags: core, ai-agent
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A message with nothing to say carries no text part
|
|
9
|
+
|
|
10
|
+
When a turn has no text, the text part is omitted rather than included as an
|
|
11
|
+
empty string.
|
|
12
|
+
|
|
13
|
+
An attachment on its own is a legitimate turn — a spoken one carries audio and
|
|
14
|
+
no text whatsoever — so "no text" is a normal state, not a degenerate one.
|
|
15
|
+
Providers are entitled to reject a message part with empty content, and that
|
|
16
|
+
rejection would land on a caller who never wrote any text to begin with.
|
|
17
|
+
|
|
18
|
+
**What this rules out:** normalising the text to `''` for a uniform message
|
|
19
|
+
shape. Uniformity here buys nothing and costs a provider error on the one turn
|
|
20
|
+
type that most needs to work.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: A transcript is recorded only when something was actually heard
|
|
4
|
+
description: Recording an empty string sends a transcript event saying the user said nothing, which renders as an empty bubble rather than a pending one
|
|
5
|
+
tags: core, ai-agent
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A transcript is recorded only when something was actually heard
|
|
9
|
+
|
|
10
|
+
A turn can carry audio that reads entirely as non-speech and still have content
|
|
11
|
+
— an image with a silent caption clip — so there is nothing above to throw.
|
|
12
|
+
`voiceInput` records the transcript only when speech was found.
|
|
13
|
+
|
|
14
|
+
Writing `''` instead would emit a transcript event asserting that the user said
|
|
15
|
+
nothing. A client that distinguishes "not transcribed yet" from "transcribed"
|
|
16
|
+
by whether the key is present would then render that turn as a permanently
|
|
17
|
+
empty bubble rather than a pending one — a worse outcome than showing nothing,
|
|
18
|
+
because it looks settled.
|
|
19
|
+
|
|
20
|
+
**What this rules out:** defaulting the transcript to an empty string for a
|
|
21
|
+
uniform event shape. Absence and emptiness mean different things to the client,
|
|
22
|
+
and only absence is recoverable.
|