@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
@@ -0,0 +1,68 @@
1
+ import { ForbiddenError } from '../../errors/errors.js'
2
+ import { addError } from '../../errors/error-handler.js'
3
+ import type { CoreUserSession } from '../../types/core.types.js'
4
+ import type {
5
+ ApprovalDecider,
6
+ WorkflowApprovalPolicy,
7
+ } from './dsl/workflow-dsl.types.js'
8
+
9
+ export class WorkflowApprovalForbiddenError extends ForbiddenError {
10
+ public payload: { reason: string; detail: string }
11
+ constructor(reason: string, detail: string) {
12
+ super(detail)
13
+ this.payload = { reason, detail }
14
+ }
15
+ }
16
+ addError(WorkflowApprovalForbiddenError, {
17
+ status: 403,
18
+ message: 'Not authorized to answer this approval.',
19
+ })
20
+
21
+ export const approvalDeciderFrom = (
22
+ session: CoreUserSession | undefined
23
+ ): ApprovalDecider | undefined =>
24
+ session ? { userId: session.userId, scopes: session.scopes } : undefined
25
+
26
+ /**
27
+ * Judge a decision against the gate's declared policy, returning the reason it
28
+ * is refused or `undefined` if it stands.
29
+ *
30
+ * Returns a message rather than throwing because the same judgement is needed
31
+ * in two places with different outcomes: refusing a live submission with a 403,
32
+ * and clearing an already-recorded decision on replay.
33
+ */
34
+ export const approvalPolicyRefusal = (
35
+ policy: WorkflowApprovalPolicy,
36
+ owner: string | undefined,
37
+ decider: ApprovalDecider | undefined
38
+ ): string | undefined => {
39
+ if (
40
+ policy.approverScope &&
41
+ !decider?.scopes?.includes(policy.approverScope)
42
+ ) {
43
+ return `Answering this approval requires the '${policy.approverScope}' scope`
44
+ }
45
+
46
+ switch (policy.approvers ?? 'any') {
47
+ case 'owner':
48
+ if (!owner) {
49
+ return undefined
50
+ }
51
+ if (!decider?.userId || decider.userId !== owner) {
52
+ return 'Only the user who started this run may answer this approval'
53
+ }
54
+ return undefined
55
+
56
+ case 'not-initiator':
57
+ if (!decider?.userId) {
58
+ return 'Answering this approval requires a signed-in user'
59
+ }
60
+ if (owner && decider.userId === owner) {
61
+ return 'The user who started this run may not answer this approval'
62
+ }
63
+ return undefined
64
+
65
+ case 'any':
66
+ return undefined
67
+ }
68
+ }
@@ -8,6 +8,14 @@ import type {
8
8
  StepState,
9
9
  WorkflowApprovalOptions,
10
10
  } from './workflow.types.js'
11
+ import type {
12
+ ApprovalDecider,
13
+ WorkflowApprovalPolicy,
14
+ } from './dsl/workflow-dsl.types.js'
15
+ import {
16
+ approvalPolicyRefusal,
17
+ WorkflowApprovalForbiddenError,
18
+ } from './workflow-approval-policy.js'
11
19
 
12
20
  /** The durable step name an approval point is recorded under. */
13
21
  export const approvalStepNameFor = (reason: string): string =>
@@ -28,6 +36,21 @@ export const approvalStateKey = (stepName: string): string => {
28
36
  return `__approval_${hex}`
29
37
  }
30
38
 
39
+ /**
40
+ * An answer to an approval gate, as the audit trail sees it.
41
+ *
42
+ * `denied` covers both a submission refused at the door and a decision cleared
43
+ * on replay — from the trail's point of view they are the same event, someone
44
+ * tried to answer a gate they could not.
45
+ */
46
+ export type ApprovalAuditEvent = {
47
+ runId: string
48
+ reason: string
49
+ outcome: 'success' | 'denied'
50
+ decidedBy?: ApprovalDecider
51
+ refusal?: string
52
+ }
53
+
31
54
  /** What the approval gate needs from the workflow service. */
