arisa 4.2.6 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -0
- package/README.md +3 -0
- package/package.json +2 -2
- package/pnpm-workspace.yaml +6 -0
- package/src/core/artifacts/artifact-store.js +32 -2
- package/src/core/config/config-defaults.js +25 -0
- package/src/core/config/config-store.js +4 -3
- package/src/core/tools/daemon-health.js +185 -0
- package/src/core/tools/daemon-policy.js +10 -0
- package/src/core/tools/daemon-processes.js +269 -44
- package/src/core/tools/daemon-runtime.js +264 -49
- package/src/runtime/bootstrap.js +3 -2
- package/src/runtime/create-app.js +1 -1
- package/src/runtime/paths.js +29 -0
- package/src/runtime/tool-process-supervisor.js +38 -26
- package/test/artifact-store.test.js +39 -2
- package/test/daemon-catalog-conformance.test.js +28 -0
- package/test/daemon-runtime.test.js +188 -0
- package/test-fixtures/fake-daemon.js +47 -0
|
@@ -1,18 +1,50 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
2
3
|
import { closeSync, openSync } from "node:fs";
|
|
3
|
-
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
chatsDir,
|
|
8
|
+
getDaemonInstanceDir,
|
|
9
|
+
getDaemonInstanceId,
|
|
10
|
+
normalizeDaemonScope,
|
|
11
|
+
toolStateDir
|
|
12
|
+
} from "../../runtime/paths.js";
|
|
13
|
+
import { loadDaemonPolicy } from "./daemon-policy.js";
|
|
6
14
|
|
|
7
|
-
export
|
|
8
|
-
|
|
15
|
+
export const DAEMON_STATES = Object.freeze([
|
|
16
|
+
"starting",
|
|
17
|
+
"ready",
|
|
18
|
+
"degraded",
|
|
19
|
+
"unhealthy",
|
|
20
|
+
"restarting",
|
|
21
|
+
"stopped",
|
|
22
|
+
"failed"
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function daemonIdentity(toolNameOrOptions, scope) {
|
|
26
|
+
if (typeof toolNameOrOptions === "string") {
|
|
27
|
+
return { toolName: toolNameOrOptions, scope: normalizeDaemonScope(scope) };
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
toolName: toolNameOrOptions.toolName,
|
|
31
|
+
scope: normalizeDaemonScope(toolNameOrOptions.scope)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function daemonPaths(toolNameOrOptions, scope) {
|
|
36
|
+
const identity = daemonIdentity(toolNameOrOptions, scope);
|
|
37
|
+
const root = getDaemonInstanceDir(identity.toolName, identity.scope);
|
|
9
38
|
return {
|
|
39
|
+
...identity,
|
|
40
|
+
instanceId: getDaemonInstanceId(identity.scope),
|
|
10
41
|
root,
|
|
11
42
|
commandsDir: path.join(root, "commands"),
|
|
12
43
|
pidFile: path.join(root, "daemon.pid"),
|
|
13
44
|
metaFile: path.join(root, "daemon.meta.json"),
|
|
14
45
|
statusFile: path.join(root, "status.json"),
|
|
15
|
-
logFile: path.join(root, "daemon.log")
|
|
46
|
+
logFile: path.join(root, "daemon.log"),
|
|
47
|
+
startLockFile: path.join(root, "daemon.start.lock")
|
|
16
48
|
};
|
|
17
49
|
}
|
|
18
50
|
|
|
@@ -26,7 +58,9 @@ export async function readJson(file, fallback = {}) {
|
|
|
26
58
|
|
|
27
59
|
export async function writeJson(file, value) {
|
|
28
60
|
await mkdir(path.dirname(file), { recursive: true });
|
|
29
|
-
|
|
61
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
62
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
63
|
+
await rename(temporary, file);
|
|
30
64
|
}
|
|
31
65
|
|
|
32
66
|
export function isProcessAlive(pid) {
|
|
@@ -39,17 +73,111 @@ export function isProcessAlive(pid) {
|
|
|
39
73
|
}
|
|
40
74
|
}
|
|
41
75
|
|
|
76
|
+
function sleep(ms) {
|
|
77
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
78
|
+
}
|
|
79
|
+
|
|
42
80
|
async function waitForExit(pid, timeoutMs) {
|
|
43
81
|
const startedAt = Date.now();
|
|
44
82
|
while (Date.now() - startedAt < timeoutMs) {
|
|
45
83
|
if (!isProcessAlive(pid)) return true;
|
|
46
|
-
await
|
|
84
|
+
await sleep(Math.min(100, timeoutMs));
|
|
47
85
|
}
|
|
48
86
|
return !isProcessAlive(pid);
|
|
49
87
|
}
|
|
50
88
|
|
|
51
|
-
export async function
|
|
52
|
-
|
|
89
|
+
export async function writeDaemonStatus(pathsOrIdentity, patch) {
|
|
90
|
+
if (patch.state && !DAEMON_STATES.includes(patch.state)) {
|
|
91
|
+
throw new Error(`Invalid daemon state: ${patch.state}`);
|
|
92
|
+
}
|
|
93
|
+
const paths = pathsOrIdentity.statusFile ? pathsOrIdentity : daemonPaths(pathsOrIdentity);
|
|
94
|
+
const current = await readJson(paths.statusFile, {});
|
|
95
|
+
const next = { ...current, ...patch, updatedAt: new Date().toISOString() };
|
|
96
|
+
await writeJson(paths.statusFile, next);
|
|
97
|
+
return next;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function registrationRecord({ paths, entryPath, autoStart, startupContext, current = {}, startedAt = null }) {
|
|
101
|
+
return {
|
|
102
|
+
toolName: paths.toolName,
|
|
103
|
+
entryPath,
|
|
104
|
+
scope: paths.scope,
|
|
105
|
+
instanceId: paths.instanceId,
|
|
106
|
+
autoStart: Boolean(autoStart),
|
|
107
|
+
startupContext,
|
|
108
|
+
registeredAt: current.registeredAt || new Date().toISOString(),
|
|
109
|
+
lastStartedAt: startedAt || current.lastStartedAt || null
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function registerManagedDaemon({
|
|
114
|
+
toolName,
|
|
115
|
+
entryPath,
|
|
116
|
+
scope = { type: "global" },
|
|
117
|
+
startupContext = {},
|
|
118
|
+
autoStart = true
|
|
119
|
+
}) {
|
|
120
|
+
const paths = daemonPaths({ toolName, scope });
|
|
121
|
+
const current = await readJson(paths.metaFile, {});
|
|
122
|
+
const record = registrationRecord({
|
|
123
|
+
paths,
|
|
124
|
+
entryPath,
|
|
125
|
+
autoStart,
|
|
126
|
+
startupContext,
|
|
127
|
+
current
|
|
128
|
+
});
|
|
129
|
+
await writeJson(paths.metaFile, record);
|
|
130
|
+
return record;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function readDaemonLaunchContext({ expectedToolName = "" } = {}) {
|
|
134
|
+
const metaFile = process.env.ARISA_DAEMON_META_FILE;
|
|
135
|
+
if (!metaFile) return null;
|
|
136
|
+
const record = await readJson(metaFile, null);
|
|
137
|
+
if (!record?.toolName || !record?.entryPath || !record?.scope) {
|
|
138
|
+
throw new Error(`Invalid daemon launch context at ${metaFile}`);
|
|
139
|
+
}
|
|
140
|
+
if (expectedToolName && record.toolName !== expectedToolName) {
|
|
141
|
+
throw new Error(`Daemon launch context is for ${record.toolName}, expected ${expectedToolName}`);
|
|
142
|
+
}
|
|
143
|
+
return record;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function acquireStartLock(paths, policy) {
|
|
147
|
+
const startedAt = Date.now();
|
|
148
|
+
while (Date.now() - startedAt < policy.startupTimeoutMs) {
|
|
149
|
+
try {
|
|
150
|
+
await mkdir(paths.root, { recursive: true });
|
|
151
|
+
await writeFile(paths.startLockFile, `${JSON.stringify({
|
|
152
|
+
pid: process.pid,
|
|
153
|
+
createdAt: new Date().toISOString()
|
|
154
|
+
})}\n`, { flag: "wx" });
|
|
155
|
+
return;
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error?.code !== "EEXIST") throw error;
|
|
158
|
+
const current = await readJson(paths.pidFile, {});
|
|
159
|
+
if (isProcessAlive(current.pid)) return false;
|
|
160
|
+
const lock = await readJson(paths.startLockFile, {});
|
|
161
|
+
if (lock.createdAt && Date.now() - new Date(lock.createdAt).getTime() >= policy.startupTimeoutMs) {
|
|
162
|
+
await rm(paths.startLockFile, { force: true });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
await sleep(policy.queuePollIntervalMs);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
throw new Error(`Timed out acquiring daemon start lock for ${paths.toolName} (${paths.instanceId})`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function stopManagedDaemon(toolNameOrOptions, {
|
|
172
|
+
scope,
|
|
173
|
+
signal = "SIGTERM",
|
|
174
|
+
forceAfterMs,
|
|
175
|
+
state = "stopped",
|
|
176
|
+
message = "Daemon stopped"
|
|
177
|
+
} = {}) {
|
|
178
|
+
const paths = daemonPaths(toolNameOrOptions, scope);
|
|
179
|
+
const policy = forceAfterMs == null ? await loadDaemonPolicy() : null;
|
|
180
|
+
const effectiveForceAfterMs = forceAfterMs ?? policy.stopTimeoutMs;
|
|
53
181
|
const { pid } = await readJson(paths.pidFile, {});
|
|
54
182
|
let stopped = false;
|
|
55
183
|
if (isProcessAlive(pid)) {
|
|
@@ -57,77 +185,174 @@ export async function stopManagedDaemon(toolName, { signal = "SIGTERM", forceAft
|
|
|
57
185
|
process.kill(pid, signal);
|
|
58
186
|
stopped = true;
|
|
59
187
|
} catch {}
|
|
60
|
-
if (signal !== "SIGKILL" &&
|
|
188
|
+
if (signal !== "SIGKILL" && effectiveForceAfterMs > 0 && !(await waitForExit(pid, effectiveForceAfterMs))) {
|
|
61
189
|
try {
|
|
62
190
|
process.kill(pid, "SIGKILL");
|
|
63
191
|
} catch {}
|
|
64
192
|
}
|
|
65
193
|
}
|
|
66
194
|
await rm(paths.pidFile, { force: true });
|
|
67
|
-
|
|
195
|
+
if (state) {
|
|
196
|
+
await writeDaemonStatus(paths, {
|
|
197
|
+
state,
|
|
198
|
+
pid: null,
|
|
199
|
+
heartbeatAt: null,
|
|
200
|
+
restartRequested: false,
|
|
201
|
+
nextRestartAt: null,
|
|
202
|
+
message
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return { toolName: paths.toolName, scope: paths.scope, pid: pid || null, stopped };
|
|
68
206
|
}
|
|
69
207
|
|
|
70
|
-
export async function startManagedDaemon({
|
|
71
|
-
|
|
208
|
+
export async function startManagedDaemon({
|
|
209
|
+
toolName,
|
|
210
|
+
entryPath,
|
|
211
|
+
scope = { type: "global" },
|
|
212
|
+
startupContext = {},
|
|
213
|
+
beforeStart = null,
|
|
214
|
+
autoStart = true
|
|
215
|
+
}) {
|
|
216
|
+
const paths = daemonPaths({ toolName, scope });
|
|
217
|
+
const policy = await loadDaemonPolicy();
|
|
72
218
|
await mkdir(paths.commandsDir, { recursive: true });
|
|
73
219
|
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
await
|
|
220
|
+
const acquired = await acquireStartLock(paths, policy);
|
|
221
|
+
if (acquired === false) {
|
|
222
|
+
const current = await readJson(paths.pidFile, {});
|
|
223
|
+
await registerManagedDaemon({
|
|
77
224
|
toolName,
|
|
78
225
|
entryPath,
|
|
79
|
-
|
|
80
|
-
|
|
226
|
+
scope: paths.scope,
|
|
227
|
+
startupContext,
|
|
228
|
+
autoStart
|
|
81
229
|
});
|
|
82
230
|
return current.pid;
|
|
83
231
|
}
|
|
84
|
-
await rm(paths.pidFile, { force: true });
|
|
85
|
-
|
|
86
|
-
await rm(paths.commandsDir, { recursive: true, force: true });
|
|
87
|
-
await mkdir(paths.commandsDir, { recursive: true });
|
|
88
|
-
if (beforeStart) await beforeStart();
|
|
89
|
-
|
|
90
|
-
const out = openSync(paths.logFile, "a");
|
|
91
232
|
try {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
233
|
+
const current = await readJson(paths.pidFile, {});
|
|
234
|
+
if (isProcessAlive(current.pid)) {
|
|
235
|
+
await registerManagedDaemon({
|
|
236
|
+
toolName,
|
|
237
|
+
entryPath,
|
|
238
|
+
scope: paths.scope,
|
|
239
|
+
startupContext,
|
|
240
|
+
autoStart
|
|
241
|
+
});
|
|
242
|
+
return current.pid;
|
|
243
|
+
}
|
|
244
|
+
await rm(paths.pidFile, { force: true });
|
|
245
|
+
await rm(paths.commandsDir, { recursive: true, force: true });
|
|
246
|
+
await mkdir(paths.commandsDir, { recursive: true });
|
|
247
|
+
if (beforeStart) await beforeStart();
|
|
98
248
|
|
|
99
249
|
const startedAt = new Date().toISOString();
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
startedAt
|
|
103
|
-
};
|
|
104
|
-
await writeJson(paths.pidFile, record);
|
|
105
|
-
await writeJson(paths.metaFile, {
|
|
106
|
-
toolName,
|
|
250
|
+
const meta = registrationRecord({
|
|
251
|
+
paths,
|
|
107
252
|
entryPath,
|
|
108
253
|
autoStart,
|
|
109
|
-
|
|
254
|
+
startupContext,
|
|
255
|
+
current: await readJson(paths.metaFile, {}),
|
|
256
|
+
startedAt
|
|
257
|
+
});
|
|
258
|
+
await writeJson(paths.metaFile, meta);
|
|
259
|
+
await writeDaemonStatus(paths, {
|
|
260
|
+
state: "starting",
|
|
261
|
+
pid: null,
|
|
262
|
+
heartbeatAt: null,
|
|
263
|
+
lastHealthCheckAt: null,
|
|
264
|
+
lastHealthSuccessAt: null,
|
|
265
|
+
consecutiveHealthFailures: 0,
|
|
266
|
+
message: "Daemon process is starting"
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const out = openSync(paths.logFile, "a");
|
|
270
|
+
let child;
|
|
271
|
+
try {
|
|
272
|
+
child = spawn(process.execPath, [entryPath, "daemon"], {
|
|
273
|
+
detached: false,
|
|
274
|
+
stdio: ["ignore", out, out],
|
|
275
|
+
env: {
|
|
276
|
+
...process.env,
|
|
277
|
+
ARISA_DAEMON_META_FILE: paths.metaFile,
|
|
278
|
+
ARISA_DAEMON_INSTANCE_ID: paths.instanceId
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
await new Promise((resolve, reject) => {
|
|
282
|
+
child.once("spawn", resolve);
|
|
283
|
+
child.once("error", reject);
|
|
284
|
+
});
|
|
285
|
+
} finally {
|
|
286
|
+
closeSync(out);
|
|
287
|
+
}
|
|
288
|
+
child.unref();
|
|
289
|
+
|
|
290
|
+
await writeJson(paths.pidFile, { pid: child.pid, startedAt });
|
|
291
|
+
await writeDaemonStatus(paths, {
|
|
292
|
+
state: "starting",
|
|
293
|
+
pid: child.pid,
|
|
294
|
+
message: "Daemon process started; waiting for health check"
|
|
110
295
|
});
|
|
111
296
|
return child.pid;
|
|
297
|
+
} catch (error) {
|
|
298
|
+
await writeDaemonStatus(paths, {
|
|
299
|
+
state: "failed",
|
|
300
|
+
pid: null,
|
|
301
|
+
lastError: {
|
|
302
|
+
at: new Date().toISOString(),
|
|
303
|
+
phase: "start",
|
|
304
|
+
message: error?.message || String(error)
|
|
305
|
+
},
|
|
306
|
+
message: error?.message || String(error)
|
|
307
|
+
}).catch(() => {});
|
|
308
|
+
throw error;
|
|
112
309
|
} finally {
|
|
113
|
-
|
|
310
|
+
if (acquired) await rm(paths.startLockFile, { force: true });
|
|
114
311
|
}
|
|
115
312
|
}
|
|
116
313
|
|
|
117
|
-
|
|
314
|
+
async function listGlobalDaemonRecords() {
|
|
118
315
|
let entries = [];
|
|
119
316
|
try {
|
|
120
317
|
entries = await readdir(toolStateDir, { withFileTypes: true });
|
|
121
318
|
} catch {
|
|
122
319
|
return [];
|
|
123
320
|
}
|
|
124
|
-
|
|
125
321
|
const records = [];
|
|
126
322
|
for (const entry of entries) {
|
|
127
323
|
if (!entry.isDirectory()) continue;
|
|
128
|
-
const
|
|
129
|
-
const meta = await readJson(paths.metaFile, null);
|
|
324
|
+
const meta = await readJson(daemonPaths(entry.name).metaFile, null);
|
|
130
325
|
if (meta?.toolName && meta?.entryPath) records.push(meta);
|
|
131
326
|
}
|
|
132
327
|
return records;
|
|
133
328
|
}
|
|
329
|
+
|
|
330
|
+
async function listChatDaemonRecords() {
|
|
331
|
+
let chatEntries = [];
|
|
332
|
+
try {
|
|
333
|
+
chatEntries = await readdir(chatsDir, { withFileTypes: true });
|
|
334
|
+
} catch {
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
const records = [];
|
|
338
|
+
for (const chatEntry of chatEntries) {
|
|
339
|
+
if (!chatEntry.isDirectory()) continue;
|
|
340
|
+
const toolsRoot = path.join(chatsDir, chatEntry.name, "state", "tools");
|
|
341
|
+
const toolEntries = await readdir(toolsRoot, { withFileTypes: true }).catch(() => []);
|
|
342
|
+
for (const toolEntry of toolEntries) {
|
|
343
|
+
if (!toolEntry.isDirectory()) continue;
|
|
344
|
+
const scope = { type: "chat", chatId: chatEntry.name };
|
|
345
|
+
const meta = await readJson(daemonPaths({ toolName: toolEntry.name, scope }).metaFile, null);
|
|
346
|
+
if (meta?.toolName && meta?.entryPath) records.push(meta);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return records;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export async function listRegisteredDaemons() {
|
|
353
|
+
const records = [
|
|
354
|
+
...await listGlobalDaemonRecords(),
|
|
355
|
+
...await listChatDaemonRecords()
|
|
356
|
+
];
|
|
357
|
+
return records.sort((a, b) => `${a.toolName}:${a.instanceId}`.localeCompare(`${b.toolName}:${b.instanceId}`));
|
|
358
|
+
}
|