@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/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +230 -125
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/work-center/attachments.js +28 -0
- package/yeaft/work-center/bridge.js +3 -2
- package/yeaft/work-center/controller.js +3 -3
- package/yeaft/work-center/durable-model.js +18 -10
- package/yeaft/work-center/projection.js +27 -3
- package/yeaft/work-center/recurrence.js +103 -0
- package/yeaft/work-center/resource-control.js +2 -4
- package/yeaft/work-center/service.js +30 -3
- package/yeaft/work-center/store.js +137 -24
- package/yeaft/work-center/transaction.js +21 -0
|
Binary file
|
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
-
},
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 :
|
|
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
|
|
749
|
-
|
|
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
|
|