@pikku/kysely 0.13.5 → 0.13.6

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.
@@ -10,7 +10,7 @@ import {
10
10
  type WorkflowStatus,
11
11
  type WorkflowVersionStatus,
12
12
  } from '@pikku/core/workflow'
13
- import type { Kysely } from 'kysely'
13
+ import { sql, type Kysely } from 'kysely'
14
14
  import type { KyselyPikkuDB } from './kysely-tables.js'
15
15
  import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
16
16
  import { parseJson } from './kysely-json.js'
@@ -112,12 +112,13 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
112
112
  retries: stepOptions?.retries ?? null,
113
113
  retryDelay: stepOptions?.retryDelay?.toString() ?? null,
114
114
  fromStepName: fromStepName ?? null,
115
+ currentAttempt: 1,
115
116
  createdAt: now,
116
117
  updatedAt: now,
117
118
  })
118
119
  .execute()
119
120
 
120
- await this.insertHistoryRecord(stepId, 'pending')
121
+ await this.insertHistoryRecord(stepId, 'pending', 1)
121
122
 
122
123
  return {
123
124
  stepId,
@@ -135,31 +136,24 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
135
136
 
136
137
  async getStepState(runId: string, stepName: string): Promise<StepState> {
137
138
  const row = await this.db
138
- .selectFrom('workflowStep as s')
139
+ .selectFrom('workflowStep')
139
140
  .select([
140
- 's.workflowStepId',
141
- 's.status',
142
- 's.result',
143
- 's.error',
144
- 's.retries',
145
- 's.retryDelay',
146
- 's.fromStepName',
147
- 's.createdAt',
148
- 's.updatedAt',
141
+ 'workflowStepId',
142
+ 'status',
143
+ 'result',
144
+ 'error',
145
+ 'retries',
146
+ 'retryDelay',
147
+ 'fromStepName',
148
+ // `current_attempt` is bumped alongside every history insert, so it is
149
+ // already the count this used to aggregate — and reading it keeps the
150
+ // engine's hottest query off a table that grows for the life of the run.
151
+ 'currentAttempt',
152
+ 'createdAt',
153
+ 'updatedAt',
149
154
  ])
150
- .select((eb) =>
151
- eb
152
- .selectFrom('workflowStepHistory')
153
- .select(eb.fn.countAll<number>().as('cnt'))
154
- .whereRef(
155
- 'workflowStepHistory.workflowStepId',
156
- '=',
157
- 's.workflowStepId'
158
- )
159
- .as('attemptCount')
160
- )
161
- .where('s.workflowRunId', '=', runId)
162
- .where('s.stepName', '=', stepName)
155
+ .where('workflowRunId', '=', runId)
156
+ .where('stepName', '=', stepName)
163
157
  .executeTakeFirst()
164
158
 
165
159
  if (!row) {
@@ -173,7 +167,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
173
167
  status: row.status as StepState['status'],
174
168
  result: parseJson(row.result),
175
169
  error: parseJson(row.error),
176
- attemptCount: Number(row.attemptCount),
170
+ attemptCount: Number(row.currentAttempt ?? 1),
177
171
  retries: row.retries != null ? Number(row.retries) : undefined,
178
172
  retryDelay: row.retryDelay ?? undefined,
179
173
  fromStepName: row.fromStepName ?? undefined,
@@ -182,6 +176,42 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
182
176
  }
183
177
  }
184
178
 
179
+ protected override async listStepStates(
180
+ runId: string
181
+ ): Promise<Array<StepState & { stepName: string }>> {
182
+ const rows = await this.db
183
+ .selectFrom('workflowStep')
184
+ .select([
185
+ 'workflowStepId',
186
+ 'stepName',
187
+ 'status',
188
+ 'result',
189
+ 'error',
190
+ 'retries',
191
+ 'retryDelay',
192
+ 'fromStepName',
193
+ 'currentAttempt',
194
+ 'createdAt',
195
+ 'updatedAt',
196
+ ])
197
+ .where('workflowRunId', '=', runId)
198
+ .execute()
199
+
200
+ return rows.map((row) => ({
201
+ stepId: row.workflowStepId,
202
+ stepName: row.stepName,
203
+ status: row.status as StepState['status'],
204
+ result: parseJson(row.result),
205
+ error: parseJson(row.error),
206
+ attemptCount: Number(row.currentAttempt ?? 1),
207
+ retries: row.retries != null ? Number(row.retries) : undefined,
208
+ retryDelay: row.retryDelay ?? undefined,
209
+ fromStepName: row.fromStepName ?? undefined,
210
+ createdAt: new Date(row.createdAt),
211
+ updatedAt: new Date(row.updatedAt),
212
+ }))
213
+ }
214
+
185
215
  async getRunHistory(
186
216
  runId: string
187
217
  ): Promise<Array<StepState & { stepName: string }>> {
@@ -189,27 +219,9 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
189
219
  }
190
220
 
191
221
  protected async setStepRunningImpl(stepId: string): Promise<void> {
192
- await this.db
193
- .updateTable('workflowStep')
194
- .set({ status: 'running', updatedAt: new Date() })
195
- .where('workflowStepId', '=', stepId)
196
- .execute()
197
-
198
- const latestHistory = await this.db
199
- .selectFrom('workflowStepHistory')
200
- .select('historyId')
201
- .where('workflowStepId', '=', stepId)
202
- .orderBy('createdAt', 'desc')
203
- .limit(1)
204
- .executeTakeFirst()
205
-
206
- if (latestHistory) {
207
- await this.db
208
- .updateTable('workflowStepHistory')
209
- .set({ status: 'running', runningAt: new Date() })
210
- .where('historyId', '=', latestHistory.historyId)
211
- .execute()
212
- }
222
+ await this.writeStepTransition(stepId, 'running', {
223
+ runningAt: new Date(),
224
+ })
213
225
  }
214
226
 
215
227
  protected async setStepScheduledImpl(stepId: string): Promise<void> {
@@ -220,9 +232,88 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
220
232
  .execute()
221
233
  }
222
234
 
235
+ /**
236
+ * Move a step and its current history attempt to the same status in one
237
+ * transaction.
238
+ *
239
+ * Both halves matter: a crash between them used to leave the step row saying
240
+ * `succeeded` while its history row still said `running`, which silently
241
+ * corrupts `getRunHistory` and every timeline reconstruction built on it.
242
+ * The history row is targeted by its `attempt` rather than by a `LIMIT 1`
243
+ * over `created_at`, so a retry that lands in the same millisecond as the
244
+ * attempt it replaces still resolves the newer row.
245
+ */
246
+ private async writeStepTransition(
247
+ stepId: string,
248
+ status: StepStatus,
249
+ historyValues: Record<string, unknown>
250
+ ): Promise<void> {
251
+ const now = new Date()
252
+ const stepValues: Record<string, unknown> = { status, updatedAt: now }
253
+ if ('result' in historyValues) stepValues.result = historyValues.result
254
+ if ('error' in historyValues) stepValues.error = historyValues.error
255
+ // A step carries only its latest outcome, so the other half is cleared.
256
+ if (status === 'succeeded') stepValues.error = null
257
+ if (status === 'failed') stepValues.result = null
258
+
259
+ await this.db.transaction().execute(async (trx) => {
260
+ await trx
261
+ .updateTable('workflowStep')
262
+ .set({
263
+ ...stepValues,
264
+ // A step that somehow reaches a transition without a live attempt
265
+ // addresses no history row at all, so give it one here. Written as an
266
+ // expression over the row's own column rather than a subquery, which
267
+ // MySQL would reject against the table being updated.
268
+ currentAttempt: sql`coalesce(current_attempt, 1)`,
269
+ } as any)
270
+ .where('workflowStepId', '=', stepId)
271
+ .execute()
272
+
273
+ const written = await trx
274
+ .updateTable('workflowStepHistory')
275
+ .set({ status, ...historyValues } as any)
276
+ .where('workflowStepId', '=', stepId)
277
+ // Correlate through the step row rather than a MAX() over this table:
278
+ // MySQL rejects a subquery that reads the table being updated, and a
279
+ // primary-key lookup is cheaper than an aggregate anyway.
280
+ .where('attempt', '=', (eb) =>
281
+ eb
282
+ .selectFrom('workflowStep')
283
+ .select('currentAttempt')
284
+ .where('workflowStepId', '=', stepId)
285
+ )
286
+ .executeTakeFirst()
287
+
288
+ // Matching nothing means the attempt has no history row to move, which
289
+ // would commit the step's new status with no record of it — precisely
290
+ // the divergence this transaction exists to prevent. Write the missing
291
+ // row instead of letting the pair drift apart.
292
+ if (Number(written?.numUpdatedRows ?? 0n) === 0) {
293
+ const step = await trx
294
+ .selectFrom('workflowStep')
295
+ .select('currentAttempt')
296
+ .where('workflowStepId', '=', stepId)
297
+ .executeTakeFirst()
298
+ await trx
299
+ .insertInto('workflowStepHistory')
300
+ .values({
301
+ historyId: crypto.randomUUID(),
302
+ workflowStepId: stepId,
303
+ status,
304
+ attempt: step?.currentAttempt ?? 1,
305
+ createdAt: now,
306
+ ...historyValues,
307
+ } as any)
308
+ .execute()
309
+ }
310
+ })
311
+ }
312
+
223
313
  private async insertHistoryRecord(
224
314
  stepId: string,
225
315
  status: string,
316
+ attempt: number,
226
317
  result?: any,
227
318
  error?: SerializedError
228
319
  ): Promise<void> {
@@ -231,6 +322,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
231
322
  historyId: crypto.randomUUID(),
232
323
  workflowStepId: stepId,
233
324
  status,
325
+ attempt,
234
326
  result: result != null ? JSON.stringify(result) : null,
235
327
  error: error != null ? JSON.stringify(error) : null,
236
328
  createdAt: now,
@@ -280,38 +372,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
280
372
  stepId: string,
281
373
  result: any
282
374
  ): Promise<void> {
283
- const resultJson = JSON.stringify(result)
284
-
285
- await this.db
286
- .updateTable('workflowStep')
287
- .set({
288
- status: 'succeeded',
289
- result: resultJson,
290
- error: null,
291
- updatedAt: new Date(),
292
- })
293
- .where('workflowStepId', '=', stepId)
294
- .execute()
295
-
296
- const latestHistory = await this.db
297
- .selectFrom('workflowStepHistory')
298
- .select('historyId')
299
- .where('workflowStepId', '=', stepId)
300
- .orderBy('createdAt', 'desc')
301
- .limit(1)
302
- .executeTakeFirst()
303
-
304
- if (latestHistory) {
305
- await this.db
306
- .updateTable('workflowStepHistory')
307
- .set({
308
- status: 'succeeded',
309
- result: resultJson,
310
- succeededAt: new Date(),
311
- })
312
- .where('historyId', '=', latestHistory.historyId)
313
- .execute()
314
- }
375
+ await this.writeStepTransition(stepId, 'succeeded', {
376
+ result: JSON.stringify(result),
377
+ succeededAt: new Date(),
378
+ })
315
379
  }
316
380
 
317
381
  protected async setStepErrorImpl(
@@ -323,73 +387,54 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
323
387
  stack: error.stack,
324
388
  code: (error as any).code,
325
389
  }
326
- const errorJson = JSON.stringify(serializedError)
327
-
328
- await this.db
329
- .updateTable('workflowStep')
330
- .set({
331
- status: 'failed',
332
- error: errorJson,
333
- result: null,
334
- updatedAt: new Date(),
335
- })
336
- .where('workflowStepId', '=', stepId)
337
- .execute()
338
-
339
- const latestHistory = await this.db
340
- .selectFrom('workflowStepHistory')
341
- .select('historyId')
342
- .where('workflowStepId', '=', stepId)
343
- .orderBy('createdAt', 'desc')
344
- .limit(1)
345
- .executeTakeFirst()
346
-
347
- if (latestHistory) {
348
- await this.db
349
- .updateTable('workflowStepHistory')
350
- .set({ status: 'failed', error: errorJson, failedAt: new Date() })
351
- .where('historyId', '=', latestHistory.historyId)
352
- .execute()
353
- }
390
+ await this.writeStepTransition(stepId, 'failed', {
391
+ error: JSON.stringify(serializedError),
392
+ failedAt: new Date(),
393
+ })
354
394
  }
