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.
Files changed (37) hide show
  1. package/LOW-MEMORY.md +49 -0
  2. package/README.md +2 -1
  3. package/package.json +7 -8
  4. package/src/core/agent/agent-turn-coordinator.js +59 -3
  5. package/src/core/agent/model-selection.js +4 -3
  6. package/src/core/agent/model-speed.js +11 -3
  7. package/src/core/artifacts/artifact-index.js +107 -0
  8. package/src/core/artifacts/artifact-store.js +15 -84
  9. package/src/core/artifacts/legacy-artifact-reader.js +46 -0
  10. package/src/core/config/config-defaults.js +4 -0
  11. package/src/core/tasks/task-database.js +128 -0
  12. package/src/core/tasks/task-store.js +42 -101
  13. package/src/core/tools/daemon-journal.js +115 -0
  14. package/src/core/tools/daemon-processes.js +10 -1
  15. package/src/core/tools/daemon-protocol.js +1 -1
  16. package/src/core/tools/daemon-worker.js +37 -5
  17. package/src/index.js +45 -5
  18. package/src/platform/paths.js +7 -1
  19. package/src/runtime/worker-recovery-report.js +7 -4
  20. package/src/transport/telegram/model-callback.js +3 -2
  21. package/src/transport/telegram/model-controls.js +3 -3
  22. package/src/transport/telegram/model-picker.js +1 -1
  23. package/src/transport/telegram/task-dispatcher.js +8 -8
  24. package/test/agent-turn-coordinator.test.js +48 -0
  25. package/test/artifact-index-memory.test.js +46 -0
  26. package/test/artifact-index-migration.test.js +88 -0
  27. package/test/artifact-store.test.js +3 -3
  28. package/test/cli-command.test.js +52 -0
  29. package/test/cli-memory.test.js +22 -0
  30. package/test/daemon-runtime.test.js +44 -4
  31. package/test/model-selection.test.js +11 -2
  32. package/test/paths.test.js +2 -0
  33. package/test/pi-compaction.test.js +1 -0
  34. package/test/pi-speed-integration.test.js +9 -8
  35. package/test/task-database.test.js +130 -0
  36. package/test/task-store.test.js +10 -5
  37. 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
 
@@ -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, tasksFile } from "../platform/paths.js";
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 = tasksFile) {
73
+ async function interruptedTaskCount(report, file) {
74
74
  try {
75
- const document = JSON.parse(await readFile(file, "utf8"));
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 = tasksFile,
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 speed 1.5x.", show_alert: true });
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, MODEL_SPEEDS, modelSupportsSpeed } from "../../core/agent/model-speed.js";
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 speed 1.5x.`);
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: MODEL_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)?)$/.exec(String(data || ""));
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 runBackgroundTool = (toolName, chatId, args, label) => agentManager.runTurn({
76
- priority: "background",
77
- label
78
- }, () => agentManager.runTool({ name: toolName, request: { args }, chatId }));
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 runBackgroundTool(
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) => runBackgroundTool(toolName, chatId, args, `poll tool ${toolName}`);
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 = JSON.parse(await readFile(getChatArtifactsIndexFile(chatId), "utf8"));
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 new ArtifactStore().forChat(chatId).createText({ text: "safe", source: { type: "test" } });
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.commandsDir)).some((file) => file.endsWith(".result.json")));
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(path.join(runtime.paths.commandsDir, `${jobId}.result.json`), null);
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" });