@ahpd/server 0.1.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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/dist/automations.d.ts +4 -0
  4. package/dist/automations.d.ts.map +1 -0
  5. package/dist/automations.js +191 -0
  6. package/dist/automations.js.map +1 -0
  7. package/dist/catalog.d.ts +48 -0
  8. package/dist/catalog.d.ts.map +1 -0
  9. package/dist/catalog.js +51 -0
  10. package/dist/catalog.js.map +1 -0
  11. package/dist/changes.d.ts +4 -0
  12. package/dist/changes.d.ts.map +1 -0
  13. package/dist/changes.js +601 -0
  14. package/dist/changes.js.map +1 -0
  15. package/dist/cron.d.ts +43 -0
  16. package/dist/cron.d.ts.map +1 -0
  17. package/dist/cron.js +211 -0
  18. package/dist/cron.js.map +1 -0
  19. package/dist/git.d.ts +16 -0
  20. package/dist/git.d.ts.map +1 -0
  21. package/dist/git.js +126 -0
  22. package/dist/git.js.map +1 -0
  23. package/dist/host.d.ts +24 -0
  24. package/dist/host.d.ts.map +1 -0
  25. package/dist/host.js +5850 -0
  26. package/dist/host.js.map +1 -0
  27. package/dist/index.d.ts +36 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +34 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/listen.d.ts +3 -0
  32. package/dist/listen.d.ts.map +1 -0
  33. package/dist/listen.js +183 -0
  34. package/dist/listen.js.map +1 -0
  35. package/dist/paging.d.ts +38 -0
  36. package/dist/paging.d.ts.map +1 -0
  37. package/dist/paging.js +53 -0
  38. package/dist/paging.js.map +1 -0
  39. package/dist/paths.d.ts +15 -0
  40. package/dist/paths.d.ts.map +1 -0
  41. package/dist/paths.js +19 -0
  42. package/dist/paths.js.map +1 -0
  43. package/dist/resources.d.ts +122 -0
  44. package/dist/resources.d.ts.map +1 -0
  45. package/dist/resources.js +494 -0
  46. package/dist/resources.js.map +1 -0
  47. package/dist/rpc.d.ts +63 -0
  48. package/dist/rpc.d.ts.map +1 -0
  49. package/dist/rpc.js +208 -0
  50. package/dist/rpc.js.map +1 -0
  51. package/dist/scheduled.d.ts +48 -0
  52. package/dist/scheduled.d.ts.map +1 -0
  53. package/dist/scheduled.js +325 -0
  54. package/dist/scheduled.js.map +1 -0
  55. package/dist/terminals.d.ts +29 -0
  56. package/dist/terminals.d.ts.map +1 -0
  57. package/dist/terminals.js +346 -0
  58. package/dist/terminals.js.map +1 -0
  59. package/dist/tools.d.ts +18 -0
  60. package/dist/tools.d.ts.map +1 -0
  61. package/dist/tools.js +65 -0
  62. package/dist/tools.js.map +1 -0
  63. package/dist/types/agent.d.ts +245 -0
  64. package/dist/types/agent.d.ts.map +1 -0
  65. package/dist/types/agent.js +3 -0
  66. package/dist/types/agent.js.map +1 -0
  67. package/dist/types/automations.d.ts +164 -0
  68. package/dist/types/automations.d.ts.map +1 -0
  69. package/dist/types/automations.js +3 -0
  70. package/dist/types/automations.js.map +1 -0
  71. package/dist/types/catalog.d.ts +30 -0
  72. package/dist/types/catalog.d.ts.map +1 -0
  73. package/dist/types/catalog.js +3 -0
  74. package/dist/types/catalog.js.map +1 -0
  75. package/dist/types/changes.d.ts +272 -0
  76. package/dist/types/changes.d.ts.map +1 -0
  77. package/dist/types/changes.js +3 -0
  78. package/dist/types/changes.js.map +1 -0
  79. package/dist/types/common.d.ts +9 -0
  80. package/dist/types/common.d.ts.map +1 -0
  81. package/dist/types/common.js +3 -0
  82. package/dist/types/common.js.map +1 -0
  83. package/dist/types/host.d.ts +372 -0
  84. package/dist/types/host.d.ts.map +1 -0
  85. package/dist/types/host.js +3 -0
  86. package/dist/types/host.js.map +1 -0
  87. package/dist/types/index.d.ts +20 -0
  88. package/dist/types/index.d.ts.map +1 -0
  89. package/dist/types/index.js +8 -0
  90. package/dist/types/index.js.map +1 -0
  91. package/dist/types/listen.d.ts +48 -0
  92. package/dist/types/listen.d.ts.map +1 -0
  93. package/dist/types/listen.js +3 -0
  94. package/dist/types/listen.js.map +1 -0
  95. package/dist/types/paging.d.ts +10 -0
  96. package/dist/types/paging.d.ts.map +1 -0
  97. package/dist/types/paging.js +3 -0
  98. package/dist/types/paging.js.map +1 -0
  99. package/dist/types/probe.d.ts +46 -0
  100. package/dist/types/probe.d.ts.map +1 -0
  101. package/dist/types/probe.js +3 -0
  102. package/dist/types/probe.js.map +1 -0
  103. package/dist/types/resources.d.ts +83 -0
  104. package/dist/types/resources.d.ts.map +1 -0
  105. package/dist/types/resources.js +3 -0
  106. package/dist/types/resources.js.map +1 -0
  107. package/dist/types/rpc.d.ts +65 -0
  108. package/dist/types/rpc.d.ts.map +1 -0
  109. package/dist/types/rpc.js +3 -0
  110. package/dist/types/rpc.js.map +1 -0
  111. package/dist/types/session.d.ts +395 -0
  112. package/dist/types/session.d.ts.map +1 -0
  113. package/dist/types/session.js +3 -0
  114. package/dist/types/session.js.map +1 -0
  115. package/dist/types/terminals.d.ts +131 -0
  116. package/dist/types/terminals.d.ts.map +1 -0
  117. package/dist/types/terminals.js +3 -0
  118. package/dist/types/terminals.js.map +1 -0
  119. package/dist/types/wire.d.ts +62 -0
  120. package/dist/types/wire.d.ts.map +1 -0
  121. package/dist/types/wire.js +13 -0
  122. package/dist/types/wire.js.map +1 -0
  123. package/dist/types/worktrees.d.ts +86 -0
  124. package/dist/types/worktrees.d.ts.map +1 -0
  125. package/dist/types/worktrees.js +3 -0
  126. package/dist/types/worktrees.js.map +1 -0
  127. package/dist/worktrees.d.ts +27 -0
  128. package/dist/worktrees.d.ts.map +1 -0
  129. package/dist/worktrees.js +152 -0
  130. package/dist/worktrees.js.map +1 -0
  131. package/package.json +61 -0
  132. package/src/automations.ts +204 -0
  133. package/src/catalog.ts +53 -0
  134. package/src/changes.ts +629 -0
  135. package/src/cron.ts +251 -0
  136. package/src/git.ts +132 -0
  137. package/src/host.ts +5947 -0
  138. package/src/index.ts +40 -0
  139. package/src/listen.ts +240 -0
  140. package/src/paging.ts +56 -0
  141. package/src/paths.ts +19 -0
  142. package/src/resources.ts +513 -0
  143. package/src/rpc.ts +222 -0
  144. package/src/scheduled.ts +369 -0
  145. package/src/terminals.ts +353 -0
  146. package/src/tools.ts +64 -0
  147. package/src/types/agent.ts +255 -0
  148. package/src/types/automations.ts +164 -0
  149. package/src/types/catalog.ts +26 -0
  150. package/src/types/changes.ts +266 -0
  151. package/src/types/common.ts +9 -0
  152. package/src/types/host.ts +386 -0
  153. package/src/types/index.ts +27 -0
  154. package/src/types/listen.ts +53 -0
  155. package/src/types/paging.ts +11 -0
  156. package/src/types/probe.ts +42 -0
  157. package/src/types/resources.ts +107 -0
  158. package/src/types/rpc.ts +68 -0
  159. package/src/types/session.ts +415 -0
  160. package/src/types/terminals.ts +133 -0
  161. package/src/types/wire.ts +69 -0
  162. package/src/types/worktrees.ts +91 -0
  163. package/src/worktrees.ts +162 -0