355
395
 
356
396
  protected async createRetryAttemptImpl(
357
397
  stepId: string,
358
398
  status: 'pending' | 'running'
359
399
  ): Promise<StepState> {
400
+ // Read before writing: the new attempt number comes from the history this
401
+ // step already has, and the row it reads is the one returned below.
402
+ const previous = await this.db
403
+ .selectFrom('workflowStep')
404
+ .select('currentAttempt')
405
+ .where('workflowStepId', '=', stepId)
406
+ .executeTakeFirst()
407
+ const attempt = (previous?.currentAttempt ?? 0) + 1
408
+
360
409
  await this.db
361
410
  .updateTable('workflowStep')
362
- .set({ status, result: null, error: null, updatedAt: new Date() })
411
+ .set({
412
+ status,
413
+ result: null,
414
+ error: null,
415
+ currentAttempt: attempt,
416
+ updatedAt: new Date(),
417
+ })
363
418
  .where('workflowStepId', '=', stepId)
364
419
  .execute()
365
420
 
366
- await this.insertHistoryRecord(stepId, status)
421
+ await this.insertHistoryRecord(stepId, status, attempt)
367
422
 
368
423
  const row = await this.db
369
- .selectFrom('workflowStep as s')
424
+ .selectFrom('workflowStep')
370
425
  .select([
371
- 's.workflowStepId',
372
- 's.status',
373
- 's.result',
374
- 's.error',
375
- 's.retries',
376
- 's.retryDelay',
377
- 's.fromStepName',
378
- 's.createdAt',
379
- 's.updatedAt',
426
+ 'workflowStepId',
427
+ 'status',
428
+ 'result',
429
+ 'error',
430
+ 'retries',
431
+ 'retryDelay',
432
+ 'fromStepName',
433
+ 'currentAttempt',
434
+ 'createdAt',
435
+ 'updatedAt',
380
436
  ])
381
- .select((eb) =>
382
- eb
383
- .selectFrom('workflowStepHistory')
384
- .select(eb.fn.countAll<number>().as('cnt'))
385
- .whereRef(
386
- 'workflowStepHistory.workflowStepId',
387
- '=',
388
- 's.workflowStepId'
389
- )
390
- .as('attemptCount')
391
- )
392
- .where('s.workflowStepId', '=', stepId)
437
+ .where('workflowStepId', '=', stepId)
393
438
  .executeTakeFirstOrThrow()
394
439
 
395
440
  return {
@@ -397,7 +442,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
397
442
  status: row.status as StepState['status'],
398
443
  result: parseJson(row.result),
399
444
  error: parseJson(row.error),
400
- attemptCount: Number(row.attemptCount),
445
+ attemptCount: Number(row.currentAttempt ?? 1),
401
446
  retries: row.retries != null ? Number(row.retries) : undefined,
402
447
  retryDelay: row.retryDelay ?? undefined,
403
448
  fromStepName: row.fromStepName ?? undefined,
@@ -526,23 +571,48 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
526
571
  .execute()
527
572
  }
528
573
 
574
+ /**
575
+ * Merge one key into the run's JSON state, as a single expression evaluated
576
+ * by the database.
577
+ *
578
+ * The SQL is dialect-specific, so subclasses override this rather than the
579
+ * caller. The default is the SQLite form; MySQL and Postgres differ in how
580
+ * they cast a JSON literal.
581
+ *
582
+ * @param path - JSON path to the key, already quoted (`$."key"`)
583
+ * @param json - The value as JSON text
584
+ */
585
+ protected jsonSetState(path: string, json: string) {
586
+ return sql<string>`json_set(coalesce(state, '{}'), ${path}, json(${json}))`
587
+ }
588
+
589
+ /**
590
+ * A JSON path for an arbitrary key. State keys are user-supplied (a graph's
591
+ * `setState` name, an approval's hex-encoded reason), so the key is quoted
592
+ * rather than interpolated bare — an unquoted `$.a.b` would silently address
593
+ * a nested path instead of the key literally called `a.b`.
594
+ */
595
+ private jsonPathFor(name: string): string {
596
+ return `$.${JSON.stringify(name)}`
597
+ }
598
+
529
599
  protected async updateRunStateImpl(
530
600
  runId: string,
531
601
  name: string,
532
602
  value: unknown
533
603
  ): Promise<void> {
534
- const row = await this.db
535
- .selectFrom('workflowRuns')
536
- .select('state')
537
- .where('workflowRunId', '=', runId)
538
- .executeTakeFirst()
539
-
540
- const state: Record<string, unknown> = parseJson(row?.state) ?? {}
541
- state[name] = value
542
-
604
+ // Merged by the database, not read-modify-written in JS: two graph nodes
605
+ // calling setState concurrently used to race, and the later write silently
606
+ // dropped whichever key the earlier one had added.
543
607
  await this.db
544
608
  .updateTable('workflowRuns')
545
- .set({ state: JSON.stringify(state), updatedAt: new Date() })
609
+ .set({
610
+ state: this.jsonSetState(
611
+ this.jsonPathFor(name),
612
+ JSON.stringify(value ?? null)
613
+ ),
614
+ updatedAt: new Date(),
615
+ })
546
616
  .where('workflowRunId', '=', runId)
547
617
  .execute()
548
618
  }
@@ -598,24 +668,5 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
598
668
  return this.runService.getWorkflowVersion(name, graphHash)
599
669
  }
