@yeaft/webchat-agent 1.0.566 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.566",
3
+ "version": "1.0.568",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -392,6 +392,34 @@ function resolveAttachmentPath(root, workItemId, attachment) {
392
392
  return { filePath: actualPath, size: stat.size, itemDirectory: itemRoot };
393
393
  }
394
394
 
395
+ /** Copy verified bytes into a new owner directory; never share source paths. */
396
+ export function cloneWorkItemAttachments(workItem, workItemId, options = {}) {
397
+ if (!workItem.attachments?.length) return [];
398
+ const state = openAttachmentDirectory(options.root, workItem.id);
399
+ try {
400
+ const files = workItem.attachments.map(attachment => {
401
+ const storageName = attachment.storageName;
402
+ if (typeof storageName !== 'string' || !/^[A-Za-z0-9_-]+(?:\.[a-z0-9]{1,10})?$/.test(storageName)) {
403
+ throw new Error('WorkItem attachment metadata is invalid');
404
+ }
405
+ assertDescriptorMatchesPath(state.rootDescriptor, state.attachmentRoot, 'WorkItem attachment root');
406
+ assertDescriptorMatchesPath(state.itemDescriptor, state.itemDirectory, 'WorkItem attachment owner directory');
407
+ const fd = openSync(`/proc/self/fd/${state.itemDescriptor}/${storageName}`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
408
+ try {
409
+ const stat = fstatSync(fd);
410
+ if (!stat.isFile()) throw new Error('WorkItem attachment is not a regular file');
411
+ assertWorkItemAttachmentSize(stat.size);
412
+ const buffer = readFileSync(fd);
413
+ if (buffer.length !== Number(attachment.size) || digest(buffer) !== attachment.sha256) {
414
+ throw new Error('WorkItem attachment changed after creation');
415
+ }
416
+ return { name: attachment.name, mimeType: attachment.mimeType, data: buffer.toString('base64') };
417
+ } finally { closeSync(fd); }
418
+ });
419
+ return persistWorkItemAttachments(files, { root: options.root, workItemId });
420
+ } finally { closeDirectoryState(state); }
421
+ }
422
+
395
423
  export function readWorkItemAttachment(workItem, attachmentId, options = {}) {
396
424
  const attachment = Array.isArray(workItem?.attachments)
397
425
  ? workItem.attachments.find(item => item?.id === attachmentId)
@@ -21,7 +21,7 @@ let serviceFactory = null;
21
21
  let featureEnabled = false;
22
22
 
23
23
  const BROWSER_DETAIL_OPS = new Set([
24
- 'get', 'create', 'update', 'start', 'cancel', 'resume', 'extend_budget', 'post_work_item_message', 'action_input', 'retry_action', 'guide', 'retry',
24
+ 'get', 'create', 'update', 'update_schedule', 'start', 'cancel', 'resume', 'extend_budget', 'post_work_item_message', 'action_input', 'retry_action', 'guide', 'retry',
25
25
  ]);
26
26
  const BROWSER_ACTION_DEBUG_OPS = new Set(['get_action_messages', 'get_action_requests', 'get_action_request']);
27
27
  // `files` is an internal server-to-Agent field. The browser relay rejects any
@@ -29,7 +29,7 @@ const BROWSER_ACTION_DEBUG_OPS = new Set(['get_action_messages', 'get_action_req
29
29
  const BROWSER_FILE_FIELDS = Object.freeze({
30
30
  create: [
31
31
  'title', 'titleSource', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'deliveryTarget', 'deliveryInstructions',
32
- 'reuseMemory', 'files', 'start',
32
+ 'reuseMemory', 'files', 'start', 'scheduledFor', 'scheduleEnabled', 'recurrence',
33
33
  ],
34
34
  post_work_item_message: [
35
35
  'id', 'clientMessageId', 'text', 'target', 'revision', 'planRevision', 'ledgerRevision',
@@ -96,6 +96,7 @@ async function getSettingsRuntime() {
96
96
  defaultWorkDir: ctx.CONFIG?.workDir || process.cwd(),
97
97
  workItemAttachments: Array.isArray(ctx.agentCapabilities)
98
98
  && ctx.agentCapabilities.includes('work_item_attachments'),
99
+ recurringSchedules: true,
99
100
  defaultStageInstructions: defaultWorkCenterStageInstructions(),
100
101
  };
101
102
  }
@@ -164,8 +164,8 @@ export class WorkflowController {
164
164
  return detail;
165
165
  }
166
166
 
167
- startScheduled(id, scheduledAt) {
168
- return this.store.startWorkItemAtomic(id, workItem => {
167
+ startScheduled(id, scheduledAt, cloneAttachments = null) {
168
+ return this.store.dispatchScheduledWorkItem(id, scheduledAt, workItem => {
169
169
  const action = initialActionFor(workItem);
170
170
  if (workItem.reuseMemory === false) return action;
171
171
  const context = this.store.getReusableContext(workItem.workDir, workItem.id);
@@ -174,7 +174,7 @@ export class WorkflowController {
174
174
  context,
175
175
  instruction: actionInstruction(action, workItem, context, renderSessionContextSnapshot(workItem.sessionContext)),
176
176
  };
177
- }, { scheduledAt });
177
+ }, cloneAttachments);
178
178
  }
179
179
 
180
180
  update(id, patch) {
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
+ import { withTransaction } from './transaction.js';
2
3
 
3
- export const WORK_CENTER_SCHEMA_VERSION = 41;
4
+ export const WORK_CENTER_SCHEMA_VERSION = 42;
4
5
 
5
6
  const MIGRATIONS = [
6
7
  ['23-conversation-stream', migrateConversationStream],
@@ -22,6 +23,7 @@ const MIGRATIONS = [
22
23
  ['39-action-creation-source', migrateActionCreationSource],
23
24
  ['40-work-item-schedules', migrateWorkItemSchedules],
24
25
  ['41-delivery-instructions', migrateDeliveryInstructions],
26
+ ['42-recurring-schedules', migrateRecurringSchedules],
25
27
  ];
26
28
 
27
29
  const MIGRATION_ALIASES = new Map([
@@ -83,15 +85,7 @@ function runMigration(db, now, name, migration) {
83
85
  db.prepare(`INSERT INTO schema_migrations(name, checksum, applied_at)
84
86
  VALUES (?, ?, ?)`).run(name, checksum, now);
85
87
  };
86
- if (db.isTransaction) return apply();
87
- db.exec('BEGIN IMMEDIATE');
88
- try {
89
- apply();
90
- db.exec('COMMIT');
91
- } catch (error) {
92
- try { db.exec('ROLLBACK'); } catch {}
93
- throw error;
94
- }
88
+ return withTransaction(db, apply);
95
89
  }
96
90
 
97
91
  export function migrateDurableWorkCenterModel(db, now = Date.now(), sourceSchemaVersion = 22) {
@@ -568,6 +562,20 @@ function migrateActionClosureAndOutputs(db) {
568
562
  `);
569
563
  }
570
564
 
565
+ function migrateRecurringSchedules(db) {
566
+ for (const [column, definition] of [
567
+ ['schedule_recurrence', 'TEXT'],
568
+ ['schedule_run_count', 'INTEGER NOT NULL DEFAULT 0'],
569
+ ['schedule_last_work_item_id', 'TEXT'],
570
+ ['source_schedule_id', 'TEXT'],
571
+ ['scheduled_occurrence_at', 'INTEGER'],
572
+ ]) {
573
+ if (!hasColumn(db, 'work_items', column)) db.exec(`ALTER TABLE work_items ADD COLUMN ${column} ${definition}`);
574
+ }
575
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_schedule_occurrence
576
+ ON work_items(source_schedule_id, scheduled_occurrence_at) WHERE source_schedule_id IS NOT NULL`);
577
+ }
578
+
571
579
  function migrateWorkItemSchedules(db) {
572
580
  for (const [column, definition] of [
573
581
  ['schedule_status', 'TEXT'],
@@ -27,6 +27,24 @@ const MAX_HISTORICAL_BRIEF_CHARS = 256;
27
27
  const MAX_CURRENT_BRIEF_BYTES = 8 * 1024;
28
28
  export const MAX_WORK_ITEM_BROWSER_DTO_BYTES = 512 * 1024;
29
29
 
30
+ function projectSchedule(schedule) {
31
+ if (!schedule) return null;
32
+ return {
33
+ status: schedule.status,
34
+ scheduledFor: schedule.scheduledFor ?? null,
35
+ triggeredAt: schedule.triggeredAt ?? null,
36
+ recurrence: schedule.recurrence || null,
37
+ runCount: count(schedule.runCount),
38
+ lastWorkItemId: schedule.lastWorkItemId || null,
39
+ // Do not forward caller-provided diagnostic text, stack, paths or metadata.
40
+ lastError: schedule.lastError ? {
41
+ code: 'schedule_dispatch_failed',
42
+ message: 'Scheduled execution could not start. The plan will retry automatically; check its configuration and attachments.',
43
+ at: count(schedule.lastError.at),
44
+ } : null,
45
+ };
46
+ }
47
+
30
48
  function jsonByteLength(value) {
31
49
  return Buffer.byteLength(JSON.stringify(value), 'utf8');
32
50
  }
@@ -1094,7 +1112,9 @@ export function projectWorkItemDetail(detail, options = {}) {
1094
1112
  executionControl: detail.executionControl,
1095
1113
  executionStats: combinedExecutionStats(detail),
1096
1114
  reuseMemory: detail.reuseMemory !== false,
1097
- schedule: detail.schedule || null,
1115
+ schedule: projectSchedule(detail.schedule),
1116
+ sourceScheduleId: detail.sourceScheduleId || null,
1117
+ scheduledOccurrenceAt: detail.scheduledOccurrenceAt ?? null,
1098
1118
  deliveryTarget: ['response', 'workspace_files', 'pull_request', 'merge'].includes(detail.deliveryTarget)
1099
1119
  ? detail.deliveryTarget : null,
1100
1120
  deliveryInstructions: truncateUtf8(detail.deliveryInstructions || '', 2 * 1024),
@@ -1206,7 +1226,9 @@ export function projectWorkItemSummary(detail) {
1206
1226
  executionStats: combinedExecutionStats(detail),
1207
1227
  executionControl: detail.executionControl,
1208
1228
  origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
1209
- schedule: detail.schedule || null,
1229
+ schedule: projectSchedule(detail.schedule),
1230
+ sourceScheduleId: detail.sourceScheduleId || null,
1231
+ scheduledOccurrenceAt: detail.scheduledOccurrenceAt ?? null,
1210
1232
  linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
1211
1233
  attachmentCount: Array.isArray(detail.attachments) ? detail.attachments.length : 0,
1212
1234
  createdAt: detail.createdAt,
@@ -1247,7 +1269,9 @@ export function projectWorkItemSummary(detail) {
1247
1269
  currentAction: projectCurrentActionSummary(action, projectedAction),
1248
1270
  actionStats: projectActionStats(detail, null),
1249
1271
  origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
1250
- schedule: detail.schedule || null,
1272
+ schedule: projectSchedule(detail.schedule),
1273
+ sourceScheduleId: detail.sourceScheduleId || null,
1274
+ scheduledOccurrenceAt: detail.scheduledOccurrenceAt ?? null,
1251
1275
  linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
1252
1276
  attachmentCount: Array.isArray(detail.attachments) ? detail.attachments.length : 0,
1253
1277
  createdAt: detail.createdAt,
@@ -0,0 +1,103 @@
1
+ // Calendar computation is pure and independent of dispatch / Agent local timezone.
2
+ // Keep arithmetic and Intl in a bounded, supported date range (1970–2099).
3
+ export const MAX_SCHEDULE_TIMESTAMP = Date.UTC(2100, 0, 1) - 1;
4
+ const DAY = 86_400_000;
5
+
6
+ export function validateScheduleTimestamp(value, name = 'scheduledFor') {
7
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_SCHEDULE_TIMESTAMP) {
8
+ throw new Error(`${name} must be epoch milliseconds between 1970 and 2099`);
9
+ }
10
+ return value;
11
+ }
12
+
13
+ export function normalizeRecurrence(value) {
14
+ if (value == null) return null;
15
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid recurrence');
16
+ const allowed = ['frequency', 'timeZone', 'time', 'weekdays', 'dayOfMonth', 'endsAt', 'maxRuns'];
17
+ if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error('Unknown recurrence field');
18
+ if (!['daily', 'weekdays', 'weekly', 'monthly'].includes(value.frequency)) throw new Error('Invalid recurrence frequency');
19
+ if (typeof value.timeZone !== 'string' || value.timeZone.length > 100 || /^[+-]/.test(value.timeZone)) throw new Error('Invalid recurrence timeZone');
20
+ try { new Intl.DateTimeFormat('en', { timeZone: value.timeZone }); } catch { throw new Error('Invalid recurrence timeZone'); }
21
+ if (typeof value.time !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(value.time)) throw new Error('Invalid recurrence time');
22
+ if (value.weekdays !== undefined && (!Array.isArray(value.weekdays) || value.weekdays.length > 7
23
+ || value.weekdays.some(day => !Number.isInteger(day) || day < 0 || day > 6)
24
+ || new Set(value.weekdays).size !== value.weekdays.length)) throw new Error('Invalid recurrence weekdays');
25
+ if (value.frequency === 'weekly' && !value.weekdays?.length) throw new Error('Weekly recurrence requires weekdays');
26
+ if (value.dayOfMonth !== undefined && (!Number.isInteger(value.dayOfMonth) || value.dayOfMonth < 1 || value.dayOfMonth > 31)) throw new Error('Invalid recurrence dayOfMonth');
27
+ if (value.frequency === 'monthly' && value.dayOfMonth === undefined) throw new Error('Monthly recurrence requires dayOfMonth');
28
+ if (value.endsAt != null) validateScheduleTimestamp(value.endsAt, 'endsAt');
29
+ if (value.maxRuns != null && (!Number.isInteger(value.maxRuns) || value.maxRuns < 1 || value.maxRuns > 1000)) throw new Error('Invalid recurrence maxRuns');
30
+ return { frequency: value.frequency, timeZone: value.timeZone, time: value.time,
31
+ ...(value.weekdays !== undefined ? { weekdays: [...value.weekdays].sort() } : {}),
32
+ ...(value.dayOfMonth !== undefined ? { dayOfMonth: value.dayOfMonth } : {}),
33
+ endsAt: value.endsAt ?? null, maxRuns: value.maxRuns ?? null };
34
+ }
35
+
36
+ function calendar(recurrence) {
37
+ const formatter = new Intl.DateTimeFormat('en-GB', { timeZone: recurrence.timeZone,
38
+ year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' });
39
+ const parts = timestamp => Object.fromEntries(formatter.formatToParts(timestamp)
40
+ .filter(part => part.type !== 'literal').map(part => [part.type, Number(part.value)]));
41
+ const localEpoch = timestamp => {
42
+ const p = parts(timestamp);
43
+ return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
44
+ };
45
+ return { parts, localEpoch };
46
+ }
47
+
48
+ // Resolve a wall time by trying nearby UTC offsets. Gaps have no matching instant;
49
+ // folds select the earlier instant, so a local date can never execute twice.
50
+ function wallInstant(date, recurrence, localEpoch) {
51
+ const [hour, minute] = recurrence.time.split(':').map(Number);
52
+ const wall = date + hour * 3_600_000 + minute * 60_000;
53
+ const candidates = new Set();
54
+ for (let hours = -36; hours <= 36; hours += 6) {
55
+ const probe = wall + hours * 3_600_000;
56
+ const candidate = wall - (localEpoch(probe) - probe);
57
+ if (localEpoch(candidate) === wall) candidates.add(candidate);
58
+ }
59
+ return candidates.size ? Math.min(...candidates) : null;
60
+ }
61
+
62
+ function matchesDate(date, recurrence) {
63
+ const d = new Date(date);
64
+ const weekday = d.getUTCDay();
65
+ if (recurrence.frequency === 'weekdays') return weekday > 0 && weekday < 6;
66
+ if (recurrence.frequency === 'weekly') return recurrence.weekdays.includes(weekday);
67
+ if (recurrence.frequency === 'monthly') {
68
+ const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
69
+ return d.getUTCDate() === Math.min(recurrence.dayOfMonth, last);
70
+ }
71
+ return true;
72
+ }
73
+
74
+ function findOccurrence(recurrence, timestamp, direction) {
75
+ validateScheduleTimestamp(timestamp);
76
+ const { parts, localEpoch } = calendar(recurrence);
77
+ const p = parts(timestamp);
78
+ const date = Date.UTC(p.year, p.month - 1, p.day);
79
+ // Monthly schedules need at most 62 days even around a skipped civil date.
80
+ // A hard bound also protects dispatch from malformed persisted data.
81
+ for (let day = 0; day < 370; day++) {
82
+ const candidateDate = date + day * direction * DAY;
83
+ if (!matchesDate(candidateDate, recurrence)) continue;
84
+ const candidate = wallInstant(candidateDate, recurrence, localEpoch);
85
+ if (candidate == null || candidate < 0 || candidate > MAX_SCHEDULE_TIMESTAMP) continue;
86
+ if (direction > 0 ? candidate > timestamp : candidate <= timestamp) return candidate;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ export function nextOccurrence(recurrence, after) {
92
+ return findOccurrence(recurrence, after, 1);
93
+ }
94
+
95
+ export function latestOccurrence(recurrence, at) {
96
+ return findOccurrence(recurrence, at, -1);
97
+ }
98
+
99
+ export function initialOccurrence(recurrence, scheduledFor) {
100
+ validateScheduleTimestamp(scheduledFor);
101
+ if (!recurrence || latestOccurrence(recurrence, scheduledFor) === scheduledFor) return scheduledFor;
102
+ return nextOccurrence(recurrence, scheduledFor);
103
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
+ import { withTransaction } from './transaction.js';
2
3
  import { LLMAdapter } from '../llm/adapter.js';
3
4
  import { normalizeTokenUsage } from '../llm/usage-accounting.js';
4
5
 
@@ -88,10 +89,7 @@ export class WorkCenterResourceControl {
88
89
  }
89
90
 
90
91
  atomic(fn) {
91
- if (this.db.isTransaction) return fn();
92
- this.db.exec('BEGIN IMMEDIATE');
93
- try { const result = fn(); this.db.exec('COMMIT'); return result; }
94
- catch (error) { this.db.exec('ROLLBACK'); throw error; }
92
+ return withTransaction(this.db, fn);
95
93
  }
96
94
 
97
95
  ensure(id) {
@@ -1,3 +1,4 @@
1
+ import { normalizeRecurrence, validateScheduleTimestamp } from './recurrence.js';
1
2
  import { realpathSync, statSync } from 'node:fs';
2
3
  import { join, resolve } from 'node:path';
3
4
  import { randomUUID } from 'node:crypto';
@@ -6,6 +7,7 @@ import { WorkflowController } from './controller.js';
6
7
  import { WorkItemWatcher } from './watcher.js';
7
8
  import {
8
9
  appendWorkItemAttachments,
10
+ cloneWorkItemAttachments,
9
11
  persistWorkItemAttachments,
10
12
  readWorkItemAttachment,
11
13
  removeWorkItemAttachmentFiles,
@@ -114,6 +116,7 @@ export class WorkCenterService {
114
116
  ...(await this.runtimeInfoProvider()),
115
117
  defaultStageInstructions: defaultWorkCenterStageInstructions(),
116
118
  workItemTypes: listWorkItemTypeTemplates(settings),
119
+ recurringSchedules: true,
117
120
  };
118
121
  };
119
122
  this.ownerBootId = options.ownerBootId || randomUUID();
@@ -255,10 +258,14 @@ export class WorkCenterService {
255
258
  workItemId,
256
259
  });
257
260
  const scheduledFor = payload.scheduledFor == null || payload.scheduledFor === ''
258
- ? null : Number(payload.scheduledFor);
261
+ ? null : payload.scheduledFor;
259
262
  if (scheduledFor != null && (!Number.isSafeInteger(scheduledFor) || scheduledFor <= this.now())) {
260
263
  throw new Error('scheduledFor must be in the future');
261
264
  }
265
+ if (scheduledFor != null) validateScheduleTimestamp(scheduledFor);
266
+ if (payload.scheduleEnabled !== undefined && typeof payload.scheduleEnabled !== 'boolean') throw new Error('scheduleEnabled must be a boolean');
267
+ const recurrence = normalizeRecurrence(payload.recurrence);
268
+ if (recurrence && scheduledFor == null) throw new Error('recurrence requires scheduledFor');
262
269
  const shouldStart = scheduledFor == null
263
270
  && (payload.start === undefined ? settings.startImmediately : payload.start !== false);
264
271
  const goal = requiredString(payload.goal, 'goal');
@@ -308,6 +315,7 @@ export class WorkCenterService {
308
315
  schedule: scheduledFor == null ? null : {
309
316
  status: payload.scheduleEnabled === false ? 'paused' : 'scheduled',
310
317
  scheduledFor,
318
+ recurrence,
311
319
  },
312
320
  start: false,
313
321
  });
@@ -745,8 +753,27 @@ export class WorkCenterService {
745
753
  #scanSchedules() {
746
754
  const now = this.now();
747
755
  for (const id of this.store.listDueScheduledWorkItemIds(now)) {
748
- const detail = this.controller.startScheduled(id, now);
749
- if (detail) this.#emit({ type: 'work_item.schedule_triggered', workItem: detail });
756
+ const createdAttachmentOwners = [];
757
+ try {
758
+ const before = this.store.getWorkItem(id);
759
+ const detail = this.controller.startScheduled(id, now, (source, occurrenceId) => {
760
+ const attachments = cloneWorkItemAttachments(source, occurrenceId, { root: this.attachmentRoot });
761
+ createdAttachmentOwners.push(occurrenceId);
762
+ return attachments;
763
+ });
764
+ const source = this.store.getWorkItemDetail(id);
765
+ if (detail) this.#emit({ type: 'work_item.schedule_triggered', workItem: source });
766
+ else if (source && source.revision !== before?.revision) {
767
+ this.#emit({ type: 'work_item.schedule_advanced', workItem: source });
768
+ }
769
+ if (detail && detail.id !== id) this.#emit({ type: 'work_item.created', workItem: detail });
770
+ } catch {
771
+ for (const owner of createdAttachmentOwners) {
772
+ if (!this.store.getWorkItem(owner)) removeWorkItemAttachments(this.attachmentRoot, owner);
773
+ }
774
+ const workItem = this.store.recordScheduleFailure(id);
775
+ if (workItem) this.#emit({ type: 'work_item.schedule_failed', workItem });
776
+ }
750
777
  }
751
778
  }
752
779