package/src/rpc.ts ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * JSON-RPC 2.0 framing.
3
+ *
4
+ * Decodes incoming frames, dispatches them to a handler and encodes the
5
+ * replies. It holds no socket: `Wire` is the only thing it writes through, and
6
+ * `listen.ts` connects that to whichever runtime's WebSocket is in use.
7
+ *
8
+ * A message with no `id` is a notification and receives no reply, whatever the
9
+ * handler returns.
10
+ */
11
+
12
+ import type { Handler, Peer, Request, Wire } from './types/rpc.js';
13
+ export const PARSE_ERROR = -32700;
14
+ export const INVALID_REQUEST = -32600;
15
+ export const METHOD_NOT_FOUND = -32601;
16
+ export const INTERNAL_ERROR = -32603;
17
+
18
+ /**
19
+ * An error carrying a JSON-RPC code, and whatever the client needs to act.
20
+ *
21
+ * The fields are declared and assigned rather than written as constructor
22
+ * parameter properties, which are the one piece of TypeScript in this
23
+ * codebase that *emits* code: a runtime that only strips types cannot run
24
+ * them, and running this source unbuilt is worth more than the two lines.
25
+ */
26
+ export class RpcError extends Error {
27
+ /** The JSON-RPC error code. */
28
+ readonly code: number;
29
+ /** Structured detail for the client, where there is any. */
30
+ readonly data?: unknown;
31
+
32
+ constructor(code: number, message: string, data?: unknown) {
33
+ super(message);
34
+ this.name = 'RpcError';
35
+ this.code = code;
36
+ this.data = data;
37
+ }
38
+ }
39
+
40
+ /** How long a question to a client waits before it is given up on. */
41
+ export const ANSWER_TIMEOUT = 30_000;
42
+
43
+ /**
44
+ * A question this host asked that never came back.
45
+ *
46
+ * Its own type rather than an `RpcError`: nothing arrived, so there is no code
47
+ * a client chose and no data it sent. A timeout read as an error the client
48
+ * returned is an answer attributed to somebody who never gave one.
49
+ */
50
+ export class RpcTimeout extends Error {
51
+ /** The method that went unanswered. */
52
+ readonly method: string;
53
+
54
+ constructor(method: string, ms: number) {
55
+ super(`${method} went unanswered for ${ms}ms`);
56
+ this.name = 'RpcTimeout';
57
+ this.method = method;
58
+ }
59
+ }
60
+
61
+ /** A question still in flight when the connection went away. */
62
+ export class RpcClosed extends Error {
63
+ /** The method that was in flight. */
64
+ readonly method: string;
65
+
66
+ constructor(method: string) {
67
+ super(`The connection closed with ${method} still unanswered`);
68
+ this.name = 'RpcClosed';
69
+ this.method = method;
70
+ }
71
+ }
72
+
73
+ /** Ask and answer over one wire. */
74
+ export function createPeer(wire: Wire): Peer {
75
+ /*
76
+ * The ids this host spends asking, which are its own to allocate.
77
+ *
78
+ * JSON-RPC numbers each direction separately - the id on a request this
79
+ * host sends is the one the client answers with - so nothing here has to
80
+ * avoid the ids a client is spending on its own requests.
81
+ */
82
+ let asked = 0;
83
+ const waiting = new Map<number, {
84
+ method: string;
85
+ settle(error: Error | undefined, result?: unknown): void;
86
+ }>();
87
+
88
+ const peer: Peer = {
89
+ send: (message) => {
90
+ if (wire.isOpen()) wire.send(JSON.stringify(message));
91
+ },
92
+ notify: (method, params) => {
93
+ peer.send({ jsonrpc: '2.0', method, params });
94
+ },
95
+ request: (method, params, timeoutMs = ANSWER_TIMEOUT) => new Promise((resolve, reject) => {
96
+ // Refused rather than sent into a closed socket, where it would sit
97
+ // until the timeout to say what is already known.
98
+ if (!wire.isOpen()) {
99
+ reject(new RpcClosed(method));
100
+ return;
101
+ }
102
+ const id = ++asked;
103
+ const timer = setTimeout(() => {
104
+ waiting.delete(id);
105
+ reject(new RpcTimeout(method, timeoutMs));
106
+ }, timeoutMs);
107
+ // Unreferenced, so a question nobody is going to answer does not hold
108
+ // the process open by itself.
109
+ (timer as unknown as { unref?(): void }).unref?.();
110
+ waiting.set(id, {
111
+ method,
112
+ settle: (error, result) => {
113
+ clearTimeout(timer);
114
+ if (error) reject(error);
115
+ else resolve(result);
116
+ },
117
+ });
118
+ peer.send({ jsonrpc: '2.0', id, method, params });
119
+ }),
120
+ answered: (message) => {
121
+ const id = typeof message.id === 'number' ? message.id : undefined;
122
+ if (id === undefined) return;
123
+ const held = waiting.get(id);
124
+ // An answer to a question this host never asked. Dropped, because there
125
+ // is nobody to hand it to and a response deserves no reply.
126
+ if (!held) return;
127
+ waiting.delete(id);
128
+ const failure = (typeof message.error === 'object' && message.error !== null
129
+ ? message.error
130
+ : undefined) as { code?: unknown; message?: unknown; data?: unknown } | undefined;
131
+ if (!failure) {
132
+ held.settle(undefined, message.result);
133
+ return;
134
+ }
135
+ held.settle(new RpcError(
136
+ typeof failure.code === 'number' ? failure.code : INTERNAL_ERROR,
137
+ typeof failure.message === 'string' ? failure.message : 'The client refused',
138
+ failure.data,
139
+ ));
140
+ },
141
+ close: () => {
142
+ // Everything in flight, before the socket goes: a promise left pending
143
+ // on a dead connection never settles at all.
144
+ for (const [id, held] of [...waiting]) {
145
+ waiting.delete(id);
146
+ held.settle(new RpcClosed(held.method));
147
+ }
148
+ wire.close();
149
+ },
150
+ };
151
+ return peer;
152
+ }
153
+
154
+ /**
155
+ * One frame in, and whatever it deserves back.
156
+ *
157
+ * AHP is JSON-RPC with one deviation worth knowing: a *notification* is a
158
+ * message with no `id`, and the client sends two of them - `unsubscribe` and
159
+ * `dispatchAction`. Answering a notification is a protocol error, so the
160
+ * absence of an id is what decides whether a reply is written, never the
161
+ * handler's return value.
162
+ */
163
+ export function receive(raw: string, peer: Peer, handle: Handler): void {
164
+ let parsed: unknown;
165
+ try {
166
+ parsed = JSON.parse(raw);
167
+ } catch {
168
+ peer.send({ jsonrpc: '2.0', id: null, error: { code: PARSE_ERROR, message: 'Not JSON' } });
169
+ return;
170
+ }
171
+
172
+ const message = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Record<string, unknown>;
173
+ const method = typeof message.method === 'string' ? message.method : undefined;
174
+ // No id is a notification. Not "an id we did not read" - the difference
175
+ // decides whether anything is written back at all.
176
+ const id = typeof message.id === 'number' ? message.id : undefined;
177
+
178
+ /*
179
+ * A response, which is a message carrying an answer and naming no method.
180
+ *
181
+ * Read before anything decides the frame is malformed. This used to answer
182
+ * `-32600 No method` to every answer a client gave, which is a JSON-RPC
183
+ * violation - a response is not a request and gets no reply - and it made
184
+ * the whole reverse direction unusable: asking a client anything produced
185
+ * an answer this host then argued with.
186
+ */
187
+ if (method === undefined && ('result' in message || 'error' in message)) {
188
+ peer.answered(message);
189
+ return;
190
+ }
191
+
192
+ if (!method) {
193
+ if (id !== undefined) {
194
+ peer.send({ jsonrpc: '2.0', id, error: { code: INVALID_REQUEST, message: 'No method' } });
195
+ }
196
+ return;
197
+ }
198
+
199
+ const params = (typeof message.params === 'object' && message.params !== null
200
+ ? message.params
201
+ : {}) as Record<string, unknown>;
202
+
203
+ void (async () => {
204
+ try {
205
+ const result = await handle({ method, params }, peer);
206
+ if (id !== undefined) peer.send({ jsonrpc: '2.0', id, result: result ?? {} });
207
+ } catch (error) {
208
+ if (id === undefined) return;
209
+ const code = error instanceof RpcError ? error.code : INTERNAL_ERROR;
210
+ const text = error instanceof Error ? error.message : String(error);
211
+ peer.send({
212
+ jsonrpc: '2.0',
213
+ id,
214
+ error: {
215
+ code,
216
+ message: text,
217
+ ...(error instanceof RpcError && error.data !== undefined ? { data: error.data } : {}),
218
+ },
219
+ });
220
+ }
221
+ })();
222
+ }
@@ -0,0 +1,369 @@
1
+ /** Automations that fire on their own: a clock, and a file they survive in. */
2
+
3
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+ import { memoryAutomations } from './automations.js';
6
+ import { nextOccurrence, parseCron, type Cron } from './cron.js';
7
+ import type { Automation, AutomationStore } from './types/automations.js';
8
+ import type { Bag } from './types/common.js';
9
+
10
+ /** How this store is built, and what a test replaces. */
11
+ export interface ScheduledOptions {
12
+ /**
13
+ * Where definitions are kept.
14
+ *
15
+ * Named by the caller rather than defaulted to somewhere under a home
16
+ * directory: this is a library, and a library that decides on its own where
17
+ * to write in somebody's home is one that has made a decision for the
18
+ * program using it. The daemon passes the file beside its configuration.
19
+ */
20
+ file: string;
21
+ /** The clock. A test supplies its own so a schedule can be reached without waiting for it. */
22
+ now?(): Date;
23
+ /**
24
+ * How a timer is armed.
25
+ *
26
+ * Handed in for the same reason the clock is: a test that had to wait for a
27
+ * real `setTimeout` would be a test that takes until nine in the morning.
28
+ */
29
+ timer?(fire: () => void, ms: number): { cancel(): void };
30
+ /** Somewhere to say that a definition could not be read. */
31
+ onProblem?(message: string): void;
32
+ }
33
+
34
+ /** What is persisted. Versioned, so a later shape can be recognised rather than guessed at. */
35
+ interface Saved {
36
+ version: 1;
37
+ automations: {
38
+ resource: string;
39
+ definition: Bag;
40
+ createdAt: string;
41
+ modifiedAt: string;
42
+ /** The occurrence this was waiting for when it was written. What catch-up reads. */
43
+ nextRunAt?: string;
44
+ }[];
45
+ }
46
+
47
+ /** `setTimeout` will not wait longer than this, so a longer wait is done in instalments. */
48
+ const MAX_DELAY = 2_147_483_647;
49
+
50
+ /** One schedule an automation carries, ready to be asked when it next fires. */
51
+ interface Schedule {
52
+ triggerId: string;
53
+ cron: Cron;
54
+ timeZone: string;
55
+ /** Whether a missed occurrence is caught up. `runOnce` is the protocol's default. */
56
+ catchUp: boolean;
57
+ }
58
+
59
+ const bag = (value: unknown): Bag => (typeof value === 'object' && value !== null ? value as Bag : {});
60
+
61
+ /**
62
+ * A host that fires its own automations.
63
+ *
64
+ * The other implementation of `AutomationStore`, and the one the port was
65
+ * written for: `memoryAutomations` holds the definitions and the run history,
66
+ * and everything added here is the clock and the file. Composed rather than
67
+ * copied, so there is still one answer to what a run is.
68
+ *
69
+ * **It does not start sessions.** The clock decides it is time and says so
70
+ * through `onDue`; the host, which is the only thing that knows what a session
71
+ * is, calls `run`. That is the same division the port already describes, and
72
+ * it is why an automation can fire with nobody connected.
73
+ *
74
+ * **Definitions survive a restart and run history does not.** A run names the
75
+ * sessions it started, and this daemon's sessions are subprocesses that go
76
+ * when it does - so a persisted history would be a list of links to things
77
+ * that are not there. What is worth keeping across a restart is what somebody
78
+ * wrote down, which is the definition and when it next fires.
79
+ */
80
+ export function scheduledAutomations(options: ScheduledOptions): AutomationStore {
81
+ const inner = memoryAutomations();
82
+ const file = options.file;
83
+ const now = options.now ?? ((): Date => new Date());
84
+ const arm = options.timer ?? ((fire, ms): { cancel(): void } => {
85
+ const held = setTimeout(fire, ms);
86
+ // A daemon should not be held open by the next automation, and should not
87
+ // be kept alive by one either.
88
+ held.unref?.();
89
+ return { cancel: () => { clearTimeout(held); } };
90
+ });
91
+ const told = (message: string): void => { options.onProblem?.(message); };
92
+
93
+ /** When each automation next fires, by resource. Absent means nothing will fire it. */
94
+ const nextAt = new Map<string, Date>();
95
+ /** Written timestamps, kept here so they survive a reload rather than becoming the load time. */
96
+ const stamps = new Map<string, { createdAt: string; modifiedAt: string }>();
97
+ const due: ((event: { automation: string; origin: Bag }) => void)[] = [];
98
+ const changed: ((event: { automation?: string; run?: string; removed?: string }) => void)[] = [];
99
+ let timer: { cancel(): void } | undefined;
100
+
101
+ /**
102
+ * The schedules on one definition.
103
+ *
104
+ * An expression that will not parse is skipped and reported rather than
105
+ * thrown: the automation is still a real thing somebody wrote, and refusing
106
+ * to list it would lose the definition along with the typo. It fires
107
+ * nothing, which is the same thing this host says by leaving `nextRunAt` off
108
+ * an automation it will not fire.
109
+ */
110
+ const schedulesOf = (automation: Automation): Schedule[] => {
111
+ const triggers = Array.isArray(automation.definition.triggers) ? automation.definition.triggers : [];
112
+ const out: Schedule[] = [];
113
+ for (const raw of triggers) {
114
+ const trigger = bag(raw);
115
+ if (trigger.kind !== 'schedule') continue;
116
+ const schedule = bag(trigger.schedule);
117
+ const expression = typeof schedule.expression === 'string' ? schedule.expression : '';
118
+ const timeZone = typeof schedule.timeZone === 'string' && schedule.timeZone !== ''
119
+ ? schedule.timeZone
120
+ : 'UTC';
121
+ try {
122
+ out.push({
123
+ triggerId: String(trigger.id ?? ''),
124
+ cron: parseCron(expression),
125
+ timeZone,
126
+ catchUp: trigger.misfirePolicy !== 'skip',
127
+ });
128
+ }
129
+ catch (error) {
130
+ told(`${automation.resource}: ${error instanceof Error ? error.message : String(error)}`);
131
+ }
132
+ }
133
+ return out;
134
+ };
135
+
136
+ /** The soonest any of an automation's schedules comes round, after that instant. */
137
+ const soonest = (automation: Automation, after: Date): { at: Date; schedule: Schedule } | undefined => {
138
+ if (automation.definition.enabled === false) return undefined;
139
+ let best: { at: Date; schedule: Schedule } | undefined;
140
+ for (const schedule of schedulesOf(automation)) {
141
+ let at: Date | undefined;
142
+ try { at = nextOccurrence(schedule.cron, after, schedule.timeZone); }
143
+ catch (error) {
144
+ // An unknown time zone, which `Intl` refuses only when asked to use it.
145
+ told(`${automation.resource}: ${error instanceof Error ? error.message : String(error)}`);
146
+ continue;
147
+ }
148
+ if (at && (!best || at < best.at)) best = { at, schedule };
149
+ }
150
+ return best;
151
+ };
152
+
153
+ /** Everything this store knows, with what it added. */
154
+ const dressed = (automation: Automation): Automation => {
155
+ const at = nextAt.get(automation.resource);
156
+ const stamp = stamps.get(automation.resource);
157
+ return {
158
+ ...automation,
159
+ ...(stamp ?? {}),
160
+ ...(at ? { nextRunAt: at.toISOString() } : {}),
161
+ };
162
+ };
163
+
164
+ const save = (): void => {
165
+ const held: Saved = {
166
+ version: 1,
167
+ automations: inner.list().map((one) => {
168
+ const at = nextAt.get(one.resource);
169
+ return {
170
+ resource: one.resource,
171
+ definition: one.definition,
172
+ ...(stamps.get(one.resource) ?? { createdAt: one.createdAt, modifiedAt: one.modifiedAt }),
173
+ ...(at ? { nextRunAt: at.toISOString() } : {}),
174
+ };
175
+ }),
176
+ };
177
+ try {
178
+ mkdirSync(dirname(file), { recursive: true });
179
+ // Written beside and moved into place, so a daemon killed mid-write
180
+ // leaves the last good file rather than half of this one.
181
+ const temporary = `${file}.${process.pid}.tmp`;
182
+ writeFileSync(temporary, `${JSON.stringify(held, null, 2)}\n`);
183
+ renameSync(temporary, file);
184
+ }
185
+ catch (error) {
186
+ told(`Could not write ${file}: ${error instanceof Error ? error.message : String(error)}`);
187
+ }
188
+ };
189
+
190
+ /** Work out when everything next fires, and set one timer for the first of them. */
191
+ const rearm = (): void => {
192
+ timer?.cancel();
193
+ timer = undefined;
194
+ const at = now();
195
+ let first: Date | undefined;
196
+ for (const automation of inner.list()) {
197
+ const found = soonest(automation, at);
198
+ if (found) {
199
+ nextAt.set(automation.resource, found.at);
200
+ if (!first || found.at < first) first = found.at;
201
+ }
202
+ else nextAt.delete(automation.resource);
203
+ }
204
+ if (!first) return;
205
+ // One timer for the earliest, not one per automation: the second earliest
206
+ // is recomputed when the first fires, and a hundred automations should not
207
+ // be a hundred timers.
208
+ const wait = Math.max(0, first.getTime() - at.getTime());
209
+ timer = arm(() => { fire(); }, Math.min(wait, MAX_DELAY));
210
+ };
211
+
212
+ /** Say what is due, then look again. */
213
+ const fire = (): void => {
214
+ const at = now();
215
+ for (const automation of inner.list()) {
216
+ const when = nextAt.get(automation.resource);
217
+ if (!when || when > at) continue;
218
+ const schedule = soonest(automation, new Date(when.getTime() - 1));
219
+ const origin: Bag = {
220
+ kind: 'trigger',
221
+ triggerId: schedule?.schedule.triggerId ?? '',
222
+ scheduledFor: when.toISOString(),
223
+ };
224
+ for (const listener of due) listener({ automation: automation.resource, origin });
225
+ }
226
+ rearm();
227
+ };
228
+
229
+ /**
230
+ * What was missed while this daemon was not running.
231
+ *
232
+ * `runOnce` is the protocol's default and its whole meaning: at most one
233
+ * catch-up run however many occurrences went by, so a machine that was off
234
+ * for a week comes back to one run and not to two hundred.
235
+ */
236
+ const catchUp = (loaded: { resource: string; nextRunAt?: string }[]): void => {
237
+ const at = now();
238
+ for (const one of loaded) {
239
+ if (one.nextRunAt === undefined) continue;
240
+ const missed = new Date(one.nextRunAt);
241
+ if (!(missed < at)) continue;
242
+ const automation = inner.get(one.resource);
243
+ if (!automation || automation.definition.enabled === false) continue;
244
+ const schedule = soonest(automation, new Date(missed.getTime() - 1));
245
+ if (!schedule?.schedule.catchUp) continue;
246
+ const origin: Bag = {
247
+ kind: 'trigger',
248
+ triggerId: schedule.schedule.triggerId,
249
+ scheduledFor: missed.toISOString(),
250
+ catchUp: true,
251
+ };
252
+ for (const listener of due) listener({ automation: one.resource, origin });
253
+ }
254
+ };
255
+
256
+ /** Read what was written, if anything was. */
257
+ const load = (): { resource: string; nextRunAt?: string }[] => {
258
+ let text: string;
259
+ try { text = readFileSync(file, 'utf8'); }
260
+ catch { return []; }
261
+ let held: Saved;
262
+ try { held = JSON.parse(text) as Saved; }
263
+ catch (error) {
264
+ told(`Could not read ${file}: ${error instanceof Error ? error.message : String(error)}`);
265
+ return [];
266
+ }
267
+ if (held.version !== 1 || !Array.isArray(held.automations)) {
268
+ told(`${file} is not something this version understands, and was left alone`);
269
+ return [];
270
+ }
271
+ const back: { resource: string; nextRunAt?: string }[] = [];
272
+ for (const one of held.automations) {
273
+ if (typeof one.resource !== 'string') continue;
274
+ inner.create(one.resource, bag(one.definition));
275
+ stamps.set(one.resource, {
276
+ createdAt: String(one.createdAt ?? now().toISOString()),
277
+ modifiedAt: String(one.modifiedAt ?? now().toISOString()),
278
+ });
279
+ back.push({
280
+ resource: one.resource,
281
+ ...(typeof one.nextRunAt === 'string' ? { nextRunAt: one.nextRunAt } : {}),
282
+ });
283
+ }
284
+ return back;
285
+ };
286
+
287
+ // What was on the clock when this daemon last wrote itself down. Read before
288
+ // `rearm`, which replaces it with what is on the clock now.
289
+ const wasWaiting = load();
290
+ rearm();
291
+
292
+ /*
293
+ * Everything the inner store announces, announced again once the clock has
294
+ * caught up.
295
+ *
296
+ * The ordering matters and cost a wrong answer on the wire: the inner store
297
+ * says "this changed" from *inside* its own `update`, and whoever is
298
+ * listening immediately reads the entry back. Rearming after that call
299
+ * returned meant the entry went out carrying the previous `nextRunAt` - so
300
+ * switching an automation off announced it as still firing at nine.
301
+ */
302
+ inner.onChanged?.((event) => {
303
+ if (event.automation !== undefined || event.removed !== undefined) {
304
+ rearm();
305
+ save();
306
+ }
307
+ for (const listener of changed) listener(event);
308
+ });
309
+
310
+ return {
311
+ ...inner,
312
+ list: () => inner.list().map(dressed),
313
+ get: (resource) => {
314
+ const found = inner.get(resource);
315
+ return found && dressed(found);
316
+ },
317
+
318
+ /**
319
+ * The event triggers this host understands, which are none.
320
+ *
321
+ * Schedule triggers are protocol-defined and never appear here - a client
322
+ * may always write one - and manual is not a trigger at all: an empty
323
+ * trigger list is what the protocol says manual-only means. So an empty
324
+ * answer is the true one for a host whose only automatic trigger is a
325
+ * clock.
326
+ */
327
+ triggers: () => [],
328
+
329
+ create: (resource, definition) => {
330
+ const made = inner.create(resource, definition);
331
+ stamps.set(resource, { createdAt: made.createdAt, modifiedAt: made.modifiedAt });
332
+ // `onChanged` above has already rearmed and written; the stamp is set
333
+ // before this returns so what it wrote carries the right one.
334
+ save();
335
+ return dressed(inner.get(resource) ?? made);
336
+ },
337
+
338
+ update: (resource, changes) => {
339
+ const after = inner.update(resource, changes);
340
+ if (!after) return undefined;
341
+ const stamp = stamps.get(resource);
342
+ if (stamp) stamps.set(resource, { ...stamp, modifiedAt: after.modifiedAt });
343
+ save();
344
+ return dressed(inner.get(resource) ?? after);
345
+ },
346
+
347
+ remove: (resource) => {
348
+ const gone = inner.remove(resource);
349
+ if (!gone) return false;
350
+ stamps.delete(resource);
351
+ nextAt.delete(resource);
352
+ save();
353
+ return true;
354
+ },
355
+
356
+ /** Subscribed through, so the clock is caught up before anybody reads back. */
357
+ onChanged: (observer) => { changed.push(observer); },
358
+
359
+ onDue: (observer) => {
360
+ due.push(observer);
361
+ // Whatever was missed, told to whoever just asked - which is the host,
362
+ // at startup, and is the only moment a catch-up can be reported to
363
+ // anybody.
364
+ catchUp(wasWaiting);
365
+ },
366
+
367
+ close: () => { timer?.cancel(); timer = undefined; },
368
+ };
369
+ }