@rome-os/node-core 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/console.d.ts +12 -0
- package/dist/console.js +101 -0
- package/dist/daemon-client.d.ts +2 -0
- package/dist/daemon-client.js +1 -0
- package/dist/daemon-protocol.d.ts +1 -1
- package/dist/daemon-protocol.js +1 -1
- package/dist/daemon.js +232 -62
- package/dist/observe-protocol.d.ts +100 -0
- package/dist/observe-protocol.js +13 -0
- package/dist/observe.d.ts +67 -0
- package/dist/observe.js +457 -0
- package/package.json +1 -1
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
|
|
@@ -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;
|
package/dist/console.js
ADDED
|
@@ -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
|
+
}
|
package/dist/daemon-client.d.ts
CHANGED
|
@@ -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, {
|
package/dist/daemon-client.js
CHANGED
|
@@ -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;
|
package/dist/daemon-protocol.js
CHANGED
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
|
-
|
|
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
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|
package/dist/observe.js
ADDED
|
@@ -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
|
+
}
|