@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.6
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/CHANGELOG.md +1288 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +13 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- package/src/util/cli.mjs +70 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily wall-clock scheduling for the plugin's self-update check.
|
|
3
|
+
*
|
|
4
|
+
* The requirement is "every day at 00:00, 03:00 and 05:00 Beijing time". Three
|
|
5
|
+
* things make that harder than a `setInterval(24h)`:
|
|
6
|
+
*
|
|
7
|
+
* 1. **It is a wall-clock time in a named zone, not an interval.** The machine
|
|
8
|
+
* may sit in any time zone; the times are Beijing's. We therefore convert
|
|
9
|
+
* through the zone rather than trusting the host's local clock.
|
|
10
|
+
* 2. **The machine sleeps.** A laptop closed at 23:50 and opened at 08:00 has
|
|
11
|
+
* missed all three slots. The user's intent is a daily check, not three
|
|
12
|
+
* reminders, so we run the check **once** on wake and resume the normal
|
|
13
|
+
* schedule -- we do not fire three times in a row.
|
|
14
|
+
* 3. **Timers drift and clocks jump.** Every wake re-derives the next instant
|
|
15
|
+
* from the current clock instead of trusting an accumulated delay.
|
|
16
|
+
*
|
|
17
|
+
* `dsh-schedule` was the obvious candidate to delegate this to and it is the
|
|
18
|
+
* wrong tool: its reminders are bound to an Agent Session and are delivered by
|
|
19
|
+
* appending a message to that Session's inbox. Its own README states it "cannot
|
|
20
|
+
* be mounted alone in a headless or SDK-only composition" and that the shipped
|
|
21
|
+
* Web composition carries no `schedule` row. A plugin-owned recurring job has no
|
|
22
|
+
* Session to bind to, so it owns its timer -- reversibly, via `ctx.effect`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Default check times: midnight, 03:00 and 05:00. */
|
|
26
|
+
export const DEFAULT_DAILY_TIMES = Object.freeze(['00:00:00', '03:00:00', '05:00:00']);
|
|
27
|
+
|
|
28
|
+
/** Default zone. Beijing has observed UTC+8 with no DST since 1991. */
|
|
29
|
+
export const DEFAULT_TIME_ZONE = 'Asia/Shanghai';
|
|
30
|
+
|
|
31
|
+
/** Hard error code for a malformed `HH:mm:ss`. */
|
|
32
|
+
export const SCHEDULE_TIME_INVALID = 'SCHEDULE_TIME_INVALID';
|
|
33
|
+
|
|
34
|
+
/** Hard error code for an unusable IANA zone name. */
|
|
35
|
+
export const SCHEDULE_ZONE_INVALID = 'SCHEDULE_ZONE_INVALID';
|
|
36
|
+
|
|
37
|
+
const SECOND_MS = 1000;
|
|
38
|
+
const DAY_MS = 86_400 * SECOND_MS;
|
|
39
|
+
|
|
40
|
+
/** setTimeout saturates above 2^31-1 ms; longer waits must be re-armed. */
|
|
41
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How late a slot may be and still run on startup.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately much shorter than a day. With a day-long window the scheduler
|
|
47
|
+
* would fire at whatever hour the process happened to start -- 06:00, 14:00,
|
|
48
|
+
* whenever -- because some slot from the last 24 hours always qualifies. That
|
|
49
|
+
* turns "check daily at 00:00/03:00/05:00" into "check at every start", which is
|
|
50
|
+
* not what was asked and would also mean three checks on a restart-heavy day.
|
|
51
|
+
*
|
|
52
|
+
* 90 minutes covers the real case (a machine asleep across one slot, or a slow
|
|
53
|
+
* boot) without inventing runs at unrelated hours. A caller with different needs
|
|
54
|
+
* passes `catchUpMs` explicitly.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_CATCH_UP_MS = 90 * 60_000;
|
|
57
|
+
|
|
58
|
+
const TIME_RE = /^(\d{2}):(\d{2}):(\d{2})$/;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse `HH:mm:ss` into a second-of-day.
|
|
62
|
+
*
|
|
63
|
+
* Strict on purpose: the value usually arrives from configuration, and a
|
|
64
|
+
* silently-accepted `"25:00"` or `"1:00"` would move the check to a time the
|
|
65
|
+
* user never asked for.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} value - `HH:mm:ss`, optionally with 1-3 fractional digits.
|
|
68
|
+
* @returns {number} Seconds since local midnight, 0-86399.
|
|
69
|
+
* @throws {Error} `SCHEDULE_TIME_INVALID` when the value is not a valid time.
|
|
70
|
+
*/
|
|
71
|
+
export function parseTimeOfDay(value) {
|
|
72
|
+
const m = typeof value === 'string' ? TIME_RE.exec(value) : null;
|
|
73
|
+
if (!m) {
|
|
74
|
+
throw Object.assign(
|
|
75
|
+
new Error(`invalid time ${JSON.stringify(value)}: expected HH:mm:ss`),
|
|
76
|
+
{ code: SCHEDULE_TIME_INVALID },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const h = Number(m[1]);
|
|
80
|
+
const mi = Number(m[2]);
|
|
81
|
+
const s = Number(m[3]);
|
|
82
|
+
if (h > 23 || mi > 59 || s > 59) {
|
|
83
|
+
throw Object.assign(
|
|
84
|
+
new Error(`invalid time ${JSON.stringify(value)}: out of range`),
|
|
85
|
+
{ code: SCHEDULE_TIME_INVALID },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return h * 3600 + mi * 60 + s;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Format a second-of-day back to `HH:mm:ss`.
|
|
93
|
+
*
|
|
94
|
+
* @param {number} seconds - Seconds since local midnight.
|
|
95
|
+
* @returns {string} The `HH:mm:ss` spelling.
|
|
96
|
+
*/
|
|
97
|
+
export function formatTimeOfDay(seconds) {
|
|
98
|
+
const h = Math.floor(seconds / 3600);
|
|
99
|
+
const mi = Math.floor((seconds % 3600) / 60);
|
|
100
|
+
const s = seconds % 60;
|
|
101
|
+
return [h, mi, s].map((n) => String(n).padStart(2, '0')).join(':');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Normalize a list of times into ascending unique order.
|
|
106
|
+
*
|
|
107
|
+
* Duplicates matter: two identical entries would arm two timers for one instant
|
|
108
|
+
* and run the check twice.
|
|
109
|
+
*
|
|
110
|
+
* @param {string[]} times - `HH:mm:ss` values.
|
|
111
|
+
* @returns {{seconds: number[], formatted: string[]}} Normalized values.
|
|
112
|
+
* @throws {Error} `SCHEDULE_TIME_INVALID` on any malformed entry.
|
|
113
|
+
*/
|
|
114
|
+
export function normalizeTimes(times) {
|
|
115
|
+
const list = Array.isArray(times) && times.length > 0 ? times : DEFAULT_DAILY_TIMES;
|
|
116
|
+
const seconds = [...new Set(list.map(parseTimeOfDay))].sort((a, b) => a - b);
|
|
117
|
+
return { seconds, formatted: seconds.map(formatTimeOfDay) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Cached `Intl.DateTimeFormat` per zone; constructing one is not cheap. */
|
|
121
|
+
const formatters = new Map();
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build (and cache) a wall-clock formatter for a zone.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} timeZone - IANA zone name.
|
|
127
|
+
* @returns {Intl.DateTimeFormat} Formatter yielding the zone's wall clock.
|
|
128
|
+
* @throws {Error} `SCHEDULE_ZONE_INVALID` when the zone is not recognized.
|
|
129
|
+
*/
|
|
130
|
+
function formatterFor(timeZone) {
|
|
131
|
+
const cached = formatters.get(timeZone);
|
|
132
|
+
if (cached) return cached;
|
|
133
|
+
let fmt;
|
|
134
|
+
try {
|
|
135
|
+
fmt = new Intl.DateTimeFormat('en-US', {
|
|
136
|
+
timeZone,
|
|
137
|
+
hourCycle: 'h23',
|
|
138
|
+
year: 'numeric',
|
|
139
|
+
month: '2-digit',
|
|
140
|
+
day: '2-digit',
|
|
141
|
+
hour: '2-digit',
|
|
142
|
+
minute: '2-digit',
|
|
143
|
+
second: '2-digit',
|
|
144
|
+
});
|
|
145
|
+
} catch (cause) {
|
|
146
|
+
throw Object.assign(
|
|
147
|
+
new Error(`invalid time zone ${JSON.stringify(timeZone)}`),
|
|
148
|
+
{ code: SCHEDULE_ZONE_INVALID, cause },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
formatters.set(timeZone, fmt);
|
|
152
|
+
return fmt;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read an instant as wall-clock fields in a zone.
|
|
157
|
+
*
|
|
158
|
+
* The offset is derived from `Intl` rather than hardcoded, so a configured zone
|
|
159
|
+
* other than Beijing still gets correct results -- including zones that observe
|
|
160
|
+
* DST.
|
|
161
|
+
*
|
|
162
|
+
* @param {number} epochMs - The instant.
|
|
163
|
+
* @param {string} timeZone - IANA zone name.
|
|
164
|
+
* @returns {{year: number, month: number, day: number, hour: number, minute: number, second: number, offsetMs: number}}
|
|
165
|
+
* Wall-clock fields plus the zone's offset at that instant.
|
|
166
|
+
*/
|
|
167
|
+
export function zonedParts(epochMs, timeZone) {
|
|
168
|
+
const fmt = formatterFor(timeZone);
|
|
169
|
+
const parts = Object.fromEntries(
|
|
170
|
+
fmt.formatToParts(new Date(epochMs))
|
|
171
|
+
.filter((p) => p.type !== 'literal')
|
|
172
|
+
.map((p) => [p.type, p.value]),
|
|
173
|
+
);
|
|
174
|
+
const wall = Date.UTC(
|
|
175
|
+
Number(parts.year),
|
|
176
|
+
Number(parts.month) - 1,
|
|
177
|
+
Number(parts.day),
|
|
178
|
+
Number(parts.hour),
|
|
179
|
+
Number(parts.minute),
|
|
180
|
+
Number(parts.second),
|
|
181
|
+
);
|
|
182
|
+
// `wall` is the zone's wall clock misread as UTC; the offset is the gap. We
|
|
183
|
+
// only need second resolution, so dropping milliseconds is safe and keeps the
|
|
184
|
+
// returned instant aligned to a second boundary.
|
|
185
|
+
const truncated = Math.floor(epochMs / SECOND_MS) * SECOND_MS;
|
|
186
|
+
return {
|
|
187
|
+
year: Number(parts.year),
|
|
188
|
+
month: Number(parts.month),
|
|
189
|
+
day: Number(parts.day),
|
|
190
|
+
hour: Number(parts.hour),
|
|
191
|
+
minute: Number(parts.minute),
|
|
192
|
+
second: Number(parts.second),
|
|
193
|
+
offsetMs: wall - truncated,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Milliseconds since local midnight in the zone, for an instant.
|
|
199
|
+
*
|
|
200
|
+
* Currently unreferenced: `nextOccurrence` works from zone parts directly. Kept because it names
|
|
201
|
+
* the inverse of what `nextOccurrence` computes and is the obvious place a future caller will look.
|
|
202
|
+
* Prefixed `_`-less and exported nowhere, so the linter is told about it rather than silencing it
|
|
203
|
+
* globally.
|
|
204
|
+
*
|
|
205
|
+
* @param {number} epochMs - The instant.
|
|
206
|
+
* @param {string} timeZone - IANA zone name.
|
|
207
|
+
* @returns {number} Seconds since local midnight.
|
|
208
|
+
*/
|
|
209
|
+
// eslint-disable-next-line no-unused-vars -- kept as the documented inverse of nextOccurrence
|
|
210
|
+
function secondOfDayIn(epochMs, timeZone) {
|
|
211
|
+
const p = zonedParts(epochMs, timeZone);
|
|
212
|
+
return p.hour * 3600 + p.minute * 60 + p.second;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Offset of the zone for the local date `YYYY-MM-DD`, sampled at local noon. */
|
|
216
|
+
function offsetForLocalDate(parts, timeZone) {
|
|
217
|
+
const guess = Date.UTC(parts.year, parts.month - 1, parts.day, 12, 0, 0);
|
|
218
|
+
return zonedParts(guess, timeZone).offsetMs;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The next instant at or after `fromMs` whose local wall clock in `timeZone`
|
|
223
|
+
* equals `secondOfDay`.
|
|
224
|
+
*
|
|
225
|
+
* @param {number} fromMs - Lower bound, inclusive.
|
|
226
|
+
* @param {number} secondOfDay - Target second since local midnight.
|
|
227
|
+
* @param {string} timeZone - IANA zone name.
|
|
228
|
+
* @returns {number} The next matching instant, epoch milliseconds.
|
|
229
|
+
*/
|
|
230
|
+
export function nextOccurrence(fromMs, secondOfDay, timeZone = DEFAULT_TIME_ZONE) {
|
|
231
|
+
formatterFor(timeZone);
|
|
232
|
+
const p = zonedParts(fromMs, timeZone);
|
|
233
|
+
for (let dayOffset = 0; dayOffset <= 2; dayOffset += 1) {
|
|
234
|
+
// Walk local calendar days by rebuilding the date from its parts, so month
|
|
235
|
+
// and year rollover are handled by `Date.UTC` rather than by arithmetic.
|
|
236
|
+
const dayProbe = Date.UTC(p.year, p.month - 1, p.day + dayOffset, 12, 0, 0);
|
|
237
|
+
const dp = zonedParts(dayProbe, timeZone);
|
|
238
|
+
const offset = offsetForLocalDate({ year: dp.year, month: dp.month, day: dp.day }, timeZone);
|
|
239
|
+
// The wall clock misread as UTC, minus the offset, is the true instant.
|
|
240
|
+
const candidate = Date.UTC(dp.year, dp.month - 1, dp.day, 0, 0, 0) + secondOfDay * SECOND_MS - offset;
|
|
241
|
+
if (candidate >= fromMs) return candidate;
|
|
242
|
+
}
|
|
243
|
+
// Unreachable: any second-of-day recurs within 24h, and we searched 3 days.
|
|
244
|
+
throw new Error(`no occurrence of ${formatTimeOfDay(secondOfDay)} within 3 days in ${timeZone}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The next scheduled instant strictly after `afterMs`, over a set of times.
|
|
249
|
+
*
|
|
250
|
+
* @param {number} afterMs - Exclusive lower bound.
|
|
251
|
+
* @param {number[]} timesSeconds - Normalized times, seconds since local midnight.
|
|
252
|
+
* @param {string} timeZone - IANA zone name.
|
|
253
|
+
* @returns {{atMs: number, secondOfDay: number, time: string}|null} The next slot.
|
|
254
|
+
*/
|
|
255
|
+
export function nextSlot(afterMs, timesSeconds, timeZone = DEFAULT_TIME_ZONE) {
|
|
256
|
+
let best = null;
|
|
257
|
+
for (const seconds of timesSeconds) {
|
|
258
|
+
const candidate = nextOccurrence(afterMs + 1, seconds, timeZone);
|
|
259
|
+
if (!best || candidate < best.atMs) best = { atMs: candidate, secondOfDay: seconds, time: formatTimeOfDay(seconds) };
|
|
260
|
+
}
|
|
261
|
+
return best;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The scheduled slot most recently passed, within a catch-up window.
|
|
266
|
+
*
|
|
267
|
+
* Used when a timer fires late -- the machine was suspended, or the process
|
|
268
|
+
* started after the slot -- so the check still happens once instead of being
|
|
269
|
+
* silently skipped until tomorrow.
|
|
270
|
+
*
|
|
271
|
+
* Strictly before `nowMs`: a slot that is exactly now is not missed. It is about
|
|
272
|
+
* to be armed by `nextSlot`, which is inclusive, so treating it as missed here
|
|
273
|
+
* would both run the check and arm a timer for the same instant.
|
|
274
|
+
*
|
|
275
|
+
* @param {number} nowMs - The current instant.
|
|
276
|
+
* @param {number[]} timesSeconds - Normalized times.
|
|
277
|
+
* @param {string} timeZone - IANA zone name.
|
|
278
|
+
* @param {number} [maxAgeMs] - How far back a missed slot still counts.
|
|
279
|
+
* @returns {{atMs: number, secondOfDay: number, time: string}|null} The slot, or null.
|
|
280
|
+
*/
|
|
281
|
+
export function missedSlot(nowMs, timesSeconds, timeZone = DEFAULT_TIME_ZONE, maxAgeMs = DAY_MS) {
|
|
282
|
+
const nowParts = zonedParts(nowMs, timeZone);
|
|
283
|
+
let best = null;
|
|
284
|
+
for (const seconds of timesSeconds) {
|
|
285
|
+
// Only today and yesterday can hold a recent occurrence, and we test both
|
|
286
|
+
// rather than deriving "yesterday" from the next occurrence: when the next
|
|
287
|
+
// occurrence is almost a full day away, that derivation lands two days back
|
|
288
|
+
// and reports a slot that never happened as if it had.
|
|
289
|
+
for (const backDays of [0, 1]) {
|
|
290
|
+
const dayProbe = Date.UTC(nowParts.year, nowParts.month - 1, nowParts.day - backDays, 12, 0, 0);
|
|
291
|
+
const dp = zonedParts(dayProbe, timeZone);
|
|
292
|
+
const offset = offsetForLocalDate({ year: dp.year, month: dp.month, day: dp.day }, timeZone);
|
|
293
|
+
const candidate = Date.UTC(dp.year, dp.month - 1, dp.day, 0, 0, 0) + seconds * SECOND_MS - offset;
|
|
294
|
+
if (candidate >= nowMs) continue; // exactly-now belongs to nextSlot, which is inclusive
|
|
295
|
+
if (nowMs - candidate > maxAgeMs) continue;
|
|
296
|
+
if (!best || candidate > best.atMs) {
|
|
297
|
+
best = { atMs: candidate, secondOfDay: seconds, time: formatTimeOfDay(seconds) };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return best;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Create a daily scheduler.
|
|
306
|
+
*
|
|
307
|
+
* Every side effect is reachable from `stop()`, so the caller can hand it to
|
|
308
|
+
* `ctx.effect` and be certain no timer survives a plugin unload. The timer is
|
|
309
|
+
* `unref`'d: a pending check must never be the reason a process stays alive.
|
|
310
|
+
*
|
|
311
|
+
* @param {object} options - Scheduler options.
|
|
312
|
+
* @param {(info: {scheduledAtMs: number, ranAtMs: number, slot: string, late: boolean}) => (void|Promise<void>)} options.run
|
|
313
|
+
* Invoked once per due slot. Rejections are routed to `onError` and never
|
|
314
|
+
* escape, so one failing check cannot kill the schedule.
|
|
315
|
+
* @param {string[]} [options.times] - `HH:mm:ss` slots; defaults to 00:00/03:00/05:00.
|
|
316
|
+
* @param {string} [options.timeZone] - IANA zone; defaults to Asia/Shanghai.
|
|
317
|
+
* @param {number} [options.catchUpMs] - How late a missed slot may be and still run.
|
|
318
|
+
* @param {(err: Error) => void} [options.onError] - Error sink; defaults to a no-op.
|
|
319
|
+
* @param {() => number} [options.nowMs] - Clock, injectable for tests.
|
|
320
|
+
* @param {(fn: () => void, ms: number) => any} [options.setTimer] - Timer, injectable.
|
|
321
|
+
* @param {(h: any) => void} [options.clearTimer] - Timer canceller, injectable.
|
|
322
|
+
* @returns {object} `{start, stop, triggerNow, describe}`.
|
|
323
|
+
*/
|
|
324
|
+
export function createDailyScheduler(options) {
|
|
325
|
+
const {
|
|
326
|
+
run,
|
|
327
|
+
times,
|
|
328
|
+
timeZone = DEFAULT_TIME_ZONE,
|
|
329
|
+
catchUpMs = DEFAULT_CATCH_UP_MS,
|
|
330
|
+
onError = () => {},
|
|
331
|
+
nowMs = Date.now,
|
|
332
|
+
setTimer = setTimeout,
|
|
333
|
+
clearTimer = clearTimeout,
|
|
334
|
+
} = options ?? {};
|
|
335
|
+
|
|
336
|
+
if (typeof run !== 'function') throw new TypeError('createDailyScheduler requires a run function');
|
|
337
|
+
|
|
338
|
+
const normalized = normalizeTimes(times);
|
|
339
|
+
|
|
340
|
+
const state = {
|
|
341
|
+
running: false,
|
|
342
|
+
/** @type {any} */ timer: null,
|
|
343
|
+
nextAtMs: /** @type {number|null} */ (null),
|
|
344
|
+
nextTime: /** @type {string|null} */ (null),
|
|
345
|
+
lastRunMs: /** @type {number|null} */ (null),
|
|
346
|
+
lastSlot: /** @type {string|null} */ (null),
|
|
347
|
+
runs: 0,
|
|
348
|
+
failures: 0,
|
|
349
|
+
stopped: false,
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/** Arm the timer for the next slot, re-deriving it from the current clock. */
|
|
353
|
+
function arm() {
|
|
354
|
+
if (state.stopped) return;
|
|
355
|
+
const now = nowMs();
|
|
356
|
+
const slot = nextSlot(now, normalized.seconds, timeZone);
|
|
357
|
+
state.nextAtMs = slot.atMs;
|
|
358
|
+
state.nextTime = slot.time;
|
|
359
|
+
const delay = Math.max(0, Math.min(slot.atMs - now, MAX_TIMER_MS));
|
|
360
|
+
state.timer = setTimer(fire, delay);
|
|
361
|
+
// Never keep the event loop alive just to wait for a scheduled check.
|
|
362
|
+
state.timer?.unref?.();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Run the check, then re-arm. Overlapping runs are impossible by construction. */
|
|
366
|
+
async function fire() {
|
|
367
|
+
if (state.stopped || state.running) return;
|
|
368
|
+
state.running = true;
|
|
369
|
+
const firedFor = state.nextAtMs;
|
|
370
|
+
try {
|
|
371
|
+
await run({
|
|
372
|
+
scheduledAtMs: firedFor,
|
|
373
|
+
ranAtMs: nowMs(),
|
|
374
|
+
slot: state.nextTime,
|
|
375
|
+
// Late means the timer was not merely imprecise but genuinely delayed --
|
|
376
|
+
// a suspended machine, not timer jitter.
|
|
377
|
+
late: firedFor === null ? false : nowMs() - firedFor > SECOND_MS,
|
|
378
|
+
});
|
|
379
|
+
state.runs += 1;
|
|
380
|
+
state.lastRunMs = nowMs();
|
|
381
|
+
state.lastSlot = state.nextTime;
|
|
382
|
+
} catch (error) {
|
|
383
|
+
state.failures += 1;
|
|
384
|
+
try {
|
|
385
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
386
|
+
} catch {
|
|
387
|
+
// A throwing error sink must not break the schedule either.
|
|
388
|
+
}
|
|
389
|
+
} finally {
|
|
390
|
+
state.running = false;
|
|
391
|
+
arm();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
/** Arm the first timer, running a catch-up check if a slot was just missed. */
|
|
397
|
+
start() {
|
|
398
|
+
if (state.stopped) throw new Error('scheduler already stopped');
|
|
399
|
+
const now = nowMs();
|
|
400
|
+
const missed = missedSlot(now, normalized.seconds, timeZone, catchUpMs);
|
|
401
|
+
if (missed) {
|
|
402
|
+
// Report the deadline that was actually missed, then continue normally.
|
|
403
|
+
// One catch-up run for any number of missed slots: the user asked for a
|
|
404
|
+
// daily check, not one per skipped hour.
|
|
405
|
+
state.nextAtMs = missed.atMs;
|
|
406
|
+
state.nextTime = missed.time;
|
|
407
|
+
void fire();
|
|
408
|
+
} else {
|
|
409
|
+
arm();
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
/** Run the check immediately, without disturbing the daily schedule. */
|
|
413
|
+
async triggerNow(reason = 'manual') {
|
|
414
|
+
if (state.stopped) throw new Error('scheduler already stopped');
|
|
415
|
+
try {
|
|
416
|
+
await run({ scheduledAtMs: null, ranAtMs: nowMs(), slot: reason, late: false });
|
|
417
|
+
state.runs += 1;
|
|
418
|
+
state.lastRunMs = nowMs();
|
|
419
|
+
} catch (error) {
|
|
420
|
+
state.failures += 1;
|
|
421
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
/** Release the timer. Idempotent, and safe to call from a disposer. */
|
|
425
|
+
stop() {
|
|
426
|
+
state.stopped = true;
|
|
427
|
+
if (state.timer !== null) {
|
|
428
|
+
clearTimer(state.timer);
|
|
429
|
+
state.timer = null;
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
/** Diagnostic snapshot for `w2m_status`. Contains no seconds-resolution secrets. */
|
|
433
|
+
describe() {
|
|
434
|
+
return {
|
|
435
|
+
time_zone: timeZone,
|
|
436
|
+
times: normalized.formatted,
|
|
437
|
+
catch_up_ms: catchUpMs,
|
|
438
|
+
started: state.nextAtMs !== null,
|
|
439
|
+
stopped: state.stopped,
|
|
440
|
+
next_at_ms: state.nextAtMs,
|
|
441
|
+
next_at: state.nextAtMs === null ? null : new Date(state.nextAtMs).toISOString(),
|
|
442
|
+
next_time: state.nextTime,
|
|
443
|
+
last_run_ms: state.lastRunMs,
|
|
444
|
+
last_run: state.lastRunMs === null ? null : new Date(state.lastRunMs).toISOString(),
|
|
445
|
+
last_slot: state.lastSlot,
|
|
446
|
+
runs: state.runs,
|
|
447
|
+
failures: state.failures,
|
|
448
|
+
running: state.running,
|
|
449
|
+
};
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
}
|