@kafca/agentdock 0.1.63 → 0.1.65
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/README.md +4 -0
- package/dist/renderer/assets/{AutomationList-DR-micIT.js → AutomationList-fCves7JJ.js} +1 -1
- package/dist/renderer/assets/{Badge-COE1Zm2i.js → Badge-03CgzC3O.js} +1 -1
- package/dist/renderer/assets/{Card-F6UNFxnQ.js → Card-BG4attR1.js} +1 -1
- package/dist/renderer/assets/{Config-BKqxfhZi.js → Config-DLmfH4Fs.js} +1 -1
- package/dist/renderer/assets/{Dashboard-Bvp7-_2z.js → Dashboard-tmYu-275.js} +2 -2
- package/dist/renderer/assets/{EmptyState-BwV_J0Og.js → EmptyState-D3nq3XaN.js} +1 -1
- package/dist/renderer/assets/{HighlightedMarkdown-CBIBaWyM.js → HighlightedMarkdown-DFiXk_8l.js} +1 -1
- package/dist/renderer/assets/{Input-C8LZd4Bi.js → Input-BuQ3SusB.js} +1 -1
- package/dist/renderer/assets/{KnowledgeDetail-DjXYNMIs.js → KnowledgeDetail-2k6D-FBu.js} +1 -1
- package/dist/renderer/assets/{KnowledgeHome-Cqk5_nJh.js → KnowledgeHome-Bv3_riqS.js} +1 -1
- package/dist/renderer/assets/{Logs-B6cXFvW4.js → Logs-DdDCHMXT.js} +1 -1
- package/dist/renderer/assets/{Modal-CAj7soll.js → Modal-BOpQurFi.js} +1 -1
- package/dist/renderer/assets/{Page-DlnMnu4F.js → Page-DAZBFNqx.js} +1 -1
- package/dist/renderer/assets/{Select-CVay_LUX.js → Select-CjT5-m7R.js} +1 -1
- package/dist/renderer/assets/{ThreadChat-BxXRpYa1.js → ThreadChat-Dd1d35z9.js} +3 -3
- package/dist/renderer/assets/{Workspace-YC0pC7cM.js → Workspace-CMHdpXa7.js} +1 -1
- package/dist/renderer/assets/{arrow-left-Cwm09dxx.js → arrow-left-DTisCSrr.js} +1 -1
- package/dist/renderer/assets/{book-open-BwcGC_im.js → book-open-DGOtHxDK.js} +1 -1
- package/dist/renderer/assets/{channels-BPytmVCd.js → channels-DTwVsSq8.js} +1 -1
- package/dist/renderer/assets/{chevron-down-2LP9oGHG.js → chevron-down-Dw5IfnsR.js} +1 -1
- package/dist/renderer/assets/{index-llCPCiYk.js → index-CDPdiGc6.js} +1 -1
- package/dist/renderer/assets/{index-eoVERL5K.js → index-jESlfFTY.js} +2 -2
- package/dist/renderer/assets/{knowledge-QujAv-Zh.js → knowledge-DnebW2tM.js} +1 -1
- package/dist/renderer/assets/{pencil-0P2INETc.js → pencil-BBgjg0RK.js} +1 -1
- package/dist/renderer/assets/{plus-tiGRgE2v.js → plus-C5OXMCrc.js} +1 -1
- package/dist/renderer/assets/{save-D8U93DNa.js → save-B9-o4ceK.js} +1 -1
- package/dist/renderer/assets/{search-C-9J9J3f.js → search-CetWQ9yl.js} +1 -1
- package/dist/renderer/assets/{shield-check-BCKkPPTJ.js → shield-check-Dts9cJZL.js} +1 -1
- package/dist/renderer/assets/{threads-Bsrwwnpm.js → threads-Co4JiiXY.js} +1 -1
- package/dist/renderer/assets/{trash-2-CP4sn15n.js → trash-2-CcjYdBI8.js} +1 -1
- package/dist/renderer/index.html +1 -1
- package/dist-electron/services/local-ai-core/src/acp/store/automation-store.js +69 -3
- package/dist-electron/services/local-ai-core/src/acp/store/local-core-acp-store.js +14 -2
- package/dist-electron/services/local-ai-core/src/acp/store/runtime-config-store.js +20 -6
- package/dist-electron/services/local-ai-core/src/automation/automation-monitor-service.js +5 -3
- package/dist-electron/services/local-ai-core/src/automation/automation-service.js +13 -4
- package/dist-electron/services/local-ai-core/src/automation/automation-trigger-engine.js +19 -18
- package/dist-electron/services/local-ai-core/src/automation/legacy-automation-mappers.js +25 -1
- package/dist-electron/services/local-ai-core/src/kernel/bootstrap.js +15 -0
- package/dist-electron/services/local-ai-core/src/runtime/local-core-controller.js +17 -3
- package/dist-electron/services/local-ai-core/src/scheduler/cron.js +197 -0
- package/dist-electron/services/local-ai-core/src/scheduler/scheduled-job-application-service.js +4 -3
- package/dist-electron/services/local-ai-core/src/scheduler/scheduler-service.js +3 -3
- package/dist-electron/tests/electron/automation-compatibility.test.js +6 -1
- package/dist-electron/tests/electron/automation-engine.test.js +67 -3
- package/dist-electron/tests/electron/automation-store.test.js +189 -0
- package/package.json +1 -1
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.compileCronExpression = compileCronExpression;
|
|
4
|
+
exports.extractFieldsInTimezone = extractFieldsInTimezone;
|
|
5
|
+
exports.cronMatchesFieldsDay = cronMatchesFieldsDay;
|
|
4
6
|
exports.cronMatchesFields = cronMatchesFields;
|
|
5
7
|
exports.cronMatchesDate = cronMatchesDate;
|
|
6
8
|
exports.findNextCronMatchUtc = findNextCronMatchUtc;
|
|
7
9
|
exports.findPreviousCronMatchUtc = findPreviousCronMatchUtc;
|
|
10
|
+
exports.findNextCronMatchInTimezone = findNextCronMatchInTimezone;
|
|
11
|
+
exports.findPreviousCronMatchInTimezone = findPreviousCronMatchInTimezone;
|
|
8
12
|
exports.floorToMinute = floorToMinute;
|
|
13
|
+
exports.isValidTimezone = isValidTimezone;
|
|
14
|
+
exports.assertSupportedTimezone = assertSupportedTimezone;
|
|
9
15
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
16
|
const GREGORIAN_CYCLE_DAYS = 146_097;
|
|
11
17
|
const MIN_DATE_MS = -8_640_000_000_000_000;
|
|
@@ -25,6 +31,62 @@ function compileCronExpression(expression) {
|
|
|
25
31
|
daysOfWeek: compileField(normalized, 'day-of-week', fields[4], 0, 6),
|
|
26
32
|
});
|
|
27
33
|
}
|
|
34
|
+
function extractFieldsInTimezone(date, timezone) {
|
|
35
|
+
return stripYear(extractWallClock(date, timezone));
|
|
36
|
+
}
|
|
37
|
+
function extractWallClock(date, timezone) {
|
|
38
|
+
const parts = getFormatter(timezone).formatToParts(date);
|
|
39
|
+
const map = {};
|
|
40
|
+
for (const part of parts) {
|
|
41
|
+
if (part.type !== 'literal')
|
|
42
|
+
map[part.type] = part.value;
|
|
43
|
+
}
|
|
44
|
+
const weekday = String(map.weekday ?? '').toLowerCase();
|
|
45
|
+
const dayOfWeek = WEEKDAY_SHORT_TO_NUMBER[weekday];
|
|
46
|
+
return {
|
|
47
|
+
year: Number(map.year ?? '1970'),
|
|
48
|
+
minute: Number(map.minute ?? '0'),
|
|
49
|
+
hour: Number(map.hour ?? '0'),
|
|
50
|
+
dayOfMonth: Number(map.day ?? '1'),
|
|
51
|
+
month: Number(map.month ?? '1'),
|
|
52
|
+
dayOfWeek: dayOfWeek ?? 0,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function stripYear(fields) {
|
|
56
|
+
return {
|
|
57
|
+
minute: fields.minute,
|
|
58
|
+
hour: fields.hour,
|
|
59
|
+
dayOfMonth: fields.dayOfMonth,
|
|
60
|
+
month: fields.month,
|
|
61
|
+
dayOfWeek: fields.dayOfWeek,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// Convert a wall-clock time (Y/M/D h:m) in `timezone` to the matching UTC instant.
|
|
65
|
+
// Returns null when that wall clock never exists on that day (DST spring-forward gap),
|
|
66
|
+
// so callers can treat the slot as non-matching.
|
|
67
|
+
function wallClockToUtc(year, month, dayOfMonth, hour, minute, timezone) {
|
|
68
|
+
const targetWallMs = Date.UTC(year, month - 1, dayOfMonth, hour, minute, 0, 0);
|
|
69
|
+
let utc = targetWallMs;
|
|
70
|
+
for (let i = 0; i < 5; i += 1) {
|
|
71
|
+
const f = extractWallClock(new Date(utc), timezone);
|
|
72
|
+
const wallAsUtc = Date.UTC(f.year, f.month - 1, f.dayOfMonth, f.hour, f.minute, 0, 0);
|
|
73
|
+
if (wallAsUtc === targetWallMs) {
|
|
74
|
+
// Round-trip verified — this instant really is the requested wall clock in `timezone`.
|
|
75
|
+
return utc;
|
|
76
|
+
}
|
|
77
|
+
// Fixed-point step: shift utc by the gap between where its wall clock lands and the target.
|
|
78
|
+
utc = utc - wallAsUtc + targetWallMs;
|
|
79
|
+
}
|
|
80
|
+
// Did not converge to a self-consistent wall clock: the requested time is in a DST gap.
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
// Day-level match: day-of-month AND month AND day-of-week (cron DOM+DOW AND semantics).
|
|
84
|
+
// Hour and minute are matched separately once a wall-clock day qualifies.
|
|
85
|
+
function cronMatchesFieldsDay(compiled, fields) {
|
|
86
|
+
return compiled.daysOfMonth.includes(fields.dayOfMonth)
|
|
87
|
+
&& compiled.months.includes(fields.month)
|
|
88
|
+
&& compiled.daysOfWeek.includes(fields.dayOfWeek);
|
|
89
|
+
}
|
|
28
90
|
function cronMatchesFields(compiled, fields) {
|
|
29
91
|
// Preserve the legacy scheduler's DOM+DOW AND behavior.
|
|
30
92
|
return compiled.minutes.includes(fields.minute)
|
|
@@ -107,6 +169,84 @@ function findPreviousCronMatchUtc(compiled, atOrBefore, afterExclusive) {
|
|
|
107
169
|
}
|
|
108
170
|
return { date: null, inspectedDays: GREGORIAN_CYCLE_DAYS + 1 };
|
|
109
171
|
}
|
|
172
|
+
// Timezone-aware variants: iterate in the target timezone's wall clock rather than UTC.
|
|
173
|
+
// These replace the UTC-only search used by the activation engine when timezone !== 'UTC'.
|
|
174
|
+
function findNextCronMatchInTimezone(compiled, after, timezone) {
|
|
175
|
+
assertSupportedTimezone(timezone);
|
|
176
|
+
const afterMs = validDateMs(after, 'cron search date');
|
|
177
|
+
const startFields = extractWallClock(after, timezone);
|
|
178
|
+
// Begin at the start of the current wall-clock day so a later match on the same day is found.
|
|
179
|
+
const startDay = wallClockToUtc(startFields.year, startFields.month, startFields.dayOfMonth, 0, 0, timezone);
|
|
180
|
+
let dayMs = startDay ?? startOfUtcDay(Date.UTC(startFields.year, startFields.month - 1, startFields.dayOfMonth, 0, 0, 0, 0));
|
|
181
|
+
for (let inspectedDays = 1; inspectedDays <= GREGORIAN_CYCLE_DAYS + 1; inspectedDays += 1) {
|
|
182
|
+
if (dayMs > MAX_DATE_MS)
|
|
183
|
+
return { date: null, inspectedDays };
|
|
184
|
+
const dayFields = extractWallClock(new Date(dayMs), timezone);
|
|
185
|
+
if (cronMatchesFieldsDay(compiled, stripYear(dayFields))) {
|
|
186
|
+
for (const hour of compiled.hours) {
|
|
187
|
+
for (const minute of compiled.minutes) {
|
|
188
|
+
const candidateMs = wallClockToUtc(dayFields.year, dayFields.month, dayFields.dayOfMonth, hour, minute, timezone);
|
|
189
|
+
if (candidateMs !== null && candidateMs > afterMs && candidateMs <= MAX_DATE_MS) {
|
|
190
|
+
return { date: new Date(candidateMs), inspectedDays };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const nextDay = wallClockToUtc(dayFields.year, dayFields.month, dayFields.dayOfMonth + 1, 0, 0, timezone);
|
|
196
|
+
// A null here only means the next wall-clock midnight is in a DST gap (a handful of
|
|
197
|
+
// zones transition at midnight). Fall back to 24 h later — the loop extracts the wall-clock
|
|
198
|
+
// day from dayMs itself, so a one-day-probe offset cannot drop or duplicate a match.
|
|
199
|
+
if (nextDay === null) {
|
|
200
|
+
dayMs += DAY_MS;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
dayMs = nextDay;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { date: null, inspectedDays: GREGORIAN_CYCLE_DAYS + 1 };
|
|
207
|
+
}
|
|
208
|
+
function findPreviousCronMatchInTimezone(compiled, atOrBefore, timezone, afterExclusive) {
|
|
209
|
+
assertSupportedTimezone(timezone);
|
|
210
|
+
const atOrBeforeMs = validDateMs(atOrBefore, 'cron search date');
|
|
211
|
+
const afterExclusiveMs = afterExclusive ? validDateMs(afterExclusive, 'cron search lower bound') : undefined;
|
|
212
|
+
if (afterExclusiveMs !== undefined && afterExclusiveMs >= atOrBeforeMs) {
|
|
213
|
+
return { date: null, inspectedDays: 0 };
|
|
214
|
+
}
|
|
215
|
+
const upperFields = extractWallClock(atOrBefore, timezone);
|
|
216
|
+
const upperDay = wallClockToUtc(upperFields.year, upperFields.month, upperFields.dayOfMonth, 0, 0, timezone);
|
|
217
|
+
let dayMs = upperDay ?? startOfUtcDay(Date.UTC(upperFields.year, upperFields.month - 1, upperFields.dayOfMonth, 0, 0, 0, 0));
|
|
218
|
+
for (let inspectedDays = 1; inspectedDays <= GREGORIAN_CYCLE_DAYS + 1; inspectedDays += 1) {
|
|
219
|
+
if (dayMs < MIN_DATE_MS)
|
|
220
|
+
return { date: null, inspectedDays };
|
|
221
|
+
if (afterExclusiveMs !== undefined && dayMs <= afterExclusiveMs) {
|
|
222
|
+
return { date: null, inspectedDays: inspectedDays - 1 };
|
|
223
|
+
}
|
|
224
|
+
const dayFields = extractWallClock(new Date(dayMs), timezone);
|
|
225
|
+
if (cronMatchesFieldsDay(compiled, stripYear(dayFields))) {
|
|
226
|
+
for (let hourIndex = compiled.hours.length - 1; hourIndex >= 0; hourIndex -= 1) {
|
|
227
|
+
for (let minuteIndex = compiled.minutes.length - 1; minuteIndex >= 0; minuteIndex -= 1) {
|
|
228
|
+
const candidateMs = wallClockToUtc(dayFields.year, dayFields.month, dayFields.dayOfMonth, compiled.hours[hourIndex], compiled.minutes[minuteIndex], timezone);
|
|
229
|
+
if (candidateMs !== null
|
|
230
|
+
&& candidateMs <= atOrBeforeMs
|
|
231
|
+
&& candidateMs >= MIN_DATE_MS
|
|
232
|
+
&& (afterExclusiveMs === undefined || candidateMs > afterExclusiveMs)) {
|
|
233
|
+
return { date: new Date(candidateMs), inspectedDays };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const prevDay = wallClockToUtc(dayFields.year, dayFields.month, dayFields.dayOfMonth - 1, 0, 0, timezone);
|
|
239
|
+
// See findNextCronMatchInTimezone: a null only marks a DST-at-midnight quirk, not the end
|
|
240
|
+
// of schedulable time. Step back 24 h and let the loop read the wall-clock day from dayMs.
|
|
241
|
+
if (prevDay === null) {
|
|
242
|
+
dayMs -= DAY_MS;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
dayMs = prevDay;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { date: null, inspectedDays: GREGORIAN_CYCLE_DAYS + 1 };
|
|
249
|
+
}
|
|
110
250
|
function floorToMinute(date) {
|
|
111
251
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes(), 0, 0);
|
|
112
252
|
}
|
|
@@ -163,3 +303,60 @@ function validDateMs(date, label) {
|
|
|
163
303
|
function invalidCron(expression, reason) {
|
|
164
304
|
return new Error(`Invalid cron expression "${expression}": ${reason}.`);
|
|
165
305
|
}
|
|
306
|
+
const WEEKDAY_SHORT_TO_NUMBER = {
|
|
307
|
+
sun: 0,
|
|
308
|
+
mon: 1,
|
|
309
|
+
tue: 2,
|
|
310
|
+
wed: 3,
|
|
311
|
+
thu: 4,
|
|
312
|
+
fri: 5,
|
|
313
|
+
sat: 6,
|
|
314
|
+
};
|
|
315
|
+
// formatToParts is dramatically faster when the formatter is reused; cache one per zone.
|
|
316
|
+
const formatterCache = new Map();
|
|
317
|
+
function getFormatter(timezone) {
|
|
318
|
+
const cached = formatterCache.get(timezone);
|
|
319
|
+
if (cached)
|
|
320
|
+
return cached;
|
|
321
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
322
|
+
timeZone: timezone,
|
|
323
|
+
hourCycle: 'h23',
|
|
324
|
+
minute: '2-digit',
|
|
325
|
+
hour: '2-digit',
|
|
326
|
+
day: '2-digit',
|
|
327
|
+
month: '2-digit',
|
|
328
|
+
weekday: 'short',
|
|
329
|
+
year: 'numeric',
|
|
330
|
+
});
|
|
331
|
+
formatterCache.set(timezone, formatter);
|
|
332
|
+
return formatter;
|
|
333
|
+
}
|
|
334
|
+
// Intl.supportedValuesOf('timeZone') builds a full IANA list on first call; cache it per runtime.
|
|
335
|
+
let supportedTimezones = null;
|
|
336
|
+
function getSupportedTimezones() {
|
|
337
|
+
if (supportedTimezones)
|
|
338
|
+
return supportedTimezones;
|
|
339
|
+
supportedTimezones = Intl.supportedValuesOf ? new Set(Intl.supportedValuesOf('timeZone')) : null;
|
|
340
|
+
return supportedTimezones;
|
|
341
|
+
}
|
|
342
|
+
function isValidTimezone(timezone) {
|
|
343
|
+
// "UTC"/"Z" are valid cron timezones but may be missing from Intl's list (which uses "Etc/UTC").
|
|
344
|
+
if (timezone === 'UTC' || timezone === 'Z' || timezone === 'Etc/UTC')
|
|
345
|
+
return true;
|
|
346
|
+
const supported = getSupportedTimezones();
|
|
347
|
+
if (supported)
|
|
348
|
+
return supported.has(timezone);
|
|
349
|
+
// Fallback for old runtimes: let the formatter constructor throw on bad zones.
|
|
350
|
+
try {
|
|
351
|
+
Intl.DateTimeFormat('en-US', { timeZone: timezone });
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function assertSupportedTimezone(timezone) {
|
|
359
|
+
if (!isValidTimezone(timezone)) {
|
|
360
|
+
throw new Error(`Automation scheduling received an unsupported timezone: ${timezone}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
package/dist-electron/services/local-ai-core/src/scheduler/scheduled-job-application-service.js
CHANGED
|
@@ -10,9 +10,10 @@ class ScheduledJobApplicationService {
|
|
|
10
10
|
this.options = options;
|
|
11
11
|
}
|
|
12
12
|
listJobs(workspaceId) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
.
|
|
13
|
+
const latestRunById = this.options.automations.listLatestRunByOrigin('scheduled-job', workspaceId);
|
|
14
|
+
return this.options.automations
|
|
15
|
+
.list(workspaceId, 'scheduled-job')
|
|
16
|
+
.map((automation) => (0, legacy_automation_mappers_js_1.automationToScheduledJob)(automation, latestRunById.get(automation.id)));
|
|
16
17
|
}
|
|
17
18
|
getJob(jobId) {
|
|
18
19
|
const resolved = this.resolveJobId(jobId);
|
|
@@ -41,9 +41,9 @@ class SchedulerService extends node_events_1.EventEmitter {
|
|
|
41
41
|
listJobs(workspaceId) {
|
|
42
42
|
const automations = this.options.automations;
|
|
43
43
|
if (automations) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.map((automation) => (0, legacy_automation_mappers_js_1.automationToScheduledJob)(automation,
|
|
44
|
+
const latestRunById = automations.listLatestRunByOrigin('scheduled-job', workspaceId);
|
|
45
|
+
return automations.list(workspaceId, 'scheduled-job')
|
|
46
|
+
.map((automation) => (0, legacy_automation_mappers_js_1.automationToScheduledJob)(automation, latestRunById.get(automation.id)));
|
|
47
47
|
}
|
|
48
48
|
return this.options.store.listScheduledJobs(workspaceId);
|
|
49
49
|
}
|
|
@@ -53,7 +53,12 @@ function fixture(executeAction, conditionEvaluator) {
|
|
|
53
53
|
description: 'Daily report',
|
|
54
54
|
enabled: true,
|
|
55
55
|
});
|
|
56
|
-
|
|
56
|
+
// Legacy cron jobs default to the host's local IANA timezone (matches the old
|
|
57
|
+
// SchedulerService, which matched cron in local wall clock). Any valid IANA zone is correct.
|
|
58
|
+
strict_1.default.equal(input.activation.kind, 'cron');
|
|
59
|
+
strict_1.default.equal(input.activation.expression, '0 9 * * *');
|
|
60
|
+
strict_1.default.equal(typeof input.activation.timezone, 'string');
|
|
61
|
+
strict_1.default.ok(input.activation.timezone.length > 0, 'timezone should be a non-empty IANA zone');
|
|
57
62
|
strict_1.default.deepEqual(input.condition, { kind: 'always' });
|
|
58
63
|
strict_1.default.equal(input.originKind, 'scheduled-job');
|
|
59
64
|
strict_1.default.equal(input.action.executionMode, 'same-thread');
|
|
@@ -62,8 +62,13 @@ const at = (value) => new Date(value);
|
|
|
62
62
|
strict_1.default.equal((0, automation_trigger_engine_js_1.isActivationDue)(activation, now, '2026-07-05T09:00:00.000Z'), false);
|
|
63
63
|
strict_1.default.equal((0, automation_trigger_engine_js_1.missedActivationAt)(activation, undefined, now), null);
|
|
64
64
|
});
|
|
65
|
-
(0, node_test_1.default)('cron
|
|
66
|
-
|
|
65
|
+
(0, node_test_1.default)('cron now supports IANA timezones beyond UTC', () => {
|
|
66
|
+
// Shanghai is UTC+8 with no DST: next match of "0 1 * * *" after 2026-07-15T00:00Z
|
|
67
|
+
// (08:00 CST) should be 2026-07-16 01:00 CST = 2026-07-15T17:00Z.
|
|
68
|
+
strict_1.default.equal((0, automation_trigger_engine_js_1.nextActivationAt)({ kind: 'cron', expression: '0 1 * * *', timezone: 'Asia/Shanghai' }, at('2026-07-15T00:00:00.000Z'))?.toISOString(), '2026-07-15T17:00:00.000Z');
|
|
69
|
+
});
|
|
70
|
+
(0, node_test_1.default)('cron rejects unknown timezones', () => {
|
|
71
|
+
strict_1.default.throws(() => (0, automation_trigger_engine_js_1.nextActivationAt)({ kind: 'cron', expression: '0 9 * * *', timezone: 'Not/AZone' }, at('2026-07-05T00:00:00.000Z')), /unsupported timezone/i);
|
|
67
72
|
});
|
|
68
73
|
(0, node_test_1.default)('cron compilation is strict and all activation entry points reject invalid expressions', () => {
|
|
69
74
|
const invalidExpressions = [
|
|
@@ -129,7 +134,66 @@ const at = (value) => new Date(value);
|
|
|
129
134
|
});
|
|
130
135
|
(0, node_test_1.default)('cron search does not return dates outside the JavaScript Date range', () => {
|
|
131
136
|
const activation = { kind: 'cron', expression: '* * * * *', timezone: 'UTC' };
|
|
132
|
-
strict_1.default.throws(() => (0, automation_trigger_engine_js_1.nextActivationAt)(activation, new Date(8_640_000_000_000_000)), /no
|
|
137
|
+
strict_1.default.throws(() => (0, automation_trigger_engine_js_1.nextActivationAt)(activation, new Date(8_640_000_000_000_000)), /no cron activation exists/i);
|
|
138
|
+
});
|
|
139
|
+
(0, node_test_1.default)('timezone-aware cron next match honors the target zone', () => {
|
|
140
|
+
// 0 1 * * * in Asia/Shanghai (UTC+8) after 2026-07-15T00:00Z (08:00 CST) → next 01:00 CST.
|
|
141
|
+
strict_1.default.equal((0, cron_js_1.findNextCronMatchInTimezone)((0, cron_js_1.compileCronExpression)('0 1 * * *'), at('2026-07-15T00:00:00.000Z'), 'Asia/Shanghai').date?.toISOString(), '2026-07-15T17:00:00.000Z');
|
|
142
|
+
});
|
|
143
|
+
(0, node_test_1.default)('timezone-aware cron next match lands later when the target-zone hour has already passed', () => {
|
|
144
|
+
// After 2026-07-15T18:00Z, Shanghai wall clock is 2026-07-16 02:00. Next 01:00 CST is a day later.
|
|
145
|
+
strict_1.default.equal((0, cron_js_1.findNextCronMatchInTimezone)((0, cron_js_1.compileCronExpression)('0 1 * * *'), at('2026-07-15T18:00:00.000Z'), 'Asia/Shanghai').date?.toISOString(), '2026-07-16T17:00:00.000Z');
|
|
146
|
+
});
|
|
147
|
+
(0, node_test_1.default)('timezone-aware previous match finds the most recent wall-clock hit', () => {
|
|
148
|
+
// At 2026-07-15T05:00Z (13:00 CST), previous 01:00 CST is 2026-07-14T17:00Z.
|
|
149
|
+
strict_1.default.equal((0, cron_js_1.findPreviousCronMatchInTimezone)((0, cron_js_1.compileCronExpression)('0 1 * * *'), at('2026-07-15T05:00:00.000Z'), 'Asia/Shanghai').date?.toISOString(), '2026-07-14T17:00:00.000Z');
|
|
150
|
+
});
|
|
151
|
+
(0, node_test_1.default)('extractFieldsInTimezone reads wall clock in the given zone', () => {
|
|
152
|
+
// 2026-07-15T09:00Z = 2026-07-15 17:00 CST = a Wednesday (dayOfWeek 3).
|
|
153
|
+
const fields = (0, cron_js_1.extractFieldsInTimezone)(at('2026-07-15T09:00:00.000Z'), 'Asia/Shanghai');
|
|
154
|
+
strict_1.default.equal(fields.hour, 17);
|
|
155
|
+
strict_1.default.equal(fields.minute, 0);
|
|
156
|
+
strict_1.default.equal(fields.dayOfMonth, 15);
|
|
157
|
+
strict_1.default.equal(fields.month, 7);
|
|
158
|
+
strict_1.default.equal(fields.dayOfWeek, 3);
|
|
159
|
+
});
|
|
160
|
+
(0, node_test_1.default)('extractFieldsInTimezone accounts for DST (America/New_York)', () => {
|
|
161
|
+
// 2026-07-15 is in EDT (UTC-4): 09:00Z = 05:00 local.
|
|
162
|
+
const fields = (0, cron_js_1.extractFieldsInTimezone)(at('2026-07-15T09:00:00.000Z'), 'America/New_York');
|
|
163
|
+
strict_1.default.equal(fields.hour, 5);
|
|
164
|
+
strict_1.default.equal(fields.minute, 0);
|
|
165
|
+
strict_1.default.equal(fields.dayOfMonth, 15);
|
|
166
|
+
// 2026-01-15 is in EST (UTC-5): 09:00Z = 04:00 local — DST shift confirmed.
|
|
167
|
+
const winter = (0, cron_js_1.extractFieldsInTimezone)(at('2026-01-15T09:00:00.000Z'), 'America/New_York');
|
|
168
|
+
strict_1.default.equal(winter.hour, 4);
|
|
169
|
+
});
|
|
170
|
+
(0, node_test_1.default)('cron next activation skips a DST gap and returns the next valid wall clock', () => {
|
|
171
|
+
// US spring-forward 2026: clocks jump from 02:00 to 03:00 local on 2026-03-08.
|
|
172
|
+
// A cron of "0 2 * * *" has no valid 02:00 local that day; the next match is 02:00
|
|
173
|
+
// the following day (2026-03-09), which does exist.
|
|
174
|
+
strict_1.default.equal((0, automation_trigger_engine_js_1.nextActivationAt)({ kind: 'cron', expression: '0 2 * * *', timezone: 'America/New_York' }, at('2026-03-07T07:00:00.000Z'))?.toISOString(), '2026-03-09T06:00:00.000Z');
|
|
175
|
+
});
|
|
176
|
+
(0, node_test_1.default)('cron resolves ambiguous fall-back wall clocks to a single instant', () => {
|
|
177
|
+
// US fall-back 2026: 2026-11-01 01:00 local happens twice (EDT 05:00Z then EST 06:00Z).
|
|
178
|
+
// The engine must resolve "0 1 * * *" to exactly one instant, not two or zero.
|
|
179
|
+
const first = (0, automation_trigger_engine_js_1.nextActivationAt)({ kind: 'cron', expression: '0 1 * * *', timezone: 'America/New_York' }, at('2026-10-31T05:00:00.000Z'))?.toISOString();
|
|
180
|
+
strict_1.default.ok(first === '2026-11-01T05:00:00.000Z' || first === '2026-11-01T06:00:00.000Z', `fall-back resolved to ${first}`);
|
|
181
|
+
// And the following day still triggers exactly once, at EST 01:00 = 06:00Z.
|
|
182
|
+
strict_1.default.equal((0, automation_trigger_engine_js_1.nextActivationAt)({ kind: 'cron', expression: '0 1 * * *', timezone: 'America/New_York' }, at('2026-11-01T06:00:00.000Z'))?.toISOString(), '2026-11-02T06:00:00.000Z');
|
|
183
|
+
});
|
|
184
|
+
(0, node_test_1.default)('cron accepts Z and Etc/UTC as aliases of UTC', () => {
|
|
185
|
+
// All three aliases must produce identical, UTC-aligned matches.
|
|
186
|
+
const after = at('2026-07-15T00:00:00.000Z');
|
|
187
|
+
const expr = '0 * * * *';
|
|
188
|
+
const viaUtc = (0, automation_trigger_engine_js_1.nextActivationAt)({ kind: 'cron', expression: expr, timezone: 'UTC' }, after)?.toISOString();
|
|
189
|
+
strict_1.default.equal(viaUtc, '2026-07-15T01:00:00.000Z');
|
|
190
|
+
strict_1.default.equal((0, automation_trigger_engine_js_1.nextActivationAt)({ kind: 'cron', expression: expr, timezone: 'Z' }, after)?.toISOString(), viaUtc, 'Z alias should match UTC');
|
|
191
|
+
strict_1.default.equal((0, automation_trigger_engine_js_1.nextActivationAt)({ kind: 'cron', expression: expr, timezone: 'Etc/UTC' }, after)?.toISOString(), viaUtc, 'Etc/UTC alias should match UTC');
|
|
192
|
+
});
|
|
193
|
+
(0, node_test_1.default)('timezone-aware search handles a half-hour offset zone', () => {
|
|
194
|
+
// Pacific/Chatham is UTC+12:45 in winter (July). 2026-07-16 00:00 local = 2026-07-15T11:15Z,
|
|
195
|
+
// so a "0 0 * * *" cron resolved right after 2026-07-15T23:00Z lands on the next calendar day.
|
|
196
|
+
strict_1.default.equal((0, cron_js_1.findNextCronMatchInTimezone)((0, cron_js_1.compileCronExpression)('0 0 * * *'), at('2026-07-15T23:00:00.000Z'), 'Pacific/Chatham').date?.toISOString(), '2026-07-16T11:15:00.000Z');
|
|
133
197
|
});
|
|
134
198
|
(0, node_test_1.default)('restart recovery returns only the most recent missed activation', () => {
|
|
135
199
|
const now = at('2026-07-05T10:05:30.000Z');
|
|
@@ -69,6 +69,195 @@ function fixture() {
|
|
|
69
69
|
context.close();
|
|
70
70
|
}
|
|
71
71
|
});
|
|
72
|
+
(0, node_test_1.default)('listDueAutomationIds returns only automations whose next_check_at is at or before now', () => {
|
|
73
|
+
const context = fixture();
|
|
74
|
+
try {
|
|
75
|
+
const due = context.store.create(createInput({ title: 'Due' }));
|
|
76
|
+
const future = context.store.create(createInput({ title: 'Future' }));
|
|
77
|
+
const unset = context.store.create(createInput({ title: 'Unset' }));
|
|
78
|
+
const disabled = context.store.create(createInput({ title: 'Disabled' }));
|
|
79
|
+
const blocked = context.store.create(createInput({ title: 'Blocked' }));
|
|
80
|
+
context.store.updateState(due.id, { nextCheckAt: '2026-07-05T01:05:00.000Z' });
|
|
81
|
+
context.store.updateState(future.id, { nextCheckAt: '2026-07-05T02:00:00.000Z' });
|
|
82
|
+
context.store.updateState(disabled.id, { nextCheckAt: '2026-07-05T01:05:00.000Z' });
|
|
83
|
+
context.store.update(disabled.id, { enabled: false });
|
|
84
|
+
context.store.updateState(blocked.id, {
|
|
85
|
+
nextCheckAt: '2026-07-05T01:05:00.000Z',
|
|
86
|
+
health: 'blocked',
|
|
87
|
+
blockedReason: 'Provider unavailable',
|
|
88
|
+
});
|
|
89
|
+
const atDue = context.store.listDueAutomationIds(new Date('2026-07-05T01:05:00.000Z'));
|
|
90
|
+
strict_1.default.equal(atDue.size, 1);
|
|
91
|
+
strict_1.default.equal(atDue.has(due.id), true);
|
|
92
|
+
strict_1.default.equal(atDue.has(future.id), false);
|
|
93
|
+
strict_1.default.equal(atDue.has(unset.id), false);
|
|
94
|
+
strict_1.default.equal(atDue.has(disabled.id), false);
|
|
95
|
+
strict_1.default.equal(atDue.has(blocked.id), false);
|
|
96
|
+
const atLater = context.store.listDueAutomationIds(new Date('2026-07-05T02:00:00.000Z'));
|
|
97
|
+
strict_1.default.equal(atLater.size, 2);
|
|
98
|
+
strict_1.default.equal(atLater.has(due.id), true);
|
|
99
|
+
strict_1.default.equal(atLater.has(future.id), true);
|
|
100
|
+
const facadeDue = context.facade.listDueAutomationIds(new Date('2026-07-05T01:05:00.000Z'));
|
|
101
|
+
strict_1.default.equal(facadeDue.size, 1);
|
|
102
|
+
strict_1.default.equal(facadeDue.has(due.id), true);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
context.close();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
(0, node_test_1.default)('listLatestFinishedEvaluationByOrigin returns the most recent finished evaluation per automation', () => {
|
|
109
|
+
const context = fixture();
|
|
110
|
+
try {
|
|
111
|
+
const monitor = context.store.createTrusted({
|
|
112
|
+
...createInput({ title: 'Monitor' }),
|
|
113
|
+
originKind: 'automation-monitor',
|
|
114
|
+
});
|
|
115
|
+
const otherWorkspace = context.store.createTrusted({
|
|
116
|
+
...createInput({ workspaceId: 'workspace-2', title: 'Other' }),
|
|
117
|
+
originKind: 'automation-monitor',
|
|
118
|
+
});
|
|
119
|
+
const earlier = context.store.createEvaluation(monitor.id, {
|
|
120
|
+
activationKind: 'provider-event',
|
|
121
|
+
startedAt: '2026-07-05T01:00:00.000Z',
|
|
122
|
+
});
|
|
123
|
+
context.store.finishEvaluation(earlier.id, {
|
|
124
|
+
conditionOutcome: 'matched',
|
|
125
|
+
triggerDecision: 'triggered',
|
|
126
|
+
finishedAt: '2026-07-05T01:00:05.000Z',
|
|
127
|
+
});
|
|
128
|
+
const later = context.store.createEvaluation(monitor.id, {
|
|
129
|
+
activationKind: 'provider-event',
|
|
130
|
+
startedAt: '2026-07-05T02:00:00.000Z',
|
|
131
|
+
});
|
|
132
|
+
context.store.finishEvaluation(later.id, {
|
|
133
|
+
conditionOutcome: 'matched',
|
|
134
|
+
triggerDecision: 'triggered',
|
|
135
|
+
finishedAt: '2026-07-05T02:00:05.000Z',
|
|
136
|
+
});
|
|
137
|
+
const running = context.store.createEvaluation(monitor.id, {
|
|
138
|
+
activationKind: 'provider-event',
|
|
139
|
+
startedAt: '2026-07-05T03:00:00.000Z',
|
|
140
|
+
});
|
|
141
|
+
const other = context.store.createEvaluation(otherWorkspace.id, {
|
|
142
|
+
activationKind: 'provider-event',
|
|
143
|
+
startedAt: '2026-07-05T01:00:00.000Z',
|
|
144
|
+
});
|
|
145
|
+
context.store.finishEvaluation(other.id, {
|
|
146
|
+
conditionOutcome: 'matched',
|
|
147
|
+
triggerDecision: 'triggered',
|
|
148
|
+
finishedAt: '2026-07-05T01:00:05.000Z',
|
|
149
|
+
});
|
|
150
|
+
void running;
|
|
151
|
+
const all = context.store.listLatestFinishedEvaluationByOrigin('automation-monitor');
|
|
152
|
+
strict_1.default.equal(all.size, 2);
|
|
153
|
+
strict_1.default.equal(all.get(monitor.id)?.id, later.id);
|
|
154
|
+
strict_1.default.equal(all.get(otherWorkspace.id)?.id, other.id);
|
|
155
|
+
const scoped = context.store.listLatestFinishedEvaluationByOrigin('automation-monitor', 'workspace-1');
|
|
156
|
+
strict_1.default.equal(scoped.size, 1);
|
|
157
|
+
strict_1.default.equal(scoped.get(monitor.id)?.id, later.id);
|
|
158
|
+
const facadeScoped = context.facade.listLatestFinishedAutomationEvaluationByOrigin('automation-monitor', 'workspace-1');
|
|
159
|
+
strict_1.default.equal(facadeScoped.get(monitor.id)?.id, later.id);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
context.close();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
(0, node_test_1.default)('listLatestRunByOrigin returns the most recent run per automation', () => {
|
|
166
|
+
const context = fixture();
|
|
167
|
+
try {
|
|
168
|
+
const job = context.store.createTrusted({
|
|
169
|
+
...createInput({ title: 'Job' }),
|
|
170
|
+
originKind: 'scheduled-job',
|
|
171
|
+
});
|
|
172
|
+
const evaluations = ['2026-07-05T01:00:00.000Z', '2026-07-05T02:00:00.000Z'].map((finishedAt, index) => {
|
|
173
|
+
const evaluation = context.store.createEvaluation(job.id, {
|
|
174
|
+
activationKind: 'cron',
|
|
175
|
+
startedAt: `2026-07-05T0${index + 1}:00:00.000Z`,
|
|
176
|
+
});
|
|
177
|
+
context.store.finishEvaluation(evaluation.id, {
|
|
178
|
+
conditionOutcome: 'matched',
|
|
179
|
+
triggerDecision: 'triggered',
|
|
180
|
+
finishedAt,
|
|
181
|
+
});
|
|
182
|
+
return evaluation;
|
|
183
|
+
});
|
|
184
|
+
const earlierRun = context.store.createRun(job.id, evaluations[0].id, { status: 'succeeded' });
|
|
185
|
+
const laterRun = context.store.createRun(job.id, evaluations[1].id, { status: 'failed', error: 'boom' });
|
|
186
|
+
void earlierRun;
|
|
187
|
+
const all = context.store.listLatestRunByOrigin('scheduled-job');
|
|
188
|
+
strict_1.default.equal(all.size, 1);
|
|
189
|
+
strict_1.default.equal(all.get(job.id)?.id, laterRun.id);
|
|
190
|
+
strict_1.default.equal(all.get(job.id)?.status, 'failed');
|
|
191
|
+
const facadeAll = context.facade.listLatestAutomationRunByOrigin('scheduled-job');
|
|
192
|
+
strict_1.default.equal(facadeAll.get(job.id)?.id, laterRun.id);
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
context.close();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
(0, node_test_1.default)('listLatestEvaluationWithStateByOrigin returns the most recent finished-with-state evaluation per automation', () => {
|
|
199
|
+
const context = fixture();
|
|
200
|
+
try {
|
|
201
|
+
const monitor = context.store.createTrusted({
|
|
202
|
+
...createInput({ title: 'Monitor' }),
|
|
203
|
+
originKind: 'automation-monitor',
|
|
204
|
+
});
|
|
205
|
+
const withoutState = context.store.createEvaluation(monitor.id, {
|
|
206
|
+
activationKind: 'provider-event',
|
|
207
|
+
startedAt: '2026-07-05T01:00:00.000Z',
|
|
208
|
+
});
|
|
209
|
+
context.store.finishEvaluation(withoutState.id, {
|
|
210
|
+
conditionOutcome: 'matched',
|
|
211
|
+
triggerDecision: 'triggered',
|
|
212
|
+
finishedAt: '2026-07-05T01:00:05.000Z',
|
|
213
|
+
});
|
|
214
|
+
const withState = context.store.createEvaluation(monitor.id, {
|
|
215
|
+
activationKind: 'provider-event',
|
|
216
|
+
startedAt: '2026-07-05T02:00:00.000Z',
|
|
217
|
+
});
|
|
218
|
+
context.store.finishEvaluation(withState.id, {
|
|
219
|
+
conditionOutcome: 'matched',
|
|
220
|
+
triggerDecision: 'triggered',
|
|
221
|
+
finishedAt: '2026-07-05T02:00:05.000Z',
|
|
222
|
+
nextState: { counter: 1 },
|
|
223
|
+
});
|
|
224
|
+
const all = context.store.listLatestEvaluationWithStateByOrigin('automation-monitor');
|
|
225
|
+
strict_1.default.equal(all.size, 1);
|
|
226
|
+
strict_1.default.equal(all.get(monitor.id)?.id, withState.id);
|
|
227
|
+
const finished = all.get(monitor.id);
|
|
228
|
+
strict_1.default.equal(finished?.status, 'finished');
|
|
229
|
+
if (finished?.status === 'finished') {
|
|
230
|
+
strict_1.default.deepEqual(finished.nextState, { counter: 1 });
|
|
231
|
+
}
|
|
232
|
+
const facadeAll = context.facade.listLatestAutomationEvaluationWithStateByOrigin('automation-monitor');
|
|
233
|
+
strict_1.default.equal(facadeAll.get(monitor.id)?.id, withState.id);
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
context.close();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
(0, node_test_1.default)('list filters by origin kind when provided', () => {
|
|
240
|
+
const context = fixture();
|
|
241
|
+
try {
|
|
242
|
+
const monitor = context.store.createTrusted({
|
|
243
|
+
...createInput({ title: 'Monitor' }),
|
|
244
|
+
originKind: 'automation-monitor',
|
|
245
|
+
});
|
|
246
|
+
const job = context.store.createTrusted({
|
|
247
|
+
...createInput({ title: 'Job' }),
|
|
248
|
+
originKind: 'scheduled-job',
|
|
249
|
+
});
|
|
250
|
+
void monitor;
|
|
251
|
+
void job;
|
|
252
|
+
strict_1.default.equal(context.store.list().length, 2);
|
|
253
|
+
strict_1.default.equal(context.store.list(undefined, 'automation-monitor').length, 1);
|
|
254
|
+
strict_1.default.equal(context.store.list(undefined, 'scheduled-job').length, 1);
|
|
255
|
+
strict_1.default.equal(context.facade.listAutomations(undefined, 'scheduled-job').length, 1);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
context.close();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
72
261
|
(0, node_test_1.default)('reconciles queued and running action runs as interrupted after restart', () => {
|
|
73
262
|
const context = fixture();
|
|
74
263
|
try {
|