@pikku/core 0.12.79 → 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 +161 -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.js +2 -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/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 +9 -2
- 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 -2
- 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 +28 -147
- package/dist/wirings/workflow/pikku-workflow-service.js +71 -493
- 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/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 +13 -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 +2 -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/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-channel-meta.test.ts +44 -0
- package/src/wirings/gateway/gateway-runner.ts +27 -19
- 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 +22 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -2
- 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 +190 -743
- 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-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,85 @@
|
|
|
1
|
+
import { getSingletonServices, pikkuState } from '../../pikku-state.js'
|
|
2
|
+
import { getDurationInMilliseconds } from '../../time-utils.js'
|
|
3
|
+
import type { JobGroup, JobOptions } from '../queue/queue.types.js'
|
|
4
|
+
import type {
|
|
5
|
+
WorkflowServiceConfig,
|
|
6
|
+
WorkflowStepOptions,
|
|
7
|
+
} from './workflow.types.js'
|
|
8
|
+
import { DEFAULT_STEP_RETRIES } from './workflow-constants.js'
|
|
9
|
+
|
|
10
|
+
export type WorkflowQueueStrategy = 'per-workflow' | 'shared-groups'
|
|
11
|
+
|
|
12
|
+
const toKebab = (s: string) =>
|
|
13
|
+
s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
|
14
|
+
|
|
15
|
+
export const resolveWorkflowConfig = (): WorkflowServiceConfig => {
|
|
16
|
+
const workflow = getSingletonServices().config?.workflow
|
|
17
|
+
return {
|
|
18
|
+
retries: workflow?.retries ?? DEFAULT_STEP_RETRIES,
|
|
19
|
+
retryDelay: workflow?.retryDelay ?? 0,
|
|
20
|
+
orchestratorQueueName:
|
|
21
|
+
workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',
|
|
22
|
+
stepWorkerQueueName:
|
|
23
|
+
workflow?.stepWorkerQueueName ?? 'pikku-workflow-step-worker',
|
|
24
|
+
sleeperRPCName: workflow?.sleeperRPCName ?? 'pikkuWorkflowSleeper',
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Falls back to the shared queue whenever the dedicated one was not generated
|
|
30
|
+
* into queue meta, so a workflow added without a rebuild still runs.
|
|
31
|
+
*/
|
|
32
|
+
const dedicatedQueueName = (
|
|
33
|
+
prefix: string,
|
|
34
|
+
name: string | undefined,
|
|
35
|
+
strategy: WorkflowQueueStrategy,
|
|
36
|
+
fallback: string
|
|
37
|
+
): string => {
|
|
38
|
+
if (!name || strategy === 'shared-groups') {
|
|
39
|
+
return fallback
|
|
40
|
+
}
|
|
41
|
+
const dedicated = `${prefix}${toKebab(name)}`
|
|
42
|
+
return pikkuState(null, 'queue', 'meta')[dedicated] ? dedicated : fallback
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const orchestratorQueueName = (
|
|
46
|
+
strategy: WorkflowQueueStrategy,
|
|
47
|
+
workflowName?: string
|
|
48
|
+
): string =>
|
|
49
|
+
dedicatedQueueName(
|
|
50
|
+
'wf-orchestrator-',
|
|
51
|
+
workflowName,
|
|
52
|
+
strategy,
|
|
53
|
+
resolveWorkflowConfig().orchestratorQueueName
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
export const stepWorkerQueueName = (
|
|
57
|
+
strategy: WorkflowQueueStrategy,
|
|
58
|
+
rpcName?: string
|
|
59
|
+
): string =>
|
|
60
|
+
dedicatedQueueName(
|
|
61
|
+
'wf-step-',
|
|
62
|
+
rpcName,
|
|
63
|
+
strategy,
|
|
64
|
+
resolveWorkflowConfig().stepWorkerQueueName
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
export const jobGroupFor = (
|
|
68
|
+
strategy: WorkflowQueueStrategy,
|
|
69
|
+
id?: string
|
|
70
|
+
): JobGroup | undefined =>
|
|
71
|
+
id && strategy === 'shared-groups' ? { id, tier: id } : undefined
|
|
72
|
+
|
|
73
|
+
export const stepJobOptions = (
|
|
74
|
+
stepOptions?: WorkflowStepOptions
|
|
75
|
+
): JobOptions => {
|
|
76
|
+
const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES
|
|
77
|
+
const retryDelay = stepOptions?.retryDelay
|
|
78
|
+
const backoff =
|
|
79
|
+
retryDelay !== undefined && retryDelay !== 'exponential'
|
|
80
|
+
? { type: 'fixed', delay: getDurationInMilliseconds(retryDelay) }
|
|
81
|
+
: retries > 0 || retryDelay === 'exponential'
|
|
82
|
+
? 'exponential'
|
|
83
|
+
: undefined
|
|
84
|
+
return { attempts: retries + 1, ...(backoff ? { backoff } : {}) }
|
|
85
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { addFunction } from '../../function/function-runner.js'
|
|
2
|
+
import { pikkuState } from '../../pikku-state.js'
|
|
3
|
+
import { wireQueueWorker } from '../queue/queue-runner.js'
|
|
4
|
+
import type {
|
|
5
|
+
GroupConcurrencyConfig,
|
|
6
|
+
PikkuWorkerConfig,
|
|
7
|
+
} from '../queue/queue.types.js'
|
|
8
|
+
import type { Logger } from '../../services/logger.js'
|
|
9
|
+
import {
|
|
10
|
+
pikkuWorkflowOrchestratorFunc,
|
|
11
|
+
pikkuWorkflowSleeperFunc,
|
|
12
|
+
pikkuWorkflowWorkerFunc,
|
|
13
|
+
} from './workflow-queue-workers.js'
|
|
14
|
+
import type { WorkflowQueueStrategy } from './workflow-queue-routing.js'
|
|
15
|
+
|
|
16
|
+
const ORCHESTRATOR_QUEUE_PREFIX = 'wf-orchestrator-'
|
|
17
|
+
const STEP_QUEUE_PREFIX = 'wf-step-'
|
|
18
|
+
|
|
19
|
+
const helperFunctionMeta = (funcId: string) => ({
|
|
20
|
+
pikkuFuncId: funcId,
|
|
21
|
+
sessionless: true,
|
|
22
|
+
functionType: 'helper' as const,
|
|
23
|
+
inputSchemaName: null,
|
|
24
|
+
outputSchemaName: null,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export type WorkflowQueueWiringOptions = {
|
|
28
|
+
strategy: WorkflowQueueStrategy
|
|
29
|
+
concurrency: number
|
|
30
|
+
groupConcurrency: number | GroupConcurrencyConfig
|
|
31
|
+
/**
|
|
32
|
+
* Resolved only when there is something to warn about: reading the logger
|
|
33
|
+
* throws while singleton services are uninitialized, and wiring queues before
|
|
34
|
+
* that point is legitimate.
|
|
35
|
+
*/
|
|
36
|
+
getLogger: () => Logger | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Registers the orchestrator, step-worker and sleeper functions and binds them
|
|
41
|
+
* to their queues. Idempotent: a queue already carrying a registered function
|
|
42
|
+
* is left alone.
|
|
43
|
+
*/
|
|
44
|
+
export const wireWorkflowQueueWorkers = ({
|
|
45
|
+
strategy,
|
|
46
|
+
concurrency,
|
|
47
|
+
groupConcurrency,
|
|
48
|
+
getLogger,
|
|
49
|
+
}: WorkflowQueueWiringOptions): void => {
|
|
50
|
+
const functions = pikkuState(null, 'function', 'functions')
|
|
51
|
+
const functionsMeta = pikkuState(null, 'function', 'meta')
|
|
52
|
+
const queueMeta = pikkuState(null, 'queue', 'meta')
|
|
53
|
+
|
|
54
|
+
const registerWorkflowFunc = (
|
|
55
|
+
funcId: string,
|
|
56
|
+
func: { func: unknown },
|
|
57
|
+
queueName: string,
|
|
58
|
+
config?: PikkuWorkerConfig
|
|
59
|
+
) => {
|
|
60
|
+
if (functions.has(funcId)) return
|
|
61
|
+
addFunction(funcId, func as never)
|
|
62
|
+
if (!queueMeta[queueName]) {
|
|
63
|
+
queueMeta[queueName] = { pikkuFuncId: funcId, name: queueName }
|
|
64
|
+
}
|
|
65
|
+
wireQueueWorker({ name: queueName, func, config } as never)
|
|
66
|
+
if (!functionsMeta[funcId]) {
|
|
67
|
+
functionsMeta[funcId] = helperFunctionMeta(funcId)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sharedGroups = strategy === 'shared-groups'
|
|
72
|
+
const sharedQueueConfig: PikkuWorkerConfig | undefined = sharedGroups
|
|
73
|
+
? { batchSize: concurrency, groupConcurrency }
|
|
74
|
+
: undefined
|
|
75
|
+
|
|
76
|
+
registerWorkflowFunc(
|
|
77
|
+
'pikkuWorkflowOrchestrator',
|
|
78
|
+
{ func: pikkuWorkflowOrchestratorFunc },
|
|
79
|
+
'pikku-workflow-orchestrator',
|
|
80
|
+
sharedQueueConfig
|
|
81
|
+
)
|
|
82
|
+
registerWorkflowFunc(
|
|
83
|
+
'pikkuWorkflowStepWorker',
|
|
84
|
+
{ func: pikkuWorkflowWorkerFunc },
|
|
85
|
+
'pikku-workflow-step-worker',
|
|
86
|
+
sharedQueueConfig
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const registerDedicatedQueues = (meta: Record<string, any>) => {
|
|
90
|
+
for (const [queueName, entry] of Object.entries(meta)) {
|
|
91
|
+
if (functions.has(entry.pikkuFuncId)) continue
|
|
92
|
+
if (queueName.startsWith(ORCHESTRATOR_QUEUE_PREFIX)) {
|
|
93
|
+
registerWorkflowFunc(
|
|
94
|
+
entry.pikkuFuncId,
|
|
95
|
+
{ func: pikkuWorkflowOrchestratorFunc },
|
|
96
|
+
queueName
|
|
97
|
+
)
|
|
98
|
+
} else if (queueName.startsWith(STEP_QUEUE_PREFIX)) {
|
|
99
|
+
registerWorkflowFunc(
|
|
100
|
+
entry.pikkuFuncId,
|
|
101
|
+
{ func: pikkuWorkflowWorkerFunc },
|
|
102
|
+
queueName
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!sharedGroups) {
|
|
109
|
+
registerDedicatedQueues(pikkuState(null, 'queue', 'meta'))
|
|
110
|
+
|
|
111
|
+
const addons = pikkuState(null, 'addons', 'packages')
|
|
112
|
+
for (const [, addon] of addons ?? []) {
|
|
113
|
+
const addonQueueMeta = pikkuState(addon.package, 'queue', 'meta')
|
|
114
|
+
if (addonQueueMeta) {
|
|
115
|
+
registerDedicatedQueues(addonQueueMeta)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
warnOnMissingDedicatedQueues(queueMeta, getLogger)
|
|
121
|
+
|
|
122
|
+
if (!functions.has('pikkuWorkflowSleeper')) {
|
|
123
|
+
addFunction('pikkuWorkflowSleeper', { func: pikkuWorkflowSleeperFunc })
|
|
124
|
+
}
|
|
125
|
+
if (!functionsMeta.pikkuWorkflowSleeper) {
|
|
126
|
+
functionsMeta.pikkuWorkflowSleeper = helperFunctionMeta(
|
|
127
|
+
'pikkuWorkflowSleeper'
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const warnOnMissingDedicatedQueues = (
|
|
133
|
+
queueMeta: Record<string, unknown>,
|
|
134
|
+
getLogger: () => Logger | undefined
|
|
135
|
+
) => {
|
|
136
|
+
const workflowCount = Object.keys(
|
|
137
|
+
pikkuState(null, 'workflows', 'meta') ?? {}
|
|
138
|
+
).length
|
|
139
|
+
const dedicatedQueues = Object.keys(queueMeta).filter((name) =>
|
|
140
|
+
name.startsWith(ORCHESTRATOR_QUEUE_PREFIX)
|
|
141
|
+
).length
|
|
142
|
+
if (workflowCount > 0 && dedicatedQueues === 0) {
|
|
143
|
+
getLogger()?.warn?.(
|
|
144
|
+
`[pikku] ${workflowCount} workflow(s) registered but no per-workflow orchestrator queues were found in queue meta. ` +
|
|
145
|
+
`All workflows will share a single orchestrator queue, where one slow step blocks every other workflow behind it. ` +
|
|
146
|
+
`Check that the generated bootstrap imports the queue-workers meta (pikku-queue-workers-wirings-meta.gen.js).`
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { Logger } from '../../services/logger.js'
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_STALLED_RUN_LIMIT,
|
|
4
|
+
DEFAULT_STALLED_RUN_MS,
|
|
5
|
+
DEFAULT_UNDISPATCHED_STEP_LIMIT,
|
|
6
|
+
DEFAULT_UNDISPATCHED_STEP_MS,
|
|
7
|
+
REDISPATCH_BACKOFF_MAX_ENTRIES,
|
|
8
|
+
REDISPATCH_BACKOFF_MAX_MS,
|
|
9
|
+
REDISPATCH_BACKOFF_MS,
|
|
10
|
+
} from './workflow-constants.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-process, advisory record of when a run may next be re-dispatched.
|
|
14
|
+
*
|
|
15
|
+
* Keyed by run rather than by step because the run is the unit of re-drive:
|
|
16
|
+
* `resumeWorkflow` replays the whole run and re-dispatches every step still
|
|
17
|
+
* owed a job, so holding off a single step while resuming its run would
|
|
18
|
+
* suppress nothing.
|
|
19
|
+
*
|
|
20
|
+
* Losing this on restart costs extra dispatches, never correctness.
|
|
21
|
+
*/
|
|
22
|
+
export class RedispatchBackoff {
|
|
23
|
+
private readonly eligibleAt = new Map<string, number>()
|
|
24
|
+
private readonly delays = new Map<string, number>()
|
|
25
|
+
|
|
26
|
+
public isEligible(runId: string, now: number): boolean {
|
|
27
|
+
const at = this.eligibleAt.get(runId)
|
|
28
|
+
return at === undefined || at <= now
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public note(runId: string, now: number): void {
|
|
32
|
+
const previous = this.delays.get(runId)
|
|
33
|
+
const delay = Math.min(
|
|
34
|
+
previous === undefined ? REDISPATCH_BACKOFF_MS : previous * 2,
|
|
35
|
+
REDISPATCH_BACKOFF_MAX_MS
|
|
36
|
+
)
|
|
37
|
+
// A run that settles is never returned again, so entries are only evicted
|
|
38
|
+
// by this bound — oldest first, which is also least recently re-dispatched.
|
|
39
|
+
if (this.eligibleAt.size >= REDISPATCH_BACKOFF_MAX_ENTRIES) {
|
|
40
|
+
const oldest = this.eligibleAt.keys().next()
|
|
41
|
+
if (!oldest.done) {
|
|
42
|
+
this.eligibleAt.delete(oldest.value)
|
|
43
|
+
this.delays.delete(oldest.value)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
this.delays.set(runId, delay)
|
|
47
|
+
this.eligibleAt.set(runId, now + delay)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type SweepDeps = {
|
|
52
|
+
resume: (runId: string) => Promise<void>
|
|
53
|
+
logger?: Logger
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const resumeEach = async (
|
|
57
|
+
runIds: Iterable<string>,
|
|
58
|
+
{ resume, logger }: SweepDeps,
|
|
59
|
+
failure: (runId: string, detail: string) => string
|
|
60
|
+
): Promise<string[]> => {
|
|
61
|
+
const succeeded: string[] = []
|
|
62
|
+
for (const runId of runIds) {
|
|
63
|
+
try {
|
|
64
|
+
await resume(runId)
|
|
65
|
+
succeeded.push(runId)
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// One unresumable run must not stop the sweep from recovering the rest.
|
|
68
|
+
logger?.error(
|
|
69
|
+
failure(runId, err instanceof Error ? err.message : String(err))
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return succeeded
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Re-drive runs whose next move was lost, and report which were resumed.
|
|
78
|
+
*
|
|
79
|
+
* Arming a step is two writes to two systems — the step row, then the queue or
|
|
80
|
+
* scheduler job — so a process that dies between them leaves a run that is
|
|
81
|
+
* `running` with nothing in flight. Nothing notices: the run parks on a step
|
|
82
|
+
* that will never complete and never error, so it neither finishes nor fails.
|
|
83
|
+
* (Seen on a `workflow.sleep()`: a deploy restart landed between the sleep
|
|
84
|
+
* step's insert and its timer, parking the run permanently.)
|
|
85
|
+
*
|
|
86
|
+
* Replay is the recovery — `resumeWorkflow` re-orchestrates from persisted step
|
|
87
|
+
* state, and every settled step is memoized, so resuming a run that was not
|
|
88
|
+
* actually stuck costs an orchestration pass and changes nothing. That
|
|
89
|
+
* idempotence is what makes an idle-time heuristic safe here; a run that is
|
|
90
|
+
* legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
|
|
91
|
+
*/
|
|
92
|
+
export const sweepStalledRuns = async (
|
|
93
|
+
findStalledRunIds: (before: Date, limit: number) => Promise<string[]>,
|
|
94
|
+
options: { stalledAfterMs?: number; limit?: number } | undefined,
|
|
95
|
+
deps: SweepDeps
|
|
96
|
+
): Promise<{ resumed: string[] }> => {
|
|
97
|
+
const before = new Date(
|
|
98
|
+
Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS)
|
|
99
|
+
)
|
|
100
|
+
const runIds = await findStalledRunIds(
|
|
101
|
+
before,
|
|
102
|
+
options?.limit ?? DEFAULT_STALLED_RUN_LIMIT
|
|
103
|
+
)
|
|
104
|
+
return {
|
|
105
|
+
resumed: await resumeEach(
|
|
106
|
+
runIds,
|
|
107
|
+
deps,
|
|
108
|
+
(runId, detail) =>
|
|
109
|
+
`Failed to resume stalled workflow run ${runId}: ${detail}`
|
|
110
|
+
),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Re-drive steps whose dispatch was lost, and report which runs were nudged.
|
|
116
|
+
*
|
|
117
|
+
* The step row is the outbox record and this is the relay. Age is the only
|
|
118
|
+
* signal available — a step `pending` because its dispatch was lost is
|
|
119
|
+
* indistinguishable from one whose job is merely still queued — so a step past
|
|
120
|
+
* `undispatchedAfterMs` is re-dispatched regardless, and correctness rests on
|
|
121
|
+
* the claim in `executeWorkflowStepInner` rather than on the guess being right.
|
|
122
|
+
* A redundant dispatch costs one queue message: the loser reads `running` and
|
|
123
|
+
* returns without invoking anything.
|
|
124
|
+
*
|
|
125
|
+
* Re-dispatches back off per run (doubling from 30s, capped at 10m) so a
|
|
126
|
+
* genuine queue backlog is not amplified by a tick that keeps firing at the
|
|
127
|
+
* steps the backlog is already delaying.
|
|
128
|
+
*/
|
|
129
|
+
export const sweepUndispatchedSteps = async (
|
|
130
|
+
findUndispatchedSteps: (
|
|
131
|
+
before: Date,
|
|
132
|
+
limit: number
|
|
133
|
+
) => Promise<Array<{ runId: string; stepId: string }>>,
|
|
134
|
+
backoff: RedispatchBackoff,
|
|
135
|
+
options: { undispatchedAfterMs?: number; limit?: number } | undefined,
|
|
136
|
+
deps: SweepDeps
|
|
137
|
+
): Promise<{ redispatched: string[] }> => {
|
|
138
|
+
const before = new Date(
|
|
139
|
+
Date.now() - (options?.undispatchedAfterMs ?? DEFAULT_UNDISPATCHED_STEP_MS)
|
|
140
|
+
)
|
|
141
|
+
const steps = await findUndispatchedSteps(
|
|
142
|
+
before,
|
|
143
|
+
options?.limit ?? DEFAULT_UNDISPATCHED_STEP_LIMIT
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const now = Date.now()
|
|
147
|
+
const runIds = new Set<string>()
|
|
148
|
+
for (const { runId } of steps) {
|
|
149
|
+
if (!backoff.isEligible(runId, now)) continue
|
|
150
|
+
backoff.note(runId, now)
|
|
151
|
+
runIds.add(runId)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
redispatched: await resumeEach(
|
|
156
|
+
runIds,
|
|
157
|
+
deps,
|
|
158
|
+
(runId, detail) =>
|
|
159
|
+
`Failed to re-dispatch workflow run ${runId}: ${detail}`
|
|
160
|
+
),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
|
|
4
4
|
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
5
|
import { pikkuState } from '../../pikku-state.js'
|
|
6
|
-
import { DEFAULT_STEP_RETRIES } from './
|
|
6
|
+
import { DEFAULT_STEP_RETRIES } from './workflow-constants.js'
|
|
7
7
|
import type { PikkuWorkflowService } from './pikku-workflow-service.js'
|
|
8
8
|
import { deriveInvocationId } from './workflow-invocation-id.js'
|
|
9
9
|
import type { WorkflowStepOptions } from './workflow.types.js'
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
|
+
import { pikkuState } from '../../pikku-state.js'
|
|
6
|
+
import { runWorkflowGraph } from './graph/graph-runner.js'
|
|
7
|
+
import { assertWorkflowRunOwner } from './workflow-run-ownership.js'
|
|
8
|
+
import { WorkflowStepFunctionMismatchError } from './workflow-errors.js'
|
|
9
|
+
import { ForbiddenError } from '../../errors/errors.js'
|
|
10
|
+
|
|
11
|
+
const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
12
|
+
|
|
13
|
+
const seedSingletons = () =>
|
|
14
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
15
|
+
logger: silentLogger,
|
|
16
|
+
// `resumeWorkflow` hands the run back to the orchestrator queue; the tests
|
|
17
|
+
// here assert on what happened before that hand-off.
|
|
18
|
+
queueService: { add: async () => 'job-1' },
|
|
19
|
+
} as any)
|
|
20
|
+
|
|
21
|
+
describe('a graph run starts at an entry node the graph declared', () => {
|
|
22
|
+
const meta = {
|
|
23
|
+
name: 'payout',
|
|
24
|
+
pikkuFuncId: 'payout',
|
|
25
|
+
source: 'graph' as const,
|
|
26
|
+
entryNodeIds: ['validate'],
|
|
27
|
+
graphHash: 'payout-hash',
|
|
28
|
+
nodes: {
|
|
29
|
+
validate: { nodeId: 'validate', rpcName: 'validatePayout' },
|
|
30
|
+
// Reachable only after validation, but its input is a literal — so
|
|
31
|
+
// `areDependenciesSatisfied` alone would happily start the run here.
|
|
32
|
+
transfer: {
|
|
33
|
+
nodeId: 'transfer',
|
|
34
|
+
rpcName: 'transferFunds',
|
|
35
|
+
input: { amount: 1000 },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('a startNode outside entryNodeIds is rejected', async () => {
|
|
41
|
+
seedSingletons()
|
|
42
|
+
const ws = new InMemoryWorkflowService()
|
|
43
|
+
|
|
44
|
+
await assert.rejects(
|
|
45
|
+
runWorkflowGraph(
|
|
46
|
+
ws,
|
|
47
|
+
'payout',
|
|
48
|
+
{},
|
|
49
|
+
{ rpcWithWire: async () => ({}) },
|
|
50
|
+
true,
|
|
51
|
+
'transfer',
|
|
52
|
+
undefined,
|
|
53
|
+
meta
|
|
54
|
+
),
|
|
55
|
+
/'transfer' is not an entry node/
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('a declared entry node still starts the run', async () => {
|
|
60
|
+
seedSingletons()
|
|
61
|
+
const ws = new InMemoryWorkflowService()
|
|
62
|
+
|
|
63
|
+
const { runId } = await runWorkflowGraph(
|
|
64
|
+
ws,
|
|
65
|
+
'payout',
|
|
66
|
+
{},
|
|
67
|
+
{ rpcWithWire: async () => ({}) },
|
|
68
|
+
true,
|
|
69
|
+
'validate',
|
|
70
|
+
undefined,
|
|
71
|
+
meta
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert.ok(runId, 'the graph ran from the entry node it declared')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('a step runs the function the workflow dispatched it with', () => {
|
|
79
|
+
test('a queue message naming another function is rejected', async () => {
|
|
80
|
+
seedSingletons()
|
|
81
|
+
const ws = new InMemoryWorkflowService()
|
|
82
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
83
|
+
type: 'queue',
|
|
84
|
+
})
|
|
85
|
+
await ws.insertStepState(runId, 'notify', 'sendReceipt', {})
|
|
86
|
+
|
|
87
|
+
const invoked: string[] = []
|
|
88
|
+
await assert.rejects(
|
|
89
|
+
ws.executeWorkflowStep(runId, 'notify', 'deleteAllUsers', {}, {
|
|
90
|
+
rpcWithWire: async (rpcName: string) => {
|
|
91
|
+
invoked.push(rpcName)
|
|
92
|
+
return {}
|
|
93
|
+
},
|
|
94
|
+
} as any),
|
|
95
|
+
WorkflowStepFunctionMismatchError
|
|
96
|
+
)
|
|
97
|
+
assert.deepEqual(invoked, [], 'nothing was invoked')
|
|
98
|
+
|
|
99
|
+
const stepState = await ws.getStepState(runId, 'notify')
|
|
100
|
+
assert.equal(
|
|
101
|
+
stepState.status,
|
|
102
|
+
'pending',
|
|
103
|
+
'the forged message left the step untouched'
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('the dispatched function still runs', async () => {
|
|
108
|
+
seedSingletons()
|
|
109
|
+
const ws = new InMemoryWorkflowService()
|
|
110
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
111
|
+
type: 'queue',
|
|
112
|
+
})
|
|
113
|
+
await ws.insertStepState(runId, 'notify', 'sendReceipt', {})
|
|
114
|
+
|
|
115
|
+
const invoked: string[] = []
|
|
116
|
+
await ws.executeWorkflowStep(runId, 'notify', 'sendReceipt', {}, {
|
|
117
|
+
rpcWithWire: async (rpcName: string) => {
|
|
118
|
+
invoked.push(rpcName)
|
|
119
|
+
return {}
|
|
120
|
+
},
|
|
121
|
+
} as any)
|
|
122
|
+
|
|
123
|
+
assert.deepEqual(invoked, ['sendReceipt'])
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('workflow run ownership', () => {
|
|
128
|
+
test('a run started by a session is readable only by that session', () => {
|
|
129
|
+
assert.throws(
|
|
130
|
+
() =>
|
|
131
|
+
assertWorkflowRunOwner(
|
|
132
|
+
{ type: 'http', pikkuUserId: 'user-a' },
|
|
133
|
+
{
|
|
134
|
+
userId: 'user-b',
|
|
135
|
+
}
|
|
136
|
+
),
|
|
137
|
+
ForbiddenError
|
|
138
|
+
)
|
|
139
|
+
assert.throws(
|
|
140
|
+
() =>
|
|
141
|
+
assertWorkflowRunOwner(
|
|
142
|
+
{ type: 'http', pikkuUserId: 'user-a' },
|
|
143
|
+
undefined
|
|
144
|
+
),
|
|
145
|
+
ForbiddenError
|
|
146
|
+
)
|
|
147
|
+
assert.doesNotThrow(() =>
|
|
148
|
+
assertWorkflowRunOwner(
|
|
149
|
+
{ type: 'http', pikkuUserId: 'user-a' },
|
|
150
|
+
{
|
|
151
|
+
userId: 'user-a',
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('a run with no recorded owner has no ownership to enforce', () => {
|
|
158
|
+
assert.doesNotThrow(() =>
|
|
159
|
+
assertWorkflowRunOwner({ type: 'trigger' }, { userId: 'user-b' })
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('approveStep refuses a caller who does not own the run', async () => {
|
|
164
|
+
seedSingletons()
|
|
165
|
+
const ws = new InMemoryWorkflowService()
|
|
166
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
167
|
+
type: 'http',
|
|
168
|
+
pikkuUserId: 'owner',
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
await assert.rejects(
|
|
172
|
+
ws.approveStep(
|
|
173
|
+
runId,
|
|
174
|
+
'release-funds',
|
|
175
|
+
{ ok: true },
|
|
176
|
+
{
|
|
177
|
+
userId: 'attacker',
|
|
178
|
+
}
|
|
179
|
+
),
|
|
180
|
+
ForbiddenError
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
const state = await ws.getRunState(runId)
|
|
184
|
+
assert.deepEqual(
|
|
185
|
+
Object.keys(state),
|
|
186
|
+
[],
|
|
187
|
+
'no decision was recorded for the run'
|
|
188
|
+
)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('approveStep accepts the run owner', async () => {
|
|
192
|
+
seedSingletons()
|
|
193
|
+
const ws = new InMemoryWorkflowService()
|
|
194
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
195
|
+
type: 'http',
|
|
196
|
+
pikkuUserId: 'owner',
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
await ws.approveStep(
|
|
200
|
+
runId,
|
|
201
|
+
'release-funds',
|
|
202
|
+
{ ok: true },
|
|
203
|
+
{
|
|
204
|
+
userId: 'owner',
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const state = await ws.getRunState(runId)
|
|
209
|
+
assert.equal(
|
|
210
|
+
Object.keys(state).length,
|
|
211
|
+
1,
|
|
212
|
+
'the owner’s decision was recorded'
|
|
213
|
+
)
|
|
214
|
+
})
|
|
215
|
+
})
|