32
55
  export type ApprovalStore = {
33
56
  getStepState: (runId: string, stepName: string) => Promise<StepState>
@@ -45,6 +68,18 @@ export type ApprovalStore = {
45
68
  updateRunState: (runId: string, key: string, value: unknown) => Promise<void>
46
69
  resumeWorkflow: (runId: string) => Promise<void>
47
70
  scheduleRunWake: (runId: string, delay: number) => Promise<void>
71
+ getRunOwner: (runId: string) => Promise<string | undefined>
72
+ auditApproval: (event: ApprovalAuditEvent) => Promise<void>
73
+ }
74
+
75
+ /** What run state holds for an approval point between suspension and replay. */
76
+ type ApprovalRecord = {
77
+ decision?: unknown
78
+ decidedAt?: string
79
+ decidedBy?: ApprovalDecider
80
+ policy?: WorkflowApprovalPolicy
81
+ expiresAt?: string
82
+ error?: unknown
48
83
  }
49
84
 
50
85
  /**
@@ -53,12 +88,19 @@ export type ApprovalStore = {
53
88
  * A decision arriving for an already-settled approval is refused rather than
54
89
  * overwriting it: the run has moved on, and a second answer would be recorded
55
90
  * against a gate nobody is waiting at.
91
+ *
92
+ * The decider is recorded rather than judged here, for the same reason the
93
+ * payload is: the gate's policy is a value on the workflow, and a decision can
94
+ * legitimately arrive before the run has ever reached the gate. Where the run
95
+ * has already published its policy, it is applied eagerly so a caller who
96
+ * cannot answer is told so rather than left waiting.
56
97
  */
57
98
  export const recordApprovalDecision = async (
58
99
  store: ApprovalStore,
59
100
  runId: string,
60
101
  reason: string,
61
- decision: unknown
102
+ decision: unknown,
103
+ decidedBy?: ApprovalDecider
62
104
  ): Promise<void> => {
63
105
  const stepName = approvalStepNameFor(reason)
64
106
  const stateKey = approvalStateKey(stepName)
@@ -78,13 +120,34 @@ export const recordApprovalDecision = async (
78
120
  }
79
121
 
80
122
  const state = await store.getRunState(runId)
81
- const record = (state[stateKey] ?? {}) as Record<string, unknown>
123
+ const record = (state[stateKey] ?? {}) as ApprovalRecord
124
+
125
+ if (record.policy) {
126
+ const refusal = approvalPolicyRefusal(
127
+ record.policy,
128
+ await store.getRunOwner(runId),
129
+ decidedBy
130
+ )
131
+ if (refusal) {
132
+ await store.auditApproval({
133
+ runId,
134
+ reason,
135
+ outcome: 'denied',
136
+ decidedBy,
137
+ refusal,
138
+ })
139
+ throw new WorkflowApprovalForbiddenError(reason, refusal)
140
+ }
141
+ }
142
+
82
143
  await store.updateRunState(runId, stateKey, {
83
144
  ...record,
84
145
  decision,
85
146
  decidedAt: new Date().toISOString(),
147
+ decidedBy,
86
148
  error: undefined,
87
- })
149
+ } satisfies ApprovalRecord)
150
+ await store.auditApproval({ runId, reason, outcome: 'success', decidedBy })
88
151
  await store.resumeWorkflow(runId)
89
152
  }
90
153
 