600
670
 
601
- async getAIGeneratedWorkflows(
602
- agentName?: string
603
- ): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>> {
604
- let query = this.db
605
- .selectFrom('workflowVersions')
606
- .select(['workflowName', 'graphHash', 'graph'])
607
- .where('source', '=', 'dynamic-workflow')
608
- .where('status', '=', 'active')
609
- if (agentName) {
610
- query = query.where('workflowName', 'like', `ai:${agentName}:%`)
611
- }
612
- const rows = await query.execute()
613
- return rows.map((row) => ({
614
- workflowName: row.workflowName,
615
- graphHash: row.graphHash,
616
- graph: typeof row.graph === 'string' ? JSON.parse(row.graph) : row.graph,
617
- }))
618
- }
619
-
620
671
  async close(): Promise<void> {}
621
672
  }
@@ -8,8 +8,10 @@ import type { PikkuSchema } from './pikku-schema.types.js'
8
8
  * primary keys had `defaultTo(sql.raw("'" + crypto.randomUUID() + "'"))`, which
9
9
  * evaluates once while the statement is built — every row would have taken the
10
10
  * same default, so it was never a generator; the services supply the id. And
11
- * `workflowStep.fromStepName` was backfilled by an `alterTable(...).catch(() =>
12
- * {})` bolted on after the fact, which is the job a migration does.
11
+ * `workflowStep.fromStepName`, `workflowStep.currentAttempt` and
12
+ * `workflowStepHistory.attempt` were each backfilled by an
13
+ * `alterTable(...).catch(() => {})` bolted on after the fact, which is the job a
14
+ * migration does — they are declared here and nowhere else.
13
15
  *
