@dsh-enhanced/assistant-automations 0.1.24 → 0.1.31
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/README.md +18 -5
- package/lib/acceptance.d.ts +52 -0
- package/lib/acceptance.d.ts.map +1 -0
- package/lib/acceptance.js +2 -0
- package/lib/acceptance.js.map +1 -0
- package/lib/coordinator.d.ts +5 -0
- package/lib/coordinator.d.ts.map +1 -1
- package/lib/coordinator.js +21 -0
- package/lib/coordinator.js.map +1 -1
- package/lib/external-event.d.ts +32 -0
- package/lib/external-event.d.ts.map +1 -0
- package/lib/external-event.js +114 -0
- package/lib/external-event.js.map +1 -0
- package/lib/growth.d.ts +10 -0
- package/lib/growth.d.ts.map +1 -1
- package/lib/growth.js +49 -16
- package/lib/growth.js.map +1 -1
- package/lib/index.d.ts +6 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -1
- package/lib/index.js.map +1 -1
- package/lib/operator.d.ts +68 -2
- package/lib/operator.d.ts.map +1 -1
- package/lib/operator.js +922 -6
- package/lib/operator.js.map +1 -1
- package/lib/preparation.d.ts +52 -0
- package/lib/preparation.d.ts.map +1 -0
- package/lib/preparation.js +191 -0
- package/lib/preparation.js.map +1 -0
- package/lib/runner.d.ts +29 -0
- package/lib/runner.d.ts.map +1 -1
- package/lib/runner.js +72 -2
- package/lib/runner.js.map +1 -1
- package/lib/service.d.ts +37 -0
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +269 -60
- package/lib/service.js.map +1 -1
- package/lib/sqlite.d.ts +1 -1
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +77 -3
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +55 -0
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +233 -14
- package/lib/store.js.map +1 -1
- package/lib/types.d.ts +3 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +29 -23
package/lib/operator.js
CHANGED
|
@@ -1,13 +1,929 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { closeSync, fstatSync, lstatSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, rmdirSync, unlinkSync, writeSync } from 'node:fs';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { automationSchemaVersion } from './sqlite.js';
|
|
9
|
+
import { normalizeAutomationDefinition } from './store.js';
|
|
10
|
+
export const automationsOperatorSnapshotProtocol = 'assistant-automations-operator-snapshot/v1';
|
|
11
|
+
export class AutomationOperatorSnapshotError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.name = 'AutomationOperatorSnapshotError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const digestPattern = /^[a-f0-9]{64}$/u;
|
|
20
|
+
function fail(code, message) {
|
|
21
|
+
throw new AutomationOperatorSnapshotError(code, `automation operator snapshot: ${message}`);
|
|
22
|
+
}
|
|
23
|
+
function freeze(value) {
|
|
24
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
25
|
+
for (const child of Object.values(value))
|
|
26
|
+
freeze(child);
|
|
27
|
+
Object.freeze(value);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function sha256(value) {
|
|
32
|
+
return createHash('sha256').update(value).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
/** Pure canonical digest used by both writers and trusted local attestation. */
|
|
35
|
+
export function automationDefinitionDigest(definition) {
|
|
36
|
+
return sha256(JSON.stringify(normalizeAutomationDefinition(definition, 16 * 1024 * 1024, 10_000)));
|
|
37
|
+
}
|
|
38
|
+
function safeInteger(value, minimum = 0) {
|
|
39
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum;
|
|
40
|
+
}
|
|
41
|
+
function canonicalText(value, maximum) {
|
|
42
|
+
return typeof value === 'string' && value.length > 0 && value === value.normalize('NFC').trim()
|
|
43
|
+
&& Buffer.byteLength(value, 'utf8') <= maximum
|
|
44
|
+
&& ![...value].some(character => {
|
|
45
|
+
const point = character.codePointAt(0) ?? 0;
|
|
46
|
+
return point <= 0x1f || point === 0x7f;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function sameFile(left, right) {
|
|
50
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
|
51
|
+
&& left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs
|
|
52
|
+
&& left.mode === right.mode && left.uid === right.uid && left.nlink === right.nlink;
|
|
53
|
+
}
|
|
54
|
+
function digestDescriptor(descriptor, size) {
|
|
55
|
+
const hash = createHash('sha256');
|
|
56
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
57
|
+
let position = 0n;
|
|
58
|
+
while (position < size) {
|
|
59
|
+
const length = Number(size - position > BigInt(buffer.length) ? BigInt(buffer.length) : size - position);
|
|
60
|
+
const count = readSync(descriptor, buffer, 0, length, Number(position));
|
|
61
|
+
if (count <= 0)
|
|
62
|
+
fail('database-drift', 'database file changed while hashing');
|
|
63
|
+
hash.update(buffer.subarray(0, count));
|
|
64
|
+
position += BigInt(count);
|
|
65
|
+
}
|
|
66
|
+
return hash.digest('hex');
|
|
67
|
+
}
|
|
68
|
+
function identity(file) {
|
|
69
|
+
return freeze({
|
|
70
|
+
device: file.stat.dev.toString(),
|
|
71
|
+
inode: file.stat.ino.toString(),
|
|
72
|
+
size: file.stat.size.toString(),
|
|
73
|
+
mtimeNs: file.stat.mtimeNs.toString(),
|
|
74
|
+
digest: file.digest,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function pinPrivateParent(path) {
|
|
78
|
+
let descriptor;
|
|
5
79
|
try {
|
|
6
|
-
|
|
80
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (typeof error === 'object' && error !== null && 'code' in error
|
|
84
|
+
&& error.code === 'ENOENT') {
|
|
85
|
+
fail('unsafe-parent', 'database parent disappeared before the snapshot');
|
|
86
|
+
}
|
|
87
|
+
fail('unsafe-parent', 'database parent is unavailable');
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const pathname = lstatSync(path, { bigint: true });
|
|
91
|
+
const metadata = fstatSync(descriptor, { bigint: true });
|
|
92
|
+
const uid = process.getuid?.();
|
|
93
|
+
const physical = realpathSync(path);
|
|
94
|
+
if (!pathname.isDirectory() || pathname.isSymbolicLink() || !metadata.isDirectory()
|
|
95
|
+
|| pathname.dev !== metadata.dev || pathname.ino !== metadata.ino || physical !== path
|
|
96
|
+
|| uid === undefined || metadata.uid !== BigInt(uid)
|
|
97
|
+
|| (metadata.mode & 3647n) !== 0n || (metadata.mode & 320n) !== 320n) {
|
|
98
|
+
fail('unsafe-parent', 'database parent must be a private owner directory without symlinks');
|
|
99
|
+
}
|
|
100
|
+
const pinned = { path, descriptor, device: metadata.dev, inode: metadata.ino, mode: metadata.mode };
|
|
101
|
+
descriptor = undefined;
|
|
102
|
+
return pinned;
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error instanceof AutomationOperatorSnapshotError)
|
|
106
|
+
throw error;
|
|
107
|
+
fail('unsafe-parent', 'database parent cannot be resolved');
|
|
7
108
|
}
|
|
8
109
|
finally {
|
|
9
|
-
|
|
110
|
+
if (descriptor !== undefined)
|
|
111
|
+
closeSync(descriptor);
|
|
10
112
|
}
|
|
113
|
+
throw new Error('unreachable');
|
|
114
|
+
}
|
|
115
|
+
function pinnedParentMatches(parent) {
|
|
116
|
+
try {
|
|
117
|
+
const pathname = lstatSync(parent.path, { bigint: true });
|
|
118
|
+
const descriptor = fstatSync(parent.descriptor, { bigint: true });
|
|
119
|
+
return pathname.isDirectory() && !pathname.isSymbolicLink() && descriptor.isDirectory()
|
|
120
|
+
&& pathname.dev === parent.device && pathname.ino === parent.inode
|
|
121
|
+
&& descriptor.dev === parent.device && descriptor.ino === parent.inode
|
|
122
|
+
&& descriptor.mode === parent.mode;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function pinnedFileIdentityMatches(file) {
|
|
129
|
+
try {
|
|
130
|
+
const pathname = lstatSync(file.path, { bigint: true });
|
|
131
|
+
const opened = fstatSync(file.descriptor, { bigint: true });
|
|
132
|
+
return pathname.isFile() && !pathname.isSymbolicLink()
|
|
133
|
+
&& pathname.dev === file.stat.dev && pathname.ino === file.stat.ino
|
|
134
|
+
&& opened.dev === file.stat.dev && opened.ino === file.stat.ino;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Inode binding is enforced through Linux /proc/self/fd. On other platforms the
|
|
141
|
+
// snapshot keeps its historical path-based pinning; CI still exercises that code.
|
|
142
|
+
const inodeBindSupported = process.platform === 'linux';
|
|
143
|
+
const procSelfFd = '/proc/self/fd';
|
|
144
|
+
/**
|
|
145
|
+
* Enumerate the regular-file inodes held by THIS process, excluding the
|
|
146
|
+
* descriptors the snapshot pinned itself. After the exclusion the only
|
|
147
|
+
* remaining database fds are SQLite's, so a (dev,ino) match proves the
|
|
148
|
+
* connection genuinely opened the pinned inode rather than an attacker's
|
|
149
|
+
* same-name replacement. An attacker in another process cannot plant an fd
|
|
150
|
+
* here because /proc/self/fd never lists another process's descriptors.
|
|
151
|
+
*/
|
|
152
|
+
function connectionInodes(exclude) {
|
|
153
|
+
let entries;
|
|
154
|
+
try {
|
|
155
|
+
entries = readdirSync(procSelfFd);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
fail('database-corrupt', 'cannot inspect the database connection file descriptors');
|
|
159
|
+
}
|
|
160
|
+
const inodes = [];
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
const descriptor = Number(entry);
|
|
163
|
+
if (!Number.isInteger(descriptor) || descriptor <= 2 || exclude.has(descriptor))
|
|
164
|
+
continue;
|
|
165
|
+
let stat;
|
|
166
|
+
try {
|
|
167
|
+
stat = fstatSync(descriptor, { bigint: true });
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
continue;
|
|
171
|
+
} // transient/closed fd
|
|
172
|
+
if (stat.isFile())
|
|
173
|
+
inodes.push({ device: stat.dev, inode: stat.ino });
|
|
174
|
+
}
|
|
175
|
+
return inodes;
|
|
176
|
+
}
|
|
177
|
+
function assertConnectionBound(files, exclude) {
|
|
178
|
+
const held = connectionInodes(exclude);
|
|
179
|
+
for (const file of files) {
|
|
180
|
+
if (!held.some(inode => inode.device === file.stat.dev && inode.inode === file.stat.ino)) {
|
|
181
|
+
fail('database-drift', 'database connection is not bound to the pinned inode');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Pin the private mkdtemp copy directory itself so a same-name swap cannot retarget the copies. */
|
|
186
|
+
function pinTempDirectory(path) {
|
|
187
|
+
let descriptor;
|
|
188
|
+
try {
|
|
189
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
190
|
+
const linked = lstatSync(path, { bigint: true });
|
|
191
|
+
const opened = fstatSync(descriptor, { bigint: true });
|
|
192
|
+
const uid = process.getuid?.();
|
|
193
|
+
if (!linked.isDirectory() || linked.isSymbolicLink() || !opened.isDirectory()
|
|
194
|
+
|| linked.dev !== opened.dev || linked.ino !== opened.ino) {
|
|
195
|
+
fail('unsafe-path', 'temporary snapshot directory cannot be pinned');
|
|
196
|
+
}
|
|
197
|
+
if ((opened.mode & 4095n) !== 448n || uid === undefined || opened.uid !== BigInt(uid)) {
|
|
198
|
+
fail('unsafe-path', 'temporary snapshot directory must be a private owner-only 0700 directory');
|
|
199
|
+
}
|
|
200
|
+
const pinned = { path, descriptor, device: opened.dev, inode: opened.ino };
|
|
201
|
+
descriptor = undefined;
|
|
202
|
+
return pinned;
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (error instanceof AutomationOperatorSnapshotError)
|
|
206
|
+
throw error;
|
|
207
|
+
fail('unsafe-path', 'temporary snapshot directory cannot be pinned');
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
if (descriptor !== undefined)
|
|
211
|
+
try {
|
|
212
|
+
closeSync(descriptor);
|
|
213
|
+
}
|
|
214
|
+
catch { }
|
|
215
|
+
}
|
|
216
|
+
throw new Error('unreachable');
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Materialise one private byte-for-byte copy THROUGH the pinned directory fd
|
|
220
|
+
* (`/proc/self/fd/<dirfd>/<name>`), then reopen it O_NOFOLLOW through the same
|
|
221
|
+
* anchor and keep that descriptor open. Neither the create, the verify, the
|
|
222
|
+
* SQLite open, or the cleanup ever resolve a swappable path component, so a
|
|
223
|
+
* same-name directory swap cannot retarget the replica. The returned file is
|
|
224
|
+
* left open and must be closed by the caller.
|
|
225
|
+
*/
|
|
226
|
+
function copyAndPin(source, targetRef) {
|
|
227
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
228
|
+
let destination;
|
|
229
|
+
try {
|
|
230
|
+
destination = openSync(targetRef, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
231
|
+
let position = 0n;
|
|
232
|
+
while (position < source.stat.size) {
|
|
233
|
+
const length = Number(source.stat.size - position > BigInt(buffer.length)
|
|
234
|
+
? BigInt(buffer.length) : source.stat.size - position);
|
|
235
|
+
const count = readSync(source.descriptor, buffer, 0, length, Number(position));
|
|
236
|
+
if (count <= 0)
|
|
237
|
+
fail('database-drift', `${basename(source.path)} changed while it was copied`);
|
|
238
|
+
let written = 0;
|
|
239
|
+
while (written < count)
|
|
240
|
+
written += writeSync(destination, buffer, written, count - written);
|
|
241
|
+
position += BigInt(count);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (destination !== undefined)
|
|
246
|
+
try {
|
|
247
|
+
closeSync(destination);
|
|
248
|
+
}
|
|
249
|
+
catch { }
|
|
250
|
+
if (error instanceof AutomationOperatorSnapshotError)
|
|
251
|
+
throw error;
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
if (destination !== undefined)
|
|
255
|
+
closeSync(destination);
|
|
256
|
+
const copied = openPrivateFile(targetRef, 'database-drift');
|
|
257
|
+
if (copied.stat.size !== source.stat.size || copied.digest !== source.digest) {
|
|
258
|
+
try {
|
|
259
|
+
closeSync(copied.descriptor);
|
|
260
|
+
}
|
|
261
|
+
catch { }
|
|
262
|
+
fail('database-drift', `${basename(source.path)} copy does not match its pinned source`);
|
|
263
|
+
}
|
|
264
|
+
return copied;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Remove the replica THROUGH the pinned directory fd, unlinking only the fixed
|
|
268
|
+
* names SQLite could create. The directory itself is rmdir'd by path only after
|
|
269
|
+
* the pinned fd proves the path still names the same empty inode; a swapped or
|
|
270
|
+
* non-empty directory is left untouched and the snapshot fails closed rather
|
|
271
|
+
* than recursively deleting an attacker-controlled replacement.
|
|
272
|
+
*/
|
|
273
|
+
function removePinnedTemp(pinned, names) {
|
|
274
|
+
const anchor = `${procSelfFd}/${pinned.descriptor}`;
|
|
275
|
+
for (const name of names) {
|
|
276
|
+
try {
|
|
277
|
+
unlinkSync(`${anchor}/${name}`);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
if (error.code !== 'ENOENT') {
|
|
281
|
+
fail('database-drift', 'temporary snapshot copy cannot be removed through its pinned directory');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
let entries;
|
|
286
|
+
try {
|
|
287
|
+
entries = readdirSync(anchor);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
fail('database-drift', 'temporary snapshot directory became unavailable');
|
|
291
|
+
}
|
|
292
|
+
if (entries.length !== 0)
|
|
293
|
+
fail('database-drift', 'temporary snapshot directory contains unexpected entries');
|
|
294
|
+
const linked = lstatSync(pinned.path, { bigint: true });
|
|
295
|
+
const opened = fstatSync(pinned.descriptor, { bigint: true });
|
|
296
|
+
if (linked.dev !== pinned.device || linked.ino !== pinned.inode
|
|
297
|
+
|| opened.dev !== pinned.device || opened.ino !== pinned.inode) {
|
|
298
|
+
fail('database-drift', 'temporary snapshot directory was retargeted before cleanup');
|
|
299
|
+
}
|
|
300
|
+
closeSync(pinned.descriptor);
|
|
301
|
+
try {
|
|
302
|
+
rmdirSync(pinned.path);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (error.code !== 'ENOENT') {
|
|
306
|
+
fail('database-drift', 'temporary snapshot directory could not be removed');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function openPrivateFile(path, missing) {
|
|
311
|
+
let linked;
|
|
312
|
+
try {
|
|
313
|
+
linked = lstatSync(path, { bigint: true });
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (typeof error === 'object' && error !== null && 'code' in error
|
|
317
|
+
&& error.code === 'ENOENT')
|
|
318
|
+
fail(missing, `${basename(path)} does not exist`);
|
|
319
|
+
fail('unsafe-path', `${basename(path)} cannot be inspected`);
|
|
320
|
+
}
|
|
321
|
+
const uid = process.getuid?.();
|
|
322
|
+
if (!linked.isFile() || linked.isSymbolicLink() || linked.nlink !== 1n
|
|
323
|
+
|| (linked.mode & 4095n) !== 384n
|
|
324
|
+
|| (uid !== undefined && linked.uid !== BigInt(uid))
|
|
325
|
+
|| linked.size > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
326
|
+
fail('unsafe-path', `${basename(path)} must be one private owned regular file`);
|
|
327
|
+
}
|
|
328
|
+
let descriptor;
|
|
329
|
+
try {
|
|
330
|
+
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
fail('unsafe-path', `${basename(path)} cannot be opened safely`);
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const opened = fstatSync(descriptor, { bigint: true });
|
|
337
|
+
const after = lstatSync(path, { bigint: true });
|
|
338
|
+
if (!sameFile(linked, opened) || !sameFile(opened, after)) {
|
|
339
|
+
fail('database-drift', `${basename(path)} changed while being opened`);
|
|
340
|
+
}
|
|
341
|
+
const digest = digestDescriptor(descriptor, opened.size);
|
|
342
|
+
const hashed = fstatSync(descriptor, { bigint: true });
|
|
343
|
+
if (!sameFile(opened, hashed))
|
|
344
|
+
fail('database-drift', `${basename(path)} changed while being hashed`);
|
|
345
|
+
return { path, descriptor, stat: opened, digest };
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
closeSync(descriptor);
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function openOptionalSidecar(path) {
|
|
353
|
+
try {
|
|
354
|
+
lstatSync(path);
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
if (typeof error === 'object' && error !== null && 'code' in error
|
|
358
|
+
&& error.code === 'ENOENT')
|
|
359
|
+
return null;
|
|
360
|
+
fail('unsafe-path', `${basename(path)} cannot be inspected`);
|
|
361
|
+
}
|
|
362
|
+
return openPrivateFile(path, 'database-drift');
|
|
363
|
+
}
|
|
364
|
+
function assertStillIdentical(file) {
|
|
365
|
+
let linked;
|
|
366
|
+
try {
|
|
367
|
+
linked = lstatSync(file.path, { bigint: true });
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
fail('database-drift', `${basename(file.path)} disappeared during the snapshot`);
|
|
371
|
+
}
|
|
372
|
+
const opened = fstatSync(file.descriptor, { bigint: true });
|
|
373
|
+
if (!sameFile(file.stat, opened) || !sameFile(opened, linked)
|
|
374
|
+
|| digestDescriptor(file.descriptor, opened.size) !== file.digest) {
|
|
375
|
+
fail('database-drift', `${basename(file.path)} changed during the snapshot`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function assertSidecarState(path, before) {
|
|
379
|
+
if (before === null) {
|
|
380
|
+
try {
|
|
381
|
+
lstatSync(path);
|
|
382
|
+
fail('database-drift', `${basename(path)} appeared during the snapshot`);
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
if (error instanceof AutomationOperatorSnapshotError)
|
|
386
|
+
throw error;
|
|
387
|
+
if (!(typeof error === 'object' && error !== null && 'code' in error
|
|
388
|
+
&& error.code === 'ENOENT')) {
|
|
389
|
+
fail('database-drift', `${basename(path)} changed during the snapshot`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
assertStillIdentical(before);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Decode a Linux user-space dev_t per <sys/sysmacros.h>, preserving the full
|
|
398
|
+
* bit width:
|
|
399
|
+
* major(dev) = ((dev >> 8) & 0xfff) | ((dev >> 32) & 0xfffff000)
|
|
400
|
+
* minor(dev) = (dev & 0xff) | ((dev >> 12) & 0xffffff00)
|
|
401
|
+
*/
|
|
402
|
+
export function decodeDeviceId(dev) {
|
|
403
|
+
return {
|
|
404
|
+
major: ((dev >> 8n) & 0xfffn) | ((dev >> 32n) & 0xfffff000n),
|
|
405
|
+
minor: (dev & 0xffn) | ((dev >> 12n) & 0xffffff00n),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function lockTargets(files) {
|
|
409
|
+
return files.filter((file) => file !== null)
|
|
410
|
+
.map(file => ({ dev: file.stat.dev, ino: file.stat.ino }));
|
|
411
|
+
}
|
|
412
|
+
/** Parse the MAJ:MIN:INO out of one /proc/locks row, including blocked-lock rows. */
|
|
413
|
+
function parseLockIdentity(fields) {
|
|
414
|
+
// /proc/locks rows are "num: KIND MODE ACCESS PID MAJ:MIN:INO START END";
|
|
415
|
+
// blocked locks insert "->" after the number, shifting the pid/identity by one.
|
|
416
|
+
const blockedOffset = fields[1] === '->' ? 1 : 0;
|
|
417
|
+
const identity = fields[5 + blockedOffset];
|
|
418
|
+
if (identity === undefined || !/^[0-9a-f]+:[0-9a-f]+:[0-9]+$/iu.test(identity))
|
|
419
|
+
return null;
|
|
420
|
+
const [major, minor, inode] = identity.split(':');
|
|
421
|
+
if (major === undefined || minor === undefined || inode === undefined)
|
|
422
|
+
return null;
|
|
423
|
+
return { major: BigInt(`0x${major}`), minor: BigInt(`0x${minor}`), inode };
|
|
424
|
+
}
|
|
425
|
+
/** Identity of a WRITE lock in one /proc/locks row; null for READ locks or malformed rows. */
|
|
426
|
+
export function parseWritableProcLock(line) {
|
|
427
|
+
const fields = line.trim().split(/\s+/u);
|
|
428
|
+
// blocked locks insert "->" after the number, shifting MODE/ACCESS/pid by one.
|
|
429
|
+
const blockedOffset = fields[1] === '->' ? 1 : 0;
|
|
430
|
+
if (fields[3 + blockedOffset] !== 'WRITE')
|
|
431
|
+
return null;
|
|
432
|
+
return parseLockIdentity(fields);
|
|
433
|
+
}
|
|
434
|
+
function assertNoExclusiveWriter(targets) {
|
|
435
|
+
let locks;
|
|
436
|
+
try {
|
|
437
|
+
locks = readFileSync('/proc/locks', 'utf8');
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// The lock table is the only available writer probe; never proceed when it
|
|
441
|
+
// cannot be read, since a live writer could then go undetected.
|
|
442
|
+
fail('database-busy', 'cannot inspect active database locks');
|
|
443
|
+
}
|
|
444
|
+
const decoded = targets.map(target => ({ ...decodeDeviceId(target.dev), inode: target.ino.toString() }));
|
|
445
|
+
const locked = locks.split('\n').some(line => {
|
|
446
|
+
const identity = parseWritableProcLock(line);
|
|
447
|
+
return identity !== null && decoded.some(target => target.major === identity.major
|
|
448
|
+
&& target.minor === identity.minor && target.inode === identity.inode);
|
|
449
|
+
});
|
|
450
|
+
if (locked)
|
|
451
|
+
fail('database-busy', 'database has an exclusive writer lock');
|
|
452
|
+
}
|
|
453
|
+
function retryExclusiveWriterProbe(targets) {
|
|
454
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
455
|
+
assertNoExclusiveWriter(targets);
|
|
456
|
+
const until = process.hrtime.bigint() + 1000000n;
|
|
457
|
+
while (process.hrtime.bigint() < until) { /* bounded race-window probe */ }
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* A hot rollback journal means an interrupted transaction may be embedded in
|
|
462
|
+
* the main file; immutable=1 opens skip journal replay, so its mere presence
|
|
463
|
+
* forces a busy verdict. Runs before the source is pinned or opened.
|
|
464
|
+
*/
|
|
465
|
+
function assertNoRollbackJournal(path) {
|
|
466
|
+
try {
|
|
467
|
+
lstatSync(`${path}-journal`);
|
|
468
|
+
fail('database-busy', 'database has a rollback journal');
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
if (error instanceof AutomationOperatorSnapshotError)
|
|
472
|
+
throw error;
|
|
473
|
+
if (typeof error === 'object' && error !== null && 'code' in error
|
|
474
|
+
&& error.code === 'ENOENT')
|
|
475
|
+
return;
|
|
476
|
+
fail('unsafe-path', 'rollback journal cannot be inspected');
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/** A journal appearing once the snapshot started is writer drift and must fail closed. */
|
|
480
|
+
function assertJournalAbsent(path) {
|
|
481
|
+
try {
|
|
482
|
+
lstatSync(`${path}-journal`);
|
|
483
|
+
fail('database-drift', 'rollback journal appeared during the snapshot');
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (error instanceof AutomationOperatorSnapshotError)
|
|
487
|
+
throw error;
|
|
488
|
+
if (typeof error === 'object' && error !== null && 'code' in error
|
|
489
|
+
&& error.code === 'ENOENT')
|
|
490
|
+
return;
|
|
491
|
+
fail('database-drift', 'rollback journal cannot be inspected during the snapshot');
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function copyOpenedFile(source, target) {
|
|
495
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
496
|
+
const destination = openSync(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
497
|
+
try {
|
|
498
|
+
let position = 0n;
|
|
499
|
+
while (position < source.stat.size) {
|
|
500
|
+
const length = Number(source.stat.size - position > BigInt(buffer.length)
|
|
501
|
+
? BigInt(buffer.length) : source.stat.size - position);
|
|
502
|
+
const count = readSync(source.descriptor, buffer, 0, length, Number(position));
|
|
503
|
+
if (count <= 0)
|
|
504
|
+
fail('database-drift', `${basename(source.path)} changed while being copied`);
|
|
505
|
+
let written = 0;
|
|
506
|
+
while (written < count)
|
|
507
|
+
written += writeSync(destination, buffer, written, count - written);
|
|
508
|
+
position += BigInt(count);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
finally {
|
|
512
|
+
closeSync(destination);
|
|
513
|
+
}
|
|
514
|
+
const copied = openPrivateFile(target, 'database-drift');
|
|
515
|
+
try {
|
|
516
|
+
if (copied.stat.size !== source.stat.size || copied.digest !== source.digest) {
|
|
517
|
+
fail('database-drift', `${basename(source.path)} copy does not match its pinned source`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
finally {
|
|
521
|
+
closeSync(copied.descriptor);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function privateDatabasePath(path) {
|
|
525
|
+
if (typeof path !== 'string' || path.length === 0 || !isAbsolute(path) || resolve(path) !== path
|
|
526
|
+
|| path === '/' || path !== path.normalize('NFC') || /[\p{Cc}]/u.test(path)) {
|
|
527
|
+
fail('invalid-path', 'database path must be an absolute canonical path');
|
|
528
|
+
}
|
|
529
|
+
let physical;
|
|
530
|
+
let parentPhysical;
|
|
531
|
+
try {
|
|
532
|
+
parentPhysical = realpathSync(dirname(path));
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
if (typeof error === 'object' && error !== null && 'code' in error
|
|
536
|
+
&& error.code === 'ENOENT')
|
|
537
|
+
fail('unsafe-parent', 'database parent disappeared');
|
|
538
|
+
fail('unsafe-parent', 'database parent cannot be resolved');
|
|
539
|
+
}
|
|
540
|
+
if (parentPhysical !== dirname(path)) {
|
|
541
|
+
fail('unsafe-path', 'database path must not traverse a symbolic link');
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
physical = realpathSync(path);
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
if (typeof error === 'object' && error !== null && 'code' in error
|
|
548
|
+
&& error.code === 'ENOENT')
|
|
549
|
+
fail('database-missing', 'database does not exist');
|
|
550
|
+
fail('unsafe-path', 'database path cannot be resolved');
|
|
551
|
+
}
|
|
552
|
+
if (physical !== path || parentPhysical !== dirname(path)) {
|
|
553
|
+
fail('unsafe-path', 'database path must not traverse a symbolic link');
|
|
554
|
+
}
|
|
555
|
+
return physical;
|
|
556
|
+
}
|
|
557
|
+
function parseDefinition(row) {
|
|
558
|
+
if (typeof row.definition_json !== 'string'
|
|
559
|
+
|| Buffer.byteLength(row.definition_json, 'utf8') > 16 * 1024 * 1024
|
|
560
|
+
|| typeof row.definition_hash !== 'string' || !digestPattern.test(row.definition_hash)) {
|
|
561
|
+
fail('database-corrupt', 'automation definition metadata is invalid');
|
|
562
|
+
}
|
|
563
|
+
let parsed;
|
|
564
|
+
try {
|
|
565
|
+
parsed = JSON.parse(row.definition_json);
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
fail('database-corrupt', 'automation definition JSON is invalid');
|
|
569
|
+
}
|
|
570
|
+
const definition = (() => {
|
|
571
|
+
try {
|
|
572
|
+
return normalizeAutomationDefinition(parsed, 16 * 1024 * 1024, 10_000);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return fail('database-corrupt', 'automation definition is not canonical');
|
|
576
|
+
}
|
|
577
|
+
})();
|
|
578
|
+
const canonical = JSON.stringify(definition);
|
|
579
|
+
if (row.definition_json !== canonical || sha256(canonical) !== row.definition_hash) {
|
|
580
|
+
fail('database-corrupt', 'automation definition digest is invalid');
|
|
581
|
+
}
|
|
582
|
+
return freeze(definition);
|
|
583
|
+
}
|
|
584
|
+
function parseRecord(row) {
|
|
585
|
+
if (!canonicalText(row.id, 500)
|
|
586
|
+
|| (row.system_owner !== null && !canonicalText(row.system_owner, 200))
|
|
587
|
+
|| (row.status !== 'active' && row.status !== 'paused' && row.status !== 'deleted')
|
|
588
|
+
|| !safeInteger(row.created_at) || !safeInteger(row.updated_at) || row.updated_at < row.created_at
|
|
589
|
+
|| !safeInteger(row.version, 1) || !safeInteger(row.running_task_count)
|
|
590
|
+
|| (row.next_run_at !== null && !safeInteger(row.next_run_at))) {
|
|
591
|
+
fail('database-corrupt', 'automation definition row is invalid');
|
|
592
|
+
}
|
|
593
|
+
if (row.status !== 'active' && row.next_run_at !== null) {
|
|
594
|
+
fail('database-corrupt', 'inactive automation has a next run time');
|
|
595
|
+
}
|
|
596
|
+
const definition = parseDefinition(row);
|
|
597
|
+
return freeze({
|
|
598
|
+
id: row.id,
|
|
599
|
+
...(row.system_owner === null ? {} : { owner: row.system_owner }),
|
|
600
|
+
definitionHash: row.definition_hash,
|
|
601
|
+
definition,
|
|
602
|
+
status: row.status,
|
|
603
|
+
...(row.next_run_at === null ? {} : { nextRunAt: row.next_run_at }),
|
|
604
|
+
createdAt: row.created_at,
|
|
605
|
+
updatedAt: row.updated_at,
|
|
606
|
+
version: row.version,
|
|
607
|
+
runningTaskCount: row.running_task_count,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
function publicRecord(record) {
|
|
611
|
+
return freeze({
|
|
612
|
+
id: record.id,
|
|
613
|
+
...(record.owner === undefined ? {} : { owner: record.owner }),
|
|
614
|
+
definitionHash: record.definitionHash,
|
|
615
|
+
status: record.status,
|
|
616
|
+
...(record.nextRunAt === undefined ? {} : { nextRunAt: record.nextRunAt }),
|
|
617
|
+
createdAt: record.createdAt,
|
|
618
|
+
updatedAt: record.updatedAt,
|
|
619
|
+
version: record.version,
|
|
620
|
+
runningTaskCount: record.runningTaskCount,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
function readSnapshot(databasePath) {
|
|
624
|
+
const path = privateDatabasePath(databasePath);
|
|
625
|
+
// Pin the private parent directory for the whole snapshot so an ancestor
|
|
626
|
+
// rename or a same-name directory swap cannot retarget the database path
|
|
627
|
+
// between the path checks and the read.
|
|
628
|
+
const pinnedParent = pinPrivateParent(dirname(path));
|
|
629
|
+
const walPath = `${path}-wal`;
|
|
630
|
+
const shmPath = `${path}-shm`;
|
|
631
|
+
let source;
|
|
632
|
+
let wal = null;
|
|
633
|
+
let shm = null;
|
|
634
|
+
let temporary;
|
|
635
|
+
let temporaryPinned;
|
|
636
|
+
let copyFiles = [];
|
|
637
|
+
let database;
|
|
638
|
+
let transaction = false;
|
|
639
|
+
let result;
|
|
640
|
+
let deferred;
|
|
641
|
+
// Descriptors the snapshot itself holds; these are excluded when proving that
|
|
642
|
+
// SQLite opened the pinned inode, so the connection can never vouch for itself.
|
|
643
|
+
const selfDescriptors = () => new Set([
|
|
644
|
+
pinnedParent.descriptor, source?.descriptor, wal?.descriptor, shm?.descriptor,
|
|
645
|
+
temporaryPinned?.descriptor, ...copyFiles.map(file => file.descriptor),
|
|
646
|
+
].filter((descriptor) => descriptor !== undefined));
|
|
647
|
+
try {
|
|
648
|
+
assertNoRollbackJournal(path);
|
|
649
|
+
source = openPrivateFile(path, 'database-missing');
|
|
650
|
+
assertJournalAbsent(path);
|
|
651
|
+
wal = openOptionalSidecar(walPath);
|
|
652
|
+
shm = openOptionalSidecar(shmPath);
|
|
653
|
+
if ((wal === null) !== (shm === null)) {
|
|
654
|
+
// An exclusive WAL writer can legitimately be observed between creating
|
|
655
|
+
// its WAL and SHM entries. Re-probe the lock before classifying a stable
|
|
656
|
+
// orphan sidecar as source drift.
|
|
657
|
+
retryExclusiveWriterProbe(lockTargets([source, wal, shm]));
|
|
658
|
+
fail('database-drift', 'WAL sidecar set is incomplete');
|
|
659
|
+
}
|
|
660
|
+
// In WAL mode the writer's POSIX WRITE lock is held on the SHM inode
|
|
661
|
+
// (byte 120), not the main database, so every sidecar inode is probed.
|
|
662
|
+
assertNoExclusiveWriter(lockTargets([source, wal, shm]));
|
|
663
|
+
assertJournalAbsent(path);
|
|
664
|
+
assertStillIdentical(source);
|
|
665
|
+
assertSidecarState(walPath, wal);
|
|
666
|
+
assertSidecarState(shmPath, shm);
|
|
667
|
+
// Files SQLite must prove it is reading. Without WAL that is the pinned
|
|
668
|
+
// source inode; with WAL it is the pinned private replica inodes.
|
|
669
|
+
let bound = [];
|
|
670
|
+
let target;
|
|
671
|
+
if (wal === null) {
|
|
672
|
+
if (inodeBindSupported) {
|
|
673
|
+
// Open the pinned descriptor itself by inode (Linux) so a same-name swap
|
|
674
|
+
// cannot retarget the connection; immutable=1 forbids WAL/journal access.
|
|
675
|
+
target = `file://${procSelfFd}/${source.descriptor}?mode=ro&immutable=1`;
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
const location = pathToFileURL(path);
|
|
679
|
+
location.searchParams.set('mode', 'ro');
|
|
680
|
+
location.searchParams.set('immutable', '1');
|
|
681
|
+
target = location;
|
|
682
|
+
}
|
|
683
|
+
bound = [source];
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
temporary = mkdtempSync(join(tmpdir(), 'automations-operator-snapshot-'));
|
|
687
|
+
const temporaryMetadata = lstatSync(temporary, { bigint: true });
|
|
688
|
+
if (!temporaryMetadata.isDirectory() || temporaryMetadata.isSymbolicLink()
|
|
689
|
+
|| (temporaryMetadata.mode & 4095n) !== 448n
|
|
690
|
+
|| (process.getuid?.() !== undefined && temporaryMetadata.uid !== BigInt(process.getuid()))) {
|
|
691
|
+
fail('unsafe-path', 'temporary snapshot directory is not private');
|
|
692
|
+
}
|
|
693
|
+
if (inodeBindSupported) {
|
|
694
|
+
temporaryPinned = pinTempDirectory(temporary);
|
|
695
|
+
const anchor = `${procSelfFd}/${temporaryPinned.descriptor}`;
|
|
696
|
+
const mainRef = `${anchor}/automations.sqlite`;
|
|
697
|
+
// Only the main file and WAL are copied; a rollback journal is never
|
|
698
|
+
// copied or replayed, and its appearance fails the snapshot below.
|
|
699
|
+
const copiedMain = copyAndPin(source, mainRef);
|
|
700
|
+
let copiedWal;
|
|
701
|
+
try {
|
|
702
|
+
copiedWal = copyAndPin(wal, `${mainRef}-wal`);
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
try {
|
|
706
|
+
closeSync(copiedMain.descriptor);
|
|
707
|
+
}
|
|
708
|
+
catch { }
|
|
709
|
+
;
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
712
|
+
copyFiles = Object.freeze([copiedMain, copiedWal]);
|
|
713
|
+
target = mainRef;
|
|
714
|
+
bound = copyFiles;
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
const copy = join(temporary, 'automations.sqlite');
|
|
718
|
+
// Only the main file and WAL are copied; a rollback journal is never
|
|
719
|
+
// copied or replayed, and its appearance fails the snapshot below.
|
|
720
|
+
copyOpenedFile(source, copy);
|
|
721
|
+
copyOpenedFile(wal, `${copy}-wal`);
|
|
722
|
+
target = copy;
|
|
723
|
+
}
|
|
724
|
+
assertStillIdentical(source);
|
|
725
|
+
assertSidecarState(walPath, wal);
|
|
726
|
+
assertSidecarState(shmPath, shm);
|
|
727
|
+
}
|
|
728
|
+
assertJournalAbsent(path);
|
|
729
|
+
if (!pinnedFileIdentityMatches(source)) {
|
|
730
|
+
fail('database-drift', 'database path changed before open');
|
|
731
|
+
}
|
|
732
|
+
database = new DatabaseSync(target, { readOnly: true });
|
|
733
|
+
// The WAL/SHM fds are opened lazily on first query, so right after the
|
|
734
|
+
// constructor only the main database inode can be claimed.
|
|
735
|
+
if (inodeBindSupported)
|
|
736
|
+
assertConnectionBound([bound[0]], selfDescriptors());
|
|
737
|
+
if (!pinnedFileIdentityMatches(source)) {
|
|
738
|
+
fail('database-drift', 'database path changed during open');
|
|
739
|
+
}
|
|
740
|
+
database.exec('PRAGMA query_only = ON; PRAGMA busy_timeout = 0; BEGIN');
|
|
741
|
+
transaction = true;
|
|
742
|
+
const queryOnly = database.prepare('PRAGMA query_only').get().query_only;
|
|
743
|
+
if (queryOnly !== 1)
|
|
744
|
+
fail('database-unavailable', 'read-only guard was not enabled');
|
|
745
|
+
const version = database.prepare('PRAGMA user_version').get().user_version;
|
|
746
|
+
if (version !== automationSchemaVersion)
|
|
747
|
+
fail('schema-unsupported', `expected schema ${automationSchemaVersion}`);
|
|
748
|
+
const check = database.prepare('PRAGMA quick_check').all();
|
|
749
|
+
if (check.length !== 1 || check[0]?.quick_check !== 'ok')
|
|
750
|
+
fail('database-corrupt', 'SQLite quick_check failed');
|
|
751
|
+
const rows = database.prepare(`
|
|
752
|
+
SELECT definition.id, definition.system_owner, definition.definition_hash, definition.definition_json,
|
|
753
|
+
definition.status, definition.next_run_at, definition.created_at, definition.updated_at, definition.version,
|
|
754
|
+
COALESCE(SUM(CASE WHEN task.status IN ('claimed', 'running') THEN 1 ELSE 0 END), 0) AS running_task_count
|
|
755
|
+
FROM automation_definitions AS definition
|
|
756
|
+
LEFT JOIN automation_tasks AS task ON task.automation_id = definition.id
|
|
757
|
+
GROUP BY definition.id
|
|
758
|
+
ORDER BY definition.id COLLATE BINARY
|
|
759
|
+
`).all();
|
|
760
|
+
const internalRecords = freeze(rows.map(parseRecord));
|
|
761
|
+
// The main query has now opened the WAL/SHM fds; re-prove every inode the
|
|
762
|
+
// result depends on is still the pinned one (non-WAL keeps just the source).
|
|
763
|
+
if (inodeBindSupported)
|
|
764
|
+
assertConnectionBound(bound, selfDescriptors());
|
|
765
|
+
const inFlight = database.prepare(`
|
|
766
|
+
SELECT COUNT(*) AS count FROM automation_tasks WHERE status IN ('claimed', 'running')
|
|
767
|
+
`).get();
|
|
768
|
+
if (!safeInteger(inFlight.count))
|
|
769
|
+
fail('database-corrupt', 'in-flight task count is invalid');
|
|
770
|
+
const summed = internalRecords.reduce((total, record) => total + record.runningTaskCount, 0);
|
|
771
|
+
if (!Number.isSafeInteger(summed) || summed !== inFlight.count) {
|
|
772
|
+
fail('database-corrupt', 'in-flight tasks are not bound to the definition inventory');
|
|
773
|
+
}
|
|
774
|
+
const foreignKeys = database.prepare('PRAGMA foreign_key_check').all();
|
|
775
|
+
if (foreignKeys.length !== 0)
|
|
776
|
+
fail('database-corrupt', 'foreign-key integrity check failed');
|
|
777
|
+
const records = freeze(internalRecords.map(publicRecord));
|
|
778
|
+
database.exec('ROLLBACK');
|
|
779
|
+
transaction = false;
|
|
780
|
+
database.close();
|
|
781
|
+
database = undefined;
|
|
782
|
+
assertJournalAbsent(path);
|
|
783
|
+
assertStillIdentical(source);
|
|
784
|
+
assertSidecarState(walPath, wal);
|
|
785
|
+
assertSidecarState(shmPath, shm);
|
|
786
|
+
const storageDigest = sha256(JSON.stringify({
|
|
787
|
+
protocol: 'assistant-automations/operator-storage/v1',
|
|
788
|
+
database: source.digest,
|
|
789
|
+
wal: wal?.digest ?? null,
|
|
790
|
+
}));
|
|
791
|
+
const inventoryDigest = sha256(JSON.stringify({
|
|
792
|
+
protocol: automationsOperatorSnapshotProtocol,
|
|
793
|
+
schemaVersion: 15,
|
|
794
|
+
storageDigest,
|
|
795
|
+
inFlightCount: inFlight.count,
|
|
796
|
+
records,
|
|
797
|
+
}));
|
|
798
|
+
const snapshot = freeze({
|
|
799
|
+
protocol: automationsOperatorSnapshotProtocol,
|
|
800
|
+
schemaVersion: 15,
|
|
801
|
+
database: identity(source),
|
|
802
|
+
sidecars: freeze({ wal: wal === null ? null : identity(wal), shm: shm === null ? null : identity(shm) }),
|
|
803
|
+
storageDigest,
|
|
804
|
+
inFlightCount: inFlight.count,
|
|
805
|
+
inventoryDigest,
|
|
806
|
+
records,
|
|
807
|
+
});
|
|
808
|
+
result = { snapshot, internalRecords };
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
deferred = error;
|
|
812
|
+
}
|
|
813
|
+
finally {
|
|
814
|
+
if (transaction)
|
|
815
|
+
try {
|
|
816
|
+
database?.exec('ROLLBACK');
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
deferred ??= error;
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
database?.close();
|
|
823
|
+
}
|
|
824
|
+
catch (error) {
|
|
825
|
+
deferred ??= error;
|
|
826
|
+
}
|
|
827
|
+
if (source !== undefined) {
|
|
828
|
+
try {
|
|
829
|
+
assertJournalAbsent(path);
|
|
830
|
+
assertStillIdentical(source);
|
|
831
|
+
assertSidecarState(walPath, wal);
|
|
832
|
+
assertSidecarState(shmPath, shm);
|
|
833
|
+
}
|
|
834
|
+
catch (error) {
|
|
835
|
+
deferred ??= error;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (!pinnedParentMatches(pinnedParent)) {
|
|
839
|
+
deferred = new AutomationOperatorSnapshotError('unsafe-parent', 'automation operator snapshot: database parent changed while it was inspected');
|
|
840
|
+
}
|
|
841
|
+
for (const file of [shm, wal, source])
|
|
842
|
+
if (file !== null && file !== undefined) {
|
|
843
|
+
try {
|
|
844
|
+
closeSync(file.descriptor);
|
|
845
|
+
}
|
|
846
|
+
catch { }
|
|
847
|
+
}
|
|
848
|
+
for (const file of copyFiles) {
|
|
849
|
+
try {
|
|
850
|
+
closeSync(file.descriptor);
|
|
851
|
+
}
|
|
852
|
+
catch { }
|
|
853
|
+
}
|
|
854
|
+
if (temporaryPinned !== undefined) {
|
|
855
|
+
try {
|
|
856
|
+
removePinnedTemp(temporaryPinned, ['automations.sqlite', 'automations.sqlite-wal', 'automations.sqlite-shm']);
|
|
857
|
+
}
|
|
858
|
+
catch (error) {
|
|
859
|
+
deferred ??= error;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
else if (temporary !== undefined) {
|
|
863
|
+
try {
|
|
864
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
865
|
+
}
|
|
866
|
+
catch (error) {
|
|
867
|
+
deferred ??= error;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
closeSync(pinnedParent.descriptor);
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
deferred ??= error;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (deferred !== undefined) {
|
|
878
|
+
if (deferred instanceof AutomationOperatorSnapshotError)
|
|
879
|
+
throw deferred;
|
|
880
|
+
const message = deferred instanceof Error ? deferred.message.toLowerCase() : '';
|
|
881
|
+
if (/(?:busy|locked)/u.test(message))
|
|
882
|
+
return fail('database-busy', 'database is busy');
|
|
883
|
+
return fail('database-corrupt', 'database cannot produce a valid snapshot');
|
|
884
|
+
}
|
|
885
|
+
return result;
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Side-effect-free, content-free operator inventory. It never creates, migrates, chmods,
|
|
889
|
+
* checkpoints, or changes journal mode, and fails closed if its source drifts.
|
|
890
|
+
*/
|
|
891
|
+
export function inspectAutomationsOperatorSnapshot(databasePath) {
|
|
892
|
+
return readSnapshot(databasePath).snapshot;
|
|
893
|
+
}
|
|
894
|
+
/** Compatibility name for callers built against the earlier inventory proposal. */
|
|
895
|
+
export const inspectAutomationInventoryLocally = inspectAutomationsOperatorSnapshot;
|
|
896
|
+
/**
|
|
897
|
+
* Whether a snapshot failure is solely the legacy writable-store condition
|
|
898
|
+
* "database file has not been created yet". The strict operator snapshot keeps
|
|
899
|
+
* failing closed; only the list-* compatibility projections treat it as the
|
|
900
|
+
* empty inventory that a fresh store would have created and returned.
|
|
901
|
+
*/
|
|
902
|
+
function isMissingDatabase(error) {
|
|
903
|
+
return error instanceof AutomationOperatorSnapshotError && error.code === 'database-missing';
|
|
904
|
+
}
|
|
905
|
+
function compatibilityRecords(databasePath) {
|
|
906
|
+
try {
|
|
907
|
+
return readSnapshot(databasePath).internalRecords;
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
if (isMissingDatabase(error))
|
|
911
|
+
return Object.freeze([]);
|
|
912
|
+
throw error;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
/** Read-only local compatibility projection; never starts the scheduler. */
|
|
916
|
+
export function listAutomationsLocally(databasePath) {
|
|
917
|
+
return compatibilityRecords(databasePath).map(record => freeze({
|
|
918
|
+
id: record.id,
|
|
919
|
+
...(record.owner === undefined ? {} : { owner: record.owner }),
|
|
920
|
+
definition: record.definition,
|
|
921
|
+
status: record.status,
|
|
922
|
+
nextRunAt: record.nextRunAt,
|
|
923
|
+
createdAt: record.createdAt,
|
|
924
|
+
updatedAt: record.updatedAt,
|
|
925
|
+
version: record.version,
|
|
926
|
+
}));
|
|
11
927
|
}
|
|
12
928
|
/** Compatibility projection retained for callers that only gate active work. */
|
|
13
929
|
export function listActiveAutomationsLocally(databasePath) {
|