@@ -95,6 +158,11 @@ export const recordApprovalDecision = async (
95
158
  * schema is a value on the workflow and only the workflow has it. A payload
96
159
  * that fails validation is cleared and the run suspends again, so a bad
97
160
  * submission cannot settle the gate.
161
+ *
162
+ * The decider is judged here for the same reason, and for one more: a decision
163
+ * can be recorded before the run has ever reached the gate, so submission time
164
+ * is not a point at which the policy is reliably knowable. Judging on replay is
165
+ * what makes the policy hold in every ordering.
98
166
  */
99
167
  export const evaluateApprovalStep = async (
100
168
  store: ApprovalStore,
@@ -109,7 +177,12 @@ export const evaluateApprovalStep = async (
109
177
  runId,
110
178
  approvalStepName,
111
179
  'pikkuWorkflowApproval',
112
- { reason, expiry: options.expiry },
180
+ {
181
+ reason,
182
+ expiry: options.expiry,
183
+ approvers: options.approvers,
184
+ approverScope: options.approverScope,
185
+ },
113
186
  undefined,
114
187
  fromStepName
115
188
  )
@@ -129,15 +202,16 @@ export const evaluateApprovalStep = async (
129
202
  }
130
203
 
131
204
  const stateKey = approvalStateKey(approvalStepName)
132
- let record = ((await store.getRunState(runId))[stateKey] ?? {}) as {
133
- decision?: unknown
134
- decidedAt?: string
135
- expiresAt?: string
136
- error?: unknown
205
+ let record = ((await store.getRunState(runId))[stateKey] ?? {}) as ApprovalRecord
206
+
207
+ const policy: WorkflowApprovalPolicy = {
208
+ approvers: options.approvers,
209
+ approverScope: options.approverScope,
137
210
  }
138
211
 
139
212
  if (stepState.status === 'pending') {
140
213
  await store.setStepRunning(stepState.stepId)
214
+ record = { ...record, policy }
141
215
  if (options.expiry !== undefined && !record.expiresAt) {
142
216
  const expiry = getDurationInMilliseconds(options.expiry)
143
217
  record = {
@@ -146,10 +220,35 @@ export const evaluateApprovalStep = async (
146
220
  }
147
221
  await store.updateRunState(runId, stateKey, record)
148
222
  await store.scheduleRunWake(runId, expiry)
223
+ } else {
224
+ await store.updateRunState(runId, stateKey, record)
149
225
  }
150
226
  }
151
227
 
152
228
  if (record.decision !== undefined) {
229
+ const refusal = approvalPolicyRefusal(
230
+ policy,
231
+ await store.getRunOwner(runId),
232
+ record.decidedBy
233
+ )
234
+ if (refusal) {
235
+ await store.updateRunState(runId, stateKey, {
236
+ ...record,
237
+ decision: undefined,
238
+ decidedAt: undefined,
239
+ decidedBy: undefined,
240
+ error: [{ message: refusal }],
241
+ } satisfies ApprovalRecord)
242
+ await store.auditApproval({
243
+ runId,
244
+ reason,
245
+ outcome: 'denied',
246
+ decidedBy: record.decidedBy,
247
+ refusal,
248
+ })
249
+ throw new WorkflowSuspendedException(runId, reason)
250
+ }
251
+
153
252
  const validation = await options.schema['~standard'].validate(
154
253
  record.decision
155
254
  )
@@ -158,6 +257,7 @@ export const evaluateApprovalStep = async (
158
257
  ...record,
159
258
  decision: undefined,
160
259
  decidedAt: undefined,
260
+ decidedBy: undefined,
161
261
  error: validation.issues.map((issue) => ({
162
262
  message: issue.message,
163
263
  path: issue.path?.map((segment) =>
@@ -167,9 +267,13 @@ export const evaluateApprovalStep = async (
167
267
  })
168
268
  throw new WorkflowSuspendedException(runId, reason)
169
269
  }
270
+ // Spread rather than assigned, so a gate answered without a session keeps
271
+ // the shape it had before there was anything to record.
170
272
  const outcome: ApprovalOutcome<unknown> = {
171
273
  status: 'decided',
172
274
  data: validation.value,
275
+ ...(record.decidedBy ? { decidedBy: record.decidedBy } : {}),
276
+ ...(record.decidedAt ? { decidedAt: record.decidedAt } : {}),
173
277
  }
174
278
  await store.setStepResult(stepState.stepId, outcome)
175
279
  return outcome
@@ -160,7 +160,7 @@ describe('workflow run ownership', () => {
160
160
  )
161
161
  })
162
162
 
163
- test('approveStep refuses a caller who does not own the run', async () => {
163
+ test('approveStep does not impose ownership on a gate that did not ask for it', async () => {
164
164
  seedSingletons()
165
165
  const ws = new InMemoryWorkflowService()
166
166
  const runId = await ws.createRun('flow', {}, false, 'hash', {
@@ -168,23 +168,20 @@ describe('workflow run ownership', () => {
168
168
  pikkuUserId: 'owner',
169
169
  })
170
170
 
171
- await assert.rejects(
172
- ws.approveStep(
173
- runId,
174
- 'release-funds',
175
- { ok: true },
176
- {
177
- userId: 'attacker',
178
- }
179
- ),
180
- ForbiddenError
171
+ await ws.approveStep(
172
+ runId,
173
+ 'release-funds',
174
+ { ok: true },
175
+ {
176
+ userId: 'not-the-owner',
177
+ }
181
178
  )
182
179
 
183
180
  const state = await ws.getRunState(runId)
184
- assert.deepEqual(
185
- Object.keys(state),
186
- [],
187
- 'no decision was recorded for the run'
181
+ assert.equal(
182
+ Object.keys(state).length,
183
+ 1,
184
+ 'the decision was recorded, to be judged against the gate on replay'
188
185
  )
189
186
  })
190
187
 
@@ -15,7 +15,8 @@ addError(WorkflowRunForbiddenError, {
15
15
 
16
16
  /**
17
17
  * A run started through a session records that session's user as its owner, and
18
- * only that user may read it or answer its approval gates.
18
+ * only that user may read it. Who may *answer* a run's approval gates is the
19
+ * gate's own declaration — see `approvers` on `workflow.approval()`.
19
20
  *
20
21
  * A run with no recorded owner — started by a trigger, a scheduler, or a route
21
22
  * wired without auth — has nobody to compare a caller against, so ownership is
@@ -9,22 +9,13 @@ export type { WorkflowService } from '../../services/workflow-service.js'
9
9
 
10
10
  export type {
11
11
  WorkflowStepOptions,
12
- WorkflowExpectEventuallyOptions,
13
- WorkflowExpectErrorOptions,
14
- WorkflowExpectServiceOptions,
15
12
  WorkflowWireDoRPC,
16
- WorkflowWireDoInline,
17
- WorkflowWireSleep,
18
- WorkflowWireSuspend,
19
- WorkflowWireApproval,
20
13
  WorkflowApprovalOptions,
21
14
  ApprovalOutcome,
22
15
  InputSource,
23
16
  OutputBinding,
24
17
  RpcStepMeta,
25
- SimpleCondition,
26
18
  Condition,
27
- BranchCase,
28
19
  BranchStepMeta,
29
20
  ParallelGroupStepMeta,
30
21
  FanoutStepMeta,
@@ -24,8 +24,12 @@ const ALLOWED: Record<string, string[]> = {
24
24
  // `remote` on a wire with no peer — one leaf module, not the channel runtime.
25
25
  http: ['channel'],
26
26
  cli: ['channel'],
27
- // An agent streams over a channel and is invoked over rpc.
28
- 'ai-agent': ['channel', 'rpc'],
27
+ // An agent streams over a channel and is invoked over rpc, and grades itself
28
+ // as the last thing a finished run does.
29
+ 'ai-agent': ['channel', 'rpc', 'ai-scorer'],
30
+ // A judge is a degenerate agent — it builds agent messages and runs them —
31
+ // and a grade is dispatched to a queue.
32
+ 'ai-scorer': ['ai-agent', 'queue'],
29
33
  // `wire.rpc.agent` — the facade lives with the agent runtime it delegates to.
30
34
  rpc: ['ai-agent'],
31
35
  gateway: ['http'],