@kafca/agentdock 0.1.62 → 0.1.64

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.
Files changed (48) hide show
  1. package/README.md +4 -0
  2. package/dist/renderer/assets/{AutomationList-BgJgAdVO.js → AutomationList-uiR0seMA.js} +1 -1
  3. package/dist/renderer/assets/{Badge-Bq2hPVcR.js → Badge-BZwdECxe.js} +1 -1
  4. package/dist/renderer/assets/{Card-BPJNSZce.js → Card-JMY-xmLX.js} +1 -1
  5. package/dist/renderer/assets/{Config-aSFqlI88.js → Config-BMcWRfWu.js} +1 -1
  6. package/dist/renderer/assets/{Dashboard-D3ezFmb7.js → Dashboard-CSk6wUMx.js} +2 -2
  7. package/dist/renderer/assets/{EmptyState-DK496gBq.js → EmptyState-3J94zJRa.js} +1 -1
  8. package/dist/renderer/assets/{HighlightedMarkdown-Ckre5jNv.js → HighlightedMarkdown-DKL7rO-G.js} +1 -1
  9. package/dist/renderer/assets/{Input-C5C1d0ba.js → Input-BGK524F9.js} +1 -1
  10. package/dist/renderer/assets/{KnowledgeDetail-N8Ey1ikc.js → KnowledgeDetail-XZ9sxTBY.js} +1 -1
  11. package/dist/renderer/assets/{KnowledgeHome-9aF8nJEf.js → KnowledgeHome-CJlGiu7a.js} +1 -1
  12. package/dist/renderer/assets/{Logs-sgAdwEeS.js → Logs-B5Y0yspB.js} +1 -1
  13. package/dist/renderer/assets/{Modal-BB10hTRc.js → Modal-DlIfXQ8_.js} +1 -1
  14. package/dist/renderer/assets/{Page-DfC319Ck.js → Page-B1i9DSW-.js} +1 -1
  15. package/dist/renderer/assets/{Select-RHJBmNER.js → Select-DDpZ4sBt.js} +1 -1
  16. package/dist/renderer/assets/{ThreadChat-BU1zCdr4.js → ThreadChat-D63t_k2d.js} +3 -3
  17. package/dist/renderer/assets/{Workspace-BoZCz7S8.js → Workspace-BPkGxjuF.js} +1 -1
  18. package/dist/renderer/assets/{arrow-left-D2V1eZSD.js → arrow-left-C6VSgxEk.js} +1 -1
  19. package/dist/renderer/assets/{book-open-DUcBn7Bb.js → book-open-DWJZSr5x.js} +1 -1
  20. package/dist/renderer/assets/{channels-DMhDSlf2.js → channels-Dkik3Wxt.js} +1 -1
  21. package/dist/renderer/assets/{chevron-down-Ca2FQDB7.js → chevron-down-BHtOK5fW.js} +1 -1
  22. package/dist/renderer/assets/{index-DRJ5wgw_.js → index-DV9qeL9P.js} +2 -2
  23. package/dist/renderer/assets/{index-BlnvlekJ.js → index-TTj4JT0d.js} +1 -1
  24. package/dist/renderer/assets/{knowledge-D1-k56eY.js → knowledge-C9mJpMmJ.js} +1 -1
  25. package/dist/renderer/assets/{pencil-BhWdv_86.js → pencil-B7bBxE-w.js} +1 -1
  26. package/dist/renderer/assets/{plus-BacLMlK3.js → plus-DPOTxoL2.js} +1 -1
  27. package/dist/renderer/assets/{save-Bpoxbzau.js → save-Drzhy5En.js} +1 -1
  28. package/dist/renderer/assets/{search-BVJcpdFX.js → search-m-2mVuF6.js} +1 -1
  29. package/dist/renderer/assets/{shield-check-C2cw_Ip6.js → shield-check-C8NOMoui.js} +1 -1
  30. package/dist/renderer/assets/{threads-DOsBQBg_.js → threads-BG4F9grx.js} +1 -1
  31. package/dist/renderer/assets/{trash-2-Bs3rEm3g.js → trash-2-Bh0nSQUA.js} +1 -1
  32. package/dist/renderer/index.html +1 -1
  33. package/dist-electron/services/local-ai-core/src/acp/store/automation-store.js +69 -3
  34. package/dist-electron/services/local-ai-core/src/acp/store/local-core-acp-store.js +14 -2
  35. package/dist-electron/services/local-ai-core/src/automation/automation-monitor-service.js +5 -3
  36. package/dist-electron/services/local-ai-core/src/automation/automation-service.js +13 -4
  37. package/dist-electron/services/local-ai-core/src/automation/automation-trigger-engine.js +19 -18
  38. package/dist-electron/services/local-ai-core/src/automation/legacy-automation-mappers.js +25 -1
  39. package/dist-electron/services/local-ai-core/src/kernel/bootstrap.js +15 -0
  40. package/dist-electron/services/local-ai-core/src/runtime/local-core-controller.js +13 -3
  41. package/dist-electron/services/local-ai-core/src/scheduler/cron.js +197 -0
  42. package/dist-electron/services/local-ai-core/src/scheduler/scheduled-job-application-service.js +4 -3
  43. package/dist-electron/services/local-ai-core/src/scheduler/scheduler-service.js +3 -3
  44. package/dist-electron/tests/electron/automation-compatibility.test.js +6 -1
  45. package/dist-electron/tests/electron/automation-engine.test.js +67 -3
  46. package/dist-electron/tests/electron/automation-sandbox-runner.test.js +20 -9
  47. package/dist-electron/tests/electron/automation-store.test.js +189 -0
  48. 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
