@ciphore/radiocli 0.2.1 → 0.2.3
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 +133 -1
- package/README.md +76 -6
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +143 -0
- package/dist/agent/launcher.js +71 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +248 -0
- package/dist/alarms/active-session.js +183 -0
- package/dist/alarms/cli.js +312 -0
- package/dist/alarms/guard.js +343 -0
- package/dist/alarms/inhibitor.js +48 -0
- package/dist/alarms/power-guard-store.js +169 -0
- package/dist/alarms/runner.js +342 -0
- package/dist/alarms/runtime-health.js +79 -0
- package/dist/alarms/schedule.js +149 -0
- package/dist/alarms/scheduler.js +250 -0
- package/dist/alarms/setup-verification.js +187 -0
- package/dist/alarms/system-volume.js +43 -0
- package/dist/alarms/terminal-launcher.js +181 -0
- package/dist/alarms/tui-presence.js +38 -0
- package/dist/cli.js +113 -5
- package/dist/player/backend-install.js +2 -1
- package/dist/player/command-diagnostics.js +27 -0
- package/dist/player/command.js +123 -62
- package/dist/player/player-controller.js +32 -2
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +36 -6
- package/dist/setup.js +462 -0
- package/dist/storage/store.js +262 -2
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +111 -26
- package/dist/ui/App.js +401 -67
- package/dist/ui/AppContent.js +24 -7
- package/dist/ui/adaptive-explore-layout.js +47 -0
- package/dist/ui/alarm-editor.js +174 -0
- package/dist/ui/alarm-tui-service.js +84 -0
- package/dist/ui/app-state.js +3 -0
- package/dist/ui/ascii.js +8 -0
- package/dist/ui/components/AdaptiveMarquee.js +28 -0
- package/dist/ui/components/StationList.js +10 -5
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/cosmo-world-map.js +5 -2
- package/dist/ui/explore-map-layout.js +18 -6
- package/dist/ui/format.js +21 -0
- package/dist/ui/help-content.js +14 -2
- package/dist/ui/layout.js +1 -1
- package/dist/ui/page-footer.js +120 -2
- package/dist/ui/receiver-animation.js +68 -0
- package/dist/ui/screen-items.js +41 -9
- package/dist/ui/screen-meta.js +4 -0
- package/dist/ui/screens/AlarmsScreen.js +202 -0
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +8 -3
- package/dist/ui/screens/HomeScreen.js +3 -1
- package/dist/ui/screens/NowPlayingScreen.js +6 -2
- package/dist/ui/screens/SettingsScreen.js +70 -53
- package/dist/ui/screens/StationScreen.js +3 -2
- package/dist/ui/selection-state.js +10 -0
- package/dist/ui/terminal-mouse.js +18 -3
- package/dist/ui/use-alarm-tui.js +727 -0
- package/dist/ui/use-app-input.js +107 -46
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +14 -7
- package/dist/ui/visualizers/receiver-visualizers.js +233 -128
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +26 -7
- package/package.json +6 -1
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { assessScheduledOccurrence, NATIVE_DISPATCH_TOLERANCE_MS, nextOccurrenceForAlarm } from './schedule.js';
|
|
5
|
+
import { startActiveAlarmSession } from './active-session.js';
|
|
6
|
+
export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
7
|
+
if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(scheduledAtText))
|
|
8
|
+
throw new Error('Scheduled alarm occurrence must be an absolute date.');
|
|
9
|
+
const scheduledAt = new Date(scheduledAtText);
|
|
10
|
+
if (!Number.isFinite(scheduledAt.getTime()))
|
|
11
|
+
throw new Error('Scheduled alarm occurrence must be an absolute date.');
|
|
12
|
+
const releaseLock = deps.acquireLock(alarmId, scheduledAt.toISOString());
|
|
13
|
+
if (!releaseLock) {
|
|
14
|
+
const current = deps.store.getAlarm(alarmId);
|
|
15
|
+
if (current)
|
|
16
|
+
try {
|
|
17
|
+
await deps.scheduler.sync(current);
|
|
18
|
+
}
|
|
19
|
+
catch { }
|
|
20
|
+
return { duplicate: true, message: 'This alarm occurrence is already running or completed.' };
|
|
21
|
+
}
|
|
22
|
+
let alarm = deps.store.getAlarm(alarmId);
|
|
23
|
+
let outcome;
|
|
24
|
+
let session;
|
|
25
|
+
let lease;
|
|
26
|
+
let systemVolumeLease;
|
|
27
|
+
let listening = false;
|
|
28
|
+
let firedAt;
|
|
29
|
+
let preserveNextOverride = false;
|
|
30
|
+
let preserveSystemVolume = false;
|
|
31
|
+
let validTerminalOccurrence = false;
|
|
32
|
+
let signalReceived = false;
|
|
33
|
+
let resolveEarlySignal = () => { };
|
|
34
|
+
let onPlaybackSignal = () => { };
|
|
35
|
+
const earlySignal = new Promise(resolve => { resolveEarlySignal = resolve; });
|
|
36
|
+
const unsubscribeSignals = deps.subscribeSignals?.(() => { signalReceived = true; resolveEarlySignal(); onPlaybackSignal(); void deps.player.stop().catch(() => undefined); });
|
|
37
|
+
const finish = (status, message) => ({ status, scheduledAt: scheduledAt.toISOString(), ...(firedAt ? { firedAt: firedAt.toISOString() } : {}), finishedAt: deps.now().toISOString(), ...(message ? { message } : {}) });
|
|
38
|
+
let action;
|
|
39
|
+
let keepPlaying = false;
|
|
40
|
+
let resolveAction = () => { };
|
|
41
|
+
const actionPromise = new Promise(resolve => { resolveAction = resolve; });
|
|
42
|
+
try {
|
|
43
|
+
if (!alarm || !alarm.enabled)
|
|
44
|
+
return { message: 'Alarm is missing or disabled.' };
|
|
45
|
+
const expected = nextExpectedOccurrence(alarm, scheduledAt);
|
|
46
|
+
if (!expected || Math.abs(expected.getTime() - scheduledAt.getTime()) > 1000) {
|
|
47
|
+
outcome = finish('missed', 'Ignored a stale native task whose occurrence no longer matches this alarm.');
|
|
48
|
+
return { status: 'missed', message: outcome.message };
|
|
49
|
+
}
|
|
50
|
+
const assessment = assessScheduledOccurrence(scheduledAt, deps.now(), alarm.reliability.missedRunGraceMinutes);
|
|
51
|
+
if (assessment === 'pending') {
|
|
52
|
+
outcome = finish('failed', 'Scheduler launched the alarm before its occurrence.');
|
|
53
|
+
return { status: outcome.status, message: outcome.message };
|
|
54
|
+
}
|
|
55
|
+
validTerminalOccurrence = true;
|
|
56
|
+
if (assessment === 'missed') {
|
|
57
|
+
outcome = finish('missed', 'Alarm was outside its missed-run grace window.');
|
|
58
|
+
return { status: outcome.status, message: outcome.message };
|
|
59
|
+
}
|
|
60
|
+
const claimingStatus = { alarmId, scheduledAt: scheduledAt.toISOString(), stationName: alarm.station.name, station: alarm.station, startedAt: deps.now().toISOString(), state: 'starting' };
|
|
61
|
+
const creatingSession = deps.createSession(claimingStatus, {
|
|
62
|
+
onDismiss: () => { action = 'dismissed'; resolveAction('dismissed'); resolveEarlySignal(); },
|
|
63
|
+
onSnooze: minutes => { deps.store.snoozeAlarm(alarmId, new Date(deps.now().getTime() + minutes * 60_000)); preserveNextOverride = true; action = 'snoozed'; resolveAction('snoozed'); resolveEarlySignal(); },
|
|
64
|
+
onKeepPlaying: () => { keepPlaying = true; session?.update({ keepPlaying: true }); },
|
|
65
|
+
onHandoff: () => { if (!listening)
|
|
66
|
+
throw new Error('Alarm playback is still starting.'); preserveSystemVolume = true; action = 'handoff'; resolveAction('handoff'); }
|
|
67
|
+
});
|
|
68
|
+
void creatingSession.then(created => { if (signalReceived)
|
|
69
|
+
void created.close().catch(() => undefined); }).catch(() => { });
|
|
70
|
+
const created = await Promise.race([creatingSession.then(value => ({ value })), earlySignal.then(() => ({ signal: true }))]);
|
|
71
|
+
if ('signal' in created) {
|
|
72
|
+
outcome = finish('dismissed', 'Alarm interrupted while local controls were starting.');
|
|
73
|
+
return { status: 'dismissed', message: outcome.message };
|
|
74
|
+
}
|
|
75
|
+
session = created.value;
|
|
76
|
+
const claimed = deps.store.getAlarm(alarmId);
|
|
77
|
+
if (claimed?.enabled && claimed.schedule.type === 'recurring') {
|
|
78
|
+
if (deps.scheduler.syncClaimed)
|
|
79
|
+
await deps.scheduler.syncClaimed(claimed, scheduledAt);
|
|
80
|
+
else
|
|
81
|
+
await deps.scheduler.sync(claimed);
|
|
82
|
+
}
|
|
83
|
+
let resolvedStation = alarm.station;
|
|
84
|
+
let lastError;
|
|
85
|
+
let interactivePreempted = false;
|
|
86
|
+
let outputPrepared = false;
|
|
87
|
+
const candidates = [alarm.station, alarm.station, alarm.playback.fallbackStation].filter((item) => Boolean(item));
|
|
88
|
+
for (const [candidateIndex, candidate] of candidates.entries()) {
|
|
89
|
+
try {
|
|
90
|
+
const resolved = await Promise.race([deps.providers.resolve(candidate).then(stream => ({ stream })), earlySignal.then(() => ({ signal: true }))]);
|
|
91
|
+
if ('signal' in resolved) {
|
|
92
|
+
outcome = finish('dismissed', 'Alarm interrupted before playback started.');
|
|
93
|
+
return { status: 'dismissed', message: outcome.message };
|
|
94
|
+
}
|
|
95
|
+
if (!interactivePreempted) {
|
|
96
|
+
interactivePreempted = true;
|
|
97
|
+
try {
|
|
98
|
+
await deps.preemptInteractivePlayback?.();
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: true, message: `Alarm continued after interactive playback could not be stopped cleanly: ${errorMessage(error)}` });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!outputPrepared) {
|
|
105
|
+
outputPrepared = true;
|
|
106
|
+
if (deps.systemVolume)
|
|
107
|
+
try {
|
|
108
|
+
systemVolumeLease = await deps.systemVolume.acquireMinimum(alarm.playback.volume);
|
|
109
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: true, active: true, message: systemVolumeLease.message });
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: false, message: `Alarm will use player volume, but system output could not be raised: ${errorMessage(error)}` });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const playPromise = deps.player.play(candidate, resolved.stream.url);
|
|
116
|
+
const tuned = await Promise.race([playPromise.then(() => ({ played: true })), earlySignal.then(() => ({ signal: true }))]);
|
|
117
|
+
if ('signal' in tuned || signalReceived) {
|
|
118
|
+
void playPromise.then(() => deps.player.stop()).catch(() => undefined);
|
|
119
|
+
outcome = finish('dismissed', 'Alarm interrupted while playback was starting.');
|
|
120
|
+
return { status: 'dismissed', message: outcome.message };
|
|
121
|
+
}
|
|
122
|
+
resolvedStation = candidate;
|
|
123
|
+
lastError = undefined;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
lastError = error;
|
|
128
|
+
}
|
|
129
|
+
if (candidateIndex < candidates.length - 1) {
|
|
130
|
+
const remaining = scheduledAt.getTime() + Math.max(NATIVE_DISPATCH_TOLERANCE_MS, alarm.reliability.missedRunGraceMinutes * 60_000) - deps.now().getTime();
|
|
131
|
+
if (remaining <= 0)
|
|
132
|
+
break;
|
|
133
|
+
const backoff = await Promise.race([deps.wait(Math.min(remaining, 1_000, 500 * (candidateIndex + 1))).then(() => ({ waited: true })), earlySignal.then(() => ({ signal: true }))]);
|
|
134
|
+
if ('signal' in backoff) {
|
|
135
|
+
outcome = finish('dismissed', 'Alarm interrupted during station retry backoff.');
|
|
136
|
+
return { status: 'dismissed', message: outcome.message };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (lastError)
|
|
141
|
+
throw lastError;
|
|
142
|
+
firedAt = deps.now();
|
|
143
|
+
try {
|
|
144
|
+
const acquiring = deps.inhibitor.acquire('RadioCLI alarm playback');
|
|
145
|
+
void acquiring.then(acquired => { if (signalReceived)
|
|
146
|
+
void acquired.release().catch(() => undefined); }).catch(() => { });
|
|
147
|
+
const acquired = await Promise.race([acquiring.then(value => ({ value })), earlySignal.then(() => ({ signal: true }))]);
|
|
148
|
+
if ('signal' in acquired) {
|
|
149
|
+
outcome = finish('dismissed', 'Alarm interrupted while sleep protection was starting.');
|
|
150
|
+
return { status: 'dismissed', message: outcome.message };
|
|
151
|
+
}
|
|
152
|
+
lease = acquired.value;
|
|
153
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'power', healthy: true, active: true, message: 'Sleep inhibition active while playing.' });
|
|
154
|
+
void lease.unexpectedExit?.then(error => { try {
|
|
155
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'power', healthy: false, active: false, message: `Playback continues after sleep protection exited: ${error.message}` });
|
|
156
|
+
}
|
|
157
|
+
catch { } }).catch(() => { });
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'power', healthy: false, active: false, message: `Playback continues without sleep protection: ${errorMessage(error)}` });
|
|
161
|
+
}
|
|
162
|
+
const startedAt = deps.now();
|
|
163
|
+
deps.store.addRecent(resolvedStation);
|
|
164
|
+
deps.store.startListeningSession(resolvedStation, startedAt);
|
|
165
|
+
listening = true;
|
|
166
|
+
const activeStatus = { alarmId, scheduledAt: scheduledAt.toISOString(), stationName: resolvedStation.name, station: resolvedStation, startedAt: startedAt.toISOString(), state: 'playing' };
|
|
167
|
+
session.update(activeStatus);
|
|
168
|
+
void deps.openControls?.(activeStatus).catch(error => { try {
|
|
169
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: true, message: `Alarm is playing, but RadioCLI controls could not open automatically: ${errorMessage(error)}` });
|
|
170
|
+
}
|
|
171
|
+
catch { } });
|
|
172
|
+
onPlaybackSignal = () => { action = 'signal'; resolveAction('signal'); };
|
|
173
|
+
if (signalReceived)
|
|
174
|
+
onPlaybackSignal();
|
|
175
|
+
{
|
|
176
|
+
const supportsFade = deps.player.getState().backend === 'mpv';
|
|
177
|
+
if (supportsFade && alarm.playback.fadeSeconds > 0) {
|
|
178
|
+
const zero = await deps.player.setVolume(0);
|
|
179
|
+
if (zero.ok) {
|
|
180
|
+
const steps = Math.min(1800, Math.max(1, Math.ceil(alarm.playback.fadeSeconds / 2)));
|
|
181
|
+
for (let step = 1; step <= steps && !action; step += 1) {
|
|
182
|
+
await Promise.race([deps.wait(alarm.playback.fadeSeconds * 1000 / steps), actionPromise]);
|
|
183
|
+
if (!action) {
|
|
184
|
+
const changed = await deps.player.setVolume(Math.round(alarm.playback.volume * step / steps));
|
|
185
|
+
if (!changed.ok) {
|
|
186
|
+
await deps.player.setVolume(alarm.playback.volume);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else
|
|
193
|
+
await deps.player.setVolume(alarm.playback.volume);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const volume = await deps.player.setVolume(alarm.playback.volume);
|
|
197
|
+
if (!volume.ok)
|
|
198
|
+
deps.health?.record({ alarmId, component: 'runner', healthy: true, message: volume.message ?? 'The selected backend does not support runtime volume; it started with the configured alarm volume.' });
|
|
199
|
+
}
|
|
200
|
+
if (!action) {
|
|
201
|
+
const elapsed = Math.max(0, deps.now().getTime() - startedAt.getTime());
|
|
202
|
+
const timeout = deps.wait(Math.max(0, alarm.playback.stopAfterMinutes * 60_000 - elapsed)).then(() => keepPlaying ? new Promise(() => { }) : 'timeout');
|
|
203
|
+
action = await Promise.race([actionPromise, timeout]);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
outcome = finish(action === 'dismissed' || action === 'snoozed' || action === 'signal' ? 'dismissed' : 'played', action === 'snoozed' ? 'Snoozed.' : action === 'handoff' ? 'Handed off to interactive playback.' : action === 'signal' ? 'Interrupted.' : undefined);
|
|
207
|
+
return { status: outcome.status, message: outcome.message };
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
outcome = finish('failed', errorMessage(error));
|
|
211
|
+
return { status: 'failed', message: outcome.message };
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
unsubscribeSignals?.();
|
|
215
|
+
try {
|
|
216
|
+
await deps.player.stop();
|
|
217
|
+
}
|
|
218
|
+
catch { }
|
|
219
|
+
if (!preserveSystemVolume)
|
|
220
|
+
try {
|
|
221
|
+
await systemVolumeLease?.release();
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
try {
|
|
225
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: false, message: `Previous system output volume could not be restored: ${errorMessage(error)}` });
|
|
226
|
+
}
|
|
227
|
+
catch { }
|
|
228
|
+
}
|
|
229
|
+
if (listening) {
|
|
230
|
+
try {
|
|
231
|
+
deps.store.checkpointActiveListeningSession(deps.now());
|
|
232
|
+
deps.store.finishActiveListeningSession(deps.now());
|
|
233
|
+
}
|
|
234
|
+
catch { }
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
await lease?.release();
|
|
238
|
+
if (lease)
|
|
239
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'power', healthy: true, active: false, message: 'Playback sleep inhibition released.' });
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
try {
|
|
243
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'power', healthy: false, active: true, message: `Unable to verify inhibitor release: ${errorMessage(error)}` });
|
|
244
|
+
}
|
|
245
|
+
catch { }
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
await session?.close();
|
|
249
|
+
}
|
|
250
|
+
catch { }
|
|
251
|
+
if (alarm && outcome) {
|
|
252
|
+
try {
|
|
253
|
+
deps.store.recordAlarmOutcome(alarmId, outcome, { clearNextOverride: !preserveNextOverride });
|
|
254
|
+
const latest = deps.store.getAlarm(alarmId);
|
|
255
|
+
if (alarm.schedule.type === 'once' && latest?.schedule.type === 'once' && latest.schedule.at === alarm.schedule.at && validTerminalOccurrence && !preserveNextOverride)
|
|
256
|
+
deps.store.toggleAlarm(alarmId, false);
|
|
257
|
+
}
|
|
258
|
+
catch { }
|
|
259
|
+
}
|
|
260
|
+
alarm = deps.store.getAlarm(alarmId);
|
|
261
|
+
if (alarm)
|
|
262
|
+
try {
|
|
263
|
+
if (deps.scheduler.syncClaimed)
|
|
264
|
+
await deps.scheduler.syncClaimed(alarm, scheduledAt);
|
|
265
|
+
else
|
|
266
|
+
await deps.scheduler.sync(alarm);
|
|
267
|
+
}
|
|
268
|
+
catch { }
|
|
269
|
+
try {
|
|
270
|
+
releaseLock();
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
try {
|
|
274
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: false, message: `Occurrence lock cleanup failed: ${errorMessage(error)}` });
|
|
275
|
+
}
|
|
276
|
+
catch { }
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
await deps.scheduler.completeOccurrence?.(alarmId, scheduledAt);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
try {
|
|
283
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'scheduler', healthy: false, message: `Completed launch job cleanup failed: ${errorMessage(error)}` });
|
|
284
|
+
}
|
|
285
|
+
catch { }
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
export function acquireOccurrenceLock(alarmId, scheduledAt, root = defaultAlarmRuntimeDirectory()) {
|
|
290
|
+
const safe = `${alarmId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)}-${Buffer.from(scheduledAt).toString('base64url')}`;
|
|
291
|
+
const path = join(root, 'locks', safe);
|
|
292
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
293
|
+
pruneCompletedOccurrenceLocks(root);
|
|
294
|
+
try {
|
|
295
|
+
mkdirSync(path, { mode: 0o700 });
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (error.code !== 'EEXIST')
|
|
299
|
+
throw error;
|
|
300
|
+
try {
|
|
301
|
+
if (existsSync(join(path, 'completed')))
|
|
302
|
+
return null;
|
|
303
|
+
const pid = Number(readFileSync(join(path, 'running'), 'utf8'));
|
|
304
|
+
if (Number.isInteger(pid) && processAlive(pid))
|
|
305
|
+
return null;
|
|
306
|
+
rmSync(path, { recursive: true, force: true });
|
|
307
|
+
mkdirSync(path, { mode: 0o700 });
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
writeFileSync(join(path, 'running'), String(process.pid), { mode: 0o600 });
|
|
314
|
+
return () => { rmSync(join(path, 'running'), { force: true }); writeFileSync(join(path, 'completed'), new Date().toISOString(), { mode: 0o600 }); };
|
|
315
|
+
}
|
|
316
|
+
export function pruneCompletedOccurrenceLocks(root = defaultAlarmRuntimeDirectory(), olderThanMs = 30 * 24 * 60 * 60_000, now = Date.now()) { const directory = join(root, 'locks'); if (!existsSync(directory))
|
|
317
|
+
return 0; let removed = 0; for (const name of readdirSync(directory)) {
|
|
318
|
+
const completed = join(directory, name, 'completed');
|
|
319
|
+
try {
|
|
320
|
+
if (existsSync(completed) && now - statSync(completed).mtimeMs > olderThanMs) {
|
|
321
|
+
rmSync(join(directory, name), { recursive: true, force: true });
|
|
322
|
+
removed += 1;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch { }
|
|
326
|
+
} return removed; }
|
|
327
|
+
export function defaultAlarmRuntimeDirectory() { if (process.env.RADIOCLI_HOME)
|
|
328
|
+
return join(process.env.RADIOCLI_HOME, 'runtime'); if (process.platform === 'win32')
|
|
329
|
+
return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime'); return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli'); }
|
|
330
|
+
export const defaultRunnerUtilities = { acquireLock: acquireOccurrenceLock, createSession: startActiveAlarmSession, wait: (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)), subscribeSignals: (handler) => { process.once('SIGTERM', handler); process.once('SIGHUP', handler); process.once('SIGINT', handler); return () => { process.off('SIGTERM', handler); process.off('SIGHUP', handler); process.off('SIGINT', handler); }; } };
|
|
331
|
+
function errorMessage(error) { return error instanceof Error ? error.message : String(error); }
|
|
332
|
+
function processAlive(pid) { try {
|
|
333
|
+
process.kill(pid, 0);
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return false;
|
|
338
|
+
} }
|
|
339
|
+
function nextExpectedOccurrence(alarm, scheduledAt) { if (alarm.nextOverride)
|
|
340
|
+
return new Date(alarm.nextOverride.at); if (alarm.schedule.type === 'once')
|
|
341
|
+
return new Date(alarm.schedule.at); return nextOccurrenceForAlarmAt(alarm, new Date(scheduledAt.getTime() - 1500)); }
|
|
342
|
+
function nextOccurrenceForAlarmAt(alarm, now) { return nextOccurrenceForAlarm(alarm, now); }
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { defaultAlarmRuntimeDirectory } from './runner.js';
|
|
4
|
+
/** Machine-local operational state; deliberately excluded from profile exports. */
|
|
5
|
+
export class AlarmRuntimeHealthStore {
|
|
6
|
+
filePath;
|
|
7
|
+
now;
|
|
8
|
+
constructor(filePath = join(defaultAlarmRuntimeDirectory(), 'alarm-health.json'), now = () => new Date()) {
|
|
9
|
+
this.filePath = filePath;
|
|
10
|
+
this.now = now;
|
|
11
|
+
}
|
|
12
|
+
record(entry) { const current = this.now(); const value = { ...entry, updatedAt: current.toISOString() }; validateEntry(value); const release = acquireHealthLock(this.filePath); try {
|
|
13
|
+
const entries = pruneEntries([value, ...this.list().filter(item => !(item.alarmId === entry.alarmId && item.component === entry.component && item.occurrenceAt === entry.occurrenceAt))], current);
|
|
14
|
+
this.write({ version: 1, entries });
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
release();
|
|
19
|
+
} }
|
|
20
|
+
get(alarmId, component) { return this.list().filter(item => item.alarmId === alarmId && (!component || item.component === component)); }
|
|
21
|
+
list() { if (!existsSync(this.filePath))
|
|
22
|
+
return []; try {
|
|
23
|
+
const file = JSON.parse(readFileSync(this.filePath, 'utf8'));
|
|
24
|
+
if (file.version !== 1 || !Array.isArray(file.entries))
|
|
25
|
+
return [];
|
|
26
|
+
return file.entries.filter(entry => { try {
|
|
27
|
+
validateEntry(entry);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
} });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
} }
|
|
37
|
+
remove(alarmId, component) { const release = acquireHealthLock(this.filePath); try {
|
|
38
|
+
this.write({ version: 1, entries: this.list().filter(item => item.alarmId !== alarmId || (component && item.component !== component)) });
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
release();
|
|
42
|
+
} }
|
|
43
|
+
write(file) { mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 }); const temp = `${this.filePath}.tmp-${process.pid}`; try {
|
|
44
|
+
writeFileSync(temp, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
45
|
+
renameSync(temp, this.filePath);
|
|
46
|
+
if (process.platform !== 'win32')
|
|
47
|
+
chmodSync(this.filePath, 0o600);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
rmSync(temp, { force: true });
|
|
51
|
+
throw error;
|
|
52
|
+
} }
|
|
53
|
+
}
|
|
54
|
+
function validateEntry(entry) { if (!entry || typeof entry.alarmId !== 'string' || !entry.alarmId || !['scheduler', 'power', 'runner'].includes(entry.component) || typeof entry.healthy !== 'boolean' || (entry.active !== undefined && typeof entry.active !== 'boolean') || typeof entry.message !== 'string' || !Number.isFinite(Date.parse(entry.updatedAt)) || (entry.occurrenceAt !== undefined && !Number.isFinite(Date.parse(entry.occurrenceAt))) || (entry.nextOccurrence !== undefined && !Number.isFinite(Date.parse(entry.nextOccurrence))))
|
|
55
|
+
throw new Error('Invalid alarm runtime health entry.'); }
|
|
56
|
+
function pruneEntries(entries, now) { const activeCutoff = now.getTime() - 24 * 60 * 60_000; const active = entries.filter(item => item.active === true && Date.parse(item.updatedAt) >= activeCutoff).slice(0, 128); const counts = new Map(); const inactive = entries.filter(item => item.active !== true).filter(item => { const key = `${item.alarmId}\0${item.component}`; const count = counts.get(key) ?? 0; if (count >= 8)
|
|
57
|
+
return false; counts.set(key, count + 1); return true; }).slice(0, 256); return [...active, ...inactive]; }
|
|
58
|
+
function acquireHealthLock(filePath) { const path = `${filePath}.lock`; mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 }); const deadline = Date.now() + 1000; while (true) {
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(path, { mode: 0o700 });
|
|
61
|
+
return () => rmSync(path, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code !== 'EEXIST')
|
|
65
|
+
throw error;
|
|
66
|
+
try {
|
|
67
|
+
if (Date.now() - statSync(path).mtimeMs > 10_000) {
|
|
68
|
+
rmSync(path, { recursive: true, force: true });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (Date.now() >= deadline)
|
|
76
|
+
throw new Error('Alarm runtime health state is busy.');
|
|
77
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
78
|
+
}
|
|
79
|
+
} }
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export const NATIVE_DISPATCH_TOLERANCE_MS = 30_000;
|
|
2
|
+
const formatterCache = new Map();
|
|
3
|
+
export function isValidTimeZone(timezone) {
|
|
4
|
+
try {
|
|
5
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format();
|
|
6
|
+
return timezone.trim().length > 0;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function canonicalizeTimeZone(timezone) {
|
|
13
|
+
const value = timezone.trim();
|
|
14
|
+
if (!isValidTimeZone(value)) {
|
|
15
|
+
throw new Error(`Invalid IANA timezone: ${timezone}`);
|
|
16
|
+
}
|
|
17
|
+
return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
|
|
18
|
+
}
|
|
19
|
+
export function canonicalizeAlarmTime(time) {
|
|
20
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
|
|
21
|
+
const hour = Number(match?.[1]);
|
|
22
|
+
const minute = Number(match?.[2]);
|
|
23
|
+
if (!match || !Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
24
|
+
throw new Error(`Invalid alarm time: ${time}. Use HH:mm in 24-hour time.`);
|
|
25
|
+
}
|
|
26
|
+
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
|
27
|
+
}
|
|
28
|
+
export function canonicalizeIsoWeekdays(weekdays) {
|
|
29
|
+
if (weekdays.length === 0 || weekdays.some(day => !Number.isInteger(day) || day < 1 || day > 7)) {
|
|
30
|
+
throw new Error('Alarm weekdays must contain ISO weekday numbers from 1 (Monday) to 7 (Sunday).');
|
|
31
|
+
}
|
|
32
|
+
return [...new Set(weekdays)].sort((left, right) => left - right);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Finds the next instant strictly after `now`.
|
|
36
|
+
*
|
|
37
|
+
* Recurring alarms are resolved from their civil date/time. During a spring DST
|
|
38
|
+
* gap, the first valid minute following the gap is used. During a fall overlap,
|
|
39
|
+
* the earlier of the two matching instants is used, so the occurrence fires once.
|
|
40
|
+
*/
|
|
41
|
+
export function nextAlarmOccurrence(schedule, now) {
|
|
42
|
+
assertValidDate(now, 'now');
|
|
43
|
+
if (schedule.type === 'once') {
|
|
44
|
+
const occurrence = new Date(schedule.at);
|
|
45
|
+
assertValidDate(occurrence, 'one-time alarm instant');
|
|
46
|
+
if (occurrence.getUTCSeconds() !== 0 || occurrence.getUTCMilliseconds() !== 0)
|
|
47
|
+
throw new Error('One-time alarms use minute precision; seconds must be zero.');
|
|
48
|
+
return occurrence.getTime() > now.getTime() ? occurrence : null;
|
|
49
|
+
}
|
|
50
|
+
const timezone = canonicalizeTimeZone(schedule.timezone);
|
|
51
|
+
const time = canonicalizeAlarmTime(schedule.time);
|
|
52
|
+
const weekdays = new Set(canonicalizeIsoWeekdays(schedule.weekdays));
|
|
53
|
+
const [hourText, minuteText] = time.split(':');
|
|
54
|
+
const hour = Number(hourText);
|
|
55
|
+
const minute = Number(minuteText);
|
|
56
|
+
const localNow = zonedParts(now, timezone);
|
|
57
|
+
for (let offset = 0; offset <= 7; offset += 1) {
|
|
58
|
+
const date = addCivilDays(localNow, offset);
|
|
59
|
+
if (!weekdays.has(isoWeekday(date.year, date.month, date.day)))
|
|
60
|
+
continue;
|
|
61
|
+
const occurrence = resolveCivilOccurrence({ ...date, hour, minute }, timezone);
|
|
62
|
+
if (occurrence && occurrence.getTime() > now.getTime())
|
|
63
|
+
return occurrence;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
export function nextOccurrenceForAlarm(alarm, now) {
|
|
68
|
+
if (!alarm.enabled)
|
|
69
|
+
return null;
|
|
70
|
+
if (alarm.nextOverride) {
|
|
71
|
+
const override = new Date(alarm.nextOverride.at);
|
|
72
|
+
assertValidDate(override, 'alarm next override');
|
|
73
|
+
if (override.getTime() > now.getTime())
|
|
74
|
+
return override;
|
|
75
|
+
}
|
|
76
|
+
return nextAlarmOccurrence(alarm.schedule, now);
|
|
77
|
+
}
|
|
78
|
+
export function assessScheduledOccurrence(scheduledAt, now, missedRunGraceMinutes) {
|
|
79
|
+
assertValidDate(scheduledAt, 'scheduled occurrence');
|
|
80
|
+
assertValidDate(now, 'now');
|
|
81
|
+
if (!Number.isFinite(missedRunGraceMinutes) || missedRunGraceMinutes < 0) {
|
|
82
|
+
throw new Error('Missed-run grace must be a non-negative number of minutes.');
|
|
83
|
+
}
|
|
84
|
+
const elapsed = now.getTime() - scheduledAt.getTime();
|
|
85
|
+
if (elapsed < 0)
|
|
86
|
+
return 'pending';
|
|
87
|
+
return elapsed <= Math.max(NATIVE_DISPATCH_TOLERANCE_MS, missedRunGraceMinutes * 60_000) ? 'due' : 'missed';
|
|
88
|
+
}
|
|
89
|
+
function zonedParts(instant, timezone) {
|
|
90
|
+
let formatter = formatterCache.get(timezone);
|
|
91
|
+
if (!formatter) {
|
|
92
|
+
formatter = new Intl.DateTimeFormat('en-CA', {
|
|
93
|
+
timeZone: timezone,
|
|
94
|
+
year: 'numeric',
|
|
95
|
+
month: '2-digit',
|
|
96
|
+
day: '2-digit',
|
|
97
|
+
hour: '2-digit',
|
|
98
|
+
minute: '2-digit',
|
|
99
|
+
hourCycle: 'h23'
|
|
100
|
+
});
|
|
101
|
+
formatterCache.set(timezone, formatter);
|
|
102
|
+
}
|
|
103
|
+
const values = Object.fromEntries(formatter.formatToParts(instant)
|
|
104
|
+
.filter(part => part.type !== 'literal')
|
|
105
|
+
.map(part => [part.type, Number(part.value)]));
|
|
106
|
+
return {
|
|
107
|
+
year: values.year ?? 0,
|
|
108
|
+
month: values.month ?? 0,
|
|
109
|
+
day: values.day ?? 0,
|
|
110
|
+
hour: values.hour ?? 0,
|
|
111
|
+
minute: values.minute ?? 0
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function resolveCivilOccurrence(target, timezone) {
|
|
115
|
+
const estimate = Date.UTC(target.year, target.month - 1, target.day, target.hour, target.minute);
|
|
116
|
+
const start = estimate - 18 * 60 * 60_000;
|
|
117
|
+
const end = estimate + 18 * 60 * 60_000;
|
|
118
|
+
let firstAfterGap = null;
|
|
119
|
+
for (let time = start; time <= end; time += 60_000) {
|
|
120
|
+
const candidate = new Date(time);
|
|
121
|
+
const local = zonedParts(candidate, timezone);
|
|
122
|
+
if (!sameCivilDate(local, target))
|
|
123
|
+
continue;
|
|
124
|
+
const comparison = compareCivilClock(local, target);
|
|
125
|
+
if (comparison === 0)
|
|
126
|
+
return candidate;
|
|
127
|
+
if (comparison > 0 && firstAfterGap === null)
|
|
128
|
+
firstAfterGap = candidate;
|
|
129
|
+
}
|
|
130
|
+
return firstAfterGap;
|
|
131
|
+
}
|
|
132
|
+
function addCivilDays(date, days) {
|
|
133
|
+
const result = new Date(Date.UTC(date.year, date.month - 1, date.day + days));
|
|
134
|
+
return { year: result.getUTCFullYear(), month: result.getUTCMonth() + 1, day: result.getUTCDate() };
|
|
135
|
+
}
|
|
136
|
+
function isoWeekday(year, month, day) {
|
|
137
|
+
const sundayBased = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
138
|
+
return (sundayBased === 0 ? 7 : sundayBased);
|
|
139
|
+
}
|
|
140
|
+
function sameCivilDate(left, right) {
|
|
141
|
+
return left.year === right.year && left.month === right.month && left.day === right.day;
|
|
142
|
+
}
|
|
143
|
+
function compareCivilClock(left, right) {
|
|
144
|
+
return left.hour * 60 + left.minute - (right.hour * 60 + right.minute);
|
|
145
|
+
}
|
|
146
|
+
function assertValidDate(date, label) {
|
|
147
|
+
if (!Number.isFinite(date.getTime()))
|
|
148
|
+
throw new Error(`Invalid ${label}.`);
|
|
149
|
+
}
|