@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.
@@ -1,4 +1,5 @@
1
1
  import { PikkuWorkflowService, } from '@pikku/core/workflow';
2
+ import { sql } from 'kysely';
2
3
  import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js';
3
4
  import { parseJson } from './kysely-json.js';
4
5
  import { ensurePikkuSchema } from './schema/index.js';
@@ -68,11 +69,12 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
68
69
  retries: stepOptions?.retries ?? null,
69
70
  retryDelay: stepOptions?.retryDelay?.toString() ?? null,
70
71
  fromStepName: fromStepName ?? null,
72
+ currentAttempt: 1,
71
73
  createdAt: now,
72
74
  updatedAt: now,
73
75
  })
74
76
  .execute();
75
- await this.insertHistoryRecord(stepId, 'pending');
77
+ await this.insertHistoryRecord(stepId, 'pending', 1);
76
78
  return {
77
79
  stepId,
78
80
  status: 'pending',
@@ -88,25 +90,24 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
88
90
  }
89
91
  async getStepState(runId, stepName) {
90
92
  const row = await this.db
91
- .selectFrom('workflowStep as s')
93
+ .selectFrom('workflowStep')
92
94
  .select([
93
- 's.workflowStepId',
94
- 's.status',
95
- 's.result',
96
- 's.error',
97
- 's.retries',
98
- 's.retryDelay',
99
- 's.fromStepName',
100
- 's.createdAt',
101
- 's.updatedAt',
95
+ 'workflowStepId',
96
+ 'status',
97
+ 'result',
98
+ 'error',
99
+ 'retries',
100
+ 'retryDelay',
101
+ 'fromStepName',
102
+ // `current_attempt` is bumped alongside every history insert, so it is
103
+ // already the count this used to aggregate — and reading it keeps the
104
+ // engine's hottest query off a table that grows for the life of the run.
105
+ 'currentAttempt',
106
+ 'createdAt',
107
+ 'updatedAt',
102
108
  ])
103
- .select((eb) => eb
104
- .selectFrom('workflowStepHistory')
105
- .select(eb.fn.countAll().as('cnt'))
106
- .whereRef('workflowStepHistory.workflowStepId', '=', 's.workflowStepId')
107
- .as('attemptCount'))
108
- .where('s.workflowRunId', '=', runId)
109
- .where('s.stepName', '=', stepName)
109
+ .where('workflowRunId', '=', runId)
110
+ .where('stepName', '=', stepName)
110
111
  .executeTakeFirst();
111
112
  if (!row) {
112
113
  throw new Error(`Step not found: runId=${runId}, stepName=${stepName}. Use insertStepState to create it.`);
@@ -116,7 +117,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
116
117
  status: row.status,
117
118
  result: parseJson(row.result),
118
119
  error: parseJson(row.error),
119
- attemptCount: Number(row.attemptCount),
120
+ attemptCount: Number(row.currentAttempt ?? 1),
120
121
  retries: row.retries != null ? Number(row.retries) : undefined,
121
122
  retryDelay: row.retryDelay ?? undefined,
122
123
  fromStepName: row.fromStepName ?? undefined,
@@ -124,29 +125,45 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
124
125
  updatedAt: new Date(row.updatedAt),
125
126
  };
126
127
  }
128
+ async listStepStates(runId) {
129
+ const rows = await this.db
130
+ .selectFrom('workflowStep')
131
+ .select([
132
+ 'workflowStepId',
133
+ 'stepName',
134
+ 'status',
135
+ 'result',
136
+ 'error',
137
+ 'retries',
138
+ 'retryDelay',
139
+ 'fromStepName',
140
+ 'currentAttempt',
141
+ 'createdAt',
142
+ 'updatedAt',
143
+ ])
144
+ .where('workflowRunId', '=', runId)
145
+ .execute();
146
+ return rows.map((row) => ({
147
+ stepId: row.workflowStepId,
148
+ stepName: row.stepName,
149
+ status: row.status,
150
+ result: parseJson(row.result),
151
+ error: parseJson(row.error),
152
+ attemptCount: Number(row.currentAttempt ?? 1),
153
+ retries: row.retries != null ? Number(row.retries) : undefined,
154
+ retryDelay: row.retryDelay ?? undefined,
155
+ fromStepName: row.fromStepName ?? undefined,
156
+ createdAt: new Date(row.createdAt),
157
+ updatedAt: new Date(row.updatedAt),
158
+ }));
159
+ }
127
160
  async getRunHistory(runId) {
128
161
  return this.runService.getRunHistory(runId);
129
162
  }