+ }
@@ -10,9 +10,10 @@ class ScheduledJobApplicationService {
10
10
  this.options = options;
11
11
  }
12
12
  listJobs(workspaceId) {
13
- return this.options.automations.list(workspaceId)
14
- .filter((automation) => automation.originKind === 'scheduled-job')
15
- .map((automation) => (0, legacy_automation_mappers_js_1.automationToScheduledJob)(automation, (0, legacy_automation_mappers_js_1.latestAutomationRun)(this.options.automations.listRuns(automation.id))));
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
- return automations.list(workspaceId)
45
- .filter((automation) => automation.originKind === 'scheduled-job')
46
- .map((automation) => (0, legacy_automation_mappers_js_1.automationToScheduledJob)(automation, (0, legacy_automation_mappers_js_1.latestAutomationRun)(automations.listRuns(automation.id))));
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
- strict_1.default.deepEqual(input.activation, { kind: 'cron', expression: '0 9 * * *', timezone: 'UTC' });
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 timezone limitations are explicit', () => {
66
- strict_1.default.throws(() => (0, automation_trigger_engine_js_1.nextActivationAt)({ kind: 'cron', expression: '0 9 * * *', timezone: 'Asia/Shanghai' }, at('2026-07-05T00:00:00.000Z')), /only UTC cron timezones/i);
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 UTC cron activation exists/i);
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');
@@ -51,6 +51,17 @@ function input(root, overrides = {}) {
51
51
  ...overrides,
52
52
  };
53
53
  }
