@rome-os/node-core 0.1.2 → 0.1.4

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 CHANGED
@@ -121,6 +121,30 @@ same environment to `nodeConfigFromEnvironment()` as the CLI to honor
121
121
  `ROME_NODE_CONFIG_DIR`, `ROME_NODE_DAEMON_PORT`, and platform defaults.
122
122
  The library does not select an environment implicitly.
123
123
 
124
+ ## Activity console
125
+
126
+ The daemon records the actions it sends in `observe.db`. Read them with
127
+ `node.status`, `log.query`, `log.stats`, and the `node` event topic of the
128
+ [local protocol](../../docs/rome-node.md#activity-log).
129
+
130
+ A backend that serves a browser console puts `openConsoleBridge` between the
131
+ browser socket and the daemon:
132
+
133
+ ```ts
134
+ import { nodeConfigFromEnvironment, openConsoleBridge } from "@rome-os/node-core/client";
135
+
136
+ const bridge = openConsoleBridge(nodeConfigFromEnvironment(process.env), {
137
+ send: (text) => socket.send(text),
138
+ close: () => socket.close(),
139
+ });
140
+ socket.on("message", (data) => bridge.receive(data.toString()));
141
+ socket.on("close", () => bridge.close());
142
+ ```
143
+
144
+ The bridge never starts the daemon. It forwards only the observation methods.
145
+ When no daemon answers, it sends `console.daemon` with state `not_configured`,
146
+ `not_running`, `incompatible`, or `disconnected`, then closes.
147
+
124
148
  ## Caller authorization
125
149
 
126
150
  ```ts
@@ -166,7 +190,9 @@ await running;
166
190
  ```
167
191
 
168
192
  `showAuthorizationUrl` and `updateApplicationState` are application callbacks.
169
- A custom credential store implements `load`, `save`, and `clear`. The built-in
193
+ A custom credential store implements `load`, `save`, and `clear`. A stored token
194
+ is either a computer credential from `loginDevice` or `loginDeviceCode`, or the
195
+ Rome desktop app's own Rome Cloud sign-in. The built-in
170
196
  login adapter uses a temporary loopback callback listener. Applications can
171
197
  supply their own authorization function returning a device session.
172
198
 
package/dist/connect.js CHANGED
@@ -15,7 +15,7 @@ export async function connectHost(options) {
15
15
  let session = isRecord(stored) &&
16
16
  stored.cloudUrl === cloudUrl &&
17
17
  typeof stored.token === "string" &&
18
- /^romedev_[A-Za-z0-9_-]{43}$/.test(stored.token) &&
18
+ /^(?:romedev|romemob)_[A-Za-z0-9_-]{43}$/.test(stored.token) &&
19
19
  validId(stored.deviceId) &&
20
20
  typeof stored.name === "string"
21
21
  ? { cloudUrl, token: stored.token, deviceId: stored.deviceId, name: stored.name }
@@ -0,0 +1,12 @@
1
+ import { type NodeConfig } from "./local.js";
2
+ export declare const MAX_CONSOLE_MESSAGE_BYTES: number;
3
+ export type ConsoleDaemonState = "not_configured" | "not_running" | "incompatible" | "disconnected";
4
+ export interface ConsoleTransport {
5
+ send(text: string): void;
6
+ close(): void;
7
+ }
8
+ export interface ConsoleBridge {
9
+ receive(text: string): void;
10
+ close(): void;
11
+ }
12
+ export declare function openConsoleBridge(config: NodeConfig, transport: ConsoleTransport): ConsoleBridge;
@@ -0,0 +1,101 @@
1
+ import { isRecord } from "./actions.js";
2
+ import { findDaemon } from "./daemon-control.js";
3
+ import { DaemonVersionError, RpcError } from "./daemon-rpc.js";
4
+ import { readOptionalCallerCredential } from "./local.js";
5
+ export const MAX_CONSOLE_MESSAGE_BYTES = 64 * 1024;
6
+ const READ_METHODS = new Set(["node.status", "log.query", "log.stats"]);
7
+ export function openConsoleBridge(config, transport) {
8
+ let closed = false;
9
+ let rpc;
10
+ const queued = [];
11
+ const lifetime = new AbortController();
12
+ const finish = (state) => {
13
+ if (closed)
14
+ return;
15
+ closed = true;
16
+ lifetime.abort();
17
+ if (state)
18
+ transport.send(JSON.stringify({ jsonrpc: "2.0", method: "console.daemon", params: { state } }));
19
+ rpc?.close();
20
+ transport.close();
21
+ };
22
+ const reply = (id, body) => {
23
+ if (!closed)
24
+ transport.send(JSON.stringify({ jsonrpc: "2.0", id, ...body }));
25
+ };
26
+ function forward(text) {
27
+ let message;
28
+ try {
29
+ message = JSON.parse(text);
30
+ }
31
+ catch {
32
+ reply(null, { error: { code: -32700, message: "Parse error" } });
33
+ return;
34
+ }
35
+ if (!isRecord(message) ||
36
+ message.jsonrpc !== "2.0" ||
37
+ typeof message.method !== "string" ||
38
+ (typeof message.id !== "string" && typeof message.id !== "number")) {
39
+ reply(null, { error: { code: -32600, message: "Invalid Request" } });
40
+ return;
41
+ }
42
+ const { id, method } = message;
43
+ const params = message.params ?? {};
44
+ const allowed = READ_METHODS.has(method) ||
45
+ ((method === "events.subscribe" || method === "events.unsubscribe") &&
46
+ isRecord(params) &&
47
+ params.topic === "node");
48
+ if (!allowed) {
49
+ reply(id, { error: { code: -32601, message: "Method not found" } });
50
+ return;
51
+ }
52
+ rpc.request(method, params, 15_000).then((result) => reply(id, { result }), (error) => reply(id, {
53
+ error: error instanceof RpcError
54
+ ? { code: error.code, message: error.message }
55
+ : { code: -32000, message: "The rome-node daemon did not answer." },
56
+ }));
57
+ }
58
+ void (async () => {
59
+ try {
60
+ const found = await findDaemon(config, lifetime.signal);
61
+ if (closed) {
62
+ found?.rpc.close();
63
+ return;
64
+ }
65
+ if (!found) {
66
+ finish((await readOptionalCallerCredential(config)) ? "not_running" : "not_configured");
67
+ return;
68
+ }
69
+ rpc = found.rpc;
70
+ rpc.onNotification = (method, params) => {
71
+ if (method === "events.node" && !closed)
72
+ transport.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
73
+ };
74
+ rpc.onClose = () => finish("disconnected");
75
+ for (const text of queued.splice(0))
76
+ forward(text);
77
+ }
78
+ catch (error) {
79
+ if (closed)
80
+ return;
81
+ finish(error instanceof DaemonVersionError ? "incompatible" : "not_running");
82
+ }
83
+ })();
84
+ return {
85
+ receive(text) {
86
+ if (closed)
87
+ return;
88
+ if (Buffer.byteLength(text) > MAX_CONSOLE_MESSAGE_BYTES) {
89
+ finish();
90
+ return;
91
+ }
92
+ if (rpc)
93
+ forward(text);
94
+ else if (queued.length < 16)
95
+ queued.push(text);
96
+ else
97
+ finish();
98
+ },
99
+ close: () => finish(),
100
+ };
101
+ }
@@ -7,6 +7,8 @@ export { daemonStatus, startDaemon, stopDaemon } from "./daemon-control.js";
7
7
  export { DaemonRequestError, DaemonVersionError } from "./daemon-rpc.js";
8
8
  export type { DaemonStatus, ConnectionEvent, CopyProgress, CopyRequest, CopySummary, } from "./daemon-protocol.js";
9
9
  export type { DevicesStatus, DeviceStatus } from "./devices-status.js";
10
+ export { openConsoleBridge, MAX_CONSOLE_MESSAGE_BYTES, type ConsoleBridge, type ConsoleDaemonState, type ConsoleTransport, } from "./console.js";
11
+ export type { ActionRow, LogQuery, LogResult, NodeEvent, NodeStatus, Outcome, RoleStatus, } from "./observe-protocol.js";
10
12
  export type { ActionResponse, BinaryActionResult } from "./actions.js";
11
13
  export declare class DeviceActionError extends Error {
12
14
  readonly response: Extract<ActionResponse, {
@@ -8,6 +8,7 @@ import { ensureDaemon, findDaemon } from "./daemon-control.js";
8
8
  export { createNodeConfig, nodeConfigFromEnvironment, readOptionalCallerCredential, CallerConfigurationError, } from "./local.js";
9
9
  export { daemonStatus, startDaemon, stopDaemon } from "./daemon-control.js";
10
10
  export { DaemonRequestError, DaemonVersionError } from "./daemon-rpc.js";
11
+ export { openConsoleBridge, MAX_CONSOLE_MESSAGE_BYTES, } from "./console.js";
11
12
  export class DeviceActionError extends Error {
12
13
  response;
13
14
  code;
@@ -1,5 +1,5 @@
1
1
  import type { ConnectionStatus } from "./client.js";
2
- export declare const DAEMON_PROTOCOL_VERSION = 3;
2
+ export declare const DAEMON_PROTOCOL_VERSION = 4;
3
3
  export declare const MAX_LOCAL_MESSAGE_BYTES: number;
4
4
  export interface DaemonStatus {
5
5
  pid: number;
@@ -1,5 +1,5 @@
1
1
  import { isRecord } from "./actions.js";
2
- export const DAEMON_PROTOCOL_VERSION = 3;
2
+ export const DAEMON_PROTOCOL_VERSION = 4;
3
3
  export const MAX_LOCAL_MESSAGE_BYTES = 64 * 1024 * 1024;
4
4
  export function parseDaemonStatus(value) {
5
5
  if (!isRecord(value) ||
package/dist/daemon.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
+ import { createRequire } from "node:module";
4
+ import { join } from "node:path";
3
5
  import WebSocket, { WebSocketServer } from "ws";
4
6
  import { actionError, isRecord } from "./actions.js";
7
+ import { ObserveStore, QueryError } from "./observe.js";
5
8
  import { DeviceConnector } from "./connector.js";
6
9
  import { daemonStatePath, readCallerCredential } from "./local.js";
7
10
  import { writePrivateJson } from "./storage.js";
@@ -11,6 +14,8 @@ import { RpcError } from "./daemon-rpc.js";
11
14
  import { isAbsolute } from "node:path";
12
15
  import { isUuid } from "./frame.js";
13
16
  import { copyWithDevice, TRANSFER_VERSION, TransferError } from "./transfer.js";
17
+ const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
18
+ const { version: NODE_CORE_VERSION } = createRequire(import.meta.url)("../package.json");
14
19
  function copyRequest(params) {
15
20
  const { direction, localPath, deviceId, remotePath } = params;
16
21
  if ((direction !== "push" && direction !== "pull") ||
@@ -24,6 +29,50 @@ function copyRequest(params) {
24
29
  return null;
25
30
  return { direction, localPath, deviceId: deviceId.toLowerCase(), remotePath };
26
31
  }
32
+ function byteCount(value) {
33
+ return typeof value === "string" ? Buffer.byteLength(value) : 0;
34
+ }
35
+ function actionStart(deviceId, action, args, mode, stdinBytes = 0) {
36
+ const exec = action === "exec" && isRecord(args) && typeof args.command === "string";
37
+ let text = null;
38
+ if (!exec)
39
+ try {
40
+ text = JSON.stringify(args ?? {});
41
+ }
42
+ catch { }
43
+ return {
44
+ direction: "out",
45
+ peer: deviceId.toLowerCase(),
46
+ action,
47
+ mode,
48
+ command: exec ? args.command : null,
49
+ args: exec ? (Array.isArray(args.args) ? args.args.map(String) : []) : text,
50
+ cwd: exec && typeof args.cwd === "string" ? args.cwd : null,
51
+ stdinBytes,
52
+ };
53
+ }
54
+ function actionEnd(response, stdoutBytes) {
55
+ if (!response.ok) {
56
+ const code = response.error.code;
57
+ return {
58
+ outcome: code === "unknown_outcome" ? "unknown_outcome" : code === "canceled" ? "aborted" : "error",
59
+ errorCode: code,
60
+ };
61
+ }
62
+ const result = isRecord(response.result) ? response.result : {};
63
+ return {
64
+ outcome: "ok",
65
+ exitCode: Number.isSafeInteger(result.exitCode) ? result.exitCode : null,
66
+ signal: typeof result.signal === "string" ? result.signal : null,
67
+ stdoutBytes: stdoutBytes ?? byteCount(result.stdout),
68
+ stderrBytes: byteCount(result.stderr),
69
+ };
70
+ }
71
+ function logQuery(params) {
72
+ if (params.kind !== "actions")
73
+ throw new RpcError(-32602, "log.query requires kind actions.");
74
+ return params;
75
+ }
27
76
  export async function serveDaemon(config, signal) {
28
77
  if (signal.aborted)
29
78
  return;
@@ -35,14 +84,37 @@ export async function serveDaemon(config, signal) {
35
84
  protocolVersion: DAEMON_PROTOCOL_VERSION,
36
85
  token: `romenode_${randomBytes(32).toString("base64url")}`,
37
86
  };
87
+ const startedAt = Date.now();
38
88
  let closing = false;
89
+ const store = new ObserveStore({
90
+ path: join(config.directory, "observe.db"),
91
+ secrets: [credential.token, state.token],
92
+ });
93
+ const caller = {
94
+ connection: "stopped",
95
+ since: startedAt,
96
+ onlineOnce: false,
97
+ reconnects: 0,
98
+ running: 0,
99
+ };
39
100
  const connector = new DeviceConnector({
40
101
  credential,
41
- onStatus: () => {
102
+ onStatus: (status) => {
42
103
  deviceStatus.invalidate();
43
104
  for (const peer of peers)
44
105
  if (peer.subscribed)
45
106
  notify(peer);
107
+ if (status === caller.connection)
108
+ return;
109
+ const at = Date.now();
110
+ caller.connection = status;
111
+ caller.since = at;
112
+ if (status === "online") {
113
+ if (caller.onlineOnce)
114
+ caller.reconnects++;
115
+ caller.onlineOnce = true;
116
+ }
117
+ broadcast({ type: "connection", role: "caller", status, at });
46
118
  },
47
119
  });
48
120
  const deviceStatus = new DeviceStatusReader(connector);
@@ -51,6 +123,39 @@ export async function serveDaemon(config, signal) {
51
123
  protocolVersion: DAEMON_PROTOCOL_VERSION,
52
124
  connection: connector.getStatus(),
53
125
  });
126
+ const nodeStatus = () => ({
127
+ version: NODE_CORE_VERSION,
128
+ startedAt,
129
+ roles: {
130
+ caller: {
131
+ auth: caller.connection === "revoked" ? "revoked" : "authorized",
132
+ connection: caller.connection,
133
+ since: caller.since,
134
+ reconnects: caller.reconnects,
135
+ running: caller.running,
136
+ },
137
+ },
138
+ });
139
+ async function observed(start, work, end) {
140
+ if (!start)
141
+ return work();
142
+ caller.running++;
143
+ const row = store.startAction(start);
144
+ if (row)
145
+ broadcast({ type: "action", id: row.id, phase: "start", row });
146
+ let ended = { outcome: "error", errorCode: "daemon_error" };
147
+ try {
148
+ const value = await work();
149
+ ended = end(value);
150
+ return value;
151
+ }
152
+ finally {
153
+ caller.running--;
154
+ const done = row && store.endAction(row.id, ended);
155
+ if (done)
156
+ broadcast({ type: "action", id: done.id, phase: "end", row: done });
157
+ }
158
+ }
54
159
  function authorized(req) {
55
160
  const actual = Buffer.from(req.headers.authorization ?? "");
56
161
  const expected = Buffer.from(`Bearer ${state.token}`);
@@ -102,17 +207,21 @@ export async function serveDaemon(config, signal) {
102
207
  });
103
208
  }
104
209
  function notify(peer) {
210
+ emit(peer, "events.connection", snapshot());
211
+ }
212
+ function broadcast(event) {
213
+ for (const peer of peers)
214
+ if (peer.observing)
215
+ emit(peer, "events.node", event);
216
+ }
217
+ function emit(peer, method, params) {
105
218
  if (peer.eventBytes > 1024 * 1024) {
106
219
  peer.socket.terminate();
107
220
  return;
108
221
  }
109
222
  if (peer.socket.readyState !== WebSocket.OPEN)
110
223
  return;
111
- const text = JSON.stringify({
112
- jsonrpc: "2.0",
113
- method: "events.connection",
114
- params: snapshot(),
115
- });
224
+ const text = JSON.stringify({ jsonrpc: "2.0", method, params });
116
225
  const size = Buffer.byteLength(text);
117
226
  peer.eventBytes += size;
118
227
  peer.socket.send(text, (error) => {
@@ -137,6 +246,48 @@ export async function serveDaemon(config, signal) {
137
246
  }, 1000);
138
247
  timer.unref();
139
248
  };
249
+ async function copyFile(peer, id, request) {
250
+ const controller = new AbortController();
251
+ peer.copies.add(controller);
252
+ if (peer.socket.readyState !== WebSocket.OPEN)
253
+ controller.abort();
254
+ const canceled = () => actionError("canceled", "The client disconnected. The copy did not start.");
255
+ let last = 0;
256
+ try {
257
+ if (controller.signal.aborted)
258
+ return canceled();
259
+ const info = await connector.run(request.deviceId, "system.info", {});
260
+ if (controller.signal.aborted)
261
+ return canceled();
262
+ if (!info.ok)
263
+ return info;
264
+ if (!isRecord(info.result) || info.result.transferVersion !== TRANSFER_VERSION)
265
+ return actionError("unsupported_device", "The device runs a rome-node version without file transfers. Update rome-node on that device.");
266
+ const summary = await copyWithDevice((events) => connector.openChannel(request.deviceId, events), request, {
267
+ signal: controller.signal,
268
+ onProgress: ({ bytes, total }) => {
269
+ const now = Date.now();
270
+ if (now - last < 250 && bytes !== total)
271
+ return;
272
+ last = now;
273
+ send(peer, {
274
+ jsonrpc: "2.0",
275
+ method: "events.transfer",
276
+ params: { requestId: id, bytes, total },
277
+ });
278
+ },
279
+ });
280
+ return { type: "response", ok: true, result: summary };
281
+ }
282
+ catch (cause) {
283
+ if (!(cause instanceof TransferError))
284
+ throw cause;
285
+ return actionError(cause.code, cause.message);
286
+ }
287
+ finally {
288
+ peer.copies.delete(controller);
289
+ }
290
+ }
140
291
  function error(id, code, message) {
141
292
  return { jsonrpc: "2.0", id, error: { code, message } };
142
293
  }
@@ -184,6 +335,29 @@ export async function serveDaemon(config, signal) {
184
335
  result = { stopped: true };
185
336
  setImmediate(close);
186
337
  break;
338
+ case "node.status":
339
+ result = nodeStatus();
340
+ break;
341
+ case "log.query":
342
+ try {
343
+ result = store.query(logQuery(params));
344
+ }
345
+ catch (cause) {
346
+ if (cause instanceof QueryError)
347
+ throw new RpcError(-32602, cause.message);
348
+ throw cause;
349
+ }
350
+ break;
351
+ case "log.stats":
352
+ try {
353
+ result = store.stats(params);
354
+ }
355
+ catch (cause) {
356
+ if (cause instanceof QueryError)
357
+ throw new RpcError(-32602, cause.message);
358
+ throw cause;
359
+ }
360
+ break;
187
361
  case "devices.status":
188
362
  result = await deviceStatus.read();
189
363
  break;
@@ -193,12 +367,19 @@ export async function serveDaemon(config, signal) {
193
367
  case "devices.run":
194
368
  if (typeof params.deviceId !== "string" || typeof params.action !== "string")
195
369
  throw new RpcError(-32602, "A device ID and action are required.");
196
- result = await connector.run(params.deviceId, params.action, params.args ?? {});
370
+ {
371
+ const { deviceId, action } = params;
372
+ const args = params.args ?? {};
373
+ result = await observed(action === "system.info" ? null : actionStart(deviceId, action, args, "json"), () => connector.run(deviceId, action, args), (response) => actionEnd(response));
374
+ }
197
375
  break;
198
376
  case "devices.runBinary": {
199
377
  if (typeof params.deviceId !== "string" || typeof params.action !== "string")
200
378
  throw new RpcError(-32602, "A device ID and action are required.");
201
- const reply = await connector.runBinary(params.deviceId, params.action, params.args ?? {}, input ?? new Uint8Array());
379
+ const { deviceId, action } = params;
380
+ const args = params.args ?? {};
381
+ const stdin = input ?? new Uint8Array();
382
+ const reply = await observed(actionStart(deviceId, action, args, "binary", stdin.byteLength), () => connector.runBinary(deviceId, action, args, stdin), ({ response, body }) => actionEnd(response, body.byteLength));
202
383
  result = reply.response;
203
384
  body = reply.body;
204
385
  break;
@@ -207,65 +388,48 @@ export async function serveDaemon(config, signal) {
207
388
  const request = copyRequest(params);
208
389
  if (!request)
209
390
  throw new RpcError(-32602, "files.copy requires direction push or pull, an absolute localPath, a device UUID, and remotePath.");
210
- const controller = new AbortController();
211
- peer.copies.add(controller);
212
- if (peer.socket.readyState !== WebSocket.OPEN)
213
- controller.abort();
214
- const canceled = () => actionError("canceled", "The client disconnected. The copy did not start.");
215
- let last = 0;
216
- try {
217
- if (controller.signal.aborted) {
218
- result = canceled();
219
- break;
220
- }
221
- const info = await connector.run(request.deviceId, "system.info", {});
222
- if (controller.signal.aborted) {
223
- result = canceled();
224
- break;
225
- }
226
- if (!info.ok) {
227
- result = info;
228
- break;
229
- }
230
- if (!isRecord(info.result) || info.result.transferVersion !== TRANSFER_VERSION) {
231
- result = actionError("unsupported_device", "The device runs a rome-node version without file transfers. Update rome-node on that device.");
232
- break;
233
- }
234
- const summary = await copyWithDevice((events) => connector.openChannel(request.deviceId, events), request, {
235
- signal: controller.signal,
236
- onProgress: ({ bytes, total }) => {
237
- const now = Date.now();
238
- if (now - last < 250 && bytes !== total)
239
- return;
240
- last = now;
241
- send(peer, {
242
- jsonrpc: "2.0",
243
- method: "events.transfer",
244
- params: { requestId: id, bytes, total },
245
- });
246
- },
247
- });
248
- result = { type: "response", ok: true, result: summary };
249
- }
250
- catch (cause) {
251
- if (!(cause instanceof TransferError))
252
- throw cause;
253
- result = actionError(cause.code, cause.message);
254
- }
255
- finally {
256
- peer.copies.delete(controller);
257
- }
391
+ result = await observed({
392
+ direction: "out",
393
+ peer: request.deviceId,
394
+ action: "copy",
395
+ mode: "copy",
396
+ transfer: {
397
+ direction: request.direction,
398
+ localPath: request.localPath,
399
+ remotePath: request.remotePath,
400
+ },
401
+ }, () => copyFile(peer, id, request), (response) => {
402
+ const summary = response.ok && isRecord(response.result) ? response.result : {};
403
+ return {
404
+ ...actionEnd(response),
405
+ transferBytes: typeof summary.bytes === "number" ? summary.bytes : null,
406
+ sha256: typeof summary.sha256 === "string" ? summary.sha256 : null,
407
+ };
408
+ });
258
409
  break;
259
410
  }
260
411
  case "events.subscribe":
261
- case "events.unsubscribe":
262
- if (params.topic !== "connection")
412
+ case "events.unsubscribe": {
413
+ const on = value.method === "events.subscribe";
414
+ if (params.topic === "connection") {
415
+ peer.subscribed = on;
416
+ if (on)
417
+ notify(peer);
418
+ }
419
+ else if (params.topic === "node") {
420
+ peer.observing = on;
421
+ if (on)
422
+ emit(peer, "events.node", {
423
+ type: "snapshot",
424
+ status: nodeStatus(),
425
+ running: store.running(),
426
+ });
427
+ }
428
+ else
263
429
  throw new RpcError(-32602, "Unknown event topic.");
264
- peer.subscribed = value.method === "events.subscribe";
265
- if (peer.subscribed)
266
- notify(peer);
267
- result = { subscribed: peer.subscribed };
430
+ result = { subscribed: on };
268
431
  break;
432
+ }
269
433
  default:
270
434
  throw new RpcError(-32601, "Method not found");
271
435
  }
@@ -291,6 +455,7 @@ export async function serveDaemon(config, signal) {
291
455
  socket,
292
456
  ready: false,
293
457
  subscribed: false,
458
+ observing: false,
294
459
  alive: true,
295
460
  pending: new Set(),
296
461
  eventBytes: 0,
@@ -353,6 +518,9 @@ export async function serveDaemon(config, signal) {
353
518
  resolve();
354
519
  });
355
520
  });
521
+ store.open();
522
+ const pruner = setInterval(() => store.prune(), PRUNE_INTERVAL_MS);
523
+ pruner.unref();
356
524
  const heartbeat = setInterval(() => {
357
525
  for (const peer of peers) {
358
526
  if (!peer.alive) {
@@ -376,6 +544,8 @@ export async function serveDaemon(config, signal) {
376
544
  finally {
377
545
  close();
378
546
  clearInterval(heartbeat);
547
+ clearInterval(pruner);
548
+ store.close();
379
549
  signal.removeEventListener("abort", close);
380
550
  }
381
551
  }
@@ -0,0 +1,100 @@
1
+ import type { ConnectionStatus } from "./client.js";
2
+ export type Outcome = "ok" | "error" | "aborted" | "unknown_outcome" | "interrupted";
3
+ export declare const OUTCOMES: readonly Outcome[];
4
+ export interface ActionRow {
5
+ id: number;
6
+ direction: "in" | "out";
7
+ peer: string | null;
8
+ action: string;
9
+ mode: "json" | "binary" | "copy";
10
+ command: string | null;
11
+ args: string[] | string | null;
12
+ cwd: string | null;
13
+ startedAt: number;
14
+ endedAt: number | null;
15
+ outcome: Outcome | null;
16
+ errorCode: string | null;
17
+ exitCode: number | null;
18
+ signal: string | null;
19
+ stdinBytes: number;
20
+ stdoutBytes: number;
21
+ stderrBytes: number;
22
+ transfer: {
23
+ direction: "push" | "pull";
24
+ localPath: string;
25
+ remotePath: string;
26
+ bytes: number | null;
27
+ sha256: string | null;
28
+ } | null;
29
+ truncated?: true;
30
+ }
31
+ export interface RoleStatus {
32
+ deviceId?: string;
33
+ name?: string;
34
+ auth: "required" | "authorized" | "revoked";
35
+ connection: ConnectionStatus;
36
+ since: number;
37
+ reconnects: number;
38
+ running: number;
39
+ }
40
+ export interface NodeStatus {
41
+ version: string;
42
+ startedAt: number;
43
+ roles: {
44
+ caller?: RoleStatus;
45
+ host?: RoleStatus;
46
+ };
47
+ }
48
+ export type NodeEvent = {
49
+ type: "snapshot";
50
+ status: NodeStatus;
51
+ running: ActionRow[];
52
+ } | {
53
+ type: "connection";
54
+ role: "caller" | "host";
55
+ status: ConnectionStatus;
56
+ at: number;
57
+ } | {
58
+ type: "action";
59
+ id: number;
60
+ phase: "start" | "end";
61
+ row: ActionRow;
62
+ };
63
+ export type ActionResult = "running" | "succeeded" | "failed" | "no_result";
64
+ export declare const ACTION_RESULTS: readonly ActionResult[];
65
+ export interface LogQuery {
66
+ kind: "actions";
67
+ before?: number;
68
+ limit?: number;
69
+ direction?: "in" | "out";
70
+ peer?: string;
71
+ outcome?: Outcome[];
72
+ result?: ActionResult[];
73
+ since?: number;
74
+ until?: number;
75
+ text?: string;
76
+ }
77
+ export interface LogStatsQuery {
78
+ since: number;
79
+ until?: number;
80
+ bucketMs: number;
81
+ direction?: "in" | "out";
82
+ peer?: string;
83
+ }
84
+ interface LogStatsGroup {
85
+ peer: string | null;
86
+ bucket: number;
87
+ result: ActionResult;
88
+ count: number;
89
+ }
90
+ export interface LogStats {
91
+ since: number;
92
+ until: number;
93
+ bucketMs: number;
94
+ groups: LogStatsGroup[];
95
+ }
96
+ export interface LogResult<T> {
97
+ rows: T[];
98
+ hasMore: boolean;
99
+ }
100
+ export {};
@@ -0,0 +1,13 @@
1
+ export const OUTCOMES = [
2
+ "ok",
3
+ "error",
4
+ "aborted",
5
+ "unknown_outcome",
6
+ "interrupted",
7
+ ];
8
+ export const ACTION_RESULTS = [
9
+ "running",
10
+ "succeeded",
11
+ "failed",
12
+ "no_result",
13
+ ];
@@ -0,0 +1,67 @@
1
+ import { type ActionRow, type LogQuery, type LogResult, type LogStats, type LogStatsQuery, type Outcome } from "./observe-protocol.js";
2
+ export declare const MAX_ROW_CHARS: number;
3
+ export interface ObserveStoreOptions {
4
+ path: string;
5
+ secrets?: string[];
6
+ retentionMs?: number;
7
+ maxBytes?: number;
8
+ now?: () => number;
9
+ }
10
+ export interface ActionStart {
11
+ direction: "in" | "out";
12
+ peer: string | null;
13
+ action: string;
14
+ mode: ActionRow["mode"];
15
+ command?: string | null;
16
+ args?: string[] | string | null;
17
+ cwd?: string | null;
18
+ stdinBytes?: number;
19
+ transfer?: {
20
+ direction: "push" | "pull";
21
+ localPath: string;
22
+ remotePath: string;
23
+ } | null;
24
+ }
25
+ export interface ActionEnd {
26
+ outcome: Exclude<Outcome, "interrupted">;
27
+ errorCode?: string | null;
28
+ exitCode?: number | null;
29
+ signal?: string | null;
30
+ stdoutBytes?: number;
31
+ stderrBytes?: number;
32
+ transferBytes?: number | null;
33
+ sha256?: string | null;
34
+ }
35
+ export declare class QueryError extends Error {
36
+ }
37
+ export declare class ObserveStore {
38
+ private readonly options;
39
+ private db?;
40
+ private statements;
41
+ private readonly secrets;
42
+ private readonly retentionMs;
43
+ private readonly maxBytes;
44
+ private readonly now;
45
+ constructor(options: ObserveStoreOptions);
46
+ open(): void;
47
+ private attach;
48
+ private connect;
49
+ private discard;
50
+ private guard;
51
+ private statement;
52
+ private run;
53
+ private all;
54
+ private mask;
55
+ startAction(start: ActionStart): ActionRow | null;
56
+ endAction(id: number, end: ActionEnd): ActionRow | null;
57
+ private action;
58
+ running(): ActionRow[];
59
+ query(query: LogQuery): LogResult<ActionRow>;
60
+ private limit;
61
+ private queryActions;
62
+ stats(query: LogStatsQuery): LogStats;
63
+ prune(): void;
64
+ private compact;
65
+ private usedBytes;
66
+ close(): void;
67
+ }
@@ -0,0 +1,457 @@
1
+ import { closeSync, existsSync, openSync, readdirSync, renameSync, rmSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { ACTION_RESULTS, OUTCOMES, } from "./observe-protocol.js";
5
+ const SCHEMA_VERSION = 1;
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+ export const MAX_ROW_CHARS = 16 * 1024;
8
+ const MAX_RESULT_BYTES = 768 * 1024;
9
+ const MAX_LIMIT = 500;
10
+ const MAX_BUCKETS = 1000;
11
+ const RESULT_SQL = `CASE
12
+ WHEN a.ended_at IS NULL THEN 'running'
13
+ WHEN a.outcome = 'ok' AND coalesce(a.exit_code, 0) = 0 AND a.signal IS NULL THEN 'succeeded'
14
+ WHEN a.outcome IN ('ok', 'error') THEN 'failed'
15
+ ELSE 'no_result' END`;
16
+ export class QueryError extends Error {
17
+ }
18
+ class CorruptDatabaseError extends Error {
19
+ }
20
+ class SchemaVersionError extends Error {
21
+ }
22
+ function isCorruption(error) {
23
+ if (error instanceof CorruptDatabaseError)
24
+ return true;
25
+ const code = error?.errcode;
26
+ return typeof code === "number" && ((code & 0xff) === 11 || (code & 0xff) === 26);
27
+ }
28
+ const SCHEMA = `
29
+ CREATE TABLE actions (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ direction TEXT NOT NULL,
32
+ peer TEXT,
33
+ action TEXT NOT NULL,
34
+ mode TEXT NOT NULL,
35
+ command TEXT,
36
+ args TEXT,
37
+ cwd TEXT,
38
+ started_at INTEGER NOT NULL,
39
+ ended_at INTEGER,
40
+ outcome TEXT,
41
+ error_code TEXT,
42
+ exit_code INTEGER,
43
+ signal TEXT,
44
+ stdin_bytes INTEGER NOT NULL DEFAULT 0,
45
+ stdout_bytes INTEGER NOT NULL DEFAULT 0,
46
+ stderr_bytes INTEGER NOT NULL DEFAULT 0,
47
+ transfer_direction TEXT,
48
+ local_path TEXT,
49
+ remote_path TEXT,
50
+ transfer_bytes INTEGER,
51
+ sha256 TEXT
52
+ );
53
+ CREATE INDEX actions_started_at ON actions(started_at);
54
+ CREATE INDEX actions_direction ON actions(direction, id);
55
+ CREATE INDEX actions_peer ON actions(peer, id);
56
+ CREATE INDEX actions_outcome ON actions(outcome, id);
57
+ CREATE INDEX actions_open ON actions(id) WHERE ended_at IS NULL;
58
+ CREATE VIRTUAL TABLE actions_fts USING fts5(
59
+ command, args, cwd, content='actions', content_rowid='id', tokenize='trigram'
60
+ );
61
+ CREATE TRIGGER actions_fts_insert AFTER INSERT ON actions BEGIN
62
+ INSERT INTO actions_fts(rowid, command, args, cwd)
63
+ VALUES (new.id, coalesce(new.command, ''), coalesce(new.args, ''), coalesce(new.cwd, ''));
64
+ END;
65
+ CREATE TRIGGER actions_fts_delete AFTER DELETE ON actions BEGIN
66
+ INSERT INTO actions_fts(actions_fts, rowid, command, args, cwd)
67
+ VALUES ('delete', old.id, coalesce(old.command, ''), coalesce(old.args, ''), coalesce(old.cwd, ''));
68
+ END;
69
+ `;
70
+ function cut(value, budget) {
71
+ if (value === null)
72
+ return value;
73
+ if (value.length <= budget.remaining) {
74
+ budget.remaining -= value.length;
75
+ return value;
76
+ }
77
+ budget.truncated = true;
78
+ const kept = value.slice(0, budget.remaining);
79
+ budget.remaining = 0;
80
+ return kept;
81
+ }
82
+ function toActionRow(row) {
83
+ const state = { remaining: MAX_ROW_CHARS, truncated: false };
84
+ const command = cut(row.command, state);
85
+ const cwd = cut(row.cwd, state);
86
+ let args = null;
87
+ if (typeof row.args === "string") {
88
+ let parsed;
89
+ try {
90
+ parsed = row.command === null ? undefined : JSON.parse(row.args);
91
+ }
92
+ catch { }
93
+ if (Array.isArray(parsed)) {
94
+ args = [];
95
+ for (const arg of parsed) {
96
+ if (!state.remaining) {
97
+ state.truncated = true;
98
+ break;
99
+ }
100
+ args.push(cut(String(arg), state) ?? "");
101
+ }
102
+ }
103
+ else
104
+ args = cut(row.args, state);
105
+ }
106
+ const value = {
107
+ id: Number(row.id),
108
+ direction: row.direction,
109
+ peer: row.peer,
110
+ action: row.action,
111
+ mode: row.mode,
112
+ command,
113
+ args,
114
+ cwd,
115
+ startedAt: Number(row.started_at),
116
+ endedAt: row.ended_at === null ? null : Number(row.ended_at),
117
+ outcome: row.outcome,
118
+ errorCode: row.error_code,
119
+ exitCode: row.exit_code === null ? null : Number(row.exit_code),
120
+ signal: row.signal,
121
+ stdinBytes: Number(row.stdin_bytes),
122
+ stdoutBytes: Number(row.stdout_bytes),
123
+ stderrBytes: Number(row.stderr_bytes),
124
+ transfer: row.transfer_direction === null
125
+ ? null
126
+ : {
127
+ direction: row.transfer_direction,
128
+ localPath: cut(row.local_path, state) ?? "",
129
+ remotePath: cut(row.remote_path, state) ?? "",
130
+ bytes: row.transfer_bytes === null ? null : Number(row.transfer_bytes),
131
+ sha256: row.sha256,
132
+ },
133
+ };
134
+ if (state.truncated)
135
+ value.truncated = true;
136
+ return value;
137
+ }
138
+ const optionalId = (value, name) => {
139
+ if (value === undefined)
140
+ return undefined;
141
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
142
+ throw new QueryError(`${name} must be a non-negative integer.`);
143
+ return value;
144
+ };
145
+ function commonFilters(query, where, params) {
146
+ if (query.direction !== undefined) {
147
+ if (query.direction !== "in" && query.direction !== "out")
148
+ throw new QueryError("direction must be in or out.");
149
+ where.push("a.direction = ?");
150
+ params.push(query.direction);
151
+ }
152
+ if (query.peer !== undefined) {
153
+ if (typeof query.peer !== "string")
154
+ throw new QueryError("peer must be a string.");
155
+ where.push("a.peer = ?");
156
+ params.push(query.peer);
157
+ }
158
+ const since = optionalId(query.since, "since");
159
+ const until = optionalId(query.until, "until");
160
+ if (since !== undefined)
161
+ where.push("a.started_at >= ?"), params.push(since);
162
+ if (until !== undefined)
163
+ where.push("a.started_at < ?"), params.push(until);
164
+ }
165
+ export class ObserveStore {
166
+ options;
167
+ db;
168
+ statements = new Map();
169
+ secrets;
170
+ retentionMs;
171
+ maxBytes;
172
+ now;
173
+ constructor(options) {
174
+ this.options = options;
175
+ this.secrets = (options.secrets ?? []).filter(Boolean);
176
+ this.retentionMs = options.retentionMs ?? 30 * DAY_MS;
177
+ this.maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
178
+ this.now = options.now ?? Date.now;
179
+ }
180
+ open() {
181
+ this.db = this.attach();
182
+ if (!this.db)
183
+ return;
184
+ this.guard(() => {
185
+ this.run("UPDATE actions SET ended_at = ?, outcome = 'interrupted' WHERE ended_at IS NULL", this.now());
186
+ });
187
+ this.prune();
188
+ }
189
+ attach() {
190
+ try {
191
+ return this.connect();
192
+ }
193
+ catch (error) {
194
+ if (error instanceof SchemaVersionError) {
195
+ if (!this.discard(false))
196
+ return undefined;
197
+ }
198
+ else if (isCorruption(error)) {
199
+ if (!this.discard(true))
200
+ return undefined;
201
+ }
202
+ else
203
+ return undefined;
204
+ }
205
+ try {
206
+ return this.connect();
207
+ }
208
+ catch {
209
+ return undefined;
210
+ }
211
+ }
212
+ connect() {
213
+ const { path } = this.options;
214
+ if (!existsSync(path))
215
+ closeSync(openSync(path, "a", 0o600));
216
+ const db = new DatabaseSync(path);
217
+ try {
218
+ db.exec("PRAGMA busy_timeout = 1000");
219
+ const check = db.prepare("PRAGMA quick_check").get();
220
+ if (check?.quick_check !== "ok")
221
+ throw new CorruptDatabaseError();
222
+ const version = Number(db.prepare("PRAGMA user_version").get()?.user_version ?? 0);
223
+ if (version === 0) {
224
+ db.exec("PRAGMA auto_vacuum = INCREMENTAL");
225
+ db.exec("PRAGMA journal_mode = WAL");
226
+ db.exec(`BEGIN; ${SCHEMA} PRAGMA user_version = ${SCHEMA_VERSION}; COMMIT;`);
227
+ }
228
+ else if (version !== SCHEMA_VERSION) {
229
+ throw new SchemaVersionError();
230
+ }
231
+ else
232
+ db.exec("PRAGMA journal_mode = WAL");
233
+ db.exec("PRAGMA synchronous = NORMAL");
234
+ return db;
235
+ }
236
+ catch (error) {
237
+ db.close();
238
+ throw error;
239
+ }
240
+ }
241
+ discard(keepCopy) {
242
+ const { path } = this.options;
243
+ this.statements.clear();
244
+ try {
245
+ this.db?.close();
246
+ }
247
+ catch { }
248
+ this.db = undefined;
249
+ try {
250
+ if (keepCopy) {
251
+ const prefix = `${basename(path)}.corrupt-`;
252
+ for (const name of readdirSync(dirname(path)))
253
+ if (name.startsWith(prefix))
254
+ rmSync(join(dirname(path), name), { force: true });
255
+ if (existsSync(path))
256
+ renameSync(path, `${path}.corrupt-${this.now()}`);
257
+ }
258
+ else
259
+ rmSync(path, { force: true });
260
+ for (const suffix of ["-wal", "-shm"])
261
+ rmSync(`${path}${suffix}`, { force: true });
262
+ return true;
263
+ }
264
+ catch {
265
+ return false;
266
+ }
267
+ }
268
+ guard(work, fallback) {
269
+ if (!this.db)
270
+ return fallback;
271
+ try {
272
+ return work();
273
+ }
274
+ catch (error) {
275
+ if (isCorruption(error))
276
+ this.db = this.discard(true) ? this.attach() : undefined;
277
+ return fallback;
278
+ }
279
+ }
280
+ statement(sql) {
281
+ let statement = this.statements.get(sql);
282
+ if (!statement) {
283
+ statement = this.db.prepare(sql);
284
+ this.statements.set(sql, statement);
285
+ }
286
+ return statement;
287
+ }
288
+ run(sql, ...params) {
289
+ return this.statement(sql).run(...params);
290
+ }
291
+ all(sql, ...params) {
292
+ return this.statement(sql).all(...params);
293
+ }
294
+ mask(value) {
295
+ if (value === null || value === undefined)
296
+ return null;
297
+ let masked = value;
298
+ for (const secret of this.secrets)
299
+ masked = masked.split(secret).join("*".repeat(secret.length));
300
+ return masked;
301
+ }
302
+ startAction(start) {
303
+ return (this.guard(() => {
304
+ const args = start.args === undefined || start.args === null
305
+ ? null
306
+ : typeof start.args === "string"
307
+ ? this.mask(start.args)
308
+ : JSON.stringify(start.args.map((arg) => this.mask(arg)));
309
+ const result = this.run(`INSERT INTO actions (direction, peer, action, mode, command, args, cwd, started_at,
310
+ stdin_bytes, transfer_direction, local_path, remote_path)
311
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, start.direction, this.mask(start.peer), start.action, start.mode, this.mask(start.command), args, this.mask(start.cwd), this.now(), start.stdinBytes ?? 0, start.transfer?.direction ?? null, this.mask(start.transfer?.localPath), this.mask(start.transfer?.remotePath));
312
+ return this.action(Number(result.lastInsertRowid));
313
+ }, null) ?? null);
314
+ }
315
+ endAction(id, end) {
316
+ return (this.guard(() => {
317
+ this.run(`UPDATE actions SET ended_at = ?, outcome = ?, error_code = ?, exit_code = ?, signal = ?,
318
+ stdout_bytes = ?, stderr_bytes = ?, transfer_bytes = ?, sha256 = ?
319
+ WHERE id = ? AND ended_at IS NULL`, this.now(), end.outcome, end.errorCode ?? null, end.exitCode ?? null, end.signal ?? null, end.stdoutBytes ?? 0, end.stderrBytes ?? 0, end.transferBytes ?? null, end.sha256 ?? null, id);
320
+ return this.action(id);
321
+ }, null) ?? null);
322
+ }
323
+ action(id) {
324
+ const row = this.all("SELECT * FROM actions WHERE id = ?", id)[0];
325
+ return row ? toActionRow(row) : null;
326
+ }
327
+ running() {
328
+ return (this.guard(() => this.all("SELECT * FROM actions WHERE ended_at IS NULL ORDER BY id LIMIT ?", MAX_LIMIT).map(toActionRow), []) ?? []);
329
+ }
330
+ query(query) {
331
+ if (query.kind !== "actions")
332
+ throw new QueryError("Unknown query kind.");
333
+ return this.queryActions(query);
334
+ }
335
+ limit(value, fallback) {
336
+ if (value === undefined)
337
+ return fallback;
338
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1)
339
+ throw new QueryError("limit must be a positive integer.");
340
+ return Math.min(value, MAX_LIMIT);
341
+ }
342
+ queryActions(query) {
343
+ const where = [];
344
+ const params = [];
345
+ const before = optionalId(query.before, "before");
346
+ const limit = this.limit(query.limit, 100);
347
+ if (before !== undefined)
348
+ where.push("a.id < ?"), params.push(before);
349
+ commonFilters(query, where, params);
350
+ if (query.outcome !== undefined) {
351
+ if (!Array.isArray(query.outcome) ||
352
+ !query.outcome.length ||
353
+ !query.outcome.every((outcome) => OUTCOMES.includes(outcome)))
354
+ throw new QueryError("outcome must list known outcomes.");
355
+ where.push(`a.outcome IN (${query.outcome.map(() => "?").join(", ")})`);
356
+ params.push(...query.outcome);
357
+ }
358
+ if (query.result !== undefined) {
359
+ if (!Array.isArray(query.result) ||
360
+ !query.result.length ||
361
+ !query.result.every((result) => ACTION_RESULTS.includes(result)))
362
+ throw new QueryError("result must list known results.");
363
+ where.push(`${RESULT_SQL} IN (${query.result.map(() => "?").join(", ")})`);
364
+ params.push(...query.result);
365
+ }
366
+ let join = "";
367
+ if (query.text !== undefined) {
368
+ if (typeof query.text !== "string" || [...query.text].length < 3)
369
+ throw new QueryError("text must have at least three characters.");
370
+ join = "JOIN actions_fts ON actions_fts.rowid = a.id";
371
+ where.push("actions_fts MATCH ?");
372
+ params.push(`"${query.text.replaceAll('"', '""')}"`);
373
+ }
374
+ const sql = `SELECT a.* FROM actions a ${join} ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY a.id DESC LIMIT ?`;
375
+ return (this.guard(() => {
376
+ const raw = this.all(sql, ...params, limit + 1);
377
+ const rows = [];
378
+ let bytes = 0;
379
+ let hasMore = raw.length > limit;
380
+ for (const item of raw.slice(0, limit)) {
381
+ const row = toActionRow(item);
382
+ bytes += Buffer.byteLength(JSON.stringify(row));
383
+ if (rows.length && bytes > MAX_RESULT_BYTES) {
384
+ hasMore = true;
385
+ break;
386
+ }
387
+ rows.push(row);
388
+ }
389
+ return { rows, hasMore };
390
+ }, { rows: [], hasMore: false }) ?? { rows: [], hasMore: false });
391
+ }
392
+ stats(query) {
393
+ const since = optionalId(query.since, "since");
394
+ if (since === undefined)
395
+ throw new QueryError("since is required.");
396
+ const until = optionalId(query.until, "until") ?? this.now();
397
+ const bucketMs = query.bucketMs;
398
+ if (typeof bucketMs !== "number" || !Number.isSafeInteger(bucketMs) || bucketMs < 1000)
399
+ throw new QueryError("bucketMs must be an integer of at least 1000.");
400
+ if (until <= since || Math.ceil((until - since) / bucketMs) > MAX_BUCKETS)
401
+ throw new QueryError(`The range must hold between 1 and ${MAX_BUCKETS} buckets.`);
402
+ const where = [];
403
+ const params = [];
404
+ commonFilters({ ...query, since, until }, where, params);
405
+ const sql = `SELECT a.peer AS peer, CAST((a.started_at - ?) / ? AS INTEGER) AS bucket,
406
+ ${RESULT_SQL} AS result, count(*) AS count
407
+ FROM actions a WHERE ${where.join(" AND ")}
408
+ GROUP BY peer, bucket, result ORDER BY bucket`;
409
+ const groups = this.guard(() => this.all(sql, since, bucketMs, ...params).map((row) => ({
410
+ peer: row.peer,
411
+ bucket: Number(row.bucket),
412
+ result: row.result,
413
+ count: Number(row.count),
414
+ })), []) ?? [];
415
+ return { since, until, bucketMs, groups };
416
+ }
417
+ prune() {
418
+ this.guard(() => {
419
+ const cutoff = this.now() - this.retentionMs;
420
+ let deleted = Number(this.run("DELETE FROM actions WHERE started_at < ? AND ended_at IS NOT NULL", cutoff)
421
+ .changes);
422
+ if (this.usedBytes() > this.maxBytes) {
423
+ this.compact();
424
+ for (let round = 0; round < 64 && this.usedBytes() > this.maxBytes; round++) {
425
+ const count = Number(this.all("SELECT count(*) AS n FROM actions")[0]?.n ?? 0);
426
+ if (!count)
427
+ break;
428
+ deleted += Number(this.run(`DELETE FROM actions WHERE id IN (SELECT id FROM actions WHERE ended_at IS NOT NULL
429
+ ORDER BY id LIMIT ?)`, Math.max(1, Math.ceil(count / 10))).changes);
430
+ this.compact();
431
+ }
432
+ }
433
+ if (!deleted)
434
+ return;
435
+ this.db.exec("PRAGMA incremental_vacuum");
436
+ this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
437
+ });
438
+ }
439
+ compact() {
440
+ this.db.exec("INSERT INTO actions_fts(actions_fts) VALUES ('optimize')");
441
+ this.db.exec("PRAGMA incremental_vacuum");
442
+ }
443
+ usedBytes() {
444
+ const pages = Number(this.all("PRAGMA page_count")[0]?.page_count ?? 0);
445
+ const free = Number(this.all("PRAGMA freelist_count")[0]?.freelist_count ?? 0);
446
+ const size = Number(this.all("PRAGMA page_size")[0]?.page_size ?? 4096);
447
+ return (pages - free) * size;
448
+ }
449
+ close() {
450
+ this.statements.clear();
451
+ try {
452
+ this.db?.close();
453
+ }
454
+ catch { }
455
+ this.db = undefined;
456
+ }
457
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rome-os/node-core",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "JavaScript APIs for Rome device hosts and shared caller daemons",
5
5
  "license": "MIT",
6
6
  "type": "module",