arisa 4.2.8 → 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.
@@ -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 { getToolStateDir, toolStateDir } from "../../runtime/paths.js";
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 function daemonPaths(toolName) {
8
- const root = getToolStateDir(toolName);
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
- await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
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 new Promise((resolve) => setTimeout(resolve, 100));
84
+ await sleep(Math.min(100, timeoutMs));
47
85
  }
48
86
  return !isProcessAlive(pid);
49
87
  }
50
88
 
51
- export async function stopManagedDaemon(toolName, { signal = "SIGTERM", forceAfterMs = 3000 } = {}) {
52
- const paths = daemonPaths(toolName);
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" && forceAfterMs > 0 && !(await waitForExit(pid, forceAfterMs))) {
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
- return { toolName, pid: pid || null, stopped };
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({ toolName, entryPath, beforeStart = null, autoStart = true }) {
71
- const paths = daemonPaths(toolName);
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 current = await readJson(paths.pidFile, {});
75
- if (isProcessAlive(current.pid)) {
76
- await writeJson(paths.metaFile, {
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
- autoStart,
80
- lastStartedAt: current.startedAt || new Date().toISOString()
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 child = spawn(process.execPath, [entryPath, "daemon"], {
93
- detached: false,
94
- stdio: ["ignore", out, out],
95
- env: process.env
96
- });
97
- child.unref();
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 record = {
101
- pid: child.pid,
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
- lastStartedAt: startedAt
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
- closeSync(out);
310
+ if (acquired) await rm(paths.startLockFile, { force: true });
114
311
  }
115
312
  }
116
313
 
117
- export async function listRegisteredDaemons() {
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 paths = daemonPaths(entry.name);
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
+ }