@moxxy/sdk 0.44.0 → 0.45.0
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/dist/agent-account-limit.d.ts +41 -0
- package/dist/agent-account-limit.d.ts.map +1 -0
- package/dist/agent-account-limit.js +64 -0
- package/dist/agent-account-limit.js.map +1 -0
- package/dist/agent-computer-use.d.ts +10 -0
- package/dist/agent-computer-use.d.ts.map +1 -0
- package/dist/agent-computer-use.js +24 -0
- package/dist/agent-computer-use.js.map +1 -0
- package/dist/agent-context-window.d.ts +22 -0
- package/dist/agent-context-window.d.ts.map +1 -0
- package/dist/agent-context-window.js +40 -0
- package/dist/agent-context-window.js.map +1 -0
- package/dist/agent-no-reply.d.ts +11 -0
- package/dist/agent-no-reply.d.ts.map +1 -0
- package/dist/agent-no-reply.js +13 -0
- package/dist/agent-no-reply.js.map +1 -0
- package/dist/agent-step.d.ts +14 -0
- package/dist/agent-step.d.ts.map +1 -0
- package/dist/agent-step.js +26 -0
- package/dist/agent-step.js.map +1 -0
- package/dist/agent-work.d.ts +34 -0
- package/dist/agent-work.d.ts.map +1 -0
- package/dist/agent-work.js +80 -0
- package/dist/agent-work.js.map +1 -0
- package/dist/attachment-payload.d.ts +1 -1
- package/dist/attachment-payload.js +1 -1
- package/dist/auto-approve.d.ts +9 -0
- package/dist/auto-approve.d.ts.map +1 -1
- package/dist/auto-approve.js +18 -0
- package/dist/auto-approve.js.map +1 -1
- package/dist/channel-auth.d.ts +3 -3
- package/dist/channel-auth.d.ts.map +1 -1
- package/dist/channel-auth.js +1 -1
- package/dist/channel.d.ts +1 -1
- package/dist/client-session.d.ts +9 -5
- package/dist/client-session.d.ts.map +1 -1
- package/dist/computer-control.d.ts +11 -11
- package/dist/computer-control.d.ts.map +1 -1
- package/dist/computer-control.js +1 -1
- package/dist/computer-control.js.map +1 -1
- package/dist/conversation-history.d.ts +50 -0
- package/dist/conversation-history.d.ts.map +1 -0
- package/dist/conversation-history.js +227 -0
- package/dist/conversation-history.js.map +1 -0
- package/dist/cron.d.ts +58 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +320 -0
- package/dist/cron.js.map +1 -0
- package/dist/define.d.ts +2 -1
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +1 -0
- package/dist/define.js.map +1 -1
- package/dist/event-store.d.ts +1 -1
- package/dist/event-store.d.ts.map +1 -1
- package/dist/event-store.js +1 -0
- package/dist/event-store.js.map +1 -1
- package/dist/events.d.ts +17 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/file-diff.d.ts +31 -0
- package/dist/file-diff.d.ts.map +1 -0
- package/dist/file-diff.js +196 -0
- package/dist/file-diff.js.map +1 -0
- package/dist/index.d.ts +22 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -4
- package/dist/index.js.map +1 -1
- package/dist/mode/agent-step-loop.d.ts +13 -0
- package/dist/mode/agent-step-loop.d.ts.map +1 -0
- package/dist/mode/agent-step-loop.js +578 -0
- package/dist/mode/agent-step-loop.js.map +1 -0
- package/dist/mode/checkpoint-gate.d.ts +25 -0
- package/dist/mode/checkpoint-gate.d.ts.map +1 -0
- package/dist/mode/checkpoint-gate.js +150 -0
- package/dist/mode/checkpoint-gate.js.map +1 -0
- package/dist/mode/checkpoint.d.ts +6 -0
- package/dist/mode/checkpoint.d.ts.map +1 -1
- package/dist/mode/collect-stream.d.ts +2 -0
- package/dist/mode/collect-stream.d.ts.map +1 -1
- package/dist/mode/collect-stream.js +1 -1
- package/dist/mode/collect-stream.js.map +1 -1
- package/dist/mode/project-messages.d.ts.map +1 -1
- package/dist/mode/project-messages.js +9 -67
- package/dist/mode/project-messages.js.map +1 -1
- package/dist/mode/react-loop-options.d.ts +113 -0
- package/dist/mode/react-loop-options.d.ts.map +1 -0
- package/dist/mode/react-loop-options.js +2 -0
- package/dist/mode/react-loop-options.js.map +1 -0
- package/dist/mode/react-loop.d.ts +2 -99
- package/dist/mode/react-loop.d.ts.map +1 -1
- package/dist/mode/react-loop.js +7 -145
- package/dist/mode/react-loop.js.map +1 -1
- package/dist/mode.d.ts +19 -7
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/npm-registry.d.ts +22 -0
- package/dist/npm-registry.d.ts.map +1 -0
- package/dist/npm-registry.js +54 -0
- package/dist/npm-registry.js.map +1 -0
- package/dist/permission.d.ts +31 -0
- package/dist/permission.d.ts.map +1 -1
- package/dist/process-invocation.d.ts +2 -0
- package/dist/process-invocation.d.ts.map +1 -1
- package/dist/process-invocation.js +2 -2
- package/dist/process-invocation.js.map +1 -1
- package/dist/process-tree.d.ts +9 -0
- package/dist/process-tree.d.ts.map +1 -0
- package/dist/process-tree.js +40 -0
- package/dist/process-tree.js.map +1 -0
- package/dist/provider-login-bridge.js +1 -1
- package/dist/provider-login-bridge.js.map +1 -1
- package/dist/provider.d.ts +408 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/question-draft.d.ts +19 -0
- package/dist/question-draft.d.ts.map +1 -0
- package/dist/question-draft.js +30 -0
- package/dist/question-draft.js.map +1 -0
- package/dist/question.d.ts +204 -0
- package/dist/question.d.ts.map +1 -0
- package/dist/question.js +141 -0
- package/dist/question.js.map +1 -0
- package/dist/questions.d.ts +7 -0
- package/dist/questions.d.ts.map +1 -0
- package/dist/questions.js +7 -0
- package/dist/questions.js.map +1 -0
- package/dist/resolvers.d.ts +14 -1
- package/dist/resolvers.d.ts.map +1 -1
- package/dist/resolvers.js +33 -21
- package/dist/resolvers.js.map +1 -1
- package/dist/schemas.d.ts +53 -53
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +3 -2
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -0
- package/dist/server.js.map +1 -1
- package/dist/session-like.d.ts +19 -1
- package/dist/session-like.d.ts.map +1 -1
- package/dist/starting-model.d.ts +12 -0
- package/dist/starting-model.d.ts.map +1 -0
- package/dist/starting-model.js +9 -0
- package/dist/starting-model.js.map +1 -0
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +10 -4
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/tool-display.d.ts +79 -3
- package/dist/tool-display.d.ts.map +1 -1
- package/dist/tool-display.js +117 -3
- package/dist/tool-display.js.map +1 -1
- package/dist/tool-modes.d.ts +1 -1
- package/dist/tool-modes.d.ts.map +1 -1
- package/dist/tool-modes.js +8 -2
- package/dist/tool-modes.js.map +1 -1
- package/dist/tool-output.d.ts +15 -0
- package/dist/tool-output.d.ts.map +1 -0
- package/dist/tool-output.js +39 -0
- package/dist/tool-output.js.map +1 -0
- package/dist/tool.d.ts +14 -1
- package/dist/tool.d.ts.map +1 -1
- package/dist/tunnel.d.ts +3 -3
- package/dist/tunnel.d.ts.map +1 -1
- package/dist/tunnel.js +2 -2
- package/dist/workflow-approval.d.ts +9 -9
- package/dist/workflow-approval.d.ts.map +1 -1
- package/dist/workflow-approval.js +1 -1
- package/dist/workflow-approval.js.map +1 -1
- package/package.json +33 -5
- package/src/agent-account-limit.test.ts +87 -0
- package/src/agent-account-limit.ts +89 -0
- package/src/agent-computer-use.test.ts +26 -0
- package/src/agent-computer-use.ts +29 -0
- package/src/agent-context-window.ts +51 -0
- package/src/agent-no-reply.ts +16 -0
- package/src/agent-step.ts +26 -0
- package/src/agent-work.ts +102 -0
- package/src/attachment-payload.ts +1 -1
- package/src/auto-approve.test.ts +13 -1
- package/src/auto-approve.ts +19 -0
- package/src/channel-auth.ts +3 -3
- package/src/channel.ts +1 -1
- package/src/client-session.ts +9 -2
- package/src/computer-control.ts +1 -1
- package/src/conversation-history.test.ts +184 -0
- package/src/conversation-history.ts +272 -0
- package/src/cron.test.ts +280 -0
- package/src/cron.ts +355 -0
- package/src/define.test.ts +1 -1
- package/src/define.ts +3 -1
- package/src/event-store.ts +2 -1
- package/src/events.ts +17 -1
- package/src/file-diff.test.ts +103 -0
- package/src/file-diff.ts +208 -0
- package/src/index.ts +121 -1
- package/src/json-file-store.test.ts +1 -1
- package/src/loop-helpers.test.ts +15 -0
- package/src/mode/agent-step-loop.ts +645 -0
- package/src/mode/checkpoint-gate.ts +198 -0
- package/src/mode/checkpoint.ts +6 -0
- package/src/mode/collect-stream.ts +1 -1
- package/src/mode/project-messages.test.ts +11 -0
- package/src/mode/project-messages.ts +8 -61
- package/src/mode/react-loop-options.ts +124 -0
- package/src/mode/react-loop.ts +10 -296
- package/src/mode.ts +19 -7
- package/src/npm-registry.test.ts +95 -0
- package/src/npm-registry.ts +57 -0
- package/src/permission.ts +30 -0
- package/src/process-invocation.test.ts +25 -0
- package/src/process-invocation.ts +8 -3
- package/src/process-tree.ts +37 -0
- package/src/provider-login-bridge.ts +1 -1
- package/src/provider-schema-constraints.test.ts +1 -1
- package/src/provider-utils.test.ts +1 -1
- package/src/provider.ts +346 -0
- package/src/question-draft.test.ts +57 -0
- package/src/question-draft.ts +47 -0
- package/src/question.test.ts +130 -0
- package/src/question.ts +229 -0
- package/src/questions.ts +6 -0
- package/src/resolvers.test.ts +84 -1
- package/src/resolvers.ts +40 -27
- package/src/schemas.test.ts +5 -0
- package/src/schemas.ts +3 -2
- package/src/server.ts +3 -0
- package/src/session-like.ts +19 -1
- package/src/skill-mentions.test.ts +1 -1
- package/src/starting-model.test.ts +19 -0
- package/src/starting-model.ts +11 -0
- package/src/token-efficiency.test.ts +1 -1
- package/src/tool-dispatch.ts +10 -4
- package/src/tool-display.test.ts +119 -0
- package/src/tool-display.ts +162 -4
- package/src/tool-gating.test.ts +1 -1
- package/src/tool-icon.test.ts +1 -1
- package/src/tool-modes.test.ts +18 -1
- package/src/tool-modes.ts +6 -2
- package/src/tool-output.test.ts +46 -0
- package/src/tool-output.ts +46 -0
- package/src/tool.ts +14 -1
- package/src/tunnel.ts +3 -3
- package/src/workflow-approval.ts +1 -1
package/src/cron.test.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { assertDefined } from './assert.js';
|
|
3
|
+
import { isValidCron, isValidTimeZone, nextFireTime, parseCron } from './cron.js';
|
|
4
|
+
|
|
5
|
+
describe('parseCron', () => {
|
|
6
|
+
it('parses every-minute', () => {
|
|
7
|
+
const c = parseCron('* * * * *');
|
|
8
|
+
expect(c.minute.values.size).toBe(60);
|
|
9
|
+
expect(c.hour.values.size).toBe(24);
|
|
10
|
+
expect(c.minute.restricted).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('parses a literal', () => {
|
|
14
|
+
const c = parseCron('0 9 * * *');
|
|
15
|
+
expect([...c.minute.values]).toEqual([0]);
|
|
16
|
+
expect([...c.hour.values]).toEqual([9]);
|
|
17
|
+
expect(c.minute.restricted).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('parses a range', () => {
|
|
21
|
+
const c = parseCron('0 9-11 * * *');
|
|
22
|
+
expect([...c.hour.values]).toEqual([9, 10, 11]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('parses a list', () => {
|
|
26
|
+
const c = parseCron('0 0,12,18 * * *');
|
|
27
|
+
expect([...c.hour.values].sort((a, b) => a - b)).toEqual([0, 12, 18]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('parses a step', () => {
|
|
31
|
+
const c = parseCron('*/15 * * * *');
|
|
32
|
+
expect([...c.minute.values].sort((a, b) => a - b)).toEqual([0, 15, 30, 45]);
|
|
33
|
+
expect(c.minute.restricted).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('parses a range with step', () => {
|
|
37
|
+
const c = parseCron('0 9-17/2 * * *');
|
|
38
|
+
expect([...c.hour.values].sort((a, b) => a - b)).toEqual([9, 11, 13, 15, 17]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rejects out-of-range', () => {
|
|
42
|
+
expect(() => parseCron('0 25 * * *')).toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rejects wrong-arity', () => {
|
|
46
|
+
expect(() => parseCron('0 9 *')).toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('isValidCron returns false on garbage', () => {
|
|
50
|
+
expect(isValidCron('not a cron')).toBe(false);
|
|
51
|
+
expect(isValidCron('0 9 * * *')).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('nextFireTime', () => {
|
|
56
|
+
it('finds the next 9 AM', () => {
|
|
57
|
+
// Mon 2026-05-11 08:30 local → next 9 AM is same day 09:00
|
|
58
|
+
const after = new Date(2026, 4, 11, 8, 30, 0);
|
|
59
|
+
const next = nextFireTime('0 9 * * *', after);
|
|
60
|
+
expect(next).not.toBeNull();
|
|
61
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
62
|
+
expect(next.getHours()).toBe(9);
|
|
63
|
+
expect(next.getMinutes()).toBe(0);
|
|
64
|
+
expect(next.getDate()).toBe(11);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('wraps to next day when already past', () => {
|
|
68
|
+
const after = new Date(2026, 4, 11, 10, 0, 0);
|
|
69
|
+
const next = nextFireTime('0 9 * * *', after);
|
|
70
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
71
|
+
expect(next.getDate()).toBe(12);
|
|
72
|
+
expect(next.getHours()).toBe(9);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('every-15-min fires at the next quarter', () => {
|
|
76
|
+
const after = new Date(2026, 4, 11, 10, 7, 0);
|
|
77
|
+
const next = nextFireTime('*/15 * * * *', after);
|
|
78
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
79
|
+
expect(next.getHours()).toBe(10);
|
|
80
|
+
expect(next.getMinutes()).toBe(15);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('every hour at minute 30 finds the next half-hour', () => {
|
|
84
|
+
const after = new Date(2026, 4, 11, 10, 45, 0);
|
|
85
|
+
const next = nextFireTime('30 * * * *', after);
|
|
86
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
87
|
+
expect(next.getHours()).toBe(11);
|
|
88
|
+
expect(next.getMinutes()).toBe(30);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns null for impossible expressions', () => {
|
|
92
|
+
// Feb 30th never exists.
|
|
93
|
+
const after = new Date(2026, 0, 1, 0, 0, 0);
|
|
94
|
+
const next = nextFireTime('0 0 30 2 *', after);
|
|
95
|
+
expect(next).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('honors DOW restriction', () => {
|
|
99
|
+
// 2026-05-11 is a Monday. "9 AM on Sundays" -> next Sunday 2026-05-17
|
|
100
|
+
const after = new Date(2026, 4, 11, 8, 0, 0);
|
|
101
|
+
const next = nextFireTime('0 9 * * 0', after);
|
|
102
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
103
|
+
expect(next.getDay()).toBe(0);
|
|
104
|
+
expect(next.getDate()).toBe(17);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// u103-4: jumpToNextMonth must walk across a year boundary. From mid-year,
|
|
108
|
+
// "midnight on Jan 1" lands on the following Jan 1.
|
|
109
|
+
it('walks across a year boundary for an annual expression', () => {
|
|
110
|
+
const after = new Date(2026, 5, 15, 12, 0, 0); // mid-June 2026
|
|
111
|
+
const next = nextFireTime('0 0 1 1 *', after);
|
|
112
|
+
expect(next).not.toBeNull();
|
|
113
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
114
|
+
expect(next.getFullYear()).toBe(2027);
|
|
115
|
+
expect(next.getMonth()).toBe(0); // January
|
|
116
|
+
expect(next.getDate()).toBe(1);
|
|
117
|
+
expect(next.getHours()).toBe(0);
|
|
118
|
+
expect(next.getMinutes()).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('jumps to the next valid month within the same year', () => {
|
|
122
|
+
// From January, "midnight on the 1st of March" -> 2026-03-01.
|
|
123
|
+
const after = new Date(2026, 0, 10, 0, 0, 0); // 2026-01-10
|
|
124
|
+
const next = nextFireTime('0 0 1 3 *', after);
|
|
125
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
126
|
+
expect(next.getFullYear()).toBe(2026);
|
|
127
|
+
expect(next.getMonth()).toBe(2); // March
|
|
128
|
+
expect(next.getDate()).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// u103-4: vixie-cron OR semantics — when BOTH DOM and DOW are restricted,
|
|
132
|
+
// a fire matches on EITHER. `0 0 13 * 5` fires on the 13th OR any Friday.
|
|
133
|
+
it('matches the soonest of DOM-13 OR Friday (DOW arm first)', () => {
|
|
134
|
+
// After Sat 2026-06-06, the next Friday is 2026-06-12 (DOW arm), which
|
|
135
|
+
// comes before the 13th (the DOM arm). So the Friday wins.
|
|
136
|
+
const after = new Date(2026, 5, 6, 0, 0, 0); // Sat 2026-06-06
|
|
137
|
+
const next = nextFireTime('0 0 13 * 5', after);
|
|
138
|
+
expect(next).not.toBeNull();
|
|
139
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
140
|
+
expect(next.getDate()).toBe(12);
|
|
141
|
+
expect(next.getDay()).toBe(5); // Friday
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('matches the 13th via the DOM arm even when it is not a Friday', () => {
|
|
145
|
+
// After Fri 2026-06-12 00:01, the next match is Sat 2026-06-13 — a 13th
|
|
146
|
+
// that is NOT a Friday. AND-semantics would skip it; OR-semantics fires.
|
|
147
|
+
const after = new Date(2026, 5, 12, 0, 1, 0); // just past midnight Fri 06-12
|
|
148
|
+
const next = nextFireTime('0 0 13 * 5', after);
|
|
149
|
+
expect(next).not.toBeNull();
|
|
150
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
151
|
+
expect(next.getDate()).toBe(13);
|
|
152
|
+
expect(next.getDay()).toBe(6); // Saturday — matched purely via DOM
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Regression for u103-1: with an explicit IANA zone the cursor walk must
|
|
156
|
+
// match AND advance in that zone, not jump in the host-local zone. These
|
|
157
|
+
// assertions hold regardless of the host TZ because they check the
|
|
158
|
+
// absolute instant's wall-clock rendering in the target zone.
|
|
159
|
+
const wallClockInZone = (d: Date, timeZone: string) => {
|
|
160
|
+
const fmt = new Intl.DateTimeFormat('en-US', {
|
|
161
|
+
timeZone,
|
|
162
|
+
hour: '2-digit',
|
|
163
|
+
minute: '2-digit',
|
|
164
|
+
hour12: false,
|
|
165
|
+
});
|
|
166
|
+
const parts: Record<string, string> = {};
|
|
167
|
+
for (const p of fmt.formatToParts(d)) parts[p.type] = p.value;
|
|
168
|
+
return { hour: Number(parts.hour) % 24, minute: Number(parts.minute) };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
it('fires at 09:00 wall-clock in an explicit New York zone', () => {
|
|
172
|
+
// Anchor far enough back that the host TZ cannot accidentally satisfy it.
|
|
173
|
+
const after = new Date(Date.UTC(2026, 4, 11, 0, 0, 0));
|
|
174
|
+
const next = nextFireTime('0 9 * * *', after, 'America/New_York');
|
|
175
|
+
expect(next).not.toBeNull();
|
|
176
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
177
|
+
const wc = wallClockInZone(next, 'America/New_York');
|
|
178
|
+
expect(wc.hour).toBe(9);
|
|
179
|
+
expect(wc.minute).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('fires at 09:00 wall-clock in an explicit Tokyo zone', () => {
|
|
183
|
+
const after = new Date(Date.UTC(2026, 4, 11, 0, 0, 0));
|
|
184
|
+
const next = nextFireTime('0 9 * * *', after, 'Asia/Tokyo');
|
|
185
|
+
expect(next).not.toBeNull();
|
|
186
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
187
|
+
const wc = wallClockInZone(next, 'Asia/Tokyo');
|
|
188
|
+
expect(wc.hour).toBe(9);
|
|
189
|
+
expect(wc.minute).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('handles spring-forward DST in an explicit zone', () => {
|
|
193
|
+
// US spring-forward 2026: 2026-03-08, clocks jump 02:00 -> 03:00 EST->EDT.
|
|
194
|
+
// "30 2 * * *" (02:30) does not exist that day; the next valid 02:30 is
|
|
195
|
+
// the following day. The walk must not loop to the 1-year cap.
|
|
196
|
+
const after = new Date(Date.UTC(2026, 2, 8, 0, 0, 0));
|
|
197
|
+
const next = nextFireTime('30 2 * * *', after, 'America/New_York');
|
|
198
|
+
expect(next).not.toBeNull();
|
|
199
|
+
assertDefined(next, 'nextFireTime resolves to a fire time');
|
|
200
|
+
const wc = wallClockInZone(next, 'America/New_York');
|
|
201
|
+
expect(wc.hour).toBe(2);
|
|
202
|
+
expect(wc.minute).toBe(30);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Worst case (DoS): a structurally-impossible expression in an EXPLICIT IANA
|
|
206
|
+
// zone must NOT walk the full 366-day minute-by-minute window (~527k
|
|
207
|
+
// Intl.formatToParts calls, ~2s of blocked event loop) before returning null.
|
|
208
|
+
// The calendar-fact pre-check bails instantly. We assert correctness (null)
|
|
209
|
+
// AND a tight time bound so a regression that removes the short-circuit fails.
|
|
210
|
+
it('bails instantly (not a 2s walk) on an impossible explicit-zone cron', () => {
|
|
211
|
+
const after = new Date(Date.UTC(2026, 0, 1, 0, 0, 0));
|
|
212
|
+
const start = performance.now();
|
|
213
|
+
expect(nextFireTime('0 0 30 2 *', after, 'America/New_York')).toBeNull(); // Feb 30
|
|
214
|
+
expect(nextFireTime('0 0 31 4 *', after, 'America/New_York')).toBeNull(); // Apr 31
|
|
215
|
+
expect(nextFireTime('0 0 31 4,6,9,11 *', after, 'Asia/Tokyo')).toBeNull(); // 31st of only 30-day months
|
|
216
|
+
const elapsed = performance.now() - start;
|
|
217
|
+
// Three impossible explicit-zone evaluations. Pre-fix this was ~6s; the
|
|
218
|
+
// short-circuit makes it sub-millisecond. Generous bound to avoid CI flake.
|
|
219
|
+
expect(elapsed).toBeLessThan(250);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('does NOT declare a valid sparse explicit-zone cron impossible', () => {
|
|
223
|
+
const after = new Date(Date.UTC(2026, 0, 1, 0, 0, 0));
|
|
224
|
+
// Jan 31 exists — must resolve, not be short-circuited to null.
|
|
225
|
+
const jan31 = nextFireTime('0 0 31 1 *', after, 'America/New_York');
|
|
226
|
+
expect(jan31).not.toBeNull();
|
|
227
|
+
assertDefined(jan31, 'Jan 31 exists, so nextFireTime resolves');
|
|
228
|
+
const wc = wallClockInZone(jan31, 'America/New_York');
|
|
229
|
+
expect(wc.hour).toBe(0);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('still finds leap-year Feb 29 (29 is a legal DOM, not impossible)', () => {
|
|
233
|
+
// 2028 is a leap year. The pre-check uses Feb max = 29 so it does not
|
|
234
|
+
// wrongly reject Feb 29; the walk then lands on it.
|
|
235
|
+
const after = new Date(2028, 0, 1, 0, 0, 0);
|
|
236
|
+
const next = nextFireTime('0 0 29 2 *', after);
|
|
237
|
+
expect(next).not.toBeNull();
|
|
238
|
+
assertDefined(next, 'Feb 29 in a leap year resolves');
|
|
239
|
+
expect(next.getMonth()).toBe(1); // February
|
|
240
|
+
expect(next.getDate()).toBe(29);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('does NOT treat an impossible-DOM cron as impossible when DOW is restricted (OR-semantics)', () => {
|
|
244
|
+
// `0 0 30 2 5` = (Feb 30, never) OR (any Friday). The Friday arm makes it
|
|
245
|
+
// perfectly reachable; the pre-check must NOT short-circuit it to null.
|
|
246
|
+
const after = new Date(2026, 0, 1, 0, 0, 0);
|
|
247
|
+
const next = nextFireTime('0 0 30 2 5', after);
|
|
248
|
+
expect(next).not.toBeNull();
|
|
249
|
+
assertDefined(next, 'the Friday (DOW) arm makes this reachable');
|
|
250
|
+
expect(next.getDay()).toBe(5); // matched via the Friday (DOW) arm
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Worst case: a non-IANA timeZone must NOT throw a RangeError out of
|
|
254
|
+
// nextFireTime (which would abort the whole poller tick) — it returns null
|
|
255
|
+
// (treated as "never due") instead.
|
|
256
|
+
it('returns null instead of throwing for a non-IANA timeZone', () => {
|
|
257
|
+
const after = new Date(Date.UTC(2026, 4, 11, 0, 0, 0));
|
|
258
|
+
expect(() => nextFireTime('0 9 * * *', after, 'Mars/Phobos')).not.toThrow();
|
|
259
|
+
expect(nextFireTime('0 9 * * *', after, 'Mars/Phobos')).toBeNull();
|
|
260
|
+
// Other non-IANA strings are rejected the same way.
|
|
261
|
+
expect(nextFireTime('0 9 * * *', after, 'Not/AZone')).toBeNull();
|
|
262
|
+
expect(nextFireTime('0 9 * * *', after, '')).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('isValidTimeZone', () => {
|
|
267
|
+
it('accepts undefined / local / real IANA zones', () => {
|
|
268
|
+
expect(isValidTimeZone(undefined)).toBe(true);
|
|
269
|
+
expect(isValidTimeZone('local')).toBe(true);
|
|
270
|
+
expect(isValidTimeZone('UTC')).toBe(true);
|
|
271
|
+
expect(isValidTimeZone('America/New_York')).toBe(true);
|
|
272
|
+
expect(isValidTimeZone('Asia/Tokyo')).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('rejects non-IANA strings that would make Intl throw', () => {
|
|
276
|
+
expect(isValidTimeZone('Mars/Phobos')).toBe(false);
|
|
277
|
+
expect(isValidTimeZone('Not/AZone')).toBe(false);
|
|
278
|
+
expect(isValidTimeZone('')).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
});
|
package/src/cron.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny POSIX-style 5-field cron parser.
|
|
3
|
+
*
|
|
4
|
+
* ┌───────────── minute (0-59)
|
|
5
|
+
* │ ┌─────────── hour (0-23)
|
|
6
|
+
* │ │ ┌───────── day-of-month (1-31)
|
|
7
|
+
* │ │ │ ┌─────── month (1-12)
|
|
8
|
+
* │ │ │ │ ┌───── day-of-week (0-6, Sunday=0)
|
|
9
|
+
* * * * * *
|
|
10
|
+
*
|
|
11
|
+
* Each field supports:
|
|
12
|
+
* - `*` — any value in range
|
|
13
|
+
* - `n` — literal
|
|
14
|
+
* - `a-b` — inclusive range
|
|
15
|
+
* - `a,b,c` — list
|
|
16
|
+
* - `*\/n` — every n
|
|
17
|
+
* - `a-b/n` — every n within a range
|
|
18
|
+
*
|
|
19
|
+
* `nextFireTime(expr, after)` walks forward minute-by-minute (with cheap
|
|
20
|
+
* field-by-field jumps) until every field matches, capped at a year
|
|
21
|
+
* lookahead. Returns null if no fire is reachable (impossible expression
|
|
22
|
+
* like `0 0 31 2 *`).
|
|
23
|
+
*
|
|
24
|
+
* Standard cron quirk: when both DOM and DOW are restricted, vixie-cron
|
|
25
|
+
* matches on EITHER. We follow that convention.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { assertDefined } from './assert.js';
|
|
29
|
+
|
|
30
|
+
interface FieldSpec {
|
|
31
|
+
readonly values: ReadonlySet<number>;
|
|
32
|
+
readonly restricted: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ParsedCron {
|
|
36
|
+
readonly minute: FieldSpec;
|
|
37
|
+
readonly hour: FieldSpec;
|
|
38
|
+
readonly dom: FieldSpec;
|
|
39
|
+
readonly month: FieldSpec;
|
|
40
|
+
readonly dow: FieldSpec;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface FieldRange {
|
|
44
|
+
readonly min: number;
|
|
45
|
+
readonly max: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const FIELD_RANGES: Record<keyof ParsedCron, FieldRange> = {
|
|
49
|
+
minute: { min: 0, max: 59 },
|
|
50
|
+
hour: { min: 0, max: 23 },
|
|
51
|
+
dom: { min: 1, max: 31 },
|
|
52
|
+
month: { min: 1, max: 12 },
|
|
53
|
+
dow: { min: 0, max: 6 },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function parseField(raw: string, range: FieldRange, fieldName: string): FieldSpec {
|
|
57
|
+
const values = new Set<number>();
|
|
58
|
+
const restricted = raw !== '*' && !raw.startsWith('*/');
|
|
59
|
+
for (const piece of raw.split(',')) {
|
|
60
|
+
const [rangePart, stepPart] = piece.split('/');
|
|
61
|
+
if (rangePart === undefined) throw new Error(`empty term in ${fieldName}: "${raw}"`);
|
|
62
|
+
const step = stepPart ? toIntStrict(stepPart, fieldName) : 1;
|
|
63
|
+
if (step < 1) throw new Error(`step must be >= 1 in ${fieldName}: "${raw}"`);
|
|
64
|
+
|
|
65
|
+
let lo: number;
|
|
66
|
+
let hi: number;
|
|
67
|
+
if (rangePart === '*') {
|
|
68
|
+
lo = range.min;
|
|
69
|
+
hi = range.max;
|
|
70
|
+
} else if (rangePart.includes('-')) {
|
|
71
|
+
const [a, b] = rangePart.split('-');
|
|
72
|
+
// A string containing '-' always splits into >= 2 parts, so both bounds
|
|
73
|
+
// are present by construction (empty strings are caught by toIntStrict).
|
|
74
|
+
assertDefined(a, 'range lower bound (rangePart contains "-")');
|
|
75
|
+
assertDefined(b, 'range upper bound (rangePart contains "-")');
|
|
76
|
+
lo = toIntStrict(a, fieldName);
|
|
77
|
+
hi = toIntStrict(b, fieldName);
|
|
78
|
+
} else {
|
|
79
|
+
lo = toIntStrict(rangePart, fieldName);
|
|
80
|
+
hi = lo;
|
|
81
|
+
}
|
|
82
|
+
if (lo < range.min || hi > range.max || lo > hi) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`out-of-range term "${piece}" in ${fieldName} (allowed ${range.min}-${range.max})`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
for (let v = lo; v <= hi; v += step) values.add(v);
|
|
88
|
+
}
|
|
89
|
+
return { values, restricted };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toIntStrict(s: string, fieldName: string): number {
|
|
93
|
+
if (!/^-?\d+$/.test(s)) throw new Error(`non-integer "${s}" in ${fieldName}`);
|
|
94
|
+
return Number(s);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function parseCron(expr: string): ParsedCron {
|
|
98
|
+
const fields = expr.trim().split(/\s+/);
|
|
99
|
+
if (fields.length !== 5) {
|
|
100
|
+
throw new Error(`cron expression must have 5 fields, got ${fields.length}: "${expr}"`);
|
|
101
|
+
}
|
|
102
|
+
const [minute, hour, dom, month, dow] = fields as [string, string, string, string, string];
|
|
103
|
+
return {
|
|
104
|
+
minute: parseField(minute, FIELD_RANGES.minute, 'minute'),
|
|
105
|
+
hour: parseField(hour, FIELD_RANGES.hour, 'hour'),
|
|
106
|
+
dom: parseField(dom, FIELD_RANGES.dom, 'dom'),
|
|
107
|
+
month: parseField(month, FIELD_RANGES.month, 'month'),
|
|
108
|
+
dow: parseField(dow, FIELD_RANGES.dow, 'dow'),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isValidCron(expr: string): boolean {
|
|
113
|
+
try {
|
|
114
|
+
parseCron(expr);
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Calendar maximum day-of-month per month (1-12). February uses 29 to allow
|
|
122
|
+
// leap-year Feb 29 fires; non-leap years are then handled by the normal walk.
|
|
123
|
+
const MAX_DOM_BY_MONTH: Record<number, number> = {
|
|
124
|
+
1: 31, 2: 29, 3: 31, 4: 30, 5: 31, 6: 30,
|
|
125
|
+
7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Cheap, zone-independent structural-impossibility check. Some cron
|
|
130
|
+
* expressions can never fire because their day-of-month is larger than every
|
|
131
|
+
* candidate month allows (e.g. `0 0 30 2 *` — Feb never has a 30th, or
|
|
132
|
+
* `0 0 31 4 *` — April has no 31st). Without this, an explicit-IANA-zone walk
|
|
133
|
+
* for such an expression advances minute-by-minute through the full 366-day cap
|
|
134
|
+
* (~527k `Intl.formatToParts` calls, ~2s of blocked event loop) only to return
|
|
135
|
+
* null. The DOM/DOW OR-semantics quirk means this is only safe to declare
|
|
136
|
+
* impossible when DOW is unrestricted (`*`): if DOW is restricted the day can
|
|
137
|
+
* still match via the weekday arm regardless of DOM. We check exactly the case
|
|
138
|
+
* the walk is slowest on — a restricted DOM whose smallest value exceeds the
|
|
139
|
+
* largest day any selected month can hold — and bail to "no fire" instantly.
|
|
140
|
+
*/
|
|
141
|
+
function isStructurallyImpossible(cron: ParsedCron): boolean {
|
|
142
|
+
// OR-semantics: a restricted DOW can satisfy the day on its own, so DOM being
|
|
143
|
+
// unreachable does not make the expression impossible. Only decide when DOW
|
|
144
|
+
// is unrestricted (every weekday allowed) and DOM is restricted.
|
|
145
|
+
if (cron.dow.restricted || !cron.dom.restricted) return false;
|
|
146
|
+
const minDom = Math.min(...cron.dom.values);
|
|
147
|
+
let maxAllowed = 0;
|
|
148
|
+
for (const month of cron.month.values) {
|
|
149
|
+
const cap = MAX_DOM_BY_MONTH[month] ?? 31;
|
|
150
|
+
if (cap > maxAllowed) maxAllowed = cap;
|
|
151
|
+
}
|
|
152
|
+
// If the smallest requested day-of-month is larger than the biggest day any
|
|
153
|
+
// selected month can ever hold, no instant can match — impossible.
|
|
154
|
+
return minDom > maxAllowed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Whether `tz` is an IANA zone the runtime's `Intl` accepts. A non-IANA
|
|
159
|
+
* string (e.g. 'PST', 'Mars/Phobos') makes `Intl.DateTimeFormat` throw a
|
|
160
|
+
* RangeError. `nextFireTime` would otherwise inherit that throw deep inside
|
|
161
|
+
* the poller tick (outside its per-entry try/catch), aborting evaluation of
|
|
162
|
+
* every schedule positioned after the bad one. We reject the zone at the
|
|
163
|
+
* trust boundary instead. `undefined`/'local' mean system-local (always ok).
|
|
164
|
+
*/
|
|
165
|
+
export function isValidTimeZone(tz: string | undefined): boolean {
|
|
166
|
+
if (tz === undefined || tz === 'local') return true;
|
|
167
|
+
try {
|
|
168
|
+
new Intl.DateTimeFormat(undefined, { timeZone: tz });
|
|
169
|
+
return true;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Minute-precision walk forward from `after` (exclusive) until every
|
|
177
|
+
* field matches. Operates in the caller-supplied IANA timezone via
|
|
178
|
+
* `Intl.DateTimeFormat` — defaults to system local. The walk is
|
|
179
|
+
* bounded at 1-year so a structurally-impossible expression returns
|
|
180
|
+
* null instead of looping forever.
|
|
181
|
+
*/
|
|
182
|
+
export function nextFireTime(
|
|
183
|
+
expr: string,
|
|
184
|
+
after: Date,
|
|
185
|
+
timeZone?: string,
|
|
186
|
+
): Date | null {
|
|
187
|
+
const cron = parseCron(expr);
|
|
188
|
+
// Defense in depth: a malformed/legacy timeZone that slipped past the
|
|
189
|
+
// trust-boundary validation would make `Intl.DateTimeFormat` throw deep in
|
|
190
|
+
// the walk — outside the poller's per-entry guard, which would abort every
|
|
191
|
+
// later schedule's evaluation. Treat an unusable zone as "no reachable fire"
|
|
192
|
+
// (null) so the entry is simply never due, matching how callers handle null.
|
|
193
|
+
if (!isValidTimeZone(timeZone)) return null;
|
|
194
|
+
// Bail instantly on a structurally-impossible expression (e.g. Feb 30,
|
|
195
|
+
// Apr 31) BEFORE the walk. For an explicit IANA zone the walk would
|
|
196
|
+
// otherwise advance minute-by-minute through the full 366-day cap (~527k
|
|
197
|
+
// `Intl.formatToParts` calls, ~2s of blocked event loop) just to return
|
|
198
|
+
// null — a self-inflicted DoS one typo'd skill could trigger every time its
|
|
199
|
+
// baseline changes. This check is calendar-fact-only, so it is correct in
|
|
200
|
+
// any zone.
|
|
201
|
+
if (isStructurallyImpossible(cron)) return null;
|
|
202
|
+
// Start at after + 1 minute, zero seconds.
|
|
203
|
+
const start = new Date(after.getTime() + 60_000);
|
|
204
|
+
start.setSeconds(0, 0);
|
|
205
|
+
const limit = new Date(start.getTime() + 366 * 24 * 60 * 60_000);
|
|
206
|
+
|
|
207
|
+
// The field-by-field jump helpers mutate the cursor with system-local
|
|
208
|
+
// `Date` methods, so they are only correct when matching is also done in
|
|
209
|
+
// the host's local zone. For an explicit (non-local) IANA zone we walk
|
|
210
|
+
// minute-by-minute on the absolute instant instead — slower, but advancing
|
|
211
|
+
// the cursor by a fixed 60_000 ms is zone-independent, whereas the local
|
|
212
|
+
// setHours/setDate jumps would land on the wrong wall-clock instant in the
|
|
213
|
+
// target zone (off-by-offset / DST errors). The 1-year cap still bounds it.
|
|
214
|
+
const explicitZone = !!timeZone && timeZone !== 'local';
|
|
215
|
+
|
|
216
|
+
const cursor = new Date(start);
|
|
217
|
+
while (cursor <= limit) {
|
|
218
|
+
const parts = decomposeInZone(cursor, timeZone);
|
|
219
|
+
if (!cron.month.values.has(parts.month)) {
|
|
220
|
+
if (explicitZone) {
|
|
221
|
+
cursor.setTime(cursor.getTime() + 60_000);
|
|
222
|
+
} else {
|
|
223
|
+
// Jump to the first day of the next candidate month.
|
|
224
|
+
jumpToNextMonth(cursor, parts, cron.month.values, timeZone);
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const domOk = cron.dom.values.has(parts.dom);
|
|
229
|
+
const dowOk = cron.dow.values.has(parts.dow);
|
|
230
|
+
// Vixie-cron OR semantics when both DOM and DOW are restricted.
|
|
231
|
+
const dayMatches = cron.dom.restricted && cron.dow.restricted
|
|
232
|
+
? domOk || dowOk
|
|
233
|
+
: domOk && dowOk;
|
|
234
|
+
if (!dayMatches) {
|
|
235
|
+
if (explicitZone) cursor.setTime(cursor.getTime() + 60_000);
|
|
236
|
+
else jumpToNextDay(cursor, timeZone);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (!cron.hour.values.has(parts.hour)) {
|
|
240
|
+
if (explicitZone) cursor.setTime(cursor.getTime() + 60_000);
|
|
241
|
+
else jumpToNextHour(cursor, timeZone);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (!cron.minute.values.has(parts.minute)) {
|
|
245
|
+
if (explicitZone) cursor.setTime(cursor.getTime() + 60_000);
|
|
246
|
+
else cursor.setMinutes(cursor.getMinutes() + 1, 0, 0);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
return new Date(cursor.getTime());
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
interface DateParts {
|
|
255
|
+
readonly year: number;
|
|
256
|
+
readonly month: number; // 1-12
|
|
257
|
+
readonly dom: number; // 1-31
|
|
258
|
+
readonly dow: number; // 0=Sunday
|
|
259
|
+
readonly hour: number;
|
|
260
|
+
readonly minute: number;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Memoized Intl.DateTimeFormat per IANA zone. Formatters are stateless, so
|
|
264
|
+
// caching them across the (potentially long) minute-walk avoids re-allocating
|
|
265
|
+
// one on every cursor step for explicit-zone crons.
|
|
266
|
+
const formatterCache = new Map<string, Intl.DateTimeFormat>();
|
|
267
|
+
|
|
268
|
+
function formatterFor(timeZone: string): Intl.DateTimeFormat {
|
|
269
|
+
let fmt = formatterCache.get(timeZone);
|
|
270
|
+
if (!fmt) {
|
|
271
|
+
fmt = new Intl.DateTimeFormat('en-US', {
|
|
272
|
+
timeZone,
|
|
273
|
+
year: 'numeric',
|
|
274
|
+
month: '2-digit',
|
|
275
|
+
day: '2-digit',
|
|
276
|
+
weekday: 'short',
|
|
277
|
+
hour: '2-digit',
|
|
278
|
+
minute: '2-digit',
|
|
279
|
+
hour12: false,
|
|
280
|
+
});
|
|
281
|
+
formatterCache.set(timeZone, fmt);
|
|
282
|
+
}
|
|
283
|
+
return fmt;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function decomposeInZone(d: Date, timeZone?: string): DateParts {
|
|
287
|
+
// Local time decomposition. We deliberately default to system-local —
|
|
288
|
+
// a user saying "every day at 9 AM" overwhelmingly means their wall
|
|
289
|
+
// clock, not UTC. Callers that need UTC pass timeZone='UTC'.
|
|
290
|
+
if (!timeZone || timeZone === 'local') {
|
|
291
|
+
return {
|
|
292
|
+
year: d.getFullYear(),
|
|
293
|
+
month: d.getMonth() + 1,
|
|
294
|
+
dom: d.getDate(),
|
|
295
|
+
dow: d.getDay(),
|
|
296
|
+
hour: d.getHours(),
|
|
297
|
+
minute: d.getMinutes(),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
// Intl-based decomposition for explicit zones. A formatter is stateless,
|
|
301
|
+
// so memoize one per IANA zone — `nextFireTime` can walk many minute steps
|
|
302
|
+
// for a sparse cron and was previously allocating a fresh formatter on each
|
|
303
|
+
// step. Correctness is unchanged; only the per-step allocation is removed.
|
|
304
|
+
const fmt = formatterFor(timeZone);
|
|
305
|
+
const parts: Record<string, string> = {};
|
|
306
|
+
for (const part of fmt.formatToParts(d)) parts[part.type] = part.value;
|
|
307
|
+
const weekdayMap: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
308
|
+
// The formatter is configured with `weekday: 'short'`, so formatToParts
|
|
309
|
+
// always emits a weekday part, so its presence is impossible by construction.
|
|
310
|
+
const weekday = parts.weekday;
|
|
311
|
+
assertDefined(weekday, 'formatToParts includes weekday (formatter sets weekday: short)');
|
|
312
|
+
return {
|
|
313
|
+
year: Number(parts.year),
|
|
314
|
+
month: Number(parts.month),
|
|
315
|
+
dom: Number(parts.day),
|
|
316
|
+
dow: weekdayMap[weekday] ?? 0,
|
|
317
|
+
hour: Number(parts.hour) % 24,
|
|
318
|
+
minute: Number(parts.minute),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function jumpToNextMonth(
|
|
323
|
+
cursor: Date,
|
|
324
|
+
current: DateParts,
|
|
325
|
+
validMonths: ReadonlySet<number>,
|
|
326
|
+
_timeZone?: string,
|
|
327
|
+
): void {
|
|
328
|
+
// Find next valid month >= current+1; wraps to next year if needed.
|
|
329
|
+
let nextMonth = current.month + 1;
|
|
330
|
+
let yearDelta = 0;
|
|
331
|
+
while (!validMonths.has(((nextMonth - 1) % 12) + 1)) {
|
|
332
|
+
nextMonth += 1;
|
|
333
|
+
if (nextMonth > 24) {
|
|
334
|
+
// Pathological: walk hit a wall. Caller's outer loop will give up
|
|
335
|
+
// at the 1-year limit.
|
|
336
|
+
cursor.setFullYear(cursor.getFullYear() + 1);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
while (nextMonth > 12) {
|
|
341
|
+
nextMonth -= 12;
|
|
342
|
+
yearDelta += 1;
|
|
343
|
+
}
|
|
344
|
+
cursor.setFullYear(cursor.getFullYear() + yearDelta, nextMonth - 1, 1);
|
|
345
|
+
cursor.setHours(0, 0, 0, 0);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function jumpToNextDay(cursor: Date, _timeZone?: string): void {
|
|
349
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
350
|
+
cursor.setHours(0, 0, 0, 0);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function jumpToNextHour(cursor: Date, _timeZone?: string): void {
|
|
354
|
+
cursor.setHours(cursor.getHours() + 1, 0, 0, 0);
|
|
355
|
+
}
|
package/src/define.test.ts
CHANGED
package/src/define.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { SynthesizerDef } from './synthesizer.js';
|
|
|
17
17
|
import type { ViewRendererDef } from './view-renderer.js';
|
|
18
18
|
import type { TunnelProviderDef } from './tunnel.js';
|
|
19
19
|
import type { WorkflowExecutorDef } from './workflow.js';
|
|
20
|
-
import type { z } from 'zod';
|
|
20
|
+
import type { z } from 'zod/v3';
|
|
21
21
|
|
|
22
22
|
export function definePlugin(spec: PluginSpec): Plugin {
|
|
23
23
|
// Spread spec first so the defaults below can't be clobbered by an
|
|
@@ -52,6 +52,7 @@ export function defineTool<S extends z.ZodTypeAny, O = unknown>(spec: {
|
|
|
52
52
|
liveState?: boolean;
|
|
53
53
|
alwaysLoaded?: boolean;
|
|
54
54
|
modes?: ReadonlyArray<string>;
|
|
55
|
+
notInModes?: ReadonlyArray<string>;
|
|
55
56
|
refusalEndsTurn?: string;
|
|
56
57
|
}): ToolDef {
|
|
57
58
|
return Object.freeze({
|
|
@@ -70,6 +71,7 @@ export function defineTool<S extends z.ZodTypeAny, O = unknown>(spec: {
|
|
|
70
71
|
...(spec.alwaysLoaded ? { alwaysLoaded: true } : {}),
|
|
71
72
|
...(spec.refusalEndsTurn !== undefined ? { refusalEndsTurn: spec.refusalEndsTurn } : {}),
|
|
72
73
|
...(spec.modes ? { modes: Object.freeze([...spec.modes]) } : {}),
|
|
74
|
+
...(spec.notInModes ? { notInModes: Object.freeze([...spec.notInModes]) } : {}),
|
|
73
75
|
});
|
|
74
76
|
}
|
|
75
77
|
|
package/src/event-store.ts
CHANGED
|
@@ -19,6 +19,7 @@ export const SESSION_SOURCES = [
|
|
|
19
19
|
'cli',
|
|
20
20
|
'tui',
|
|
21
21
|
'desktop',
|
|
22
|
+
// The mobile app was removed; kept so sessions it recorded still read back.
|
|
22
23
|
'mobile',
|
|
23
24
|
'slack',
|
|
24
25
|
'telegram',
|
|
@@ -34,7 +35,7 @@ export type SessionSource = (typeof SESSION_SOURCES)[number];
|
|
|
34
35
|
/**
|
|
35
36
|
* The single per-session metadata record (the JSONL impl writes it to
|
|
36
37
|
* `~/.moxxy/sessions/<id>.json`). ONE per session — the unit every surface
|
|
37
|
-
* (TUI/desktop
|
|
38
|
+
* (TUI/desktop) lists, searches and caches; the conversation itself is
|
|
38
39
|
* the append-only event stream. Fields split by owner: the runner owns the
|
|
39
40
|
* content fields (`firstPrompt`/`eventCount`/`lastActivity`/`provider`/`model`/
|
|
40
41
|
* `startedAt`/`source`); the UI owns `title` (rename) and `groupId` (desk).
|