@pikku/core 0.12.2 → 0.12.3

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 (131) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/crypto-utils.d.ts +10 -0
  3. package/dist/crypto-utils.js +62 -0
  4. package/dist/function/function-runner.js +4 -4
  5. package/dist/function/functions.types.d.ts +26 -1
  6. package/dist/function/functions.types.js +16 -0
  7. package/dist/handle-error.d.ts +1 -1
  8. package/dist/handle-error.js +7 -3
  9. package/dist/index.d.ts +14 -2
  10. package/dist/index.js +1 -1
  11. package/dist/pikku-state.js +3 -1
  12. package/dist/schema.js +1 -1
  13. package/dist/services/in-memory-workflow-service.d.ts +8 -2
  14. package/dist/services/in-memory-workflow-service.js +28 -1
  15. package/dist/services/workflow-service.d.ts +8 -2
  16. package/dist/testing/index.d.ts +2 -0
  17. package/dist/testing/index.js +1 -0
  18. package/dist/testing/service-tests.d.ts +31 -0
  19. package/dist/testing/service-tests.js +598 -0
  20. package/dist/types/core.types.d.ts +9 -1
  21. package/dist/types/state.types.d.ts +4 -2
  22. package/dist/utils/hash.d.ts +2 -0
  23. package/dist/utils/hash.js +23 -0
  24. package/dist/wirings/ai-agent/agent-dynamic-workflow.d.ts +6 -0
  25. package/dist/wirings/ai-agent/agent-dynamic-workflow.js +348 -0
  26. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +1 -1
  27. package/dist/wirings/ai-agent/ai-agent-memory.js +6 -5
  28. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +10 -7
  29. package/dist/wirings/ai-agent/ai-agent-prepare.js +127 -47
  30. package/dist/wirings/ai-agent/ai-agent-runner.d.ts +4 -0
  31. package/dist/wirings/ai-agent/ai-agent-runner.js +381 -27
  32. package/dist/wirings/ai-agent/ai-agent-stream.d.ts +7 -2
  33. package/dist/wirings/ai-agent/ai-agent-stream.js +109 -46
  34. package/dist/wirings/ai-agent/ai-agent.types.d.ts +22 -1
  35. package/dist/wirings/ai-agent/index.d.ts +1 -2
  36. package/dist/wirings/ai-agent/index.js +1 -2
  37. package/dist/wirings/channel/channel-handler.js +5 -1
  38. package/dist/wirings/channel/channel.types.d.ts +1 -7
  39. package/dist/wirings/channel/local/local-channel-handler.d.ts +1 -1
  40. package/dist/wirings/channel/local/local-channel-handler.js +5 -3
  41. package/dist/wirings/channel/local/local-channel-runner.js +14 -4
  42. package/dist/wirings/channel/serverless/serverless-channel-runner.js +4 -1
  43. package/dist/wirings/cli/cli-runner.js +10 -2
  44. package/dist/wirings/cli/cli.types.d.ts +1 -0
  45. package/dist/wirings/http/http-routes.js +0 -1
  46. package/dist/wirings/http/http-runner.d.ts +1 -1
  47. package/dist/wirings/http/http-runner.js +25 -3
  48. package/dist/wirings/http/http.types.d.ts +3 -11
  49. package/dist/wirings/http/index.d.ts +2 -1
  50. package/dist/wirings/http/index.js +1 -0
  51. package/dist/wirings/http/pikku-fetch-http-request.d.ts +1 -0
  52. package/dist/wirings/http/pikku-fetch-http-request.js +3 -0
  53. package/dist/wirings/http/pikku-fetch-http-response.d.ts +1 -0
  54. package/dist/wirings/http/pikku-fetch-http-response.js +4 -4
  55. package/dist/wirings/http/web-request.d.ts +12 -0
  56. package/dist/wirings/http/web-request.js +85 -0
  57. package/dist/wirings/mcp/mcp-endpoint-registry.js +1 -1
  58. package/dist/wirings/mcp/mcp-runner.js +13 -1
  59. package/dist/wirings/rpc/rpc-runner.d.ts +30 -6
  60. package/dist/wirings/rpc/rpc-runner.js +36 -14
  61. package/dist/wirings/rpc/rpc-types.d.ts +6 -1
  62. package/dist/wirings/rpc/wire-addon.d.ts +1 -0
  63. package/dist/wirings/rpc/wire-addon.js +1 -1
  64. package/dist/wirings/scheduler/scheduler-runner.js +1 -0
  65. package/dist/wirings/workflow/graph/graph-validation.d.ts +3 -0
  66. package/dist/wirings/workflow/graph/graph-validation.js +175 -0
  67. package/dist/wirings/workflow/graph/index.d.ts +1 -0
  68. package/dist/wirings/workflow/graph/index.js +1 -0
  69. package/dist/wirings/workflow/index.d.ts +1 -1
  70. package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -2
  71. package/dist/wirings/workflow/pikku-workflow-service.js +1 -2
  72. package/dist/wirings/workflow/workflow.types.d.ts +10 -1
  73. package/package.json +4 -2
  74. package/src/crypto-utils.test.ts +99 -1
  75. package/src/crypto-utils.ts +114 -0
  76. package/src/function/function-runner.ts +5 -5
  77. package/src/function/functions.types.ts +38 -1
  78. package/src/handle-error.test.ts +65 -2
  79. package/src/handle-error.ts +8 -4
  80. package/src/index.ts +14 -0
  81. package/src/pikku-state.ts +3 -1
  82. package/src/schema.ts +1 -1
  83. package/src/services/in-memory-workflow-service.ts +45 -2
  84. package/src/services/workflow-service.ts +12 -1
  85. package/src/testing/index.ts +2 -0
  86. package/src/testing/service-tests.ts +873 -0
  87. package/src/types/core.types.ts +9 -1
  88. package/src/types/state.types.ts +4 -2
  89. package/src/utils/hash.test.ts +68 -0
  90. package/src/utils/hash.ts +26 -0
  91. package/src/wirings/ai-agent/agent-dynamic-workflow.ts +469 -0
  92. package/src/wirings/ai-agent/ai-agent-memory.ts +7 -6
  93. package/src/wirings/ai-agent/ai-agent-prepare.ts +175 -61
  94. package/src/wirings/ai-agent/ai-agent-runner.test.ts +3 -1
  95. package/src/wirings/ai-agent/ai-agent-runner.ts +553 -31
  96. package/src/wirings/ai-agent/ai-agent-stream.test.ts +209 -2
  97. package/src/wirings/ai-agent/ai-agent-stream.ts +182 -89
  98. package/src/wirings/ai-agent/ai-agent.types.ts +23 -1
  99. package/src/wirings/ai-agent/index.ts +1 -5
  100. package/src/wirings/channel/channel-handler.ts +11 -2
  101. package/src/wirings/channel/channel.types.ts +1 -7
  102. package/src/wirings/channel/local/local-channel-handler.ts +5 -3
  103. package/src/wirings/channel/local/local-channel-runner.ts +17 -7
  104. package/src/wirings/channel/serverless/serverless-channel-runner.ts +4 -1
  105. package/src/wirings/cli/cli-runner.ts +12 -2
  106. package/src/wirings/cli/cli.types.ts +1 -0
  107. package/src/wirings/http/http-routes.ts +0 -1
  108. package/src/wirings/http/http-runner.ts +35 -10
  109. package/src/wirings/http/http.types.ts +3 -12
  110. package/src/wirings/http/index.ts +3 -0
  111. package/src/wirings/http/pikku-fetch-http-request.ts +4 -0
  112. package/src/wirings/http/pikku-fetch-http-response.ts +4 -4
  113. package/src/wirings/http/web-request.test.ts +236 -0
  114. package/src/wirings/http/web-request.ts +104 -0
  115. package/src/wirings/mcp/mcp-endpoint-registry.ts +1 -1
  116. package/src/wirings/mcp/mcp-runner.ts +30 -12
  117. package/src/wirings/rpc/rpc-runner.ts +45 -24
  118. package/src/wirings/rpc/rpc-types.ts +6 -1
  119. package/src/wirings/rpc/wire-addon.ts +2 -1
  120. package/src/wirings/scheduler/scheduler-runner.ts +1 -0
  121. package/src/wirings/workflow/graph/graph-validation.test.ts +170 -0
  122. package/src/wirings/workflow/graph/graph-validation.ts +237 -0
  123. package/src/wirings/workflow/graph/index.ts +5 -0
  124. package/src/wirings/workflow/index.ts +1 -0
  125. package/src/wirings/workflow/pikku-workflow-service.ts +14 -3
  126. package/src/wirings/workflow/workflow.types.ts +9 -1
  127. package/tsconfig.tsbuildinfo +1 -1
  128. package/dist/wirings/ai-agent/ai-agent-assistant-ui.d.ts +0 -5
  129. package/dist/wirings/ai-agent/ai-agent-assistant-ui.js +0 -168
  130. package/src/wirings/ai-agent/ai-agent-assistant-ui.test.ts +0 -363
  131. package/src/wirings/ai-agent/ai-agent-assistant-ui.ts +0 -238
