@yeaft/webchat-agent 1.0.567 → 1.0.568

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,6 @@
1
+ import { normalizeRecurrence, validateScheduleTimestamp, initialOccurrence, nextOccurrence, latestOccurrence } from './recurrence.js';
1
2
  import { DatabaseSync } from 'node:sqlite';
3
+ import { withTransaction } from './transaction.js';
2
4
  import { mkdirSync, realpathSync } from 'node:fs';
3
5
  import { dirname, resolve } from 'node:path';
4
6
  import { createHash, randomUUID } from 'node:crypto';
@@ -114,10 +116,15 @@ function mapWorkItem(row) {
114
116
  deliveryTarget: row.delivery_target || null,
115
117
  deliveryInstructions: row.delivery_instructions || '',
116
118
  schedule: row.schedule_status ? {
117
- status: row.schedule_status,
119
+ status: row.status === 'cancelled' ? 'cancelled' : row.schedule_status,
118
120
  scheduledFor: Number(row.scheduled_for) || null,
119
121
  triggeredAt: Number(row.schedule_triggered_at) || null,
122
+ recurrence: parseJson(row.schedule_recurrence, null),
123
+ runCount: Number(row.schedule_run_count) || 0,
124
+ lastWorkItemId: row.schedule_last_work_item_id || null,
120
125
  } : null,
126
+ sourceScheduleId: row.source_schedule_id || null,
127
+ scheduledOccurrenceAt: row.scheduled_occurrence_at ?? null,
121
128
  title: row.title,
122
129
  titleSource: row.title_source || 'explicit',
123
130
  requirement: row.requirement ?? row.goal,
@@ -341,18 +348,6 @@ function mapEvent(row) {
341
348
  };
342
349
  }
343
350
 
344
- function withTransaction(db, fn) {
345
- db.exec('BEGIN IMMEDIATE');
346
- try {
347
- const result = fn();
348
- db.exec('COMMIT');
349
- return result;
350
- } catch (err) {
351
- try { db.exec('ROLLBACK'); } catch {}
352
- throw err;
353
- }
354
- }
355
-
356
351
  function hasColumn(db, table, column) {
357
352
  return db.prepare(`PRAGMA table_info(${table})`).all().some(row => row.name === column);
358
353
  }
@@ -841,7 +836,8 @@ export class WorkItemStore {
841
836
  extendExecutionBudget(id, revision, additions) { return this.resourceControl.extend(id, revision, additions); }
842
837
  stopExecution(id, code, details) { return this.resourceControl.stop(id, code, details); }
843
838
  canAutomaticallyCoordinate(id, { userMessage = false } = {}) {
844
- const row = this.db.prepare('SELECT status FROM work_items WHERE id = ?').get(id);
839
+ const row = this.db.prepare('SELECT status, schedule_recurrence FROM work_items WHERE id = ?').get(id);
840
+ if (row?.schedule_recurrence && parseJson(row.schedule_recurrence, null)) return false;
845
841
  if (!row || ['done', 'cancelled'].includes(row.status) || this.isExecutionStopped(id)) return false;
846
842
  const control = this.getExecutionControl(id);
847
843
  // A failed Action may be closed without retrying it. Enforce lifetime
@@ -1364,7 +1360,7 @@ export class WorkItemStore {
1364
1360
  );
1365
1361
  return this.db.prepare('SELECT * FROM action_entries WHERE id = ?').get(id);
1366
1362
  };
1367
- return this.db.isTransaction ? append() : withTransaction(this.db, append);
1363
+ return withTransaction(this.db, append);
1368
1364
  }
1369
1365
 
1370
1366
  appendActionControl(workItemId, actionId, command, options = {}) {
@@ -1403,7 +1399,7 @@ export class WorkItemStore {
1403
1399
  );
1404
1400
  return this.db.prepare('SELECT * FROM coordinator_mailbox_entries WHERE id = ?').get(id);
1405
1401
  };
1406
- return this.db.isTransaction ? enqueue() : withTransaction(this.db, enqueue);
1402
+ return withTransaction(this.db, enqueue);
1407
1403
  }
1408
1404
 
