@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
|
@@ -146,3 +146,78 @@ describe('workflow.expectService', () => {
|
|
|
146
146
|
)
|
|
147
147
|
})
|
|
148
148
|
})
|
|
149
|
+
|
|
150
|
+
describe('workflow.expectScore', () => {
|
|
151
|
+
beforeEach(() => resetPikkuState())
|
|
152
|
+
|
|
153
|
+
const grades = (grade: { score: number; reason?: string }) => ({
|
|
154
|
+
rpcWithWire: async (rpcName: string, data: any) => {
|
|
155
|
+
assert.equal(rpcName, 'pikkuScenarioGradeRun')
|
|
156
|
+
assert.equal(data.runId, 'run-1')
|
|
157
|
+
return grade
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('returns the grade when the score clears the bound', async () => {
|
|
162
|
+
const { wire } = await scenarioWire(grades({ score: 0.9 }))
|
|
163
|
+
|
|
164
|
+
const grade = await wire.expectScore('was brief', 'run-1', 'brevity', {
|
|
165
|
+
atLeast: 0.8,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
assert.equal(grade.score, 0.9)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('a scorer that graded zero fails without a bound being stated', async () => {
|
|
172
|
+
const { wire } = await scenarioWire(grades({ score: 0 }))
|
|
173
|
+
|
|
174
|
+
await assert.rejects(
|
|
175
|
+
wire.expectScore('was brief', 'run-1', 'brevity', { retries: 0 }),
|
|
176
|
+
/expected 'brevity' to grade run run-1 at least 0\.5, got 0/
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('the failure carries the reason the judge gave', async () => {
|
|
181
|
+
const { wire } = await scenarioWire(
|
|
182
|
+
grades({ score: 0.2, reason: 'It rambled for four paragraphs' })
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
await assert.rejects(
|
|
186
|
+
wire.expectScore('was brief', 'run-1', 'brevity', {
|
|
187
|
+
atLeast: 0.8,
|
|
188
|
+
retries: 0,
|
|
189
|
+
}),
|
|
190
|
+
/It rambled for four paragraphs/
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('atMost fails a score that is too high', async () => {
|
|
195
|
+
const { wire } = await scenarioWire(grades({ score: 0.95 }))
|
|
196
|
+
|
|
197
|
+
await assert.rejects(
|
|
198
|
+
wire.expectScore('was not sycophantic', 'run-1', 'sycophancy', {
|
|
199
|
+
atLeast: 0,
|
|
200
|
+
atMost: 0.3,
|
|
201
|
+
retries: 0,
|
|
202
|
+
}),
|
|
203
|
+
/between 0 and 0\.3, got 0\.95/
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('a reference answer reaches the grader', async () => {
|
|
208
|
+
let seen: any
|
|
209
|
+
const { wire } = await scenarioWire({
|
|
210
|
+
rpcWithWire: async (_rpcName: string, data: any) => {
|
|
211
|
+
seen = data
|
|
212
|
+
return { score: 1 }
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
await wire.expectScore('matched the answer key', 'run-1', 'correctness', {
|
|
217
|
+
reference: 'Paris',
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
assert.equal(seen.reference, 'Paris')
|
|
221
|
+
assert.equal(seen.scorer, 'correctness')
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -185,8 +185,9 @@ describe('scenario before/after hooks', () => {
|
|
|
185
185
|
})
|
|
186
186
|
|
|
187
187
|
test('after reads what the body wrote to the scenario context', async () => {
|
|
188
|
-
// The reason the context exists:
|
|
189
|
-
//
|
|
188
|
+
// The reason the context exists: a hook's `data` is only ever the run's
|
|
189
|
+
// *input*, never what the body produced. The context is the channel the
|
|
190
|
+
// body writes that through, and it reaches the hook on the shared wire.
|
|
190
191
|
let seen: any
|
|
191
192
|
await runScenario('contextHandover', {
|
|
192
193
|
after: async (_services, _data, wire) => {
|
|
@@ -229,6 +229,14 @@ export interface ScenarioBrowserProvider {
|
|
|
229
229
|
* scenario cannot leave the next signed in as somebody else.
|
|
230
230
|
*/
|
|
231
231
|
reset?(): Promise<void>
|
|
232
|
+
/**
|
|
233
|
+
* Name the scenario about to run, so anything it captures is filed under it.
|
|
234
|
+
*
|
|
235
|
+
* Called before each scenario. A provider that is never told has to fall
|
|
236
|
+
* back to one shared label, which puts every run's artifacts in a single
|
|
237
|
+
* folder — findable only by timestamp.
|
|
238
|
+
*/
|
|
239
|
+
beginScenario?(scenario: string): void
|
|
232
240
|
/**
|
|
233
241
|
* Snapshot every open window for a failed scenario. `label` identifies the
|
|
234
242
|
* scenario in artifact filenames. Never throws: a failure to capture must
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getSingletonServices } from '../../pikku-state.js'
|
|
2
|
+
import type { ApprovalAuditEvent } from './workflow-approval.js'
|
|
3
|
+
|
|
4
|
+
/** The audit type an answer to an approval gate is recorded under. */
|
|
5
|
+
export const APPROVAL_AUDIT_TYPE = 'workflow.approval.decided'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Record an answer to an approval gate where it outlives the run.
|
|
9
|
+
*
|
|
10
|
+
* The step result carries the settled decision, but it is deleted with the run
|
|
11
|
+
* — `deleteRun` cascades to steps and to history — and a refused attempt never
|
|
12
|
+
* reaches a step at all. An approval is asked for precisely so it can be
|
|
13
|
+
* answered for afterwards, so the answer also goes to the audit sink, which
|
|
14
|
+
* holds no foreign key to the run.
|
|
15
|
+
*
|
|
16
|
+
* A project with no sink wired records nothing, and a sink that fails is logged
|
|
17
|
+
* rather than thrown: the trail must not be the reason a decision is lost.
|
|
18
|
+
*/
|
|
19
|
+
export const auditApprovalDecision = async (
|
|
20
|
+
event: ApprovalAuditEvent
|
|
21
|
+
): Promise<void> => {
|
|
22
|
+
const services = getSingletonServices()
|
|
23
|
+
if (!services?.audit) {
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
await services.audit.audit({
|
|
28
|
+
type: APPROVAL_AUDIT_TYPE,
|
|
29
|
+
source: 'explicit',
|
|
30
|
+
outcome: event.outcome,
|
|
31
|
+
occurredAt: new Date().toISOString(),
|
|
32
|
+
wireType: 'workflow',
|
|
33
|
+
userIdentity: { pikkuUserId: event.decidedBy?.userId },
|
|
34
|
+
metadata: {
|
|
35
|
+
runId: event.runId,
|
|
36
|
+
reason: event.reason,
|
|
37
|
+
scopes: event.decidedBy?.scopes,
|
|
38
|
+
refusal: event.refusal,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
} catch (error) {
|
|
42
|
+
services.logger?.warn(
|
|
43
|
+
`Failed to audit the decision on approval '${event.reason}' for run ${event.runId}`,
|
|
44
|
+
error
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
4
|
+
|
|
5
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
6
|
+
import { pikkuState } from '../../pikku-state.js'
|
|
7
|
+
import { addWorkflow } from './dsl/workflow-runner.js'
|
|
8
|
+
import { WorkflowSuspendedException } from './workflow-errors.js'
|
|
9
|
+
import {
|
|
10
|
+
approvalPolicyRefusal,
|
|
11
|
+
WorkflowApprovalForbiddenError,
|
|
12
|
+
} from './workflow-approval-policy.js'
|
|
13
|
+
import { approvalStateKey, approvalStepNameFor } from './workflow-approval.js'
|
|
14
|
+
import type {
|
|
15
|
+
PikkuWorkflowWire,
|
|
16
|
+
WorkflowApprovalOptions,
|
|
17
|
+
} from './workflow.types.js'
|
|
18
|
+
|
|
19
|
+
const anySchema: StandardSchemaV1<unknown, unknown> = {
|
|
20
|
+
'~standard': {
|
|
21
|
+
version: 1,
|
|
22
|
+
vendor: 'test',
|
|
23
|
+
validate: (value) => ({ value }),
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const registerApprovalWorkflow = (
|
|
28
|
+
workflowName: string,
|
|
29
|
+
graphHash: string,
|
|
30
|
+
options: Omit<WorkflowApprovalOptions, 'schema'>,
|
|
31
|
+
audit?: { audit: (event: unknown) => Promise<void> }
|
|
32
|
+
) => {
|
|
33
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
34
|
+
logger: { error() {}, info() {}, warn() {}, debug() {} },
|
|
35
|
+
queueService: { add: async () => {} },
|
|
36
|
+
audit,
|
|
37
|
+
} as any)
|
|
38
|
+
|
|
39
|
+
const metaState = pikkuState(null, 'workflows', 'meta')
|
|
40
|
+
metaState[workflowName] = {
|
|
41
|
+
name: workflowName,
|
|
42
|
+
pikkuFuncId: workflowName,
|
|
43
|
+
source: 'dsl',
|
|
44
|
+
graphHash,
|
|
45
|
+
}
|
|
46
|
+
const functionMetaState = pikkuState(null, 'function', 'meta')
|
|
47
|
+
functionMetaState[workflowName] = {
|
|
48
|
+
name: workflowName,
|
|
49
|
+
sessionless: true,
|
|
50
|
+
permissions: [],
|
|
51
|
+
} as any
|
|
52
|
+
|
|
53
|
+
addWorkflow(workflowName, {
|
|
54
|
+
func: async (
|
|
55
|
+
_services: any,
|
|
56
|
+
_data: any,
|
|
57
|
+
{ workflow }: { workflow: PikkuWorkflowWire }
|
|
58
|
+
) => {
|
|
59
|
+
const decision = await workflow.approval('Release funds', {
|
|
60
|
+
schema: anySchema,
|
|
61
|
+
...options,
|
|
62
|
+
})
|
|
63
|
+
return { decision }
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
delete metaState[workflowName]
|
|
69
|
+
delete functionMetaState[workflowName]
|
|
70
|
+
pikkuState(null, 'workflows', 'registrations').delete(workflowName)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Run until the gate suspends, so the gate has published its policy. */
|
|
75
|
+
const runToGate = async (ws: InMemoryWorkflowService, runId: string) => {
|
|
76
|
+
await assert.rejects(
|
|
77
|
+
ws.runWorkflowJob(runId, {}),
|
|
78
|
+
(error: unknown) => error instanceof WorkflowSuspendedException
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('approval policy', () => {
|
|
83
|
+
test('the default lets anyone the entrypoint admitted answer', () => {
|
|
84
|
+
assert.equal(approvalPolicyRefusal({}, 'owner', undefined), undefined)
|
|
85
|
+
assert.equal(
|
|
86
|
+
approvalPolicyRefusal({}, 'owner', { userId: 'someone-else' }),
|
|
87
|
+
undefined
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('owner admits only the user who started the run', () => {
|
|
92
|
+
const policy = { approvers: 'owner' as const }
|
|
93
|
+
assert.equal(
|
|
94
|
+
approvalPolicyRefusal(policy, 'owner', { userId: 'owner' }),
|
|
95
|
+
undefined
|
|
96
|
+
)
|
|
97
|
+
assert.match(
|
|
98
|
+
approvalPolicyRefusal(policy, 'owner', { userId: 'other' })!,
|
|
99
|
+
/Only the user who started this run/
|
|
100
|
+
)
|
|
101
|
+
assert.match(
|
|
102
|
+
approvalPolicyRefusal(policy, 'owner', undefined)!,
|
|
103
|
+
/Only the user who started this run/
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('a run with no initiator has no owner to compare against', () => {
|
|
108
|
+
assert.equal(
|
|
109
|
+
approvalPolicyRefusal({ approvers: 'owner' }, undefined, {
|
|
110
|
+
userId: 'anyone',
|
|
111
|
+
}),
|
|
112
|
+
undefined
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('not-initiator excludes the user who started the run', () => {
|
|
117
|
+
const policy = { approvers: 'not-initiator' as const }
|
|
118
|
+
assert.match(
|
|
119
|
+
approvalPolicyRefusal(policy, 'owner', { userId: 'owner' })!,
|
|
120
|
+
/may not answer/
|
|
121
|
+
)
|
|
122
|
+
assert.equal(
|
|
123
|
+
approvalPolicyRefusal(policy, 'owner', { userId: 'reviewer' }),
|
|
124
|
+
undefined
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('not-initiator still requires a signed-in decider on an unowned run', () => {
|
|
129
|
+
assert.match(
|
|
130
|
+
approvalPolicyRefusal({ approvers: 'not-initiator' }, undefined, {})!,
|
|
131
|
+
/requires a signed-in user/
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('approverScope is required on top of the approvers rule', () => {
|
|
136
|
+
const policy = { approvers: 'not-initiator' as const, approverScope: 'sre' }
|
|
137
|
+
assert.match(
|
|
138
|
+
approvalPolicyRefusal(policy, 'owner', { userId: 'reviewer' })!,
|
|
139
|
+
/requires the 'sre' scope/
|
|
140
|
+
)
|
|
141
|
+
assert.equal(
|
|
142
|
+
approvalPolicyRefusal(policy, 'owner', {
|
|
143
|
+
userId: 'reviewer',
|
|
144
|
+
scopes: ['sre'],
|
|
145
|
+
}),
|
|
146
|
+
undefined
|
|
147
|
+
)
|
|
148
|
+
assert.match(
|
|
149
|
+
approvalPolicyRefusal(policy, 'owner', {
|
|
150
|
+
userId: 'owner',
|
|
151
|
+
scopes: ['sre'],
|
|
152
|
+
})!,
|
|
153
|
+
/may not answer/
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('approval policy enforcement', () => {
|
|
159
|
+
test('a gate with no declared policy accepts a caller who does not own the run', async () => {
|
|
160
|
+
const ws = new InMemoryWorkflowService()
|
|
161
|
+
const cleanup = registerApprovalWorkflow('openGate', 'open-gate', {})
|
|
162
|
+
|
|
163
|
+
const runId = await ws.createRun('openGate', {}, false, 'open-gate', {
|
|
164
|
+
type: 'http',
|
|
165
|
+
pikkuUserId: 'initiator',
|
|
166
|
+
})
|
|
167
|
+
await runToGate(ws, runId)
|
|
168
|
+
|
|
169
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
170
|
+
userId: 'someone-else',
|
|
171
|
+
})
|
|
172
|
+
await ws.runWorkflowJob(runId, {})
|
|
173
|
+
|
|
174
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
175
|
+
cleanup()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('a four-eyes gate refuses the initiator and admits a second user', async () => {
|
|
179
|
+
const ws = new InMemoryWorkflowService()
|
|
180
|
+
const cleanup = registerApprovalWorkflow('fourEyes', 'four-eyes', {
|
|
181
|
+
approvers: 'not-initiator',
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const runId = await ws.createRun('fourEyes', {}, false, 'four-eyes', {
|
|
185
|
+
type: 'http',
|
|
186
|
+
pikkuUserId: 'initiator',
|
|
187
|
+
})
|
|
188
|
+
await runToGate(ws, runId)
|
|
189
|
+
|
|
190
|
+
await assert.rejects(
|
|
191
|
+
ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
192
|
+
userId: 'initiator',
|
|
193
|
+
}),
|
|
194
|
+
WorkflowApprovalForbiddenError
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
198
|
+
userId: 'reviewer',
|
|
199
|
+
})
|
|
200
|
+
await ws.runWorkflowJob(runId, {})
|
|
201
|
+
|
|
202
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
203
|
+
cleanup()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('an owner-only gate refuses a caller who did not start the run', async () => {
|
|
207
|
+
const ws = new InMemoryWorkflowService()
|
|
208
|
+
const cleanup = registerApprovalWorkflow('ownerGate', 'owner-gate', {
|
|
209
|
+
approvers: 'owner',
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const runId = await ws.createRun('ownerGate', {}, false, 'owner-gate', {
|
|
213
|
+
type: 'http',
|
|
214
|
+
pikkuUserId: 'initiator',
|
|
215
|
+
})
|
|
216
|
+
await runToGate(ws, runId)
|
|
217
|
+
|
|
218
|
+
await assert.rejects(
|
|
219
|
+
ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
220
|
+
userId: 'attacker',
|
|
221
|
+
}),
|
|
222
|
+
WorkflowApprovalForbiddenError
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
226
|
+
userId: 'initiator',
|
|
227
|
+
})
|
|
228
|
+
await ws.runWorkflowJob(runId, {})
|
|
229
|
+
|
|
230
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
231
|
+
cleanup()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('a scoped gate refuses a decider without the scope', async () => {
|
|
235
|
+
const ws = new InMemoryWorkflowService()
|
|
236
|
+
const cleanup = registerApprovalWorkflow('scopedGate', 'scoped-gate', {
|
|
237
|
+
approverScope: 'payments:approve',
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const runId = await ws.createRun('scopedGate', {}, false, 'scoped-gate', {
|
|
241
|
+
type: 'http',
|
|
242
|
+
pikkuUserId: 'initiator',
|
|
243
|
+
})
|
|
244
|
+
await runToGate(ws, runId)
|
|
245
|
+
|
|
246
|
+
await assert.rejects(
|
|
247
|
+
ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
248
|
+
userId: 'reviewer',
|
|
249
|
+
scopes: ['payments:read'],
|
|
250
|
+
}),
|
|
251
|
+
WorkflowApprovalForbiddenError
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
255
|
+
userId: 'reviewer',
|
|
256
|
+
scopes: ['payments:approve'],
|
|
257
|
+
})
|
|
258
|
+
await ws.runWorkflowJob(runId, {})
|
|
259
|
+
|
|
260
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
261
|
+
cleanup()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Run state is where a decision waits, not where it is kept: the record is
|
|
266
|
+
* overwritten by the next write to that key and cleared outright whenever a
|
|
267
|
+
* decision is refused. Four-eyes exists to be audited, so who signed and when
|
|
268
|
+
* has to reach the step result, which is append-only and carried into
|
|
269
|
+
* workflowStepHistory.
|
|
270
|
+
*/
|
|
271
|
+
test('the decider and the moment reach the step result, not just run state', async () => {
|
|
272
|
+
const ws = new InMemoryWorkflowService()
|
|
273
|
+
const cleanup = registerApprovalWorkflow('signedGate', 'signed-gate', {
|
|
274
|
+
approvers: 'not-initiator',
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
const runId = await ws.createRun('signedGate', {}, false, 'signed-gate', {
|
|
278
|
+
type: 'http',
|
|
279
|
+
pikkuUserId: 'initiator',
|
|
280
|
+
})
|
|
281
|
+
await runToGate(ws, runId)
|
|
282
|
+
|
|
283
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
284
|
+
userId: 'reviewer',
|
|
285
|
+
scopes: ['payments:approve'],
|
|
286
|
+
})
|
|
287
|
+
await ws.runWorkflowJob(runId, {})
|
|
288
|
+
|
|
289
|
+
const step = await ws.getStepState(
|
|
290
|
+
runId,
|
|
291
|
+
approvalStepNameFor('Release funds')
|
|
292
|
+
)
|
|
293
|
+
const outcome = step.result as {
|
|
294
|
+
status: string
|
|
295
|
+
decidedBy?: { userId?: string; scopes?: string[] }
|
|
296
|
+
decidedAt?: string
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
assert.equal(outcome.status, 'decided')
|
|
300
|
+
assert.equal(outcome.decidedBy?.userId, 'reviewer')
|
|
301
|
+
assert.deepEqual(outcome.decidedBy?.scopes, ['payments:approve'])
|
|
302
|
+
assert.ok(
|
|
303
|
+
outcome.decidedAt && !Number.isNaN(Date.parse(outcome.decidedAt)),
|
|
304
|
+
'decidedAt is an ISO timestamp'
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
cleanup()
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('an expired gate has nobody to record', async () => {
|
|
311
|
+
const ws = new InMemoryWorkflowService()
|
|
312
|
+
const cleanup = registerApprovalWorkflow('expiredGate', 'expired-gate', {
|
|
313
|
+
expiry: 100,
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
const runId = await ws.createRun('expiredGate', {}, false, 'expired-gate', {
|
|
317
|
+
type: 'http',
|
|
318
|
+
pikkuUserId: 'initiator',
|
|
319
|
+
})
|
|
320
|
+
await runToGate(ws, runId)
|
|
321
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
322
|
+
await ws.runWorkflowJob(runId, {})
|
|
323
|
+
|
|
324
|
+
const step = await ws.getStepState(
|
|
325
|
+
runId,
|
|
326
|
+
approvalStepNameFor('Release funds')
|
|
327
|
+
)
|
|
328
|
+
assert.deepEqual(step.result, { status: 'expired' })
|
|
329
|
+
|
|
330
|
+
cleanup()
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test('a decision recorded before the run reaches the gate is still judged', async () => {
|
|
334
|
+
const ws = new InMemoryWorkflowService()
|
|
335
|
+
const cleanup = registerApprovalWorkflow('earlyGate', 'early-gate', {
|
|
336
|
+
approvers: 'not-initiator',
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
const runId = await ws.createRun('earlyGate', {}, false, 'early-gate', {
|
|
340
|
+
type: 'http',
|
|
341
|
+
pikkuUserId: 'initiator',
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
// The gate has not been reached, so no policy has been published and the
|
|
345
|
+
// submission cannot be judged here — it is accepted and judged on replay.
|
|
346
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
347
|
+
userId: 'initiator',
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
await runToGate(ws, runId)
|
|
351
|
+
|
|
352
|
+
const stateKey = approvalStateKey(approvalStepNameFor('Release funds'))
|
|
353
|
+
const record = (await ws.getRunState(runId))[stateKey] as {
|
|
354
|
+
decision?: unknown
|
|
355
|
+
error?: Array<{ message: string }>
|
|
356
|
+
}
|
|
357
|
+
assert.equal(record.decision, undefined, 'the decision was cleared')
|
|
358
|
+
assert.match(record.error?.[0]?.message ?? '', /may not answer/)
|
|
359
|
+
assert.equal((await ws.getRun(runId))?.status, 'suspended')
|
|
360
|
+
|
|
361
|
+
cleanup()
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The step result is deleted with the run — `deleteRun` cascades to steps and
|
|
367
|
+
* to history — and a refused attempt never reaches a step at all. An approval
|
|
368
|
+
* is asked for so it can be answered for afterwards, so it also goes to the
|
|
369
|
+
* audit sink, which holds no foreign key to the run.
|
|
370
|
+
*/
|
|
371
|
+
describe('approval audit trail', () => {
|
|
372
|
+
const collectingAudit = () => {
|
|
373
|
+
const events: any[] = []
|
|
374
|
+
return {
|
|
375
|
+
events,
|
|
376
|
+
sink: { audit: async (event: any) => void events.push(event) },
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
test('an accepted decision is recorded against the user who made it', async () => {
|
|
381
|
+
const ws = new InMemoryWorkflowService()
|
|
382
|
+
const { events, sink } = collectingAudit()
|
|
383
|
+
const cleanup = registerApprovalWorkflow(
|
|
384
|
+
'auditedGate',
|
|
385
|
+
'audited-gate',
|
|
386
|
+
{ approvers: 'not-initiator' },
|
|
387
|
+
sink
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
const runId = await ws.createRun('auditedGate', {}, false, 'audited-gate', {
|
|
391
|
+
type: 'http',
|
|
392
|
+
pikkuUserId: 'initiator',
|
|
393
|
+
})
|
|
394
|
+
await runToGate(ws, runId)
|
|
395
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
396
|
+
userId: 'reviewer',
|
|
397
|
+
scopes: ['payments:approve'],
|
|
398
|
+
})
|
|
399
|
+
await ws.runWorkflowJob(runId, {})
|
|
400
|
+
|
|
401
|
+
assert.equal(events.length, 1)
|
|
402
|
+
assert.equal(events[0].type, 'workflow.approval.decided')
|
|
403
|
+
assert.equal(events[0].outcome, 'success')
|
|
404
|
+
assert.equal(events[0].wireType, 'workflow')
|
|
405
|
+
assert.equal(events[0].userIdentity.pikkuUserId, 'reviewer')
|
|
406
|
+
assert.equal(events[0].metadata.runId, runId)
|
|
407
|
+
assert.equal(events[0].metadata.reason, 'Release funds')
|
|
408
|
+
assert.deepEqual(events[0].metadata.scopes, ['payments:approve'])
|
|
409
|
+
assert.ok(!Number.isNaN(Date.parse(events[0].occurredAt)))
|
|
410
|
+
|
|
411
|
+
cleanup()
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
test('a refused attempt is recorded too — that is the one worth keeping', async () => {
|
|
415
|
+
const ws = new InMemoryWorkflowService()
|
|
416
|
+
const { events, sink } = collectingAudit()
|
|
417
|
+
const cleanup = registerApprovalWorkflow(
|
|
418
|
+
'refusedGate',
|
|
419
|
+
'refused-gate',
|
|
420
|
+
{ approvers: 'not-initiator' },
|
|
421
|
+
sink
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
const runId = await ws.createRun('refusedGate', {}, false, 'refused-gate', {
|
|
425
|
+
type: 'http',
|
|
426
|
+
pikkuUserId: 'initiator',
|
|
427
|
+
})
|
|
428
|
+
await runToGate(ws, runId)
|
|
429
|
+
|
|
430
|
+
await assert.rejects(
|
|
431
|
+
ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
432
|
+
userId: 'initiator',
|
|
433
|
+
}),
|
|
434
|
+
WorkflowApprovalForbiddenError
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
assert.equal(events.length, 1)
|
|
438
|
+
assert.equal(events[0].outcome, 'denied')
|
|
439
|
+
assert.equal(events[0].userIdentity.pikkuUserId, 'initiator')
|
|
440
|
+
assert.match(events[0].metadata.refusal, /may not answer/)
|
|
441
|
+
|
|
442
|
+
cleanup()
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
test('a decision refused on replay is recorded, having been refused nowhere else', async () => {
|
|
446
|
+
const ws = new InMemoryWorkflowService()
|
|
447
|
+
const { events, sink } = collectingAudit()
|
|
448
|
+
const cleanup = registerApprovalWorkflow(
|
|
449
|
+
'replayRefused',
|
|
450
|
+
'replay-refused',
|
|
451
|
+
{ approvers: 'not-initiator' },
|
|
452
|
+
sink
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
const runId = await ws.createRun(
|
|
456
|
+
'replayRefused',
|
|
457
|
+
{},
|
|
458
|
+
false,
|
|
459
|
+
'replay-refused',
|
|
460
|
+
{ type: 'http', pikkuUserId: 'initiator' }
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
// Submitted before the gate published its policy, so it is accepted here
|
|
464
|
+
// and only refused on replay.
|
|
465
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
466
|
+
userId: 'initiator',
|
|
467
|
+
})
|
|
468
|
+
assert.equal(events[0].outcome, 'success')
|
|
469
|
+
|
|
470
|
+
await runToGate(ws, runId)
|
|
471
|
+
|
|
472
|
+
assert.equal(events.length, 2)
|
|
473
|
+
assert.equal(events[1].outcome, 'denied')
|
|
474
|
+
assert.equal(events[1].userIdentity.pikkuUserId, 'initiator')
|
|
475
|
+
assert.match(events[1].metadata.refusal, /may not answer/)
|
|
476
|
+
|
|
477
|
+
cleanup()
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
test('a failing sink does not cost the decision', async () => {
|
|
481
|
+
const ws = new InMemoryWorkflowService()
|
|
482
|
+
const cleanup = registerApprovalWorkflow(
|
|
483
|
+
'brokenSink',
|
|
484
|
+
'broken-sink',
|
|
485
|
+
{},
|
|
486
|
+
{
|
|
487
|
+
audit: async () => {
|
|
488
|
+
throw new Error('sink is down')
|
|
489
|
+
},
|
|
490
|
+
}
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
const runId = await ws.createRun('brokenSink', {}, false, 'broken-sink', {
|
|
494
|
+
type: 'http',
|
|
495
|
+
pikkuUserId: 'initiator',
|
|
496
|
+
})
|
|
497
|
+
await runToGate(ws, runId)
|
|
498
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
499
|
+
userId: 'reviewer',
|
|
500
|
+
})
|
|
501
|
+
await ws.runWorkflowJob(runId, {})
|
|
502
|
+
|
|
503
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
504
|
+
cleanup()
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
test('a project with no audit sink wired is unaffected', async () => {
|
|
508
|
+
const ws = new InMemoryWorkflowService()
|
|
509
|
+
const cleanup = registerApprovalWorkflow('noSink', 'no-sink', {})
|
|
510
|
+
|
|
511
|
+
const runId = await ws.createRun('noSink', {}, false, 'no-sink', {
|
|
512
|
+
type: 'http',
|
|
513
|
+
pikkuUserId: 'initiator',
|
|
514
|
+
})
|
|
515
|
+
await runToGate(ws, runId)
|
|
516
|
+
await ws.approveStep(runId, 'Release funds', { ok: true }, {
|
|
517
|
+
userId: 'reviewer',
|
|
518
|
+
})
|
|
519
|
+
await ws.runWorkflowJob(runId, {})
|
|
520
|
+
|
|
521
|
+
assert.equal((await ws.getRun(runId))?.status, 'completed')
|
|
522
|
+
cleanup()
|
|
523
|
+
})
|
|
524
|
+
})
|