arisa 5.2.19 → 5.2.21
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/LOW-MEMORY.md +49 -0
- package/README.md +2 -1
- package/package.json +7 -8
- package/src/core/agent/agent-turn-coordinator.js +59 -3
- package/src/core/agent/model-selection.js +4 -3
- package/src/core/agent/model-speed.js +11 -3
- package/src/core/artifacts/artifact-index.js +107 -0
- package/src/core/artifacts/artifact-store.js +15 -84
- package/src/core/artifacts/legacy-artifact-reader.js +46 -0
- package/src/core/config/config-defaults.js +4 -0
- package/src/core/tasks/task-database.js +128 -0
- package/src/core/tasks/task-store.js +42 -101
- package/src/core/tools/daemon-journal.js +115 -0
- package/src/core/tools/daemon-processes.js +10 -1
- package/src/core/tools/daemon-protocol.js +1 -1
- package/src/core/tools/daemon-worker.js +37 -5
- package/src/index.js +45 -5
- package/src/platform/paths.js +7 -1
- package/src/runtime/worker-recovery-report.js +7 -4
- package/src/transport/telegram/model-callback.js +3 -2
- package/src/transport/telegram/model-controls.js +3 -3
- package/src/transport/telegram/model-picker.js +1 -1
- package/src/transport/telegram/task-dispatcher.js +8 -8
- package/test/agent-turn-coordinator.test.js +48 -0
- package/test/artifact-index-memory.test.js +46 -0
- package/test/artifact-index-migration.test.js +88 -0
- package/test/artifact-store.test.js +3 -3
- package/test/cli-command.test.js +52 -0
- package/test/cli-memory.test.js +22 -0
- package/test/daemon-runtime.test.js +44 -4
- package/test/model-selection.test.js +11 -2
- package/test/paths.test.js +2 -0
- package/test/pi-compaction.test.js +1 -0
- package/test/pi-speed-integration.test.js +9 -8
- package/test/task-database.test.js +130 -0
- package/test/task-store.test.js +10 -5
- package/test/telegram-task-dispatcher.test.js +2 -5
package/src/index.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
|
|
4
|
-
import { applyRuntimeOverrides, createApp } from "./runtime/create-app.js";
|
|
5
3
|
import { loadConfig } from "./core/config/config-store.js";
|
|
6
4
|
import { createLogger } from "./runtime/logger.js";
|
|
7
5
|
import { getServiceStatus, handoffServiceRestart, registerServiceProcess, restartService, serviceEntryFile, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
|
|
@@ -10,16 +8,14 @@ import { recordUnexpectedWorkerExit } from "./runtime/worker-recovery-report.js"
|
|
|
10
8
|
import { flushArisaHome } from "./runtime/flush.js";
|
|
11
9
|
import { readPackageVersion, showServiceLogs } from "./runtime/log-viewer.js";
|
|
12
10
|
import { arisaPackageDir } from "./platform/paths.js";
|
|
13
|
-
import { runSlaveCli } from "./runtime/slave-cli.js";
|
|
14
11
|
import { unregisterSlaveServiceProcess } from "./runtime/slave-service.js";
|
|
15
12
|
import { protectCoreFromOom } from "./runtime/oom-protection.js";
|
|
16
|
-
import { runTui } from "./runtime/tui.js";
|
|
17
13
|
|
|
18
14
|
process.env.ARISA_PACKAGE_DIR = arisaPackageDir;
|
|
19
15
|
|
|
20
16
|
const args = process.argv.slice(2);
|
|
21
17
|
const cli = parseCliArgs(args);
|
|
22
|
-
const command = cli.positionals[0] || "run";
|
|
18
|
+
const command = cli.flags.help ? "help" : cli.positionals[0] || "run";
|
|
23
19
|
const forceBootstrap = Boolean(cli.flags.bootstrap);
|
|
24
20
|
const verbose = !cli.flags.silent;
|
|
25
21
|
const serviceRunner = Boolean(cli.flags["service-runner"]);
|
|
@@ -128,7 +124,13 @@ process.once("SIGINT", () => {
|
|
|
128
124
|
shutdown(0);
|
|
129
125
|
});
|
|
130
126
|
|
|
127
|
+
async function bootstrapIfNeeded(options) {
|
|
128
|
+
const bootstrap = await import("./runtime/bootstrap.js");
|
|
129
|
+
return bootstrap.bootstrapIfNeeded(options);
|
|
130
|
+
}
|
|
131
|
+
|
|
131
132
|
async function startRuntimeApp() {
|
|
133
|
+
const { createApp } = await import("./runtime/create-app.js");
|
|
132
134
|
const app = await createApp({
|
|
133
135
|
logger,
|
|
134
136
|
runtimeOverrides,
|
|
@@ -142,6 +144,7 @@ async function startRuntimeApp() {
|
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
async function startBackgroundService() {
|
|
147
|
+
const { applyRuntimeOverrides } = await import("./runtime/create-app.js");
|
|
145
148
|
const persistedConfig = await loadConfig();
|
|
146
149
|
applyRuntimeOverrides(persistedConfig, runtimeOverrides);
|
|
147
150
|
const result = await startService({ verbose, cliArgs: toServiceRunnerArgs(cli.nestedFlags) });
|
|
@@ -155,6 +158,7 @@ async function startBackgroundService() {
|
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
async function restartBackgroundService() {
|
|
161
|
+
const { applyRuntimeOverrides } = await import("./runtime/create-app.js");
|
|
158
162
|
const persistedConfig = await loadConfig();
|
|
159
163
|
applyRuntimeOverrides(persistedConfig, runtimeOverrides);
|
|
160
164
|
const result = await restartService({
|
|
@@ -222,8 +226,37 @@ async function runForeground() {
|
|
|
222
226
|
}
|
|
223
227
|
}
|
|
224
228
|
|
|
229
|
+
function printHelp() {
|
|
230
|
+
console.log([
|
|
231
|
+
"Usage: arisa [command] [options]",
|
|
232
|
+
"",
|
|
233
|
+
"Commands:",
|
|
234
|
+
" run Run Arisa in the foreground (default)",
|
|
235
|
+
" tui Open the terminal interface",
|
|
236
|
+
" start Start the background service",
|
|
237
|
+
" stop Stop the background service",
|
|
238
|
+
" restart Restart the background service",
|
|
239
|
+
" status Show background service status",
|
|
240
|
+
" log Show background service logs",
|
|
241
|
+
" flush Remove Arisa state while stopped",
|
|
242
|
+
" slave Manage a Slave host",
|
|
243
|
+
" help Show this help",
|
|
244
|
+
"",
|
|
245
|
+
"Options:",
|
|
246
|
+
" --help Show this help",
|
|
247
|
+
" --silent Reduce runtime logging",
|
|
248
|
+
" --bootstrap Reopen interactive setup"
|
|
249
|
+
].join("\n"));
|
|
250
|
+
}
|
|
251
|
+
|
|
225
252
|
async function main() {
|
|
253
|
+
if (command === "help") {
|
|
254
|
+
printHelp();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
226
258
|
if (slaveCommand) {
|
|
259
|
+
const { runSlaveCli } = await import("./runtime/slave-cli.js");
|
|
227
260
|
const result = await runSlaveCli({
|
|
228
261
|
positionals: cli.positionals.slice(1),
|
|
229
262
|
flags: cli.flags,
|
|
@@ -265,6 +298,7 @@ async function main() {
|
|
|
265
298
|
}
|
|
266
299
|
|
|
267
300
|
if (command === "tui") {
|
|
301
|
+
const { runTui } = await import("./runtime/tui.js");
|
|
268
302
|
await runTui({ logger });
|
|
269
303
|
return;
|
|
270
304
|
}
|
|
@@ -328,6 +362,12 @@ async function main() {
|
|
|
328
362
|
return;
|
|
329
363
|
}
|
|
330
364
|
|
|
365
|
+
if (command !== "run") {
|
|
366
|
+
const error = new Error(`Unknown Arisa command: ${command}`);
|
|
367
|
+
error.code = "ARISA_UNKNOWN_COMMAND";
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
|
|
331
371
|
await runForeground();
|
|
332
372
|
}
|
|
333
373
|
|
package/src/platform/paths.js
CHANGED
|
@@ -25,7 +25,8 @@ export function createIpcSocketPath({ homeDir = arisaHomeDir, platform = process
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export const arisaIpcSocketFile = createIpcSocketPath();
|
|
28
|
-
export const tasksFile = path.join(stateDir, "tasks.json");
|
|
28
|
+
export const tasksFile = path.join(stateDir, "tasks.json"); // Legacy migration source; retained unchanged.
|
|
29
|
+
export const tasksDatabaseFile = path.join(stateDir, "tasks.sqlite");
|
|
29
30
|
export const toolsDir = path.join(arisaHomeDir, "tools");
|
|
30
31
|
export const chatsDir = path.join(arisaHomeDir, "chats");
|
|
31
32
|
export const toolStateDir = path.join(stateDir, "tools");
|
|
@@ -46,10 +47,15 @@ export function getChatArtifactsDir(chatId) {
|
|
|
46
47
|
return path.join(getChatDir(chatId), "artifacts");
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
// Legacy JSON index; retained unchanged as a migration backup.
|
|
49
51
|
export function getChatArtifactsIndexFile(chatId) {
|
|
50
52
|
return path.join(getChatDir(chatId), "state", "artifacts.json");
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
export function getChatArtifactsDatabaseFile(chatId) {
|
|
56
|
+
return path.join(getChatDir(chatId), "state", "artifacts.sqlite");
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
export function getChatSessionSeedFile(chatId) {
|
|
54
60
|
return path.join(getChatDir(chatId), "state", "session-seed.jsonl");
|
|
55
61
|
}
|
|
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { readRecentLogLines } from "./log-viewer.js";
|
|
5
|
-
import { arisaPackageDir, serviceLogFile, stateDir
|
|
5
|
+
import { arisaPackageDir, serviceLogFile, stateDir } from "../platform/paths.js";
|
|
6
6
|
|
|
7
7
|
export const workerRecoveryReportFile = path.join(stateDir, "worker-recovery-report.json");
|
|
8
8
|
|
|
@@ -70,9 +70,12 @@ export function summarizeRecoveryEvidence(lines, report) {
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
async function interruptedTaskCount(report, file
|
|
73
|
+
async function interruptedTaskCount(report, file) {
|
|
74
74
|
try {
|
|
75
|
-
|
|
75
|
+
// Explicit JSON input is retained for offline reports. Runtime reads the live DB.
|
|
76
|
+
const document = file
|
|
77
|
+
? JSON.parse(await readFile(file, "utf8"))
|
|
78
|
+
: await new (await import("../core/tasks/task-store.js")).TaskStore().list();
|
|
76
79
|
const tasks = Array.isArray(document) ? document : document.tasks || [];
|
|
77
80
|
const occurredAt = new Date(report.occurredAt).getTime();
|
|
78
81
|
return tasks.filter((task) => {
|
|
@@ -98,7 +101,7 @@ async function runtimeVersion() {
|
|
|
98
101
|
export async function loadWorkerRecoveryReport({
|
|
99
102
|
reportFile = workerRecoveryReportFile,
|
|
100
103
|
logFile = serviceLogFile,
|
|
101
|
-
taskFile
|
|
104
|
+
taskFile,
|
|
102
105
|
readLines = readRecentLogLines,
|
|
103
106
|
getVersion = runtimeVersion
|
|
104
107
|
} = {}) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getErrorMessage } from "../../core/agent/auth-flow.js";
|
|
2
2
|
import { resolveChatModel, resolveChatSpeed, resolveChatThinkingLevel } from "../../core/agent/model-selection.js";
|
|
3
3
|
import { clampModelThinkingLevel, listModelThinkingLevels, modelSupportsThinking } from "../../core/agent/pi-runtime.js";
|
|
4
|
-
import { modelSupportsSpeed } from "../../core/agent/model-speed.js";
|
|
4
|
+
import { clampModelSpeed, modelSupportsSpeed } from "../../core/agent/model-speed.js";
|
|
5
5
|
import { parseEffortPickerAction, parseModelPickerAction, parseSpeedPickerAction } from "./model-picker.js";
|
|
6
6
|
|
|
7
7
|
export async function closeModelPicker(ctx, { messageText, callbackText }) {
|
|
@@ -181,9 +181,10 @@ export function createTelegramModelCallbackHandler({
|
|
|
181
181
|
return;
|
|
182
182
|
}
|
|
183
183
|
if (!modelSupportsSpeed(model)) {
|
|
184
|
-
await ctx.answerCallbackQuery({ text: "This model does not support
|
|
184
|
+
await ctx.answerCallbackQuery({ text: "This model does not support fast mode.", show_alert: true });
|
|
185
185
|
return;
|
|
186
186
|
}
|
|
187
|
+
action.speed = clampModelSpeed(model, action.speed);
|
|
187
188
|
const currentSpeed = resolveChatSpeed(config, modelChatId);
|
|
188
189
|
if (action.speed === currentSpeed) {
|
|
189
190
|
await closeModelPicker(ctx, {
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
selectChatThinkingLevel
|
|
9
9
|
} from "../../core/agent/model-selection.js";
|
|
10
10
|
import { clampModelThinkingLevel, createPiRuntime, listModelThinkingLevels, listProviderModels, modelSupportsThinking } from "../../core/agent/pi-runtime.js";
|
|
11
|
-
import { clampModelSpeed,
|
|
11
|
+
import { clampModelSpeed, listModelSpeeds, modelSupportsSpeed } from "../../core/agent/model-speed.js";
|
|
12
12
|
import { buildEffortPicker, buildModelPicker, buildSpeedPicker, reverseModelOrder } from "./model-picker.js";
|
|
13
13
|
|
|
14
14
|
function chatKey(chatId) {
|
|
@@ -89,12 +89,12 @@ export function createTelegramModelControls({ config, saveConfig, agentManager,
|
|
|
89
89
|
const model = models.find((item) => item.id === resolveChatModel(config, route.sessionId));
|
|
90
90
|
if (!model) throw new Error(`Model not found for provider ${agentConfig.provider}`);
|
|
91
91
|
if (!modelSupportsSpeed(model)) {
|
|
92
|
-
return editOrReplyText(ctx, `${model.provider}/${model.id} does not support
|
|
92
|
+
return editOrReplyText(ctx, `${model.provider}/${model.id} does not support fast mode.`);
|
|
93
93
|
}
|
|
94
94
|
const picker = buildSpeedPicker({
|
|
95
95
|
provider: model.provider,
|
|
96
96
|
modelId: model.id,
|
|
97
|
-
speeds:
|
|
97
|
+
speeds: listModelSpeeds(model),
|
|
98
98
|
selectedSpeed: resolveChatSpeed(config, route.sessionId)
|
|
99
99
|
});
|
|
100
100
|
return editOrReplyPicker(ctx, picker);
|
|
@@ -37,7 +37,7 @@ export function parseEffortPickerAction(data) {
|
|
|
37
37
|
|
|
38
38
|
export function parseSpeedPickerAction(data) {
|
|
39
39
|
if (data === "noop:page") return { type: "noop", value: null };
|
|
40
|
-
const speed = /^speed:(1(?:\.5)
|
|
40
|
+
const speed = /^speed:(1(?:\.5)?|2)$/.exec(String(data || ""));
|
|
41
41
|
return speed ? { type: "speed", speed: Number(speed[1]) } : null;
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -72,10 +72,11 @@ export function createTelegramTaskDispatcher({
|
|
|
72
72
|
const agentTimeoutMs = boundedTimeout(taskTimeouts.agentTimeoutMs, 15 * 60_000);
|
|
73
73
|
const eventTimeoutMs = boundedTimeout(taskTimeouts.eventTimeoutMs, 5 * 60_000);
|
|
74
74
|
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
const runHeadlessTool = (toolName, chatId, args) => agentManager.runTool({
|
|
76
|
+
name: toolName,
|
|
77
|
+
request: { args },
|
|
78
|
+
chatId
|
|
79
|
+
});
|
|
79
80
|
|
|
80
81
|
function throwToolFailure(result, toolName, fallbackResolution) {
|
|
81
82
|
const error = new Error(result?.error || `${toolName} failed`);
|
|
@@ -98,11 +99,10 @@ export function createTelegramTaskDispatcher({
|
|
|
98
99
|
async function dispatchAgentTask(task, chatId) {
|
|
99
100
|
if (!task.payload.prompt) throw new NonRetryableTaskError("agent_task missing prompt");
|
|
100
101
|
if (task.authBlock?.toolName) {
|
|
101
|
-
const probe = await
|
|
102
|
+
const probe = await runHeadlessTool(
|
|
102
103
|
task.authBlock.toolName,
|
|
103
104
|
chatId,
|
|
104
|
-
task.authBlock.probeArgs || {}
|
|
105
|
-
`authentication probe ${task.authBlock.toolName}`
|
|
105
|
+
task.authBlock.probeArgs || {}
|
|
106
106
|
);
|
|
107
107
|
if (probe?.ok === false) throwToolFailure(probe, task.authBlock.toolName, task.authBlock);
|
|
108
108
|
logger?.log("tasks", `authentication restored for ${task.authBlock.toolName} (task ${task.id})`);
|
|
@@ -156,7 +156,7 @@ export function createTelegramTaskDispatcher({
|
|
|
156
156
|
if (!toolName) throw new NonRetryableTaskError("poll_tool missing toolName");
|
|
157
157
|
logger?.log("tasks", `polling tool ${toolName} (task ${task.id}) for chat ${chatId}`);
|
|
158
158
|
|
|
159
|
-
const runTool = (args) =>
|
|
159
|
+
const runTool = (args) => runHeadlessTool(toolName, chatId, args);
|
|
160
160
|
|
|
161
161
|
if (task.authBlock) {
|
|
162
162
|
const probe = await runTool(task.authBlock.probeArgs || {});
|
|
@@ -25,6 +25,54 @@ test("interactive turns run before queued background turns without overlapping",
|
|
|
25
25
|
assert.equal(coordinator.diagnostic().completed, 3);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
test("reserves a quiet window for interactive follow-ups before background work", async (t) => {
|
|
29
|
+
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
30
|
+
let now = 1_000;
|
|
31
|
+
const coordinator = new AgentTurnCoordinator({
|
|
32
|
+
config: { interactiveQuietMs: 100 },
|
|
33
|
+
now: () => now
|
|
34
|
+
});
|
|
35
|
+
const releaseFirstInteractive = await coordinator.acquire({ priority: "interactive", label: "first" });
|
|
36
|
+
releaseFirstInteractive();
|
|
37
|
+
|
|
38
|
+
let backgroundStarted = false;
|
|
39
|
+
const background = coordinator.acquire({ priority: "background", label: "background" }).then((release) => {
|
|
40
|
+
backgroundStarted = true;
|
|
41
|
+
return release;
|
|
42
|
+
});
|
|
43
|
+
assert.equal(backgroundStarted, false);
|
|
44
|
+
|
|
45
|
+
now = 1_050;
|
|
46
|
+
const releaseFollowUp = await coordinator.acquire({ priority: "interactive", label: "follow-up" });
|
|
47
|
+
assert.equal(coordinator.diagnostic().active.label, "follow-up");
|
|
48
|
+
releaseFollowUp();
|
|
49
|
+
await Promise.resolve();
|
|
50
|
+
assert.equal(backgroundStarted, false);
|
|
51
|
+
|
|
52
|
+
now = 1_150;
|
|
53
|
+
t.mock.timers.tick(100);
|
|
54
|
+
const releaseBackground = await background;
|
|
55
|
+
assert.equal(backgroundStarted, true);
|
|
56
|
+
releaseBackground();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reports wait metrics separately for interactive and background turns", async () => {
|
|
60
|
+
let now = 1_000;
|
|
61
|
+
const coordinator = new AgentTurnCoordinator({ now: () => now });
|
|
62
|
+
const releaseActive = await coordinator.acquire({ priority: "background" });
|
|
63
|
+
const interactive = coordinator.acquire({ priority: "interactive" });
|
|
64
|
+
now = 1_025;
|
|
65
|
+
releaseActive();
|
|
66
|
+
const releaseInteractive = await interactive;
|
|
67
|
+
releaseInteractive();
|
|
68
|
+
|
|
69
|
+
const diagnostic = coordinator.diagnostic();
|
|
70
|
+
assert.equal(diagnostic.priorities.background.completed, 1);
|
|
71
|
+
assert.equal(diagnostic.priorities.interactive.completed, 1);
|
|
72
|
+
assert.equal(diagnostic.priorities.interactive.maxWaitMs, 25);
|
|
73
|
+
assert.equal(diagnostic.priorities.interactive.averageWaitMs, 25);
|
|
74
|
+
});
|
|
75
|
+
|
|
28
76
|
test("background turns expire safely before execution when their queue TTL elapses", async (t) => {
|
|
29
77
|
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
30
78
|
const coordinator = new AgentTurnCoordinator();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, open, rm } from "node:fs/promises";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
|
|
8
|
+
test("migrates a 100 MiB history and repeatedly accesses it with a 48 MiB heap", { timeout: 120_000 }, async (t) => {
|
|
9
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "artifact-memory-"));
|
|
10
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
11
|
+
const options = {chatId:"test",legacyFile:path.join(root,"artifacts.json"),databaseFile:path.join(root,"artifacts.sqlite")};
|
|
12
|
+
const file = await open(options.legacyFile, "wx", 0o600);
|
|
13
|
+
try {
|
|
14
|
+
await file.write("[");
|
|
15
|
+
for (let i = 0; i < 1024; i++) {
|
|
16
|
+
await file.write((i ? "," : "") + JSON.stringify({id:String(i),chatId:"test",text:"x".repeat(100*1024)}));
|
|
17
|
+
}
|
|
18
|
+
await file.write("]");
|
|
19
|
+
} finally { await file.close(); }
|
|
20
|
+
const moduleUrl = new URL("../src/core/artifacts/artifact-index.js", import.meta.url).href;
|
|
21
|
+
const code = `
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import {withArtifactIndex,getArtifact,appendArtifact,listRecentArtifacts} from ${JSON.stringify(moduleUrl)};
|
|
24
|
+
const f=${JSON.stringify(options)};
|
|
25
|
+
const start=performance.now();
|
|
26
|
+
await withArtifactIndex(f,db=>assert.equal(getArtifact(db,'0').text.length,102400));
|
|
27
|
+
const migrated=performance.now();
|
|
28
|
+
for(let i=0;i<100;i++) {
|
|
29
|
+
await withArtifactIndex(f,db=>appendArtifact(db,{id:'new-'+i,chatId:'test',text:'small'}));
|
|
30
|
+
await withArtifactIndex(f,db=>assert.equal(getArtifact(db,String(i)).text.length,102400));
|
|
31
|
+
await withArtifactIndex(f,db=>assert.equal(listRecentArtifacts(db,20).length,20));
|
|
32
|
+
}
|
|
33
|
+
await withArtifactIndex(f,db=>assert.equal(db.prepare('SELECT count(*) AS n FROM artifacts').get().n,1124));
|
|
34
|
+
console.log(JSON.stringify({migrationMs:Math.round(migrated-start),operationsMs:Math.round(performance.now()-migrated),maxRssKiB:process.resourceUsage().maxRSS,heapMiB:Math.round(process.memoryUsage().heapUsed/1048576)}));
|
|
35
|
+
`;
|
|
36
|
+
const result = await new Promise((resolve, reject) => {
|
|
37
|
+
const child = spawn(process.execPath, ["--max-old-space-size=48", "--input-type=module", "-e", code], {stdio:["ignore","pipe","pipe"]});
|
|
38
|
+
let stdout = "", stderr = "";
|
|
39
|
+
child.stdout.on("data", data => {stdout += data;});
|
|
40
|
+
child.stderr.on("data", data => {stderr += data;});
|
|
41
|
+
child.on("error", reject);
|
|
42
|
+
child.on("exit", code => code === 0 ? resolve(JSON.parse(stdout)) : reject(new Error(stderr)));
|
|
43
|
+
});
|
|
44
|
+
t.diagnostic(JSON.stringify(result));
|
|
45
|
+
assert.ok(result.heapMiB < 48);
|
|
46
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, writeFile, readFile, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { readLegacyArtifacts } from "../src/core/artifacts/legacy-artifact-reader.js";
|
|
9
|
+
import { withArtifactIndex, getArtifact, appendArtifact, listRecentArtifacts } from "../src/core/artifacts/artifact-index.js";
|
|
10
|
+
|
|
11
|
+
async function fixture(t) {
|
|
12
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "artifact-migration-"));
|
|
13
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
14
|
+
return { chatId: "test", legacyFile: path.join(root, "artifacts.json"), databaseFile: path.join(root, "artifacts.sqlite") };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const artifacts = [
|
|
18
|
+
{ id: "a", chatId: "test", kind: "text", text: 'ñ🙂[\\\"}]\n', metadata: { nested: [{ x: true }] } },
|
|
19
|
+
{ id: "b", chatId: "test", kind: "document", path: "/unchanged/report.pdf", source: { type: "test" } }
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
test("legacy parser handles UTF-8, escaping and every token crossing chunk boundaries", async (t) => {
|
|
23
|
+
const f = await fixture(t);
|
|
24
|
+
await writeFile(f.legacyFile, JSON.stringify(artifacts, null, 2));
|
|
25
|
+
for (const highWaterMark of [1, 2, 7, 64]) {
|
|
26
|
+
const result = [];
|
|
27
|
+
for await (const item of readLegacyArtifacts(f.legacyFile, { highWaterMark })) result.push(item);
|
|
28
|
+
assert.deepEqual(result, artifacts);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("migration preserves all fields, order and original bytes; runs only once", async (t) => {
|
|
33
|
+
const f = await fixture(t);
|
|
34
|
+
const original = JSON.stringify(artifacts, null, 2);
|
|
35
|
+
await writeFile(f.legacyFile, original);
|
|
36
|
+
assert.deepEqual(await withArtifactIndex(f, db => listRecentArtifacts(db, 20)), artifacts.toReversed());
|
|
37
|
+
assert.equal(await readFile(f.legacyFile, "utf8"), original);
|
|
38
|
+
const next = { id: "c", chatId: "test", text: "new" };
|
|
39
|
+
await withArtifactIndex(f, db => appendArtifact(db, next));
|
|
40
|
+
assert.deepEqual(await withArtifactIndex(f, db => getArtifact(db, "a")), artifacts[0]);
|
|
41
|
+
assert.deepEqual(await withArtifactIndex(f, db => listRecentArtifacts(db, 20)), [next, ...artifacts.toReversed()]);
|
|
42
|
+
assert.equal((await stat(f.databaseFile)).mode & 0o777, 0o600);
|
|
43
|
+
const db = new DatabaseSync(f.databaseFile);
|
|
44
|
+
assert.equal(db.prepare("PRAGMA integrity_check").get().integrity_check, "ok");
|
|
45
|
+
db.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("invalid migrations roll back completely and can be retried after repair", async (t) => {
|
|
49
|
+
const f = await fixture(t);
|
|
50
|
+
for (const broken of ['{}', '[', '[{}]', '[null]', '[1]', '[{"id":"a","chatId":"test"},]', JSON.stringify(artifacts).slice(0, -1), JSON.stringify(artifacts) + 'x', JSON.stringify([artifacts[0], artifacts[0]]), JSON.stringify([{...artifacts[0],chatId:"other"}])]) {
|
|
51
|
+
await writeFile(f.legacyFile, broken);
|
|
52
|
+
await assert.rejects(withArtifactIndex(f, () => {}), /Artifact index is unreadable/);
|
|
53
|
+
assert.equal(await readFile(f.legacyFile, "utf8"), broken);
|
|
54
|
+
const db = new DatabaseSync(f.databaseFile);
|
|
55
|
+
assert.equal(db.prepare("PRAGMA user_version").get().user_version, 0);
|
|
56
|
+
assert.equal(db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE name='artifacts'").get().n, 0);
|
|
57
|
+
db.close();
|
|
58
|
+
}
|
|
59
|
+
await writeFile(f.legacyFile, JSON.stringify(artifacts));
|
|
60
|
+
assert.deepEqual(await withArtifactIndex(f, db => getArtifact(db, "a")), artifacts[0]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("separate processes migrate and append without lost writes", async (t) => {
|
|
64
|
+
const f = await fixture(t);
|
|
65
|
+
await writeFile(f.legacyFile, JSON.stringify(artifacts));
|
|
66
|
+
const moduleUrl = new URL("../src/core/artifacts/artifact-index.js", import.meta.url).href;
|
|
67
|
+
await Promise.all(Array.from({length: 3}, (_, n) => new Promise((resolve, reject) => {
|
|
68
|
+
const child = spawn(process.execPath, ["--input-type=module", "-e", `
|
|
69
|
+
import {withArtifactIndex,appendArtifact} from ${JSON.stringify(moduleUrl)};
|
|
70
|
+
for(let i=0;i<20;i++) await withArtifactIndex(${JSON.stringify(f)}, db => appendArtifact(db, {id:'${n}-'+i,chatId:'test',text:'ok'}));
|
|
71
|
+
`], {stdio:["ignore","ignore","pipe"]});
|
|
72
|
+
let stderr = "";
|
|
73
|
+
child.stderr.on("data", data => {stderr += data;});
|
|
74
|
+
child.on("error", reject);
|
|
75
|
+
child.on("exit", code => code === 0 ? resolve() : reject(new Error(stderr)));
|
|
76
|
+
})));
|
|
77
|
+
assert.equal((await withArtifactIndex(f, db => listRecentArtifacts(db, 100))).length, 62);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("recent queries reject oversized results instead of materializing the whole selection", async (t) => {
|
|
81
|
+
const f = await fixture(t);
|
|
82
|
+
await withArtifactIndex(f, db => {
|
|
83
|
+
for (let i = 0; i < 20; i++) appendArtifact(db, {id: String(i), chatId: 'test', text: 'x'.repeat(1024 * 1024)});
|
|
84
|
+
});
|
|
85
|
+
await assert.rejects(withArtifactIndex(f, db => listRecentArtifacts(db, 20)), /exceed 16 MiB/);
|
|
86
|
+
assert.equal((await withArtifactIndex(f, db => listRecentArtifacts(db, 2))).length, 2);
|
|
87
|
+
assert.deepEqual(await withArtifactIndex(f, db => listRecentArtifacts(db, 0)), []);
|
|
88
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
@@ -57,7 +57,7 @@ test("serializes 100 concurrent artifact writes across store instances", async (
|
|
|
57
57
|
})
|
|
58
58
|
)));
|
|
59
59
|
|
|
60
|
-
const persisted =
|
|
60
|
+
const persisted = await new ArtifactStore().forChat(chatId).listRecent(100);
|
|
61
61
|
assert.equal(persisted.length, 100);
|
|
62
62
|
assert.equal(new Set(persisted.map((artifact) => artifact.id)).size, 100);
|
|
63
63
|
assert.deepEqual(
|
|
@@ -72,7 +72,7 @@ test("refuses to overwrite a corrupt artifact index", async () => {
|
|
|
72
72
|
await resetHome();
|
|
73
73
|
const chatId = "corrupt-chat";
|
|
74
74
|
const indexFile = getChatArtifactsIndexFile(chatId);
|
|
75
|
-
await
|
|
75
|
+
await mkdir(path.dirname(indexFile), { recursive: true });
|
|
76
76
|
await writeFile(indexFile, "{truncated", "utf8");
|
|
77
77
|
|
|
78
78
|
await assert.rejects(
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const entry = path.join(packageDir, "src", "index.js");
|
|
13
|
+
|
|
14
|
+
async function isolatedEnvironment() {
|
|
15
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "arisa-cli-command-"));
|
|
16
|
+
return {
|
|
17
|
+
home,
|
|
18
|
+
env: { ...process.env, ARISA_HOME: home }
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("prints CLI help without starting the runtime", async (t) => {
|
|
23
|
+
const isolated = await isolatedEnvironment();
|
|
24
|
+
t.after(() => rm(isolated.home, { recursive: true, force: true }));
|
|
25
|
+
|
|
26
|
+
const { stdout, stderr } = await execFileAsync(process.execPath, [entry, "--help"], {
|
|
27
|
+
cwd: packageDir,
|
|
28
|
+
env: isolated.env
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
assert.match(stdout, /^Usage: arisa/m);
|
|
32
|
+
assert.match(stdout, /status\s+Show background service status/);
|
|
33
|
+
assert.equal(stderr, "");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("rejects unknown CLI commands instead of starting the runtime", async (t) => {
|
|
37
|
+
const isolated = await isolatedEnvironment();
|
|
38
|
+
t.after(() => rm(isolated.home, { recursive: true, force: true }));
|
|
39
|
+
|
|
40
|
+
await assert.rejects(
|
|
41
|
+
() => execFileAsync(process.execPath, [entry, "doctor"], {
|
|
42
|
+
cwd: packageDir,
|
|
43
|
+
env: isolated.env
|
|
44
|
+
}),
|
|
45
|
+
(error) => {
|
|
46
|
+
assert.equal(error.code, 1);
|
|
47
|
+
assert.match(error.stderr, /Unknown Arisa command: doctor/);
|
|
48
|
+
assert.doesNotMatch(error.stderr, /loading config|validating Pi session/);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
test("status runs with a 24 MiB heap without loading the agent, SQLite or TUI", async (t) => {
|
|
12
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "arisa-cli-memory-"));
|
|
13
|
+
t.after(() => rm(home, { recursive: true, force: true }));
|
|
14
|
+
const entry = new URL("../src/index.js", import.meta.url);
|
|
15
|
+
const {stdout, stderr} = await exec(process.execPath, ["--max-old-space-size=24", entry.pathname, "status"], {
|
|
16
|
+
env: {...process.env, ARISA_HOME: home}, timeout: 15_000
|
|
17
|
+
});
|
|
18
|
+
assert.match(stdout, /Arisa is not running/);
|
|
19
|
+
assert.equal(stderr, "");
|
|
20
|
+
const source = await readFile(entry, "utf8");
|
|
21
|
+
assert.doesNotMatch(source, /^import .*from .*runtime\/(create-app|bootstrap|tui|slave-cli)\.js/m);
|
|
22
|
+
});
|
|
@@ -21,7 +21,10 @@ const policy = {
|
|
|
21
21
|
restartBackoffMaxMs: 40,
|
|
22
22
|
startupTimeoutMs: 2_000,
|
|
23
23
|
stopTimeoutMs: 300,
|
|
24
|
-
queuePollIntervalMs: 10
|
|
24
|
+
queuePollIntervalMs: 10,
|
|
25
|
+
journalRetentionMs: 24 * 60 * 60_000,
|
|
26
|
+
journalMaxCompleted: 128,
|
|
27
|
+
journalSweepIntervalMs: 60_000
|
|
25
28
|
};
|
|
26
29
|
|
|
27
30
|
await mkdir(path.join(homeDir, "state"), { recursive: true });
|
|
@@ -51,8 +54,10 @@ const {
|
|
|
51
54
|
const { submitDaemonControl: directSubmitDaemonControl } = await import("../src/core/tools/daemon-client.js");
|
|
52
55
|
const {
|
|
53
56
|
DAEMON_EVENT_TYPES: directDaemonEventTypes,
|
|
54
|
-
DAEMON_PROTOCOL_VERSION: directDaemonProtocolVersion
|
|
57
|
+
DAEMON_PROTOCOL_VERSION: directDaemonProtocolVersion,
|
|
58
|
+
daemonJobPaths
|
|
55
59
|
} = await import("../src/core/tools/daemon-protocol.js");
|
|
60
|
+
const { maintainDaemonJournal } = await import("../src/core/tools/daemon-journal.js");
|
|
56
61
|
const { createToolProcessSupervisor, formatDaemonOutcome } = await import("../src/runtime/tool-process-supervisor.js");
|
|
57
62
|
const { superviseDaemon } = await import("../src/core/tools/daemon-health.js");
|
|
58
63
|
const { ToolRegistry } = await import("../src/core/tools/tool-registry.js");
|
|
@@ -127,7 +132,7 @@ test("streams ordered daemon events and persists the terminal result", async ()
|
|
|
127
132
|
assert.deepEqual(output, { echo: "done" });
|
|
128
133
|
assert.deepEqual(events.map((event) => event.type), ["accepted", "progress", "chunk", "completed"]);
|
|
129
134
|
assert.deepEqual(events.map((event) => event.sequence), [1, 2, 3, 4]);
|
|
130
|
-
assert.ok((await readdir(runtime.paths.
|
|
135
|
+
assert.ok((await readdir(runtime.paths.resultsDir)).some((file) => file.endsWith(".result.json")));
|
|
131
136
|
await runtime.stop();
|
|
132
137
|
});
|
|
133
138
|
|
|
@@ -140,7 +145,7 @@ test("cancels a timed-out job without restarting the shared daemon", async () =>
|
|
|
140
145
|
);
|
|
141
146
|
|
|
142
147
|
const terminal = await waitFor(async () => {
|
|
143
|
-
const result = await readJson(
|
|
148
|
+
const result = await readJson(daemonJobPaths(runtime.paths, jobId).result, null);
|
|
144
149
|
return result?.terminal || null;
|
|
145
150
|
});
|
|
146
151
|
assert.equal(terminal.type, "failed");
|
|
@@ -192,6 +197,41 @@ test("recovers queued and accepted journal records after daemon start", async ()
|
|
|
192
197
|
await runtime.stop();
|
|
193
198
|
});
|
|
194
199
|
|
|
200
|
+
test("migrates and bounds completed daemon journal records outside the active queue", async () => {
|
|
201
|
+
const paths = daemonPaths({ toolName: "journal-test", scope: { type: "chat", chatId: "303" } });
|
|
202
|
+
await maintainDaemonJournal(paths);
|
|
203
|
+
for (let index = 0; index < 5; index += 1) {
|
|
204
|
+
await writeJson(path.join(paths.commandsDir, `legacy-${index}.result.json`), {
|
|
205
|
+
id: `legacy-${index}`,
|
|
206
|
+
terminal: { version: 1, jobId: `legacy-${index}`, type: "completed", sequence: 2, payload: { output: { index } } }
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
await writeJson(path.join(paths.commandsDir, "legacy-active.request.json"), {
|
|
210
|
+
id: "legacy-active",
|
|
211
|
+
status: "queued",
|
|
212
|
+
payload: { value: "active" }
|
|
213
|
+
});
|
|
214
|
+
await writeJson(path.join(paths.commandsDir, "legacy-active.result.json"), {
|
|
215
|
+
id: "legacy-active",
|
|
216
|
+
terminal: { version: 1, jobId: "legacy-active", type: "completed", sequence: 2, payload: { output: { active: true } } }
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const journal = await maintainDaemonJournal(paths, {
|
|
220
|
+
journalRetentionMs: 60_000,
|
|
221
|
+
journalMaxCompleted: 2
|
|
222
|
+
});
|
|
223
|
+
const activeFiles = await readdir(paths.commandsDir);
|
|
224
|
+
const resultFiles = await readdir(paths.resultsDir);
|
|
225
|
+
|
|
226
|
+
assert.equal(journal.active, 1);
|
|
227
|
+
assert.equal(journal.completed, 3);
|
|
228
|
+
assert.equal(journal.migrated, 3);
|
|
229
|
+
assert.equal(journal.pruned, 3);
|
|
230
|
+
assert.deepEqual(activeFiles, ["legacy-active.request.json"]);
|
|
231
|
+
assert.equal(resultFiles.includes("legacy-active.result.json"), true);
|
|
232
|
+
assert.equal(resultFiles.length, 3);
|
|
233
|
+
});
|
|
234
|
+
|
|
195
235
|
test("isolates daemon process files and context by chat scope", async () => {
|
|
196
236
|
const first = runtimeFor({ type: "chat", chatId: "101" });
|
|
197
237
|
const second = runtimeFor({ type: "chat", chatId: "202" });
|