@@ -0,0 +1,873 @@
1
+ import { describe, test, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import type { ChannelStore } from '../wirings/channel/channel-store.js'
5
+ import type { EventHubStore } from '../wirings/channel/eventhub-store.js'
6
+ import type { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js'
7
+ import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js'
8
+ import type { DeploymentService } from '../services/deployment-service.js'
9
+ import type { AIStorageService } from '../services/ai-storage-service.js'
10
+ import type { AIRunStateService } from '../services/ai-run-state-service.js'
11
+ import type { SecretService } from '../services/secret-service.js'
12
+ import type { AgentRunService } from '../wirings/ai-agent/ai-agent.types.js'
13
+
14
+ export interface ServiceTestConfig {
15
+ name: string
16
+ services: {
17
+ channelStore?: () => Promise<ChannelStore>
18
+ eventHubStore?: () => Promise<EventHubStore<Record<string, any>>>
19
+ workflowService?: () => Promise<PikkuWorkflowService>
20
+ workflowRunService?: () => Promise<WorkflowRunService>
21
+ deploymentService?: () => Promise<
22
+ DeploymentService & { stop(): Promise<void> }
23
+ >
24
+ aiStorageService?: () => Promise<AIStorageService & AIRunStateService>
25
+ agentRunService?: () => Promise<AgentRunService>
26
+ secretService?: (config: {
27
+ key: string
28
+ keyVersion?: number
29
+ previousKey?: string
30
+ }) => Promise<SecretService & { rotateKEK?(): Promise<number> }>
31
+ }
32
+ }
33
+
34
+ export function defineServiceTests(config: ServiceTestConfig): void {
35
+ const { name, services } = config
36
+
37
+ if (services.channelStore) {
38
+ const factory = services.channelStore
39
+ describe(`ChannelStore [${name}]`, () => {
40
+ let store: ChannelStore
41
+
42
+ before(async () => {
43
+ store = await factory()
44
+ })
45
+
46
+ test('addChannel and getChannelAndSession', async () => {
47
+ await store.addChannel({
48
+ channelId: 'ch-1',
49
+ channelName: 'test-channel',
50
+ openingData: { foo: 'bar' },
51
+ })
52
+
53
+ const result = await store.getChannelAndSession('ch-1')
54
+ assert.equal(result.channelId, 'ch-1')
55
+ assert.equal(result.channelName, 'test-channel')
56
+ assert.deepEqual(result.openingData, { foo: 'bar' })
57
+ assert.deepEqual(result.session, {})
58
+ })
59
+
60
+ test('setUserSession', async () => {
61
+ const session = { userId: 'user-1' } as any
62
+ await store.setUserSession('ch-1', session)
63
+
64
+ const result = await store.getChannelAndSession('ch-1')
65
+ assert.deepEqual(result.session, session)
66
+ })
67
+
68
+ test('getChannelAndSession throws for missing channel', async () => {
69
+ await assert.rejects(
70
+ async () => {
71
+ await store.getChannelAndSession('missing')
72
+ },
73
+ { message: 'Channel not found: missing' }
74
+ )
75
+ })
76
+
77
+ test('removeChannels', async () => {
78
+ await store.addChannel({
79
+ channelId: 'ch-2',
80
+ channelName: 'temp-channel',
81
+ })
82
+ await store.removeChannels(['ch-2'])
83
+ await assert.rejects(async () => {
84
+ await store.getChannelAndSession('ch-2')
85
+ })
86
+ })
87
+
88
+ test('removeChannels with empty array is no-op', async () => {
89
+ await store.removeChannels([])
90
+ })
91
+ })
92
+ }
93
+
94
+ if (services.eventHubStore) {
95
+ const factory = services.eventHubStore
96
+ describe(`EventHubStore [${name}]`, () => {
97
+ let store: EventHubStore<Record<string, any>>
98
+
99
+ before(async () => {
100
+ store = await factory()
101
+ })
102
+
103
+ test('subscribe and getChannelIdsForTopic', async () => {
104
+ const result = await store.subscribe('topic-1', 'ch-1')
105
+ assert.equal(result, true)
106
+
107
+ const ids = await store.getChannelIdsForTopic('topic-1')
108
+ assert.deepEqual(ids, ['ch-1'])
109
+ })
110
+
111
+ test('subscribe duplicate is idempotent', async () => {
112
+ const result = await store.subscribe('topic-1', 'ch-1')
113
+ assert.equal(result, true)
114
+ })
115
+
116
+ test('unsubscribe returns true when exists', async () => {
117
+ const result = await store.unsubscribe('topic-1', 'ch-1')
118
+ assert.equal(result, true)
119
+ })
120
+
121
+ test('unsubscribe returns false when not exists', async () => {
122
+ const result = await store.unsubscribe('topic-1', 'ch-1')
123
+ assert.equal(result, false)
124
+ })
125
+
126
+ test('getChannelIdsForTopic returns empty for unknown topic', async () => {
127
+ const ids = await store.getChannelIdsForTopic('unknown')
128
+ assert.deepEqual(ids, [])
129
+ })
130
+ })
131
+ }
132
+
133
+ if (services.workflowService) {
134
+ const factory = services.workflowService
135
+ const wire = { type: 'test' }
136
+
137
+ describe(`WorkflowService [${name}]`, () => {
138
+ let service: PikkuWorkflowService
139
+
140
+ before(async () => {
141
+ service = await factory()
142
+ })
143
+
144
+ test('createRun and getRun', async () => {
145
+ const runId = await service.createRun(
146
+ 'test-workflow',
147
+ { key: 'value' },
148
+ false,
149
+ 'hash-1',
150
+ wire
151
+ )
152
+
153
+ assert.ok(runId)
154
+ const run = await service.getRun(runId)
155
+ assert.ok(run)
156
+ assert.equal(run.workflow, 'test-workflow')
157
+ assert.equal(run.status, 'running')
158
+ assert.deepEqual(run.input, { key: 'value' })
159
+ assert.equal(run.graphHash, 'hash-1')
160
+ })
161
+
162
+ test('updateRunStatus', async () => {
163
+ const runId = await service.createRun(
164
+ 'status-workflow',
165
+ {},
166
+ false,
167
+ 'hash-2',
168
+ wire
169
+ )
170
+ await service.updateRunStatus(runId, 'completed', { result: 'done' })
171
+
172
+ const run = await service.getRun(runId)
173
+ assert.ok(run)
174
+ assert.equal(run.status, 'completed')
175
+ assert.deepEqual(run.output, { result: 'done' })
176
+ })
177
+
178
+ test('insertStepState and getStepState', async () => {
179
+ const runId = await service.createRun(
180
+ 'step-workflow',
181
+ {},
182
+ false,
183
+ 'hash-3',
184
+ wire
185
+ )
186
+
187
+ const step = await service.insertStepState(
188
+ runId,
189
+ 'step-1',
190
+ 'myRpc',
191
+ { data: 'test' },
192
+ { retries: 3, retryDelay: '1000' }
193
+ )
194
+
195
+ assert.ok(step.stepId)
196
+ assert.equal(step.status, 'pending')
197
+ assert.equal(step.attemptCount, 1)
198
+ assert.equal(step.retries, 3)
199
+ assert.equal(step.retryDelay, '1000')
200
+
201
+ const fetched = await service.getStepState(runId, 'step-1')
202
+ assert.equal(fetched.stepId, step.stepId)
203
+ assert.equal(fetched.status, 'pending')
204
+ assert.equal(fetched.attemptCount, 1)
205
+ })
206
+
207
+ test('setStepRunning, setStepResult', async () => {
208
+ const runId = await service.createRun(
209
+ 'result-workflow',
210
+ {},
211
+ false,
212
+ 'hash-4',
213
+ wire
214
+ )
215
+ const step = await service.insertStepState(
216
+ runId,
217
+ 'result-step',
218
+ 'myRpc',
219
+ {}
220
+ )
221
+
222
+ await service.setStepRunning(step.stepId)
223
+ let fetched = await service.getStepState(runId, 'result-step')
224
+ assert.equal(fetched.status, 'running')
225
+
226
+ await service.setStepResult(step.stepId, { answer: 42 })
227
+ fetched = await service.getStepState(runId, 'result-step')
228
+ assert.equal(fetched.status, 'succeeded')
229
+ assert.deepEqual(fetched.result, { answer: 42 })
230
+ })
231
+
232
+ test('setStepError', async () => {
233
+ const runId = await service.createRun(
234
+ 'error-workflow',
235
+ {},
236
+ false,
237
+ 'hash-5',
238
+ wire
239
+ )
240
+ const step = await service.insertStepState(
241
+ runId,
242
+ 'error-step',
243
+ 'myRpc',
244
+ {}
245
+ )
246
+
247
+ await service.setStepRunning(step.stepId)
248
+ await service.setStepError(step.stepId, new Error('boom'))
249
+
250
+ const fetched = await service.getStepState(runId, 'error-step')
251
+ assert.equal(fetched.status, 'failed')
252
+ assert.equal(fetched.error?.message, 'boom')
253
+ })
254
+
255
+ test('createRetryAttempt', async () => {
256
+ const runId = await service.createRun(
257
+ 'retry-workflow',
258
+ {},
259
+ false,
260
+ 'hash-6',
261
+ wire
262
+ )
263
+ const step = await service.insertStepState(
264
+ runId,
265
+ 'retry-step',
266
+ 'myRpc',
267
+ {},
268
+ { retries: 2 }
269
+ )
270
+
271
+ await service.setStepError(step.stepId, new Error('fail'))
272
+ const retried = await service.createRetryAttempt(step.stepId, 'pending')
273
+ assert.equal(retried.status, 'pending')
274
+ assert.equal(retried.attemptCount, 2)
275
+ assert.equal(retried.error, undefined)
276
+ assert.equal(retried.result, undefined)
277
+ })
278
+
279
+ test('getNodesWithoutSteps', async () => {
280
+ const runId = await service.createRun(
281
+ 'graph-workflow',
282
+ {},
283
+ false,
284
+ 'hash-7',
285
+ wire
286
+ )
287
+ await service.insertStepState(runId, 'existing-node', 'rpc', {})
288
+
289
+ const missing = await service.getNodesWithoutSteps(runId, [
290
+ 'existing-node',
291
+ 'missing-node',
292
+ ])
293
+ assert.deepEqual(missing, ['missing-node'])
294
+ })
295
+
296
+ test('getNodesWithoutSteps with empty array', async () => {
297
+ const result = await service.getNodesWithoutSteps('any-id', [])
298
+ assert.deepEqual(result, [])
299
+ })
300
+
301
+ test('getNodeResults', async () => {
302
+ const runId = await service.createRun(
303
+ 'results-workflow',
304
+ {},
305
+ false,
306
+ 'hash-8',
307
+ wire
308
+ )
309
+ const step = await service.insertStepState(runId, 'node-a', 'rpc', {})
310
+ await service.setStepResult(step.stepId, { out: 'hello' })
311
+
312
+ const results = await service.getNodeResults(runId, ['node-a'])
313
+ assert.deepEqual(results['node-a'], { out: 'hello' })
314
+ })
315
+
316
+ test('setBranchTaken and getCompletedGraphState', async () => {
317
+ const runId = await service.createRun(
318
+ 'branch-workflow',
319
+ {},
320
+ false,
321
+ 'hash-9',
322
+ wire
323
+ )
324
+ const step = await service.insertStepState(
325
+ runId,
326
+ 'branch-node',
327
+ 'rpc',
328
+ {}
329
+ )
330
+ await service.setStepResult(step.stepId, {})
331
+ await service.setBranchTaken(step.stepId, 'left')
332
+
333
+ const state = await service.getCompletedGraphState(runId)
334
+ assert.ok(state.completedNodeIds.includes('branch-node'))
335
+ assert.equal(state.branchKeys['branch-node'], 'left')
336
+ })
337
+
338
+ test('updateRunState and getRunState', async () => {
339
+ const runId = await service.createRun(
340
+ 'state-workflow',
341
+ {},
342
+ false,
343
+ 'hash-10',
344
+ wire
345
+ )
346
+
347
+ await service.updateRunState(runId, 'counter', 5)
348
+ await service.updateRunState(runId, 'name', 'test')
349
+
350
+ const state = await service.getRunState(runId)
351
+ assert.equal(state.counter, 5)
352
+ assert.equal(state.name, 'test')
353
+ })
354
+
355
+ test('upsertWorkflowVersion and getWorkflowVersion', async () => {
356
+ await service.upsertWorkflowVersion(
357
+ 'my-workflow',
358
+ 'v1-hash',
359
+ { nodes: ['a', 'b'] },
360
+ 'dsl'
361
+ )
362
+
363
+ const version = await service.getWorkflowVersion(
364
+ 'my-workflow',
365
+ 'v1-hash'
366
+ )
367
+ assert.ok(version)
368
+ assert.deepEqual(version.graph, { nodes: ['a', 'b'] })
369
+ assert.equal(version.source, 'dsl')
370
+ })
371
+
372
+ test('upsertWorkflowVersion duplicate is no-op', async () => {
373
+ await service.upsertWorkflowVersion(
374
+ 'my-workflow',
375
+ 'v1-hash',
376
+ { nodes: ['changed'] },
377
+ 'dsl'
378
+ )
379
+
380
+ const version = await service.getWorkflowVersion(
381
+ 'my-workflow',
382
+ 'v1-hash'
383
+ )
384
+ assert.ok(version)
385
+ assert.deepEqual(version.graph, { nodes: ['a', 'b'] })
386
+ })
387
+
388
+ test('getWorkflowVersion returns null for missing', async () => {
389
+ const version = await service.getWorkflowVersion('missing', 'missing')
390
+ assert.equal(version, null)
391
+ })
392
+ })
393
+ }
394
+
395
+ if (services.workflowRunService) {
396
+ const factory = services.workflowRunService
397
+ describe(`WorkflowRunService [${name}]`, () => {
398
+ let runService: WorkflowRunService
399
+
400
+ before(async () => {
401
+ runService = await factory()
402
+ })
403
+
404
+ test('listRuns returns runs', async () => {
405
+ const runs = await runService.listRuns()
406
+ assert.ok(Array.isArray(runs))
407
+ assert.ok(runs.length > 0)
408
+ })
409
+
410
+ test('listRuns with filter', async () => {
411
+ const runs = await runService.listRuns({
412
+ workflowName: 'test-workflow',
413
+ })
414
+ assert.ok(runs.every((r) => r.workflow === 'test-workflow'))
415
+ })
416
+
417
+ test('getDistinctWorkflowNames', async () => {
418
+ const names = await runService.getDistinctWorkflowNames()
419
+ assert.ok(Array.isArray(names))
420
+ assert.ok(names.includes('test-workflow'))
421
+ })
422
+
423
+ test('deleteRun', async () => {
424
+ const runs = await runService.listRuns({
425
+ workflowName: 'status-workflow',
426
+ })
427
+ assert.ok(runs.length > 0)
428
+
429
+ const deleted = await runService.deleteRun(runs[0]!.id)
430
+ assert.equal(deleted, true)
431
+
432
+ const afterDelete = await runService.getRun(runs[0]!.id)
433
+ assert.equal(afterDelete, null)
434
+ })
435
+
436
+ test('deleteRun returns false for missing', async () => {
437
+ const deleted = await runService.deleteRun('non-existent-id')
438
+ assert.equal(deleted, false)
439
+ })
440
+ })
441
+ }
442
+
443
+ if (services.aiStorageService) {
444
+ const factory = services.aiStorageService
445
+ describe(`AIStorageService [${name}]`, () => {
446
+ let storage: AIStorageService & AIRunStateService
447
+
448
+ before(async () => {
449
+ storage = await factory()
450
+ })
451
+
452
+ test('createThread and getThread', async () => {
453
+ const thread = await storage.createThread('resource-1', {
454
+ title: 'Test Thread',
455
+ metadata: { key: 'val' },
456
+ })
457
+
458
+ assert.ok(thread.id)
459
+ assert.equal(thread.resourceId, 'resource-1')
460
+ assert.equal(thread.title, 'Test Thread')
461
+ assert.deepEqual(thread.metadata, { key: 'val' })
462
+
463
+ const fetched = await storage.getThread(thread.id)
464
+ assert.equal(fetched.id, thread.id)
465
+ assert.equal(fetched.title, 'Test Thread')
466
+ })
467
+
468
+ test('getThreads', async () => {
469
+ const threads = await storage.getThreads('resource-1')
470
+ assert.ok(threads.length >= 1)
471
+ assert.ok(threads.every((t) => t.resourceId === 'resource-1'))
472
+ })
473
+
474
+ test('getThread throws for missing', async () => {
475
+ await assert.rejects(() => storage.getThread('missing-thread'))
476
+ })
477
+
478
+ test('saveMessages and getMessages', async () => {
479
+ const thread = await storage.createThread('resource-2')
480
+ const now = new Date()
481
+
482
+ await storage.saveMessages(thread.id, [
483
+ { id: 'msg-1', role: 'user', content: 'Hello', createdAt: now },
484
+ {
485
+ id: 'msg-2',
486
+ role: 'assistant',
487
+ content: 'Hi there',
488
+ createdAt: new Date(now.getTime() + 1000),
489
+ },
490
+ ])
491
+
492
+ const messages = await storage.getMessages(thread.id)
493
+ assert.equal(messages.length, 2)
494
+ assert.equal(messages[0]!.role, 'user')
495
+ assert.equal(messages[0]!.content, 'Hello')
496
+ assert.equal(messages[1]!.role, 'assistant')
497
+ assert.equal(messages[1]!.content, 'Hi there')
498
+ })
499
+
500
+ test('saveMessages with tool calls and results', async () => {
501
+ const thread = await storage.createThread('resource-3')
502
+ const now = new Date()
503
+
504
+ await storage.saveMessages(thread.id, [
505
+ {
506
+ id: 'msg-tc',
507
+ role: 'assistant',
508
+ content: 'Let me call a tool',
509
+ toolCalls: [
510
+ { id: 'tc-1', name: 'search', args: { query: 'test' } },
511
+ ],
512
+ createdAt: now,
513
+ },
514
+ ])
515
+
516
+ await storage.saveMessages(thread.id, [
517
+ {
518
+ id: 'tool-results-msg-tc',
519
+ role: 'tool',
520
+ toolResults: [{ id: 'tc-1', name: 'search', result: 'found it' }],
521
+ createdAt: new Date(now.getTime() + 1000),
522
+ },
523
+ ])
524
+
525
+ const messages = await storage.getMessages(thread.id)
526
+ assert.equal(messages.length, 2)
527
+
528
+ const assistantMsg = messages[0]!
529
+ assert.equal(assistantMsg.role, 'assistant')
530
+ assert.ok(assistantMsg.toolCalls)
531
+ assert.equal(assistantMsg.toolCalls[0]!.name, 'search')
532
+
533
+ const toolMsg = messages[1]!
534
+ assert.equal(toolMsg.role, 'tool')
535
+ assert.ok(toolMsg.toolResults)
536
+ assert.equal(toolMsg.toolResults[0]!.result, 'found it')
537
+ })
538
+
539
+ test('getMessages with lastN', async () => {
540
+ const thread = await storage.createThread('resource-4')
541
+ const base = Date.now()
542
+
543
+ await storage.saveMessages(
544
+ thread.id,
545
+ Array.from({ length: 5 }, (_, i) => ({
546
+ id: `bulk-msg-${i}`,
547
+ role: 'user' as const,
548
+ content: `Message ${i}`,
549
+ createdAt: new Date(base + i * 1000),
550
+ }))
551
+ )
552
+
553
+ const messages = await storage.getMessages(thread.id, { lastN: 2 })
554
+ assert.equal(messages.length, 2)
555
+ assert.equal(messages[0]!.content, 'Message 3')
556
+ assert.equal(messages[1]!.content, 'Message 4')
557
+ })
558
+
559
+ test('working memory: save and get', async () => {
560
+ await storage.saveWorkingMemory('res-1', 'resource', {
561
+ key: 'value',
562
+ })
563
+
564
+ const mem = await storage.getWorkingMemory('res-1', 'resource')
565
+ assert.deepEqual(mem, { key: 'value' })
566
+ })
567
+
568
+ test('working memory: upsert overwrites', async () => {
569
+ await storage.saveWorkingMemory('res-1', 'resource', {
570
+ key: 'updated',
571
+ })
572
+
573
+ const mem = await storage.getWorkingMemory('res-1', 'resource')
574
+ assert.deepEqual(mem, { key: 'updated' })
575
+ })
576
+
577
+ test('working memory: returns null for missing', async () => {
578
+ const mem = await storage.getWorkingMemory('missing', 'thread')
579
+ assert.equal(mem, null)
580
+ })
581
+
582
+ test('createRun and getRun', async () => {
583
+ const thread = await storage.createThread('resource-5')
584
+ const now = new Date()
585
+
586
+ const runId = await storage.createRun({
587
+ agentName: 'test-agent',
588
+ threadId: thread.id,
589
+ resourceId: 'resource-5',
590
+ status: 'running',
591
+ usage: { inputTokens: 100, outputTokens: 50, model: 'test-model' },
592
+ createdAt: now,
593
+ updatedAt: now,
594
+ })
595
+
596
+ assert.ok(runId)
597
+
598
+ const run = await storage.getRun(runId)
599
+ assert.ok(run)
600
+ assert.equal(run.agentName, 'test-agent')
601
+ assert.equal(run.status, 'running')
602
+ assert.equal(run.usage.inputTokens, 100)
603
+ })
604
+
605
+ test('updateRun', async () => {
606
+ const thread = await storage.createThread('resource-6')
607
+ const now = new Date()
608
+
609
+ const runId = await storage.createRun({
610
+ agentName: 'update-agent',
611
+ threadId: thread.id,
612
+ resourceId: 'resource-6',
613
+ status: 'running',
614
+ usage: { inputTokens: 0, outputTokens: 0, model: 'test' },
615
+ createdAt: now,
616
+ updatedAt: now,
617
+ })
618
+
619
+ await storage.updateRun(runId, {
620
+ status: 'completed',
621
+ usage: { inputTokens: 200, outputTokens: 100, model: 'test-v2' },
622
+ })
623
+
624
+ const run = await storage.getRun(runId)
625
+ assert.ok(run)
626
+ assert.equal(run.status, 'completed')
627
+ assert.equal(run.usage.inputTokens, 200)
628
+ assert.equal(run.usage.model, 'test-v2')
629
+ })
630
+
631
+ test('getRunsByThread', async () => {
632
+ const thread = await storage.createThread('resource-7')
633
+ const now = new Date()
634
+
635
+ await storage.createRun({
636
+ agentName: 'multi-agent',
637
+ threadId: thread.id,
638
+ resourceId: 'resource-7',
639
+ status: 'completed',
640
+ usage: { inputTokens: 10, outputTokens: 5, model: 'test' },
641
+ createdAt: now,
642
+ updatedAt: now,
643
+ })
644
+
645
+ const runs = await storage.getRunsByThread(thread.id)
646
+ assert.ok(runs.length >= 1)
647
+ assert.ok(runs.every((r) => r.threadId === thread.id))
648
+ })
649
+
650
+ test('resolveApproval', async () => {
651
+ const thread = await storage.createThread('resource-8')
652
+ const now = new Date()
653
+
654
+ await storage.saveMessages(thread.id, [
655
+ {
656
+ id: 'approval-msg',
657
+ role: 'assistant',
658
+ toolCalls: [
659
+ { id: 'approval-tc', name: 'dangerous-tool', args: {} },
660
+ ],
661
+ createdAt: now,
662
+ },
663
+ ])
664
+
665
+ const runId = await storage.createRun({
666
+ agentName: 'approval-agent',
667
+ threadId: thread.id,
668
+ resourceId: 'resource-8',
669
+ status: 'suspended',
670
+ suspendReason: 'approval',
671
+ pendingApprovals: [
672
+ {
673
+ type: 'tool-call',
674
+ toolCallId: 'approval-tc',
675
+ toolName: 'dangerous-tool',
676
+ args: {},
677
+ },
678
+ ],
679
+ usage: { inputTokens: 0, outputTokens: 0, model: 'test' },
680
+ createdAt: now,
681
+ updatedAt: now,
682
+ })
683
+
684
+ let run = await storage.getRun(runId)
685
+ assert.ok(run)
686
+ assert.ok(run.pendingApprovals)
687
+ assert.equal(run.pendingApprovals.length, 1)
688
+
689
+ await storage.resolveApproval('approval-tc', 'approved')
690
+
691
+ run = await storage.getRun(runId)
692
+ assert.ok(run)
693
+ assert.equal(run.pendingApprovals, undefined)
694
+ })
695
+
696
+ test('deleteThread cascades', async () => {
697
+ const thread = await storage.createThread('resource-del')
698
+ await storage.saveMessages(thread.id, [
699
+ {
700
+ id: 'del-msg',
701
+ role: 'user',
702
+ content: 'goodbye',
703
+ createdAt: new Date(),
704
+ },
705
+ ])
706
+
707
+ await storage.deleteThread(thread.id)
708
+ await assert.rejects(() => storage.getThread(thread.id))
709
+ })
710
+ })
711
+ }
712
+
713
+ if (services.deploymentService) {
714
+ const factory = services.deploymentService
715
+ describe(`DeploymentService [${name}]`, () => {
716
+ let service: DeploymentService & { stop(): Promise<void> }
717
+
718
+ before(async () => {
719
+ service = await factory()
720
+ })
721
+
722
+ after(async () => {
723
+ await service.stop()
724
+ })
725
+
726
+ test('start registers deployment', async () => {
727
+ await service.start({
728
+ deploymentId: 'deploy-1',
729
+ endpoint: 'http://localhost:3000',
730
+ functions: ['funcA', 'funcB'],
731
+ })
732
+
733
+ const infos = await service.findFunction('funcA')
734
+ assert.ok(infos.length >= 1)
735
+ assert.equal(infos[0]!.deploymentId, 'deploy-1')
736
+ assert.equal(infos[0]!.endpoint, 'http://localhost:3000')
737
+ })
738
+
739
+ test('findFunction returns empty for unknown function', async () => {
740
+ const infos = await service.findFunction('unknown-func')
741
+ assert.deepEqual(infos, [])
742
+ })
743
+ })
744
+ }
745
+
746
+ if (services.secretService) {
747
+ const factory = services.secretService
748
+ const kek = 'test-key-encryption-key-32chars!'
749
+
750
+ describe(`SecretService [${name}]`, () => {
751
+ test('setSecretJSON and getSecretJSON', async () => {
752
+ const service = await factory({ key: kek })
753
+ await service.setSecretJSON('api-key', {
754
+ token: 'sk-123',
755
+ endpoint: 'https://api.example.com',
756
+ })
757
+ const result = await service.getSecretJSON<{
758
+ token: string
759
+ endpoint: string
760
+ }>('api-key')
761
+ assert.deepEqual(result, {
762
+ token: 'sk-123',
763
+ endpoint: 'https://api.example.com',
764
+ })
765
+ })
766
+
767
+ test('getSecret returns raw string', async () => {
768
+ const service = await factory({ key: kek })
769
+ await service.setSecretJSON('string-secret', 'plain-value')
770
+ const result = await service.getSecret('string-secret')
771
+ assert.strictEqual(result, '"plain-value"')
772
+ })
773
+
774
+ test('hasSecret returns true/false', async () => {
775
+ const service = await factory({ key: kek })
776
+ assert.strictEqual(await service.hasSecret('api-key'), true)
777
+ assert.strictEqual(await service.hasSecret('nonexistent'), false)
778
+ })
779
+
780
+ test('getSecret throws for missing key', async () => {
781
+ const service = await factory({ key: kek })
782
+ await assert.rejects(() => service.getSecret('nonexistent'), {
783
+ message: 'Secret not found: nonexistent',
784
+ })
785
+ })
786
+
787
+ test('setSecretJSON upserts existing key', async () => {
788
+ const service = await factory({ key: kek })
789
+ await service.setSecretJSON('upsert-key', { v: 1 })
790
+ await service.setSecretJSON('upsert-key', { v: 2 })
791
+ const result = await service.getSecretJSON<{ v: number }>('upsert-key')
792
+ assert.deepEqual(result, { v: 2 })
793
+ })
794
+
795
+ test('deleteSecret removes the key', async () => {
796
+ const service = await factory({ key: kek })
797
+ await service.setSecretJSON('to-delete', 'bye')
798
+ assert.strictEqual(await service.hasSecret('to-delete'), true)
799
+ await service.deleteSecret('to-delete')
800
+ assert.strictEqual(await service.hasSecret('to-delete'), false)
801
+ })
802
+
803
+ test('rotateKEK re-wraps all secrets', async () => {
804
+ const newKEK = 'new-key-encryption-key-rotated!'
805
+ const oldService = await factory({ key: kek })
806
+ await oldService.setSecretJSON('rotate-test', { important: 'data' })
807
+
808
+ const rotatedService = await factory({
809
+ key: newKEK,
810
+ keyVersion: 2,
811
+ previousKey: kek,
812
+ })
813
+
814
+ const before = await rotatedService.getSecretJSON<{
815
+ important: string
816
+ }>('rotate-test')
817
+ assert.deepEqual(before, { important: 'data' })
818
+
819
+ assert.ok(rotatedService.rotateKEK)
820
+ const count = await rotatedService.rotateKEK!()
821
+ assert.ok(count > 0)
822
+
823
+ const newOnlyService = await factory({
824
+ key: newKEK,
825
+ keyVersion: 2,
826
+ })
827
+ const after = await newOnlyService.getSecretJSON<{
828
+ important: string
829
+ }>('rotate-test')
830
+ assert.deepEqual(after, { important: 'data' })
831
+ })
832
+
833
+ test('rotateKEK throws without previousKey', async () => {
834
+ const service = await factory({ key: kek })
835
+ assert.ok(service.rotateKEK)
836
+ await assert.rejects(() => service.rotateKEK!(), {
837
+ message: 'No previousKey configured — nothing to rotate from',
838
+ })
839
+ })
840
+ })
841
+ }
842
+
843
+ if (services.agentRunService) {
844
+ const factory = services.agentRunService
845
+ describe(`AgentRunService [${name}]`, () => {
846
+ let agentService: AgentRunService
847
+
848
+ before(async () => {
849
+ agentService = await factory()
850
+ })
851
+
852
+ test('listThreads', async () => {
853
+ const threads = await agentService.listThreads()
854
+ assert.ok(Array.isArray(threads))
855
+ })
856
+
857
+ test('getThread returns null for missing', async () => {
858
+ const thread = await agentService.getThread('missing-thread')
859
+ assert.equal(thread, null)
860
+ })
861
+
862
+ test('getDistinctAgentNames', async () => {
863
+ const names = await agentService.getDistinctAgentNames()
864
+ assert.ok(Array.isArray(names))
865
+ })
866
+
867
+ test('deleteThread returns false for missing', async () => {
868
+ const result = await agentService.deleteThread('missing-thread')
869
+ assert.equal(result, false)
870
+ })
871
+ })
872
+ }
873
+ }