@pikku/kysely 0.12.10 → 0.12.12
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 +22 -4
- 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-tables.d.ts +4 -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-run-service.js +6 -0
- package/dist/src/kysely-workflow-service.d.ts +17 -14
- package/dist/src/kysely-workflow-service.js +19 -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-session-store.ts +1 -3
- package/src/kysely-tables.ts +4 -0
- package/src/kysely-workflow-mirror.test.ts +246 -0
- package/src/kysely-workflow-mirror.ts +438 -0
- package/src/kysely-workflow-run-service.ts +6 -0
- package/src/kysely-workflow-service.ts +37 -14
|
@@ -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
|
}
|
|
@@ -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
|
@@ -11,6 +11,8 @@ export interface ChannelsTable {
|
|
|
11
11
|
createdAt: Generated<Date>
|
|
12
12
|
openingData: string
|
|
13
13
|
pikkuUserId: string | null
|
|
14
|
+
/** JSON-serialized per-socket channel state, keyed by channelId. */
|
|
15
|
+
state: string | null
|
|
14
16
|
lastWire: Generated<Date>
|
|
15
17
|
}
|
|
16
18
|
|
|
@@ -29,6 +31,8 @@ export interface WorkflowRunsTable {
|
|
|
29
31
|
state: Generated<string>
|
|
30
32
|
inline: Generated<boolean>
|
|
31
33
|
graphHash: string | null
|
|
34
|
+
deterministic: Generated<boolean>
|
|
35
|
+
plannedSteps: string | null
|
|
32
36
|
wire: string | null
|
|
33
37
|
createdAt: Generated<Date>
|
|
34
38
|
updatedAt: Generated<Date>
|
|
@@ -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
|
+
})
|