arisa 5.1.2 → 5.1.8
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 +12 -4
- package/ARISA-MASTER-SLAVE-SPEC.md +844 -0
- package/README.md +25 -0
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +55 -12
- package/src/core/config/config-defaults.js +3 -1
- package/src/core/tools/daemon-processes.js +48 -6
- package/src/core/tools/daemon-runtime.js +370 -138
- package/src/core/tools/ipc-client.js +3 -0
- package/src/core/tools/official-tool-catalog.js +32 -0
- package/src/core/tools/official-tool-installer.js +183 -0
- package/src/core/tools/tool-registry.js +203 -18
- package/src/core/tools/tool-resource-note-store.js +78 -0
- package/src/index.js +25 -2
- package/src/official-tools.lock.json +40 -0
- package/src/runtime/arisa-capabilities.js +43 -3
- package/src/runtime/create-app.js +9 -0
- package/src/runtime/create-headless-app.js +77 -0
- package/src/runtime/doctor.js +27 -2
- package/src/runtime/headless-tool-executor.js +45 -0
- package/src/runtime/paths.js +16 -4
- package/src/runtime/secure-request-file.js +21 -0
- package/src/runtime/slave-bootstrap-url.js +51 -0
- package/src/runtime/slave-cli.js +267 -0
- package/src/runtime/slave-service.js +225 -0
- package/src/runtime/tool-usage-report.js +11 -3
- package/src/transport/telegram/bot.js +37 -7
- package/test/capabilities-security.test.js +29 -0
- package/test/daemon-catalog-conformance.test.js +3 -1
- package/test/daemon-runtime.test.js +58 -2
- package/test/official-tool-installer.test.js +107 -0
- package/test/paths.test.js +6 -12
- package/test/slave-cli.test.js +282 -0
- package/test/telegram-text-artifact.test.js +24 -1
- package/test/tool-capability-search.test.js +55 -0
- package/test/tool-registry-run.test.js +70 -1
- package/test/tool-resource-note.test.js +50 -0
- package/test/tool-usage.test.js +10 -5
- package/test-fixtures/fake-daemon.js +12 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { chmod, mkdir, readFile, readdir, rename, rm, unlink } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import {
|
|
5
6
|
daemonPaths,
|
|
7
|
+
ensureDaemonCapability,
|
|
6
8
|
isProcessAlive,
|
|
7
9
|
readJson,
|
|
8
10
|
startManagedDaemon,
|
|
@@ -13,6 +15,9 @@ import {
|
|
|
13
15
|
import { loadDaemonPolicy } from "./daemon-policy.js";
|
|
14
16
|
|
|
15
17
|
const CONTROL_FIELD = "__daemon";
|
|
18
|
+
export const DAEMON_PROTOCOL_VERSION = 1;
|
|
19
|
+
export const DAEMON_EVENT_TYPES = Object.freeze(["accepted", "progress", "chunk", "completed", "failed"]);
|
|
20
|
+
const TERMINAL_EVENT_TYPES = new Set(["completed", "failed"]);
|
|
16
21
|
|
|
17
22
|
function sleep(ms) {
|
|
18
23
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -44,43 +49,193 @@ function jobPaths(paths, id) {
|
|
|
44
49
|
};
|
|
45
50
|
}
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
function daemonFrame(jobId, type, sequence, payload = {}) {
|
|
53
|
+
return { version: DAEMON_PROTOCOL_VERSION, jobId, type, sequence, payload };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function terminalResult(frame) {
|
|
57
|
+
if (frame.type === "failed") {
|
|
58
|
+
const error = new Error(frame.payload?.error || "Daemon job failed");
|
|
59
|
+
if (frame.payload?.code) error.code = frame.payload.code;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
return frame.payload?.output || {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readCapability(paths) {
|
|
66
|
+
const token = (await readFile(paths.capabilityFile, "utf8")).trim();
|
|
67
|
+
if (!token) throw new Error(`Invalid daemon capability for ${paths.toolName}`);
|
|
68
|
+
return token;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function writeSocketFrame(socket, frame, { maxFrameBytes = 1_048_576, streamBufferBytes = 1_048_576 } = {}) {
|
|
72
|
+
if (socket.destroyed || !socket.writable) return false;
|
|
73
|
+
const encoded = `${JSON.stringify(frame)}\n`;
|
|
74
|
+
if (Buffer.byteLength(encoded, "utf8") > maxFrameBytes) {
|
|
75
|
+
throw new Error(`Daemon IPC frame exceeds ${maxFrameBytes} bytes`);
|
|
76
|
+
}
|
|
77
|
+
if (socket.writableLength >= streamBufferBytes) {
|
|
78
|
+
await new Promise((resolve) => {
|
|
79
|
+
const finish = () => {
|
|
80
|
+
socket.off("drain", finish);
|
|
81
|
+
socket.off("close", finish);
|
|
82
|
+
socket.off("error", finish);
|
|
83
|
+
resolve();
|
|
84
|
+
};
|
|
85
|
+
socket.once("drain", finish);
|
|
86
|
+
socket.once("close", finish);
|
|
87
|
+
socket.once("error", finish);
|
|
88
|
+
});
|
|
89
|
+
if (socket.destroyed || !socket.writable) return false;
|
|
90
|
+
}
|
|
91
|
+
if (socket.write(encoded)) return true;
|
|
92
|
+
await new Promise((resolve) => {
|
|
93
|
+
const finish = () => {
|
|
94
|
+
socket.off("drain", finish);
|
|
95
|
+
socket.off("close", finish);
|
|
96
|
+
socket.off("error", finish);
|
|
97
|
+
resolve();
|
|
98
|
+
};
|
|
99
|
+
socket.once("drain", finish);
|
|
100
|
+
socket.once("close", finish);
|
|
101
|
+
socket.once("error", finish);
|
|
102
|
+
});
|
|
103
|
+
return !socket.destroyed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function validateDaemonEvent(frame, { jobId, previousSequence = 0, terminalSeen = false } = {}) {
|
|
107
|
+
if (!frame || frame.version !== DAEMON_PROTOCOL_VERSION || frame.jobId !== jobId) {
|
|
108
|
+
throw new Error("Invalid daemon event identity or protocol version");
|
|
109
|
+
}
|
|
110
|
+
if (!DAEMON_EVENT_TYPES.includes(frame.type)) throw new Error(`Invalid daemon event type: ${frame.type}`);
|
|
111
|
+
if (!Number.isSafeInteger(frame.sequence) || frame.sequence <= previousSequence) {
|
|
112
|
+
throw new Error(`Invalid daemon event sequence for ${jobId}: ${frame.sequence}`);
|
|
113
|
+
}
|
|
114
|
+
if (terminalSeen) throw new Error(`Daemon job ${jobId} emitted more than one terminal event`);
|
|
115
|
+
return { sequence: frame.sequence, terminal: TERMINAL_EVENT_TYPES.has(frame.type) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function connectDaemon(paths, request, { timeoutMs, onEvent, maxFrameBytes }) {
|
|
49
119
|
const startedAt = Date.now();
|
|
120
|
+
const token = await readCapability(paths);
|
|
121
|
+
let lastError;
|
|
50
122
|
while (Date.now() - startedAt < timeoutMs) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
123
|
+
try {
|
|
124
|
+
return await new Promise((resolve, reject) => {
|
|
125
|
+
const socket = net.createConnection(paths.socketFile);
|
|
126
|
+
let buffer = "";
|
|
127
|
+
let sequence = 0;
|
|
128
|
+
let terminalSeen = false;
|
|
129
|
+
let settled = false;
|
|
130
|
+
let observerChain = Promise.resolve();
|
|
131
|
+
const remainingMs = Math.max(1, timeoutMs - (Date.now() - startedAt));
|
|
132
|
+
const timer = setTimeout(() => finish(reject, Object.assign(
|
|
133
|
+
new Error(`${paths.toolName} daemon job timed out after ${timeoutMs}ms`),
|
|
134
|
+
{ code: "DAEMON_JOB_TIMEOUT" }
|
|
135
|
+
)), remainingMs);
|
|
136
|
+
timer.unref?.();
|
|
137
|
+
|
|
138
|
+
function finish(fn, value) {
|
|
139
|
+
if (settled) return;
|
|
140
|
+
settled = true;
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
socket.destroy();
|
|
143
|
+
fn(value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
socket.setEncoding("utf8");
|
|
147
|
+
socket.once("connect", () => {
|
|
148
|
+
socket.write(`${JSON.stringify({
|
|
149
|
+
version: DAEMON_PROTOCOL_VERSION,
|
|
150
|
+
type: "submit",
|
|
151
|
+
jobId: request.jobId,
|
|
152
|
+
capabilityToken: token
|
|
153
|
+
})}\n`);
|
|
154
|
+
});
|
|
155
|
+
socket.on("data", (chunk) => {
|
|
156
|
+
buffer += chunk;
|
|
157
|
+
if (Buffer.byteLength(buffer, "utf8") > maxFrameBytes && !buffer.includes("\n")) {
|
|
158
|
+
finish(reject, new Error(`Daemon IPC frame exceeds ${maxFrameBytes} bytes`));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
162
|
+
while (newlineIndex !== -1) {
|
|
163
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
164
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
165
|
+
newlineIndex = buffer.indexOf("\n");
|
|
166
|
+
if (!line) continue;
|
|
167
|
+
let frame;
|
|
168
|
+
try {
|
|
169
|
+
frame = JSON.parse(line);
|
|
170
|
+
const validated = validateDaemonEvent(frame, {
|
|
171
|
+
jobId: request.jobId,
|
|
172
|
+
previousSequence: sequence,
|
|
173
|
+
terminalSeen
|
|
174
|
+
});
|
|
175
|
+
sequence = validated.sequence;
|
|
176
|
+
terminalSeen = validated.terminal;
|
|
177
|
+
} catch (error) {
|
|
178
|
+
finish(reject, error);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
observerChain = observerChain.then(() => onEvent?.(frame));
|
|
182
|
+
if (terminalSeen) {
|
|
183
|
+
observerChain.then(() => {
|
|
184
|
+
try {
|
|
185
|
+
finish(resolve, terminalResult(frame));
|
|
186
|
+
} catch (error) {
|
|
187
|
+
finish(reject, error);
|
|
188
|
+
}
|
|
189
|
+
}, (error) => finish(reject, error));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
socket.once("error", (error) => finish(reject, error));
|
|
194
|
+
socket.once("close", () => {
|
|
195
|
+
if (!settled) finish(reject, new Error("Daemon IPC connection closed before terminal result"));
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
lastError = error;
|
|
200
|
+
if (!["ENOENT", "ECONNREFUSED"].includes(error?.code)) throw error;
|
|
201
|
+
await sleep(Math.min(25, Math.max(1, timeoutMs - (Date.now() - startedAt))));
|
|
60
202
|
}
|
|
61
|
-
await sleep(intervalMs);
|
|
62
203
|
}
|
|
63
|
-
const error = new Error(`${paths.toolName} daemon
|
|
64
|
-
error.code = "
|
|
204
|
+
const error = new Error(`${paths.toolName} daemon IPC was unavailable after ${timeoutMs}ms`);
|
|
205
|
+
error.code = lastError?.code || "DAEMON_IPC_UNAVAILABLE";
|
|
65
206
|
throw error;
|
|
66
207
|
}
|
|
67
208
|
|
|
68
|
-
async function enqueue(paths, payload, { control = false, timeoutMs,
|
|
209
|
+
async function enqueue(paths, payload, { control = false, timeoutMs, onEvent, jobId, maxFrameBytes } = {}) {
|
|
69
210
|
await mkdir(paths.commandsDir, { recursive: true });
|
|
70
|
-
const id = `${control ? "control" : "job"}-${crypto.randomUUID()}`;
|
|
71
|
-
|
|
72
|
-
|
|
211
|
+
const id = jobId || `${control ? "control" : "job"}-${crypto.randomUUID()}`;
|
|
212
|
+
const files = jobPaths(paths, id);
|
|
213
|
+
const existingResult = await readJson(files.result, null);
|
|
214
|
+
if (existingResult?.terminal) {
|
|
215
|
+
await onEvent?.(existingResult.terminal);
|
|
216
|
+
return terminalResult(existingResult.terminal);
|
|
217
|
+
}
|
|
218
|
+
const existingRequest = await readJson(files.request, null);
|
|
219
|
+
const existingAccepted = await readJson(files.processing, null);
|
|
220
|
+
if (!existingRequest && !existingAccepted) {
|
|
221
|
+
await writeJson(files.request, {
|
|
222
|
+
id,
|
|
223
|
+
status: "queued",
|
|
224
|
+
queuedAt: new Date().toISOString(),
|
|
225
|
+
payload
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return connectDaemon(paths, { jobId: id }, { timeoutMs, onEvent, maxFrameBytes });
|
|
73
229
|
}
|
|
74
230
|
|
|
75
|
-
export async function submitDaemonControl(record, operation, { timeoutMs } = {}) {
|
|
231
|
+
export async function submitDaemonControl(record, operation, { timeoutMs, onEvent } = {}) {
|
|
76
232
|
const paths = daemonPaths({ toolName: record.toolName, scope: record.scope });
|
|
77
233
|
const policy = await loadDaemonPolicy();
|
|
78
|
-
return enqueue(paths, {
|
|
79
|
-
[CONTROL_FIELD]: { operation }
|
|
80
|
-
}, {
|
|
234
|
+
return enqueue(paths, { [CONTROL_FIELD]: { operation } }, {
|
|
81
235
|
control: true,
|
|
82
236
|
timeoutMs: timeoutMs ?? policy.healthTimeoutMs,
|
|
83
|
-
|
|
237
|
+
onEvent,
|
|
238
|
+
maxFrameBytes: policy.ipcFrameBytes || 1_048_576
|
|
84
239
|
});
|
|
85
240
|
}
|
|
86
241
|
|
|
@@ -119,10 +274,7 @@ export function createDaemonRuntime({
|
|
|
119
274
|
}
|
|
120
275
|
|
|
121
276
|
async function start() {
|
|
122
|
-
return startManagedDaemon({
|
|
123
|
-
...registration,
|
|
124
|
-
beforeStart
|
|
125
|
-
});
|
|
277
|
+
return startManagedDaemon({ ...registration, beforeStart });
|
|
126
278
|
}
|
|
127
279
|
|
|
128
280
|
async function stop() {
|
|
@@ -148,60 +300,61 @@ export function createDaemonRuntime({
|
|
|
148
300
|
const status = await readJson(paths.statusFile, {});
|
|
149
301
|
const pid = await getPid();
|
|
150
302
|
if (isDaemonReady(status, pid, policy)) return status;
|
|
151
|
-
await submitDaemonControl(registration, "health", {
|
|
152
|
-
timeoutMs: timeoutMs ?? policy.healthTimeoutMs
|
|
153
|
-
});
|
|
303
|
+
await submitDaemonControl(registration, "health", { timeoutMs: timeoutMs ?? policy.healthTimeoutMs });
|
|
154
304
|
return waitReady({ timeoutMs: timeoutMs ?? policy.startupTimeoutMs });
|
|
155
305
|
}
|
|
156
306
|
|
|
157
307
|
async function submit(payload, {
|
|
158
308
|
timeoutMs,
|
|
159
309
|
readyTimeoutMs,
|
|
160
|
-
requireReady = true
|
|
310
|
+
requireReady = true,
|
|
311
|
+
onEvent,
|
|
312
|
+
jobId
|
|
161
313
|
} = {}) {
|
|
162
314
|
const policy = await loadDaemonPolicy();
|
|
163
315
|
await start();
|
|
164
316
|
if (requireReady) await ensureReady({ timeoutMs: readyTimeoutMs });
|
|
165
317
|
return enqueue(paths, payload, {
|
|
166
318
|
timeoutMs: timeoutMs ?? policy.startupTimeoutMs,
|
|
167
|
-
|
|
319
|
+
onEvent,
|
|
320
|
+
jobId,
|
|
321
|
+
maxFrameBytes: policy.ipcFrameBytes || 1_048_576
|
|
168
322
|
});
|
|
169
323
|
}
|
|
170
324
|
|
|
171
325
|
async function claimNext() {
|
|
172
326
|
await ensure();
|
|
173
|
-
const files =
|
|
174
|
-
|
|
327
|
+
const files = await readdir(paths.commandsDir);
|
|
328
|
+
const pending = files
|
|
329
|
+
.filter((file) => file.endsWith(".request.json") || file.endsWith(".processing.json"))
|
|
175
330
|
.sort((a, b) => {
|
|
176
331
|
const aControl = a.startsWith("control-") ? 0 : 1;
|
|
177
332
|
const bControl = b.startsWith("control-") ? 0 : 1;
|
|
178
333
|
return aControl - bControl || a.localeCompare(b);
|
|
179
334
|
});
|
|
180
|
-
for (const file of
|
|
181
|
-
const id = file.replace(/\.request\.json$/, "");
|
|
335
|
+
for (const file of pending) {
|
|
336
|
+
const id = file.replace(/\.(?:request|processing)\.json$/, "");
|
|
182
337
|
const item = jobPaths(paths, id);
|
|
338
|
+
if (await readJson(item.result, null)) {
|
|
339
|
+
await Promise.all([unlink(item.request).catch(() => {}), unlink(item.processing).catch(() => {})]);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
183
342
|
try {
|
|
184
|
-
await rename(item.request, item.processing);
|
|
185
|
-
|
|
343
|
+
if (file.endsWith(".request.json")) await rename(item.request, item.processing);
|
|
344
|
+
const record = await readJson(item.processing, null);
|
|
345
|
+
if (!record) continue;
|
|
346
|
+
const accepted = {
|
|
347
|
+
...record,
|
|
348
|
+
status: "accepted",
|
|
349
|
+
acceptedAt: record.acceptedAt || new Date().toISOString()
|
|
350
|
+
};
|
|
351
|
+
await writeJson(item.processing, accepted);
|
|
352
|
+
return { id, ...item, payload: accepted.payload };
|
|
186
353
|
} catch {}
|
|
187
354
|
}
|
|
188
355
|
return null;
|
|
189
356
|
}
|
|
190
357
|
|
|
191
|
-
async function complete(job, output) {
|
|
192
|
-
await writeJson(job.result, { ok: true, output });
|
|
193
|
-
await unlink(job.processing).catch(() => {});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async function fail(job, error) {
|
|
197
|
-
await writeJson(job.result, {
|
|
198
|
-
ok: false,
|
|
199
|
-
error: error?.message || String(error),
|
|
200
|
-
code: error?.code || null
|
|
201
|
-
});
|
|
202
|
-
await unlink(job.processing).catch(() => {});
|
|
203
|
-
}
|
|
204
|
-
|
|
205
358
|
async function workLoop({
|
|
206
359
|
processJob,
|
|
207
360
|
healthCheck,
|
|
@@ -209,17 +362,21 @@ export function createDaemonRuntime({
|
|
|
209
362
|
beforeExit = null,
|
|
210
363
|
idleTimeoutMs = 0
|
|
211
364
|
}) {
|
|
212
|
-
if (typeof healthCheck !== "function") {
|
|
213
|
-
throw new Error(`${toolName} daemon must declare healthCheck`);
|
|
214
|
-
}
|
|
365
|
+
if (typeof healthCheck !== "function") throw new Error(`${toolName} daemon must declare healthCheck`);
|
|
215
366
|
const policy = await loadDaemonPolicy();
|
|
216
|
-
const
|
|
367
|
+
const ipcLimits = {
|
|
368
|
+
maxFrameBytes: policy.ipcFrameBytes || 1_048_576,
|
|
369
|
+
streamBufferBytes: policy.streamBufferBytes || 1_048_576
|
|
370
|
+
};
|
|
371
|
+
const subscribers = new Map();
|
|
217
372
|
let lastActivity = Date.now();
|
|
218
373
|
let processing = false;
|
|
219
374
|
let exiting = false;
|
|
220
375
|
let acceptingWork = true;
|
|
376
|
+
let processRequested = false;
|
|
221
377
|
|
|
222
378
|
await ensure();
|
|
379
|
+
const capabilityToken = process.env.ARISA_DAEMON_CAPABILITY || await ensureDaemonCapability(paths);
|
|
223
380
|
await writeStatus({
|
|
224
381
|
state: "starting",
|
|
225
382
|
pid: process.pid,
|
|
@@ -228,107 +385,182 @@ export function createDaemonRuntime({
|
|
|
228
385
|
message: "Daemon work loop started; waiting for health check"
|
|
229
386
|
});
|
|
230
387
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
388
|
+
async function publish(frame) {
|
|
389
|
+
const sockets = [...(subscribers.get(frame.jobId) || [])];
|
|
390
|
+
for (const socket of sockets) {
|
|
391
|
+
if (!(await writeSocketFrame(socket, frame, ipcLimits))) subscribers.get(frame.jobId)?.delete(socket);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
234
394
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
395
|
+
async function persistTerminal(job, frame) {
|
|
396
|
+
await writeJson(job.result, {
|
|
397
|
+
id: job.id,
|
|
398
|
+
status: frame.type,
|
|
399
|
+
completedAt: new Date().toISOString(),
|
|
400
|
+
terminal: frame
|
|
401
|
+
});
|
|
402
|
+
await unlink(job.processing).catch(() => {});
|
|
403
|
+
await publish(frame);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function execute(job) {
|
|
407
|
+
let sequence = 1;
|
|
408
|
+
await publish(daemonFrame(job.id, "accepted", sequence, {}));
|
|
409
|
+
const emit = async (type, payload = {}) => {
|
|
410
|
+
if (!['progress', 'chunk'].includes(type)) throw new Error(`Invalid non-terminal daemon event type: ${type}`);
|
|
411
|
+
sequence += 1;
|
|
412
|
+
await publish(daemonFrame(job.id, type, sequence, payload));
|
|
413
|
+
};
|
|
414
|
+
const operation = job.payload?.[CONTROL_FIELD]?.operation;
|
|
238
415
|
try {
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
}
|
|
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
|
-
...(error?.code ? { code: error.code } : {})
|
|
294
|
-
},
|
|
295
|
-
message: error?.message || String(error)
|
|
296
|
-
});
|
|
297
|
-
await fail(job, error);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
if (idleTimeoutMs > 0 && Date.now() - lastActivity > idleTimeoutMs) {
|
|
301
|
-
exiting = true;
|
|
302
|
-
clearInterval(heartbeatTimer);
|
|
303
|
-
clearInterval(workTimer);
|
|
304
|
-
if (beforeExit) await beforeExit();
|
|
416
|
+
let output;
|
|
417
|
+
if (operation === "health") {
|
|
418
|
+
const checkedAt = new Date().toISOString();
|
|
419
|
+
await writeStatus({ lastHealthCheckAt: checkedAt });
|
|
420
|
+
output = await withTimeout(
|
|
421
|
+
healthCheck,
|
|
422
|
+
policy.healthTimeoutMs,
|
|
423
|
+
`${toolName} health check timed out after ${policy.healthTimeoutMs}ms`
|
|
424
|
+
);
|
|
305
425
|
await writeStatus({
|
|
306
|
-
state: "
|
|
426
|
+
state: "ready",
|
|
427
|
+
lastHealthSuccessAt: new Date().toISOString(),
|
|
428
|
+
consecutiveHealthFailures: 0,
|
|
429
|
+
restartAttempts: 0,
|
|
307
430
|
restartRequested: false,
|
|
308
431
|
nextRestartAt: null,
|
|
309
|
-
message: "
|
|
432
|
+
message: output?.message || "Daemon health check passed"
|
|
310
433
|
});
|
|
311
|
-
|
|
434
|
+
output ||= { ok: true };
|
|
435
|
+
} else if (operation === "recover") {
|
|
436
|
+
const recovered = typeof recover === "function"
|
|
437
|
+
? await withTimeout(recover, policy.healthTimeoutMs, `${toolName} recovery timed out after ${policy.healthTimeoutMs}ms`)
|
|
438
|
+
: false;
|
|
439
|
+
output = { recovered: recovered !== false };
|
|
440
|
+
} else {
|
|
441
|
+
lastActivity = Date.now();
|
|
442
|
+
output = await processJob(job.payload, { emit, jobId: job.id });
|
|
443
|
+
await writeStatus({ lastSuccessfulJobAt: new Date().toISOString() });
|
|
444
|
+
lastActivity = Date.now();
|
|
312
445
|
}
|
|
446
|
+
sequence += 1;
|
|
447
|
+
await persistTerminal(job, daemonFrame(job.id, "completed", sequence, { output }));
|
|
313
448
|
} catch (error) {
|
|
449
|
+
if (error?.code === "DAEMON_OPERATION_TIMEOUT") acceptingWork = false;
|
|
450
|
+
const current = await readJson(paths.statusFile, {});
|
|
314
451
|
await writeStatus({
|
|
315
|
-
|
|
452
|
+
...(operation === "health" ? {
|
|
453
|
+
state: error?.code === "DAEMON_OPERATION_TIMEOUT" ? "unhealthy" : "degraded",
|
|
454
|
+
consecutiveHealthFailures: Number(current.consecutiveHealthFailures || 0) + 1
|
|
455
|
+
} : {}),
|
|
316
456
|
lastError: {
|
|
317
457
|
at: new Date().toISOString(),
|
|
318
|
-
phase: "
|
|
458
|
+
phase: operation || "job",
|
|
319
459
|
message: error?.message || String(error),
|
|
320
460
|
...(error?.code ? { code: error.code } : {})
|
|
321
461
|
},
|
|
322
462
|
message: error?.message || String(error)
|
|
323
463
|
});
|
|
464
|
+
sequence += 1;
|
|
465
|
+
await persistTerminal(job, daemonFrame(job.id, "failed", sequence, {
|
|
466
|
+
error: error?.message || String(error),
|
|
467
|
+
code: error?.code || null
|
|
468
|
+
}));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function processQueue() {
|
|
473
|
+
if (processing || exiting || !acceptingWork) {
|
|
474
|
+
processRequested = true;
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
processing = true;
|
|
478
|
+
try {
|
|
479
|
+
do {
|
|
480
|
+
processRequested = false;
|
|
481
|
+
const job = await claimNext();
|
|
482
|
+
if (!job) break;
|
|
483
|
+
await execute(job);
|
|
484
|
+
} while (!exiting && acceptingWork);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
await writeStatus({
|
|
487
|
+
state: "degraded",
|
|
488
|
+
lastError: { at: new Date().toISOString(), phase: "work-loop", message: error?.message || String(error), ...(error?.code ? { code: error.code } : {}) },
|
|
489
|
+
message: error?.message || String(error)
|
|
490
|
+
});
|
|
324
491
|
} finally {
|
|
325
492
|
processing = false;
|
|
493
|
+
if (processRequested && !exiting && acceptingWork) queueMicrotask(() => processQueue().catch(() => {}));
|
|
326
494
|
}
|
|
327
|
-
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (process.platform !== "win32") await rm(paths.socketFile, { force: true });
|
|
498
|
+
const server = net.createServer((socket) => {
|
|
499
|
+
socket.setEncoding("utf8");
|
|
500
|
+
let buffer = "";
|
|
501
|
+
const subscribedJobs = new Set();
|
|
502
|
+
const removeSocket = () => {
|
|
503
|
+
for (const jobId of subscribedJobs) subscribers.get(jobId)?.delete(socket);
|
|
504
|
+
};
|
|
505
|
+
socket.on("data", (chunk) => {
|
|
506
|
+
buffer += chunk;
|
|
507
|
+
if (Buffer.byteLength(buffer, "utf8") > ipcLimits.maxFrameBytes && !buffer.includes("\n")) {
|
|
508
|
+
socket.destroy();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
512
|
+
while (newlineIndex !== -1) {
|
|
513
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
514
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
515
|
+
newlineIndex = buffer.indexOf("\n");
|
|
516
|
+
if (!line) continue;
|
|
517
|
+
let notification;
|
|
518
|
+
try {
|
|
519
|
+
notification = JSON.parse(line);
|
|
520
|
+
} catch {
|
|
521
|
+
socket.destroy();
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (notification.version !== DAEMON_PROTOCOL_VERSION
|
|
525
|
+
|| notification.type !== "submit"
|
|
526
|
+
|| typeof notification.jobId !== "string"
|
|
527
|
+
|| notification.capabilityToken !== capabilityToken) {
|
|
528
|
+
socket.destroy();
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const { jobId } = notification;
|
|
532
|
+
if (!subscribers.has(jobId)) subscribers.set(jobId, new Set());
|
|
533
|
+
subscribers.get(jobId).add(socket);
|
|
534
|
+
subscribedJobs.add(jobId);
|
|
535
|
+
readJson(jobPaths(paths, jobId).result, null).then((result) => {
|
|
536
|
+
if (result?.terminal) return writeSocketFrame(socket, result.terminal, ipcLimits);
|
|
537
|
+
return processQueue();
|
|
538
|
+
}).catch(() => socket.destroy());
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
socket.once("close", removeSocket);
|
|
542
|
+
socket.once("error", removeSocket);
|
|
543
|
+
});
|
|
544
|
+
await new Promise((resolve, reject) => {
|
|
545
|
+
server.once("error", reject);
|
|
546
|
+
server.listen(paths.socketFile, resolve);
|
|
547
|
+
});
|
|
548
|
+
if (process.platform !== "win32") await chmod(paths.socketFile, 0o600);
|
|
549
|
+
|
|
550
|
+
const heartbeatTimer = setInterval(() => {
|
|
551
|
+
writeStatus({ heartbeatAt: new Date().toISOString() }).catch(() => {});
|
|
552
|
+
}, policy.heartbeatIntervalMs);
|
|
553
|
+
const idleTimer = idleTimeoutMs > 0 ? setInterval(async () => {
|
|
554
|
+
if (processing || exiting || Date.now() - lastActivity <= idleTimeoutMs) return;
|
|
555
|
+
exiting = true;
|
|
556
|
+
clearInterval(heartbeatTimer);
|
|
557
|
+
clearInterval(idleTimer);
|
|
558
|
+
await beforeExit?.();
|
|
559
|
+
await writeStatus({ state: "stopped", restartRequested: false, nextRestartAt: null, message: "Idle timeout reached" });
|
|
560
|
+
server.close(() => process.exit(0));
|
|
561
|
+
}, Math.min(idleTimeoutMs, 1_000)) : null;
|
|
328
562
|
|
|
329
|
-
|
|
330
|
-
timeoutMs: policy.healthTimeoutMs
|
|
331
|
-
}).catch(() => {});
|
|
563
|
+
processQueue().catch(() => {});
|
|
332
564
|
}
|
|
333
565
|
|
|
334
566
|
return {
|
|
@@ -97,6 +97,9 @@ export function createArisaClient({
|
|
|
97
97
|
help: (params) => call("tools.help", params),
|
|
98
98
|
skills: (params) => call("tools.skills", params),
|
|
99
99
|
setConfig: (params) => call("tools.setConfig", params),
|
|
100
|
+
setResourceNote: (params) => call("tools.setResourceNote", params),
|
|
101
|
+
getResourceNote: (params) => call("tools.getResourceNote", params),
|
|
102
|
+
installOfficial: (params, options) => call("tools.installOfficial", params, options),
|
|
100
103
|
run: (params, options) => call("tools.run", params, options)
|
|
101
104
|
},
|
|
102
105
|
paths: {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { rankToolMatches } from "./tool-registry.js";
|
|
2
|
+
|
|
3
|
+
const catalogApi = "https://api.github.com/repos/clasen/Arisa/contents/tools";
|
|
4
|
+
const rawBase = "https://raw.githubusercontent.com/clasen/Arisa/main/tools";
|
|
5
|
+
|
|
6
|
+
async function fetchJson(fetchImpl, url) {
|
|
7
|
+
const response = await fetchImpl(url, { headers: { Accept: "application/vnd.github+json" } });
|
|
8
|
+
if (!response.ok) throw new Error(`Official catalog request failed (${response.status})`);
|
|
9
|
+
return response.json();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function searchOfficialToolCatalog(query, { fetchImpl = fetch } = {}) {
|
|
13
|
+
const entries = await fetchJson(fetchImpl, catalogApi);
|
|
14
|
+
const directories = entries.filter((entry) => entry.type === "dir" && entry.name);
|
|
15
|
+
const manifests = (await Promise.all(directories.map(async ({ name }) => {
|
|
16
|
+
try {
|
|
17
|
+
return await fetchJson(fetchImpl, `${rawBase}/${encodeURIComponent(name)}/tool.manifest.json`);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}))).filter(Boolean);
|
|
22
|
+
return rankToolMatches(manifests, query).map(({ tool, score }) => ({
|
|
23
|
+
name: tool.name,
|
|
24
|
+
description: tool.description,
|
|
25
|
+
input: tool.input,
|
|
26
|
+
output: tool.output,
|
|
27
|
+
category: tool.category || null,
|
|
28
|
+
keywords: tool.keywords || [],
|
|
29
|
+
score,
|
|
30
|
+
source: "official-catalog"
|
|
31
|
+
}));
|
|
32
|
+
}
|