54
+ function createFakeRunner(root, manager = new FakeManager()) {
55
+ return new anthropic_sandbox_runner_js_1.AnthropicSandboxRunner({
56
+ platform: process.platform,
57
+ manager,
58
+ tempRoot: root,
59
+ // These execution-path tests use FakeManager and must not depend on
60
+ // packages installed on the host runner. Capability-specific behavior is
61
+ // covered by the explicit macOS/Linux probe tests above.
62
+ commandExists: () => true,
63
+ });
64
+ }
54
65
  (0, node_test_1.default)('builds read-only package and temp-only write policy', () => {
55
66
  const root = '/workspace/automation';
56
67
  const config = (0, anthropic_sandbox_runner_js_1.buildSandboxRuntimeConfig)(input(root, {
@@ -215,7 +226,7 @@ function input(root, overrides = {}) {
215
226
  const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'automation-sandbox-runner-'));
216
227
  try {
217
228
  const manager = new FakeManager();
218
- const runner = new anthropic_sandbox_runner_js_1.AnthropicSandboxRunner({ platform: process.platform, manager, tempRoot: root });
229
+ const runner = createFakeRunner(root, manager);
219
230
  const result = await runner.run({ ...input(root), env: { PATH: (0, node_path_1.join)(root, 'fake-bin') }, allowedEnv: ['PATH'] });
220
231
  strict_1.default.equal(result.exitCode, 0);
221
232
  strict_1.default.equal(result.stdout, 'sandbox-ok');
@@ -229,7 +240,7 @@ function input(root, overrides = {}) {
229
240
  (0, node_test_1.default)('enforces streamed output ceilings before buffering unbounded output', async () => {
230
241
  const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'automation-sandbox-runner-'));
231
242
  try {
232
- const runner = new anthropic_sandbox_runner_js_1.AnthropicSandboxRunner({ platform: process.platform, manager: new FakeManager(), tempRoot: root });
243
+ const runner = createFakeRunner(root);
233
244
  const result = await runner.run({ ...input(root, { command: 'yes output', stdoutBytes: 64, timeoutMs: 5_000 }) });
234
245
  strict_1.default.equal(result.outputLimitExceeded, 'stdout');
235
246
  strict_1.default.ok(Buffer.byteLength(result.stdout, 'utf8') <= 64);
@@ -241,7 +252,7 @@ function input(root, overrides = {}) {
241
252
  (0, node_test_1.default)('stream truncation respects UTF-8 byte limits without replacement characters', async () => {
242
253
  const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'automation-sandbox-runner-'));
243
254
  try {
244
- const runner = new anthropic_sandbox_runner_js_1.AnthropicSandboxRunner({ platform: process.platform, manager: new FakeManager(), tempRoot: root });
255
+ const runner = createFakeRunner(root);
245
256
  const result = await runner.run({ ...input(root, { command: "printf '€€'", stdoutBytes: 4, timeoutMs: 5_000 }) });
246
257
  strict_1.default.equal(result.outputLimitExceeded, 'stdout');
247
258
  strict_1.default.ok(Buffer.byteLength(result.stdout, 'utf8') <= 4);
@@ -254,7 +265,7 @@ function input(root, overrides = {}) {
254
265
  (0, node_test_1.default)('stream truncation preserves a complete replacement character before overflow', async () => {
255
266
  const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'automation-sandbox-runner-'));
256
267
  try {
257
- const runner = new anthropic_sandbox_runner_js_1.AnthropicSandboxRunner({ platform: process.platform, manager: new FakeManager(), tempRoot: root });
268
+ const runner = createFakeRunner(root);
258
269
  const result = await runner.run({ ...input(root, { command: "printf '�€'", stdoutBytes: 5, timeoutMs: 5_000 }) });
259
270
  strict_1.default.equal(result.stdout, '�');
260
271
  strict_1.default.equal(Buffer.byteLength(result.stdout, 'utf8'), 3);
@@ -266,7 +277,7 @@ function input(root, overrides = {}) {
266
277
  (0, node_test_1.default)('timeout terminates the sandbox command process group', { skip: process.platform === 'win32' }, async () => {
267
278
  const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'automation-sandbox-runner-'));
268
279
  try {
269
- const runner = new anthropic_sandbox_runner_js_1.AnthropicSandboxRunner({ platform: process.platform, manager: new FakeManager(), tempRoot: root });
280
+ const runner = createFakeRunner(root);
270
281
  const started = Date.now();
271
282
  const result = await runner.run({ ...input(root, { command: 'sleep 3 & wait', timeoutMs: 50 }) });
272
283
  strict_1.default.ok(Date.now() - started < 2_000);
@@ -280,8 +291,8 @@ function input(root, overrides = {}) {
280
291
  const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'automation-sandbox-lock-'));
281
292
  try {
282
293
  const manager = new FakeManager();
283
- const first = new anthropic_sandbox_runner_js_1.AnthropicSandboxRunner({ platform: process.platform, manager, tempRoot: root });
284
- const second = new anthropic_sandbox_runner_js_1.AnthropicSandboxRunner({ platform: process.platform, manager, tempRoot: root });
294
+ const first = createFakeRunner(root, manager);
295
+ const second = createFakeRunner(root, manager);
285
296
  const [left, right] = await Promise.all([
286
297
  first.run(input(root)),
287
298
  second.run(input(root)),
@@ -298,7 +309,7 @@ function input(root, overrides = {}) {
298
309
  const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'automation-sandbox-env-'));
299
310
  try {
300
311
  const manager = new FakeManager();
301
- const runner = new anthropic_sandbox_runner_js_1.AnthropicSandboxRunner({ platform: process.platform, manager, tempRoot: root });
312
+ const runner = createFakeRunner(root, manager);
302
313
  const result = await runner.run({
303
314
  ...input(root),
304
315
  command: 'printf "%s-%s" "$TOKEN" "$BASH_ENV"',
@@ -316,7 +327,7 @@ function input(root, overrides = {}) {
316
327
  const root = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'automation-sandbox-empty-env-'));
317
328
  try {
318
329
  const manager = new FakeManager();
319
- const runner = new anthropic_sandbox_runner_js_1.AnthropicSandboxRunner({ platform: process.platform, manager, tempRoot: root });
330
+ const runner = createFakeRunner(root, manager);
320
331
  const result = await runner.run({
321
332
  ...input(root),
322
333
  command: 'printf "%s" "$TOKEN"',
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kafca/agentdock",
3
- "version": "0.1.62",
3
+ "version": "0.1.64",
4
4
  "license": "PolyForm-Noncommercial-1.0.0",
5
5
  "type": "module",
6
6
  "main": "dist-electron/electron/main.js",