@pikku/kysely 0.13.24 → 0.13.27
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 +235 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/kysely-analytics-service.d.ts +30 -0
- package/dist/src/kysely-analytics-service.js +80 -0
- package/dist/src/kysely-feature-flag-store.d.ts +37 -0
- package/dist/src/kysely-feature-flag-store.js +239 -0
- package/dist/src/kysely-tables.d.ts +45 -0
- package/dist/src/schema/analytics.schema.d.ts +17 -0
- package/dist/src/schema/analytics.schema.js +48 -0
- package/dist/src/schema/flag.schema.d.ts +16 -0
- package/dist/src/schema/flag.schema.js +62 -0
- package/dist/src/schema/index.d.ts +2 -0
- package/dist/src/schema/index.js +6 -0
- package/package.json +11 -6
- package/run-tests.sh +9 -1
- package/bin/pikku-kysely-pure.ts +0 -59
- package/dist/src/kysely-lock-vault.d.ts +0 -34
- package/dist/src/kysely-lock-vault.js +0 -77
- package/dist/src/schema/data-lock.schema.d.ts +0 -15
- package/dist/src/schema/data-lock.schema.js +0 -27
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/classification-crypto.test.ts +0 -143
- package/src/classification-crypto.ts +0 -163
- package/src/classification-plugin.pglite.test.ts +0 -226
- package/src/classification-plugin.test.ts +0 -265
- package/src/classification-plugin.ts +0 -249
- package/src/coercion-plugin.test.ts +0 -160
- package/src/coercion-plugin.ts +0 -187
- package/src/create-audited-kysely.test.ts +0 -560
- package/src/create-audited-kysely.ts +0 -254
- package/src/index.ts +0 -81
- package/src/kysely-agent-run-scores.ts +0 -47
- package/src/kysely-agent-run-service.ts +0 -244
- package/src/kysely-agent-run-state-service.ts +0 -200
- package/src/kysely-agent-storage-service.ts +0 -632
- package/src/kysely-audit-service.test.ts +0 -352
- package/src/kysely-audit-service.ts +0 -237
- package/src/kysely-channel-store.ts +0 -107
- package/src/kysely-credential-service.ts +0 -286
- package/src/kysely-deployment-service.ts +0 -163
- package/src/kysely-eventhub-store.ts +0 -53
- package/src/kysely-json.ts +0 -5
- package/src/kysely-scope-service.test.ts +0 -418
- package/src/kysely-scope-service.ts +0 -425
- package/src/kysely-secret-service.ts +0 -242
- package/src/kysely-services.test.ts +0 -405
- package/src/kysely-session-store.ts +0 -57
- package/src/kysely-tables.test.ts +0 -110
- package/src/kysely-tables.ts +0 -434
- package/src/kysely-virtual-user-run-store.test.ts +0 -369
- package/src/kysely-virtual-user-run-store.ts +0 -235
- package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
- package/src/kysely-virtual-user-schedule-store.ts +0 -187
- package/src/kysely-webhook-service.test.ts +0 -153
- package/src/kysely-webhook-service.ts +0 -139
- package/src/kysely-workflow-mirror.test.ts +0 -259
- package/src/kysely-workflow-mirror.ts +0 -374
- package/src/kysely-workflow-run-service.ts +0 -305
- package/src/kysely-workflow-service.test.ts +0 -544
- package/src/kysely-workflow-service.ts +0 -820
- package/src/schema/agent.schema.ts +0 -162
- package/src/schema/audit.schema.ts +0 -57
- package/src/schema/channel.schema.ts +0 -38
- package/src/schema/credential.schema.ts +0 -58
- package/src/schema/deployment.schema.ts +0 -47
- package/src/schema/index.ts +0 -441
- package/src/schema/pikku-schema.types.ts +0 -169
- package/src/schema/schema.test.ts +0 -591
- package/src/schema/scope.schema.ts +0 -110
- package/src/schema/secret.schema.ts +0 -49
- package/src/schema/session.schema.ts +0 -20
- package/src/schema/virtual-user-schedule.schema.ts +0 -48
- package/src/schema/virtual-user.schema.ts +0 -106
- package/src/schema/webhook.schema.ts +0 -58
- package/src/schema/workflow.schema.ts +0 -150
- package/src/serialize-plugin.ts +0 -233
- package/tsconfig.json +0 -17
|
@@ -1,820 +0,0 @@
|
|
|
1
|
-
import type { SerializedError } from '@pikku/core/errors'
|
|
2
|
-
import {
|
|
3
|
-
PikkuWorkflowService,
|
|
4
|
-
WorkflowStepFunctionMismatchError,
|
|
5
|
-
} from '@pikku/core/workflow'
|
|
6
|
-
import type {
|
|
7
|
-
WorkflowPlannedStep,
|
|
8
|
-
WorkflowQueueOptions,
|
|
9
|
-
WorkflowRun,
|
|
10
|
-
WorkflowRunWire,
|
|
11
|
-
StepState,
|
|
12
|
-
StepStatus,
|
|
13
|
-
WorkflowStatus,
|
|
14
|
-
WorkflowVersionStatus,
|
|
15
|
-
} from '@pikku/core/workflow'
|
|
16
|
-
import { sql, type Kysely } from 'kysely'
|
|
17
|
-
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
18
|
-
import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
|
|
19
|
-
import { parseJson } from './kysely-json.js'
|
|
20
|
-
import { requirePikkuSchema } from './schema/index.js'
|
|
21
|
-
import { workflowSchema } from './schema/workflow.schema.js'
|
|
22
|
-
|
|
23
|
-
export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
24
|
-
private initialized = false
|
|
25
|
-
private runService: KyselyWorkflowRunService
|
|
26
|
-
|
|
27
|
-
constructor(
|
|
28
|
-
protected db: Kysely<KyselyPikkuDB>,
|
|
29
|
-
options: WorkflowQueueOptions = {}
|
|
30
|
-
) {
|
|
31
|
-
super(options)
|
|
32
|
-
this.runService = new KyselyWorkflowRunService(db)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
public async init(): Promise<void> {
|
|
36
|
-
if (this.initialized) return
|
|
37
|
-
await requirePikkuSchema(this.db, workflowSchema)
|
|
38
|
-
this.initialized = true
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
protected async createRunImpl(
|
|
42
|
-
workflowName: string,
|
|
43
|
-
input: any,
|
|
44
|
-
inline: boolean,
|
|
45
|
-
graphHash: string,
|
|
46
|
-
wire: WorkflowRunWire,
|
|
47
|
-
options?: {
|
|
48
|
-
deterministic?: boolean
|
|
49
|
-
plannedSteps?: WorkflowPlannedStep[]
|
|
50
|
-
}
|
|
51
|
-
): Promise<string> {
|
|
52
|
-
const id = crypto.randomUUID()
|
|
53
|
-
await this.db
|
|
54
|
-
.insertInto('workflowRuns')
|
|
55
|
-
.values({
|
|
56
|
-
workflowRunId: id,
|
|
57
|
-
workflow: workflowName,
|
|
58
|
-
status: 'running',
|
|
59
|
-
input: JSON.stringify(input),
|
|
60
|
-
inline,
|
|
61
|
-
graphHash: graphHash ?? null,
|
|
62
|
-
deterministic: options?.deterministic ?? false,
|
|
63
|
-
plannedSteps: options?.plannedSteps
|
|
64
|
-
? JSON.stringify(options.plannedSteps)
|
|
65
|
-
: null,
|
|
66
|
-
wire: wire ? JSON.stringify(wire) : null,
|
|
67
|
-
})
|
|
68
|
-
.execute()
|
|
69
|
-
|
|
70
|
-
return id
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async getRun(id: string): Promise<WorkflowRun | null> {
|
|
74
|
-
return this.runService.getRun(id)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
protected async updateRunStatusImpl(
|
|
78
|
-
id: string,
|
|
79
|
-
status: WorkflowStatus,
|
|
80
|
-
output?: any,
|
|
81
|
-
error?: SerializedError
|
|
82
|
-
): Promise<void> {
|
|
83
|
-
await this.db
|
|
84
|
-
.updateTable('workflowRuns')
|
|
85
|
-
.set({
|
|
86
|
-
status,
|
|
87
|
-
output: output ? JSON.stringify(output) : null,
|
|
88
|
-
error: error ? JSON.stringify(error) : null,
|
|
89
|
-
updatedAt: new Date(),
|
|
90
|
-
})
|
|
91
|
-
.where('workflowRunId', '=', id)
|
|
92
|
-
.execute()
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
protected async insertStepStateImpl(
|
|
96
|
-
runId: string,
|
|
97
|
-
stepName: string,
|
|
98
|
-
rpcName: string | null,
|
|
99
|
-
data: any,
|
|
100
|
-
stepOptions?: { retries?: number; retryDelay?: string | number },
|
|
101
|
-
fromStepName?: string
|
|
102
|
-
): Promise<StepState> {
|
|
103
|
-
const stepId = crypto.randomUUID()
|
|
104
|
-
const now = new Date()
|
|
105
|
-
|
|
106
|
-
await this.db
|
|
107
|
-
.insertInto('workflowStep')
|
|
108
|
-
.values({
|
|
109
|
-
workflowStepId: stepId,
|
|
110
|
-
workflowRunId: runId,
|
|
111
|
-
stepName: stepName,
|
|
112
|
-
rpcName: rpcName,
|
|
113
|
-
data: data != null ? JSON.stringify(data) : null,
|
|
114
|
-
status: 'pending',
|
|
115
|
-
retries: stepOptions?.retries ?? null,
|
|
116
|
-
retryDelay: stepOptions?.retryDelay?.toString() ?? null,
|
|
117
|
-
fromStepName: fromStepName ?? null,
|
|
118
|
-
currentAttempt: 1,
|
|
119
|
-
createdAt: now,
|
|
120
|
-
updatedAt: now,
|
|
121
|
-
})
|
|
122
|
-
.execute()
|
|
123
|
-
|
|
124
|
-
await this.insertHistoryRecord(stepId, 'pending', 1)
|
|
125
|
-
|
|
126
|
-
return {
|
|
127
|
-
stepId,
|
|
128
|
-
status: 'pending',
|
|
129
|
-
rpcName,
|
|
130
|
-
result: undefined,
|
|
131
|
-
error: undefined,
|
|
132
|
-
attemptCount: 1,
|
|
133
|
-
retries: stepOptions?.retries,
|
|
134
|
-
retryDelay: stepOptions?.retryDelay?.toString(),
|
|
135
|
-
fromStepName,
|
|
136
|
-
createdAt: now,
|
|
137
|
-
updatedAt: now,
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async getStepState(runId: string, stepName: string): Promise<StepState> {
|
|
142
|
-
const row = await this.db
|
|
143
|
-
.selectFrom('workflowStep')
|
|
144
|
-
.select([
|
|
145
|
-
'workflowStepId',
|
|
146
|
-
'status',
|
|
147
|
-
'rpcName',
|
|
148
|
-
'result',
|
|
149
|
-
'error',
|
|
150
|
-
'retries',
|
|
151
|
-
'retryDelay',
|
|
152
|
-
'fromStepName',
|
|
153
|
-
// `current_attempt` is bumped alongside every history insert, so it is
|
|
154
|
-
// already the count this used to aggregate — and reading it keeps the
|
|
155
|
-
// engine's hottest query off a table that grows for the life of the run.
|
|
156
|
-
'currentAttempt',
|
|
157
|
-
'createdAt',
|
|
158
|
-
'updatedAt',
|
|
159
|
-
])
|
|
160
|
-
.where('workflowRunId', '=', runId)
|
|
161
|
-
.where('stepName', '=', stepName)
|
|
162
|
-
.executeTakeFirst()
|
|
163
|
-
|
|
164
|
-
if (!row) {
|
|
165
|
-
throw new Error(
|
|
166
|
-
`Step not found: runId=${runId}, stepName=${stepName}. Use insertStepState to create it.`
|
|
167
|
-
)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return {
|
|
171
|
-
stepId: row.workflowStepId,
|
|
172
|
-
status: row.status as StepState['status'],
|
|
173
|
-
rpcName: row.rpcName ?? null,
|
|
174
|
-
result: parseJson(row.result),
|
|
175
|
-
error: parseJson(row.error),
|
|
176
|
-
attemptCount: Number(row.currentAttempt ?? 1),
|
|
177
|
-
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
178
|
-
retryDelay: row.retryDelay ?? undefined,
|
|
179
|
-
fromStepName: row.fromStepName ?? undefined,
|
|
180
|
-
createdAt: new Date(row.createdAt),
|
|
181
|
-
updatedAt: new Date(row.updatedAt),
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
protected override async listStepStates(
|
|
186
|
-
runId: string
|
|
187
|
-
): Promise<Array<StepState & { stepName: string }>> {
|
|
188
|
-
const rows = await this.db
|
|
189
|
-
.selectFrom('workflowStep')
|
|
190
|
-
.select([
|
|
191
|
-
'workflowStepId',
|
|
192
|
-
'stepName',
|
|
193
|
-
'status',
|
|
194
|
-
'result',
|
|
195
|
-
'error',
|
|
196
|
-
'retries',
|
|
197
|
-
'retryDelay',
|
|
198
|
-
'fromStepName',
|
|
199
|
-
'currentAttempt',
|
|
200
|
-
'createdAt',
|
|
201
|
-
'updatedAt',
|
|
202
|
-
])
|
|
203
|
-
.where('workflowRunId', '=', runId)
|
|
204
|
-
.execute()
|
|
205
|
-
|
|
206
|
-
return rows.map((row) => ({
|
|
207
|
-
stepId: row.workflowStepId,
|
|
208
|
-
stepName: row.stepName,
|
|
209
|
-
status: row.status as StepState['status'],
|
|
210
|
-
result: parseJson(row.result),
|
|
211
|
-
error: parseJson(row.error),
|
|
212
|
-
attemptCount: Number(row.currentAttempt ?? 1),
|
|
213
|
-
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
214
|
-
retryDelay: row.retryDelay ?? undefined,
|
|
215
|
-
fromStepName: row.fromStepName ?? undefined,
|
|
216
|
-
createdAt: new Date(row.createdAt),
|
|
217
|
-
updatedAt: new Date(row.updatedAt),
|
|
218
|
-
}))
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async getRunHistory(
|
|
222
|
-
runId: string
|
|
223
|
-
): Promise<Array<StepState & { stepName: string }>> {
|
|
224
|
-
return this.runService.getRunHistory(runId)
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
protected async setStepRunningImpl(stepId: string): Promise<void> {
|
|
228
|
-
await this.writeStepTransition(stepId, 'running', {
|
|
229
|
-
runningAt: new Date(),
|
|
230
|
-
})
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
protected async setStepScheduledImpl(stepId: string): Promise<void> {
|
|
234
|
-
await this.db
|
|
235
|
-
.updateTable('workflowStep')
|
|
236
|
-
.set({ status: 'scheduled', updatedAt: new Date() })
|
|
237
|
-
.where('workflowStepId', '=', stepId)
|
|
238
|
-
.execute()
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Move a step and its current history attempt to the same status in one
|
|
243
|
-
* transaction.
|
|
244
|
-
*
|
|
245
|
-
* Both halves matter: a crash between them used to leave the step row saying
|
|
246
|
-
* `succeeded` while its history row still said `running`, which silently
|
|
247
|
-
* corrupts `getRunHistory` and every timeline reconstruction built on it.
|
|
248
|
-
* The history row is targeted by its `attempt` rather than by a `LIMIT 1`
|
|
249
|
-
* over `created_at`, so a retry that lands in the same millisecond as the
|
|
250
|
-
* attempt it replaces still resolves the newer row.
|
|
251
|
-
*/
|
|
252
|
-
private async writeStepTransition(
|
|
253
|
-
stepId: string,
|
|
254
|
-
status: StepStatus,
|
|
255
|
-
historyValues: Record<string, unknown>
|
|
256
|
-
): Promise<void> {
|
|
257
|
-
const now = new Date()
|
|
258
|
-
const stepValues: Record<string, unknown> = { status, updatedAt: now }
|
|
259
|
-
if ('result' in historyValues) stepValues.result = historyValues.result
|
|
260
|
-
if ('error' in historyValues) stepValues.error = historyValues.error
|
|
261
|
-
// A step carries only its latest outcome, so the other half is cleared.
|
|
262
|
-
if (status === 'succeeded') stepValues.error = null
|
|
263
|
-
if (status === 'failed') stepValues.result = null
|
|
264
|
-
|
|
265
|
-
await this.db.transaction().execute(async (trx) => {
|
|
266
|
-
await trx
|
|
267
|
-
.updateTable('workflowStep')
|
|
268
|
-
.set({
|
|
269
|
-
...stepValues,
|
|
270
|
-
// A step that somehow reaches a transition without a live attempt
|
|
271
|
-
// addresses no history row at all, so give it one here. Written as an
|
|
272
|
-
// expression over the row's own column rather than a subquery, which
|
|
273
|
-
// MySQL would reject against the table being updated.
|
|
274
|
-
currentAttempt: sql`coalesce(current_attempt, 1)`,
|
|
275
|
-
} as any)
|
|
276
|
-
.where('workflowStepId', '=', stepId)
|
|
277
|
-
.execute()
|
|
278
|
-
|
|
279
|
-
const written = await trx
|
|
280
|
-
.updateTable('workflowStepHistory')
|
|
281
|
-
.set({ status, ...historyValues } as any)
|
|
282
|
-
.where('workflowStepId', '=', stepId)
|
|
283
|
-
// Correlate through the step row rather than a MAX() over this table:
|
|
284
|
-
// MySQL rejects a subquery that reads the table being updated, and a
|
|
285
|
-
// primary-key lookup is cheaper than an aggregate anyway.
|
|
286
|
-
.where('attempt', '=', (eb) =>
|
|
287
|
-
eb
|
|
288
|
-
.selectFrom('workflowStep')
|
|
289
|
-
.select('currentAttempt')
|
|
290
|
-
.where('workflowStepId', '=', stepId)
|
|
291
|
-
)
|
|
292
|
-
.executeTakeFirst()
|
|
293
|
-
|
|
294
|
-
// Matching nothing means the attempt has no history row to move, which
|
|
295
|
-
// would commit the step's new status with no record of it — precisely
|
|
296
|
-
// the divergence this transaction exists to prevent. Write the missing
|
|
297
|
-
// row instead of letting the pair drift apart.
|
|
298
|
-
if (Number(written?.numUpdatedRows ?? 0n) === 0) {
|
|
299
|
-
const step = await trx
|
|
300
|
-
.selectFrom('workflowStep')
|
|
301
|
-
.select('currentAttempt')
|
|
302
|
-
.where('workflowStepId', '=', stepId)
|
|
303
|
-
.executeTakeFirst()
|
|
304
|
-
await trx
|
|
305
|
-
.insertInto('workflowStepHistory')
|
|
306
|
-
.values({
|
|
307
|
-
historyId: crypto.randomUUID(),
|
|
308
|
-
workflowStepId: stepId,
|
|
309
|
-
status,
|
|
310
|
-
attempt: step?.currentAttempt ?? 1,
|
|
311
|
-
createdAt: now,
|
|
312
|
-
...historyValues,
|
|
313
|
-
} as any)
|
|
314
|
-
.execute()
|
|
315
|
-
}
|
|
316
|
-
})
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
private async insertHistoryRecord(
|
|
320
|
-
stepId: string,
|
|
321
|
-
status: string,
|
|
322
|
-
attempt: number,
|
|
323
|
-
result?: any,
|
|
324
|
-
error?: SerializedError
|
|
325
|
-
): Promise<void> {
|
|
326
|
-
const now = new Date()
|
|
327
|
-
const values: Record<string, any> = {
|
|
328
|
-
historyId: crypto.randomUUID(),
|
|
329
|
-
workflowStepId: stepId,
|
|
330
|
-
status,
|
|
331
|
-
attempt,
|
|
332
|
-
result: result != null ? JSON.stringify(result) : null,
|
|
333
|
-
error: error != null ? JSON.stringify(error) : null,
|
|
334
|
-
createdAt: now,
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const timestampField = this.getTimestampFieldForStatus(status)
|
|
338
|
-
if (timestampField !== 'createdAt') {
|
|
339
|
-
values[timestampField] = now
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
await this.db
|
|
343
|
-
.insertInto('workflowStepHistory')
|
|
344
|
-
.values(values as any)
|
|
345
|
-
.execute()
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
private getTimestampFieldForStatus(status: string): string {
|
|
349
|
-
switch (status) {
|
|
350
|
-
case 'running':
|
|
351
|
-
return 'runningAt'
|
|
352
|
-
case 'scheduled':
|
|
353
|
-
return 'scheduledAt'
|
|
354
|
-
case 'succeeded':
|
|
355
|
-
return 'succeededAt'
|
|
356
|
-
case 'failed':
|
|
357
|
-
return 'failedAt'
|
|
358
|
-
default:
|
|
359
|
-
return 'createdAt'
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
protected async setStepChildRunIdImpl(
|
|
364
|
-
stepId: string,
|
|
365
|
-
childRunId: string
|
|
366
|
-
): Promise<void> {
|
|
367
|
-
await this.db
|
|
368
|
-
.updateTable('workflowStep')
|
|
369
|
-
.set({
|
|
370
|
-
childRunId: childRunId,
|
|
371
|
-
updatedAt: new Date(),
|
|
372
|
-
})
|
|
373
|
-
.where('workflowStepId', '=', stepId)
|
|
374
|
-
.execute()
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
protected async setStepResultImpl(
|
|
378
|
-
stepId: string,
|
|
379
|
-
result: any
|
|
380
|
-
): Promise<void> {
|
|
381
|
-
await this.writeStepTransition(stepId, 'succeeded', {
|
|
382
|
-
result: JSON.stringify(result),
|
|
383
|
-
succeededAt: new Date(),
|
|
384
|
-
})
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
protected async setStepErrorImpl(
|
|
388
|
-
stepId: string,
|
|
389
|
-
error: Error
|
|
390
|
-
): Promise<void> {
|
|
391
|
-
const serializedError: SerializedError = {
|
|
392
|
-
message: error.message,
|
|
393
|
-
stack: error.stack,
|
|
394
|
-
code: (error as any).code,
|
|
395
|
-
}
|
|
396
|
-
await this.writeStepTransition(stepId, 'failed', {
|
|
397
|
-
error: JSON.stringify(serializedError),
|
|
398
|
-
failedAt: new Date(),
|
|
399
|
-
})
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
protected async createRetryAttemptImpl(
|
|
403
|
-
stepId: string,
|
|
404
|
-
status: 'pending' | 'running'
|
|
405
|
-
): Promise<StepState> {
|
|
406
|
-
// Read before writing: the new attempt number comes from the history this
|
|
407
|
-
// step already has, and the row it reads is the one returned below.
|
|
408
|
-
const previous = await this.db
|
|
409
|
-
.selectFrom('workflowStep')
|
|
410
|
-
.select('currentAttempt')
|
|
411
|
-
.where('workflowStepId', '=', stepId)
|
|
412
|
-
.executeTakeFirst()
|
|
413
|
-
const attempt = (previous?.currentAttempt ?? 0) + 1
|
|
414
|
-
|
|
415
|
-
await this.db
|
|
416
|
-
.updateTable('workflowStep')
|
|
417
|
-
.set({
|
|
418
|
-
status,
|
|
419
|
-
result: null,
|
|
420
|
-
error: null,
|
|
421
|
-
currentAttempt: attempt,
|
|
422
|
-
updatedAt: new Date(),
|
|
423
|
-
})
|
|
424
|
-
.where('workflowStepId', '=', stepId)
|
|
425
|
-
.execute()
|
|
426
|
-
|
|
427
|
-
await this.insertHistoryRecord(stepId, status, attempt)
|
|
428
|
-
|
|
429
|
-
const row = await this.db
|
|
430
|
-
.selectFrom('workflowStep')
|
|
431
|
-
.select([
|
|
432
|
-
'workflowStepId',
|
|
433
|
-
'status',
|
|
434
|
-
'rpcName',
|
|
435
|
-
'result',
|
|
436
|
-
'error',
|
|
437
|
-
'retries',
|
|
438
|
-
'retryDelay',
|
|
439
|
-
'fromStepName',
|
|
440
|
-
'currentAttempt',
|
|
441
|
-
'createdAt',
|
|
442
|
-
'updatedAt',
|
|
443
|
-
])
|
|
444
|
-
.where('workflowStepId', '=', stepId)
|
|
445
|
-
.executeTakeFirstOrThrow()
|
|
446
|
-
|
|
447
|
-
return {
|
|
448
|
-
stepId: row.workflowStepId,
|
|
449
|
-
status: row.status as StepState['status'],
|
|
450
|
-
rpcName: row.rpcName ?? null,
|
|
451
|
-
result: parseJson(row.result),
|
|
452
|
-
error: parseJson(row.error),
|
|
453
|
-
attemptCount: Number(row.currentAttempt ?? 1),
|
|
454
|
-
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
455
|
-
retryDelay: row.retryDelay ?? undefined,
|
|
456
|
-
fromStepName: row.fromStepName ?? undefined,
|
|
457
|
-
createdAt: new Date(row.createdAt),
|
|
458
|
-
updatedAt: new Date(row.updatedAt),
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
async withRunLock<T>(_id: string, fn: () => Promise<T>): Promise<T> {
|
|
463
|
-
return fn()
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/**
|
|
467
|
-
* A pass-through, and deliberately so: the one decision that must exclude —
|
|
468
|
-
* claiming a step to execute it — is made by `claimStepForExecution` below as
|
|
469
|
-
* a single conditional write, which needs no lock to be exclusive.
|
|
470
|
-
*
|
|
471
|
-
* What is left are the suspend and approval sections in the engine, where the
|
|
472
|
-
* lock is genuine mutual exclusion rather than a claim. A dialect with a real
|
|
473
|
-
* primitive overrides this to cover them — `kysely-postgres` with
|
|
474
|
-
* `pg_advisory_xact_lock`, `kysely-mysql` with `GET_LOCK`.
|
|
475
|
-
*/
|
|
476
|
-
async withStepLock<T>(
|
|
477
|
-
_runId: string,
|
|
478
|
-
_stepName: string,
|
|
479
|
-
fn: () => Promise<T>
|
|
480
|
-
): Promise<T> {
|
|
481
|
-
return fn()
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
/**
|
|
485
|
-
* Claim the step with a status-guarded `UPDATE` and read the affected-row
|
|
486
|
-
* count: the database decides the winner in one statement, so two dispatches
|
|
487
|
-
* racing for the same step cannot both proceed.
|
|
488
|
-
*
|
|
489
|
-
* This replaces the read-then-write the base engine does under `withStepLock`,
|
|
490
|
-
* which is only as exclusive as that lock — and every dialect but Postgres and
|
|
491
|
-
* MySQL inherits a pass-through. A conditional update needs no advisory-lock
|
|
492
|
-
* primitive, so every SQL dialect gets the same guarantee.
|
|
493
|
-
*
|
|
494
|
-
* The winner then goes through the ordinary transition methods, so history
|
|
495
|
-
* rows and the mirror see exactly what they saw before.
|
|
496
|
-
*/
|
|
497
|
-
protected override async claimStepForExecution(
|
|
498
|
-
runId: string,
|
|
499
|
-
stepName: string,
|
|
500
|
-
rpcName: string
|
|
501
|
-
): Promise<StepState | null> {
|
|
502
|
-
const stepState = await this.getStepState(runId, stepName)
|
|
503
|
-
|
|
504
|
-
// knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
|
|
505
|
-
if (
|
|
506
|
-
stepState.rpcName !== undefined &&
|
|
507
|
-
stepState.rpcName !== (rpcName ?? null)
|
|
508
|
-
) {
|
|
509
|
-
throw new WorkflowStepFunctionMismatchError(runId, stepName)
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
if (stepState.status === 'succeeded' || stepState.status === 'running') {
|
|
513
|
-
return null
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
if (stepState.status === 'failed') {
|
|
517
|
-
if (!(await this.claimStepStatus(stepState.stepId, ['failed']))) {
|
|
518
|
-
return null
|
|
519
|
-
}
|
|
520
|
-
return this.createRetryAttempt(stepState.stepId, 'running')
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
if (stepState.status === 'pending' || stepState.status === 'scheduled') {
|
|
524
|
-
if (
|
|
525
|
-
!(await this.claimStepStatus(stepState.stepId, [
|
|
526
|
-
'pending',
|
|
527
|
-
'scheduled',
|
|
528
|
-
]))
|
|
529
|
-
) {
|
|
530
|
-
return null
|
|
531
|
-
}
|
|
532
|
-
await this.setStepRunning(stepState.stepId)
|
|
533
|
-
return { ...stepState, status: 'running' }
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
return stepState
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/**
|
|
540
|
-
* Move a step to `running` only if it is still in one of `from`, reporting
|
|
541
|
-
* whether this caller is the one that moved it.
|
|
542
|
-
*/
|
|
543
|
-
private async claimStepStatus(
|
|
544
|
-
stepId: string,
|
|
545
|
-
from: StepStatus[]
|
|
546
|
-
): Promise<boolean> {
|
|
547
|
-
const claimed = await this.db
|
|
548
|
-
.updateTable('workflowStep')
|
|
549
|
-
.set({ status: 'running', updatedAt: new Date() })
|
|
550
|
-
.where('workflowStepId', '=', stepId)
|
|
551
|
-
.where('status', 'in', from)
|
|
552
|
-
.executeTakeFirst()
|
|
553
|
-
|
|
554
|
-
return Number(claimed?.numUpdatedRows ?? 0n) > 0
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
async getCompletedGraphState(runId: string): Promise<{
|
|
558
|
-
completedNodeIds: string[]
|
|
559
|
-
failedNodeIds: string[]
|
|
560
|
-
branchKeys: Record<string, string>
|
|
561
|
-
}> {
|
|
562
|
-
const results = await this.db
|
|
563
|
-
.selectFrom('workflowStep as ws')
|
|
564
|
-
.select(['ws.stepName', 'ws.status', 'ws.branchTaken', 'ws.retries'])
|
|
565
|
-
.select((eb) =>
|
|
566
|
-
eb
|
|
567
|
-
.selectFrom('workflowStepHistory as h')
|
|
568
|
-
.select(eb.fn.countAll<number>().as('cnt'))
|
|
569
|
-
.whereRef('h.workflowStepId', '=', 'ws.workflowStepId')
|
|
570
|
-
.as('attemptCount')
|
|
571
|
-
)
|
|
572
|
-
.where('ws.workflowRunId', '=', runId)
|
|
573
|
-
.where('ws.status', 'in', ['succeeded', 'failed'])
|
|
574
|
-
.execute()
|
|
575
|
-
|
|
576
|
-
const completedNodeIds: string[] = []
|
|
577
|
-
const failedNodeIds: string[] = []
|
|
578
|
-
const branchKeys: Record<string, string> = {}
|
|
579
|
-
|
|
580
|
-
for (const row of results) {
|
|
581
|
-
const nodeId = row.stepName
|
|
582
|
-
|
|
583
|
-
if (row.status === 'succeeded') {
|
|
584
|
-
completedNodeIds.push(nodeId)
|
|
585
|
-
if (row.branchTaken) {
|
|
586
|
-
branchKeys[nodeId] = row.branchTaken
|
|
587
|
-
}
|
|
588
|
-
} else if (row.status === 'failed') {
|
|
589
|
-
const maxAttempts = (row.retries ?? 0) + 1
|
|
590
|
-
if (Number(row.attemptCount) >= maxAttempts) {
|
|
591
|
-
failedNodeIds.push(nodeId)
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
return { completedNodeIds, failedNodeIds, branchKeys }
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
async getStepInstances(
|
|
600
|
-
runId: string
|
|
601
|
-
): Promise<
|
|
602
|
-
Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
|
|
603
|
-
> {
|
|
604
|
-
const rows = await this.db
|
|
605
|
-
.selectFrom('workflowStep')
|
|
606
|
-
.select(['stepName', 'status', 'fromStepName'])
|
|
607
|
-
.where('workflowRunId', '=', runId)
|
|
608
|
-
.execute()
|
|
609
|
-
return rows.map((r) => ({
|
|
610
|
-
stepName: r.stepName,
|
|
611
|
-
status: r.status as StepStatus,
|
|
612
|
-
fromStepName: r.fromStepName ?? undefined,
|
|
613
|
-
}))
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
async getNodeResults(
|
|
617
|
-
runId: string,
|
|
618
|
-
nodeIds: string[]
|
|
619
|
-
): Promise<Record<string, any>> {
|
|
620
|
-
if (nodeIds.length === 0) return {}
|
|
621
|
-
|
|
622
|
-
const result = await this.db
|
|
623
|
-
.selectFrom('workflowStep')
|
|
624
|
-
.select(['stepName', 'result'])
|
|
625
|
-
.where('workflowRunId', '=', runId)
|
|
626
|
-
.where('stepName', 'in', nodeIds)
|
|
627
|
-
.where('status', '=', 'succeeded')
|
|
628
|
-
.execute()
|
|
629
|
-
|
|
630
|
-
const results: Record<string, any> = {}
|
|
631
|
-
for (const row of result) {
|
|
632
|
-
results[row.stepName] = parseJson(row.result)
|
|
633
|
-
}
|
|
634
|
-
return results
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
protected async setBranchTakenImpl(
|
|
638
|
-
stepId: string,
|
|
639
|
-
branchKey: string
|
|
640
|
-
): Promise<void> {
|
|
641
|
-
await this.db
|
|
642
|
-
.updateTable('workflowStep')
|
|
643
|
-
.set({ branchTaken: branchKey, updatedAt: new Date() })
|
|
644
|
-
.where('workflowStepId', '=', stepId)
|
|
645
|
-
.execute()
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/**
|
|
649
|
-
* Merge one key into the run's JSON state, as a single expression evaluated
|
|
650
|
-
* by the database.
|
|
651
|
-
*
|
|
652
|
-
* The SQL is dialect-specific, so subclasses override this rather than the
|
|
653
|
-
* caller. The default is the SQLite form; MySQL and Postgres differ in how
|
|
654
|
-
* they cast a JSON literal.
|
|
655
|
-
*
|
|
656
|
-
* @param path - JSON path to the key, already quoted (`$."key"`)
|
|
657
|
-
* @param json - The value as JSON text
|
|
658
|
-
*/
|
|
659
|
-
protected jsonSetState(path: string, json: string) {
|
|
660
|
-
return sql<string>`json_set(coalesce(state, '{}'), ${path}, json(${json}))`
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* A JSON path for an arbitrary key. State keys are user-supplied (a graph's
|
|
665
|
-
* `setState` name, an approval's hex-encoded reason), so the key is quoted
|
|
666
|
-
* rather than interpolated bare — an unquoted `$.a.b` would silently address
|
|
667
|
-
* a nested path instead of the key literally called `a.b`.
|
|
668
|
-
*/
|
|
669
|
-
private jsonPathFor(name: string): string {
|
|
670
|
-
return `$.${JSON.stringify(name)}`
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
protected async updateRunStateImpl(
|
|
674
|
-
runId: string,
|
|
675
|
-
name: string,
|
|
676
|
-
value: unknown
|
|
677
|
-
): Promise<void> {
|
|
678
|
-
// Merged by the database, not read-modify-written in JS: two graph nodes
|
|
679
|
-
// calling setState concurrently used to race, and the later write silently
|
|
680
|
-
// dropped whichever key the earlier one had added.
|
|
681
|
-
await this.db
|
|
682
|
-
.updateTable('workflowRuns')
|
|
683
|
-
.set({
|
|
684
|
-
state: this.jsonSetState(
|
|
685
|
-
this.jsonPathFor(name),
|
|
686
|
-
JSON.stringify(value ?? null)
|
|
687
|
-
),
|
|
688
|
-
updatedAt: new Date(),
|
|
689
|
-
})
|
|
690
|
-
.where('workflowRunId', '=', runId)
|
|
691
|
-
.execute()
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
/**
|
|
695
|
-
* The undispatched-step query, shared by every dialect.
|
|
696
|
-
*
|
|
697
|
-
* It used to be deliberately NOT an override, because this class's
|
|
698
|
-
* `withStepLock` is a pass-through: a subclass inheriting it (kysely-sqlite)
|
|
699
|
-
* had no atomic claim, so the relay's redundant dispatches would have become
|
|
700
|
-
* double executions, and only `kysely-postgres` and `kysely-mysql` opted in
|
|
701
|
-
* on the strength of their real locks. `claimStepForExecution` no longer
|
|
702
|
-
* needs that lock — the claim is a status-guarded `UPDATE`, atomic in every
|
|
703
|
-
* dialect — so the relay is safe here for all of them and the override lives
|
|
704
|
-
* in the base.
|
|
705
|
-
*/
|
|
706
|
-
protected override async findUndispatchedSteps(
|
|
707
|
-
before: Date,
|
|
708
|
-
limit: number
|
|
709
|
-
): Promise<Array<{ runId: string; stepId: string }>> {
|
|
710
|
-
const rows = await this.db
|
|
711
|
-
.selectFrom('workflowStep as s')
|
|
712
|
-
.innerJoin('workflowRuns as r', 'r.workflowRunId', 's.workflowRunId')
|
|
713
|
-
.select(['s.workflowStepId', 's.workflowRunId'])
|
|
714
|
-
.where('s.status', '=', 'pending')
|
|
715
|
-
.where('s.updatedAt', '<', before)
|
|
716
|
-
// Leftover `pending` steps on a settled run are not work; only a live run
|
|
717
|
-
// still has anything to dispatch.
|
|
718
|
-
.where('r.status', '=', 'running')
|
|
719
|
-
.orderBy('s.updatedAt', 'asc')
|
|
720
|
-
.limit(limit)
|
|
721
|
-
.execute()
|
|
722
|
-
|
|
723
|
-
return rows.map((row) => ({
|
|
724
|
-
runId: row.workflowRunId,
|
|
725
|
-
stepId: row.workflowStepId,
|
|
726
|
-
}))
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
protected async findStalledRunIds(
|
|
730
|
-
before: Date,
|
|
731
|
-
limit: number
|
|
732
|
-
): Promise<string[]> {
|
|
733
|
-
const rows = await this.db
|
|
734
|
-
.selectFrom('workflowRuns as r')
|
|
735
|
-
.select('r.workflowRunId')
|
|
736
|
-
.where('r.status', '=', 'running')
|
|
737
|
-
.where(({ not, exists, selectFrom }) =>
|
|
738
|
-
not(
|
|
739
|
-
exists(
|
|
740
|
-
selectFrom('workflowStep as s')
|
|
741
|
-
.select('s.workflowStepId')
|
|
742
|
-
.whereRef('s.workflowRunId', '=', 'r.workflowRunId')
|
|
743
|
-
.where('s.status', 'in', ['running', 'scheduled', 'suspended'])
|
|
744
|
-
)
|
|
745
|
-
)
|
|
746
|
-
)
|
|
747
|
-
// The run row only moves on a status change, so idleness has to hold for
|
|
748
|
-
// the steps too — expressed as "no step touched since `before`" rather
|
|
749
|
-
// than max(updated_at) so it stays one index-friendly NOT EXISTS.
|
|
750
|
-
.where('r.updatedAt', '<', before)
|
|
751
|
-
.where(({ not, exists, selectFrom }) =>
|
|
752
|
-
not(
|
|
753
|
-
exists(
|
|
754
|
-
selectFrom('workflowStep as s')
|
|
755
|
-
.select('s.workflowStepId')
|
|
756
|
-
.whereRef('s.workflowRunId', '=', 'r.workflowRunId')
|
|
757
|
-
.where('s.updatedAt', '>=', before)
|
|
758
|
-
)
|
|
759
|
-
)
|
|
760
|
-
)
|
|
761
|
-
.orderBy('r.updatedAt', 'asc')
|
|
762
|
-
.limit(limit)
|
|
763
|
-
.execute()
|
|
764
|
-
|
|
765
|
-
return rows.map((row) => row.workflowRunId)
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
async getRunState(runId: string): Promise<Record<string, unknown>> {
|
|
769
|
-
const row = await this.db
|
|
770
|
-
.selectFrom('workflowRuns')
|
|
771
|
-
.select('state')
|
|
772
|
-
.where('workflowRunId', '=', runId)
|
|
773
|
-
.executeTakeFirst()
|
|
774
|
-
|
|
775
|
-
if (!row) return {}
|
|
776
|
-
return parseJson(row.state) ?? {}
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
protected async upsertWorkflowVersionImpl(
|
|
780
|
-
name: string,
|
|
781
|
-
graphHash: string,
|
|
782
|
-
graph: any,
|
|
783
|
-
source: string,
|
|
784
|
-
status?: WorkflowVersionStatus
|
|
785
|
-
): Promise<void> {
|
|
786
|
-
await this.db
|
|
787
|
-
.insertInto('workflowVersions')
|
|
788
|
-
.values({
|
|
789
|
-
workflowName: name,
|
|
790
|
-
graphHash: graphHash,
|
|
791
|
-
graph: JSON.stringify(graph),
|
|
792
|
-
source,
|
|
793
|
-
status: status ?? 'active',
|
|
794
|
-
})
|
|
795
|
-
.onConflict((oc) => oc.columns(['workflowName', 'graphHash']).doNothing())
|
|
796
|
-
.execute()
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
protected async updateWorkflowVersionStatusImpl(
|
|
800
|
-
name: string,
|
|
801
|
-
graphHash: string,
|
|
802
|
-
status: WorkflowVersionStatus
|
|
803
|
-
): Promise<void> {
|
|
804
|
-
await this.db
|
|
805
|
-
.updateTable('workflowVersions')
|
|
806
|
-
.set({ status })
|
|
807
|
-
.where('workflowName', '=', name)
|
|
808
|
-
.where('graphHash', '=', graphHash)
|
|
809
|
-
.execute()
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
async getWorkflowVersion(
|
|
813
|
-
name: string,
|
|
814
|
-
graphHash: string
|
|
815
|
-
): Promise<{ graph: any; source: string } | null> {
|
|
816
|
-
return this.runService.getWorkflowVersion(name, graphHash)
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
async close(): Promise<void> {}
|
|
820
|
-
}
|