@pikku/kysely 0.12.11 → 0.12.13
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 +20 -0
- package/dist/src/create-audited-kysely.d.ts +10 -0
- package/dist/src/create-audited-kysely.js +167 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/kysely-ai-agent-run-service.d.ts +1 -0
- package/dist/src/kysely-ai-agent-run-service.js +4 -1
- package/dist/src/kysely-channel-store.d.ts +3 -0
- package/dist/src/kysely-channel-store.js +25 -0
- package/dist/src/kysely-secret-service.d.ts +2 -3
- package/dist/src/kysely-secret-service.js +2 -7
- package/dist/src/kysely-tables.d.ts +2 -0
- package/dist/src/kysely-workflow-mirror.d.ts +60 -0
- package/dist/src/kysely-workflow-mirror.js +329 -0
- package/dist/src/kysely-workflow-service.d.ts +13 -13
- package/dist/src/kysely-workflow-service.js +13 -13
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/create-audited-kysely.test.ts +560 -0
- package/src/create-audited-kysely.ts +254 -0
- package/src/index.ts +5 -0
- package/src/kysely-ai-agent-run-service.ts +6 -1
- package/src/kysely-ai-run-state-service.ts +6 -2
- package/src/kysely-channel-store.ts +27 -0
- package/src/kysely-secret-service.ts +4 -17
- package/src/kysely-services.test.ts +2 -2
- package/src/kysely-session-store.ts +1 -3
- package/src/kysely-tables.ts +2 -0
- package/src/kysely-workflow-mirror.test.ts +246 -0
- package/src/kysely-workflow-mirror.ts +438 -0
- package/src/kysely-workflow-service.ts +25 -13
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { AuditLog } from '@pikku/core'
|
|
2
|
+
import type {
|
|
3
|
+
Kysely,
|
|
4
|
+
KyselyPlugin,
|
|
5
|
+
PluginTransformQueryArgs,
|
|
6
|
+
PluginTransformResultArgs,
|
|
7
|
+
QueryResult,
|
|
8
|
+
RootOperationNode,
|
|
9
|
+
UnknownRow,
|
|
10
|
+
} from 'kysely'
|
|
11
|
+
|
|
12
|
+
export interface CreateAuditedKyselyOptions {
|
|
13
|
+
audit: AuditLog
|
|
14
|
+
auditReads?: boolean
|
|
15
|
+
eventType?: string
|
|
16
|
+
transactionId?: string | null
|
|
17
|
+
queryIdPrefix?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type AuditedQuery = {
|
|
21
|
+
queryKind: 'select' | 'insert' | 'update' | 'delete'
|
|
22
|
+
queryId: string
|
|
23
|
+
tables: string[]
|
|
24
|
+
changedColumns?: string[]
|
|
25
|
+
changes?: unknown
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const QUERY_KIND_BY_NODE: Record<
|
|
29
|
+
string,
|
|
30
|
+
AuditedQuery['queryKind'] | undefined
|
|
31
|
+
> = {
|
|
32
|
+
SelectQueryNode: 'select',
|
|
33
|
+
InsertQueryNode: 'insert',
|
|
34
|
+
UpdateQueryNode: 'update',
|
|
35
|
+
DeleteQueryNode: 'delete',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getIdentifierName(node: unknown): string | undefined {
|
|
39
|
+
if (!node || typeof node !== 'object') return undefined
|
|
40
|
+
const candidate = node as {
|
|
41
|
+
name?: string
|
|
42
|
+
column?: { name?: string }
|
|
43
|
+
identifier?: { name?: string }
|
|
44
|
+
table?: { identifier?: { name?: string } }
|
|
45
|
+
}
|
|
46
|
+
return (
|
|
47
|
+
candidate.name ??
|
|
48
|
+
candidate.column?.name ??
|
|
49
|
+
candidate.identifier?.name ??
|
|
50
|
+
candidate.table?.identifier?.name
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function collectTableNames(node: unknown, out: Set<string>): void {
|
|
55
|
+
if (!node || typeof node !== 'object') return
|
|
56
|
+
|
|
57
|
+
const candidate = node as {
|
|
58
|
+
kind?: string
|
|
59
|
+
table?: { identifier?: { name?: string } }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (
|
|
63
|
+
candidate.kind === 'TableNode' &&
|
|
64
|
+
typeof candidate.table?.identifier?.name === 'string'
|
|
65
|
+
) {
|
|
66
|
+
out.add(candidate.table.identifier.name)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const value of Object.values(node as Record<string, unknown>)) {
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
for (const item of value) collectTableNames(item, out)
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
collectTableNames(value, out)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractValue(node: unknown): unknown {
|
|
79
|
+
if (!node || typeof node !== 'object') return node
|
|
80
|
+
|
|
81
|
+
const candidate = node as {
|
|
82
|
+
kind?: string
|
|
83
|
+
value?: unknown
|
|
84
|
+
values?: unknown[]
|
|
85
|
+
left?: unknown
|
|
86
|
+
right?: unknown
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
switch (candidate.kind) {
|
|
90
|
+
case 'ValueNode':
|
|
91
|
+
return candidate.value
|
|
92
|
+
case 'PrimitiveValueListNode':
|
|
93
|
+
case 'ValueListNode':
|
|
94
|
+
return (candidate.values ?? []).map(extractValue)
|
|
95
|
+
case 'DefaultInsertValueNode':
|
|
96
|
+
return '[default]'
|
|
97
|
+
case 'ColumnNode':
|
|
98
|
+
case 'ReferenceNode':
|
|
99
|
+
return `[${candidate.kind}]`
|
|
100
|
+
case 'RawNode':
|
|
101
|
+
return '[raw]'
|
|
102
|
+
case 'SelectQueryNode':
|
|
103
|
+
return '[subquery]'
|
|
104
|
+
case 'ParensNode':
|
|
105
|
+
return extractValue((candidate as { node?: unknown }).node)
|
|
106
|
+
case 'BinaryOperationNode':
|
|
107
|
+
return {
|
|
108
|
+
left: extractValue(candidate.left),
|
|
109
|
+
right: extractValue(candidate.right),
|
|
110
|
+
}
|
|
111
|
+
default:
|
|
112
|
+
return `[${candidate.kind ?? 'expression'}]`
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function extractInsertChanges(node: any): {
|
|
117
|
+
changedColumns?: string[]
|
|
118
|
+
changes?: unknown
|
|
119
|
+
} {
|
|
120
|
+
const columns = Array.isArray(node.columns)
|
|
121
|
+
? node.columns
|
|
122
|
+
.map((column: unknown) => getIdentifierName(column))
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
: []
|
|
125
|
+
const values = Array.isArray(node.values?.values) ? node.values.values : []
|
|
126
|
+
if (columns.length === 0 || values.length === 0) {
|
|
127
|
+
return {
|
|
128
|
+
changedColumns: columns.length > 0 ? columns : undefined,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const rows = values.map((row: any) => {
|
|
133
|
+
const rowValues = Array.isArray(row?.values)
|
|
134
|
+
? row.values.map(extractValue)
|
|
135
|
+
: []
|
|
136
|
+
return Object.fromEntries(
|
|
137
|
+
columns.map((column, index) => [column, rowValues[index]])
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
changedColumns: columns,
|
|
143
|
+
changes: rows.length === 1 ? rows[0] : rows,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function extractUpdateChanges(node: any): {
|
|
148
|
+
changedColumns?: string[]
|
|
149
|
+
changes?: unknown
|
|
150
|
+
} {
|
|
151
|
+
const updates = Array.isArray(node.updates) ? node.updates : []
|
|
152
|
+
const entries = updates
|
|
153
|
+
.map((update: any) => {
|
|
154
|
+
const column = getIdentifierName(update.column)
|
|
155
|
+
if (!column) return null
|
|
156
|
+
return [column, extractValue(update.value)] as const
|
|
157
|
+
})
|
|
158
|
+
.filter(Boolean) as Array<readonly [string, unknown]>
|
|
159
|
+
|
|
160
|
+
if (entries.length === 0) {
|
|
161
|
+
return {}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
changedColumns: entries.map(([column]) => column),
|
|
166
|
+
changes: Object.fromEntries(entries),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function extractChangeSet(
|
|
171
|
+
node: RootOperationNode,
|
|
172
|
+
queryKind: AuditedQuery['queryKind']
|
|
173
|
+
) {
|
|
174
|
+
if (queryKind === 'insert') {
|
|
175
|
+
return extractInsertChanges(node as any)
|
|
176
|
+
}
|
|
177
|
+
if (queryKind === 'update') {
|
|
178
|
+
return extractUpdateChanges(node as any)
|
|
179
|
+
}
|
|
180
|
+
return {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
class AuditedKyselyPlugin implements KyselyPlugin {
|
|
184
|
+
private readonly pending = new Map<unknown, AuditedQuery>()
|
|
185
|
+
private queryCounter = 0
|
|
186
|
+
|
|
187
|
+
constructor(private readonly options: CreateAuditedKyselyOptions) {}
|
|
188
|
+
|
|
189
|
+
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
|
190
|
+
const queryKind =
|
|
191
|
+
QUERY_KIND_BY_NODE[(args.node as { kind?: string }).kind ?? '']
|
|
192
|
+
if (!queryKind) {
|
|
193
|
+
return args.node
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (queryKind === 'select' && this.options.auditReads === false) {
|
|
197
|
+
return args.node
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const tables = new Set<string>()
|
|
201
|
+
collectTableNames(args.node, tables)
|
|
202
|
+
const queryId = `${this.options.queryIdPrefix ?? 'q'}-${++this.queryCounter}`
|
|
203
|
+
this.pending.set(args.queryId, {
|
|
204
|
+
queryKind,
|
|
205
|
+
queryId,
|
|
206
|
+
tables: [...tables].sort(),
|
|
207
|
+
...extractChangeSet(args.node, queryKind),
|
|
208
|
+
})
|
|
209
|
+
return args.node
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async transformResult(
|
|
213
|
+
args: PluginTransformResultArgs
|
|
214
|
+
): Promise<QueryResult<UnknownRow>> {
|
|
215
|
+
const pending = this.pending.get(args.queryId)
|
|
216
|
+
if (!pending) {
|
|
217
|
+
return args.result
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this.pending.delete(args.queryId)
|
|
221
|
+
const rawResult = args.result as QueryResult<UnknownRow> & {
|
|
222
|
+
numAffectedRows?: bigint
|
|
223
|
+
numUpdatedOrDeletedRows?: bigint
|
|
224
|
+
}
|
|
225
|
+
const rowCount =
|
|
226
|
+
rawResult.rows.length > 0
|
|
227
|
+
? rawResult.rows.length
|
|
228
|
+
: Number(
|
|
229
|
+
rawResult.numUpdatedOrDeletedRows ?? rawResult.numAffectedRows ?? 0n
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
await this.options.audit.write({
|
|
233
|
+
type: this.options.eventType ?? 'db.query',
|
|
234
|
+
source: 'auto',
|
|
235
|
+
transactionId: this.options.transactionId ?? null,
|
|
236
|
+
queryId: pending.queryId,
|
|
237
|
+
metadata: {
|
|
238
|
+
queryKind: pending.queryKind,
|
|
239
|
+
tables: pending.tables,
|
|
240
|
+
changedColumns: pending.changedColumns,
|
|
241
|
+
changes: pending.changes,
|
|
242
|
+
rowCount,
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
return rawResult
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function createAuditedKysely<DB>(
|
|
250
|
+
db: Kysely<DB>,
|
|
251
|
+
options: CreateAuditedKyselyOptions
|
|
252
|
+
): Kysely<DB> {
|
|
253
|
+
return db.withPlugin(new AuditedKyselyPlugin(options))
|
|
254
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { KyselyChannelStore } from './kysely-channel-store.js'
|
|
|
2
2
|
export { KyselyEventHubStore } from './kysely-eventhub-store.js'
|
|
3
3
|
export { KyselyWorkflowService } from './kysely-workflow-service.js'
|
|
4
4
|
export { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
|
|
5
|
+
export { KyselyWorkflowMirror } from './kysely-workflow-mirror.js'
|
|
5
6
|
export { KyselyDeploymentService } from './kysely-deployment-service.js'
|
|
6
7
|
export { KyselyAIStorageService } from './kysely-ai-storage-service.js'
|
|
7
8
|
export { KyselyAgentRunService } from './kysely-ai-agent-run-service.js'
|
|
@@ -11,6 +12,10 @@ export type { KyselySecretServiceConfig } from './kysely-secret-service.js'
|
|
|
11
12
|
export { KyselyCredentialService } from './kysely-credential-service.js'
|
|
12
13
|
export type { KyselyCredentialServiceConfig } from './kysely-credential-service.js'
|
|
13
14
|
export { KyselySessionStore } from './kysely-session-store.js'
|
|
15
|
+
export {
|
|
16
|
+
createAuditedKysely,
|
|
17
|
+
type CreateAuditedKyselyOptions,
|
|
18
|
+
} from './create-audited-kysely.js'
|
|
14
19
|
|
|
15
20
|
export type { KyselyPikkuDB } from './kysely-tables.js'
|
|
16
21
|
export type { WorkflowRunService } from '@pikku/core/workflow'
|
|
@@ -13,10 +13,11 @@ export class KyselyAgentRunService implements AgentRunService {
|
|
|
13
13
|
|
|
14
14
|
async listThreads(options?: {
|
|
15
15
|
agentName?: string
|
|
16
|
+
resourceId?: string
|
|
16
17
|
limit?: number
|
|
17
18
|
offset?: number
|
|
18
19
|
}): Promise<AIThread[]> {
|
|
19
|
-
const { agentName, limit = 50, offset = 0 } = options ?? {}
|
|
20
|
+
const { agentName, resourceId, limit = 50, offset = 0 } = options ?? {}
|
|
20
21
|
|
|
21
22
|
let query = this.db
|
|
22
23
|
.selectFrom('aiThreads as t')
|
|
@@ -41,6 +42,10 @@ export class KyselyAgentRunService implements AgentRunService {
|
|
|
41
42
|
)
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
if (resourceId) {
|
|
46
|
+
query = query.where('t.resourceId', '=', resourceId)
|
|
47
|
+
}
|
|
48
|
+
|
|
44
49
|
const result = await query
|
|
45
50
|
.orderBy('t.updatedAt', 'desc')
|
|
46
51
|
.limit(limit)
|
|
@@ -123,7 +123,9 @@ export class KyselyAIRunStateService implements AIRunStateService {
|
|
|
123
123
|
try {
|
|
124
124
|
approvals = JSON.parse(row.pendingApprovals as string)
|
|
125
125
|
} catch {
|
|
126
|
-
console.warn(
|
|
126
|
+
console.warn(
|
|
127
|
+
`Failed to parse pendingApprovals for run ${row.runId}, treating as empty`
|
|
128
|
+
)
|
|
127
129
|
}
|
|
128
130
|
}
|
|
129
131
|
const filtered = approvals.filter((a) => a.toolCallId !== toolCallId)
|
|
@@ -161,7 +163,9 @@ export class KyselyAIRunStateService implements AIRunStateService {
|
|
|
161
163
|
try {
|
|
162
164
|
approvals = JSON.parse((row as any).pendingApprovals)
|
|
163
165
|
} catch {
|
|
164
|
-
console.warn(
|
|
166
|
+
console.warn(
|
|
167
|
+
`Failed to parse pendingApprovals for run ${row.runId}, treating as empty`
|
|
168
|
+
)
|
|
165
169
|
}
|
|
166
170
|
}
|
|
167
171
|
const approval = approvals.find((a) => a.toolCallId === toolCallId)
|
|
@@ -27,6 +27,7 @@ export class KyselyChannelStore extends ChannelStore {
|
|
|
27
27
|
)
|
|
28
28
|
.addColumn('opening_data', 'text', (col) => col.notNull().defaultTo('{}'))
|
|
29
29
|
.addColumn('pikku_user_id', 'text')
|
|
30
|
+
.addColumn('state', 'text')
|
|
30
31
|
.addColumn('last_wire', 'timestamp', (col) =>
|
|
31
32
|
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
32
33
|
)
|
|
@@ -106,5 +107,31 @@ export class KyselyChannelStore extends ChannelStore {
|
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
public async setState(channelId: string, state: unknown): Promise<void> {
|
|
111
|
+
await this.db
|
|
112
|
+
.updateTable('channels')
|
|
113
|
+
.set({ state: JSON.stringify(state ?? null) })
|
|
114
|
+
.where('channelId', '=', channelId)
|
|
115
|
+
.execute()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public async getState(channelId: string): Promise<unknown | undefined> {
|
|
119
|
+
const row = await this.db
|
|
120
|
+
.selectFrom('channels')
|
|
121
|
+
.select(['state'])
|
|
122
|
+
.where('channelId', '=', channelId)
|
|
123
|
+
.executeTakeFirst()
|
|
124
|
+
if (!row || !row.state) return undefined
|
|
125
|
+
return parseJson(row.state) ?? undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
public async clearState(channelId: string): Promise<void> {
|
|
129
|
+
await this.db
|
|
130
|
+
.updateTable('channels')
|
|
131
|
+
.set({ state: null })
|
|
132
|
+
.where('channelId', '=', channelId)
|
|
133
|
+
.execute()
|
|
134
|
+
}
|
|
135
|
+
|
|
109
136
|
public async close(): Promise<void> {}
|
|
110
137
|
}
|
|
@@ -93,7 +93,7 @@ export class KyselySecretService implements SecretService {
|
|
|
93
93
|
throw new Error(`No KEK available for key_version ${version}`)
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
async getSecret(key: string): Promise<
|
|
96
|
+
async getSecret<T = string>(key: string): Promise<T> {
|
|
97
97
|
const row = await this.db
|
|
98
98
|
.selectFrom('secrets')
|
|
99
99
|
.select(['ciphertext', 'wrappedDek', 'keyVersion'])
|
|
@@ -103,20 +103,11 @@ export class KyselySecretService implements SecretService {
|
|
|
103
103
|
if (!row) throw new Error('Requested secret not found')
|
|
104
104
|
|
|
105
105
|
const kek = this.getKEK(row.keyVersion)
|
|
106
|
-
const result = await envelopeDecrypt<
|
|
107
|
-
kek,
|
|
108
|
-
row.ciphertext,
|
|
109
|
-
row.wrappedDek
|
|
110
|
-
)
|
|
106
|
+
const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
|
|
111
107
|
await this.logAudit(key, 'read')
|
|
112
108
|
return result
|
|
113
109
|
}
|
|
114
110
|
|
|
115
|
-
async getSecretJSON<R = {}>(key: string): Promise<R> {
|
|
116
|
-
const raw = await this.getSecret(key)
|
|
117
|
-
return JSON.parse(raw) as R
|
|
118
|
-
}
|
|
119
|
-
|
|
120
111
|
async hasSecret(key: string): Promise<boolean> {
|
|
121
112
|
const row = await this.db
|
|
122
113
|
.selectFrom('secrets')
|
|
@@ -126,12 +117,8 @@ export class KyselySecretService implements SecretService {
|
|
|
126
117
|
return !!row
|
|
127
118
|
}
|
|
128
119
|
|
|
129
|
-
async
|
|
130
|
-
const
|
|
131
|
-
const { ciphertext, wrappedDEK } = await envelopeEncrypt(
|
|
132
|
-
this.key,
|
|
133
|
-
plaintext
|
|
134
|
-
)
|
|
120
|
+
async setSecret(key: string, value: unknown): Promise<void> {
|
|
121
|
+
const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.key, value)
|
|
135
122
|
const now = new Date().toISOString()
|
|
136
123
|
|
|
137
124
|
await this.db
|
|
@@ -128,7 +128,7 @@ function registerTests(
|
|
|
128
128
|
auditReads: true,
|
|
129
129
|
})
|
|
130
130
|
await service.init()
|
|
131
|
-
await service.
|
|
131
|
+
await service.setSecret('audit-test', 'value')
|
|
132
132
|
await service.getSecret('audit-test')
|
|
133
133
|
await service.deleteSecret('audit-test')
|
|
134
134
|
|
|
@@ -152,7 +152,7 @@ function registerTests(
|
|
|
152
152
|
auditReads: false,
|
|
153
153
|
})
|
|
154
154
|
await service.init()
|
|
155
|
-
await service.
|
|
155
|
+
await service.setSecret('no-read-audit', 'value')
|
|
156
156
|
await service.getSecret('no-read-audit')
|
|
157
157
|
|
|
158
158
|
const logs = await getDb()
|
|
@@ -42,9 +42,7 @@ export class KyselySessionStore implements SessionStore {
|
|
|
42
42
|
return undefined
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
return (parseJson(row.session) ?? undefined) as
|
|
46
|
-
| CoreUserSession
|
|
47
|
-
| undefined
|
|
45
|
+
return (parseJson(row.session) ?? undefined) as CoreUserSession | undefined
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
async set(pikkuUserId: string, session: CoreUserSession): Promise<void> {
|
package/src/kysely-tables.ts
CHANGED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, test, beforeEach, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { CamelCasePlugin, 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 { KyselyWorkflowMirror } from './kysely-workflow-mirror.js'
|
|
9
|
+
import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
|
|
10
|
+
|
|
11
|
+
let db: Kysely<KyselyPikkuDB>
|
|
12
|
+
let mirror: KyselyWorkflowMirror
|
|
13
|
+
let runService: KyselyWorkflowRunService
|
|
14
|
+
|
|
15
|
+
const RUN_ID = 'run-aaaa'
|
|
16
|
+
const STEP_ID = 'step-bbbb'
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
db = new Kysely<KyselyPikkuDB>({
|
|
20
|
+
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
21
|
+
plugins: [new CamelCasePlugin(), new SerializePlugin()],
|
|
22
|
+
})
|
|
23
|
+
mirror = new KyselyWorkflowMirror(db)
|
|
24
|
+
await mirror.init()
|
|
25
|
+
runService = new KyselyWorkflowRunService(db)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await db.destroy()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
async function seedRun(opts?: { input?: any; inline?: boolean }) {
|
|
33
|
+
await mirror.createRun(
|
|
34
|
+
RUN_ID,
|
|
35
|
+
'wf',
|
|
36
|
+
opts?.input ?? { foo: 1 },
|
|
37
|
+
opts?.inline ?? false,
|
|
38
|
+
'hash1',
|
|
39
|
+
{ type: 'do', id: RUN_ID } as any
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function seedStep(stepId = STEP_ID, stepName = 'step1') {
|
|
44
|
+
await mirror.insertStepState(RUN_ID, {
|
|
45
|
+
stepId,
|
|
46
|
+
stepName,
|
|
47
|
+
rpcName: 'rpc.fn',
|
|
48
|
+
data: { x: 1 },
|
|
49
|
+
status: 'pending',
|
|
50
|
+
attemptCount: 1,
|
|
51
|
+
createdAt: new Date(),
|
|
52
|
+
updatedAt: new Date(),
|
|
53
|
+
} as any)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('KyselyWorkflowMirror — runs', () => {
|
|
57
|
+
test('createRun is visible to KyselyWorkflowRunService.getRun', async () => {
|
|
58
|
+
await seedRun({ input: { hello: 'world' } })
|
|
59
|
+
const run = await runService.getRun(RUN_ID)
|
|
60
|
+
assert.ok(run)
|
|
61
|
+
assert.equal(run.workflow, 'wf')
|
|
62
|
+
assert.equal(run.status, 'running')
|
|
63
|
+
assert.deepEqual(run.input, { hello: 'world' })
|
|
64
|
+
assert.equal(run.graphHash, 'hash1')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('updateRunStatus reflects in getRun', async () => {
|
|
68
|
+
await seedRun()
|
|
69
|
+
await mirror.updateRunStatus(RUN_ID, 'completed', { ok: true })
|
|
70
|
+
const run = await runService.getRun(RUN_ID)
|
|
71
|
+
assert.equal(run!.status, 'completed')
|
|
72
|
+
assert.deepEqual(run!.output, { ok: true })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('listRuns surfaces mirrored runs', async () => {
|
|
76
|
+
await seedRun()
|
|
77
|
+
const runs = await runService.listRuns()
|
|
78
|
+
assert.equal(runs.length, 1)
|
|
79
|
+
assert.equal(runs[0]!.id, RUN_ID)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('getDistinctWorkflowNames returns mirrored workflow names', async () => {
|
|
83
|
+
await seedRun()
|
|
84
|
+
const names = await runService.getDistinctWorkflowNames()
|
|
85
|
+
assert.deepEqual(names, ['wf'])
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('KyselyWorkflowMirror — step lifecycle', () => {
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
await seedRun()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('insertStepState creates step + initial pending history', async () => {
|
|
95
|
+
await seedStep()
|
|
96
|
+
const steps = await runService.getRunSteps(RUN_ID)
|
|
97
|
+
assert.equal(steps.length, 1)
|
|
98
|
+
assert.equal(steps[0]!.stepName, 'step1')
|
|
99
|
+
assert.equal(steps[0]!.status, 'pending')
|
|
100
|
+
|
|
101
|
+
const history = await runService.getRunHistory(RUN_ID)
|
|
102
|
+
assert.equal(history.length, 1)
|
|
103
|
+
assert.equal(history[0]!.status, 'pending')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('running → succeeded updates the in-place history row', async () => {
|
|
107
|
+
await seedStep()
|
|
108
|
+
await mirror.setStepRunning(STEP_ID)
|
|
109
|
+
await mirror.setStepResult(STEP_ID, { value: 42 })
|
|
110
|
+
|
|
111
|
+
const steps = await runService.getRunSteps(RUN_ID)
|
|
112
|
+
assert.equal(steps[0]!.status, 'succeeded')
|
|
113
|
+
assert.deepEqual(steps[0]!.result, { value: 42 })
|
|
114
|
+
|
|
115
|
+
const history = await runService.getRunHistory(RUN_ID)
|
|
116
|
+
assert.equal(history.length, 1, 'updates collapse onto latest history row')
|
|
117
|
+
assert.equal(history[0]!.status, 'succeeded')
|
|
118
|
+
assert.deepEqual(history[0]!.result, { value: 42 })
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('setStepError stores serialized error', async () => {
|
|
122
|
+
await seedStep()
|
|
123
|
+
await mirror.setStepError(STEP_ID, {
|
|
124
|
+
message: 'boom',
|
|
125
|
+
stack: 'stack',
|
|
126
|
+
code: 'EBOOM',
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const steps = await runService.getRunSteps(RUN_ID)
|
|
130
|
+
assert.equal(steps[0]!.status, 'failed')
|
|
131
|
+
assert.equal(steps[0]!.error?.message, 'boom')
|
|
132
|
+
assert.equal(steps[0]!.error?.code, 'EBOOM')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('createRetryAttempt appends a new history row + clears result/error', async () => {
|
|
136
|
+
await seedStep()
|
|
137
|
+
await mirror.setStepError(STEP_ID, { message: 'first try', code: 'X' })
|
|
138
|
+
|
|
139
|
+
await mirror.createRetryAttempt(STEP_ID, {
|
|
140
|
+
stepId: STEP_ID,
|
|
141
|
+
stepName: 'step1',
|
|
142
|
+
status: 'pending',
|
|
143
|
+
attemptCount: 2,
|
|
144
|
+
createdAt: new Date(),
|
|
145
|
+
updatedAt: new Date(),
|
|
146
|
+
} as any)
|
|
147
|
+
|
|
148
|
+
const steps = await runService.getRunSteps(RUN_ID)
|
|
149
|
+
assert.equal(steps[0]!.status, 'pending')
|
|
150
|
+
assert.equal(steps[0]!.error, undefined)
|
|
151
|
+
assert.equal(steps[0]!.result, undefined)
|
|
152
|
+
|
|
153
|
+
const history = await runService.getRunHistory(RUN_ID)
|
|
154
|
+
// pending (initial — collapsed onto failed) + pending (retry append)
|
|
155
|
+
assert.equal(history.length, 2)
|
|
156
|
+
assert.equal(history[0]!.status, 'failed')
|
|
157
|
+
assert.equal(history[1]!.status, 'pending')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('setStepChildRunId persists', async () => {
|
|
161
|
+
await seedStep()
|
|
162
|
+
await mirror.setStepChildRunId(STEP_ID, 'child-run-1')
|
|
163
|
+
const steps = await runService.getRunSteps(RUN_ID)
|
|
164
|
+
assert.equal((steps[0] as any).childRunId, 'child-run-1')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('setBranchTaken persists the branch key', async () => {
|
|
168
|
+
await seedStep()
|
|
169
|
+
await mirror.setBranchTaken(STEP_ID, 'left')
|
|
170
|
+
const row = await db
|
|
171
|
+
.selectFrom('workflowStep')
|
|
172
|
+
.select('branchTaken')
|
|
173
|
+
.where('workflowStepId', '=', STEP_ID)
|
|
174
|
+
.executeTakeFirst()
|
|
175
|
+
assert.equal(row?.branchTaken, 'left')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('KyselyWorkflowMirror — run state', () => {
|
|
180
|
+
beforeEach(async () => {
|
|
181
|
+
await seedRun()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('updateRunState merges values', async () => {
|
|
185
|
+
await mirror.updateRunState(RUN_ID, 'counter', 1)
|
|
186
|
+
await mirror.updateRunState(RUN_ID, 'flag', true)
|
|
187
|
+
const row = await db
|
|
188
|
+
.selectFrom('workflowRuns')
|
|
189
|
+
.select('state')
|
|
190
|
+
.where('workflowRunId', '=', RUN_ID)
|
|
191
|
+
.executeTakeFirst()
|
|
192
|
+
const state =
|
|
193
|
+
typeof row?.state === 'string' ? JSON.parse(row.state) : row?.state
|
|
194
|
+
assert.deepEqual(state, { counter: 1, flag: true })
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
describe('KyselyWorkflowMirror — workflow versions', () => {
|
|
199
|
+
test('upsertWorkflowVersion is idempotent on (name, hash)', async () => {
|
|
200
|
+
await mirror.upsertWorkflowVersion('wf', 'h1', { v: 1 }, 'graph')
|
|
201
|
+
await mirror.upsertWorkflowVersion('wf', 'h1', { v: 2 }, 'graph')
|
|
202
|
+
const v = await runService.getWorkflowVersion('wf', 'h1')
|
|
203
|
+
assert.deepEqual(v?.graph, { v: 1 }, 'first write wins (doNothing)')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('updateWorkflowVersionStatus mutates status', async () => {
|
|
207
|
+
await mirror.upsertWorkflowVersion('wf', 'h1', {}, 'graph', 'active')
|
|
208
|
+
await mirror.updateWorkflowVersionStatus('wf', 'h1', 'archived')
|
|
209
|
+
const row = await db
|
|
210
|
+
.selectFrom('workflowVersions')
|
|
211
|
+
.select('status')
|
|
212
|
+
.where('workflowName', '=', 'wf')
|
|
213
|
+
.where('graphHash', '=', 'h1')
|
|
214
|
+
.executeTakeFirst()
|
|
215
|
+
assert.equal(row?.status, 'archived')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('AI-generated workflows surface via getAIGeneratedWorkflows', async () => {
|
|
219
|
+
await mirror.upsertWorkflowVersion(
|
|
220
|
+
'ai:agent-x:foo',
|
|
221
|
+
'h1',
|
|
222
|
+
{ nodes: [] },
|
|
223
|
+
'dynamic-workflow'
|
|
224
|
+
)
|
|
225
|
+
const list = await runService.getAIGeneratedWorkflows('agent-x')
|
|
226
|
+
assert.equal(list.length, 1)
|
|
227
|
+
assert.equal(list[0]!.workflowName, 'ai:agent-x:foo')
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
describe('KyselyWorkflowMirror — init', () => {
|
|
232
|
+
test('init is idempotent', async () => {
|
|
233
|
+
await mirror.init()
|
|
234
|
+
await mirror.init()
|
|
235
|
+
// No-op on second call; just shouldn't throw.
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('cohabits with KyselyWorkflowService schema (ifNotExists)', async () => {
|
|
239
|
+
// Same DB, schema already created by mirror.init() in beforeEach.
|
|
240
|
+
// If KyselyWorkflowService.init() ran on the same DB, both should
|
|
241
|
+
// succeed because both use ifNotExists. We can't import it without
|
|
242
|
+
// the singleton-services bootstrap, so simulate by re-calling our
|
|
243
|
+
// own init — proves the SQL is idempotent.
|
|
244
|
+
await assert.doesNotReject(mirror.init())
|
|
245
|
+
})
|
|
246
|
+
})
|