130
163
  async setStepRunningImpl(stepId) {
131
- await this.db
132
- .updateTable('workflowStep')
133
- .set({ status: 'running', updatedAt: new Date() })
134
- .where('workflowStepId', '=', stepId)
135
- .execute();
136
- const latestHistory = await this.db
137
- .selectFrom('workflowStepHistory')
138
- .select('historyId')
139
- .where('workflowStepId', '=', stepId)
140
- .orderBy('createdAt', 'desc')
141
- .limit(1)
142
- .executeTakeFirst();
143
- if (latestHistory) {
144
- await this.db
145
- .updateTable('workflowStepHistory')
146
- .set({ status: 'running', runningAt: new Date() })
147
- .where('historyId', '=', latestHistory.historyId)
148
- .execute();
149
- }
164
+ await this.writeStepTransition(stepId, 'running', {
165
+ runningAt: new Date(),
166
+ });
150
167
  }
151
168
  async setStepScheduledImpl(stepId) {
152
169
  await this.db
@@ -155,12 +172,85 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
155
172
  .where('workflowStepId', '=', stepId)
156
173
  .execute();
157
174
  }
158
- async insertHistoryRecord(stepId, status, result, error) {
175
+ /**
176
+ * Move a step and its current history attempt to the same status in one
177
+ * transaction.
178
+ *
179
+ * Both halves matter: a crash between them used to leave the step row saying
180
+ * `succeeded` while its history row still said `running`, which silently
181
+ * corrupts `getRunHistory` and every timeline reconstruction built on it.
182
+ * The history row is targeted by its `attempt` rather than by a `LIMIT 1`
183
+ * over `created_at`, so a retry that lands in the same millisecond as the
184
+ * attempt it replaces still resolves the newer row.
185
+ */
186
+ async writeStepTransition(stepId, status, historyValues) {
187
+ const now = new Date();
188
+ const stepValues = { status, updatedAt: now };
189
+ if ('result' in historyValues)
190
+ stepValues.result = historyValues.result;
191
+ if ('error' in historyValues)
192
+ stepValues.error = historyValues.error;
193
+ // A step carries only its latest outcome, so the other half is cleared.
194
+ if (status === 'succeeded')
195
+ stepValues.error = null;
196
+ if (status === 'failed')
197
+ stepValues.result = null;
198
+ await this.db.transaction().execute(async (trx) => {
199
+ await trx
200
+ .updateTable('workflowStep')
201
+ .set({
202
+ ...stepValues,
203
+ // A step that somehow reaches a transition without a live attempt
204
+ // addresses no history row at all, so give it one here. Written as an
205
+ // expression over the row's own column rather than a subquery, which
206
+ // MySQL would reject against the table being updated.
207
+ currentAttempt: sql `coalesce(current_attempt, 1)`,
208
+ })
209
+ .where('workflowStepId', '=', stepId)
210
+ .execute();
211
+ const written = await trx
212
+ .updateTable('workflowStepHistory')
213
+ .set({ status, ...historyValues })
214
+ .where('workflowStepId', '=', stepId)
215
+ // Correlate through the step row rather than a MAX() over this table:
216
+ // MySQL rejects a subquery that reads the table being updated, and a
217
+ // primary-key lookup is cheaper than an aggregate anyway.
218
+ .where('attempt', '=', (eb) => eb
219
+ .selectFrom('workflowStep')
220
+ .select('currentAttempt')
221
+ .where('workflowStepId', '=', stepId))
222
+ .executeTakeFirst();
223
+ // Matching nothing means the attempt has no history row to move, which
224
+ // would commit the step's new status with no record of it — precisely
225
+ // the divergence this transaction exists to prevent. Write the missing
226
+ // row instead of letting the pair drift apart.
227
+ if (Number(written?.numUpdatedRows ?? 0n) === 0) {
228
+ const step = await trx
229
+ .selectFrom('workflowStep')
230
+ .select('currentAttempt')
231
+ .where('workflowStepId', '=', stepId)
232
+ .executeTakeFirst();
233
+ await trx
234
+ .insertInto('workflowStepHistory')
235
+ .values({
236
+ historyId: crypto.randomUUID(),
237
+ workflowStepId: stepId,
238
+ status,
239
+ attempt: step?.currentAttempt ?? 1,
240
+ createdAt: now,
241
+ ...historyValues,
242
+ })
243
+ .execute();
244
+ }
245
+ });
246
+ }
247
+ async insertHistoryRecord(stepId, status, attempt, result, error) {
159
248
  const now = new Date();
160
249
  const values = {
161
250
  historyId: crypto.randomUUID(),
162
251
  workflowStepId: stepId,
163
252
  status,
253
+ attempt,
164
254
  result: result != null ? JSON.stringify(result) : null,
165
255
  error: error != null ? JSON.stringify(error) : null,
166
256
  createdAt: now,
@@ -199,35 +289,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
199
289
  .execute();
200
290
  }
201
291
  async setStepResultImpl(stepId, result) {
202
- const resultJson = JSON.stringify(result);
203
- await this.db
204
- .updateTable('workflowStep')
205
- .set({
206
- status: 'succeeded',
207
- result: resultJson,
208
- error: null,
209
- updatedAt: new Date(),
210
- })
211
- .where('workflowStepId', '=', stepId)
212
- .execute();
213
- const latestHistory = await this.db
214
- .selectFrom('workflowStepHistory')
215
- .select('historyId')
216
- .where('workflowStepId', '=', stepId)
217
- .orderBy('createdAt', 'desc')
218
- .limit(1)
219
- .executeTakeFirst();
220
- if (latestHistory) {
221
- await this.db
222
- .updateTable('workflowStepHistory')
223
- .set({
224
- status: 'succeeded',
225
- result: resultJson,
226
- succeededAt: new Date(),
227
- })
228
- .where('historyId', '=', latestHistory.historyId)
229
- .execute();
230
- }
292
+ await this.writeStepTransition(stepId, 'succeeded', {
293
+ result: JSON.stringify(result),
294
+ succeededAt: new Date(),
295
+ });
231
296
  }
232
297
  async setStepErrorImpl(stepId, error) {
233
298
  const serializedError = {
@@ -235,65 +300,54 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
235
300
  stack: error.stack,
236
301
  code: error.code,
237
302
  };
238
- const errorJson = JSON.stringify(serializedError);
303
+ await this.writeStepTransition(stepId, 'failed', {
304
+ error: JSON.stringify(serializedError),
305
+ failedAt: new Date(),
306
+ });
307
+ }
308
+ async createRetryAttemptImpl(stepId, status) {
309
+ // Read before writing: the new attempt number comes from the history this
310
+ // step already has, and the row it reads is the one returned below.
311
+ const previous = await this.db
312
+ .selectFrom('workflowStep')
313
+ .select('currentAttempt')
314
+ .where('workflowStepId', '=', stepId)
315
+ .executeTakeFirst();
316
+ const attempt = (previous?.currentAttempt ?? 0) + 1;
239
317
  await this.db
240
318
  .updateTable('workflowStep')
241
319
  .set({
242
- status: 'failed',
243
- error: errorJson,
320
+ status,
244
321
  result: null,
322
+ error: null,
323
+ currentAttempt: attempt,
245
324
  updatedAt: new Date(),
246
325
  })
247
326
  .where('workflowStepId', '=', stepId)
248
327
  .execute();
249
- const latestHistory = await this.db
250
- .selectFrom('workflowStepHistory')
251
- .select('historyId')
252
- .where('workflowStepId', '=', stepId)
253
- .orderBy('createdAt', 'desc')
254
- .limit(1)
255
- .executeTakeFirst();
256
- if (latestHistory) {
257
- await this.db
258
- .updateTable('workflowStepHistory')
259
- .set({ status: 'failed', error: errorJson, failedAt: new Date() })
260
- .where('historyId', '=', latestHistory.historyId)
261
- .execute();
262
- }
263
- }
264
- async createRetryAttemptImpl(stepId, status) {
265
- await this.db
266
- .updateTable('workflowStep')
267
- .set({ status, result: null, error: null, updatedAt: new Date() })
268
- .where('workflowStepId', '=', stepId)
269
- .execute();
270
- await this.insertHistoryRecord(stepId, status);
328
+ await this.insertHistoryRecord(stepId, status, attempt);
271
329
  const row = await this.db
272
- .selectFrom('workflowStep as s')
330
+ .selectFrom('workflowStep')
273
331
  .select([
274
- 's.workflowStepId',
275
- 's.status',
276
- 's.result',
277
- 's.error',
278
- 's.retries',
279
- 's.retryDelay',
280
- 's.fromStepName',
281
- 's.createdAt',
282
- 's.updatedAt',
332
+ 'workflowStepId',
333
+ 'status',
334
+ 'result',
335
+ 'error',
336
+ 'retries',
337
+ 'retryDelay',
338
+ 'fromStepName',
339
+ 'currentAttempt',
340
+ 'createdAt',
341
+ 'updatedAt',
283
342
  ])
284
- .select((eb) => eb
285
- .selectFrom('workflowStepHistory')
286
- .select(eb.fn.countAll().as('cnt'))
287
- .whereRef('workflowStepHistory.workflowStepId', '=', 's.workflowStepId')
288
- .as('attemptCount'))
289
- .where('s.workflowStepId', '=', stepId)
343
+ .where('workflowStepId', '=', stepId)
290
344
  .executeTakeFirstOrThrow();
291
345
  return {
292
346
  stepId: row.workflowStepId,
293
347
  status: row.status,
294
348
  result: parseJson(row.result),
295
349
  error: parseJson(row.error),
296
- attemptCount: Number(row.attemptCount),
350
+ attemptCount: Number(row.currentAttempt ?? 1),
297
351
  retries: row.retries != null ? Number(row.retries) : undefined,
298
352
  retryDelay: row.retryDelay ?? undefined,
299
353
  fromStepName: row.fromStepName ?? undefined,
@@ -386,17 +440,39 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
386
440
  .where('workflowStepId', '=', stepId)
387
441
  .execute();
388
442
  }
443
+ /**
444
+ * Merge one key into the run's JSON state, as a single expression evaluated
445
+ * by the database.
446
+ *
447
+ * The SQL is dialect-specific, so subclasses override this rather than the
448
+ * caller. The default is the SQLite form; MySQL and Postgres differ in how
449
+ * they cast a JSON literal.
450
+ *
451
+ * @param path - JSON path to the key, already quoted (`$."key"`)
452
+ * @param json - The value as JSON text
453
+ */
454
+ jsonSetState(path, json) {
455
+ return sql `json_set(coalesce(state, '{}'), ${path}, json(${json}))`;
456
+ }
457
+ /**
458
+ * A JSON path for an arbitrary key. State keys are user-supplied (a graph's
459
+ * `setState` name, an approval's hex-encoded reason), so the key is quoted
460
+ * rather than interpolated bare — an unquoted `$.a.b` would silently address
461
+ * a nested path instead of the key literally called `a.b`.
462
+ */
463
+ jsonPathFor(name) {
464
+ return `$.${JSON.stringify(name)}`;
465
+ }
389
466
  async updateRunStateImpl(runId, name, value) {
390
- const row = await this.db
391
- .selectFrom('workflowRuns')
392
- .select('state')
393
- .where('workflowRunId', '=', runId)
394
- .executeTakeFirst();
395
- const state = parseJson(row?.state) ?? {};
396
- state[name] = value;
467
+ // Merged by the database, not read-modify-written in JS: two graph nodes
468
+ // calling setState concurrently used to race, and the later write silently
469
+ // dropped whichever key the earlier one had added.
397
470
  await this.db
398
471
  .updateTable('workflowRuns')
399
- .set({ state: JSON.stringify(state), updatedAt: new Date() })
472
+ .set({
473
+ state: this.jsonSetState(this.jsonPathFor(name), JSON.stringify(value ?? null)),
474
+ updatedAt: new Date(),
475
+ })
400
476
  .where('workflowRunId', '=', runId)
401
477
  .execute();
402
478
  }
@@ -434,21 +510,5 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
434
510
  async getWorkflowVersion(name, graphHash) {
435
511
  return this.runService.getWorkflowVersion(name, graphHash);
436
512
  }
437
- async getAIGeneratedWorkflows(agentName) {
438
- let query = this.db
439
- .selectFrom('workflowVersions')
440
- .select(['workflowName', 'graphHash', 'graph'])
441
- .where('source', '=', 'dynamic-workflow')
442
- .where('status', '=', 'active');
443
- if (agentName) {
444
- query = query.where('workflowName', 'like', `ai:${agentName}:%`);
445
- }
446
- const rows = await query.execute();
447
- return rows.map((row) => ({
448
- workflowName: row.workflowName,
449
- graphHash: row.graphHash,
450
- graph: typeof row.graph === 'string' ? JSON.parse(row.graph) : row.graph,
451
- }));
452
- }
453
513
  async close() { }
454
514
  }
@@ -6,8 +6,10 @@ import type { PikkuSchema } from './pikku-schema.types.js';
6
6
  * primary keys had `defaultTo(sql.raw("'" + crypto.randomUUID() + "'"))`, which
7
7
  * evaluates once while the statement is built — every row would have taken the
8
8
  * same default, so it was never a generator; the services supply the id. And
9
- * `workflowStep.fromStepName` was backfilled by an `alterTable(...).catch(() =>
10
- * {})` bolted on after the fact, which is the job a migration does.
9
+ * `workflowStep.fromStepName`, `workflowStep.currentAttempt` and
10
+ * `workflowStepHistory.attempt` were each backfilled by an
11
+ * `alterTable(...).catch(() => {})` bolted on after the fact, which is the job a
12
+ * migration does — they are declared here and nowhere else.
11
13
  *
12
14
  * `KyselyWorkflowMirror` created these same four tables separately. There is one
13
15
  * declaration now, and both services read it.
@@ -6,8 +6,10 @@ import { sql } from 'kysely';
6
6
  * primary keys had `defaultTo(sql.raw("'" + crypto.randomUUID() + "'"))`, which
7
7
  * evaluates once while the statement is built — every row would have taken the
8
8
  * same default, so it was never a generator; the services supply the id. And
9
- * `workflowStep.fromStepName` was backfilled by an `alterTable(...).catch(() =>
10
- * {})` bolted on after the fact, which is the job a migration does.
9
+ * `workflowStep.fromStepName`, `workflowStep.currentAttempt` and
10
+ * `workflowStepHistory.attempt` were each backfilled by an
11
+ * `alterTable(...).catch(() => {})` bolted on after the fact, which is the job a
12
+ * migration does — they are declared here and nowhere else.
11
13
  *
12
14
  * `KyselyWorkflowMirror` created these same four tables separately. There is one
13
15
  * declaration now, and both services read it.
@@ -49,6 +51,7 @@ export const workflowSchema = {
49
51
  .addColumn('retries', 'integer')
50
52
  .addColumn('retryDelay', 'text')
51
53
  .addColumn('fromStepName', 'text')
54
+ .addColumn('currentAttempt', 'integer')
52
55
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
53
56
  .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
54
57
  .addUniqueConstraint('workflow_step_run_name_unique', [
@@ -65,6 +68,7 @@ export const workflowSchema = {
65
68
  .addColumn('status', 'text', (col) => col.notNull())
66
69
  .addColumn('result', 'text')
67
70
  .addColumn('error', 'text')
71
+ .addColumn('attempt', 'integer')
68
72
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
69
73
  .addColumn('runningAt', 'timestamp')
70
74
  .addColumn('scheduledAt', 'timestamp')
@@ -82,5 +86,27 @@ export const workflowSchema = {
82
86
  'workflowName',
83
87
  'graphHash',
84
88
  ]),
89
+ // The indexes trail the tables so that a table is never indexed before it
90
+ // exists, whatever order the declarations are read in.
91
+ (db) => db.schema
92
+ .createIndex('idx_workflow_runs_status_created')
93
+ .on('workflowRuns')
94
+ .columns(['status', 'createdAt']),
95
+ (db) => db.schema
96
+ .createIndex('idx_workflow_runs_workflow_created')
97
+ .on('workflowRuns')
98
+ .columns(['workflow', 'createdAt']),
99
+ (db) => db.schema
100
+ .createIndex('idx_workflow_step_run_status')
101
+ .on('workflowStep')
102
+ .columns(['workflowRunId', 'status']),
103
+ (db) => db.schema
104
+ .createIndex('idx_workflow_step_history_step')
105
+ .on('workflowStepHistory')
106
+ .columns(['workflowStepId', 'createdAt']),
107
+ (db) => db.schema
108
+ .createIndex('idx_workflow_versions_source_status')
109
+ .on('workflowVersions')
110
+ .columns(['source', 'status']),
85
111
  ],
86
112
  };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.13.5",
3
+ "version": "0.13.6",
4
+ "description": "Kysely-backed Pikku services — workflow state, secrets, sessions, scopes and audit",
4
5
  "author": "yasser.fadl@gmail.com",
5
6
  "license": "MIT",
6
7
  "module": "dist/src/index.js",
@@ -20,7 +21,7 @@
20
21
  "prepublishOnly": "yarn build"
21
22
  },
22
23
  "peerDependencies": {
23
- "@pikku/core": "^0.12.69"
24
+ "@pikku/core": "^0.12.71"
24
25
  },
25
26
  "dependencies": {
26
27
  "kysely": "^0.29.0"
@@ -52,6 +52,8 @@ export interface WorkflowStepTable {
52
52
  retries: number | null
53
53
  retryDelay: string | null
54
54
  fromStepName: string | null
55
+ /** The history attempt currently in flight; see WorkflowStepHistoryTable.attempt. */
56
+ currentAttempt: number | null
55
57
  createdAt: Generated<Date>
56
58
  updatedAt: Generated<Date>
57
59
  }
@@ -62,6 +64,12 @@ export interface WorkflowStepHistoryTable {
62
64
  status: StepStatus
63
65
  result: string | null
64
66
  error: string | null
67
+ /**
68
+ * Monotonic attempt number within the step, starting at 1. Orders attempts
69
+ * and identifies the live one without relying on `createdAt`, which cannot
70
+ * separate a retry from the attempt it replaces in the same millisecond.
71
+ */
72
+ attempt: number | null
65
73
  createdAt: Generated<Date>
66
74
  runningAt: Date | null
67
75
  scheduledAt: Date | null
@@ -1,6 +1,6 @@
1
1
  import { describe, test, beforeEach, afterEach } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
3
+ import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
4
4
  import { SerializePlugin } from './serialize-plugin.js'
5
5
  import Database from 'better-sqlite3'
6
6
 
@@ -237,18 +237,6 @@ describe('KyselyWorkflowMirror — workflow versions', () => {
237
237
  .executeTakeFirst()
238
238
  assert.equal(row?.status, 'archived')
239
239
  })
240
-
241
- test('AI-generated workflows surface via getAIGeneratedWorkflows', async () => {
242
- await mirror.upsertWorkflowVersion(
243
- 'ai:agent-x:foo',
244
- 'h1',
245
- { nodes: [] },
246
- 'dynamic-workflow'
247
- )
248
- const list = await runService.getAIGeneratedWorkflows('agent-x')
249
- assert.equal(list.length, 1)
250
- assert.equal(list[0]!.workflowName, 'ai:agent-x:foo')
251
- })
252
240
  })
253
241
 
254
242
  describe('KyselyWorkflowMirror — init', () => {