@pikku/kysely 0.13.31 → 0.13.34
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 +84 -0
- package/README.md +12 -2
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +2 -1
- package/dist/src/kysely-lease-clock.d.ts +14 -0
- package/dist/src/kysely-lease-clock.js +14 -0
- package/dist/src/kysely-lease-service.d.ts +37 -0
- package/dist/src/kysely-lease-service.js +116 -0
- package/dist/src/kysely-tables.d.ts +16 -0
- package/dist/src/kysely-workflow-service.d.ts +36 -20
- package/dist/src/kysely-workflow-service.js +195 -102
- package/dist/src/schema/agent.schema.js +43 -36
- package/dist/src/schema/analytics.schema.js +17 -17
- package/dist/src/schema/audit.schema.js +22 -22
- package/dist/src/schema/channel.schema.js +13 -10
- package/dist/src/schema/credential.schema.js +6 -6
- package/dist/src/schema/deployment.schema.js +12 -12
- package/dist/src/schema/flag.schema.js +15 -12
- package/dist/src/schema/incoming-webhook.schema.js +7 -7
- package/dist/src/schema/index.d.ts +1 -0
- package/dist/src/schema/index.js +33 -7
- package/dist/src/schema/lease.schema.d.ts +2 -0
- package/dist/src/schema/lease.schema.js +12 -0
- package/dist/src/schema/pikku-schema.types.d.ts +42 -2
- package/dist/src/schema/pikku-schema.types.js +23 -7
- package/dist/src/schema/scope.schema.js +34 -22
- package/dist/src/schema/secret.schema.js +5 -5
- package/dist/src/schema/session.schema.js +3 -3
- package/dist/src/schema/trigger-source.schema.js +8 -8
- package/dist/src/schema/virtual-user-schedule.schema.js +4 -4
- package/dist/src/schema/virtual-user.schema.js +14 -14
- package/dist/src/schema/webhook.schema.js +17 -17
- package/dist/src/schema/workflow.schema.js +50 -49
- package/knowledge/decisions/a-lease-is-taken-by-one-guarded-update.md +30 -0
- package/knowledge/decisions/index.md +3 -0
- package/knowledge/decisions/leases-are-judged-by-the-database-clock.md +42 -0
- package/knowledge/decisions/workflows-own-the-lease-table-too.md +22 -0
- package/package.json +2 -2
|
@@ -1,14 +1,52 @@
|
|
|
1
|
-
import { PikkuWorkflowService, WorkflowStepFunctionMismatchError, } from '@pikku/core/workflow';
|
|
1
|
+
import { PikkuWorkflowService, WorkflowStepFunctionMismatchError, WorkflowStepLeaseExpiredError, WorkflowStepSupersededError, leaseAttemptsExhausted, } from '@pikku/core/workflow';
|
|
2
2
|
import { sql } from 'kysely';
|
|
3
|
+
import { appNowMs, leaseUntil } from './kysely-lease-clock.js';
|
|
3
4
|
import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js';
|
|
4
5
|
import { parseJson } from './kysely-json.js';
|
|
5
6
|
import { requirePikkuSchema } from './schema/index.js';
|
|
6
7
|
import { workflowSchema } from './schema/workflow.schema.js';
|
|
8
|
+
/**
|
|
9
|
+
* What a step read returns, one list for the single read and the whole-run
|
|
10
|
+
* read alike, so the replay snapshot cannot drift from `getStepState`.
|
|
11
|
+
*/
|
|
12
|
+
const STEP_STATE_COLUMNS = [
|
|
13
|
+
'workflowStepId',
|
|
14
|
+
'status',
|
|
15
|
+
'rpcName',
|
|
16
|
+
'result',
|
|
17
|
+
'error',
|
|
18
|
+
'retries',
|
|
19
|
+
'retryDelay',
|
|
20
|
+
'fromStepName',
|
|
21
|
+
// `current_attempt` is bumped alongside every history insert, so it is
|
|
22
|
+
// already the count this used to aggregate — and reading it keeps the
|
|
23
|
+
// engine's hottest query off a table that grows for the life of the run.
|
|
24
|
+
'currentAttempt',
|
|
25
|
+
'leaseExpiresAt',
|
|
26
|
+
'createdAt',
|
|
27
|
+
'updatedAt',
|
|
28
|
+
];
|
|
29
|
+
const toStepState = (row) => ({
|
|
30
|
+
stepId: row.workflowStepId,
|
|
31
|
+
status: row.status,
|
|
32
|
+
rpcName: row.rpcName ?? null,
|
|
33
|
+
result: parseJson(row.result),
|
|
34
|
+
error: parseJson(row.error),
|
|
35
|
+
attemptCount: Number(row.currentAttempt ?? 1),
|
|
36
|
+
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
37
|
+
retryDelay: row.retryDelay ?? undefined,
|
|
38
|
+
fromStepName: row.fromStepName ?? undefined,
|
|
39
|
+
leaseExpiresAt: row.leaseExpiresAt != null
|
|
40
|
+
? new Date(Number(row.leaseExpiresAt))
|
|
41
|
+
: undefined,
|
|
42
|
+
createdAt: new Date(row.createdAt),
|
|
43
|
+
updatedAt: new Date(row.updatedAt),
|
|
44
|
+
});
|
|
7
45
|
export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
8
46
|
db;
|
|
9
47
|
initialized = false;
|
|
10
48
|
runService;
|
|
11
|
-
constructor(db, options
|
|
49
|
+
constructor(db, options) {
|
|
12
50
|
super(options);
|
|
13
51
|
this.db = db;
|
|
14
52
|
this.runService = new KyselyWorkflowRunService(db);
|
|
@@ -92,73 +130,25 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
92
130
|
async getStepState(runId, stepName) {
|
|
93
131
|
const row = await this.db
|
|
94
132
|
.selectFrom('workflowStep')
|
|
95
|
-
.select(
|
|
96
|
-
'workflowStepId',
|
|
97
|
-
'status',
|
|
98
|
-
'rpcName',
|
|
99
|
-
'result',
|
|
100
|
-
'error',
|
|
101
|
-
'retries',
|
|
102
|
-
'retryDelay',
|
|
103
|
-
'fromStepName',
|
|
104
|
-
// `current_attempt` is bumped alongside every history insert, so it is
|
|
105
|
-
// already the count this used to aggregate — and reading it keeps the
|
|
106
|
-
// engine's hottest query off a table that grows for the life of the run.
|
|
107
|
-
'currentAttempt',
|
|
108
|
-
'createdAt',
|
|
109
|
-
'updatedAt',
|
|
110
|
-
])
|
|
133
|
+
.select(STEP_STATE_COLUMNS)
|
|
111
134
|
.where('workflowRunId', '=', runId)
|
|
112
135
|
.where('stepName', '=', stepName)
|
|
113
136
|
.executeTakeFirst();
|
|
114
137
|
if (!row) {
|
|
115
138
|
throw new Error(`Step not found: runId=${runId}, stepName=${stepName}. Use insertStepState to create it.`);
|
|
116
139
|
}
|
|
117
|
-
return
|
|
118
|
-
stepId: row.workflowStepId,
|
|
119
|
-
status: row.status,
|
|
120
|
-
rpcName: row.rpcName ?? null,
|
|
121
|
-
result: parseJson(row.result),
|
|
122
|
-
error: parseJson(row.error),
|
|
123
|
-
attemptCount: Number(row.currentAttempt ?? 1),
|
|
124
|
-
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
125
|
-
retryDelay: row.retryDelay ?? undefined,
|
|
126
|
-
fromStepName: row.fromStepName ?? undefined,
|
|
127
|
-
createdAt: new Date(row.createdAt),
|
|
128
|
-
updatedAt: new Date(row.updatedAt),
|
|
129
|
-
};
|
|
140
|
+
return toStepState(row);
|
|
130
141
|
}
|
|
131
142
|
async listStepStates(runId) {
|
|
132
143
|
const rows = await this.db
|
|
133
144
|
.selectFrom('workflowStep')
|
|
134
|
-
.select([
|
|
135
|
-
'workflowStepId',
|
|
136
|
-
'stepName',
|
|
137
|
-
'status',
|
|
138
|
-
'result',
|
|
139
|
-
'error',
|
|
140
|
-
'retries',
|
|
141
|
-
'retryDelay',
|
|
142
|
-
'fromStepName',
|
|
143
|
-
'currentAttempt',
|
|
144
|
-
'createdAt',
|
|
145
|
-
'updatedAt',
|
|
146
|
-
])
|
|
145
|
+
.select([...STEP_STATE_COLUMNS, 'stepName'])
|
|
147
146
|
.where('workflowRunId', '=', runId)
|
|
148
147
|
.execute();
|
|
149
|
-
return rows.map((row) => ({
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
result: parseJson(row.result),
|
|
154
|
-
error: parseJson(row.error),
|
|
155
|
-
attemptCount: Number(row.currentAttempt ?? 1),
|
|
156
|
-
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
157
|
-
retryDelay: row.retryDelay ?? undefined,
|
|
158
|
-
fromStepName: row.fromStepName ?? undefined,
|
|
159
|
-
createdAt: new Date(row.createdAt),
|
|
160
|
-
updatedAt: new Date(row.updatedAt),
|
|
161
|
-
}));
|
|
148
|
+
return rows.map((row) => ({ ...toStepState(row), stepName: row.stepName }));
|
|
149
|
+
}
|
|
150
|
+
async getRunSteps(runId) {
|
|
151
|
+
return this.runService.getRunSteps(runId);
|
|
162
152
|
}
|
|
163
153
|
async getRunHistory(runId) {
|
|
164
154
|
return this.runService.getRunHistory(runId);
|
|
@@ -168,6 +158,24 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
168
158
|
runningAt: new Date(),
|
|
169
159
|
});
|
|
170
160
|
}
|
|
161
|
+
async refreshStepLease(stepId, leaseMs, attempt) {
|
|
162
|
+
let query = this.db
|
|
163
|
+
.updateTable('workflowStep')
|
|
164
|
+
.set({
|
|
165
|
+
leaseExpiresAt: leaseMs === null ? null : leaseUntil(this.nowMs(), leaseMs),
|
|
166
|
+
updatedAt: new Date(),
|
|
167
|
+
})
|
|
168
|
+
.where('workflowStepId', '=', stepId);
|
|
169
|
+
if (attempt !== undefined) {
|
|
170
|
+
query = query.where(sql `coalesce(current_attempt, 1)`, '=', attempt);
|
|
171
|
+
}
|
|
172
|
+
const renewed = await query.executeTakeFirst();
|
|
173
|
+
return Number(renewed?.numUpdatedRows ?? 0n) > 0;
|
|
174
|
+
}
|
|
175
|
+
/** Now, in epoch milliseconds, on the clock step leases are judged by. */
|
|
176
|
+
nowMs() {
|
|
177
|
+
return appNowMs();
|
|
178
|
+
}
|
|
171
179
|
async setStepScheduledImpl(stepId) {
|
|
172
180
|
await this.db
|
|
173
181
|
.updateTable('workflowStep')
|
|
@@ -186,7 +194,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
186
194
|
* over `created_at`, so a retry that lands in the same millisecond as the
|
|
187
195
|
* attempt it replaces still resolves the newer row.
|
|
188
196
|
*/
|
|
189
|
-
async writeStepTransition(stepId, status, historyValues) {
|
|
197
|
+
async writeStepTransition(stepId, status, historyValues, attempt) {
|
|
190
198
|
const now = new Date();
|
|
191
199
|
const stepValues = { status, updatedAt: now };
|
|
192
200
|
if ('result' in historyValues)
|
|
@@ -199,7 +207,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
199
207
|
if (status === 'failed')
|
|
200
208
|
stepValues.result = null;
|
|
201
209
|
await this.db.transaction().execute(async (trx) => {
|
|
202
|
-
|
|
210
|
+
let step = trx
|
|
203
211
|
.updateTable('workflowStep')
|
|
204
212
|
.set({
|
|
205
213
|
...stepValues,
|
|
@@ -209,8 +217,17 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
209
217
|
// MySQL would reject against the table being updated.
|
|
210
218
|
currentAttempt: sql `coalesce(current_attempt, 1)`,
|
|
211
219
|
})
|
|
212
|
-
.where('workflowStepId', '=', stepId)
|
|
213
|
-
|
|
220
|
+
.where('workflowStepId', '=', stepId);
|
|
221
|
+
// Fenced to the attempt that made the write, so a dispatch whose step
|
|
222
|
+
// was claimed again after its lease lapsed cannot record over the newer
|
|
223
|
+
// attempt. Throwing rolls the transaction back.
|
|
224
|
+
if (attempt !== undefined) {
|
|
225
|
+
step = step.where(sql `coalesce(current_attempt, 1)`, '=', attempt);
|
|
226
|
+
}
|
|
227
|
+
const moved = await step.executeTakeFirst();
|
|
228
|
+
if (attempt !== undefined && Number(moved?.numUpdatedRows ?? 0n) === 0) {
|
|
229
|
+
throw new WorkflowStepSupersededError(stepId, attempt);
|
|
230
|
+
}
|
|
214
231
|
const written = await trx
|
|
215
232
|
.updateTable('workflowStepHistory')
|
|
216
233
|
.set({ status, ...historyValues })
|
|
@@ -247,7 +264,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
247
264
|
}
|
|
248
265
|
});
|
|
249
266
|
}
|
|
250
|
-
async insertHistoryRecord(stepId, status, attempt, result, error) {
|
|
267
|
+
async insertHistoryRecord(stepId, status, attempt, result, error, db = this.db) {
|
|
251
268
|
const now = new Date();
|
|
252
269
|
const values = {
|
|
253
270
|
historyId: crypto.randomUUID(),
|
|
@@ -262,7 +279,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
262
279
|
if (timestampField !== 'createdAt') {
|
|
263
280
|
values[timestampField] = now;
|
|
264
281
|
}
|
|
265
|
-
await
|
|
282
|
+
await db
|
|
266
283
|
.insertInto('workflowStepHistory')
|
|
267
284
|
.values(values)
|
|
268
285
|
.execute();
|
|
@@ -291,13 +308,13 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
291
308
|
.where('workflowStepId', '=', stepId)
|
|
292
309
|
.execute();
|
|
293
310
|
}
|
|
294
|
-
async setStepResultImpl(stepId, result) {
|
|
311
|
+
async setStepResultImpl(stepId, result, attempt) {
|
|
295
312
|
await this.writeStepTransition(stepId, 'succeeded', {
|
|
296
313
|
result: JSON.stringify(result),
|
|
297
314
|
succeededAt: new Date(),
|
|
298
|
-
});
|
|
315
|
+
}, attempt);
|
|
299
316
|
}
|
|
300
|
-
async setStepErrorImpl(stepId, error) {
|
|
317
|
+
async setStepErrorImpl(stepId, error, attempt) {
|
|
301
318
|
const serializedError = {
|
|
302
319
|
message: error.message,
|
|
303
320
|
stack: error.stack,
|
|
@@ -306,7 +323,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
306
323
|
await this.writeStepTransition(stepId, 'failed', {
|
|
307
324
|
error: JSON.stringify(serializedError),
|
|
308
325
|
failedAt: new Date(),
|
|
309
|
-
});
|
|
326
|
+
}, attempt);
|
|
310
327
|
}
|
|
311
328
|
async createRetryAttemptImpl(stepId, status) {
|
|
312
329
|
// Read before writing: the new attempt number comes from the history this
|
|
@@ -329,6 +346,9 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
329
346
|
.where('workflowStepId', '=', stepId)
|
|
330
347
|
.execute();
|
|
331
348
|
await this.insertHistoryRecord(stepId, status, attempt);
|
|
349
|
+
return this.readStepAttempt(stepId);
|
|
350
|
+
}
|
|
351
|
+
async readStepAttempt(stepId) {
|
|
332
352
|
const row = await this.db
|
|
333
353
|
.selectFrom('workflowStep')
|
|
334
354
|
.select([
|
|
@@ -360,22 +380,6 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
360
380
|
updatedAt: new Date(row.updatedAt),
|
|
361
381
|
};
|
|
362
382
|
}
|
|
363
|
-
async withRunLock(_id, fn) {
|
|
364
|
-
return fn();
|
|
365
|
-
}
|
|
366
|
-
/**
|
|
367
|
-
* A pass-through, and deliberately so: the one decision that must exclude —
|
|
368
|
-
* claiming a step to execute it — is made by `claimStepForExecution` below as
|
|
369
|
-
* a single conditional write, which needs no lock to be exclusive.
|
|
370
|
-
*
|
|
371
|
-
* What is left are the suspend and approval sections in the engine, where the
|
|
372
|
-
* lock is genuine mutual exclusion rather than a claim. A dialect with a real
|
|
373
|
-
* primitive overrides this to cover them — `kysely-postgres` with
|
|
374
|
-
* `pg_advisory_xact_lock`, `kysely-mysql` with `GET_LOCK`.
|
|
375
|
-
*/
|
|
376
|
-
async withStepLock(_runId, _stepName, fn) {
|
|
377
|
-
return fn();
|
|
378
|
-
}
|
|
379
383
|
/**
|
|
380
384
|
* Claim the step with a status-guarded `UPDATE` and read the affected-row
|
|
381
385
|
* count: the database decides the winner in one statement, so two dispatches
|
|
@@ -388,28 +392,56 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
388
392
|
*
|
|
389
393
|
* The winner then goes through the ordinary transition methods, so history
|
|
390
394
|
* rows and the mirror see exactly what they saw before.
|
|
395
|
+
*
|
|
396
|
+
* A `running` step is taken back only once its lease has lapsed, and the
|
|
397
|
+
* lease is part of the same guarded `UPDATE` — checking it here and writing
|
|
398
|
+
* it afterwards would let two dispatches read one lapsed lease and both
|
|
399
|
+
* proceed. Whether it has lapsed is the database's call, on its own clock:
|
|
400
|
+
* a live lease simply matches no row.
|
|
391
401
|
*/
|
|
392
|
-
async claimStepForExecution(runId, stepName, rpcName) {
|
|
402
|
+
async claimStepForExecution(runId, stepName, rpcName, leaseMs) {
|
|
393
403
|
const stepState = await this.getStepState(runId, stepName);
|
|
394
404
|
// knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
|
|
395
405
|
if (stepState.rpcName !== undefined &&
|
|
396
406
|
stepState.rpcName !== (rpcName ?? null)) {
|
|
397
407
|
throw new WorkflowStepFunctionMismatchError(runId, stepName);
|
|
398
408
|
}
|
|
399
|
-
if (stepState.status === 'succeeded'
|
|
409
|
+
if (stepState.status === 'succeeded') {
|
|
400
410
|
return null;
|
|
401
411
|
}
|
|
402
|
-
if (stepState.status === '
|
|
403
|
-
if (
|
|
412
|
+
if (stepState.status === 'running') {
|
|
413
|
+
if (leaseAttemptsExhausted(stepState)) {
|
|
414
|
+
// Fail it only if its lease is still lapsed at the moment of writing:
|
|
415
|
+
// a worker that renewed since the read above still owns the step.
|
|
416
|
+
if (!(await this.claimStepStatus(stepState.stepId, ['running'], leaseMs))) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
await this.setStepError(stepState.stepId, new WorkflowStepLeaseExpiredError(runId, stepName, stepState.attemptCount), stepState.attemptCount);
|
|
420
|
+
// `executeWorkflowStepInner` reads a null claim as "another dispatch
|
|
421
|
+
// owns it" and returns without resuming, so the run would stay
|
|
422
|
+
// `running` until a stalled sweep. Requeue the orchestrator here.
|
|
423
|
+
await this.resumeWorkflow(runId);
|
|
404
424
|
return null;
|
|
405
425
|
}
|
|
406
|
-
|
|
426
|
+
}
|
|
427
|
+
// A failed step that has spent every attempt is settled, however it failed;
|
|
428
|
+
// a redelivered message must not buy it another one.
|
|
429
|
+
if (stepState.status === 'failed' && leaseAttemptsExhausted(stepState)) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
if (stepState.status === 'failed' || stepState.status === 'running') {
|
|
433
|
+
const attempt = stepState.attemptCount + 1;
|
|
434
|
+
return this.mirrored(() => this.reclaimStepAttempt(stepState, attempt, leaseMs), async (mirror, newStep) => {
|
|
435
|
+
if (newStep) {
|
|
436
|
+
await mirror.createRetryAttempt(stepState.stepId, {
|
|
437
|
+
...newStep,
|
|
438
|
+
stepName,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
});
|
|
407
442
|
}
|
|
408
443
|
if (stepState.status === 'pending' || stepState.status === 'scheduled') {
|
|
409
|
-
if (!(await this.claimStepStatus(stepState.stepId, [
|
|
410
|
-
'pending',
|
|
411
|
-
'scheduled',
|
|
412
|
-
]))) {
|
|
444
|
+
if (!(await this.claimStepStatus(stepState.stepId, ['pending', 'scheduled'], leaseMs))) {
|
|
413
445
|
return null;
|
|
414
446
|
}
|
|
415
447
|
await this.setStepRunning(stepState.stepId);
|
|
@@ -418,16 +450,62 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
418
450
|
return stepState;
|
|
419
451
|
}
|
|
420
452
|
/**
|
|
421
|
-
*
|
|
422
|
-
*
|
|
453
|
+
* Take back a failed or lapsed step as a new attempt. The attempt number is
|
|
454
|
+
* advanced by the same guarded `UPDATE` that grants the lease, so the worker
|
|
455
|
+
* it is taken from is fenced out of its step at once — deferring that to
|
|
456
|
+
* `createRetryAttempt` would leave a window in which its result still lands,
|
|
457
|
+
* only to be cleared and the step run again.
|
|
458
|
+
*/
|
|
459
|
+
async reclaimStepAttempt(stepState, attempt, leaseMs) {
|
|
460
|
+
const now = this.nowMs();
|
|
461
|
+
const claimed = await this.db.transaction().execute(async (trx) => {
|
|
462
|
+
let query = trx
|
|
463
|
+
.updateTable('workflowStep')
|
|
464
|
+
.set({
|
|
465
|
+
status: 'running',
|
|
466
|
+
result: null,
|
|
467
|
+
error: null,
|
|
468
|
+
currentAttempt: attempt,
|
|
469
|
+
leaseExpiresAt: leaseUntil(now, leaseMs),
|
|
470
|
+
updatedAt: new Date(),
|
|
471
|
+
})
|
|
472
|
+
.where('workflowStepId', '=', stepState.stepId)
|
|
473
|
+
.where('status', '=', stepState.status)
|
|
474
|
+
.where(sql `coalesce(current_attempt, 1)`, '=', stepState.attemptCount);
|
|
475
|
+
if (stepState.status === 'running') {
|
|
476
|
+
query = query.where('leaseExpiresAt', '<', now);
|
|
477
|
+
}
|
|
478
|
+
const moved = await query.executeTakeFirst();
|
|
479
|
+
if (Number(moved?.numUpdatedRows ?? 0n) === 0)
|
|
480
|
+
return false;
|
|
481
|
+
await this.insertHistoryRecord(stepState.stepId, 'running', attempt, undefined, undefined, trx);
|
|
482
|
+
return true;
|
|
483
|
+
});
|
|
484
|
+
return claimed ? this.readStepAttempt(stepState.stepId) : null;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Move a step to `running` under a fresh lease, only if it is still in one of
|
|
488
|
+
* `from`, reporting whether this caller is the one that moved it.
|
|
489
|
+
*
|
|
490
|
+
* Claiming back a `running` step additionally requires its lease to have
|
|
491
|
+
* lapsed. A null lease never matches, which is what keeps a step parked on a
|
|
492
|
+
* child run — running, with no worker on it by design — out of reach.
|
|
423
493
|
*/
|
|
424
|
-
async claimStepStatus(stepId, from) {
|
|
425
|
-
const
|
|
494
|
+
async claimStepStatus(stepId, from, leaseMs) {
|
|
495
|
+
const now = this.nowMs();
|
|
496
|
+
let query = this.db
|
|
426
497
|
.updateTable('workflowStep')
|
|
427
|
-
.set({
|
|
498
|
+
.set({
|
|
499
|
+
status: 'running',
|
|
500
|
+
leaseExpiresAt: leaseUntil(now, leaseMs),
|
|
501
|
+
updatedAt: new Date(),
|
|
502
|
+
})
|
|
428
503
|
.where('workflowStepId', '=', stepId)
|
|
429
|
-
.where('status', 'in', from)
|
|
430
|
-
|
|
504
|
+
.where('status', 'in', from);
|
|
505
|
+
if (from.includes('running')) {
|
|
506
|
+
query = query.where('leaseExpiresAt', '<', now);
|
|
507
|
+
}
|
|
508
|
+
const claimed = await query.executeTakeFirst();
|
|
431
509
|
return Number(claimed?.numUpdatedRows ?? 0n) > 0;
|
|
432
510
|
}
|
|
433
511
|
async getCompletedGraphState(runId) {
|
|
@@ -465,13 +543,16 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
465
543
|
async getStepInstances(runId) {
|
|
466
544
|
const rows = await this.db
|
|
467
545
|
.selectFrom('workflowStep')
|
|
468
|
-
.select(['stepName', 'status', 'fromStepName'])
|
|
546
|
+
.select(['stepName', 'status', 'fromStepName', 'leaseExpiresAt'])
|
|
469
547
|
.where('workflowRunId', '=', runId)
|
|
470
548
|
.execute();
|
|
471
549
|
return rows.map((r) => ({
|
|
472
550
|
stepName: r.stepName,
|
|
473
551
|
status: r.status,
|
|
474
552
|
fromStepName: r.fromStepName ?? undefined,
|
|
553
|
+
leaseExpiresAt: r.leaseExpiresAt != null
|
|
554
|
+
? new Date(Number(r.leaseExpiresAt))
|
|
555
|
+
: undefined,
|
|
475
556
|
}));
|
|
476
557
|
}
|
|
477
558
|
async getNodeResults(runId, nodeIds) {
|
|
@@ -537,7 +618,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
537
618
|
* The undispatched-step query, shared by every dialect.
|
|
538
619
|
*
|
|
539
620
|
* It used to be deliberately NOT an override, because this class's
|
|
540
|
-
* `withStepLock`
|
|
621
|
+
* `withStepLock` was a pass-through: a subclass inheriting it (kysely-sqlite)
|
|
541
622
|
* had no atomic claim, so the relay's redundant dispatches would have become
|
|
542
623
|
* double executions, and only `kysely-postgres` and `kysely-mysql` opted in
|
|
543
624
|
* on the strength of their real locks. `claimStepForExecution` no longer
|
|
@@ -571,7 +652,19 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
571
652
|
.where(({ not, exists, selectFrom }) => not(exists(selectFrom('workflowStep as s')
|
|
572
653
|
.select('s.workflowStepId')
|
|
573
654
|
.whereRef('s.workflowRunId', '=', 'r.workflowRunId')
|
|
574
|
-
.where(
|
|
655
|
+
.where((eb) => eb.or([
|
|
656
|
+
eb('s.status', 'in', ['scheduled', 'suspended']),
|
|
657
|
+
// A `running` step counts as in flight only while someone
|
|
658
|
+
// still holds it. A lapsed lease is a dispatch that died, and
|
|
659
|
+
// a null one a step nothing was expected to hand back.
|
|
660
|
+
eb.and([
|
|
661
|
+
eb('s.status', '=', 'running'),
|
|
662
|
+
eb.or([
|
|
663
|
+
eb('s.leaseExpiresAt', 'is', null),
|
|
664
|
+
eb('s.leaseExpiresAt', '>', this.nowMs()),
|
|
665
|
+
]),
|
|
666
|
+
]),
|
|
667
|
+
])))))
|
|
575
668
|
// The run row only moves on a status change, so idleness has to hold for
|
|
576
669
|
// the steps too — expressed as "no step touched since `before`" rather
|
|
577
670
|
// than max(updated_at) so it stays one index-friendly NOT EXISTS.
|
|
@@ -13,89 +13,96 @@ export const agentSchema = {
|
|
|
13
13
|
name: 'agent',
|
|
14
14
|
ownedBy: ['agentStorage', 'agentRunState', 'agentRunService'],
|
|
15
15
|
statements: [
|
|
16
|
-
(db) => db.schema
|
|
16
|
+
(db, _types, ctx) => db.schema
|
|
17
17
|
.createTable('agentThreads')
|
|
18
18
|
.addColumn('id', 'varchar(36)', (col) => col.primaryKey())
|
|
19
19
|
.addColumn('resourceId', 'varchar(255)', (col) => col.notNull())
|
|
20
|
-
.addColumn('title',
|
|
21
|
-
.addColumn('metadata',
|
|
20
|
+
.addColumn('title', ctx.text)
|
|
21
|
+
.addColumn('metadata', ctx.text)
|
|
22
22
|
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
23
23
|
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
24
|
-
(db) => db.schema
|
|
24
|
+
(db, _types, ctx) => db.schema
|
|
25
25
|
.createIndex('idx_agent_threads_resource')
|
|
26
26
|
.on('agentThreads')
|
|
27
27
|
.column('resourceId'),
|
|
28
|
-
(db) => db.schema
|
|
28
|
+
(db, _types, ctx) => db.schema
|
|
29
29
|
.createTable('agentMessage')
|
|
30
30
|
.addColumn('id', 'varchar(36)', (col) => col.primaryKey())
|
|
31
|
-
.addColumn('threadId', 'varchar(36)', (col) => col.notNull()
|
|
31
|
+
.addColumn('threadId', 'varchar(36)', (col) => ctx.references(col.notNull(), 'agentThreads.id'))
|
|
32
32
|
.addColumn('role', 'varchar(50)', (col) => col.notNull())
|
|
33
|
-
.addColumn('content',
|
|
34
|
-
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
35
|
-
|
|
33
|
+
.addColumn('content', ctx.text)
|
|
34
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
35
|
+
.$call(ctx.foreignKeys('agentMessage', { threadId: 'agentThreads.id' })),
|
|
36
|
+
(db, _types, ctx) => db.schema
|
|
36
37
|
.createIndex('idx_agent_message_thread')
|
|
37
38
|
.on('agentMessage')
|
|
38
39
|
.columns(['threadId', 'createdAt']),
|
|
39
|
-
(db) => db.schema
|
|
40
|
+
(db, _types, ctx) => db.schema
|
|
40
41
|
.createTable('agentToolCall')
|
|
41
42
|
.addColumn('id', 'varchar(36)', (col) => col.primaryKey())
|
|
42
|
-
.addColumn('threadId', 'varchar(36)', (col) => col.notNull()
|
|
43
|
-
.addColumn('messageId', 'varchar(36)', (col) => col.notNull()
|
|
43
|
+
.addColumn('threadId', 'varchar(36)', (col) => ctx.references(col.notNull(), 'agentThreads.id'))
|
|
44
|
+
.addColumn('messageId', 'varchar(36)', (col) => ctx.references(col.notNull(), 'agentMessage.id'))
|
|
44
45
|
.addColumn('runId', 'varchar(36)')
|
|
45
46
|
.addColumn('toolName', 'varchar(255)', (col) => col.notNull())
|
|
46
|
-
.addColumn('args',
|
|
47
|
-
.addColumn('result',
|
|
47
|
+
.addColumn('args', ctx.text, (col) => col.notNull())
|
|
48
|
+
.addColumn('result', ctx.text)
|
|
48
49
|
.addColumn('approvalStatus', 'varchar(50)')
|
|
49
50
|
.addColumn('approvalType', 'varchar(50)')
|
|
50
51
|
.addColumn('agentRunId', 'varchar(36)')
|
|
51
52
|
.addColumn('displayToolName', 'varchar(255)')
|
|
52
|
-
.addColumn('displayArgs',
|
|
53
|
-
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
54
|
-
|
|
53
|
+
.addColumn('displayArgs', ctx.text)
|
|
54
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
55
|
+
.$call(ctx.foreignKeys('agentToolCall', {
|
|
56
|
+
threadId: 'agentThreads.id',
|
|
57
|
+
messageId: 'agentMessage.id',
|
|
58
|
+
})),
|
|
59
|
+
(db, _types, ctx) => db.schema
|
|
55
60
|
.createIndex('idx_agent_tool_call_thread')
|
|
56
61
|
.on('agentToolCall')
|
|
57
62
|
.column('threadId'),
|
|
58
|
-
(db) => db.schema
|
|
63
|
+
(db, _types, ctx) => db.schema
|
|
59
64
|
.createIndex('idx_agent_tool_call_message')
|
|
60
65
|
.on('agentToolCall')
|
|
61
66
|
.column('messageId'),
|
|
62
|
-
(db) => db.schema
|
|
67
|
+
(db, _types, ctx) => db.schema
|
|
63
68
|
.createTable('agentWorkingMemory')
|
|
64
69
|
.addColumn('id', 'varchar(255)', (col) => col.notNull())
|
|
65
70
|
.addColumn('scope', 'varchar(50)', (col) => col.notNull())
|
|
66
|
-
.addColumn('data',
|
|
71
|
+
.addColumn('data', ctx.text, (col) => col.notNull())
|
|
67
72
|
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
68
73
|
.addPrimaryKeyConstraint('agent_working_memory_pk', ['id', 'scope']),
|
|
69
|
-
(db) => db.schema
|
|
74
|
+
(db, _types, ctx) => db.schema
|
|
70
75
|
.createTable('agentRun')
|
|
71
76
|
.addColumn('runId', 'varchar(36)', (col) => col.primaryKey())
|
|
72
77
|
.addColumn('agentName', 'varchar(255)', (col) => col.notNull())
|
|
73
|
-
.addColumn('threadId', 'varchar(36)', (col) => col.notNull()
|
|
78
|
+
.addColumn('threadId', 'varchar(36)', (col) => ctx.references(col.notNull(), 'agentThreads.id'))
|
|
74
79
|
.addColumn('resourceId', 'varchar(255)', (col) => col.notNull())
|
|
75
|
-
.addColumn('status', 'varchar(50)', (col) => col.notNull().defaultTo('running'))
|
|
76
|
-
.addColumn('errorMessage',
|
|
77
|
-
.addColumn('suspendReason',
|
|
78
|
-
.addColumn('missingRpcs',
|
|
79
|
-
.addColumn('pendingApprovals',
|
|
80
|
+
.addColumn('status', 'varchar(50)', (col) => col.notNull().defaultTo(ctx.defaultText('running')))
|
|
81
|
+
.addColumn('errorMessage', ctx.text)
|
|
82
|
+
.addColumn('suspendReason', ctx.text)
|
|
83
|
+
.addColumn('missingRpcs', ctx.text)
|
|
84
|
+
.addColumn('pendingApprovals', ctx.text)
|
|
80
85
|
.addColumn('usageInputTokens', 'integer', (col) => col.notNull().defaultTo(0))
|
|
81
86
|
.addColumn('usageOutputTokens', 'integer', (col) => col.notNull().defaultTo(0))
|
|
82
|
-
.addColumn('usageModel', 'varchar(255)', (col) => col.notNull().defaultTo(''))
|
|
87
|
+
.addColumn('usageModel', 'varchar(255)', (col) => col.notNull().defaultTo(ctx.defaultText('')))
|
|
83
88
|
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
84
|
-
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
85
|
-
|
|
89
|
+
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
90
|
+
.$call(ctx.foreignKeys('agentRun', { threadId: 'agentThreads.id' })),
|
|
91
|
+
(db, _types, ctx) => db.schema
|
|
86
92
|
.createIndex('idx_agent_run_thread')
|
|
87
93
|
.on('agentRun')
|
|
88
94
|
.columns(['threadId', 'createdAt']),
|
|
89
|
-
(db) => db.schema
|
|
95
|
+
(db, _types, ctx) => db.schema
|
|
90
96
|
.createTable('agentRunScore')
|
|
91
97
|
.addColumn('id', 'varchar(36)', (col) => col.primaryKey())
|
|
92
|
-
.addColumn('runId', 'varchar(36)', (col) => col.notNull()
|
|
98
|
+
.addColumn('runId', 'varchar(36)', (col) => ctx.references(col.notNull(), 'agentRun.runId'))
|
|
93
99
|
.addColumn('scorerName', 'varchar(255)', (col) => col.notNull())
|
|
94
100
|
.addColumn('score', 'real', (col) => col.notNull())
|
|
95
|
-
.addColumn('reason',
|
|
96
|
-
.addColumn('metadata',
|
|
97
|
-
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
98
|
-
|
|
101
|
+
.addColumn('reason', ctx.text)
|
|
102
|
+
.addColumn('metadata', ctx.text)
|
|
103
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
104
|
+
.$call(ctx.foreignKeys('agentRunScore', { runId: 'agentRun.runId' })),
|
|
105
|
+
(db, _types, ctx) => db.schema
|
|
99
106
|
.createIndex('idx_agent_run_score_run')
|
|
100
107
|
.on('agentRunScore')
|
|
101
108
|
.columns(['runId', 'createdAt']),
|
|
@@ -17,30 +17,30 @@ export const analyticsSchema = {
|
|
|
17
17
|
name: 'analytics',
|
|
18
18
|
ownedBy: ['analyticsService'],
|
|
19
19
|
statements: [
|
|
20
|
-
(db) => db.schema
|
|
20
|
+
(db, _types, ctx) => db.schema
|
|
21
21
|
.createTable('pikkuAnalyticsEvents')
|
|
22
|
-
.addColumn('eventId',
|
|
23
|
-
.addColumn('name',
|
|
24
|
-
.addColumn('occurredAt',
|
|
25
|
-
.addColumn('source',
|
|
26
|
-
.addColumn('functionId',
|
|
27
|
-
.addColumn('wireType',
|
|
28
|
-
.addColumn('traceId',
|
|
29
|
-
.addColumn('userId',
|
|
30
|
-
.addColumn('orgId',
|
|
31
|
-
.addColumn('pikkuUserId',
|
|
22
|
+
.addColumn('eventId', ctx.key, (col) => col.primaryKey())
|
|
23
|
+
.addColumn('name', ctx.key, (col) => col.notNull())
|
|
24
|
+
.addColumn('occurredAt', ctx.key, (col) => col.notNull())
|
|
25
|
+
.addColumn('source', ctx.text, (col) => col.notNull())
|
|
26
|
+
.addColumn('functionId', ctx.text)
|
|
27
|
+
.addColumn('wireType', ctx.text)
|
|
28
|
+
.addColumn('traceId', ctx.text)
|
|
29
|
+
.addColumn('userId', ctx.text)
|
|
30
|
+
.addColumn('orgId', ctx.text)
|
|
31
|
+
.addColumn('pikkuUserId', ctx.text)
|
|
32
32
|
// The device id for a visitor with no session. Its own column beside
|
|
33
33
|
// `userId` and not folded into it: the whole point of the anonymous id
|
|
34
34
|
// is the traffic that has no user to be attributed to yet.
|
|
35
|
-
.addColumn('anonymousId',
|
|
36
|
-
.addColumn('vendorIds',
|
|
37
|
-
.addColumn('consent',
|
|
38
|
-
.addColumn('props',
|
|
39
|
-
(db) => db.schema
|
|
35
|
+
.addColumn('anonymousId', ctx.text)
|
|
36
|
+
.addColumn('vendorIds', ctx.text)
|
|
37
|
+
.addColumn('consent', ctx.text)
|
|
38
|
+
.addColumn('props', ctx.text),
|
|
39
|
+
(db, _types, ctx) => db.schema
|
|
40
40
|
.createIndex('idx_pikku_analytics_events_occurred_at')
|
|
41
41
|
.on('pikkuAnalyticsEvents')
|
|
42
42
|
.column('occurredAt'),
|
|
43
|
-
(db) => db.schema
|
|
43
|
+
(db, _types, ctx) => db.schema
|
|
44
44
|
.createIndex('idx_pikku_analytics_events_name')
|
|
45
45
|
.on('pikkuAnalyticsEvents')
|
|
46
46
|
.column('name'),
|