@pikku/core 0.12.63 → 0.12.66
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 +368 -0
- package/dist/dev/hot-reload.js +13 -55
- package/dist/dev/module-runner.d.ts +14 -0
- package/dist/dev/module-runner.js +59 -0
- package/dist/dev/reload-meta.d.ts +13 -0
- package/dist/dev/reload-meta.js +22 -0
- package/dist/errors/errors.d.ts +12 -0
- package/dist/errors/errors.js +19 -0
- package/dist/function/function-runner.d.ts +5 -5
- package/dist/function/function-runner.js +39 -111
- package/dist/function/functions.types.d.ts +17 -7
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/middleware/auth-apikey.d.ts +1 -0
- package/dist/middleware/auth-bearer.d.ts +1 -0
- package/dist/middleware/auth-cookie.d.ts +1 -0
- package/dist/middleware/cors.d.ts +1 -0
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/remote-auth.d.ts +1 -0
- package/dist/middleware/telemetry.d.ts +2 -0
- package/dist/middleware/timeout.d.ts +1 -0
- package/dist/permissions.d.ts +42 -27
- package/dist/permissions.js +101 -203
- package/dist/pikku-state.js +0 -2
- package/dist/scopes.d.ts +17 -0
- package/dist/scopes.js +59 -0
- package/dist/services/ai-embedding-service.d.ts +31 -0
- package/dist/services/ai-embedding-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +3 -1
- package/dist/services/credential-wire-service.js +10 -4
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meta-service.d.ts +15 -2
- package/dist/services/meta-service.js +51 -1
- package/dist/services/queue-webhook-service.d.ts +55 -0
- package/dist/services/queue-webhook-service.js +136 -0
- package/dist/services/scope-service.d.ts +67 -0
- package/dist/services/scope-service.js +1 -0
- package/dist/services/typed-secret-service.d.ts +8 -0
- package/dist/services/typed-secret-service.js +39 -4
- package/dist/services/webhook-service.d.ts +140 -0
- package/dist/services/webhook-service.js +44 -0
- package/dist/services/workflow-service.d.ts +7 -0
- package/dist/testing/service-tests.js +37 -0
- package/dist/types/core.types.d.ts +34 -6
- package/dist/types/state.types.d.ts +20 -2
- package/dist/utils/hmac.d.ts +16 -0
- package/dist/utils/hmac.js +26 -0
- package/dist/utils/safe-fetch.d.ts +51 -0
- package/dist/utils/safe-fetch.js +192 -0
- package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +105 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +249 -11
- package/dist/wirings/ai-agent/ai-agent-runner.js +10 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +88 -16
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +56 -2
- package/dist/wirings/ai-agent/index.d.ts +2 -1
- package/dist/wirings/ai-agent/index.js +2 -1
- package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
- package/dist/wirings/ai-agent/voice-input.js +5 -41
- package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
- package/dist/wirings/channel/channel-common.js +0 -1
- package/dist/wirings/channel/channel-handler.js +1 -4
- package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
- package/dist/wirings/channel/channel-middleware-runner.js +34 -19
- package/dist/wirings/channel/channel.types.d.ts +2 -6
- package/dist/wirings/cli/cli-runner.js +3 -2
- package/dist/wirings/cli/cli.types.d.ts +1 -2
- package/dist/wirings/cli/command-parser.d.ts +2 -0
- package/dist/wirings/cli/command-parser.js +59 -2
- package/dist/wirings/credential/credential.types.d.ts +14 -0
- package/dist/wirings/credential/validate-credential-definitions.js +1 -0
- package/dist/wirings/gateway/gateway-runner.js +100 -50
- package/dist/wirings/gateway/gateway.types.d.ts +8 -5
- package/dist/wirings/http/http-routes.js +0 -3
- package/dist/wirings/http/http-runner.d.ts +9 -29
- package/dist/wirings/http/http-runner.js +9 -34
- package/dist/wirings/http/http.types.d.ts +4 -12
- package/dist/wirings/mcp/mcp-runner.js +0 -2
- package/dist/wirings/mcp/mcp.types.d.ts +5 -11
- package/dist/wirings/oauth2/index.d.ts +0 -3
- package/dist/wirings/oauth2/index.js +1 -2
- package/dist/wirings/rpc/addon-runner.d.ts +28 -0
- package/dist/wirings/rpc/addon-runner.js +173 -0
- package/dist/wirings/rpc/index.d.ts +5 -1
- package/dist/wirings/rpc/index.js +3 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
- package/dist/wirings/rpc/remote-addon-auth.js +43 -0
- package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
- package/dist/wirings/rpc/rpc-runner.js +109 -3
- package/dist/wirings/rpc/rpc-types.d.ts +15 -1
- package/dist/wirings/rpc/wire-addon.js +9 -0
- package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
- package/dist/wirings/rpc/wire-remote-addon.js +19 -0
- package/dist/wirings/scope/index.d.ts +3 -0
- package/dist/wirings/scope/index.js +2 -0
- package/dist/wirings/scope/scope.types.d.ts +39 -0
- package/dist/wirings/scope/scope.types.js +1 -0
- package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
- package/dist/wirings/scope/validate-scope-definitions.js +76 -0
- package/dist/wirings/scope/wire-scope.d.ts +33 -0
- package/dist/wirings/scope/wire-scope.js +32 -0
- package/dist/wirings/secret/secret.types.d.ts +14 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/variable/validate-variable-definitions.js +2 -0
- package/dist/wirings/variable/variable.types.d.ts +14 -0
- package/dist/wirings/workflow/dsl/index.d.ts +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +89 -7
- package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +3 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
- package/dist/wirings/workflow/index.d.ts +2 -2
- package/dist/wirings/workflow/index.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +59 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +187 -0
- package/dist/wirings/workflow/workflow.types.d.ts +1 -3
- package/package.json +6 -1
- package/run-tests.sh +1 -0
- package/src/dev/hot-reload.ts +13 -68
- package/src/dev/module-runner.test.ts +169 -0
- package/src/dev/module-runner.ts +103 -0
- package/src/dev/reload-meta.test.ts +31 -2
- package/src/dev/reload-meta.ts +26 -0
- package/src/errors/errors.ts +24 -0
- package/src/function/function-runner.test.ts +276 -142
- package/src/function/function-runner.ts +52 -145
- package/src/function/functions.types.ts +28 -38
- package/src/index.ts +8 -6
- package/src/middleware/index.ts +1 -5
- package/src/permissions.test.ts +160 -373
- package/src/permissions.ts +137 -279
- package/src/pikku-state.ts +0 -2
- package/src/scopes.test.ts +167 -0
- package/src/scopes.ts +69 -0
- package/src/services/ai-embedding-service.ts +31 -0
- package/src/services/credential-wire-service.ts +10 -4
- package/src/services/index.ts +20 -0
- package/src/services/meta-service.ts +68 -3
- package/src/services/queue-webhook-service.test.ts +408 -0
- package/src/services/queue-webhook-service.ts +182 -0
- package/src/services/scope-service.ts +75 -0
- package/src/services/typed-secret-service.test.ts +35 -0
- package/src/services/typed-secret-service.ts +39 -4
- package/src/services/webhook-service.ts +180 -0
- package/src/services/workflow-service.ts +7 -0
- package/src/testing/service-tests.ts +49 -0
- package/src/types/core.types.ts +42 -14
- package/src/types/state.types.ts +23 -3
- package/src/utils/hmac.ts +27 -0
- package/src/utils/safe-fetch.test.ts +373 -0
- package/src/utils/safe-fetch.ts +213 -0
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
- package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +483 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +324 -13
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +28 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +301 -10
- package/src/wirings/ai-agent/ai-agent-stream.ts +122 -16
- package/src/wirings/ai-agent/ai-agent.types.ts +57 -0
- package/src/wirings/ai-agent/index.ts +5 -0
- package/src/wirings/ai-agent/voice-input.test.ts +90 -0
- package/src/wirings/ai-agent/voice-input.ts +9 -40
- package/src/wirings/channel/channel-common.ts +0 -1
- package/src/wirings/channel/channel-handler.ts +1 -9
- package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
- package/src/wirings/channel/channel-middleware-runner.ts +39 -27
- package/src/wirings/channel/channel.types.ts +0 -6
- package/src/wirings/cli/cli-runner.ts +4 -2
- package/src/wirings/cli/cli.types.ts +0 -2
- package/src/wirings/cli/command-parser.test.ts +130 -0
- package/src/wirings/cli/command-parser.ts +80 -2
- package/src/wirings/credential/credential.types.ts +14 -0
- package/src/wirings/credential/validate-credential-definitions.ts +1 -0
- package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
- package/src/wirings/gateway/gateway-runner.ts +114 -68
- package/src/wirings/gateway/gateway.types.ts +7 -9
- package/src/wirings/http/http-routes.ts +0 -3
- package/src/wirings/http/http-runner.test.ts +1 -11
- package/src/wirings/http/http-runner.ts +16 -39
- package/src/wirings/http/http.types.ts +7 -14
- package/src/wirings/mcp/mcp-runner.ts +0 -2
- package/src/wirings/mcp/mcp.types.ts +3 -11
- package/src/wirings/oauth2/index.ts +0 -3
- package/src/wirings/rpc/addon-runner.ts +233 -0
- package/src/wirings/rpc/index.ts +17 -1
- package/src/wirings/rpc/remote-addon-auth.ts +69 -0
- package/src/wirings/rpc/rpc-runner.test.ts +319 -16
- package/src/wirings/rpc/rpc-runner.ts +155 -3
- package/src/wirings/rpc/rpc-types.ts +16 -5
- package/src/wirings/rpc/wire-addon.test.ts +6 -3
- package/src/wirings/rpc/wire-addon.ts +9 -0
- package/src/wirings/rpc/wire-remote-addon.ts +57 -0
- package/src/wirings/scope/index.ts +14 -0
- package/src/wirings/scope/scope.test.ts +135 -0
- package/src/wirings/scope/scope.types.ts +44 -0
- package/src/wirings/scope/validate-scope-definitions.ts +110 -0
- package/src/wirings/scope/wire-scope.ts +34 -0
- package/src/wirings/secret/secret.types.ts +14 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/variable/validate-variable-definitions.ts +2 -0
- package/src/wirings/variable/variable.types.ts +14 -0
- package/src/wirings/workflow/dsl/index.ts +4 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +97 -6
- package/src/wirings/workflow/graph/graph-node.ts +2 -0
- package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
- package/src/wirings/workflow/graph/graph-runner.ts +3 -0
- package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
- package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
- package/src/wirings/workflow/index.ts +5 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +272 -0
- package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
- package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
- package/src/wirings/workflow/workflow.types.ts +4 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
- package/src/wirings/oauth2/oauth2-client.ts +0 -331
- package/src/wirings/oauth2/oauth2-routes.ts +0 -234
|
@@ -48,9 +48,11 @@ import type { PikkuWire, SerializedError } from '../../types/core.types.js'
|
|
|
48
48
|
import type { QueueService } from '../queue/queue.types.js'
|
|
49
49
|
import { runScheduledTask } from '../scheduler/scheduler-runner.js'
|
|
50
50
|
import type {
|
|
51
|
+
ApprovalOutcome,
|
|
51
52
|
PikkuScenarioWire,
|
|
52
53
|
StepState,
|
|
53
54
|
StepStatus,
|
|
55
|
+
WorkflowApprovalOptions,
|
|
54
56
|
WorkflowPlannedStep,
|
|
55
57
|
WorkflowRun,
|
|
56
58
|
WorkflowRunMirror,
|
|
@@ -203,6 +205,27 @@ addError(WorkflowRunCancelledError, {
|
|
|
203
205
|
message: 'Workflow was cancelled.',
|
|
204
206
|
})
|
|
205
207
|
|
|
208
|
+
/**
|
|
209
|
+
* A decision arrived for an approval gate that has already resolved. The gate
|
|
210
|
+
* caches its outcome as the step result and never re-reads run state, so the
|
|
211
|
+
* decision could not take effect — it is rejected rather than accepted and
|
|
212
|
+
* dropped.
|
|
213
|
+
*/
|
|
214
|
+
export class WorkflowApprovalResolvedError extends PikkuError {
|
|
215
|
+
public payload: {
|
|
216
|
+
reason: string
|
|
217
|
+
outcome: ApprovalOutcome<unknown>['status']
|
|
218
|
+
}
|
|
219
|
+
constructor(reason: string, outcome: ApprovalOutcome<unknown>['status']) {
|
|
220
|
+
super(`Approval already ${outcome}: ${reason}`)
|
|
221
|
+
this.payload = { reason, outcome }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
addError(WorkflowApprovalResolvedError, {
|
|
225
|
+
status: 409,
|
|
226
|
+
message: 'Approval has already been resolved.',
|
|
227
|
+
})
|
|
228
|
+
|
|
206
229
|
export class WorkflowServiceNotInitialized extends Error {}
|
|
207
230
|
export class WorkflowStepNameNotString extends Error {
|
|
208
231
|
constructor(stepName: any) {
|
|
@@ -1802,6 +1825,30 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1802
1825
|
}
|
|
1803
1826
|
}
|
|
1804
1827
|
|
|
1828
|
+
/**
|
|
1829
|
+
* Run a failed step's compensation handler as a durable step of its own.
|
|
1830
|
+
*
|
|
1831
|
+
* Durable rather than a bare invoke so a replay does not compensate twice —
|
|
1832
|
+
* a handler is typically a refund or a rollback. `onError` is deliberately
|
|
1833
|
+
* not forwarded: a compensation handler cannot itself compensate.
|
|
1834
|
+
*/
|
|
1835
|
+
private async runStepCompensation(
|
|
1836
|
+
runId: string,
|
|
1837
|
+
stepName: string,
|
|
1838
|
+
onErrorRpcName: string,
|
|
1839
|
+
rpcService: any,
|
|
1840
|
+
error: Error
|
|
1841
|
+
): Promise<void> {
|
|
1842
|
+
await this.rpcStep(
|
|
1843
|
+
runId,
|
|
1844
|
+
`${stepName}:onError`,
|
|
1845
|
+
onErrorRpcName,
|
|
1846
|
+
{ error: { message: error.message } },
|
|
1847
|
+
rpcService,
|
|
1848
|
+
{ retries: 0 }
|
|
1849
|
+
)
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1805
1852
|
private async rpcStep(
|
|
1806
1853
|
runId: string,
|
|
1807
1854
|
logicalStepName: string,
|
|
@@ -1821,6 +1868,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1821
1868
|
retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
|
|
1822
1869
|
retryDelay: stepOptions?.retryDelay,
|
|
1823
1870
|
actor: stepOptions?.actor,
|
|
1871
|
+
onError: stepOptions?.onError,
|
|
1824
1872
|
}
|
|
1825
1873
|
// Check if step already exists
|
|
1826
1874
|
let stepState: StepState
|
|
@@ -1849,6 +1897,17 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1849
1897
|
stepState.error?.message ||
|
|
1850
1898
|
`Step '${stepName}' failed after exhausting all retries`
|
|
1851
1899
|
)
|
|
1900
|
+
// Compensation, mirroring a graph node's onError: run the handler, then
|
|
1901
|
+
// still throw — the workflow fails either way.
|
|
1902
|
+
if (resolvedStepOptions.onError) {
|
|
1903
|
+
await this.runStepCompensation(
|
|
1904
|
+
runId,
|
|
1905
|
+
stepName,
|
|
1906
|
+
resolvedStepOptions.onError,
|
|
1907
|
+
rpcService,
|
|
1908
|
+
error
|
|
1909
|
+
)
|
|
1910
|
+
}
|
|
1852
1911
|
// Preserve original error properties if available
|
|
1853
1912
|
if (stepState.error) {
|
|
1854
1913
|
Object.assign(error, stepState.error)
|
|
@@ -2148,6 +2207,214 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
2148
2207
|
})
|
|
2149
2208
|
}
|
|
2150
2209
|
|
|
2210
|
+
/**
|
|
2211
|
+
* Wake a run later by enqueuing a delayed orchestrator pass. Deliberately NOT
|
|
2212
|
+
* {@link scheduleSleep}: that resolves the step it is given, which for an
|
|
2213
|
+
* approval would resolve the gate itself. This only nudges the run to replay
|
|
2214
|
+
* and re-evaluate — the gate stays the sole judge of its own outcome.
|
|
2215
|
+
*
|
|
2216
|
+
* Best-effort by design. Expiry is decided from the recorded deadline on
|
|
2217
|
+
* replay, so losing this wake costs liveness (the run sits until something
|
|
2218
|
+
* else resumes it), never correctness.
|
|
2219
|
+
*/
|
|
2220
|
+
private async scheduleRunWake(runId: string, delay: number): Promise<void> {
|
|
2221
|
+
try {
|
|
2222
|
+
const queueService = this.verifyQueueService()
|
|
2223
|
+
const run = await this.getRun(runId)
|
|
2224
|
+
if (!run?.workflow) return
|
|
2225
|
+
await queueService.add(
|
|
2226
|
+
this.getOrchestratorQueueName(run.workflow),
|
|
2227
|
+
{ runId },
|
|
2228
|
+
{ ...this.resolveStepJobOptions(), delay }
|
|
2229
|
+
)
|
|
2230
|
+
} catch (error) {
|
|
2231
|
+
this.logger?.warn(
|
|
2232
|
+
`Failed to schedule approval expiry wake for run ${runId}; expiry will still resolve on the next replay`,
|
|
2233
|
+
error
|
|
2234
|
+
)
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
/**
|
|
2239
|
+
* Durable step name for an approval gate. Namespaced separately from suspend
|
|
2240
|
+
* so the two can't collide, and derived from `reason` for the same reason
|
|
2241
|
+
* {@link getSuspendStepName} is: it must be stable across replays.
|
|
2242
|
+
*/
|
|
2243
|
+
private getApprovalStepName(reason: string): string {
|
|
2244
|
+
return `__workflow_approval:${reason}`
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
/**
|
|
2248
|
+
* Run-state key holding an approval gate's record. Hex-encoded because the
|
|
2249
|
+
* Mongo backend restricts state keys to `/^[a-zA-Z0-9_]+$/` and a `reason` is
|
|
2250
|
+
* arbitrary human text. One key per gate, so two gates resolving concurrently
|
|
2251
|
+
* can't clobber each other through a read-modify-write.
|
|
2252
|
+
*/
|
|
2253
|
+
private approvalStateKey(stepName: string): string {
|
|
2254
|
+
let hex = ''
|
|
2255
|
+
for (const byte of new TextEncoder().encode(stepName)) {
|
|
2256
|
+
hex += byte.toString(16).padStart(2, '0')
|
|
2257
|
+
}
|
|
2258
|
+
return `__approval_${hex}`
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
/**
|
|
2262
|
+
* Record a decision against an approval gate and wake the run. Called from
|
|
2263
|
+
* outside the workflow (an HTTP route, an RPC), so the schema value is NOT in
|
|
2264
|
+
* scope here — the payload is stored raw and validated on replay inside the
|
|
2265
|
+
* workflow body, which is the only place the schema exists. An invalid payload
|
|
2266
|
+
* therefore leaves the gate closed rather than failing the run.
|
|
2267
|
+
*
|
|
2268
|
+
* `reason` addresses the first reach of that gate. An approval reached more
|
|
2269
|
+
* than once under the same reason (e.g. in a loop) gets `#N`-suffixed step
|
|
2270
|
+
* rows that this cannot currently target.
|
|
2271
|
+
*/
|
|
2272
|
+
public async approveStep(
|
|
2273
|
+
runId: string,
|
|
2274
|
+
reason: string,
|
|
2275
|
+
decision: unknown
|
|
2276
|
+
): Promise<void> {
|
|
2277
|
+
const stepName = this.getApprovalStepName(reason)
|
|
2278
|
+
const stateKey = this.approvalStateKey(stepName)
|
|
2279
|
+
|
|
2280
|
+
// A resolved gate returns its cached step result and never re-reads state,
|
|
2281
|
+
// so a decision recorded now would be silently discarded — most obviously
|
|
2282
|
+
// when it loses the race with expiry. Reject instead, so the approver
|
|
2283
|
+
// learns their decision did not land.
|
|
2284
|
+
let resolved: StepState | undefined
|
|
2285
|
+
try {
|
|
2286
|
+
resolved = await this.getStepState(runId, stepName)
|
|
2287
|
+
} catch {
|
|
2288
|
+
// No step row yet: the run has not reached the gate. Recording a decision
|
|
2289
|
+
// ahead of it is legitimate — the gate picks it up on arrival.
|
|
2290
|
+
}
|
|
2291
|
+
if (resolved?.stepId && resolved.status === 'succeeded') {
|
|
2292
|
+
const outcome = resolved.result as ApprovalOutcome<unknown> | undefined
|
|
2293
|
+
throw new WorkflowApprovalResolvedError(
|
|
2294
|
+
reason,
|
|
2295
|
+
outcome?.status ?? 'decided'
|
|
2296
|
+
)
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
const state = await this.getRunState(runId)
|
|
2300
|
+
const record = (state[stateKey] ?? {}) as Record<string, unknown>
|
|
2301
|
+
await this.updateRunState(runId, stateKey, {
|
|
2302
|
+
...record,
|
|
2303
|
+
decision,
|
|
2304
|
+
decidedAt: new Date().toISOString(),
|
|
2305
|
+
error: undefined,
|
|
2306
|
+
})
|
|
2307
|
+
await this.resumeWorkflow(runId)
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
private async approvalStep(
|
|
2311
|
+
runId: string,
|
|
2312
|
+
reason: string,
|
|
2313
|
+
options: WorkflowApprovalOptions
|
|
2314
|
+
): Promise<ApprovalOutcome<unknown>> {
|
|
2315
|
+
const fromStepName = this.lastStepName(runId)
|
|
2316
|
+
const approvalStepName = this.nextStepKey(
|
|
2317
|
+
runId,
|
|
2318
|
+
this.getApprovalStepName(reason)
|
|
2319
|
+
)
|
|
2320
|
+
return await this.withStepLock(runId, approvalStepName, async () => {
|
|
2321
|
+
let stepState: StepState
|
|
2322
|
+
try {
|
|
2323
|
+
stepState = await this.getStepState(runId, approvalStepName)
|
|
2324
|
+
} catch {
|
|
2325
|
+
stepState = await this.insertStepState(
|
|
2326
|
+
runId,
|
|
2327
|
+
approvalStepName,
|
|
2328
|
+
'pikkuWorkflowApproval',
|
|
2329
|
+
{ reason, expiry: options.expiry },
|
|
2330
|
+
undefined,
|
|
2331
|
+
fromStepName
|
|
2332
|
+
)
|
|
2333
|
+
}
|
|
2334
|
+
if (!stepState.stepId) {
|
|
2335
|
+
stepState = await this.insertStepState(
|
|
2336
|
+
runId,
|
|
2337
|
+
approvalStepName,
|
|
2338
|
+
'pikkuWorkflowApproval',
|
|
2339
|
+
{ reason, expiry: options.expiry },
|
|
2340
|
+
undefined,
|
|
2341
|
+
fromStepName
|
|
2342
|
+
)
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// Unlike suspend, `succeeded` here means a decision (or expiry) was
|
|
2346
|
+
// actually resolved, and the step result IS the return channel.
|
|
2347
|
+
if (stepState.status === 'succeeded') {
|
|
2348
|
+
return stepState.result as ApprovalOutcome<unknown>
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
const stateKey = this.approvalStateKey(approvalStepName)
|
|
2352
|
+
let record = ((await this.getRunState(runId))[stateKey] ?? {}) as {
|
|
2353
|
+
decision?: unknown
|
|
2354
|
+
decidedAt?: string
|
|
2355
|
+
expiresAt?: string
|
|
2356
|
+
error?: unknown
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
if (stepState.status === 'pending') {
|
|
2360
|
+
await this.setStepRunning(stepState.stepId)
|
|
2361
|
+
// First reach: stamp the deadline and nudge the run awake when it
|
|
2362
|
+
// passes. The deadline is what's authoritative — see below.
|
|
2363
|
+
if (options.expiry !== undefined && !record.expiresAt) {
|
|
2364
|
+
const expiresAt = new Date(
|
|
2365
|
+
Date.now() + getDurationInMilliseconds(options.expiry)
|
|
2366
|
+
).toISOString()
|
|
2367
|
+
record = { ...record, expiresAt }
|
|
2368
|
+
await this.updateRunState(runId, stateKey, record)
|
|
2369
|
+
await this.scheduleRunWake(
|
|
2370
|
+
runId,
|
|
2371
|
+
getDurationInMilliseconds(options.expiry)
|
|
2372
|
+
)
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
if (record.decision !== undefined) {
|
|
2377
|
+
const validation = await options.schema['~standard'].validate(
|
|
2378
|
+
record.decision
|
|
2379
|
+
)
|
|
2380
|
+
if (validation.issues) {
|
|
2381
|
+
// Drop the bad decision and re-close the gate, leaving the failure
|
|
2382
|
+
// legible to whoever tries next. Failing the run instead would let any
|
|
2383
|
+
// caller kill a workflow with a malformed payload.
|
|
2384
|
+
await this.updateRunState(runId, stateKey, {
|
|
2385
|
+
...record,
|
|
2386
|
+
decision: undefined,
|
|
2387
|
+
decidedAt: undefined,
|
|
2388
|
+
error: validation.issues.map((issue) => ({
|
|
2389
|
+
message: issue.message,
|
|
2390
|
+
path: issue.path?.map((segment) =>
|
|
2391
|
+
typeof segment === 'object' ? segment.key : segment
|
|
2392
|
+
),
|
|
2393
|
+
})),
|
|
2394
|
+
})
|
|
2395
|
+
throw new WorkflowSuspendedException(runId, reason)
|
|
2396
|
+
}
|
|
2397
|
+
const outcome: ApprovalOutcome<unknown> = {
|
|
2398
|
+
status: 'decided',
|
|
2399
|
+
data: validation.value,
|
|
2400
|
+
}
|
|
2401
|
+
await this.setStepResult(stepState.stepId, outcome)
|
|
2402
|
+
return outcome
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// Expiry is decided by comparing against the recorded deadline rather than
|
|
2406
|
+
// by the timer having fired, so a duplicate, late, or dropped timer all
|
|
2407
|
+
// produce the same answer.
|
|
2408
|
+
if (record.expiresAt && Date.now() >= Date.parse(record.expiresAt)) {
|
|
2409
|
+
const outcome: ApprovalOutcome<unknown> = { status: 'expired' }
|
|
2410
|
+
await this.setStepResult(stepState.stepId, outcome)
|
|
2411
|
+
return outcome
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
throw new WorkflowSuspendedException(runId, reason)
|
|
2415
|
+
})
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2151
2418
|
public createWorkflowWire(
|
|
2152
2419
|
name: string,
|
|
2153
2420
|
runId: string,
|
|
@@ -2345,6 +2612,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
2345
2612
|
await this.suspendStep(runId, reason)
|
|
2346
2613
|
},
|
|
2347
2614
|
|
|
2615
|
+
approval: (async (reason: string, options: WorkflowApprovalOptions) => {
|
|
2616
|
+
this.verifyStepName(reason)
|
|
2617
|
+
return await this.approvalStep(runId, reason, options)
|
|
2618
|
+
}) as PikkuScenarioWire['approval'],
|
|
2619
|
+
|
|
2348
2620
|
runScheduledTask: async (taskName: string) => {
|
|
2349
2621
|
await runScheduledTask({ name: taskName })
|
|
2350
2622
|
},
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drive the real failure branch: a step that is already terminally failed must
|
|
8
|
+
* invoke its onError handler and still throw. Only the nested compensation
|
|
9
|
+
* call is intercepted, so the branch under test is the production one.
|
|
10
|
+
*/
|
|
11
|
+
class TestWorkflowService extends InMemoryWorkflowService {
|
|
12
|
+
public compensations: Array<{ rpcName: string; data: any }> = []
|
|
13
|
+
public dispatches = 0
|
|
14
|
+
private pretendDispatchSucceeds = false
|
|
15
|
+
|
|
16
|
+
public alwaysDispatch() {
|
|
17
|
+
this.pretendDispatchSucceeds = true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected override async dispatchStep(...args: any[]): Promise<boolean> {
|
|
21
|
+
this.dispatches++
|
|
22
|
+
if (this.pretendDispatchSucceeds) {
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
return (super.dispatchStep as any)(...args)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public async callFailedStep(
|
|
29
|
+
runId: string,
|
|
30
|
+
stepName: string,
|
|
31
|
+
onError?: string
|
|
32
|
+
) {
|
|
33
|
+
const self = this as any
|
|
34
|
+
const realRpcStep = self.rpcStep.bind(self)
|
|
35
|
+
let depth = 0
|
|
36
|
+
self.rpcStep = async (...args: any[]) => {
|
|
37
|
+
if (depth++ > 0) {
|
|
38
|
+
this.compensations.push({ rpcName: args[2], data: args[3] })
|
|
39
|
+
return { compensated: true }
|
|
40
|
+
}
|
|
41
|
+
return realRpcStep(...args)
|
|
42
|
+
}
|
|
43
|
+
return self.rpcStep(runId, stepName, 'chargeCard', {}, {}, { onError })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Seed a step in a non-terminal state, as a replay would find one that is
|
|
49
|
+
* waiting on the queue rather than one that has failed.
|
|
50
|
+
*/
|
|
51
|
+
async function seedPendingStep(ws: TestWorkflowService, stepName: string) {
|
|
52
|
+
const runId = await ws.createRun('wf', {}, true, 'hash', {
|
|
53
|
+
type: 'inline',
|
|
54
|
+
} as any)
|
|
55
|
+
const step = await ws.insertStepState(runId, stepName, 'chargeCard', {})
|
|
56
|
+
return { runId, step }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function seedFailedStep(ws: TestWorkflowService, stepName: string) {
|
|
60
|
+
const runId = await ws.createRun('wf', {}, true, 'hash', {
|
|
61
|
+
type: 'inline',
|
|
62
|
+
} as any)
|
|
63
|
+
const step = await ws.insertStepState(runId, stepName, 'chargeCard', {})
|
|
64
|
+
await (ws as any).updateStepStateImpl?.(runId, stepName, {
|
|
65
|
+
status: 'failed',
|
|
66
|
+
error: { message: 'card declined' },
|
|
67
|
+
})
|
|
68
|
+
const raw = (ws as any).steps ?? (ws as any).stepStates
|
|
69
|
+
if (raw?.get) {
|
|
70
|
+
const key = [...raw.keys()].find((k: string) => k.includes(stepName))
|
|
71
|
+
if (key) {
|
|
72
|
+
raw.set(key, {
|
|
73
|
+
...raw.get(key),
|
|
74
|
+
status: 'failed',
|
|
75
|
+
error: { message: 'card declined' },
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { runId, step }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('workflow onError — compensation on terminal step failure', () => {
|
|
83
|
+
test('a failed step runs its handler and still throws the original error', async () => {
|
|
84
|
+
const ws = new TestWorkflowService()
|
|
85
|
+
const { runId } = await seedFailedStep(ws, 'Charge')
|
|
86
|
+
|
|
87
|
+
await assert.rejects(
|
|
88
|
+
() => ws.callFailedStep(runId, 'Charge', 'refundOrder'),
|
|
89
|
+
/card declined/,
|
|
90
|
+
'compensation must not swallow the failure — the workflow still fails'
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
assert.equal(
|
|
94
|
+
ws.compensations.length,
|
|
95
|
+
1,
|
|
96
|
+
'the onError handler must have been invoked'
|
|
97
|
+
)
|
|
98
|
+
assert.equal(ws.compensations[0].rpcName, 'refundOrder')
|
|
99
|
+
assert.deepEqual(
|
|
100
|
+
ws.compensations[0].data,
|
|
101
|
+
{ error: { message: 'card declined' } },
|
|
102
|
+
'the handler receives the failure reason, as a graph onError node does'
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('a step still sitting on the queue pauses without compensating', async () => {
|
|
107
|
+
const ws = new TestWorkflowService()
|
|
108
|
+
const { runId, step } = await seedPendingStep(ws, 'Charge')
|
|
109
|
+
await ws.setStepScheduled(step.stepId)
|
|
110
|
+
|
|
111
|
+
await assert.rejects(
|
|
112
|
+
() => ws.callFailedStep(runId, 'Charge', 'refundOrder'),
|
|
113
|
+
(error: Error) => error.name === 'WorkflowAsyncException',
|
|
114
|
+
'a scheduled step must pause the workflow, not fail it'
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert.equal(
|
|
118
|
+
ws.compensations.length,
|
|
119
|
+
0,
|
|
120
|
+
'compensating a step that has not run yet would refund a charge that was never made'
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('the pause thrown right after dispatch does not compensate', async () => {
|
|
125
|
+
const ws = new TestWorkflowService()
|
|
126
|
+
ws.alwaysDispatch()
|
|
127
|
+
const { runId } = await seedPendingStep(ws, 'Charge')
|
|
128
|
+
|
|
129
|
+
await assert.rejects(
|
|
130
|
+
() => ws.callFailedStep(runId, 'Charge', 'refundOrder'),
|
|
131
|
+
(error: Error) => error.name === 'WorkflowAsyncException',
|
|
132
|
+
'a freshly dispatched step pauses the workflow'
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
assert.equal(ws.dispatches, 1, 'the step should have been dispatched once')
|
|
136
|
+
assert.equal(
|
|
137
|
+
ws.compensations.length,
|
|
138
|
+
0,
|
|
139
|
+
'queued is not failed — the handler must not run'
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('a failed step with no onError simply throws', async () => {
|
|
144
|
+
const ws = new TestWorkflowService()
|
|
145
|
+
const { runId } = await seedFailedStep(ws, 'Charge')
|
|
146
|
+
|
|
147
|
+
await assert.rejects(() => ws.callFailedStep(runId, 'Charge', undefined))
|
|
148
|
+
assert.equal(
|
|
149
|
+
ws.compensations.length,
|
|
150
|
+
0,
|
|
151
|
+
'no handler configured means nothing to compensate'
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -31,7 +31,9 @@ function capturingRpcService(): {
|
|
|
31
31
|
|
|
32
32
|
describe('queued workflow steps carry the run wire pikkuUserId', () => {
|
|
33
33
|
test('invokeStepRpc merges run.wire.pikkuUserId into the step wire override', async () => {
|
|
34
|
-
pikkuState(null, 'package', 'singletonServices', {
|
|
34
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
35
|
+
logger: silentLogger,
|
|
36
|
+
} as any)
|
|
35
37
|
|
|
36
38
|
const ws = new InMemoryWorkflowService()
|
|
37
39
|
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
@@ -56,19 +58,33 @@ describe('queued workflow steps carry the run wire pikkuUserId', () => {
|
|
|
56
58
|
'user-abc',
|
|
57
59
|
'the step wire override carries the run wire pikkuUserId so authed steps rehydrate'
|
|
58
60
|
)
|
|
59
|
-
assert.ok(
|
|
61
|
+
assert.ok(
|
|
62
|
+
rpc.wire().workflowStep,
|
|
63
|
+
'workflowStep provenance is still present'
|
|
64
|
+
)
|
|
60
65
|
})
|
|
61
66
|
|
|
62
67
|
test('no pikkuUserId key is injected when the run wire has none', async () => {
|
|
63
|
-
pikkuState(null, 'package', 'singletonServices', {
|
|
68
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
69
|
+
logger: silentLogger,
|
|
70
|
+
} as any)
|
|
64
71
|
|
|
65
72
|
const ws = new InMemoryWorkflowService()
|
|
66
|
-
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
73
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
74
|
+
type: 'test',
|
|
75
|
+
})
|
|
67
76
|
await ws.insertStepState(runId, 'noauth', 'someFn', {})
|
|
68
77
|
const stepState = await ws.getStepState(runId, 'noauth')
|
|
69
78
|
|
|
70
79
|
const rpc = capturingRpcService()
|
|
71
|
-
await (ws as any).invokeStepRpc(
|
|
80
|
+
await (ws as any).invokeStepRpc(
|
|
81
|
+
runId,
|
|
82
|
+
'noauth',
|
|
83
|
+
stepState,
|
|
84
|
+
'someFn',
|
|
85
|
+
{},
|
|
86
|
+
rpc.service
|
|
87
|
+
)
|
|
72
88
|
|
|
73
89
|
assert.ok(
|
|
74
90
|
!('pikkuUserId' in rpc.wire()),
|
|
@@ -14,6 +14,9 @@ export type {
|
|
|
14
14
|
WorkflowWireDoInline,
|
|
15
15
|
WorkflowWireSleep,
|
|
16
16
|
WorkflowWireSuspend,
|
|
17
|
+
WorkflowWireApproval,
|
|
18
|
+
WorkflowApprovalOptions,
|
|
19
|
+
ApprovalOutcome,
|
|
17
20
|
InputSource,
|
|
18
21
|
OutputBinding,
|
|
19
22
|
RpcStepMeta,
|
|
@@ -28,6 +31,7 @@ export type {
|
|
|
28
31
|
SleepStepMeta,
|
|
29
32
|
CancelStepMeta,
|
|
30
33
|
SuspendStepMeta,
|
|
34
|
+
ApprovalStepMeta,
|
|
31
35
|
SetStepMeta,
|
|
32
36
|
SwitchCaseMeta,
|
|
33
37
|
SwitchStepMeta,
|
|
@@ -282,8 +286,6 @@ export type CoreWorkflow<
|
|
|
282
286
|
func: PikkuFunctionConfig
|
|
283
287
|
/** Middleware chain for this workflow */
|
|
284
288
|
middleware?: PikkuFunctionConfig['middleware']
|
|
285
|
-
/** Permission requirements */
|
|
286
|
-
permissions?: PikkuFunctionConfig['permissions']
|
|
287
289
|
/** Tags for organization and filtering */
|
|
288
290
|
tags?: string[]
|
|
289
291
|
}
|