@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.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/automations.d.ts +4 -0
- package/dist/automations.d.ts.map +1 -0
- package/dist/automations.js +191 -0
- package/dist/automations.js.map +1 -0
- package/dist/catalog.d.ts +48 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +51 -0
- package/dist/catalog.js.map +1 -0
- package/dist/changes.d.ts +4 -0
- package/dist/changes.d.ts.map +1 -0
- package/dist/changes.js +601 -0
- package/dist/changes.js.map +1 -0
- package/dist/cron.d.ts +43 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +211 -0
- package/dist/cron.js.map +1 -0
- package/dist/git.d.ts +16 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +126 -0
- package/dist/git.js.map +1 -0
- package/dist/host.d.ts +24 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +5850 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/listen.d.ts +3 -0
- package/dist/listen.d.ts.map +1 -0
- package/dist/listen.js +183 -0
- package/dist/listen.js.map +1 -0
- package/dist/paging.d.ts +38 -0
- package/dist/paging.d.ts.map +1 -0
- package/dist/paging.js +53 -0
- package/dist/paging.js.map +1 -0
- package/dist/paths.d.ts +15 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +19 -0
- package/dist/paths.js.map +1 -0
- package/dist/resources.d.ts +122 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +494 -0
- package/dist/resources.js.map +1 -0
- package/dist/rpc.d.ts +63 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +208 -0
- package/dist/rpc.js.map +1 -0
- package/dist/scheduled.d.ts +48 -0
- package/dist/scheduled.d.ts.map +1 -0
- package/dist/scheduled.js +325 -0
- package/dist/scheduled.js.map +1 -0
- package/dist/terminals.d.ts +29 -0
- package/dist/terminals.d.ts.map +1 -0
- package/dist/terminals.js +346 -0
- package/dist/terminals.js.map +1 -0
- package/dist/tools.d.ts +18 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +65 -0
- package/dist/tools.js.map +1 -0
- package/dist/types/agent.d.ts +245 -0
- package/dist/types/agent.d.ts.map +1 -0
- package/dist/types/agent.js +3 -0
- package/dist/types/agent.js.map +1 -0
- package/dist/types/automations.d.ts +164 -0
- package/dist/types/automations.d.ts.map +1 -0
- package/dist/types/automations.js +3 -0
- package/dist/types/automations.js.map +1 -0
- package/dist/types/catalog.d.ts +30 -0
- package/dist/types/catalog.d.ts.map +1 -0
- package/dist/types/catalog.js +3 -0
- package/dist/types/catalog.js.map +1 -0
- package/dist/types/changes.d.ts +272 -0
- package/dist/types/changes.d.ts.map +1 -0
- package/dist/types/changes.js +3 -0
- package/dist/types/changes.js.map +1 -0
- package/dist/types/common.d.ts +9 -0
- package/dist/types/common.d.ts.map +1 -0
- package/dist/types/common.js +3 -0
- package/dist/types/common.js.map +1 -0
- package/dist/types/host.d.ts +372 -0
- package/dist/types/host.d.ts.map +1 -0
- package/dist/types/host.js +3 -0
- package/dist/types/host.js.map +1 -0
- package/dist/types/index.d.ts +20 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +8 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/listen.d.ts +48 -0
- package/dist/types/listen.d.ts.map +1 -0
- package/dist/types/listen.js +3 -0
- package/dist/types/listen.js.map +1 -0
- package/dist/types/paging.d.ts +10 -0
- package/dist/types/paging.d.ts.map +1 -0
- package/dist/types/paging.js +3 -0
- package/dist/types/paging.js.map +1 -0
- package/dist/types/probe.d.ts +46 -0
- package/dist/types/probe.d.ts.map +1 -0
- package/dist/types/probe.js +3 -0
- package/dist/types/probe.js.map +1 -0
- package/dist/types/resources.d.ts +83 -0
- package/dist/types/resources.d.ts.map +1 -0
- package/dist/types/resources.js +3 -0
- package/dist/types/resources.js.map +1 -0
- package/dist/types/rpc.d.ts +65 -0
- package/dist/types/rpc.d.ts.map +1 -0
- package/dist/types/rpc.js +3 -0
- package/dist/types/rpc.js.map +1 -0
- package/dist/types/session.d.ts +395 -0
- package/dist/types/session.d.ts.map +1 -0
- package/dist/types/session.js +3 -0
- package/dist/types/session.js.map +1 -0
- package/dist/types/terminals.d.ts +131 -0
- package/dist/types/terminals.d.ts.map +1 -0
- package/dist/types/terminals.js +3 -0
- package/dist/types/terminals.js.map +1 -0
- package/dist/types/wire.d.ts +62 -0
- package/dist/types/wire.d.ts.map +1 -0
- package/dist/types/wire.js +13 -0
- package/dist/types/wire.js.map +1 -0
- package/dist/types/worktrees.d.ts +86 -0
- package/dist/types/worktrees.d.ts.map +1 -0
- package/dist/types/worktrees.js +3 -0
- package/dist/types/worktrees.js.map +1 -0
- package/dist/worktrees.d.ts +27 -0
- package/dist/worktrees.d.ts.map +1 -0
- package/dist/worktrees.js +152 -0
- package/dist/worktrees.js.map +1 -0
- package/package.json +61 -0
- package/src/automations.ts +204 -0
- package/src/catalog.ts +53 -0
- package/src/changes.ts +629 -0
- package/src/cron.ts +251 -0
- package/src/git.ts +132 -0
- package/src/host.ts +5947 -0
- package/src/index.ts +40 -0
- package/src/listen.ts +240 -0
- package/src/paging.ts +56 -0
- package/src/paths.ts +19 -0
- package/src/resources.ts +513 -0
- package/src/rpc.ts +222 -0
- package/src/scheduled.ts +369 -0
- package/src/terminals.ts +353 -0
- package/src/tools.ts +64 -0
- package/src/types/agent.ts +255 -0
- package/src/types/automations.ts +164 -0
- package/src/types/catalog.ts +26 -0
- package/src/types/changes.ts +266 -0
- package/src/types/common.ts +9 -0
- package/src/types/host.ts +386 -0
- package/src/types/index.ts +27 -0
- package/src/types/listen.ts +53 -0
- package/src/types/paging.ts +11 -0
- package/src/types/probe.ts +42 -0
- package/src/types/resources.ts +107 -0
- package/src/types/rpc.ts +68 -0
- package/src/types/session.ts +415 -0
- package/src/types/terminals.ts +133 -0
- package/src/types/wire.ts +69 -0
- package/src/types/worktrees.ts +91 -0
- 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
|
+
}
|
package/src/scheduled.ts
ADDED
|
@@ -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
|
+
}
|