@pikku/kysely 0.13.23 → 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.
Files changed (80) hide show
  1. package/CHANGELOG.md +248 -0
  2. package/dist/src/index.d.ts +4 -1
  3. package/dist/src/index.js +3 -1
  4. package/dist/src/kysely-analytics-service.d.ts +30 -0
  5. package/dist/src/kysely-analytics-service.js +80 -0
  6. package/dist/src/kysely-feature-flag-store.d.ts +37 -0
  7. package/dist/src/kysely-feature-flag-store.js +239 -0
  8. package/dist/src/kysely-tables.d.ts +45 -0
  9. package/dist/src/kysely-webhook-service.d.ts +1 -1
  10. package/dist/src/kysely-webhook-service.js +1 -1
  11. package/dist/src/schema/analytics.schema.d.ts +17 -0
  12. package/dist/src/schema/analytics.schema.js +48 -0
  13. package/dist/src/schema/flag.schema.d.ts +16 -0
  14. package/dist/src/schema/flag.schema.js +62 -0
  15. package/dist/src/schema/index.d.ts +2 -0
  16. package/dist/src/schema/index.js +6 -0
  17. package/package.json +11 -6
  18. package/run-tests.sh +9 -1
  19. package/bin/pikku-kysely-pure.ts +0 -59
  20. package/dist/src/kysely-lock-vault.d.ts +0 -34
  21. package/dist/src/kysely-lock-vault.js +0 -77
  22. package/dist/src/schema/data-lock.schema.d.ts +0 -15
  23. package/dist/src/schema/data-lock.schema.js +0 -27
  24. package/dist/tsconfig.tsbuildinfo +0 -1
  25. package/src/classification-crypto.test.ts +0 -143
  26. package/src/classification-crypto.ts +0 -163
  27. package/src/classification-plugin.pglite.test.ts +0 -226
  28. package/src/classification-plugin.test.ts +0 -265
  29. package/src/classification-plugin.ts +0 -249
  30. package/src/coercion-plugin.test.ts +0 -160
  31. package/src/coercion-plugin.ts +0 -187
  32. package/src/create-audited-kysely.test.ts +0 -560
  33. package/src/create-audited-kysely.ts +0 -254
  34. package/src/index.ts +0 -81
  35. package/src/kysely-agent-run-scores.ts +0 -47
  36. package/src/kysely-agent-run-service.ts +0 -244
  37. package/src/kysely-agent-run-state-service.ts +0 -200
  38. package/src/kysely-agent-storage-service.ts +0 -632
  39. package/src/kysely-audit-service.test.ts +0 -352
  40. package/src/kysely-audit-service.ts +0 -237
  41. package/src/kysely-channel-store.ts +0 -107
  42. package/src/kysely-credential-service.ts +0 -286
  43. package/src/kysely-deployment-service.ts +0 -163
  44. package/src/kysely-eventhub-store.ts +0 -53
  45. package/src/kysely-json.ts +0 -5
  46. package/src/kysely-scope-service.test.ts +0 -418
  47. package/src/kysely-scope-service.ts +0 -425
  48. package/src/kysely-secret-service.ts +0 -242
  49. package/src/kysely-services.test.ts +0 -405
  50. package/src/kysely-session-store.ts +0 -57
  51. package/src/kysely-tables.test.ts +0 -110
  52. package/src/kysely-tables.ts +0 -434
  53. package/src/kysely-virtual-user-run-store.test.ts +0 -369
  54. package/src/kysely-virtual-user-run-store.ts +0 -235
  55. package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
  56. package/src/kysely-virtual-user-schedule-store.ts +0 -187
  57. package/src/kysely-webhook-service.test.ts +0 -124
  58. package/src/kysely-webhook-service.ts +0 -137
  59. package/src/kysely-workflow-mirror.test.ts +0 -259
  60. package/src/kysely-workflow-mirror.ts +0 -374
  61. package/src/kysely-workflow-run-service.ts +0 -305
  62. package/src/kysely-workflow-service.test.ts +0 -544
  63. package/src/kysely-workflow-service.ts +0 -820
  64. package/src/schema/agent.schema.ts +0 -162
  65. package/src/schema/audit.schema.ts +0 -57
  66. package/src/schema/channel.schema.ts +0 -38
  67. package/src/schema/credential.schema.ts +0 -58
  68. package/src/schema/deployment.schema.ts +0 -47
  69. package/src/schema/index.ts +0 -441
  70. package/src/schema/pikku-schema.types.ts +0 -169
  71. package/src/schema/schema.test.ts +0 -591
  72. package/src/schema/scope.schema.ts +0 -110
  73. package/src/schema/secret.schema.ts +0 -49
  74. package/src/schema/session.schema.ts +0 -20
  75. package/src/schema/virtual-user-schedule.schema.ts +0 -48
  76. package/src/schema/virtual-user.schema.ts +0 -106
  77. package/src/schema/webhook.schema.ts +0 -58
  78. package/src/schema/workflow.schema.ts +0 -150
  79. package/src/serialize-plugin.ts +0 -233
  80. 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
- }