@pikku/core 0.12.80 → 0.12.82
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 +312 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/function/function-runner.js +2 -5
- package/dist/function/index.d.ts +1 -1
- package/dist/index.d.ts +11 -11
- package/dist/index.js +3 -3
- package/dist/pikku-state.js +4 -0
- package/dist/services/ai-agent-runner-service.d.ts +7 -0
- package/dist/services/ai-run-state-service.d.ts +10 -0
- package/dist/services/in-memory-ai-run-state-service.d.ts +5 -1
- package/dist/services/in-memory-ai-run-state-service.js +9 -0
- package/dist/services/index.d.ts +15 -15
- package/dist/services/index.js +5 -5
- package/dist/services/scoped-credential-service.d.ts +21 -0
- package/dist/services/scoped-credential-service.js +53 -0
- package/dist/testing/service-tests/ai-storage-service-tests.js +76 -0
- package/dist/types/core.types.d.ts +0 -2
- package/dist/types/state.types.d.ts +13 -0
- package/dist/wirings/actor-flow/index.d.ts +1 -1
- package/dist/wirings/ai-agent/ai-agent-finalize.d.ts +58 -0
- package/dist/wirings/ai-agent/ai-agent-finalize.js +138 -0
- package/dist/wirings/ai-agent/ai-agent-interrupt.js +1 -0
- package/dist/wirings/ai-agent/ai-agent-memory.d.ts +2 -8
- package/dist/wirings/ai-agent/ai-agent-memory.js +34 -17
- package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +7 -0
- package/dist/wirings/ai-agent/ai-agent-model-config.js +44 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -0
- package/dist/wirings/ai-agent/ai-agent-runner.js +61 -40
- package/dist/wirings/ai-agent/ai-agent-stream.js +89 -36
- package/dist/wirings/ai-agent/ai-agent-turn.d.ts +1 -0
- package/dist/wirings/ai-agent/ai-agent-turn.js +1 -0
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +46 -1
- package/dist/wirings/ai-agent/index.d.ts +8 -7
- package/dist/wirings/ai-agent/index.js +5 -4
- package/dist/wirings/ai-scorer/ai-scorer-grade.d.ts +26 -0
- package/dist/wirings/ai-scorer/ai-scorer-grade.js +33 -0
- package/dist/wirings/ai-scorer/ai-scorer-judge.d.ts +17 -0
- package/dist/wirings/ai-scorer/ai-scorer-judge.js +92 -0
- package/dist/wirings/ai-scorer/ai-scorer-live.d.ts +15 -0
- package/dist/wirings/ai-scorer/ai-scorer-live.js +38 -0
- package/dist/wirings/ai-scorer/ai-scorer-registry.d.ts +18 -0
- package/dist/wirings/ai-scorer/ai-scorer-registry.js +46 -0
- package/dist/wirings/ai-scorer/ai-scorer-sampling.d.ts +8 -0
- package/dist/wirings/ai-scorer/ai-scorer-sampling.js +31 -0
- package/dist/wirings/ai-scorer/ai-scorer-snapshots.d.ts +10 -0
- package/dist/wirings/ai-scorer/ai-scorer-snapshots.js +40 -0
- package/dist/wirings/ai-scorer/ai-scorer-worker.d.ts +15 -0
- package/dist/wirings/ai-scorer/ai-scorer-worker.js +58 -0
- package/dist/wirings/ai-scorer/ai-scorer.d.ts +39 -0
- package/dist/wirings/ai-scorer/ai-scorer.js +40 -0
- package/dist/wirings/ai-scorer/ai-scorer.types.d.ts +90 -0
- package/dist/wirings/ai-scorer/ai-scorer.types.js +4 -0
- package/dist/wirings/ai-scorer/index.d.ts +6 -0
- package/dist/wirings/ai-scorer/index.js +5 -0
- package/dist/wirings/channel/index.d.ts +5 -6
- package/dist/wirings/channel/index.js +3 -4
- package/dist/wirings/channel/local/local-channel-runner.js +8 -1
- package/dist/wirings/cli/channel/cli-raw-channel-runner.js +9 -1
- package/dist/wirings/cli/channel/index.d.ts +1 -2
- package/dist/wirings/cli/channel/index.js +0 -1
- package/dist/wirings/cli/cli-runner.js +13 -1
- package/dist/wirings/credential/index.d.ts +1 -1
- package/dist/wirings/gateway/index.d.ts +1 -1
- package/dist/wirings/http/http-runner.js +8 -2
- package/dist/wirings/http/index.d.ts +1 -2
- package/dist/wirings/mcp/index.d.ts +1 -1
- package/dist/wirings/mcp/mcp-runner.d.ts +15 -0
- package/dist/wirings/mcp/mcp-runner.js +18 -5
- package/dist/wirings/persona/index.d.ts +3 -4
- package/dist/wirings/persona/index.js +2 -3
- package/dist/wirings/queue/index.d.ts +1 -3
- package/dist/wirings/queue/index.js +1 -3
- package/dist/wirings/rpc/addon-runner.d.ts +4 -0
- package/dist/wirings/rpc/addon-runner.js +19 -3
- package/dist/wirings/rpc/rpc-runner.js +2 -0
- package/dist/wirings/rpc/rpc-types.d.ts +4 -0
- package/dist/wirings/rpc/wire-addon.d.ts +13 -0
- package/dist/wirings/rpc/wire-addon.js +4 -0
- package/dist/wirings/scheduler/index.d.ts +1 -1
- package/dist/wirings/trigger/index.d.ts +1 -1
- package/dist/wirings/virtual-user/index.d.ts +5 -6
- package/dist/wirings/virtual-user/index.js +2 -4
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +85 -15
- package/dist/wirings/workflow/index.d.ts +6 -6
- package/dist/wirings/workflow/index.js +2 -2
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +7 -7
- package/dist/wirings/workflow/pikku-scenario-service.js +39 -13
- package/dist/wirings/workflow/pikku-workflow-service.js +17 -3
- package/dist/wirings/workflow/scenario-step.types.d.ts +8 -0
- package/dist/wirings/workflow/workflow-approval-audit.d.ts +16 -0
- package/dist/wirings/workflow/workflow-approval-audit.js +40 -0
- package/dist/wirings/workflow/workflow-approval-policy.d.ts +20 -0
- package/dist/wirings/workflow/workflow-approval-policy.js +48 -0
- package/dist/wirings/workflow/workflow-approval.d.ts +29 -1
- package/dist/wirings/workflow/workflow-approval.js +65 -2
- package/dist/wirings/workflow/workflow-run-ownership.d.ts +2 -1
- package/dist/wirings/workflow/workflow-run-ownership.js +2 -1
- package/dist/wirings/workflow/workflow.types.d.ts +1 -1
- package/knowledge/decisions/internals/addon-pikku-meta-ships-at-the-package-root-or-under-dist.md +32 -0
- package/knowledge/decisions/internals/an-addon-scope-root-loses-to-a-root-the-host-already-declares.md +39 -0
- package/knowledge/decisions/internals/index.md +30 -3
- package/knowledge/decisions/internals/validate-runs-checks-by-precondition.md +115 -0
- package/knowledge/decisions/security/a-function-never-receives-the-secret-service.md +37 -0
- package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +30 -14
- package/knowledge/decisions/security/an-approval-answer-outlives-the-run-it-answered.md +59 -0
- package/knowledge/decisions/security/index.md +3 -1
- package/knowledge/questions/index.md +1 -1
- package/package.json +3 -1
- package/scripts/generate-api-report.mts +143 -18
- package/src/api-report.test.ts +2 -2
- package/src/errors/index.ts +1 -1
- package/src/function/function-runner.test.ts +52 -0
- package/src/function/function-runner.ts +5 -9
- package/src/function/index.ts +0 -2
- package/src/index.ts +0 -35
- package/src/pikku-state.ts +5 -0
- package/src/public-surface.json +70 -94
- package/src/services/ai-agent-runner-service.ts +12 -1
- package/src/services/ai-run-state-service.ts +11 -0
- package/src/services/in-memory-ai-run-state-service.ts +13 -0
- package/src/services/index.ts +3 -43
- package/src/services/scoped-credential-service.test.ts +86 -0
- package/src/services/scoped-credential-service.ts +63 -0
- package/src/testing/service-tests/ai-storage-service-tests.ts +93 -0
- package/src/types/core.types.ts +3 -6
- package/src/types/state.types.ts +16 -0
- package/src/wirings/actor-flow/index.ts +0 -3
- package/src/wirings/ai-agent/ai-agent-finalize.test.ts +186 -0
- package/src/wirings/ai-agent/ai-agent-finalize.ts +197 -0
- package/src/wirings/ai-agent/ai-agent-interrupt.ts +1 -0
- package/src/wirings/ai-agent/ai-agent-memory.ts +54 -38
- package/src/wirings/ai-agent/ai-agent-model-config.test.ts +72 -3
- package/src/wirings/ai-agent/ai-agent-model-config.ts +49 -1
- package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -0
- package/src/wirings/ai-agent/ai-agent-runner.ts +71 -40
- package/src/wirings/ai-agent/ai-agent-stream-output-hooks.test.ts +353 -0
- package/src/wirings/ai-agent/ai-agent-stream.ts +116 -54
- package/src/wirings/ai-agent/ai-agent-turn.test.ts +67 -0
- package/src/wirings/ai-agent/ai-agent-turn.ts +1 -0
- package/src/wirings/ai-agent/ai-agent.types.ts +64 -4
- package/src/wirings/ai-agent/index.ts +2 -16
- package/src/wirings/ai-scorer/ai-scorer-grade.test.ts +106 -0
- package/src/wirings/ai-scorer/ai-scorer-grade.ts +55 -0
- package/src/wirings/ai-scorer/ai-scorer-judge.test.ts +143 -0
- package/src/wirings/ai-scorer/ai-scorer-judge.ts +120 -0
- package/src/wirings/ai-scorer/ai-scorer-live.test.ts +174 -0
- package/src/wirings/ai-scorer/ai-scorer-live.ts +56 -0
- package/src/wirings/ai-scorer/ai-scorer-registry.ts +63 -0
- package/src/wirings/ai-scorer/ai-scorer-sampling.test.ts +34 -0
- package/src/wirings/ai-scorer/ai-scorer-sampling.ts +36 -0
- package/src/wirings/ai-scorer/ai-scorer-snapshots.test.ts +49 -0
- package/src/wirings/ai-scorer/ai-scorer-snapshots.ts +46 -0
- package/src/wirings/ai-scorer/ai-scorer-worker.test.ts +122 -0
- package/src/wirings/ai-scorer/ai-scorer-worker.ts +69 -0
- package/src/wirings/ai-scorer/ai-scorer.ts +76 -0
- package/src/wirings/ai-scorer/ai-scorer.types.ts +107 -0
- package/src/wirings/ai-scorer/index.ts +24 -0
- package/src/wirings/channel/index.ts +1 -20
- package/src/wirings/channel/local/local-channel-runner.test.ts +68 -0
- package/src/wirings/channel/local/local-channel-runner.ts +8 -1
- package/src/wirings/cli/channel/cli-raw-channel-runner.test.ts +23 -0
- package/src/wirings/cli/channel/cli-raw-channel-runner.ts +12 -1
- package/src/wirings/cli/channel/index.ts +0 -7
- package/src/wirings/cli/cli-runner.test.ts +68 -0
- package/src/wirings/cli/cli-runner.ts +18 -1
- package/src/wirings/credential/index.ts +0 -1
- package/src/wirings/gateway/index.ts +0 -3
- package/src/wirings/http/http-runner.test.ts +66 -0
- package/src/wirings/http/http-runner.ts +10 -2
- package/src/wirings/http/index.ts +1 -1
- package/src/wirings/mcp/index.ts +0 -1
- package/src/wirings/mcp/mcp-runner.test.ts +181 -0
- package/src/wirings/mcp/mcp-runner.ts +35 -5
- package/src/wirings/persona/index.ts +0 -8
- package/src/wirings/queue/index.ts +0 -14
- package/src/wirings/rpc/addon-runner.ts +34 -3
- package/src/wirings/rpc/addon-secrets.test.ts +261 -0
- package/src/wirings/rpc/rpc-runner.test.ts +2 -0
- package/src/wirings/rpc/rpc-runner.ts +2 -0
- package/src/wirings/rpc/rpc-types.ts +4 -0
- package/src/wirings/rpc/wire-addon.ts +17 -0
- package/src/wirings/scheduler/index.ts +0 -1
- package/src/wirings/trigger/index.ts +0 -1
- package/src/wirings/virtual-user/index.ts +0 -16
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +96 -16
- package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
- package/src/wirings/workflow/index.ts +2 -20
- package/src/wirings/workflow/pikku-scenario-service.ts +60 -15
- package/src/wirings/workflow/pikku-workflow-service.test.ts +13 -12
- package/src/wirings/workflow/pikku-workflow-service.ts +28 -4
- package/src/wirings/workflow/scenario-expectations.test.ts +75 -0
- package/src/wirings/workflow/scenario-hooks.test.ts +3 -2
- package/src/wirings/workflow/scenario-step.types.ts +8 -0
- package/src/wirings/workflow/workflow-approval-audit.ts +47 -0
- package/src/wirings/workflow/workflow-approval-policy.test.ts +524 -0
- package/src/wirings/workflow/workflow-approval-policy.ts +68 -0
- package/src/wirings/workflow/workflow-approval.ts +113 -9
- package/src/wirings/workflow/workflow-run-authority.test.ts +12 -15
- package/src/wirings/workflow/workflow-run-ownership.ts +2 -1
- package/src/wirings/workflow/workflow.types.ts +0 -9
- package/src/wirings-stay-decoupled.test.ts +6 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/internal.d.ts +0 -3
- package/dist/internal.js +0 -2
- package/dist/middleware/timeout.d.ts +0 -9
- package/dist/middleware/timeout.js +0 -15
- package/dist/pikku-response.d.ts +0 -6
- package/dist/pikku-response.js +0 -6
- package/dist/services/gopass-secrets.d.ts +0 -15
- package/dist/services/gopass-secrets.js +0 -76
- package/dist/services/http-scenario-actors.d.ts +0 -75
- package/dist/services/http-scenario-actors.js +0 -195
- package/dist/services/http-user-flow-actors.d.ts +0 -67
- package/dist/services/http-user-flow-actors.js +0 -193
- package/dist/services/scenario-actors-service.d.ts +0 -127
- package/dist/services/scenario-actors-service.js +0 -40
- package/dist/services/user-flow-actors-service.d.ts +0 -39
- package/dist/services/user-flow-actors-service.js +0 -1
- package/dist/wirings/credential/wire-credential.d.ts +0 -48
- package/dist/wirings/credential/wire-credential.js +0 -47
- package/dist/wirings/oauth2/oauth2-client.d.ts +0 -47
- package/dist/wirings/oauth2/oauth2-client.js +0 -263
- package/dist/wirings/oauth2/oauth2-routes.d.ts +0 -35
- package/dist/wirings/oauth2/oauth2-routes.js +0 -146
- package/dist/wirings/scope/wire-scope.d.ts +0 -33
- package/dist/wirings/scope/wire-scope.js +0 -32
- package/dist/wirings/workflow/dsl/index.d.ts +0 -5
- package/dist/wirings/workflow/dsl/index.js +0 -4
- package/dist/wirings/workflow/graph/index.d.ts +0 -5
- package/dist/wirings/workflow/graph/index.js +0 -4
|
@@ -156,16 +156,16 @@ export declare class PikkuScenarioService implements WorkflowRunExtension {
|
|
|
156
156
|
}): Promise<void>;
|
|
157
157
|
detachRunContext(runId: string): void;
|
|
158
158
|
/**
|
|
159
|
-
* What the run has accumulated
|
|
160
|
-
*
|
|
159
|
+
* What the run has accumulated.
|
|
160
|
+
*
|
|
161
|
+
* Undefined only for a run whose wire was never decorated — decoration calls
|
|
162
|
+
* `contextForRun` unconditionally, so a plain workflow that has reached a
|
|
163
|
+
* step holds an empty context rather than none.
|
|
161
164
|
*/
|
|
162
165
|
getRunContext(runId: string): Record<string, unknown> | undefined;
|
|
163
166
|
/**
|
|
164
|
-
* The run's context, created on demand
|
|
165
|
-
*
|
|
166
|
-
* `attachRunContext` seeds it for an ordinary scenario run, but the wire is
|
|
167
|
-
* also built on paths that skip attach; without this the body would be
|
|
168
|
-
* writing to an `undefined` and teardown would read nothing.
|
|
167
|
+
* The run's context, created on demand because the wire is also built on
|
|
168
|
+
* paths that skip `attachRunContext`.
|
|
169
169
|
*/
|
|
170
170
|
private contextForRun;
|
|
171
171
|
decorateRunWire(wire: PikkuRawWire, context: {
|
|
@@ -176,9 +176,9 @@ export class PikkuScenarioService {
|
|
|
176
176
|
// Scenario actors per run: live authenticated clients (cookie jars) are
|
|
177
177
|
// process-local by nature, so they ride this map, never the persisted wire.
|
|
178
178
|
runActors = new Map();
|
|
179
|
-
// What each run has accumulated so far
|
|
180
|
-
//
|
|
181
|
-
//
|
|
179
|
+
// What each run has accumulated so far — process-local like the actors above,
|
|
180
|
+
// so the body and its hooks share one object rather than reading it back off
|
|
181
|
+
// the persisted wire.
|
|
182
182
|
runContexts = new Map();
|
|
183
183
|
scenarioBrowserProvider;
|
|
184
184
|
scenarioEnvironment;
|
|
@@ -235,18 +235,18 @@ export class PikkuScenarioService {
|
|
|
235
235
|
this.runContexts.delete(runId);
|
|
236
236
|
}
|
|
237
237
|
/**
|
|
238
|
-
* What the run has accumulated
|
|
239
|
-
*
|
|
238
|
+
* What the run has accumulated.
|
|
239
|
+
*
|
|
240
|
+
* Undefined only for a run whose wire was never decorated — decoration calls
|
|
241
|
+
* `contextForRun` unconditionally, so a plain workflow that has reached a
|
|
242
|
+
* step holds an empty context rather than none.
|
|
240
243
|
*/
|
|
241
244
|
getRunContext(runId) {
|
|
242
245
|
return this.runContexts.get(runId);
|
|
243
246
|
}
|
|
244
247
|
/**
|
|
245
|
-
* The run's context, created on demand
|
|
246
|
-
*
|
|
247
|
-
* `attachRunContext` seeds it for an ordinary scenario run, but the wire is
|
|
248
|
-
* also built on paths that skip attach; without this the body would be
|
|
249
|
-
* writing to an `undefined` and teardown would read nothing.
|
|
248
|
+
* The run's context, created on demand because the wire is also built on
|
|
249
|
+
* paths that skip `attachRunContext`.
|
|
250
250
|
*/
|
|
251
251
|
contextForRun(runId) {
|
|
252
252
|
let runContext = this.runContexts.get(runId);
|
|
@@ -397,9 +397,6 @@ export class PikkuScenarioService {
|
|
|
397
397
|
rpcService,
|
|
398
398
|
});
|
|
399
399
|
Object.assign(workflowWire, {
|
|
400
|
-
// One object per run, resolved through the map rather than created here,
|
|
401
|
-
// so the body and its before/after hooks share the same scratch even
|
|
402
|
-
// though the hooks are separate functions that cannot see its locals.
|
|
403
400
|
context: this.contextForRun(runId),
|
|
404
401
|
// Durable polling step: invoke an RPC (as an actor when options.as is
|
|
405
402
|
// set) until the predicate passes or `within` elapses. The whole poll is
|
|
@@ -485,6 +482,35 @@ export class PikkuScenarioService {
|
|
|
485
482
|
}
|
|
486
483
|
}, options);
|
|
487
484
|
},
|
|
485
|
+
expectScore: async (stepName, agentRunId, scorerName, options) => {
|
|
486
|
+
this.engine.verifyStepName(stepName);
|
|
487
|
+
return await this.engine.inlineStep(runId, stepName, async () => {
|
|
488
|
+
const rpcName = 'pikkuScenarioGradeRun';
|
|
489
|
+
const data = {
|
|
490
|
+
runId: agentRunId,
|
|
491
|
+
scorer: scorerName,
|
|
492
|
+
...(options?.reference !== undefined
|
|
493
|
+
? { reference: options.reference }
|
|
494
|
+
: {}),
|
|
495
|
+
};
|
|
496
|
+
const grade = options?.actor
|
|
497
|
+
? await options.actor.invoke(rpcName, data)
|
|
498
|
+
: await rpcService.rpcWithWire(rpcName, data, {});
|
|
499
|
+
// A scorer that graded is a scorer that answered, so an unstated
|
|
500
|
+
// bound still fails a zero rather than passing anything at all.
|
|
501
|
+
const atLeast = options?.atLeast ?? 0.5;
|
|
502
|
+
const failed = grade.score < atLeast ||
|
|
503
|
+
(options?.atMost !== undefined && grade.score > options.atMost);
|
|
504
|
+
if (failed) {
|
|
505
|
+
const bound = options?.atMost !== undefined
|
|
506
|
+
? `between ${atLeast} and ${options.atMost}`
|
|
507
|
+
: `at least ${atLeast}`;
|
|
508
|
+
throw new Error(`[workflow] expectScore '${stepName}' expected '${scorerName}' to grade run ${agentRunId} ${bound}, got ${grade.score}` +
|
|
509
|
+
`${grade.reason ? `: ${grade.reason}` : ''}`);
|
|
510
|
+
}
|
|
511
|
+
return grade;
|
|
512
|
+
}, options);
|
|
513
|
+
},
|
|
488
514
|
// knowledge: decisions/internals/scenario-given-and-when-are-sugar-but-then-is-not.md
|
|
489
515
|
given: (stepName, stepFunc, data, options) => this.scenarioStep('given', scenarioStepContext(), stepName, stepFunc, data, options),
|
|
490
516
|
when: (stepName, stepFunc, data, options) => this.scenarioStep('when', scenarioStepContext(), stepName, stepFunc, data, options),
|
|
@@ -6,7 +6,7 @@ import { isExpectedError } from '../../errors/error-handler.js';
|
|
|
6
6
|
import { PikkuMissingMetaError } from '../../errors/errors.js';
|
|
7
7
|
import { RPCNotFoundError } from '../rpc/rpc-runner.js';
|
|
8
8
|
import { deriveInvocationId } from './workflow-invocation-id.js';
|
|
9
|
-
import {
|
|
9
|
+
import { approvalDeciderFrom } from './workflow-approval-policy.js';
|
|
10
10
|
import { buildRunTimeline, reconstructStateAt, } from './run-timeline.js';
|
|
11
11
|
import { DEFAULT_STEP_RETRIES, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, } from './workflow-constants.js';
|
|
12
12
|
import { WorkflowAsyncException, WorkflowCancelledException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotFoundError, WorkflowStepFunctionMismatchError, WorkflowStepNameNotString, WorkflowSuspendedException, } from './workflow-errors.js';
|
|
@@ -14,6 +14,7 @@ import { resolveWorkflowMeta } from './workflow-meta-resolver.js';
|
|
|
14
14
|
import { jobGroupFor, orchestratorQueueName, resolveWorkflowConfig, stepJobOptions, stepWorkerQueueName, } from './workflow-queue-routing.js';
|
|
15
15
|
import { wireWorkflowQueueWorkers } from './workflow-queue-wiring.js';
|
|
16
16
|
import { approvalStepNameFor, evaluateApprovalStep, recordApprovalDecision, } from './workflow-approval.js';
|
|
17
|
+
import { auditApprovalDecision } from './workflow-approval-audit.js';
|
|
17
18
|
import { recordSuspension, suspendStepNameFor } from './workflow-suspend.js';
|
|
18
19
|
import { RedispatchBackoff, sweepStalledRuns, sweepUndispatchedSteps, } from './workflow-recovery.js';
|
|
19
20
|
export class PikkuWorkflowService {
|
|
@@ -371,6 +372,18 @@ export class PikkuWorkflowService {
|
|
|
371
372
|
throw new WorkflowNotFoundError(name);
|
|
372
373
|
}
|
|
373
374
|
if (workflowMeta.source === 'graph') {
|
|
375
|
+
// A caller-supplied startNode must be one of the graph's declared entry
|
|
376
|
+
// nodes. startWorkflow is the boundary the public
|
|
377
|
+
// `/workflow/:name/graph/:nodeId` route and triggers enter through, so
|
|
378
|
+
// without this a request could name any dependency-free node — one whose
|
|
379
|
+
// input reads only `trigger` — and fire its RPC directly with
|
|
380
|
+
// attacker-chosen data, skipping every upstream eligibility, validation or
|
|
381
|
+
// approval node. (Internal resume/replay drives runWorkflowGraph directly
|
|
382
|
+
// and is unaffected.)
|
|
383
|
+
if (options?.startNode &&
|
|
384
|
+
!(workflowMeta.entryNodeIds ?? []).includes(options.startNode)) {
|
|
385
|
+
throw new Error(`Workflow graph '${name}': '${options.startNode}' is not a declared entry node`);
|
|
386
|
+
}
|
|
374
387
|
const shouldInline = options?.inline || !getSingletonServices()?.queueService;
|
|
375
388
|
return runWorkflowGraph(this, name, input, rpcService, shouldInline, options?.startNode, wire, workflowMeta);
|
|
376
389
|
}
|
|
@@ -1014,11 +1027,12 @@ export class PikkuWorkflowService {
|
|
|
1014
1027
|
updateRunState: (runId, key, value) => this.updateRunState(runId, key, value),
|
|
1015
1028
|
resumeWorkflow: (runId) => this.resumeWorkflow(runId),
|
|
1016
1029
|
scheduleRunWake: (runId, delay) => this.scheduleRunWake(runId, delay),
|
|
1030
|
+
getRunOwner: async (runId) => (await this.getRunIdentity(runId))?.wire?.pikkuUserId,
|
|
1031
|
+
auditApproval: (event) => auditApprovalDecision(event),
|
|
1017
1032
|
};
|
|
1018
1033
|
}
|
|
1019
1034
|
async approveStep(runId, reason, decision, session) {
|
|
1020
|
-
|
|
1021
|
-
return recordApprovalDecision(this.approvalStore, runId, reason, decision);
|
|
1035
|
+
return recordApprovalDecision(this.approvalStore, runId, reason, decision, approvalDeciderFrom(session));
|
|
1022
1036
|
}
|
|
1023
1037
|
async approvalStep(runId, reason, options) {
|
|
1024
1038
|
const fromStepName = this.lastStepName(runId);
|
|
@@ -210,6 +210,14 @@ export interface ScenarioBrowserProvider {
|
|
|
210
210
|
* scenario cannot leave the next signed in as somebody else.
|
|
211
211
|
*/
|
|
212
212
|
reset?(): Promise<void>;
|
|
213
|
+
/**
|
|
214
|
+
* Name the scenario about to run, so anything it captures is filed under it.
|
|
215
|
+
*
|
|
216
|
+
* Called before each scenario. A provider that is never told has to fall
|
|
217
|
+
* back to one shared label, which puts every run's artifacts in a single
|
|
218
|
+
* folder — findable only by timestamp.
|
|
219
|
+
*/
|
|
220
|
+
beginScenario?(scenario: string): void;
|
|
213
221
|
/**
|
|
214
222
|
* Snapshot every open window for a failed scenario. `label` identifies the
|
|
215
223
|
* scenario in artifact filenames. Never throws: a failure to capture must
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ApprovalAuditEvent } from './workflow-approval.js';
|
|
2
|
+
/** The audit type an answer to an approval gate is recorded under. */
|
|
3
|
+
export declare const APPROVAL_AUDIT_TYPE = "workflow.approval.decided";
|
|
4
|
+
/**
|
|
5
|
+
* Record an answer to an approval gate where it outlives the run.
|
|
6
|
+
*
|
|
7
|
+
* The step result carries the settled decision, but it is deleted with the run
|
|
8
|
+
* — `deleteRun` cascades to steps and to history — and a refused attempt never
|
|
9
|
+
* reaches a step at all. An approval is asked for precisely so it can be
|
|
10
|
+
* answered for afterwards, so the answer also goes to the audit sink, which
|
|
11
|
+
* holds no foreign key to the run.
|
|
12
|
+
*
|
|
13
|
+
* A project with no sink wired records nothing, and a sink that fails is logged
|
|
14
|
+
* rather than thrown: the trail must not be the reason a decision is lost.
|
|
15
|
+
*/
|
|
16
|
+
export declare const auditApprovalDecision: (event: ApprovalAuditEvent) => Promise<void>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getSingletonServices } from '../../pikku-state.js';
|
|
2
|
+
/** The audit type an answer to an approval gate is recorded under. */
|
|
3
|
+
export const APPROVAL_AUDIT_TYPE = 'workflow.approval.decided';
|
|
4
|
+
/**
|
|
5
|
+
* Record an answer to an approval gate where it outlives the run.
|
|
6
|
+
*
|
|
7
|
+
* The step result carries the settled decision, but it is deleted with the run
|
|
8
|
+
* — `deleteRun` cascades to steps and to history — and a refused attempt never
|
|
9
|
+
* reaches a step at all. An approval is asked for precisely so it can be
|
|
10
|
+
* answered for afterwards, so the answer also goes to the audit sink, which
|
|
11
|
+
* holds no foreign key to the run.
|
|
12
|
+
*
|
|
13
|
+
* A project with no sink wired records nothing, and a sink that fails is logged
|
|
14
|
+
* rather than thrown: the trail must not be the reason a decision is lost.
|
|
15
|
+
*/
|
|
16
|
+
export const auditApprovalDecision = async (event) => {
|
|
17
|
+
const services = getSingletonServices();
|
|
18
|
+
if (!services?.audit) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
await services.audit.audit({
|
|
23
|
+
type: APPROVAL_AUDIT_TYPE,
|
|
24
|
+
source: 'explicit',
|
|
25
|
+
outcome: event.outcome,
|
|
26
|
+
occurredAt: new Date().toISOString(),
|
|
27
|
+
wireType: 'workflow',
|
|
28
|
+
userIdentity: { pikkuUserId: event.decidedBy?.userId },
|
|
29
|
+
metadata: {
|
|
30
|
+
runId: event.runId,
|
|
31
|
+
reason: event.reason,
|
|
32
|
+
scopes: event.decidedBy?.scopes,
|
|
33
|
+
refusal: event.refusal,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
services.logger?.warn(`Failed to audit the decision on approval '${event.reason}' for run ${event.runId}`, error);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ForbiddenError } from '../../errors/errors.js';
|
|
2
|
+
import type { CoreUserSession } from '../../types/core.types.js';
|
|
3
|
+
import type { ApprovalDecider, WorkflowApprovalPolicy } from './dsl/workflow-dsl.types.js';
|
|
4
|
+
export declare class WorkflowApprovalForbiddenError extends ForbiddenError {
|
|
5
|
+
payload: {
|
|
6
|
+
reason: string;
|
|
7
|
+
detail: string;
|
|
8
|
+
};
|
|
9
|
+
constructor(reason: string, detail: string);
|
|
10
|
+
}
|
|
11
|
+
export declare const approvalDeciderFrom: (session: CoreUserSession | undefined) => ApprovalDecider | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Judge a decision against the gate's declared policy, returning the reason it
|
|
14
|
+
* is refused or `undefined` if it stands.
|
|
15
|
+
*
|
|
16
|
+
* Returns a message rather than throwing because the same judgement is needed
|
|
17
|
+
* in two places with different outcomes: refusing a live submission with a 403,
|
|
18
|
+
* and clearing an already-recorded decision on replay.
|
|
19
|
+
*/
|
|
20
|
+
export declare const approvalPolicyRefusal: (policy: WorkflowApprovalPolicy, owner: string | undefined, decider: ApprovalDecider | undefined) => string | undefined;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ForbiddenError } from '../../errors/errors.js';
|
|
2
|
+
import { addError } from '../../errors/error-handler.js';
|
|
3
|
+
export class WorkflowApprovalForbiddenError extends ForbiddenError {
|
|
4
|
+
payload;
|
|
5
|
+
constructor(reason, detail) {
|
|
6
|
+
super(detail);
|
|
7
|
+
this.payload = { reason, detail };
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
addError(WorkflowApprovalForbiddenError, {
|
|
11
|
+
status: 403,
|
|
12
|
+
message: 'Not authorized to answer this approval.',
|
|
13
|
+
});
|
|
14
|
+
export const approvalDeciderFrom = (session) => session ? { userId: session.userId, scopes: session.scopes } : undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Judge a decision against the gate's declared policy, returning the reason it
|
|
17
|
+
* is refused or `undefined` if it stands.
|
|
18
|
+
*
|
|
19
|
+
* Returns a message rather than throwing because the same judgement is needed
|
|
20
|
+
* in two places with different outcomes: refusing a live submission with a 403,
|
|
21
|
+
* and clearing an already-recorded decision on replay.
|
|
22
|
+
*/
|
|
23
|
+
export const approvalPolicyRefusal = (policy, owner, decider) => {
|
|
24
|
+
if (policy.approverScope &&
|
|
25
|
+
!decider?.scopes?.includes(policy.approverScope)) {
|
|
26
|
+
return `Answering this approval requires the '${policy.approverScope}' scope`;
|
|
27
|
+
}
|
|
28
|
+
switch (policy.approvers ?? 'any') {
|
|
29
|
+
case 'owner':
|
|
30
|
+
if (!owner) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
if (!decider?.userId || decider.userId !== owner) {
|
|
34
|
+
return 'Only the user who started this run may answer this approval';
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
case 'not-initiator':
|
|
38
|
+
if (!decider?.userId) {
|
|
39
|
+
return 'Answering this approval requires a signed-in user';
|
|
40
|
+
}
|
|
41
|
+
if (owner && decider.userId === owner) {
|
|
42
|
+
return 'The user who started this run may not answer this approval';
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
case 'any':
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ApprovalOutcome, StepState, WorkflowApprovalOptions } from './workflow.types.js';
|
|
2
|
+
import type { ApprovalDecider } from './dsl/workflow-dsl.types.js';
|
|
2
3
|
/** The durable step name an approval point is recorded under. */
|
|
3
4
|
export declare const approvalStepNameFor: (reason: string) => string;
|
|
4
5
|
/**
|
|
@@ -9,6 +10,20 @@ export declare const approvalStepNameFor: (reason: string) => string;
|
|
|
9
10
|
* shadow, another key.
|
|
10
11
|
*/
|
|
11
12
|
export declare const approvalStateKey: (stepName: string) => string;
|
|
13
|
+
/**
|
|
14
|
+
* An answer to an approval gate, as the audit trail sees it.
|
|
15
|
+
*
|
|
16
|
+
* `denied` covers both a submission refused at the door and a decision cleared
|
|
17
|
+
* on replay — from the trail's point of view they are the same event, someone
|
|
18
|
+
* tried to answer a gate they could not.
|
|
19
|
+
*/
|
|
20
|
+
export type ApprovalAuditEvent = {
|
|
21
|
+
runId: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
outcome: 'success' | 'denied';
|
|
24
|
+
decidedBy?: ApprovalDecider;
|
|
25
|
+
refusal?: string;
|
|
26
|
+
};
|
|
12
27
|
/** What the approval gate needs from the workflow service. */
|
|
13
28
|
export type ApprovalStore = {
|
|
14
29
|
getStepState: (runId: string, stepName: string) => Promise<StepState>;
|
|
@@ -19,6 +34,8 @@ export type ApprovalStore = {
|
|
|
19
34
|
updateRunState: (runId: string, key: string, value: unknown) => Promise<void>;
|
|
20
35
|
resumeWorkflow: (runId: string) => Promise<void>;
|
|
21
36
|
scheduleRunWake: (runId: string, delay: number) => Promise<void>;
|
|
37
|
+
getRunOwner: (runId: string) => Promise<string | undefined>;
|
|
38
|
+
auditApproval: (event: ApprovalAuditEvent) => Promise<void>;
|
|
22
39
|
};
|
|
23
40
|
/**
|
|
24
41
|
* Record a decision against an approval point and wake the run.
|
|
@@ -26,8 +43,14 @@ export type ApprovalStore = {
|
|
|
26
43
|
* A decision arriving for an already-settled approval is refused rather than
|
|
27
44
|
* overwriting it: the run has moved on, and a second answer would be recorded
|
|
28
45
|
* against a gate nobody is waiting at.
|
|
46
|
+
*
|
|
47
|
+
* The decider is recorded rather than judged here, for the same reason the
|
|
48
|
+
* payload is: the gate's policy is a value on the workflow, and a decision can
|
|
49
|
+
* legitimately arrive before the run has ever reached the gate. Where the run
|
|
50
|
+
* has already published its policy, it is applied eagerly so a caller who
|
|
51
|
+
* cannot answer is told so rather than left waiting.
|
|
29
52
|
*/
|
|
30
|
-
export declare const recordApprovalDecision: (store: ApprovalStore, runId: string, reason: string, decision: unknown) => Promise<void>;
|
|
53
|
+
export declare const recordApprovalDecision: (store: ApprovalStore, runId: string, reason: string, decision: unknown, decidedBy?: ApprovalDecider) => Promise<void>;
|
|
31
54
|
/**
|
|
32
55
|
* Evaluate an approval gate on replay.
|
|
33
56
|
*
|
|
@@ -35,5 +58,10 @@ export declare const recordApprovalDecision: (store: ApprovalStore, runId: strin
|
|
|
35
58
|
* schema is a value on the workflow and only the workflow has it. A payload
|
|
36
59
|
* that fails validation is cleared and the run suspends again, so a bad
|
|
37
60
|
* submission cannot settle the gate.
|
|
61
|
+
*
|
|
62
|
+
* The decider is judged here for the same reason, and for one more: a decision
|
|
63
|
+
* can be recorded before the run has ever reached the gate, so submission time
|
|
64
|
+
* is not a point at which the policy is reliably knowable. Judging on replay is
|
|
65
|
+
* what makes the policy hold in every ordering.
|
|
38
66
|
*/
|
|
39
67
|
export declare const evaluateApprovalStep: (store: ApprovalStore, runId: string, reason: string, approvalStepName: string, fromStepName: string | undefined, options: WorkflowApprovalOptions) => Promise<ApprovalOutcome<unknown>>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getDurationInMilliseconds } from '../../time-utils.js';
|
|
2
2
|
import { WorkflowApprovalResolvedError, WorkflowSuspendedException, } from './workflow-errors.js';
|
|
3
|
+
import { approvalPolicyRefusal, WorkflowApprovalForbiddenError, } from './workflow-approval-policy.js';
|
|
3
4
|
/** The durable step name an approval point is recorded under. */
|
|
4
5
|
export const approvalStepNameFor = (reason) => `__workflow_approval:${reason}`;
|
|
5
6
|
/**
|
|
@@ -22,8 +23,14 @@ export const approvalStateKey = (stepName) => {
|
|
|
22
23
|
* A decision arriving for an already-settled approval is refused rather than
|
|
23
24
|
* overwriting it: the run has moved on, and a second answer would be recorded
|
|
24
25
|
* against a gate nobody is waiting at.
|
|
26
|
+
*
|
|
27
|
+
* The decider is recorded rather than judged here, for the same reason the
|
|
28
|
+
* payload is: the gate's policy is a value on the workflow, and a decision can
|
|
29
|
+
* legitimately arrive before the run has ever reached the gate. Where the run
|
|
30
|
+
* has already published its policy, it is applied eagerly so a caller who
|
|
31
|
+
* cannot answer is told so rather than left waiting.
|
|
25
32
|
*/
|
|
26
|
-
export const recordApprovalDecision = async (store, runId, reason, decision) => {
|
|
33
|
+
export const recordApprovalDecision = async (store, runId, reason, decision, decidedBy) => {
|
|
27
34
|
const stepName = approvalStepNameFor(reason);
|
|
28
35
|
const stateKey = approvalStateKey(stepName);
|
|
29
36
|
let resolved;
|
|
@@ -39,12 +46,27 @@ export const recordApprovalDecision = async (store, runId, reason, decision) =>
|
|
|
39
46
|
}
|
|
40
47
|
const state = await store.getRunState(runId);
|
|
41
48
|
const record = (state[stateKey] ?? {});
|
|
49
|
+
if (record.policy) {
|
|
50
|
+
const refusal = approvalPolicyRefusal(record.policy, await store.getRunOwner(runId), decidedBy);
|
|
51
|
+
if (refusal) {
|
|
52
|
+
await store.auditApproval({
|
|
53
|
+
runId,
|
|
54
|
+
reason,
|
|
55
|
+
outcome: 'denied',
|
|
56
|
+
decidedBy,
|
|
57
|
+
refusal,
|
|
58
|
+
});
|
|
59
|
+
throw new WorkflowApprovalForbiddenError(reason, refusal);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
42
62
|
await store.updateRunState(runId, stateKey, {
|
|
43
63
|
...record,
|
|
44
64
|
decision,
|
|
45
65
|
decidedAt: new Date().toISOString(),
|
|
66
|
+
decidedBy,
|
|
46
67
|
error: undefined,
|
|
47
68
|
});
|
|
69
|
+
await store.auditApproval({ runId, reason, outcome: 'success', decidedBy });
|
|
48
70
|
await store.resumeWorkflow(runId);
|
|
49
71
|
};
|
|
50
72
|
/**
|
|
@@ -54,9 +76,19 @@ export const recordApprovalDecision = async (store, runId, reason, decision) =>
|
|
|
54
76
|
* schema is a value on the workflow and only the workflow has it. A payload
|
|
55
77
|
* that fails validation is cleared and the run suspends again, so a bad
|
|
56
78
|
* submission cannot settle the gate.
|
|
79
|
+
*
|
|
80
|
+
* The decider is judged here for the same reason, and for one more: a decision
|
|
81
|
+
* can be recorded before the run has ever reached the gate, so submission time
|
|
82
|
+
* is not a point at which the policy is reliably knowable. Judging on replay is
|
|
83
|
+
* what makes the policy hold in every ordering.
|
|
57
84
|
*/
|
|
58
85
|
export const evaluateApprovalStep = async (store, runId, reason, approvalStepName, fromStepName, options) => {
|
|
59
|
-
const insert = () => store.insertStepState(runId, approvalStepName, 'pikkuWorkflowApproval', {
|
|
86
|
+
const insert = () => store.insertStepState(runId, approvalStepName, 'pikkuWorkflowApproval', {
|
|
87
|
+
reason,
|
|
88
|
+
expiry: options.expiry,
|
|
89
|
+
approvers: options.approvers,
|
|
90
|
+
approverScope: options.approverScope,
|
|
91
|
+
}, undefined, fromStepName);
|
|
60
92
|
let stepState;
|
|
61
93
|
try {
|
|
62
94
|
stepState = await store.getStepState(runId, approvalStepName);
|
|
@@ -72,8 +104,13 @@ export const evaluateApprovalStep = async (store, runId, reason, approvalStepNam
|
|
|
72
104
|
}
|
|
73
105
|
const stateKey = approvalStateKey(approvalStepName);
|
|
74
106
|
let record = ((await store.getRunState(runId))[stateKey] ?? {});
|
|
107
|
+
const policy = {
|
|
108
|
+
approvers: options.approvers,
|
|
109
|
+
approverScope: options.approverScope,
|
|
110
|
+
};
|
|
75
111
|
if (stepState.status === 'pending') {
|
|
76
112
|
await store.setStepRunning(stepState.stepId);
|
|
113
|
+
record = { ...record, policy };
|
|
77
114
|
if (options.expiry !== undefined && !record.expiresAt) {
|
|
78
115
|
const expiry = getDurationInMilliseconds(options.expiry);
|
|
79
116
|
record = {
|
|
@@ -83,14 +120,36 @@ export const evaluateApprovalStep = async (store, runId, reason, approvalStepNam
|
|
|
83
120
|
await store.updateRunState(runId, stateKey, record);
|
|
84
121
|
await store.scheduleRunWake(runId, expiry);
|
|
85
122
|
}
|
|
123
|
+
else {
|
|
124
|
+
await store.updateRunState(runId, stateKey, record);
|
|
125
|
+
}
|
|
86
126
|
}
|
|
87
127
|
if (record.decision !== undefined) {
|
|
128
|
+
const refusal = approvalPolicyRefusal(policy, await store.getRunOwner(runId), record.decidedBy);
|
|
129
|
+
if (refusal) {
|
|
130
|
+
await store.updateRunState(runId, stateKey, {
|
|
131
|
+
...record,
|
|
132
|
+
decision: undefined,
|
|
133
|
+
decidedAt: undefined,
|
|
134
|
+
decidedBy: undefined,
|
|
135
|
+
error: [{ message: refusal }],
|
|
136
|
+
});
|
|
137
|
+
await store.auditApproval({
|
|
138
|
+
runId,
|
|
139
|
+
reason,
|
|
140
|
+
outcome: 'denied',
|
|
141
|
+
decidedBy: record.decidedBy,
|
|
142
|
+
refusal,
|
|
143
|
+
});
|
|
144
|
+
throw new WorkflowSuspendedException(runId, reason);
|
|
145
|
+
}
|
|
88
146
|
const validation = await options.schema['~standard'].validate(record.decision);
|
|
89
147
|
if (validation.issues) {
|
|
90
148
|
await store.updateRunState(runId, stateKey, {
|
|
91
149
|
...record,
|
|
92
150
|
decision: undefined,
|
|
93
151
|
decidedAt: undefined,
|
|
152
|
+
decidedBy: undefined,
|
|
94
153
|
error: validation.issues.map((issue) => ({
|
|
95
154
|
message: issue.message,
|
|
96
155
|
path: issue.path?.map((segment) => typeof segment === 'object' ? segment.key : segment),
|
|
@@ -98,9 +157,13 @@ export const evaluateApprovalStep = async (store, runId, reason, approvalStepNam
|
|
|
98
157
|
});
|
|
99
158
|
throw new WorkflowSuspendedException(runId, reason);
|
|
100
159
|
}
|
|
160
|
+
// Spread rather than assigned, so a gate answered without a session keeps
|
|
161
|
+
// the shape it had before there was anything to record.
|
|
101
162
|
const outcome = {
|
|
102
163
|
status: 'decided',
|
|
103
164
|
data: validation.value,
|
|
165
|
+
...(record.decidedBy ? { decidedBy: record.decidedBy } : {}),
|
|
166
|
+
...(record.decidedAt ? { decidedAt: record.decidedAt } : {}),
|
|
104
167
|
};
|
|
105
168
|
await store.setStepResult(stepState.stepId, outcome);
|
|
106
169
|
return outcome;
|
|
@@ -6,7 +6,8 @@ export declare class WorkflowRunForbiddenError extends ForbiddenError {
|
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
8
|
* A run started through a session records that session's user as its owner, and
|
|
9
|
-
* only that user may read it
|
|
9
|
+
* only that user may read it. Who may *answer* a run's approval gates is the
|
|
10
|
+
* gate's own declaration — see `approvers` on `workflow.approval()`.
|
|
10
11
|
*
|
|
11
12
|
* A run with no recorded owner — started by a trigger, a scheduler, or a route
|
|
12
13
|
* wired without auth — has nobody to compare a caller against, so ownership is
|
|
@@ -11,7 +11,8 @@ addError(WorkflowRunForbiddenError, {
|
|
|
11
11
|
});
|
|
12
12
|
/**
|
|
13
13
|
* A run started through a session records that session's user as its owner, and
|
|
14
|
-
* only that user may read it
|
|
14
|
+
* only that user may read it. Who may *answer* a run's approval gates is the
|
|
15
|
+
* gate's own declaration — see `approvers` on `workflow.approval()`.
|
|
15
16
|
*
|
|
16
17
|
* A run with no recorded owner — started by a trigger, a scheduler, or a route
|
|
17
18
|
* wired without auth — has nobody to compare a caller against, so ownership is
|
|
@@ -2,7 +2,7 @@ import type { SerializedError, CommonWireMeta } from '../../types/core.types.js'
|
|
|
2
2
|
import type { CorePikkuFunctionConfig, CorePikkuFunctionHook } from '../../function/functions.types.js';
|
|
3
3
|
import type { GroupConcurrencyConfig } from '../queue/queue.types.js';
|
|
4
4
|
export type { WorkflowService } from '../../services/workflow-service.js';
|
|
5
|
-
export type { WorkflowStepOptions,
|
|
5
|
+
export type { WorkflowStepOptions, WorkflowWireDoRPC, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, Condition, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, ScenarioStepInvocation, ScenarioStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './dsl/workflow-dsl.types.js';
|
|
6
6
|
export type { ScenarioStepPhase, ScenarioStepOptions, PikkuScenarioStepWire, PikkuBrowserWire, ScenarioBrowserProvider, } from './scenario-step.types.js';
|
|
7
7
|
import type { WorkflowStepMeta } from './dsl/workflow-dsl.types.js';
|
|
8
8
|
export interface WorkflowRunWire {
|
package/knowledge/decisions/internals/addon-pikku-meta-ships-at-the-package-root-or-under-dist.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: An addon's .pikku meta ships at the package root or under dist, and readers probe both
|
|
4
|
+
description: The layout depends on the addon's build and files field, so a consumer resolving the directory by path must try `<pkg>/.pikku` and `<pkg>/dist/.pikku` before concluding an addon declares nothing
|
|
5
|
+
tags: services
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# An addon's .pikku meta ships at the package root or under dist, and readers probe both
|
|
9
|
+
|
|
10
|
+
An addon generates `.pikku/` at its project root, and whether that directory
|
|
11
|
+
reaches the published tarball at the root or inside `dist/` depends on the
|
|
12
|
+
addon's own `tsconfig` and `files` — both layouts are in the wild and both are
|
|
13
|
+
supported. The published `exports` map hides the difference for anyone
|
|
14
|
+
resolving by specifier: `"./.pikku/*"` points at wherever it landed, so
|
|
15
|
+
`require.resolve('<pkg>/.pikku/scopes/pikku-scopes-meta.gen.json')` works for
|
|
16
|
+
either shape, and that is the resolution every consumer should prefer.
|
|
17
|
+
|
|
18
|
+
Code that builds the path itself must probe both. `addonPikkuDir`
|
|
19
|
+
(`packages/addon/pikku-console/src/lib/derive-instance-overrides.ts`) tries
|
|
20
|
+
`<pkg>/.pikku` then `<pkg>/dist/.pikku` and returns `null` when neither exists.
|
|
21
|
+
The reason to say so out loud is the failure mode: a reader that assumes the
|
|
22
|
+
root finds nothing under a `dist`-shipping addon and cannot distinguish that
|
|
23
|
+
from an addon that declares no secrets, variables or scopes at all. Both answers
|
|
24
|
+
are "empty", and the wrong one is indistinguishable from a correct one until
|
|
25
|
+
something downstream reports an addon as ready when its secrets were never
|
|
26
|
+
checked.
|
|
27
|
+
|
|
28
|
+
**What this rules out:** hardcoding either path in a consumer, and treating a
|
|
29
|
+
missing directory as evidence an addon declares nothing without having tried
|
|
30
|
+
both. It also rules out normalising the layout by making the CLI move the
|
|
31
|
+
directory at publish time — the export map already makes the layout private to
|
|
32
|
+
the package, and the addons shipping each shape are already published.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: An addon's scope root loses to a root the host app already declares
|
|
4
|
+
description: loadAddonFunctionsMeta merges addon scope trees by root name, first declaration wins, so an addon sharing a root with its host contributes nothing — silently
|
|
5
|
+
tags: services
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# An addon's scope root loses to a root the host app already declares
|
|
9
|
+
|
|
10
|
+
`loadAddonFunctionsMeta`
|
|
11
|
+
(`packages/inspector/src/utils/load-addon-functions-meta.ts`) reads each wired
|
|
12
|
+
addon's `pikku-scopes-meta.gen.json` and pushes its entries into
|
|
13
|
+
`state.scopes.definitions`, skipping any whose root name is already present. The
|
|
14
|
+
host app's own declarations were inspected first, so the host always wins.
|
|
15
|
+
|
|
16
|
+
The skip is per **root**, not per node. An addon declaring `admin.console.*` in
|
|
17
|
+
an app that declares `admin` contributes none of it — not the root it shares,
|
|
18
|
+
and not the branch the app never declared. Nothing errors: the scopes simply do
|
|
19
|
+
not exist, so `ScopeId` never gains them, no role can be granted one, and every
|
|
20
|
+
addon function requiring one denies everybody. The failure surfaces as a
|
|
21
|
+
`MissingScopeError` against a scope that cannot be granted, which reads as a
|
|
22
|
+
permissions bug rather than a merge that dropped.
|
|
23
|
+
|
|
24
|
+
This is a different rule from
|
|
25
|
+
[scope roots may be co-declared by an addon and its host
|
|
26
|
+
app](scope-roots-may-be-co-declared-by-an-addon-and-its-host-app.md): that one
|
|
27
|
+
is about `flattenScopeDefinitions` deduping ids *within* a build where both
|
|
28
|
+
declarations are present and identical. This one is about the addon's copy never
|
|
29
|
+
arriving. The consequence for addon authors is the same either way — own a root
|
|
30
|
+
outright, named for the package or vendor, and nest everything under it. It is
|
|
31
|
+
why `@pikku/addon-console` declares `pikku:console:*` rather than
|
|
32
|
+
`admin:console:*`.
|
|
33
|
+
|
|
34
|
+
**What this rules out:** shipping an addon whose scopes hang off a root a host
|
|
35
|
+
is likely to declare, and deep-merging the two trees here so both survive. A
|
|
36
|
+
merge would have to reconcile conflicting descriptions and display names for the
|
|
37
|
+
shared nodes, and would let an addon graft capabilities onto the host's `admin`
|
|
38
|
+
tree — where a role granting `admin` would pick them up without anyone having
|
|
39
|
+
asked for them.
|