@yeaft/webchat-agent 1.0.166 → 1.0.167
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/package.json +1 -1
- package/yeaft/tools/create-work-item.js +3 -1
- package/yeaft/work-center/assignment.js +12 -4
- package/yeaft/work-center/bridge.js +11 -2
- package/yeaft/work-center/controller.js +46 -5
- package/yeaft/work-center/projection.js +4 -0
- package/yeaft/work-center/runner.js +173 -9
- package/yeaft/work-center/service.js +5 -0
- package/yeaft/work-center/session-context.js +75 -0
- package/yeaft/work-center/store.js +381 -67
- package/yeaft/work-center/watcher.js +164 -84
- package/yeaft/work-center/workflow.js +77 -5
- package/yeaft/work-center/workspace.js +183 -0
|
@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
5
5
|
import { normalizeEvidence } from './evidence.js';
|
|
6
6
|
import { normalizeActionCheckpoint } from './action-checkpoint.js';
|
|
7
7
|
|
|
8
|
-
const SCHEMA_VERSION =
|
|
8
|
+
const SCHEMA_VERSION = 11;
|
|
9
9
|
const OPEN_ACTION_STATUSES = "'ready','running','waiting'";
|
|
10
10
|
const MAX_REUSABLE_CONTEXT_ITEMS = 12;
|
|
11
11
|
const MAX_RUN_RESPONSE_CHARS = 65_536;
|
|
@@ -47,6 +47,7 @@ function mapWorkItem(row) {
|
|
|
47
47
|
reuseMemory: row.reuse_memory !== 0,
|
|
48
48
|
origin: parseJson(row.origin, null),
|
|
49
49
|
linkedSessionIds: parseJson(row.linked_session_ids, []),
|
|
50
|
+
sessionContext: parseJson(row.session_context, []),
|
|
50
51
|
attachments: parseJson(row.attachments, []),
|
|
51
52
|
executionStats: {
|
|
52
53
|
llmRequestCount: Math.max(0, Number(row.usage_llm_request_count) || 0),
|
|
@@ -73,6 +74,10 @@ function mapAction(row) {
|
|
|
73
74
|
stageId: row.stage_id || row.type,
|
|
74
75
|
assignmentPolicy: parseJson(row.assignment_policy, null),
|
|
75
76
|
modelPolicy: parseJson(row.model_policy, null),
|
|
77
|
+
dependsOnStageIds: parseJson(row.depends_on_stage_ids, []),
|
|
78
|
+
workspaceMode: row.workspace_mode || 'shared',
|
|
79
|
+
changesRequestedStageId: row.changes_requested_stage_id || null,
|
|
80
|
+
workspace: parseJson(row.workspace, null),
|
|
76
81
|
requiredRole: row.required_role || '',
|
|
77
82
|
instruction: row.instruction,
|
|
78
83
|
brief: parseJson(row.brief, null),
|
|
@@ -189,6 +194,7 @@ export class WorkItemStore {
|
|
|
189
194
|
reuse_memory INTEGER NOT NULL DEFAULT 1,
|
|
190
195
|
origin TEXT,
|
|
191
196
|
linked_session_ids TEXT NOT NULL DEFAULT '[]',
|
|
197
|
+
session_context TEXT NOT NULL DEFAULT '[]',
|
|
192
198
|
attachments TEXT NOT NULL DEFAULT '[]',
|
|
193
199
|
created_at INTEGER NOT NULL,
|
|
194
200
|
updated_at INTEGER NOT NULL
|
|
@@ -202,6 +208,10 @@ export class WorkItemStore {
|
|
|
202
208
|
stage_id TEXT,
|
|
203
209
|
assignment_policy TEXT,
|
|
204
210
|
model_policy TEXT,
|
|
211
|
+
depends_on_stage_ids TEXT NOT NULL DEFAULT '[]',
|
|
212
|
+
workspace_mode TEXT NOT NULL DEFAULT 'shared',
|
|
213
|
+
changes_requested_stage_id TEXT,
|
|
214
|
+
workspace TEXT,
|
|
205
215
|
instruction TEXT NOT NULL,
|
|
206
216
|
brief TEXT,
|
|
207
217
|
context TEXT NOT NULL DEFAULT '[]',
|
|
@@ -325,6 +335,9 @@ export class WorkItemStore {
|
|
|
325
335
|
if (!hasColumn(this.db, 'work_items', 'workflow_snapshot')) {
|
|
326
336
|
this.db.exec('ALTER TABLE work_items ADD COLUMN workflow_snapshot TEXT');
|
|
327
337
|
}
|
|
338
|
+
if (!hasColumn(this.db, 'work_items', 'session_context')) {
|
|
339
|
+
this.db.exec("ALTER TABLE work_items ADD COLUMN session_context TEXT NOT NULL DEFAULT '[]'");
|
|
340
|
+
}
|
|
328
341
|
if (!hasColumn(this.db, 'work_items', 'attachments')) {
|
|
329
342
|
this.db.exec("ALTER TABLE work_items ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]'");
|
|
330
343
|
}
|
|
@@ -337,6 +350,18 @@ export class WorkItemStore {
|
|
|
337
350
|
if (!hasColumn(this.db, 'actions', 'model_policy')) {
|
|
338
351
|
this.db.exec('ALTER TABLE actions ADD COLUMN model_policy TEXT');
|
|
339
352
|
}
|
|
353
|
+
if (!hasColumn(this.db, 'actions', 'depends_on_stage_ids')) {
|
|
354
|
+
this.db.exec("ALTER TABLE actions ADD COLUMN depends_on_stage_ids TEXT NOT NULL DEFAULT '[]'");
|
|
355
|
+
}
|
|
356
|
+
if (!hasColumn(this.db, 'actions', 'workspace_mode')) {
|
|
357
|
+
this.db.exec("ALTER TABLE actions ADD COLUMN workspace_mode TEXT NOT NULL DEFAULT 'shared'");
|
|
358
|
+
}
|
|
359
|
+
if (!hasColumn(this.db, 'actions', 'changes_requested_stage_id')) {
|
|
360
|
+
this.db.exec('ALTER TABLE actions ADD COLUMN changes_requested_stage_id TEXT');
|
|
361
|
+
}
|
|
362
|
+
if (!hasColumn(this.db, 'actions', 'workspace')) {
|
|
363
|
+
this.db.exec('ALTER TABLE actions ADD COLUMN workspace TEXT');
|
|
364
|
+
}
|
|
340
365
|
this.db.prepare(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
|
|
341
366
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(SCHEMA_VERSION));
|
|
342
367
|
}
|
|
@@ -367,8 +392,8 @@ export class WorkItemStore {
|
|
|
367
392
|
this.db.prepare(`INSERT INTO work_items
|
|
368
393
|
(id, revision, title, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
|
|
369
394
|
current_action_id, current_run_id, work_dir, workspace_key, reuse_memory, origin, linked_session_ids,
|
|
370
|
-
attachments, created_at, updated_at)
|
|
371
|
-
VALUES (?, 1, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
395
|
+
session_context, attachments, created_at, updated_at)
|
|
396
|
+
VALUES (?, 1, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
372
397
|
id,
|
|
373
398
|
input.title,
|
|
374
399
|
input.goal,
|
|
@@ -381,6 +406,7 @@ export class WorkItemStore {
|
|
|
381
406
|
input.reuseMemory === false ? 0 : 1,
|
|
382
407
|
stringify(input.origin || null),
|
|
383
408
|
stringify(input.linkedSessionIds || []),
|
|
409
|
+
stringify(input.sessionContext || []),
|
|
384
410
|
stringify(input.attachments || []),
|
|
385
411
|
now,
|
|
386
412
|
now,
|
|
@@ -404,6 +430,10 @@ export class WorkItemStore {
|
|
|
404
430
|
stageId: input.stageId || input.type,
|
|
405
431
|
assignmentPolicy: input.assignmentPolicy || null,
|
|
406
432
|
modelPolicy: input.modelPolicy || null,
|
|
433
|
+
dependsOnStageIds: Array.isArray(input.dependsOnStageIds) ? input.dependsOnStageIds : [],
|
|
434
|
+
workspaceMode: input.workspaceMode || 'shared',
|
|
435
|
+
changesRequestedStageId: input.changesRequestedStageId || null,
|
|
436
|
+
workspace: input.workspace || null,
|
|
407
437
|
requiredRole: input.requiredRole || '',
|
|
408
438
|
instruction: input.instruction || '',
|
|
409
439
|
brief: input.brief && typeof input.brief === 'object' ? input.brief : null,
|
|
@@ -419,9 +449,9 @@ export class WorkItemStore {
|
|
|
419
449
|
};
|
|
420
450
|
this.db.prepare(`INSERT INTO actions
|
|
421
451
|
(id, work_item_id, sequence, type, required_role, stage_id, assignment_policy, model_policy,
|
|
422
|
-
|
|
423
|
-
lease_epoch, created_at, updated_at)
|
|
424
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
|
|
452
|
+
depends_on_stage_ids, workspace_mode, changes_requested_stage_id, workspace, instruction, brief, context, contract_revision,
|
|
453
|
+
status, attempt, max_attempts, current_run_id, lease_epoch, created_at, updated_at)
|
|
454
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
|
|
425
455
|
action.id,
|
|
426
456
|
workItemId,
|
|
427
457
|
action.sequence,
|
|
@@ -430,6 +460,10 @@ export class WorkItemStore {
|
|
|
430
460
|
action.stageId,
|
|
431
461
|
stringify(action.assignmentPolicy),
|
|
432
462
|
stringify(action.modelPolicy),
|
|
463
|
+
stringify(action.dependsOnStageIds),
|
|
464
|
+
action.workspaceMode,
|
|
465
|
+
action.changesRequestedStageId,
|
|
466
|
+
stringify(action.workspace),
|
|
433
467
|
action.instruction,
|
|
434
468
|
stringify(action.brief),
|
|
435
469
|
stringify(action.context),
|
|
@@ -448,10 +482,188 @@ export class WorkItemStore {
|
|
|
448
482
|
return Number(row.seq) + 1;
|
|
449
483
|
}
|
|
450
484
|
|
|
485
|
+
#hasActiveIntegrationReservation(action, now = this.now()) {
|
|
486
|
+
const reservation = action?.workspace?.integration?.reservation;
|
|
487
|
+
return !!reservation && Number(reservation.expiresAt) > now;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
#assertNoIntegrationReservation(actions, now = this.now()) {
|
|
491
|
+
if (actions.some(action => this.#hasActiveIntegrationReservation(action, now))) {
|
|
492
|
+
throw new Error('Work Center integration finalization currently owns the Action lease');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
#resetGraphFromStage(workItemId, targetStageId, replacement, reason, now) {
|
|
497
|
+
const actions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
|
|
498
|
+
AND status NOT IN ('superseded', 'cancelled') ORDER BY sequence`).all(workItemId).map(mapAction);
|
|
499
|
+
const target = actions.find(action => action.stageId === targetStageId);
|
|
500
|
+
if (!target) throw new Error('Work Center graph reset target is missing');
|
|
501
|
+
const affected = new Set([targetStageId]);
|
|
502
|
+
for (const action of actions) {
|
|
503
|
+
if (action.status === 'running') affected.add(action.stageId);
|
|
504
|
+
}
|
|
505
|
+
let changed = true;
|
|
506
|
+
while (changed) {
|
|
507
|
+
changed = false;
|
|
508
|
+
for (const action of actions) {
|
|
509
|
+
if (affected.has(action.stageId)) continue;
|
|
510
|
+
if (action.dependsOnStageIds.some(stageId => affected.has(stageId))) {
|
|
511
|
+
affected.add(action.stageId);
|
|
512
|
+
changed = true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const affectedActions = actions.filter(action => affected.has(action.stageId));
|
|
517
|
+
this.#assertNoIntegrationReservation(affectedActions, now);
|
|
518
|
+
const preservedTargetWorkspace = target.workspaceMode === 'integrate'
|
|
519
|
+
&& ['prepared', 'finalized'].includes(target.workspace?.integration?.status)
|
|
520
|
+
&& (!replacement || replacement.workspaceMode === 'integrate')
|
|
521
|
+
? target.workspace
|
|
522
|
+
: null;
|
|
523
|
+
const ids = affectedActions.map(action => action.id);
|
|
524
|
+
const running = affectedActions.filter(action => action.status === 'running' && action.currentRunId);
|
|
525
|
+
const nextEpoch = new Map(actions.map(action => [action.id, action.leaseEpoch]));
|
|
526
|
+
for (const action of running) nextEpoch.set(action.id, action.leaseEpoch + 1);
|
|
527
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
528
|
+
if (ids.length > 0) {
|
|
529
|
+
this.db.prepare(`UPDATE runs SET status = 'superseded', ended_at = ?, error = ?
|
|
530
|
+
WHERE action_id IN (${placeholders}) AND status = 'running'`).run(now, reason, ...ids);
|
|
531
|
+
for (const action of affectedActions) {
|
|
532
|
+
const workspace = action.id === target.id ? preservedTargetWorkspace : null;
|
|
533
|
+
this.db.prepare(`UPDATE actions SET status = 'ready', attempt = 0, current_run_id = NULL,
|
|
534
|
+
lease_epoch = ?, workspace = ?, updated_at = ? WHERE id = ?`).run(
|
|
535
|
+
nextEpoch.get(action.id), stringify(workspace), now, action.id,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (replacement) {
|
|
540
|
+
this.db.prepare(`UPDATE actions SET type = ?, required_role = ?, assignment_policy = ?,
|
|
541
|
+
model_policy = ?, depends_on_stage_ids = ?, workspace_mode = ?, changes_requested_stage_id = ?,
|
|
542
|
+
instruction = ?, brief = ?, context = ?, max_attempts = ?, workspace = ?, updated_at = ?
|
|
543
|
+
WHERE id = ?`).run(
|
|
544
|
+
replacement.type || target.type,
|
|
545
|
+
replacement.requiredRole || '',
|
|
546
|
+
stringify(replacement.assignmentPolicy || null),
|
|
547
|
+
stringify(replacement.modelPolicy || null),
|
|
548
|
+
stringify(Array.isArray(replacement.dependsOnStageIds) ? replacement.dependsOnStageIds : []),
|
|
549
|
+
replacement.workspaceMode || 'shared',
|
|
550
|
+
replacement.changesRequestedStageId || null,
|
|
551
|
+
replacement.instruction || '',
|
|
552
|
+
stringify(replacement.brief || null),
|
|
553
|
+
stringify(Array.isArray(replacement.context) ? replacement.context : []),
|
|
554
|
+
Number.isInteger(replacement.maxAttempts) ? replacement.maxAttempts : 2,
|
|
555
|
+
stringify(preservedTargetWorkspace),
|
|
556
|
+
now,
|
|
557
|
+
target.id,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
return this.getAction(target.id);
|
|
561
|
+
}
|
|
562
|
+
|
|
451
563
|
createNextAction(workItemId, input) {
|
|
452
564
|
return this.#insertAction(workItemId, input, this.#nextSequence(workItemId));
|
|
453
565
|
}
|
|
454
566
|
|
|
567
|
+
setActionWorkspace(actionId, workspace, workspaceMode = null) {
|
|
568
|
+
const changed = this.db.prepare(`UPDATE actions SET workspace = ?,
|
|
569
|
+
workspace_mode = COALESCE(?, workspace_mode), updated_at = ? WHERE id = ?`).run(
|
|
570
|
+
stringify(workspace), workspaceMode, this.now(), actionId,
|
|
571
|
+
);
|
|
572
|
+
return Number(changed.changes) === 1 ? this.getAction(actionId) : null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
setIntegrationWorkspaceForRun(actionId, runId, ownerBootId, leaseEpoch, workspace) {
|
|
576
|
+
return withTransaction(this.db, () => {
|
|
577
|
+
const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
|
|
578
|
+
if (!active || active.action_id !== actionId) return null;
|
|
579
|
+
const changed = this.db.prepare(`UPDATE actions SET workspace = ?, workspace_mode = 'integrate',
|
|
580
|
+
updated_at = ? WHERE id = ? AND status = 'running' AND current_run_id = ? AND lease_epoch = ?`).run(
|
|
581
|
+
stringify(workspace), this.now(), actionId, runId, leaseEpoch,
|
|
582
|
+
);
|
|
583
|
+
return Number(changed.changes) === 1 ? this.getAction(actionId) : null;
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
acquireIntegrationFinalization(actionId, runId, ownerBootId, leaseEpoch, reservationMs = 300_000) {
|
|
588
|
+
return withTransaction(this.db, () => {
|
|
589
|
+
const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
|
|
590
|
+
if (!active || active.action_id !== actionId) return null;
|
|
591
|
+
const action = this.getAction(actionId);
|
|
592
|
+
if (action?.workspaceMode !== 'integrate' || action.workspace?.integration?.status !== 'prepared') return null;
|
|
593
|
+
const now = this.now();
|
|
594
|
+
const token = randomUUID();
|
|
595
|
+
const expiresAt = now + Math.max(10_000, Number(reservationMs) || 300_000);
|
|
596
|
+
const workspace = {
|
|
597
|
+
...action.workspace,
|
|
598
|
+
integration: {
|
|
599
|
+
...action.workspace.integration,
|
|
600
|
+
reservation: { token, runId, ownerBootId, leaseEpoch, expiresAt },
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
const changed = this.db.prepare(`UPDATE actions SET workspace = ?, updated_at = ?
|
|
604
|
+
WHERE id = ? AND status = 'running' AND current_run_id = ? AND lease_epoch = ?`).run(
|
|
605
|
+
stringify(workspace), now, actionId, runId, leaseEpoch,
|
|
606
|
+
);
|
|
607
|
+
if (Number(changed.changes) !== 1) return null;
|
|
608
|
+
this.db.prepare(`UPDATE runs SET expires_at = ? WHERE id = ? AND owner_boot_id = ?
|
|
609
|
+
AND lease_epoch = ? AND status = 'running'`).run(expiresAt, runId, ownerBootId, leaseEpoch);
|
|
610
|
+
return { action: this.getAction(actionId), token, expiresAt };
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
finishIntegrationFinalization(actionId, runId, ownerBootId, leaseEpoch, token, workspace) {
|
|
615
|
+
return withTransaction(this.db, () => {
|
|
616
|
+
const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, false);
|
|
617
|
+
if (!active || active.action_id !== actionId) return null;
|
|
618
|
+
const action = this.getAction(actionId);
|
|
619
|
+
const reservation = action?.workspace?.integration?.reservation;
|
|
620
|
+
if (!reservation || reservation.token !== token || reservation.runId !== runId
|
|
621
|
+
|| reservation.ownerBootId !== ownerBootId || reservation.leaseEpoch !== leaseEpoch
|
|
622
|
+
|| Number(reservation.expiresAt) <= this.now()) return null;
|
|
623
|
+
const changed = this.db.prepare(`UPDATE actions SET workspace = ?, workspace_mode = 'integrate',
|
|
624
|
+
updated_at = ? WHERE id = ? AND status = 'running' AND current_run_id = ? AND lease_epoch = ?`).run(
|
|
625
|
+
stringify(workspace), this.now(), actionId, runId, leaseEpoch,
|
|
626
|
+
);
|
|
627
|
+
return Number(changed.changes) === 1 ? this.getAction(actionId) : null;
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
rollbackIntegrationFinalization(actionId, runId, ownerBootId, leaseEpoch, token) {
|
|
632
|
+
return withTransaction(this.db, () => {
|
|
633
|
+
const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, false);
|
|
634
|
+
if (!active || active.action_id !== actionId) return null;
|
|
635
|
+
const action = this.getAction(actionId);
|
|
636
|
+
const reservation = action?.workspace?.integration?.reservation;
|
|
637
|
+
if (!reservation || reservation.token !== token || reservation.runId !== runId
|
|
638
|
+
|| reservation.ownerBootId !== ownerBootId || reservation.leaseEpoch !== leaseEpoch
|
|
639
|
+
|| Number(reservation.expiresAt) <= this.now()) return null;
|
|
640
|
+
const changed = this.db.prepare(`UPDATE actions SET workspace = NULL, workspace_mode = 'integrate',
|
|
641
|
+
updated_at = ? WHERE id = ? AND status = 'running' AND current_run_id = ? AND lease_epoch = ?`).run(
|
|
642
|
+
this.now(), actionId, runId, leaseEpoch,
|
|
643
|
+
);
|
|
644
|
+
return Number(changed.changes) === 1 ? this.getAction(actionId) : null;
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
listActionDependencies(workItemId, stageIds) {
|
|
649
|
+
if (!Array.isArray(stageIds) || stageIds.length === 0) return [];
|
|
650
|
+
const placeholders = stageIds.map(() => '?').join(',');
|
|
651
|
+
return this.db.prepare(`SELECT a.*, r.summary AS dependency_summary,
|
|
652
|
+
r.evidence AS dependency_evidence, r.vp_snapshot AS dependency_vp_snapshot
|
|
653
|
+
FROM actions a LEFT JOIN runs r ON r.id = (
|
|
654
|
+
SELECT completed.id FROM runs completed WHERE completed.action_id = a.id
|
|
655
|
+
AND completed.status = 'completed'
|
|
656
|
+
ORDER BY completed.ended_at DESC LIMIT 1
|
|
657
|
+
) WHERE a.work_item_id = ? AND COALESCE(a.stage_id, a.type) IN (${placeholders})
|
|
658
|
+
AND a.status != 'superseded'
|
|
659
|
+
ORDER BY a.sequence`).all(workItemId, ...stageIds).map(row => ({
|
|
660
|
+
...mapAction(row),
|
|
661
|
+
summary: row.dependency_summary || '',
|
|
662
|
+
evidence: normalizeEvidence(parseJson(row.dependency_evidence, [])),
|
|
663
|
+
vpId: parseJson(row.dependency_vp_snapshot, null)?.id || null,
|
|
664
|
+
}));
|
|
665
|
+
}
|
|
666
|
+
|
|
455
667
|
getWorkItem(id) {
|
|
456
668
|
return mapWorkItem(this.db.prepare('SELECT * FROM work_items WHERE id = ?').get(id));
|
|
457
669
|
}
|
|
@@ -582,6 +794,9 @@ export class WorkItemStore {
|
|
|
582
794
|
}
|
|
583
795
|
|
|
584
796
|
#invalidateExecution(workItem, actionStatus, runStatus, reason, now) {
|
|
797
|
+
const openActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
|
|
798
|
+
AND status IN (${OPEN_ACTION_STATUSES})`).all(workItem.id).map(mapAction);
|
|
799
|
+
this.#assertNoIntegrationReservation(openActions, now);
|
|
585
800
|
if (workItem.currentRunId) {
|
|
586
801
|
this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, error = ?
|
|
587
802
|
WHERE id = ? AND status = 'running'`).run(runStatus, now, reason, workItem.currentRunId);
|
|
@@ -704,8 +919,23 @@ export class WorkItemStore {
|
|
|
704
919
|
AND status != 'running' ORDER BY ended_at DESC, started_at DESC LIMIT 1`).get(id, previous.id))
|
|
705
920
|
: null;
|
|
706
921
|
const now = this.now();
|
|
922
|
+
const replacement = makeAction(workItem, previous, previousRun);
|
|
923
|
+
if (workItem.workflowSnapshot?.executionMode === 'graph') {
|
|
924
|
+
if (!previous) throw new Error('WorkItem graph retry target is missing');
|
|
925
|
+
const action = this.#resetGraphFromStage(
|
|
926
|
+
id,
|
|
927
|
+
previous.stageId,
|
|
928
|
+
replacement,
|
|
929
|
+
'Superseded by manual graph retry',
|
|
930
|
+
now,
|
|
931
|
+
);
|
|
932
|
+
this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
|
|
933
|
+
current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
|
|
934
|
+
this.appendEvent(id, 'work_item.retried', { targetStageId: action.stageId }, { actionId: action.id });
|
|
935
|
+
return this.getWorkItemDetail(id);
|
|
936
|
+
}
|
|
707
937
|
const action = this.#insertAction(id, {
|
|
708
|
-
...
|
|
938
|
+
...replacement,
|
|
709
939
|
contractRevision: workItem.revision,
|
|
710
940
|
}, this.#nextSequence(id), now);
|
|
711
941
|
this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
|
|
@@ -720,7 +950,31 @@ export class WorkItemStore {
|
|
|
720
950
|
const row = this.db.prepare(`SELECT a.* FROM actions a
|
|
721
951
|
JOIN work_items w ON w.id = a.work_item_id
|
|
722
952
|
WHERE a.status = 'ready' AND a.current_run_id IS NULL
|
|
723
|
-
AND
|
|
953
|
+
AND (
|
|
954
|
+
(COALESCE(json_extract(w.workflow_snapshot, '$.executionMode'), 'linear') != 'graph'
|
|
955
|
+
AND w.status = 'ready' AND w.current_action_id = a.id AND w.current_run_id IS NULL)
|
|
956
|
+
OR
|
|
957
|
+
(json_extract(w.workflow_snapshot, '$.executionMode') = 'graph'
|
|
958
|
+
AND w.status IN ('ready', 'running')
|
|
959
|
+
AND NOT EXISTS (
|
|
960
|
+
SELECT 1 FROM json_each(a.depends_on_stage_ids) dependency
|
|
961
|
+
LEFT JOIN actions required ON required.work_item_id = a.work_item_id
|
|
962
|
+
AND required.stage_id = dependency.value
|
|
963
|
+
WHERE required.id IS NULL OR required.status != 'completed'
|
|
964
|
+
)
|
|
965
|
+
)
|
|
966
|
+
)
|
|
967
|
+
AND NOT EXISTS (
|
|
968
|
+
SELECT 1 FROM actions running
|
|
969
|
+
JOIN work_items running_item ON running_item.id = running.work_item_id
|
|
970
|
+
WHERE running.status = 'running'
|
|
971
|
+
AND running_item.workspace_key != ''
|
|
972
|
+
AND running_item.workspace_key = w.workspace_key
|
|
973
|
+
AND (a.workspace_mode IN ('shared', 'integrate')
|
|
974
|
+
OR running.workspace_mode IN ('shared', 'integrate')
|
|
975
|
+
OR (running.work_item_id != a.work_item_id
|
|
976
|
+
AND a.workspace_mode != 'read' AND running.workspace_mode != 'read'))
|
|
977
|
+
)
|
|
724
978
|
ORDER BY a.updated_at ASC, a.sequence ASC LIMIT 1`).get();
|
|
725
979
|
if (!row) return null;
|
|
726
980
|
const now = this.now();
|
|
@@ -732,35 +986,30 @@ export class WorkItemStore {
|
|
|
732
986
|
const changedAction = this.db.prepare(`UPDATE actions SET status = 'running', attempt = attempt + 1,
|
|
733
987
|
current_run_id = ?, lease_epoch = ?, updated_at = ?
|
|
734
988
|
WHERE id = ? AND status = 'ready' AND current_run_id IS NULL`).run(
|
|
735
|
-
runId,
|
|
736
|
-
leaseEpoch,
|
|
737
|
-
now,
|
|
738
|
-
row.id,
|
|
989
|
+
runId, leaseEpoch, now, row.id,
|
|
739
990
|
);
|
|
740
991
|
if (Number(changedAction.changes) !== 1) return null;
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
992
|
+
const workItem = this.getWorkItem(row.work_item_id);
|
|
993
|
+
const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
|
|
994
|
+
const changedWorkItem = graphMode
|
|
995
|
+
? this.db.prepare(`UPDATE work_items SET status = 'running', current_action_id = ?,
|
|
996
|
+
current_run_id = NULL, updated_at = ? WHERE id = ? AND status IN ('ready', 'running')`).run(
|
|
997
|
+
row.id, now, row.work_item_id,
|
|
998
|
+
)
|
|
999
|
+
: this.db.prepare(`UPDATE work_items SET status = 'running', current_run_id = ?, updated_at = ?
|
|
1000
|
+
WHERE id = ? AND status = 'ready' AND current_action_id = ? AND current_run_id IS NULL`).run(
|
|
1001
|
+
runId, now, row.work_item_id, row.id,
|
|
1002
|
+
);
|
|
1003
|
+
if (Number(changedWorkItem.changes) !== 1) throw new Error('WorkItem claim lost its Action fence');
|
|
750
1004
|
this.db.prepare(`INSERT INTO runs
|
|
751
1005
|
(id, action_id, work_item_id, owner_boot_id, lease_epoch, status, started_at,
|
|
752
1006
|
expires_at, evidence, progress_revision)
|
|
753
1007
|
VALUES (?, ?, ?, ?, ?, 'running', ?, ?, '[]', ?)`).run(
|
|
754
|
-
runId,
|
|
755
|
-
row.id,
|
|
756
|
-
row.work_item_id,
|
|
757
|
-
ownerBootId,
|
|
758
|
-
leaseEpoch,
|
|
759
|
-
now,
|
|
760
|
-
now + leaseMs,
|
|
761
|
-
progressRevision,
|
|
1008
|
+
runId, row.id, row.work_item_id, ownerBootId, leaseEpoch, now, now + leaseMs, progressRevision,
|
|
762
1009
|
);
|
|
763
|
-
this.appendEvent(row.work_item_id, 'run.claimed', { ownerBootId, leaseEpoch }, {
|
|
1010
|
+
this.appendEvent(row.work_item_id, 'run.claimed', { ownerBootId, leaseEpoch }, {
|
|
1011
|
+
actionId: row.id, runId,
|
|
1012
|
+
});
|
|
764
1013
|
return {
|
|
765
1014
|
workItem: this.getWorkItem(row.work_item_id),
|
|
766
1015
|
action: this.getAction(row.id),
|
|
@@ -770,18 +1019,20 @@ export class WorkItemStore {
|
|
|
770
1019
|
}
|
|
771
1020
|
|
|
772
1021
|
#activeRunRow(runId, ownerBootId, leaseEpoch, requireUnexpired = true) {
|
|
773
|
-
|
|
1022
|
+
const row = this.db.prepare(`SELECT r.* FROM runs r
|
|
774
1023
|
JOIN actions a ON a.id = r.action_id
|
|
775
1024
|
JOIN work_items w ON w.id = r.work_item_id
|
|
776
1025
|
WHERE r.id = ? AND r.owner_boot_id = ? AND r.lease_epoch = ? AND r.status = 'running'
|
|
777
1026
|
AND a.status = 'running' AND a.current_run_id = r.id AND a.lease_epoch = r.lease_epoch
|
|
778
|
-
AND w.status
|
|
1027
|
+
AND w.status IN ('ready', 'running', 'waiting')
|
|
779
1028
|
${requireUnexpired ? 'AND r.expires_at > ?' : ''}`).get(
|
|
780
|
-
runId,
|
|
781
|
-
ownerBootId,
|
|
782
|
-
leaseEpoch,
|
|
783
|
-
...(requireUnexpired ? [this.now()] : []),
|
|
1029
|
+
runId, ownerBootId, leaseEpoch, ...(requireUnexpired ? [this.now()] : []),
|
|
784
1030
|
);
|
|
1031
|
+
if (!row) return null;
|
|
1032
|
+
const workItem = this.getWorkItem(row.work_item_id);
|
|
1033
|
+
if (workItem?.workflowSnapshot?.executionMode === 'graph') return row;
|
|
1034
|
+
return workItem?.status === 'running' && workItem.currentActionId === row.action_id
|
|
1035
|
+
&& workItem.currentRunId === row.id ? row : null;
|
|
785
1036
|
}
|
|
786
1037
|
|
|
787
1038
|
renewLease(runId, ownerBootId, leaseEpoch, leaseMs = 60_000) {
|
|
@@ -814,6 +1065,7 @@ export class WorkItemStore {
|
|
|
814
1065
|
if (!active) return false;
|
|
815
1066
|
const action = this.getAction(active.action_id);
|
|
816
1067
|
const now = this.now();
|
|
1068
|
+
this.#assertNoIntegrationReservation([action], now);
|
|
817
1069
|
const retryable = action.type !== 'deliver' && action.attempt < action.maxAttempts;
|
|
818
1070
|
const hasFinalProgress = finalProgress && typeof finalProgress === 'object';
|
|
819
1071
|
const runChanged = hasFinalProgress
|
|
@@ -858,15 +1110,17 @@ export class WorkItemStore {
|
|
|
858
1110
|
leaseEpoch,
|
|
859
1111
|
);
|
|
860
1112
|
if (Number(actionChanged.changes) !== 1) throw new Error('Run interruption lost the Action fence');
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1113
|
+
const graphMode = this.getWorkItem(active.work_item_id)?.workflowSnapshot?.executionMode === 'graph';
|
|
1114
|
+
const itemChanged = graphMode
|
|
1115
|
+
? this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?, current_run_id = NULL,
|
|
1116
|
+
updated_at = ? WHERE id = ? AND status = 'running'`).run(
|
|
1117
|
+
retryable ? 'ready' : 'needs_attention', action.id, now, active.work_item_id,
|
|
1118
|
+
)
|
|
1119
|
+
: this.db.prepare(`UPDATE work_items SET status = ?, current_run_id = NULL,
|
|
1120
|
+
updated_at = ? WHERE id = ? AND status = 'running' AND current_action_id = ?
|
|
1121
|
+
AND current_run_id = ?`).run(
|
|
1122
|
+
retryable ? 'ready' : 'needs_attention', now, active.work_item_id, action.id, runId,
|
|
1123
|
+
);
|
|
870
1124
|
if (Number(itemChanged.changes) !== 1) throw new Error('Run interruption lost the WorkItem fence');
|
|
871
1125
|
this.appendEvent(active.work_item_id, 'run.interrupted', { retryable, reason }, {
|
|
872
1126
|
actionId: action.id,
|
|
@@ -1018,24 +1272,52 @@ export class WorkItemStore {
|
|
|
1018
1272
|
}
|
|
1019
1273
|
|
|
1020
1274
|
let nextAction = null;
|
|
1275
|
+
if (transition.graphResetStageId) {
|
|
1276
|
+
nextAction = this.#resetGraphFromStage(
|
|
1277
|
+
workItem.id,
|
|
1278
|
+
transition.graphResetStageId,
|
|
1279
|
+
transition.graphResetAction,
|
|
1280
|
+
'Superseded by review changes request',
|
|
1281
|
+
now,
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
const nextActions = Array.isArray(transition.nextActions) ? transition.nextActions : [];
|
|
1285
|
+
for (const candidate of nextActions) {
|
|
1286
|
+
const inserted = this.#insertAction(workItem.id, {
|
|
1287
|
+
...candidate,
|
|
1288
|
+
contractRevision: nextWorkItem.revision,
|
|
1289
|
+
}, this.#nextSequence(workItem.id), now);
|
|
1290
|
+
if (!nextAction) nextAction = inserted;
|
|
1291
|
+
}
|
|
1021
1292
|
if (transition.nextAction) {
|
|
1022
1293
|
nextAction = this.#insertAction(workItem.id, {
|
|
1023
1294
|
...transition.nextAction,
|
|
1024
1295
|
contractRevision: nextWorkItem.revision,
|
|
1025
1296
|
}, this.#nextSequence(workItem.id), now);
|
|
1026
1297
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1298
|
+
let workItemStatus = transition.workItemStatus;
|
|
1299
|
+
let currentActionId = nextAction?.id ?? (transition.keepCurrentAction ? action.id : null);
|
|
1300
|
+
let changedWorkItem;
|
|
1301
|
+
if (transition.graphAdvance) {
|
|
1302
|
+
const remaining = this.db.prepare(`SELECT id, status FROM actions WHERE work_item_id = ?
|
|
1303
|
+
AND status IN ('ready', 'running', 'waiting', 'failed') ORDER BY sequence`).all(workItem.id);
|
|
1304
|
+
const blocked = remaining.find(candidate => candidate.status === 'waiting' || candidate.status === 'failed');
|
|
1305
|
+
const runnable = remaining.find(candidate => candidate.status === 'ready' || candidate.status === 'running');
|
|
1306
|
+
workItemStatus = blocked ? (blocked.status === 'waiting' ? 'waiting' : 'needs_attention')
|
|
1307
|
+
: runnable ? (remaining.some(candidate => candidate.status === 'running') ? 'running' : 'ready')
|
|
1308
|
+
: 'done';
|
|
1309
|
+
currentActionId = blocked?.id || runnable?.id || null;
|
|
1310
|
+
changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
|
|
1311
|
+
current_run_id = NULL, updated_at = ? WHERE id = ? AND status IN ('ready', 'running')`).run(
|
|
1312
|
+
workItemStatus, currentActionId, now, workItem.id,
|
|
1313
|
+
);
|
|
1314
|
+
} else {
|
|
1315
|
+
changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
|
|
1316
|
+
current_run_id = NULL, updated_at = ? WHERE id = ? AND current_run_id = ?
|
|
1317
|
+
AND current_action_id = ? AND status = 'running'`).run(
|
|
1318
|
+
workItemStatus, currentActionId, now, workItem.id, runId, action.id,
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1039
1321
|
if (Number(changedWorkItem.changes) !== 1) {
|
|
1040
1322
|
throw new Error('Work Center terminal transition lost the current WorkItem fence');
|
|
1041
1323
|
}
|
|
@@ -1048,22 +1330,47 @@ export class WorkItemStore {
|
|
|
1048
1330
|
});
|
|
1049
1331
|
}
|
|
1050
1332
|
|
|
1333
|
+
#refreshGraphWorkItem(workItemId, now) {
|
|
1334
|
+
const remaining = this.db.prepare(`SELECT id, status FROM actions WHERE work_item_id = ?
|
|
1335
|
+
AND status IN ('ready', 'running', 'waiting', 'failed') ORDER BY sequence`).all(workItemId);
|
|
1336
|
+
const blocked = remaining.find(action => action.status === 'waiting' || action.status === 'failed');
|
|
1337
|
+
const runnable = remaining.find(action => action.status === 'ready' || action.status === 'running');
|
|
1338
|
+
const status = blocked ? (blocked.status === 'waiting' ? 'waiting' : 'needs_attention')
|
|
1339
|
+
: runnable ? (remaining.some(action => action.status === 'running') ? 'running' : 'ready')
|
|
1340
|
+
: 'done';
|
|
1341
|
+
const currentActionId = blocked?.id || runnable?.id || null;
|
|
1342
|
+
this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?, current_run_id = NULL,
|
|
1343
|
+
updated_at = ? WHERE id = ?`).run(status, currentActionId, now, workItemId);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1051
1346
|
recoverInterruptedRuns(ownerBootId) {
|
|
1052
1347
|
return withTransaction(this.db, () => {
|
|
1053
1348
|
const now = this.now();
|
|
1054
1349
|
const rows = this.db.prepare(`SELECT * FROM runs
|
|
1055
1350
|
WHERE status = 'running' AND (owner_boot_id != ? OR expires_at <= ?)`).all(ownerBootId, now);
|
|
1351
|
+
let recovered = 0;
|
|
1056
1352
|
for (const row of rows) {
|
|
1057
1353
|
const action = this.getAction(row.action_id);
|
|
1354
|
+
if (this.#hasActiveIntegrationReservation(action, now)) continue;
|
|
1058
1355
|
const workItem = this.getWorkItem(row.work_item_id);
|
|
1356
|
+
const graphMode = workItem?.workflowSnapshot?.executionMode === 'graph';
|
|
1059
1357
|
const isCurrent = action?.status === 'running'
|
|
1060
1358
|
&& action.currentRunId === row.id
|
|
1061
1359
|
&& action.leaseEpoch === row.lease_epoch
|
|
1062
1360
|
&& workItem?.status === 'running'
|
|
1063
|
-
&& workItem.currentActionId === action.id
|
|
1064
|
-
&& workItem.currentRunId === row.id;
|
|
1361
|
+
&& (graphMode || (workItem.currentActionId === action.id && workItem.currentRunId === row.id));
|
|
1065
1362
|
if (!isCurrent) {
|
|
1066
1363
|
const staleStatus = workItem?.status === 'cancelled' ? 'cancelled' : 'superseded';
|
|
1364
|
+
const stillOwnsAction = action?.status === 'running'
|
|
1365
|
+
&& action.currentRunId === row.id
|
|
1366
|
+
&& action.leaseEpoch === row.lease_epoch;
|
|
1367
|
+
if (stillOwnsAction) {
|
|
1368
|
+
const retryable = action.type !== 'deliver' && action.attempt < action.maxAttempts;
|
|
1369
|
+
this.db.prepare(`UPDATE actions SET status = ?, current_run_id = NULL, updated_at = ?
|
|
1370
|
+
WHERE id = ? AND status = 'running' AND current_run_id = ? AND lease_epoch = ?`).run(
|
|
1371
|
+
retryable ? 'ready' : 'failed', now, action.id, row.id, row.lease_epoch,
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1067
1374
|
this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, error = ?
|
|
1068
1375
|
WHERE id = ? AND status = 'running'`).run(
|
|
1069
1376
|
staleStatus,
|
|
@@ -1075,6 +1382,8 @@ export class WorkItemStore {
|
|
|
1075
1382
|
actionId: row.action_id,
|
|
1076
1383
|
runId: row.id,
|
|
1077
1384
|
});
|
|
1385
|
+
if (graphMode && workItem) this.#refreshGraphWorkItem(workItem.id, now);
|
|
1386
|
+
recovered += 1;
|
|
1078
1387
|
continue;
|
|
1079
1388
|
}
|
|
1080
1389
|
|
|
@@ -1084,19 +1393,24 @@ export class WorkItemStore {
|
|
|
1084
1393
|
const retryable = action.type !== 'deliver' && action.attempt < action.maxAttempts;
|
|
1085
1394
|
this.db.prepare(`UPDATE actions SET status = ?, current_run_id = NULL, updated_at = ?
|
|
1086
1395
|
WHERE id = ?`).run(retryable ? 'ready' : 'failed', now, action.id);
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1396
|
+
if (graphMode) {
|
|
1397
|
+
this.#refreshGraphWorkItem(workItem.id, now);
|
|
1398
|
+
} else {
|
|
1399
|
+
this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
|
|
1400
|
+
current_run_id = NULL, updated_at = ? WHERE id = ?`).run(
|
|
1401
|
+
retryable ? 'ready' : 'needs_attention',
|
|
1402
|
+
action.id,
|
|
1403
|
+
now,
|
|
1404
|
+
workItem.id,
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1094
1407
|
this.appendEvent(workItem.id, 'run.interrupted', { retryable }, {
|
|
1095
1408
|
actionId: action.id,
|
|
1096
1409
|
runId: row.id,
|
|
1097
1410
|
});
|
|
1411
|
+
recovered += 1;
|
|
1098
1412
|
}
|
|
1099
|
-
return
|
|
1413
|
+
return recovered;
|
|
1100
1414
|
});
|
|
1101
1415
|
}
|
|
1102
1416
|
}
|