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