1409
1405
  claimCoordinatorTurn(workItemId, turnId, owner, leaseMs = 60_000) {
@@ -2428,6 +2424,9 @@ export class WorkItemStore {
2428
2424
  const now = this.now();
2429
2425
  const id = input.id || randomUUID();
2430
2426
  const workspaceKey = canonicalWorkspaceKey(input.workDir);
2427
+ const recurrence = normalizeRecurrence(input.schedule?.recurrence);
2428
+ if (recurrence && firstAction) throw new Error('A recurring schedule must remain a draft plan');
2429
+ if (input.schedule) validateScheduleTimestamp(input.schedule.scheduledFor);
2431
2430
  this.db.prepare(`INSERT INTO work_items
2432
2431
  (id, revision, execution_schema_version, ledger_revision, coordination_mode, final_result, delivery_target, delivery_instructions,
2433
2432
  schedule_status, scheduled_for, schedule_triggered_at, title, title_source, requirement, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
@@ -2459,6 +2458,12 @@ export class WorkItemStore {
2459
2458
  now,
2460
2459
  now,
2461
2460
  );
2461
+ if (recurrence) {
2462
+ const due = initialOccurrence(recurrence, input.schedule.scheduledFor);
2463
+ const completed = due == null || (recurrence.endsAt != null && (recurrence.endsAt < now || due > recurrence.endsAt));
2464
+ this.db.prepare(`UPDATE work_items SET schedule_recurrence = ?, scheduled_for = ?, schedule_status = ? WHERE id = ?`)
2465
+ .run(stringify(recurrence), due, completed ? 'completed' : input.schedule.status, id);
2466
+ }
2462
2467
  let action = null;
2463
2468
  if (firstAction) {
2464
2469
  action = this.#insertAction(id, { ...firstAction, contractRevision: 1 }, 1, now);
@@ -3090,6 +3095,7 @@ export class WorkItemStore {
3090
3095
 
3091
3096
  getWorkItem(id) {
3092
3097
  const workItem = mapWorkItem(this.db.prepare('SELECT * FROM work_items WHERE id = ?').get(id));
3098
+ if (workItem?.schedule) workItem.schedule.lastError = this.getScheduleLastError(id);
3093
3099
  if (!usesLegacyGraph(workItem) && !isDynamicWorkItem(workItem)) return workItem;
3094
3100
  const actions = this.db.prepare('SELECT * FROM actions WHERE work_item_id = ? ORDER BY sequence')
3095
3101
  .all(id).map(mapAction);
@@ -3135,12 +3141,96 @@ export class WorkItemStore {
3135
3141
  }));
3136
3142
  }
3137
3143
 
3144
+ // Event IDs, not timestamps, order failures and clearing transitions (which may
3145
+ // share a clock tick). Unrelated plan edits must not hide a dispatch failure.
3146
+ getScheduleLastError(id) {
3147
+ const event = this.db.prepare(`SELECT type, created_at FROM events WHERE work_item_id = ?
3148
+ AND type IN ('work_item.schedule_failed', 'work_item.schedule_triggered',
3149
+ 'work_item.schedule_advanced', 'work_item.schedule_updated', 'work_item.started')
3150
+ ORDER BY id DESC LIMIT 1`).get(id);
3151
+ return event?.type === 'work_item.schedule_failed' ? {
3152
+ code: 'schedule_dispatch_failed',
3153
+ message: 'Scheduled execution could not start. The plan will retry automatically; check its configuration and attachments.',
3154
+ at: Number(event.created_at),
3155
+ } : null;
3156
+ }
3157
+
3158
+ recordScheduleFailure(id) {
3159
+ return withTransaction(this.db, () => {
3160
+ const workItem = this.getWorkItem(id);
3161
+ if (workItem?.status !== 'draft' || workItem.schedule?.status !== 'scheduled'
3162
+ || workItem.schedule.lastError) return null;
3163
+ // One durable notification per uninterrupted failure episode, even across
3164
+ // restarts. Never persist exception text: it may contain paths or secrets.
3165
+ this.appendEvent(id, 'work_item.schedule_failed', { code: 'schedule_dispatch_failed' });
3166
+ this.db.prepare('UPDATE work_items SET revision = revision + 1, updated_at = ? WHERE id = ?')
3167
+ .run(this.now(), id);
3168
+ return this.getWorkItemDetail(id);
3169
+ });
3170
+ }
3171
+
3138
3172
  listDueScheduledWorkItemIds(now = this.now()) {
3139
3173
  return this.db.prepare(`SELECT id FROM work_items
3140
3174
  WHERE schedule_status = 'scheduled' AND scheduled_for <= ? AND status = 'draft'
3141
3175
  ORDER BY scheduled_for, id`).all(now).map(row => row.id);
3142
3176
  }
3143
3177
 
3178
+ dispatchScheduledWorkItem(id, scheduledAt, makeInitialAction, cloneAttachments = null) {
3179
+ validateScheduleTimestamp(scheduledAt);
3180
+ return withTransaction(this.db, () => {
3181
+ const source = this.getWorkItem(id);
3182
+ if (!source || source.status !== 'draft' || source.schedule?.status !== 'scheduled'
3183
+ || source.schedule.scheduledFor > scheduledAt) return null;
3184
+ const schedule = source.schedule;
3185
+ if (!schedule.recurrence) return this.startWorkItemAtomic(id, makeInitialAction, { scheduledAt });
3186
+ const recurrence = normalizeRecurrence(schedule.recurrence);
3187
+ const expired = recurrence.endsAt != null && scheduledAt > recurrence.endsAt;
3188
+ const exhausted = recurrence.maxRuns != null && schedule.runCount >= recurrence.maxRuns;
3189
+ const due = latestOccurrence(recurrence, scheduledAt);
3190
+ const next = nextOccurrence(recurrence, scheduledAt);
3191
+ // failed Actions / Runs do not mean a terminal WorkItem: needs_attention,
3192
+ // waiting and running remain live and may be retried by their owner.
3193
+ const overlapping = this.db.prepare(`SELECT id FROM work_items WHERE source_schedule_id = ?
3194
+ AND status NOT IN ('done', 'cancelled', 'failed', 'error') LIMIT 1`).get(id);
3195
+ let occurrence = null;
3196
+ if (!expired && !exhausted && !overlapping && due != null && due >= schedule.scheduledFor) {
3197
+ const occurrenceId = randomUUID();
3198
+ const attachments = source.attachments.length
3199
+ ? cloneAttachments?.(source, occurrenceId) : [];
3200
+ if (!attachments) throw new Error('Recurring attachments require an owner-safe clone');
3201
+ this.createWorkItem({
3202
+ id: occurrenceId, title: source.title, titleSource: source.titleSource, goal: source.goal,
3203
+ acceptanceCriteria: source.acceptanceCriteria, workflowTemplate: source.workflowTemplate,
3204
+ workflowSnapshot: source.workflowSnapshot, executionSchemaVersion: source.executionSchemaVersion,
3205
+ coordinationMode: source.coordinationMode, workDir: source.workDir, reuseMemory: source.reuseMemory,
3206
+ origin: source.origin, linkedSessionIds: source.linkedSessionIds, sessionContext: source.sessionContext,
3207
+ deliveryTarget: source.deliveryTarget, deliveryInstructions: source.deliveryInstructions, attachments,
3208
+ });
3209
+ this.db.prepare(`UPDATE work_items SET source_schedule_id = ?, scheduled_occurrence_at = ?, requirement = ? WHERE id = ?`)
3210
+ .run(id, due, source.requirement, occurrenceId);
3211
+ // Copy the plan's approved limits, not usage, stops, reservations or claims.
3212
+ this.db.prepare(`INSERT INTO work_item_execution_controls (work_item_id, limits_json, action_attempts_extension)
3213
+ SELECT ?, limits_json, action_attempts_extension FROM work_item_execution_controls WHERE work_item_id = ?
3214
+ ON CONFLICT(work_item_id) DO UPDATE SET limits_json = excluded.limits_json,
3215
+ action_attempts_extension = excluded.action_attempts_extension`).run(occurrenceId, id);
3216
+ occurrence = this.startWorkItemAtomic(occurrenceId, makeInitialAction);
3217
+ }
3218
+ const runCount = schedule.runCount + (occurrence ? 1 : 0);
3219
+ const completed = expired || exhausted || next == null
3220
+ || (recurrence.endsAt != null && next > recurrence.endsAt)
3221
+ || (recurrence.maxRuns != null && runCount >= recurrence.maxRuns);
3222
+ this.db.prepare(`UPDATE work_items SET schedule_status = ?, scheduled_for = ?, schedule_run_count = ?,
3223
+ schedule_last_work_item_id = ?, schedule_triggered_at = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
3224
+ .run(completed ? 'completed' : 'scheduled', next, runCount, occurrence?.id || schedule.lastWorkItemId,
3225
+ occurrence ? scheduledAt : schedule.triggeredAt, this.now(), id);
3226
+ this.appendEvent(id, occurrence ? 'work_item.schedule_triggered' : 'work_item.schedule_advanced', {
3227
+ occurrenceId: occurrence?.id || null, scheduledOccurrenceAt: occurrence?.scheduledOccurrenceAt || null,
3228
+ nextScheduledFor: next, status: completed ? 'completed' : 'scheduled', skippedOverlap: !!overlapping,
3229
+ });
3230
+ return occurrence;
3231
+ });
3232
+ }
3233
+
3144
3234
  updateWorkItemSchedule(id, input = {}) {
3145
3235
  return withTransaction(this.db, () => {
3146
3236
  const row = this.db.prepare('SELECT * FROM work_items WHERE id = ?').get(id);
@@ -3148,15 +3238,30 @@ export class WorkItemStore {
3148
3238
  if (row.status !== 'draft' || !['scheduled', 'paused'].includes(row.schedule_status)) {
3149
3239
  throw new Error('Only pending scheduled WorkItems can be changed');
3150
3240
  }
3151
- const scheduledFor = input.scheduledFor == null ? Number(row.scheduled_for) : Number(input.scheduledFor);
3152
- if (!Number.isSafeInteger(scheduledFor) || scheduledFor <= this.now()) {
3153
- throw new Error('scheduledFor must be in the future');
3241
+ if (typeof input.enabled !== 'boolean') throw new Error('enabled must be a boolean');
3242
+ if (input.revision !== undefined && input.revision !== row.revision) {
3243
+ throw new Error('Schedule changed; refresh and try again');
3154
3244
  }
3155
- const status = input.enabled === false ? 'paused' : 'scheduled';
3245
+ const oldRecurrence = parseJson(row.schedule_recurrence, null);
3246
+ const recurrence = Object.hasOwn(input, 'recurrence') ? normalizeRecurrence(input.recurrence) : oldRecurrence;
3247
+ if (oldRecurrence && !recurrence) throw new Error('A recurring plan cannot be converted to a one-shot WorkItem');
3156
3248
  const now = this.now();
3157
- this.db.prepare(`UPDATE work_items SET schedule_status = ?, scheduled_for = ?,
3158
- revision = revision + 1, updated_at = ? WHERE id = ?`).run(status, scheduledFor, now, id);
3159
- this.appendEvent(id, 'work_item.schedule_updated', { status, scheduledFor });
3249
+ let scheduledFor = Object.hasOwn(input, 'scheduledFor')
3250
+ ? validateScheduleTimestamp(input.scheduledFor) : Number(row.scheduled_for);
3251
+ if (Object.hasOwn(input, 'scheduledFor') && scheduledFor <= now) throw new Error('scheduledFor must be in the future');
3252
+ if (recurrence) {
3253
+ scheduledFor = initialOccurrence(recurrence, scheduledFor);
3254
+ if (input.enabled && scheduledFor != null && scheduledFor <= now) scheduledFor = nextOccurrence(recurrence, now);
3255
+ } else if (input.enabled && scheduledFor <= now) {
3256
+ throw new Error('Overdue one-shot schedule requires a future scheduledFor to resume');
3257
+ }
3258
+ const completed = recurrence && (scheduledFor == null
3259
+ || (recurrence.endsAt != null && (recurrence.endsAt < now || scheduledFor > recurrence.endsAt))
3260
+ || (recurrence.maxRuns != null && row.schedule_run_count >= recurrence.maxRuns));
3261
+ const status = completed ? 'completed' : input.enabled ? 'scheduled' : 'paused';
3262
+ this.db.prepare(`UPDATE work_items SET schedule_status = ?, scheduled_for = ?, schedule_recurrence = ?,
3263
+ revision = revision + 1, updated_at = ? WHERE id = ?`).run(status, scheduledFor, stringify(recurrence), now, id);
3264
+ this.appendEvent(id, 'work_item.schedule_updated', { status, scheduledFor, recurrence });
3160
3265
  return this.getWorkItemDetail(id);
3161
3266
  });
3162
3267
  }
@@ -3261,6 +3366,7 @@ export class WorkItemStore {
3261
3366
  runsByWorkItem.get(row.work_item_id).push(mapRun(row));
3262
3367
  }
3263
3368
  return workItems.map(workItem => {
3369
+ if (workItem.schedule) workItem.schedule.lastError = this.getScheduleLastError(workItem.id);
3264
3370
  const actions = actionsByWorkItem.get(workItem.id) || [];
3265
3371
  return graphExecutionState({ ...workItem, executionControl: this.getExecutionControl(workItem.id), actions, runs: runsByWorkItem.get(workItem.id) || [] }, actions);
3266
3372
  });
@@ -3454,6 +3560,7 @@ export class WorkItemStore {
3454
3560
  }
3455
3561
  const workItem = this.getWorkItem(id);
3456
3562
  if (!workItem) return null;
3563
+ if (workItem.schedule?.recurrence) throw new Error('Recurring schedule plans cannot accept Coordinator messages; edit the plan instead');
3457
3564
  if (this.isExecutionStopped(id)) throw new WorkCenterResourceStopError(this.getExecutionControl(id).stopReason);
3458
3565
  if (['done', 'cancelled'].includes(workItem.status)) {
3459
3566
  throw new Error(`WorkItem in ${workItem.status} cannot accept Coordinator messages`);
@@ -4375,8 +4482,11 @@ export class WorkItemStore {
4375
4482
  now,
4376
4483
  id,
4377
4484
  );
4485
+ if (current.schedule?.recurrence && next.goal !== current.goal) {
4486
+ this.db.prepare('UPDATE work_items SET requirement = ? WHERE id = ?').run(next.goal, id);
4487
+ }
4378
4488
  let action = null;
4379
- if (contractChanged) {
4489
+ if (contractChanged && !current.schedule?.recurrence) {
4380
4490
  const updated = this.getWorkItem(id);
4381
4491
  if (isDynamicWorkItem(updated)) {
4382
4492
  this.db.prepare(`UPDATE work_items SET status = 'running', current_action_id = NULL,
@@ -4416,6 +4526,7 @@ export class WorkItemStore {
4416
4526
  now,
4417
4527
  );
4418
4528
  this.db.prepare(`UPDATE work_items SET status = 'cancelled', current_action_id = NULL,
4529
+ schedule_status = CASE WHEN schedule_status IN ('scheduled', 'paused') THEN 'paused' ELSE schedule_status END,
4419
4530
  current_run_id = NULL, updated_at = ? WHERE id = ?`).run(now, id);
4420
4531
  this.appendEvent(id, 'work_item.cancelled');
4421
4532
  return this.getWorkItem(id);
@@ -4426,6 +4537,7 @@ export class WorkItemStore {
4426
4537
  return withTransaction(this.db, () => {
4427
4538
  const workItem = this.getWorkItem(id);
4428
4539
  if (!workItem) return null;
4540
+ if (workItem.schedule?.recurrence) throw new Error('Cannot resume execution of a recurring schedule plan');
4429
4541
  if (!Number.isInteger(expectedRevision) || workItem.revision !== expectedRevision) {
4430
4542
  throw new Error('WorkItem changed before it was resumed; refresh and try again');
4431
4543
  }
@@ -4558,6 +4670,7 @@ export class WorkItemStore {
4558
4670
  return withTransaction(this.db, () => {
4559
4671
  const workItem = this.getWorkItem(id);
4560
4672
  if (!workItem) return null;
4673
+ if (workItem.schedule?.recurrence) throw new Error('Cannot manually start a recurring schedule plan');
4561
4674
  const scheduledAt = Number(options.scheduledAt);
4562
4675
  const scheduledStart = Number.isSafeInteger(scheduledAt);
4563
4676
  if (scheduledStart && (workItem.status !== 'draft' || workItem.schedule?.status !== 'scheduled'
@@ -0,0 +1,21 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ /**
4
+ * Run synchronous database work atomically, including inside a caller-owned
5
+ * transaction. SAVEPOINT works without DatabaseSync.isTransaction (Node 22.5).
6
+ * Releasing an outermost savepoint commits; nested releases never commit the
7
+ * caller's transaction. Unlike BEGIN IMMEDIATE, the write lock is acquired on
8
+ * the first write, so callbacks must not rely on holding it before then.
9
+ */
10
+ export function withTransaction(db, fn) {
11
+ const savepoint = `wc_${randomUUID().replaceAll('-', '')}`;
12
+ db.exec(`SAVEPOINT ${savepoint}`);
13
+ try {
14
+ const result = fn();
15
+ db.exec(`RELEASE ${savepoint}`);
16
+ return result;
17
+ } catch (error) {
18
+ try { db.exec(`ROLLBACK TO ${savepoint}; RELEASE ${savepoint}`); } catch {}
19
+ throw error;
20
+ }
21
+ }