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.
- package/AGENTS.md +15 -0
- package/README.md +3 -0
- package/package.json +1 -1
- 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
package/AGENTS.md
CHANGED
|
@@ -47,6 +47,12 @@ Each tool declares in `tool.manifest.json`:
|
|
|
47
47
|
- `keywords`: optional intent tags for capability discovery
|
|
48
48
|
- `skillHints`: optional skills to apply when using or editing the tool
|
|
49
49
|
|
|
50
|
+
## Text encoding
|
|
51
|
+
All textual content generated or sent by Arisa or its tools must use UTF-8. This includes text files, assistant-created attachments, tool exports, email bodies, messages, HTTP responses, and API payloads.
|
|
52
|
+
|
|
53
|
+
- Text files must start with a UTF-8 byte-order mark (BOM).
|
|
54
|
+
- Protocol payloads must declare UTF-8 through the protocol's standard mechanism and encode their bytes as UTF-8. For example, email and HTTP text content must use a `Content-Type` with `charset=UTF-8`.
|
|
55
|
+
|
|
50
56
|
## Tool-to-Arisa IPC
|
|
51
57
|
Tools that expose a web UI or HTTP endpoint own that server, usually through the shared daemon runtime; Arisa core does not mount tool routes or proxies. Use Arisa IPC when a tool needs registered tools, artifacts, tasks, agent events, or runtime paths.
|
|
52
58
|
|
|
@@ -121,6 +127,15 @@ When such a tool is built, implement it with the shared daemon runtime instead o
|
|
|
121
127
|
- keep daemon tools headless/server-safe by default when they are meant to run on VPS machines
|
|
122
128
|
- if the daemon exposes an HTTP server, keep that server inside the tool; Arisa core does not discover or mount tool routes
|
|
123
129
|
|
|
130
|
+
Every managed daemon must also follow the scoped health contract:
|
|
131
|
+
- declare `daemon.scope`, `daemon.autoStart`, and `daemon.health: "internal"` in `tool.manifest.json`
|
|
132
|
+
- use `{ type: "global" }` for shared infrastructure, or `{ type: "chat", chatId }` for one isolated process per chat
|
|
133
|
+
- implement `workLoop({ processJob, healthCheck, recover })`; `healthCheck` must exercise the real capability without human input, and `recover` is optional
|
|
134
|
+
- never write `ready` directly: the shared runtime sets it only after the health operation succeeds through the normal command queue
|
|
135
|
+
- keep daemon infrastructure for a chat-scoped process under `getChatToolStateDir(chatId, toolName)/daemon`; keep user data such as sessions, inboxes, and databases outside that subdirectory
|
|
136
|
+
- persist only non-secret launch identity in `startupContext`; reload global or chat-scoped tool config after every process restart
|
|
137
|
+
- use only the standard daemon states: `starting`, `ready`, `degraded`, `unhealthy`, `restarting`, `stopped`, `failed`
|
|
138
|
+
|
|
124
139
|
## Manual pipe behavior
|
|
125
140
|
To run a pipe, the agent should:
|
|
126
141
|
1. understand whether the needed pipe belongs to pre-reasoning normalization or post-reasoning tool chaining
|
package/README.md
CHANGED
|
@@ -96,8 +96,11 @@ Per chat (`~/.arisa/chats/<chatId>/`):
|
|
|
96
96
|
- the artifact index is stored in `state/artifacts.json`
|
|
97
97
|
- the Pi session lives under `state/pi-sessions/`
|
|
98
98
|
- chat-scoped tool config overrides live in `config/tools/<tool>/config.js`
|
|
99
|
+
- chat-scoped daemon infrastructure lives in `state/tools/<tool>/daemon/`; persistent tool data stays beside it
|
|
99
100
|
- ephemeral scratch lives under `tmp/`
|
|
100
101
|
|
|
102
|
+
Managed daemons become ready only after their tool-defined health operation succeeds through the normal command queue. Arisa records heartbeats, successful jobs, errors, and standard lifecycle states, then retries recovery or recreates an unhealthy process with its persisted scope and startup context.
|
|
103
|
+
|
|
101
104
|
Pi authentication can use either:
|
|
102
105
|
- an API key entered during bootstrap
|
|
103
106
|
- or Pi's existing OAuth login when supported, such as `openai-codex`
|
package/package.json
CHANGED
|
@@ -3,15 +3,45 @@ import path from "node:path";
|
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
4
|
import { getChatArtifactsDir, getChatArtifactsIndexFile } from "../../runtime/paths.js";
|
|
5
5
|
|
|
6
|
+
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
|
|
7
|
+
|
|
6
8
|
function id() {
|
|
7
9
|
return crypto.randomUUID();
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
function withUtf8Bom(content) {
|
|
13
|
+
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
|
|
14
|
+
const body = bytes.subarray(0, UTF8_BOM.length).equals(UTF8_BOM)
|
|
15
|
+
? bytes.subarray(UTF8_BOM.length)
|
|
16
|
+
: bytes;
|
|
17
|
+
return Buffer.concat([UTF8_BOM, body]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isTextMimeType(mimeType = "") {
|
|
21
|
+
const type = mimeType.split(";", 1)[0].trim().toLowerCase();
|
|
22
|
+
return type.startsWith("text/")
|
|
23
|
+
|| type === "application/json"
|
|
24
|
+
|| type.endsWith("+json")
|
|
25
|
+
|| type === "application/xml"
|
|
26
|
+
|| type.endsWith("+xml")
|
|
27
|
+
|| type === "application/javascript"
|
|
28
|
+
|| type === "application/ecmascript"
|
|
29
|
+
|| type === "image/svg+xml";
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
function writeArtifactFile(filePath, content) {
|
|
11
|
-
if (typeof content === "string") return writeFile(filePath, content
|
|
33
|
+
if (typeof content === "string") return writeFile(filePath, withUtf8Bom(content));
|
|
12
34
|
return writeFile(filePath, content);
|
|
13
35
|
}
|
|
14
36
|
|
|
37
|
+
async function copyArtifactFile(originalPath, destPath, mimeType) {
|
|
38
|
+
if (!isTextMimeType(mimeType)) return copyFile(originalPath, destPath);
|
|
39
|
+
|
|
40
|
+
const content = await readFile(originalPath);
|
|
41
|
+
new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
42
|
+
return writeFile(destPath, withUtf8Bom(content));
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
class ChatArtifactStore {
|
|
16
46
|
constructor(chatId) {
|
|
17
47
|
this.chatId = String(chatId);
|
|
@@ -63,7 +93,7 @@ class ChatArtifactStore {
|
|
|
63
93
|
const dir = path.join(this.rootDir, artifactId);
|
|
64
94
|
await mkdir(dir, { recursive: true });
|
|
65
95
|
const destPath = path.join(dir, fileName);
|
|
66
|
-
await
|
|
96
|
+
await copyArtifactFile(originalPath, destPath, mimeType);
|
|
67
97
|
const artifact = {
|
|
68
98
|
id: artifactId,
|
|
69
99
|
chatId: this.chatId,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const daemonConfigDefaults = Object.freeze({
|
|
2
|
+
supervisorIntervalMs: 5_000,
|
|
3
|
+
heartbeatIntervalMs: 5_000,
|
|
4
|
+
heartbeatStaleMs: 20_000,
|
|
5
|
+
healthIntervalMs: 30_000,
|
|
6
|
+
healthTimeoutMs: 120_000,
|
|
7
|
+
healthRetryLimit: 2,
|
|
8
|
+
healthRetryBackoffMs: 1_000,
|
|
9
|
+
restartLimit: 3,
|
|
10
|
+
restartBackoffMs: 2_000,
|
|
11
|
+
restartBackoffMaxMs: 60_000,
|
|
12
|
+
startupTimeoutMs: 120_000,
|
|
13
|
+
stopTimeoutMs: 3_000,
|
|
14
|
+
queuePollIntervalMs: 250
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export function applyConfigDefaults(config) {
|
|
18
|
+
return {
|
|
19
|
+
...config,
|
|
20
|
+
daemons: {
|
|
21
|
+
...daemonConfigDefaults,
|
|
22
|
+
...(config.daemons || {})
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { configFile } from "../../runtime/
|
|
3
|
+
import { configFile } from "../../runtime/paths.js";
|
|
4
|
+
import { applyConfigDefaults } from "./config-defaults.js";
|
|
4
5
|
|
|
5
6
|
export async function loadConfig() {
|
|
6
7
|
const raw = await readFile(configFile, "utf8");
|
|
7
|
-
return JSON.parse(raw);
|
|
8
|
+
return applyConfigDefaults(JSON.parse(raw));
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export async function saveConfig(config) {
|
|
11
12
|
await mkdir(path.dirname(configFile), { recursive: true });
|
|
12
|
-
await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
13
|
+
await writeFile(configFile, `${JSON.stringify(applyConfigDefaults(config), null, 2)}\n`, "utf8");
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export async function updateConfig(mutator) {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import {
|
|
2
|
+
daemonPaths,
|
|
3
|
+
isProcessAlive,
|
|
4
|
+
readJson,
|
|
5
|
+
startManagedDaemon,
|
|
6
|
+
stopManagedDaemon,
|
|
7
|
+
writeDaemonStatus
|
|
8
|
+
} from "./daemon-processes.js";
|
|
9
|
+
import { retryDelay } from "./daemon-policy.js";
|
|
10
|
+
import { submitDaemonControl } from "./daemon-runtime.js";
|
|
11
|
+
|
|
12
|
+
function sleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function errorRecord(phase, error) {
|
|
17
|
+
return {
|
|
18
|
+
at: new Date().toISOString(),
|
|
19
|
+
phase,
|
|
20
|
+
message: error?.message || String(error)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isTimeout(error) {
|
|
25
|
+
return ["DAEMON_JOB_TIMEOUT", "DAEMON_OPERATION_TIMEOUT"].includes(error?.code);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function healthDue(status, policy, now = Date.now()) {
|
|
29
|
+
const heartbeatAt = new Date(status.heartbeatAt || 0).getTime();
|
|
30
|
+
const healthAt = new Date(status.lastHealthCheckAt || 0).getTime();
|
|
31
|
+
return status.state !== "ready"
|
|
32
|
+
|| !heartbeatAt
|
|
33
|
+
|| now - heartbeatAt > policy.heartbeatStaleMs
|
|
34
|
+
|| !healthAt
|
|
35
|
+
|| now - healthAt >= policy.healthIntervalMs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function probeWithRetries(record, paths, policy) {
|
|
39
|
+
let lastError;
|
|
40
|
+
for (let attempt = 0; attempt <= policy.healthRetryLimit; attempt += 1) {
|
|
41
|
+
if (attempt > 0) {
|
|
42
|
+
await sleep(policy.healthRetryBackoffMs * (2 ** (attempt - 1)));
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return await submitDaemonControl(record, "health", {
|
|
46
|
+
timeoutMs: policy.healthTimeoutMs
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
lastError = error;
|
|
50
|
+
const status = await readJson(paths.statusFile, {});
|
|
51
|
+
await writeDaemonStatus(paths, {
|
|
52
|
+
state: isTimeout(error) ? "unhealthy" : "degraded",
|
|
53
|
+
...(error?.code === "DAEMON_JOB_TIMEOUT"
|
|
54
|
+
? { consecutiveHealthFailures: Number(status.consecutiveHealthFailures || 0) + 1 }
|
|
55
|
+
: {}),
|
|
56
|
+
lastError: errorRecord("health", error),
|
|
57
|
+
message: error?.message || String(error)
|
|
58
|
+
});
|
|
59
|
+
if (isTimeout(error)) throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw lastError;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function tryInternalRecovery(record, paths, policy) {
|
|
66
|
+
const status = await readJson(paths.statusFile, {});
|
|
67
|
+
if (!status.supportsRecovery) return false;
|
|
68
|
+
await writeDaemonStatus(paths, {
|
|
69
|
+
state: "restarting",
|
|
70
|
+
message: "Attempting tool-specific recovery"
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
const result = await submitDaemonControl(record, "recover", {
|
|
74
|
+
timeoutMs: policy.healthTimeoutMs
|
|
75
|
+
});
|
|
76
|
+
if (result.recovered === false) return false;
|
|
77
|
+
await probeWithRetries(record, paths, policy);
|
|
78
|
+
return true;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
await writeDaemonStatus(paths, {
|
|
81
|
+
state: "unhealthy",
|
|
82
|
+
lastError: errorRecord("recovery", error),
|
|
83
|
+
message: error?.message || String(error)
|
|
84
|
+
});
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function scheduleRestart(record, paths, status, policy, reason) {
|
|
90
|
+
const current = await readJson(paths.statusFile, status);
|
|
91
|
+
const restartAttempts = Number(current.restartAttempts || 0) + 1;
|
|
92
|
+
if (restartAttempts > policy.restartLimit) {
|
|
93
|
+
await stopManagedDaemon(
|
|
94
|
+
{ toolName: record.toolName, scope: record.scope },
|
|
95
|
+
{ state: null }
|
|
96
|
+
);
|
|
97
|
+
await writeDaemonStatus(paths, {
|
|
98
|
+
state: "failed",
|
|
99
|
+
pid: null,
|
|
100
|
+
heartbeatAt: null,
|
|
101
|
+
restartAttempts,
|
|
102
|
+
restartRequested: false,
|
|
103
|
+
nextRestartAt: null,
|
|
104
|
+
lastError: errorRecord("restart", reason),
|
|
105
|
+
message: `Daemon restart limit reached: ${reason?.message || reason}`
|
|
106
|
+
});
|
|
107
|
+
return "failed";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const delayMs = retryDelay(restartAttempts, policy);
|
|
111
|
+
await stopManagedDaemon(
|
|
112
|
+
{ toolName: record.toolName, scope: record.scope },
|
|
113
|
+
{ state: null }
|
|
114
|
+
);
|
|
115
|
+
await writeDaemonStatus(paths, {
|
|
116
|
+
state: "restarting",
|
|
117
|
+
pid: null,
|
|
118
|
+
heartbeatAt: null,
|
|
119
|
+
restartAttempts,
|
|
120
|
+
restartRequested: true,
|
|
121
|
+
nextRestartAt: new Date(Date.now() + delayMs).toISOString(),
|
|
122
|
+
lastError: errorRecord("restart", reason),
|
|
123
|
+
message: `Daemon will restart after ${delayMs}ms`
|
|
124
|
+
});
|
|
125
|
+
return "restart-scheduled";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function restartIfDue(record, paths, status, policy) {
|
|
129
|
+
if (!record.autoStart && !status.restartRequested) return "stopped";
|
|
130
|
+
if (status.state === "failed") return "failed";
|
|
131
|
+
const nextRestartAt = new Date(status.nextRestartAt || 0).getTime();
|
|
132
|
+
if (nextRestartAt && Date.now() < nextRestartAt) return "backoff";
|
|
133
|
+
if (Number(status.restartAttempts || 0) > policy.restartLimit) {
|
|
134
|
+
await writeDaemonStatus(paths, {
|
|
135
|
+
state: "failed",
|
|
136
|
+
restartRequested: false,
|
|
137
|
+
nextRestartAt: null,
|
|
138
|
+
message: "Daemon restart limit reached"
|
|
139
|
+
});
|
|
140
|
+
return "failed";
|
|
141
|
+
}
|
|
142
|
+
await startManagedDaemon({
|
|
143
|
+
toolName: record.toolName,
|
|
144
|
+
entryPath: record.entryPath,
|
|
145
|
+
scope: record.scope,
|
|
146
|
+
startupContext: record.startupContext,
|
|
147
|
+
autoStart: record.autoStart
|
|
148
|
+
});
|
|
149
|
+
return "started";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function superviseDaemon(record, policy) {
|
|
153
|
+
const paths = daemonPaths({ toolName: record.toolName, scope: record.scope });
|
|
154
|
+
const { pid } = await readJson(paths.pidFile, {});
|
|
155
|
+
const status = await readJson(paths.statusFile, {});
|
|
156
|
+
const alive = isProcessAlive(pid);
|
|
157
|
+
|
|
158
|
+
if (!alive) {
|
|
159
|
+
if (!record.autoStart && !status.restartRequested && ["stopped", "unhealthy", "failed"].includes(status.state)) {
|
|
160
|
+
return status.state;
|
|
161
|
+
}
|
|
162
|
+
if (!status.state || status.state === "stopped") {
|
|
163
|
+
return restartIfDue(record, paths, status, policy);
|
|
164
|
+
}
|
|
165
|
+
if (status.state === "restarting") {
|
|
166
|
+
return restartIfDue(record, paths, status, policy);
|
|
167
|
+
}
|
|
168
|
+
return scheduleRestart(record, paths, status, policy, new Error("Daemon process exited"));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!healthDue(status, policy)) return "healthy";
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await probeWithRetries(record, paths, policy);
|
|
175
|
+
return "healthy";
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (!isTimeout(error) && await tryInternalRecovery(record, paths, policy)) return "recovered";
|
|
178
|
+
await writeDaemonStatus(paths, {
|
|
179
|
+
state: "unhealthy",
|
|
180
|
+
lastError: errorRecord("health", error),
|
|
181
|
+
message: error?.message || String(error)
|
|
182
|
+
});
|
|
183
|
+
return scheduleRestart(record, paths, status, policy, error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { loadConfig } from "../config/config-store.js";
|
|
2
|
+
|
|
3
|
+
export async function loadDaemonPolicy() {
|
|
4
|
+
const config = await loadConfig();
|
|
5
|
+
return config.daemons;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function retryDelay(attempt, { restartBackoffMs, restartBackoffMaxMs }) {
|
|
9
|
+
return Math.min(restartBackoffMaxMs, restartBackoffMs * (2 ** Math.max(0, attempt - 1)));
|
|
10
|
+
}
|