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
|
@@ -7,22 +7,106 @@ import {
|
|
|
7
7
|
readJson,
|
|
8
8
|
startManagedDaemon,
|
|
9
9
|
stopManagedDaemon,
|
|
10
|
+
writeDaemonStatus,
|
|
10
11
|
writeJson
|
|
11
12
|
} from "./daemon-processes.js";
|
|
13
|
+
import { loadDaemonPolicy } from "./daemon-policy.js";
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
const paths = daemonPaths(toolName);
|
|
15
|
+
const CONTROL_FIELD = "__daemon";
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
function sleep(ms) {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function withTimeout(work, timeoutMs, message) {
|
|
22
|
+
let timer;
|
|
23
|
+
try {
|
|
24
|
+
return await Promise.race([
|
|
25
|
+
Promise.resolve().then(work),
|
|
26
|
+
new Promise((_, reject) => {
|
|
27
|
+
timer = setTimeout(() => {
|
|
28
|
+
const error = new Error(message);
|
|
29
|
+
error.code = "DAEMON_OPERATION_TIMEOUT";
|
|
30
|
+
reject(error);
|
|
31
|
+
}, timeoutMs);
|
|
32
|
+
})
|
|
33
|
+
]);
|
|
34
|
+
} finally {
|
|
35
|
+
clearTimeout(timer);
|
|
18
36
|
}
|
|
37
|
+
}
|
|
19
38
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
39
|
+
function jobPaths(paths, id) {
|
|
40
|
+
return {
|
|
41
|
+
request: path.join(paths.commandsDir, `${id}.request.json`),
|
|
42
|
+
processing: path.join(paths.commandsDir, `${id}.processing.json`),
|
|
43
|
+
result: path.join(paths.commandsDir, `${id}.result.json`)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function waitForResult(paths, id, { timeoutMs, intervalMs }) {
|
|
48
|
+
const files = jobPaths(paths, id);
|
|
49
|
+
const startedAt = Date.now();
|
|
50
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
51
|
+
const result = await readJson(files.result, null);
|
|
52
|
+
if (result) {
|
|
53
|
+
await unlink(files.result).catch(() => {});
|
|
54
|
+
if (!result.ok) {
|
|
55
|
+
const error = new Error(result.error || `${paths.toolName} daemon job failed`);
|
|
56
|
+
if (result.code) error.code = result.code;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return result.output || {};
|
|
60
|
+
}
|
|
61
|
+
await sleep(intervalMs);
|
|
62
|
+
}
|
|
63
|
+
const error = new Error(`${paths.toolName} daemon job timed out after ${timeoutMs}ms`);
|
|
64
|
+
error.code = "DAEMON_JOB_TIMEOUT";
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function enqueue(paths, payload, { control = false, timeoutMs, intervalMs }) {
|
|
69
|
+
await mkdir(paths.commandsDir, { recursive: true });
|
|
70
|
+
const id = `${control ? "control" : "job"}-${crypto.randomUUID()}`;
|
|
71
|
+
await writeJson(jobPaths(paths, id).request, { id, ...payload });
|
|
72
|
+
return waitForResult(paths, id, { timeoutMs, intervalMs });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function submitDaemonControl(record, operation, { timeoutMs } = {}) {
|
|
76
|
+
const paths = daemonPaths({ toolName: record.toolName, scope: record.scope });
|
|
77
|
+
const policy = await loadDaemonPolicy();
|
|
78
|
+
return enqueue(paths, {
|
|
79
|
+
[CONTROL_FIELD]: { operation }
|
|
80
|
+
}, {
|
|
81
|
+
control: true,
|
|
82
|
+
timeoutMs: timeoutMs ?? policy.healthTimeoutMs,
|
|
83
|
+
intervalMs: policy.queuePollIntervalMs
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isDaemonReady(status, pid, policy, now = Date.now()) {
|
|
88
|
+
if (status.state !== "ready" || !isProcessAlive(pid)) return false;
|
|
89
|
+
const heartbeatAt = new Date(status.heartbeatAt || 0).getTime();
|
|
90
|
+
const healthAt = new Date(status.lastHealthSuccessAt || 0).getTime();
|
|
91
|
+
if (!heartbeatAt || now - heartbeatAt > policy.heartbeatStaleMs) return false;
|
|
92
|
+
if (!healthAt || now - healthAt > policy.healthIntervalMs + policy.healthTimeoutMs) return false;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createDaemonRuntime({
|
|
97
|
+
toolName,
|
|
98
|
+
entryPath,
|
|
99
|
+
scope = { type: "global" },
|
|
100
|
+
startupContext = {},
|
|
101
|
+
beforeStart = null,
|
|
102
|
+
autoStart = true
|
|
103
|
+
}) {
|
|
104
|
+
const paths = daemonPaths({ toolName, scope });
|
|
105
|
+
const registration = { toolName, entryPath, scope: paths.scope, startupContext, autoStart };
|
|
106
|
+
let statusWrite = Promise.resolve();
|
|
107
|
+
|
|
108
|
+
async function ensure() {
|
|
109
|
+
await mkdir(paths.commandsDir, { recursive: true });
|
|
26
110
|
}
|
|
27
111
|
|
|
28
112
|
async function getPid() {
|
|
@@ -30,60 +114,72 @@ export function createDaemonRuntime({ toolName, entryPath, beforeStart = null })
|
|
|
30
114
|
}
|
|
31
115
|
|
|
32
116
|
async function writeStatus(patch) {
|
|
33
|
-
|
|
34
|
-
|
|
117
|
+
statusWrite = statusWrite.catch(() => {}).then(() => writeDaemonStatus(paths, patch));
|
|
118
|
+
return statusWrite;
|
|
35
119
|
}
|
|
36
120
|
|
|
37
121
|
async function start() {
|
|
38
122
|
return startManagedDaemon({
|
|
39
|
-
|
|
40
|
-
entryPath,
|
|
123
|
+
...registration,
|
|
41
124
|
beforeStart
|
|
42
125
|
});
|
|
43
126
|
}
|
|
44
127
|
|
|
45
128
|
async function stop() {
|
|
46
|
-
await stopManagedDaemon(toolName);
|
|
129
|
+
await stopManagedDaemon({ toolName, scope: paths.scope });
|
|
47
130
|
}
|
|
48
131
|
|
|
49
|
-
async function waitReady({ timeoutMs
|
|
132
|
+
async function waitReady({ timeoutMs } = {}) {
|
|
133
|
+
const policy = await loadDaemonPolicy();
|
|
134
|
+
const effectiveTimeoutMs = timeoutMs ?? policy.startupTimeoutMs;
|
|
50
135
|
const startTime = Date.now();
|
|
51
|
-
while (Date.now() - startTime <
|
|
136
|
+
while (Date.now() - startTime < effectiveTimeoutMs) {
|
|
52
137
|
const status = await readJson(paths.statusFile, {});
|
|
53
138
|
const pid = await getPid();
|
|
54
|
-
if (
|
|
55
|
-
if (status.state === "
|
|
56
|
-
await
|
|
139
|
+
if (isDaemonReady(status, pid, policy)) return status;
|
|
140
|
+
if (status.state === "failed") throw new Error(status.message || `${toolName} daemon failed`);
|
|
141
|
+
await sleep(policy.queuePollIntervalMs);
|
|
57
142
|
}
|
|
58
|
-
throw new Error(`${toolName} daemon was not ready after ${
|
|
143
|
+
throw new Error(`${toolName} daemon was not ready after ${effectiveTimeoutMs}ms`);
|
|
59
144
|
}
|
|
60
145
|
|
|
61
|
-
async function
|
|
62
|
-
await
|
|
63
|
-
await
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
await
|
|
146
|
+
async function ensureReady({ timeoutMs } = {}) {
|
|
147
|
+
const policy = await loadDaemonPolicy();
|
|
148
|
+
const status = await readJson(paths.statusFile, {});
|
|
149
|
+
const pid = await getPid();
|
|
150
|
+
if (isDaemonReady(status, pid, policy)) return status;
|
|
151
|
+
await submitDaemonControl(registration, "health", {
|
|
152
|
+
timeoutMs: timeoutMs ?? policy.healthTimeoutMs
|
|
153
|
+
});
|
|
154
|
+
return waitReady({ timeoutMs: timeoutMs ?? policy.startupTimeoutMs });
|
|
155
|
+
}
|
|
67
156
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
157
|
+
async function submit(payload, {
|
|
158
|
+
timeoutMs,
|
|
159
|
+
readyTimeoutMs,
|
|
160
|
+
requireReady = true
|
|
161
|
+
} = {}) {
|
|
162
|
+
const policy = await loadDaemonPolicy();
|
|
163
|
+
await start();
|
|
164
|
+
if (requireReady) await ensureReady({ timeoutMs: readyTimeoutMs });
|
|
165
|
+
return enqueue(paths, payload, {
|
|
166
|
+
timeoutMs: timeoutMs ?? policy.startupTimeoutMs,
|
|
167
|
+
intervalMs: policy.queuePollIntervalMs
|
|
168
|
+
});
|
|
79
169
|
}
|
|
80
170
|
|
|
81
171
|
async function claimNext() {
|
|
82
172
|
await ensure();
|
|
83
|
-
const files = (await readdir(paths.commandsDir))
|
|
173
|
+
const files = (await readdir(paths.commandsDir))
|
|
174
|
+
.filter((file) => file.endsWith(".request.json"))
|
|
175
|
+
.sort((a, b) => {
|
|
176
|
+
const aControl = a.startsWith("control-") ? 0 : 1;
|
|
177
|
+
const bControl = b.startsWith("control-") ? 0 : 1;
|
|
178
|
+
return aControl - bControl || a.localeCompare(b);
|
|
179
|
+
});
|
|
84
180
|
for (const file of files) {
|
|
85
181
|
const id = file.replace(/\.request\.json$/, "");
|
|
86
|
-
const item = jobPaths(id);
|
|
182
|
+
const item = jobPaths(paths, id);
|
|
87
183
|
try {
|
|
88
184
|
await rename(item.request, item.processing);
|
|
89
185
|
return { id, ...item, payload: await readJson(item.processing, null) };
|
|
@@ -98,33 +194,152 @@ export function createDaemonRuntime({ toolName, entryPath, beforeStart = null })
|
|
|
98
194
|
}
|
|
99
195
|
|
|
100
196
|
async function fail(job, error) {
|
|
101
|
-
await writeJson(job.result, {
|
|
197
|
+
await writeJson(job.result, {
|
|
198
|
+
ok: false,
|
|
199
|
+
error: error?.message || String(error),
|
|
200
|
+
code: error?.code || null
|
|
201
|
+
});
|
|
102
202
|
await unlink(job.processing).catch(() => {});
|
|
103
203
|
}
|
|
104
204
|
|
|
105
|
-
async function workLoop({
|
|
205
|
+
async function workLoop({
|
|
206
|
+
processJob,
|
|
207
|
+
healthCheck,
|
|
208
|
+
recover = null,
|
|
209
|
+
beforeExit = null,
|
|
210
|
+
idleTimeoutMs = 0
|
|
211
|
+
}) {
|
|
212
|
+
if (typeof healthCheck !== "function") {
|
|
213
|
+
throw new Error(`${toolName} daemon must declare healthCheck`);
|
|
214
|
+
}
|
|
215
|
+
const policy = await loadDaemonPolicy();
|
|
216
|
+
const intervalMs = policy.queuePollIntervalMs;
|
|
106
217
|
let lastActivity = Date.now();
|
|
107
|
-
|
|
218
|
+
let processing = false;
|
|
219
|
+
let exiting = false;
|
|
220
|
+
let acceptingWork = true;
|
|
221
|
+
|
|
222
|
+
await ensure();
|
|
223
|
+
await writeStatus({
|
|
224
|
+
state: "starting",
|
|
225
|
+
pid: process.pid,
|
|
226
|
+
heartbeatAt: new Date().toISOString(),
|
|
227
|
+
supportsRecovery: typeof recover === "function",
|
|
228
|
+
message: "Daemon work loop started; waiting for health check"
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const heartbeatTimer = setInterval(() => {
|
|
232
|
+
writeStatus({ heartbeatAt: new Date().toISOString() }).catch(() => {});
|
|
233
|
+
}, policy.heartbeatIntervalMs);
|
|
234
|
+
|
|
235
|
+
const workTimer = setInterval(async () => {
|
|
236
|
+
if (processing || exiting || !acceptingWork) return;
|
|
237
|
+
processing = true;
|
|
108
238
|
try {
|
|
109
239
|
const job = await claimNext();
|
|
110
240
|
if (job) {
|
|
111
|
-
|
|
241
|
+
const operation = job.payload?.[CONTROL_FIELD]?.operation;
|
|
112
242
|
try {
|
|
113
|
-
|
|
243
|
+
if (operation === "health") {
|
|
244
|
+
const checkedAt = new Date().toISOString();
|
|
245
|
+
await writeStatus({ lastHealthCheckAt: checkedAt });
|
|
246
|
+
const output = await withTimeout(
|
|
247
|
+
healthCheck,
|
|
248
|
+
policy.healthTimeoutMs,
|
|
249
|
+
`${toolName} health check timed out after ${policy.healthTimeoutMs}ms`
|
|
250
|
+
);
|
|
251
|
+
await writeStatus({
|
|
252
|
+
state: "ready",
|
|
253
|
+
lastHealthSuccessAt: new Date().toISOString(),
|
|
254
|
+
consecutiveHealthFailures: 0,
|
|
255
|
+
restartAttempts: 0,
|
|
256
|
+
restartRequested: false,
|
|
257
|
+
nextRestartAt: null,
|
|
258
|
+
message: output?.message || "Daemon health check passed"
|
|
259
|
+
});
|
|
260
|
+
await complete(job, output || { ok: true });
|
|
261
|
+
} else if (operation === "recover") {
|
|
262
|
+
const recovered = typeof recover === "function"
|
|
263
|
+
? await withTimeout(
|
|
264
|
+
recover,
|
|
265
|
+
policy.healthTimeoutMs,
|
|
266
|
+
`${toolName} recovery timed out after ${policy.healthTimeoutMs}ms`
|
|
267
|
+
)
|
|
268
|
+
: false;
|
|
269
|
+
await complete(job, { recovered: recovered !== false });
|
|
270
|
+
} else {
|
|
271
|
+
lastActivity = Date.now();
|
|
272
|
+
const output = await processJob(job.payload);
|
|
273
|
+
await writeStatus({ lastSuccessfulJobAt: new Date().toISOString() });
|
|
274
|
+
await complete(job, output);
|
|
275
|
+
lastActivity = Date.now();
|
|
276
|
+
}
|
|
114
277
|
} catch (error) {
|
|
278
|
+
if (error?.code === "DAEMON_OPERATION_TIMEOUT") {
|
|
279
|
+
acceptingWork = false;
|
|
280
|
+
}
|
|
281
|
+
const current = await readJson(paths.statusFile, {});
|
|
282
|
+
await writeStatus({
|
|
283
|
+
...(operation === "health"
|
|
284
|
+
? {
|
|
285
|
+
state: error?.code === "DAEMON_OPERATION_TIMEOUT" ? "unhealthy" : "degraded",
|
|
286
|
+
consecutiveHealthFailures: Number(current.consecutiveHealthFailures || 0) + 1
|
|
287
|
+
}
|
|
288
|
+
: {}),
|
|
289
|
+
lastError: {
|
|
290
|
+
at: new Date().toISOString(),
|
|
291
|
+
phase: operation || "job",
|
|
292
|
+
message: error?.message || String(error)
|
|
293
|
+
},
|
|
294
|
+
message: error?.message || String(error)
|
|
295
|
+
});
|
|
115
296
|
await fail(job, error);
|
|
116
297
|
}
|
|
117
|
-
lastActivity = Date.now();
|
|
118
298
|
}
|
|
119
299
|
if (idleTimeoutMs > 0 && Date.now() - lastActivity > idleTimeoutMs) {
|
|
120
|
-
|
|
300
|
+
exiting = true;
|
|
301
|
+
clearInterval(heartbeatTimer);
|
|
302
|
+
clearInterval(workTimer);
|
|
303
|
+
if (beforeExit) await beforeExit();
|
|
304
|
+
await writeStatus({
|
|
305
|
+
state: "stopped",
|
|
306
|
+
restartRequested: false,
|
|
307
|
+
nextRestartAt: null,
|
|
308
|
+
message: "Idle timeout reached"
|
|
309
|
+
});
|
|
121
310
|
process.exit(0);
|
|
122
311
|
}
|
|
123
312
|
} catch (error) {
|
|
124
|
-
await writeStatus({
|
|
313
|
+
await writeStatus({
|
|
314
|
+
state: "degraded",
|
|
315
|
+
lastError: {
|
|
316
|
+
at: new Date().toISOString(),
|
|
317
|
+
phase: "work-loop",
|
|
318
|
+
message: error?.message || String(error)
|
|
319
|
+
},
|
|
320
|
+
message: error?.message || String(error)
|
|
321
|
+
});
|
|
322
|
+
} finally {
|
|
323
|
+
processing = false;
|
|
125
324
|
}
|
|
126
325
|
}, intervalMs);
|
|
326
|
+
|
|
327
|
+
submitDaemonControl(registration, "health", {
|
|
328
|
+
timeoutMs: policy.healthTimeoutMs
|
|
329
|
+
}).catch(() => {});
|
|
127
330
|
}
|
|
128
331
|
|
|
129
|
-
return {
|
|
332
|
+
return {
|
|
333
|
+
paths,
|
|
334
|
+
registration,
|
|
335
|
+
ensure,
|
|
336
|
+
getPid,
|
|
337
|
+
writeStatus,
|
|
338
|
+
start,
|
|
339
|
+
stop,
|
|
340
|
+
waitReady,
|
|
341
|
+
ensureReady,
|
|
342
|
+
submit,
|
|
343
|
+
workLoop
|
|
344
|
+
};
|
|
130
345
|
}
|
package/src/runtime/bootstrap.js
CHANGED
|
@@ -6,6 +6,7 @@ import { spawn } from "node:child_process";
|
|
|
6
6
|
import { Bot } from "grammy";
|
|
7
7
|
import { createPiOAuthLogin } from "../core/agent/pi-auth-login.js";
|
|
8
8
|
import { createPiRuntime, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
|
|
9
|
+
import { applyConfigDefaults } from "../core/config/config-defaults.js";
|
|
9
10
|
import { buildDeviceCodeTelegramMessage } from "../transport/telegram/device-code-message.js";
|
|
10
11
|
import { configFile, ensureArisaHome } from "./paths.js";
|
|
11
12
|
|
|
@@ -28,7 +29,7 @@ async function exists(file) {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
function buildConfig({ telegramApiKey, telegramMaxChatIds, authorizedChatIds = [], chatMeta = {}, provider, model, piApiKey }) {
|
|
31
|
-
return {
|
|
32
|
+
return applyConfigDefaults({
|
|
32
33
|
telegram: {
|
|
33
34
|
token: telegramApiKey,
|
|
34
35
|
maxChatIds: telegramMaxChatIds,
|
|
@@ -41,7 +42,7 @@ function buildConfig({ telegramApiKey, telegramMaxChatIds, authorizedChatIds = [
|
|
|
41
42
|
apiKey: piApiKey
|
|
42
43
|
},
|
|
43
44
|
createdAt: new Date().toISOString()
|
|
44
|
-
};
|
|
45
|
+
});
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
function sortBootstrapProviders(providers) {
|
|
@@ -98,7 +98,7 @@ export async function createApp({ logger, runtimeOverrides } = {}) {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
const artifactStore = new ArtifactStore();
|
|
101
|
-
const toolProcessSupervisor = createToolProcessSupervisor({ logger });
|
|
101
|
+
const toolProcessSupervisor = createToolProcessSupervisor({ logger, policy: config.daemons });
|
|
102
102
|
const toolRegistry = new ToolRegistry({ logger });
|
|
103
103
|
const taskStore = new TaskStore();
|
|
104
104
|
await toolRegistry.load();
|
package/src/runtime/paths.js
CHANGED
|
@@ -43,6 +43,35 @@ export function getChatToolStateDir(chatId, toolName) {
|
|
|
43
43
|
return path.join(getChatDir(chatId), "state", "tools", toolName);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export function normalizeDaemonScope(scope = { type: "global" }) {
|
|
47
|
+
if (!scope || scope === "global" || scope.type === "global") {
|
|
48
|
+
return { type: "global" };
|
|
49
|
+
}
|
|
50
|
+
if (scope === "chat") {
|
|
51
|
+
throw new Error("chat daemon scope requires chatId");
|
|
52
|
+
}
|
|
53
|
+
if (scope.type !== "chat") {
|
|
54
|
+
throw new Error(`Unsupported daemon scope: ${scope.type || scope}`);
|
|
55
|
+
}
|
|
56
|
+
const chatId = String(scope.chatId ?? "").trim();
|
|
57
|
+
if (!/^-?\d+$/.test(chatId)) {
|
|
58
|
+
throw new Error(`Invalid chat daemon scope: ${chatId || "missing chatId"}`);
|
|
59
|
+
}
|
|
60
|
+
return { type: "chat", chatId };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getDaemonInstanceId(scope = { type: "global" }) {
|
|
64
|
+
const normalized = normalizeDaemonScope(scope);
|
|
65
|
+
return normalized.type === "global" ? "global" : `chat:${normalized.chatId}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getDaemonInstanceDir(toolName, scope = { type: "global" }) {
|
|
69
|
+
const normalized = normalizeDaemonScope(scope);
|
|
70
|
+
return normalized.type === "global"
|
|
71
|
+
? getToolStateDir(toolName)
|
|
72
|
+
: path.join(getChatToolStateDir(normalized.chatId, toolName), "daemon");
|
|
73
|
+
}
|
|
74
|
+
|
|
46
75
|
export function getChatPiSessionsDir(chatId) {
|
|
47
76
|
return path.join(getChatDir(chatId), "state", "pi-sessions");
|
|
48
77
|
}
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import { access
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
listRegisteredDaemons,
|
|
6
|
-
readJson,
|
|
7
|
-
startManagedDaemon
|
|
8
|
-
} from "../core/tools/daemon-processes.js";
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { superviseDaemon } from "../core/tools/daemon-health.js";
|
|
3
|
+
import { listRegisteredDaemons } from "../core/tools/daemon-processes.js";
|
|
4
|
+
import { loadDaemonPolicy } from "../core/tools/daemon-policy.js";
|
|
9
5
|
import { ensureArisaHome } from "./paths.js";
|
|
10
6
|
|
|
11
7
|
async function fileExists(file) {
|
|
@@ -17,47 +13,63 @@ async function fileExists(file) {
|
|
|
17
13
|
}
|
|
18
14
|
}
|
|
19
15
|
|
|
20
|
-
export function createToolProcessSupervisor({ logger } = {}) {
|
|
16
|
+
export function createToolProcessSupervisor({ logger, policy } = {}) {
|
|
21
17
|
let running = false;
|
|
18
|
+
let timer = null;
|
|
19
|
+
let reconciliation = null;
|
|
20
|
+
let daemonPolicy = policy;
|
|
21
|
+
|
|
22
|
+
function reportLoopError(error) {
|
|
23
|
+
logger?.error?.("tools", `daemon supervisor loop failed: ${error?.message || error}`);
|
|
24
|
+
}
|
|
22
25
|
|
|
23
26
|
async function reconcileDaemons() {
|
|
24
27
|
await ensureArisaHome();
|
|
25
28
|
for (const record of await listRegisteredDaemons()) {
|
|
26
|
-
if (!record.autoStart) continue;
|
|
27
29
|
if (!(await fileExists(record.entryPath))) {
|
|
28
30
|
logger?.log("tools", `skipping daemon ${record.toolName}: missing entry ${record.entryPath}`);
|
|
29
31
|
continue;
|
|
30
32
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (pid) {
|
|
39
|
-
await rm(paths.pidFile, { force: true });
|
|
40
|
-
logger?.log("tools", `removed stale daemon pid for ${record.toolName} (${pid})`);
|
|
33
|
+
try {
|
|
34
|
+
const outcome = await superviseDaemon(record, daemonPolicy);
|
|
35
|
+
if (outcome !== "healthy") {
|
|
36
|
+
logger?.log("tools", `${record.toolName} (${record.instanceId || "global"}): ${outcome}`);
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
logger?.error?.("tools", `daemon supervision failed for ${record.toolName}: ${error?.message || error}`);
|
|
41
40
|
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
async function runLoop() {
|
|
45
|
+
if (!running || reconciliation) return;
|
|
46
|
+
reconciliation = reconcileDaemons();
|
|
47
|
+
try {
|
|
48
|
+
await reconciliation;
|
|
49
|
+
} finally {
|
|
50
|
+
reconciliation = null;
|
|
51
|
+
if (running) {
|
|
52
|
+
timer = setTimeout(() => {
|
|
53
|
+
runLoop().catch(reportLoopError);
|
|
54
|
+
}, daemonPolicy.supervisorIntervalMs);
|
|
55
|
+
}
|
|
48
56
|
}
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
return {
|
|
52
60
|
async start() {
|
|
53
61
|
if (running) return;
|
|
62
|
+
daemonPolicy ||= await loadDaemonPolicy();
|
|
54
63
|
running = true;
|
|
55
|
-
|
|
64
|
+
runLoop().catch(reportLoopError);
|
|
56
65
|
},
|
|
57
66
|
|
|
58
67
|
async stop() {
|
|
59
68
|
if (!running) return;
|
|
60
69
|
running = false;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
timer = null;
|
|
72
|
+
await reconciliation?.catch(() => {});
|
|
61
73
|
}
|
|
62
74
|
};
|
|
63
75
|
}
|
|
@@ -78,12 +78,12 @@ test("creates generated file artifacts", async () => {
|
|
|
78
78
|
source: { type: "assistant" }
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
assert.equal(await readFile(artifact.path, "utf8"), "# Hello\n");
|
|
81
|
+
assert.equal(await readFile(artifact.path, "utf8"), "\ufeff# Hello\n");
|
|
82
82
|
assert.equal(artifact.kind, "document");
|
|
83
83
|
assert.equal(artifact.mimeType, "text/markdown");
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
test("writes generated text file artifacts as UTF-8", async () => {
|
|
86
|
+
test("writes generated text file artifacts as BOM UTF-8", async () => {
|
|
87
87
|
await resetHome();
|
|
88
88
|
const content = "# Español\nÑandú\n";
|
|
89
89
|
const artifact = await new ArtifactStore().forChat("chat-1").createGeneratedFile({
|
|
@@ -94,6 +94,43 @@ test("writes generated text file artifacts as UTF-8", async () => {
|
|
|
94
94
|
source: { type: "assistant" }
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
assert.deepEqual(await readFile(artifact.path), Buffer.from(`\ufeff${content}`, "utf8"));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("normalizes copied generated text files to BOM UTF-8", async () => {
|
|
101
|
+
await resetHome();
|
|
102
|
+
const originalDir = await mkdtemp(path.join(os.tmpdir(), "arisa-source-file-"));
|
|
103
|
+
const originalPath = path.join(originalDir, "report.json");
|
|
104
|
+
const content = "{\"message\":\"Español\"}\n";
|
|
105
|
+
await writeFile(originalPath, content, "utf8");
|
|
106
|
+
|
|
107
|
+
const artifact = await new ArtifactStore().forChat("chat-1").createFromFile({
|
|
108
|
+
originalPath,
|
|
109
|
+
fileName: "report.json",
|
|
110
|
+
kind: "document",
|
|
111
|
+
mimeType: "application/json",
|
|
112
|
+
source: { type: "tool", toolName: "reporter" }
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
assert.deepEqual(await readFile(artifact.path), Buffer.from(`\ufeff${content}`, "utf8"));
|
|
116
|
+
assert.equal(await readFile(originalPath, "utf8"), content);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("does not duplicate an existing UTF-8 BOM", async () => {
|
|
120
|
+
await resetHome();
|
|
121
|
+
const originalDir = await mkdtemp(path.join(os.tmpdir(), "arisa-source-file-"));
|
|
122
|
+
const originalPath = path.join(originalDir, "report.txt");
|
|
123
|
+
const content = "\ufeffAlready marked\n";
|
|
124
|
+
await writeFile(originalPath, content, "utf8");
|
|
125
|
+
|
|
126
|
+
const artifact = await new ArtifactStore().forChat("chat-1").createFromFile({
|
|
127
|
+
originalPath,
|
|
128
|
+
fileName: "report.txt",
|
|
129
|
+
kind: "document",
|
|
130
|
+
mimeType: "text/plain",
|
|
131
|
+
source: { type: "tool", toolName: "reporter" }
|
|
132
|
+
});
|
|
133
|
+
|
|
97
134
|
assert.deepEqual(await readFile(artifact.path), Buffer.from(content, "utf8"));
|
|
98
135
|
});
|
|
99
136
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
8
|
+
const expected = {
|
|
9
|
+
"whispermix-transcribe": { scope: "global", autoStart: false },
|
|
10
|
+
"whatsapp-web": { scope: "chat", autoStart: false },
|
|
11
|
+
"roster-sites": { scope: "global", autoStart: true },
|
|
12
|
+
"turn-server": { scope: "global", autoStart: true },
|
|
13
|
+
"signaling-server": { scope: "global", autoStart: true }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
for (const [toolName, daemon] of Object.entries(expected)) {
|
|
17
|
+
test(`${toolName} declares the managed daemon contract`, async () => {
|
|
18
|
+
const toolDir = path.join(repositoryRoot, "tools", toolName);
|
|
19
|
+
const manifest = JSON.parse(await readFile(path.join(toolDir, "tool.manifest.json"), "utf8"));
|
|
20
|
+
const source = await readFile(path.join(toolDir, manifest.entry), "utf8");
|
|
21
|
+
|
|
22
|
+
assert.equal(manifest.daemon.scope, daemon.scope);
|
|
23
|
+
assert.equal(manifest.daemon.autoStart, daemon.autoStart);
|
|
24
|
+
assert.equal(manifest.daemon.health, "internal");
|
|
25
|
+
assert.match(source, /createDaemonRuntime/);
|
|
26
|
+
assert.match(source, /healthCheck/);
|
|
27
|
+
});
|
|
28
|
+
}
|