14
16
  * `KyselyWorkflowMirror` created these same four tables separately. There is one
15
17
  * declaration now, and both services read it.
@@ -62,6 +64,7 @@ export const workflowSchema: PikkuSchema = {
62
64
  .addColumn('retries', 'integer')
63
65
  .addColumn('retryDelay', 'text')
64
66
  .addColumn('fromStepName', 'text')
67
+ .addColumn('currentAttempt', 'integer')
65
68
  .addColumn('createdAt', 'timestamp', (col) =>
66
69
  col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
67
70
  )
@@ -86,6 +89,7 @@ export const workflowSchema: PikkuSchema = {
86
89
  .addColumn('status', 'text', (col) => col.notNull())
87
90
  .addColumn('result', 'text')
88
91
  .addColumn('error', 'text')
92
+ .addColumn('attempt', 'integer')
89
93
  .addColumn('createdAt', 'timestamp', (col) =>
90
94
  col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
91
95
  )
@@ -109,5 +113,37 @@ export const workflowSchema: PikkuSchema = {
109
113
  'workflowName',
110
114
  'graphHash',
111
115
  ]),
116
+
117
+ // The indexes trail the tables so that a table is never indexed before it
118
+ // exists, whatever order the declarations are read in.
119
+ (db) =>
120
+ db.schema
121
+ .createIndex('idx_workflow_runs_status_created')
122
+ .on('workflowRuns')
123
+ .columns(['status', 'createdAt']),
124
+
125
+ (db) =>
126
+ db.schema
127
+ .createIndex('idx_workflow_runs_workflow_created')
128
+ .on('workflowRuns')
129
+ .columns(['workflow', 'createdAt']),
130
+
131
+ (db) =>
132
+ db.schema
133
+ .createIndex('idx_workflow_step_run_status')
134
+ .on('workflowStep')
135
+ .columns(['workflowRunId', 'status']),
136
+
137
+ (db) =>
138
+ db.schema
139
+ .createIndex('idx_workflow_step_history_step')
140
+ .on('workflowStepHistory')
141
+ .columns(['workflowStepId', 'createdAt']),
142
+
143
+ (db) =>
144
+ db.schema
145
+ .createIndex('idx_workflow_versions_source_status')
146
+ .on('workflowVersions')
147
+ .columns(['source', 'status']),
112
148
  ],
113
149
  }