@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.
Files changed (231) hide show
  1. package/CHANGELOG.md +312 -0
  2. package/dist/errors/index.d.ts +1 -1
  3. package/dist/errors/index.js +1 -1
  4. package/dist/function/function-runner.js +2 -5
  5. package/dist/function/index.d.ts +1 -1
  6. package/dist/index.d.ts +11 -11
  7. package/dist/index.js +3 -3
  8. package/dist/pikku-state.js +4 -0
  9. package/dist/services/ai-agent-runner-service.d.ts +7 -0
  10. package/dist/services/ai-run-state-service.d.ts +10 -0
  11. package/dist/services/in-memory-ai-run-state-service.d.ts +5 -1
  12. package/dist/services/in-memory-ai-run-state-service.js +9 -0
  13. package/dist/services/index.d.ts +15 -15
  14. package/dist/services/index.js +5 -5
  15. package/dist/services/scoped-credential-service.d.ts +21 -0
  16. package/dist/services/scoped-credential-service.js +53 -0
  17. package/dist/testing/service-tests/ai-storage-service-tests.js +76 -0
  18. package/dist/types/core.types.d.ts +0 -2
  19. package/dist/types/state.types.d.ts +13 -0
  20. package/dist/wirings/actor-flow/index.d.ts +1 -1
  21. package/dist/wirings/ai-agent/ai-agent-finalize.d.ts +58 -0
  22. package/dist/wirings/ai-agent/ai-agent-finalize.js +138 -0
  23. package/dist/wirings/ai-agent/ai-agent-interrupt.js +1 -0
  24. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +2 -8
  25. package/dist/wirings/ai-agent/ai-agent-memory.js +34 -17
  26. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +7 -0
  27. package/dist/wirings/ai-agent/ai-agent-model-config.js +44 -1
  28. package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -0
  29. package/dist/wirings/ai-agent/ai-agent-runner.js +61 -40
  30. package/dist/wirings/ai-agent/ai-agent-stream.js +89 -36
  31. package/dist/wirings/ai-agent/ai-agent-turn.d.ts +1 -0
  32. package/dist/wirings/ai-agent/ai-agent-turn.js +1 -0
  33. package/dist/wirings/ai-agent/ai-agent.types.d.ts +46 -1
  34. package/dist/wirings/ai-agent/index.d.ts +8 -7
  35. package/dist/wirings/ai-agent/index.js +5 -4
  36. package/dist/wirings/ai-scorer/ai-scorer-grade.d.ts +26 -0
  37. package/dist/wirings/ai-scorer/ai-scorer-grade.js +33 -0
  38. package/dist/wirings/ai-scorer/ai-scorer-judge.d.ts +17 -0
  39. package/dist/wirings/ai-scorer/ai-scorer-judge.js +92 -0
  40. package/dist/wirings/ai-scorer/ai-scorer-live.d.ts +15 -0
  41. package/dist/wirings/ai-scorer/ai-scorer-live.js +38 -0
  42. package/dist/wirings/ai-scorer/ai-scorer-registry.d.ts +18 -0
  43. package/dist/wirings/ai-scorer/ai-scorer-registry.js +46 -0
  44. package/dist/wirings/ai-scorer/ai-scorer-sampling.d.ts +8 -0
  45. package/dist/wirings/ai-scorer/ai-scorer-sampling.js +31 -0
  46. package/dist/wirings/ai-scorer/ai-scorer-snapshots.d.ts +10 -0
  47. package/dist/wirings/ai-scorer/ai-scorer-snapshots.js +40 -0
  48. package/dist/wirings/ai-scorer/ai-scorer-worker.d.ts +15 -0
  49. package/dist/wirings/ai-scorer/ai-scorer-worker.js +58 -0
  50. package/dist/wirings/ai-scorer/ai-scorer.d.ts +39 -0
  51. package/dist/wirings/ai-scorer/ai-scorer.js +40 -0
  52. package/dist/wirings/ai-scorer/ai-scorer.types.d.ts +90 -0
  53. package/dist/wirings/ai-scorer/ai-scorer.types.js +4 -0
  54. package/dist/wirings/ai-scorer/index.d.ts +6 -0
  55. package/dist/wirings/ai-scorer/index.js +5 -0
  56. package/dist/wirings/channel/index.d.ts +5 -6
  57. package/dist/wirings/channel/index.js +3 -4
  58. package/dist/wirings/channel/local/local-channel-runner.js +8 -1
  59. package/dist/wirings/cli/channel/cli-raw-channel-runner.js +9 -1
  60. package/dist/wirings/cli/channel/index.d.ts +1 -2
  61. package/dist/wirings/cli/channel/index.js +0 -1
  62. package/dist/wirings/cli/cli-runner.js +13 -1
  63. package/dist/wirings/credential/index.d.ts +1 -1
  64. package/dist/wirings/gateway/index.d.ts +1 -1
  65. package/dist/wirings/http/http-runner.js +8 -2
  66. package/dist/wirings/http/index.d.ts +1 -2
  67. package/dist/wirings/mcp/index.d.ts +1 -1
  68. package/dist/wirings/mcp/mcp-runner.d.ts +15 -0
  69. package/dist/wirings/mcp/mcp-runner.js +18 -5
  70. package/dist/wirings/persona/index.d.ts +3 -4
  71. package/dist/wirings/persona/index.js +2 -3
  72. package/dist/wirings/queue/index.d.ts +1 -3
  73. package/dist/wirings/queue/index.js +1 -3
  74. package/dist/wirings/rpc/addon-runner.d.ts +4 -0
  75. package/dist/wirings/rpc/addon-runner.js +19 -3
  76. package/dist/wirings/rpc/rpc-runner.js +2 -0
  77. package/dist/wirings/rpc/rpc-types.d.ts +4 -0
  78. package/dist/wirings/rpc/wire-addon.d.ts +13 -0
  79. package/dist/wirings/rpc/wire-addon.js +4 -0
  80. package/dist/wirings/scheduler/index.d.ts +1 -1
  81. package/dist/wirings/trigger/index.d.ts +1 -1
  82. package/dist/wirings/virtual-user/index.d.ts +5 -6
  83. package/dist/wirings/virtual-user/index.js +2 -4
  84. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +85 -15
  85. package/dist/wirings/workflow/index.d.ts +6 -6
  86. package/dist/wirings/workflow/index.js +2 -2
  87. package/dist/wirings/workflow/pikku-scenario-service.d.ts +7 -7
  88. package/dist/wirings/workflow/pikku-scenario-service.js +39 -13
  89. package/dist/wirings/workflow/pikku-workflow-service.js +17 -3
  90. package/dist/wirings/workflow/scenario-step.types.d.ts +8 -0
  91. package/dist/wirings/workflow/workflow-approval-audit.d.ts +16 -0
  92. package/dist/wirings/workflow/workflow-approval-audit.js +40 -0
  93. package/dist/wirings/workflow/workflow-approval-policy.d.ts +20 -0
  94. package/dist/wirings/workflow/workflow-approval-policy.js +48 -0
  95. package/dist/wirings/workflow/workflow-approval.d.ts +29 -1
  96. package/dist/wirings/workflow/workflow-approval.js +65 -2
  97. package/dist/wirings/workflow/workflow-run-ownership.d.ts +2 -1
  98. package/dist/wirings/workflow/workflow-run-ownership.js +2 -1
  99. package/dist/wirings/workflow/workflow.types.d.ts +1 -1
  100. package/knowledge/decisions/internals/addon-pikku-meta-ships-at-the-package-root-or-under-dist.md +32 -0
  101. package/knowledge/decisions/internals/an-addon-scope-root-loses-to-a-root-the-host-already-declares.md +39 -0
  102. package/knowledge/decisions/internals/index.md +30 -3
  103. package/knowledge/decisions/internals/validate-runs-checks-by-precondition.md +115 -0
  104. package/knowledge/decisions/security/a-function-never-receives-the-secret-service.md +37 -0
  105. package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +30 -14
  106. package/knowledge/decisions/security/an-approval-answer-outlives-the-run-it-answered.md +59 -0
  107. package/knowledge/decisions/security/index.md +3 -1
  108. package/knowledge/questions/index.md +1 -1
  109. package/package.json +3 -1
  110. package/scripts/generate-api-report.mts +143 -18
  111. package/src/api-report.test.ts +2 -2
  112. package/src/errors/index.ts +1 -1
  113. package/src/function/function-runner.test.ts +52 -0
  114. package/src/function/function-runner.ts +5 -9
  115. package/src/function/index.ts +0 -2
  116. package/src/index.ts +0 -35
  117. package/src/pikku-state.ts +5 -0
  118. package/src/public-surface.json +70 -94
  119. package/src/services/ai-agent-runner-service.ts +12 -1
  120. package/src/services/ai-run-state-service.ts +11 -0
  121. package/src/services/in-memory-ai-run-state-service.ts +13 -0
  122. package/src/services/index.ts +3 -43
  123. package/src/services/scoped-credential-service.test.ts +86 -0
  124. package/src/services/scoped-credential-service.ts +63 -0
  125. package/src/testing/service-tests/ai-storage-service-tests.ts +93 -0
  126. package/src/types/core.types.ts +3 -6
  127. package/src/types/state.types.ts +16 -0
  128. package/src/wirings/actor-flow/index.ts +0 -3
  129. package/src/wirings/ai-agent/ai-agent-finalize.test.ts +186 -0
  130. package/src/wirings/ai-agent/ai-agent-finalize.ts +197 -0
  131. package/src/wirings/ai-agent/ai-agent-interrupt.ts +1 -0
  132. package/src/wirings/ai-agent/ai-agent-memory.ts +54 -38
  133. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +72 -3
  134. package/src/wirings/ai-agent/ai-agent-model-config.ts +49 -1
  135. package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -0
  136. package/src/wirings/ai-agent/ai-agent-runner.ts +71 -40
  137. package/src/wirings/ai-agent/ai-agent-stream-output-hooks.test.ts +353 -0
  138. package/src/wirings/ai-agent/ai-agent-stream.ts +116 -54
  139. package/src/wirings/ai-agent/ai-agent-turn.test.ts +67 -0
  140. package/src/wirings/ai-agent/ai-agent-turn.ts +1 -0
  141. package/src/wirings/ai-agent/ai-agent.types.ts +64 -4
  142. package/src/wirings/ai-agent/index.ts +2 -16
  143. package/src/wirings/ai-scorer/ai-scorer-grade.test.ts +106 -0
  144. package/src/wirings/ai-scorer/ai-scorer-grade.ts +55 -0
  145. package/src/wirings/ai-scorer/ai-scorer-judge.test.ts +143 -0
  146. package/src/wirings/ai-scorer/ai-scorer-judge.ts +120 -0
  147. package/src/wirings/ai-scorer/ai-scorer-live.test.ts +174 -0
  148. package/src/wirings/ai-scorer/ai-scorer-live.ts +56 -0
  149. package/src/wirings/ai-scorer/ai-scorer-registry.ts +63 -0
  150. package/src/wirings/ai-scorer/ai-scorer-sampling.test.ts +34 -0
  151. package/src/wirings/ai-scorer/ai-scorer-sampling.ts +36 -0
  152. package/src/wirings/ai-scorer/ai-scorer-snapshots.test.ts +49 -0
  153. package/src/wirings/ai-scorer/ai-scorer-snapshots.ts +46 -0
  154. package/src/wirings/ai-scorer/ai-scorer-worker.test.ts +122 -0
  155. package/src/wirings/ai-scorer/ai-scorer-worker.ts +69 -0
  156. package/src/wirings/ai-scorer/ai-scorer.ts +76 -0
  157. package/src/wirings/ai-scorer/ai-scorer.types.ts +107 -0
  158. package/src/wirings/ai-scorer/index.ts +24 -0
  159. package/src/wirings/channel/index.ts +1 -20
  160. package/src/wirings/channel/local/local-channel-runner.test.ts +68 -0
  161. package/src/wirings/channel/local/local-channel-runner.ts +8 -1
  162. package/src/wirings/cli/channel/cli-raw-channel-runner.test.ts +23 -0
  163. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +12 -1
  164. package/src/wirings/cli/channel/index.ts +0 -7
  165. package/src/wirings/cli/cli-runner.test.ts +68 -0
  166. package/src/wirings/cli/cli-runner.ts +18 -1
  167. package/src/wirings/credential/index.ts +0 -1
  168. package/src/wirings/gateway/index.ts +0 -3
  169. package/src/wirings/http/http-runner.test.ts +66 -0
  170. package/src/wirings/http/http-runner.ts +10 -2
  171. package/src/wirings/http/index.ts +1 -1
  172. package/src/wirings/mcp/index.ts +0 -1
  173. package/src/wirings/mcp/mcp-runner.test.ts +181 -0
  174. package/src/wirings/mcp/mcp-runner.ts +35 -5
  175. package/src/wirings/persona/index.ts +0 -8
  176. package/src/wirings/queue/index.ts +0 -14
  177. package/src/wirings/rpc/addon-runner.ts +34 -3
  178. package/src/wirings/rpc/addon-secrets.test.ts +261 -0
  179. package/src/wirings/rpc/rpc-runner.test.ts +2 -0
  180. package/src/wirings/rpc/rpc-runner.ts +2 -0
  181. package/src/wirings/rpc/rpc-types.ts +4 -0
  182. package/src/wirings/rpc/wire-addon.ts +17 -0
  183. package/src/wirings/scheduler/index.ts +0 -1
  184. package/src/wirings/trigger/index.ts +0 -1
  185. package/src/wirings/virtual-user/index.ts +0 -16
  186. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +96 -16
  187. package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
  188. package/src/wirings/workflow/index.ts +2 -20
  189. package/src/wirings/workflow/pikku-scenario-service.ts +60 -15
  190. package/src/wirings/workflow/pikku-workflow-service.test.ts +13 -12
  191. package/src/wirings/workflow/pikku-workflow-service.ts +28 -4
  192. package/src/wirings/workflow/scenario-expectations.test.ts +75 -0
  193. package/src/wirings/workflow/scenario-hooks.test.ts +3 -2
  194. package/src/wirings/workflow/scenario-step.types.ts +8 -0
  195. package/src/wirings/workflow/workflow-approval-audit.ts +47 -0
  196. package/src/wirings/workflow/workflow-approval-policy.test.ts +524 -0
  197. package/src/wirings/workflow/workflow-approval-policy.ts +68 -0
  198. package/src/wirings/workflow/workflow-approval.ts +113 -9
  199. package/src/wirings/workflow/workflow-run-authority.test.ts +12 -15
  200. package/src/wirings/workflow/workflow-run-ownership.ts +2 -1
  201. package/src/wirings/workflow/workflow.types.ts +0 -9
  202. package/src/wirings-stay-decoupled.test.ts +6 -2
  203. package/tsconfig.tsbuildinfo +1 -1
  204. package/dist/internal.d.ts +0 -3
  205. package/dist/internal.js +0 -2
  206. package/dist/middleware/timeout.d.ts +0 -9
  207. package/dist/middleware/timeout.js +0 -15
  208. package/dist/pikku-response.d.ts +0 -6
  209. package/dist/pikku-response.js +0 -6
  210. package/dist/services/gopass-secrets.d.ts +0 -15
  211. package/dist/services/gopass-secrets.js +0 -76
  212. package/dist/services/http-scenario-actors.d.ts +0 -75
  213. package/dist/services/http-scenario-actors.js +0 -195
  214. package/dist/services/http-user-flow-actors.d.ts +0 -67
  215. package/dist/services/http-user-flow-actors.js +0 -193
  216. package/dist/services/scenario-actors-service.d.ts +0 -127
  217. package/dist/services/scenario-actors-service.js +0 -40
  218. package/dist/services/user-flow-actors-service.d.ts +0 -39
  219. package/dist/services/user-flow-actors-service.js +0 -1
  220. package/dist/wirings/credential/wire-credential.d.ts +0 -48
  221. package/dist/wirings/credential/wire-credential.js +0 -47
  222. package/dist/wirings/oauth2/oauth2-client.d.ts +0 -47
  223. package/dist/wirings/oauth2/oauth2-client.js +0 -263
  224. package/dist/wirings/oauth2/oauth2-routes.d.ts +0 -35
  225. package/dist/wirings/oauth2/oauth2-routes.js +0 -146
  226. package/dist/wirings/scope/wire-scope.d.ts +0 -33
  227. package/dist/wirings/scope/wire-scope.js +0 -32
  228. package/dist/wirings/workflow/dsl/index.d.ts +0 -5
  229. package/dist/wirings/workflow/dsl/index.js +0 -4
  230. package/dist/wirings/workflow/graph/index.d.ts +0 -5
  231. 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: teardown needs the ids the body minted,
189
- // and a hook only ever receives the run's *input*.
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
+ })