@inetafrica/open-claudia 2.15.0 → 3.0.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/.env.example +30 -4
- package/CHANGELOG.md +12 -0
- package/README.md +87 -66
- package/bin/agent.js +44 -18
- package/bin/cli.js +30 -28
- package/bin/dream.js +5 -11
- package/bin/loopback-client.js +29 -5
- package/bin/schedule.js +19 -5
- package/bin/tool.js +23 -5
- package/bot-agent.js +5 -2350
- package/bot.js +66 -3
- package/channels/telegram/adapter.js +6 -1
- package/channels/voice/adapter.js +1 -0
- package/channels/voice/manage.js +43 -5
- package/config-dir.js +3 -11
- package/core/actions.js +149 -40
- package/core/approvals.js +136 -29
- package/core/auth-flow.js +103 -19
- package/core/config-dir.js +19 -0
- package/core/config.js +115 -69
- package/core/doctor.js +111 -57
- package/core/dream.js +219 -62
- package/core/enforcer.js +0 -0
- package/core/entities.js +2 -1
- package/core/fsutil.js +21 -4
- package/core/handlers.js +503 -217
- package/core/ideas.js +2 -1
- package/core/jobs.js +589 -72
- package/core/lessons.js +3 -2
- package/core/loopback.js +42 -6
- package/core/memory-mutation-queue.js +241 -0
- package/core/migration-backup.js +1060 -0
- package/core/pack-review.js +295 -88
- package/core/packs.js +4 -3
- package/core/persona.js +2 -1
- package/core/process-tree.js +19 -2
- package/core/provider-migration.js +39 -0
- package/core/provider-status.js +80 -0
- package/core/providers/claude-events.js +117 -0
- package/core/providers/claude-hook.js +114 -0
- package/core/providers/claude.js +296 -0
- package/core/providers/codex-events.js +121 -0
- package/core/providers/codex-hook.js +280 -0
- package/core/providers/codex.js +286 -0
- package/core/providers/contract.js +153 -0
- package/core/providers/env.js +148 -0
- package/core/providers/events.js +170 -0
- package/core/providers/hook-command.js +22 -0
- package/core/providers/index.js +240 -0
- package/core/providers/utility-policy.js +269 -0
- package/core/recall/classic.js +2 -2
- package/core/recall/discoverer.js +71 -22
- package/core/recall/metrics.js +27 -1
- package/core/recall/tuning.js +4 -1
- package/core/recall/warm-walker.js +151 -108
- package/core/recall-filter.js +86 -61
- package/core/router.js +79 -7
- package/core/run-context.js +185 -0
- package/core/runner.js +1414 -1290
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/state.js +1084 -97
- package/core/subagent.js +72 -98
- package/core/system-prompt.js +462 -279
- package/core/tool-guard.js +73 -39
- package/core/tools.js +22 -5
- package/core/transcripts.js +30 -1
- package/core/usage-log.js +19 -4
- package/core/utility-agent.js +654 -0
- package/core/web-auth.js +29 -13
- package/docs/CHANNEL_DESIGN.md +181 -0
- package/docs/MULTI_CHANNEL_PLAN.md +254 -0
- package/docs/PROVIDER_MIGRATION.md +67 -0
- package/health.js +50 -39
- package/package.json +48 -4
- package/setup.js +198 -38
- package/soul.md +39 -0
- package/test-ability-extraction.js +5 -0
- package/test-approval-async.js +63 -4
- package/test-cursor-removal.js +334 -0
- package/test-enforcer.js +70 -27
- package/test-fixtures/current-provider-parity.json +132 -0
- package/test-fixtures/fake-agent-cli.js +477 -0
- package/test-fixtures/migrations/claude-only/jobs.json +3 -0
- package/test-fixtures/migrations/claude-only/sessions.json +5 -0
- package/test-fixtures/migrations/claude-only/state.json +5 -0
- package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
- package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
- package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
- package/test-fixtures/migrations/cursor-selected/state.json +6 -0
- package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
- package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
- package/test-fixtures/migrations/dual-provider/state.json +10 -0
- package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
- package/test-fixtures/migrations/malformed-partial/state.json +1 -0
- package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
- package/test-fixtures/migrations/missing-project/jobs.json +3 -0
- package/test-fixtures/migrations/missing-project/sessions.json +3 -0
- package/test-fixtures/migrations/missing-project/state.json +4 -0
- package/test-fixtures/migrations/multi-user/jobs.json +4 -0
- package/test-fixtures/migrations/multi-user/sessions.json +4 -0
- package/test-fixtures/migrations/multi-user/state.json +6 -0
- package/test-fixtures/provider-event-samples.json +17 -0
- package/test-learning-e2e.js +5 -0
- package/test-memory-mutation-queue.js +191 -0
- package/test-provider-boot-matrix.js +399 -0
- package/test-provider-bootstrap.js +158 -0
- package/test-provider-capabilities.js +232 -0
- package/test-provider-claude.js +206 -0
- package/test-provider-codex.js +228 -0
- package/test-provider-compaction.js +371 -0
- package/test-provider-core-prompt.js +264 -0
- package/test-provider-coupling-audit.js +90 -0
- package/test-provider-dream.js +312 -0
- package/test-provider-enforcer.js +252 -0
- package/test-provider-env-isolation.js +150 -0
- package/test-provider-events.js +141 -0
- package/test-provider-fixture.js +332 -0
- package/test-provider-gateway.js +508 -0
- package/test-provider-language.js +89 -0
- package/test-provider-migration-backup.js +424 -0
- package/test-provider-pack-review.js +436 -0
- package/test-provider-parity-e2e.js +537 -0
- package/test-provider-prompt-parity.js +89 -0
- package/test-provider-recall.js +271 -0
- package/test-provider-registry.js +251 -0
- package/test-provider-resume-parity.js +41 -0
- package/test-provider-run-lifecycle.js +349 -0
- package/test-provider-runner.js +271 -0
- package/test-provider-scheduler.js +689 -0
- package/test-provider-session-commands.js +185 -0
- package/test-provider-session-history.js +205 -0
- package/test-provider-side-chat.js +337 -0
- package/test-provider-state-migration.js +316 -0
- package/test-provider-stream-decoder.js +69 -0
- package/test-provider-subagent.js +228 -0
- package/test-provider-tool-hooks.js +360 -0
- package/test-recall-discoverer.js +1 -0
- package/test-recall-engine.js +3 -3
- package/test-recall-evolution.js +18 -0
- package/test-runner-watchdog-static.js +16 -8
- package/test-single-runtime.js +35 -0
- package/test-telegram-poll-recovery.js +56 -0
- package/test-tool-guard.js +56 -0
- package/test-tools.js +19 -0
- package/test-unified-identity.js +3 -1
- package/test-usage-accounting.js +92 -20
- package/test-utility-provider-policy.js +486 -0
- package/web.js +130 -35
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const assert = require("assert");
|
|
6
|
+
const crypto = require("crypto");
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const { createClaudeProvider } = require("./core/providers/claude");
|
|
11
|
+
const { createCodexProvider } = require("./core/providers/codex");
|
|
12
|
+
const { captureRunContext } = require("./core/run-context");
|
|
13
|
+
const {
|
|
14
|
+
ACTIVE_TASK_DESCRIPTION_MAX,
|
|
15
|
+
SideChatCoordinator,
|
|
16
|
+
createSideChatPromptBuilder,
|
|
17
|
+
createSideChatRequest,
|
|
18
|
+
readRuntimeMode,
|
|
19
|
+
} = require("./core/side-chat");
|
|
20
|
+
const {
|
|
21
|
+
createSideChatQueueItem,
|
|
22
|
+
createSideChatService,
|
|
23
|
+
finalizeSideChatQueueItem,
|
|
24
|
+
shouldStartSideChat,
|
|
25
|
+
} = require("./core/runner");
|
|
26
|
+
|
|
27
|
+
const fixture = path.join(__dirname, "test-fixtures", "fake-agent-cli.js");
|
|
28
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-side-chat-"));
|
|
29
|
+
const project = path.join(root, "project-a");
|
|
30
|
+
fs.mkdirSync(project);
|
|
31
|
+
|
|
32
|
+
function providerEnv(kind, capturePath) {
|
|
33
|
+
const env = {
|
|
34
|
+
HOME: root,
|
|
35
|
+
PATH: process.env.PATH || path.dirname(process.execPath),
|
|
36
|
+
FAKE_AGENT_KIND: kind,
|
|
37
|
+
FAKE_AGENT_CAPTURE: capturePath,
|
|
38
|
+
FAKE_AGENT_TEXT: `${kind} isolated side response`,
|
|
39
|
+
FAKE_AGENT_CHUNK_MODE: "split",
|
|
40
|
+
FAKE_AGENT_FINAL_NEWLINE: "0",
|
|
41
|
+
};
|
|
42
|
+
if (kind === "claude") env.ANTHROPIC_API_KEY = "fixture-claude-presence-only";
|
|
43
|
+
else env.OPENAI_API_KEY = "fixture-openai-presence-only";
|
|
44
|
+
return env;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeProvider(kind, capturePath) {
|
|
48
|
+
const common = {
|
|
49
|
+
hookTransport: false,
|
|
50
|
+
resolveExecutable: () => fixture,
|
|
51
|
+
defaultModel: `${kind}-fixture-model`,
|
|
52
|
+
buildEnv: () => providerEnv(kind, capturePath),
|
|
53
|
+
getAuthStatus: () => ({ state: "authenticated", reason: null }),
|
|
54
|
+
};
|
|
55
|
+
return kind === "claude" ? createClaudeProvider(common) : createCodexProvider(common);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mainState(provider) {
|
|
59
|
+
return {
|
|
60
|
+
userId: "fixture-user",
|
|
61
|
+
currentSession: { name: "project-a", dir: project },
|
|
62
|
+
settings: {
|
|
63
|
+
backend: provider,
|
|
64
|
+
model: `${provider}-fixture-model`,
|
|
65
|
+
effort: null,
|
|
66
|
+
budget: null,
|
|
67
|
+
permissionMode: null,
|
|
68
|
+
worktree: false,
|
|
69
|
+
},
|
|
70
|
+
activeSessions: { "project-a": { claude: "claude-main-session", codex: "codex-main-thread" } },
|
|
71
|
+
providerSettings: {
|
|
72
|
+
claude: { model: "claude-fixture-model", permissionMode: "plan" },
|
|
73
|
+
codex: { model: "codex-fixture-model", permissionMode: "plan" },
|
|
74
|
+
},
|
|
75
|
+
sessionUsage: { turns: 7, inputTokens: 123, outputTokens: 45 },
|
|
76
|
+
lastCompactedAt: 999,
|
|
77
|
+
messageQueue: [],
|
|
78
|
+
isCompacting: false,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function captureMainRun(state, prompt, runId) {
|
|
83
|
+
return captureRunContext({
|
|
84
|
+
state,
|
|
85
|
+
prompt,
|
|
86
|
+
cwd: project,
|
|
87
|
+
replyToMsgId: `message-${runId}`,
|
|
88
|
+
opts: { purpose: "foreground" },
|
|
89
|
+
channelId: "fixture-channel",
|
|
90
|
+
canonicalUserId: state.userId,
|
|
91
|
+
runId,
|
|
92
|
+
store: {
|
|
93
|
+
adapter: { id: "telegram-fixture", type: "telegram" },
|
|
94
|
+
channelId: "fixture-channel",
|
|
95
|
+
canonicalUserId: state.userId,
|
|
96
|
+
userId: state.userId,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function providerProbe(kind) {
|
|
102
|
+
const capturePath = path.join(root, `${kind}-capture.json`);
|
|
103
|
+
const provider = makeProvider(kind, capturePath);
|
|
104
|
+
const state = mainState(kind);
|
|
105
|
+
const before = structuredClone(state);
|
|
106
|
+
const incoming = captureMainRun(state, `${kind} side question`, `${kind}-incoming`);
|
|
107
|
+
const active = captureMainRun(state, `heavy ${kind} task sk-ant-side-secret ${"x".repeat(1000)}`, `${kind}-main`);
|
|
108
|
+
const request = createSideChatRequest({
|
|
109
|
+
runContext: incoming,
|
|
110
|
+
activeRunContext: active,
|
|
111
|
+
store: { marker: `${kind}-origin-store` },
|
|
112
|
+
token: `${kind}-token`,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
assert.ok(Object.isFrozen(request));
|
|
116
|
+
assert.ok(Object.isFrozen(request.runContext));
|
|
117
|
+
assert.strictEqual(request.runContext.provider, kind);
|
|
118
|
+
assert.strictEqual(request.runContext.project.name, "project-a");
|
|
119
|
+
assert.strictEqual(request.runContext.origin.transport, "telegram");
|
|
120
|
+
assert.strictEqual(request.runContext.sessionId, null);
|
|
121
|
+
assert.strictEqual(request.runContext.resumeSessionId, null);
|
|
122
|
+
assert.strictEqual(request.runContext.continueSession, false);
|
|
123
|
+
assert.strictEqual(request.runContext.fresh, true);
|
|
124
|
+
assert.strictEqual(request.runContext.purpose, "side-chat");
|
|
125
|
+
assert.ok(request.activeTaskDescription.length <= ACTIVE_TASK_DESCRIPTION_MAX);
|
|
126
|
+
assert.ok(!request.activeTaskDescription.includes("sk-ant-side-secret"));
|
|
127
|
+
assert.match(request.activeTaskDescription, /REDACTED/);
|
|
128
|
+
|
|
129
|
+
// Switching every mutable main selector after admission must not retarget the
|
|
130
|
+
// side response or make it attach to either native main session.
|
|
131
|
+
state.settings.backend = kind === "claude" ? "codex" : "claude";
|
|
132
|
+
state.currentSession = { name: "project-b", dir: path.join(root, "project-b") };
|
|
133
|
+
state.activeSessions["project-a"].claude = "changed-claude-session";
|
|
134
|
+
state.activeSessions["project-a"].codex = "changed-codex-thread";
|
|
135
|
+
|
|
136
|
+
const forbiddenEffects = [];
|
|
137
|
+
const service = createSideChatService({
|
|
138
|
+
resolveProvider: (id) => {
|
|
139
|
+
assert.strictEqual(id, kind);
|
|
140
|
+
return provider;
|
|
141
|
+
},
|
|
142
|
+
buildCoreInstructions: () => "SIDE CORE SENTINEL",
|
|
143
|
+
persistTranscript: () => forbiddenEffects.push("transcript"),
|
|
144
|
+
persistUsage: () => forbiddenEffects.push("usage"),
|
|
145
|
+
persistSession: () => forbiddenEffects.push("session"),
|
|
146
|
+
deliver: () => forbiddenEffects.push("delivery"),
|
|
147
|
+
memoryReview: () => forbiddenEffects.push("review"),
|
|
148
|
+
compact: () => forbiddenEffects.push("compact"),
|
|
149
|
+
});
|
|
150
|
+
const result = await service.run(request);
|
|
151
|
+
assert.strictEqual(result.ok, true);
|
|
152
|
+
assert.strictEqual(result.provider, kind);
|
|
153
|
+
assert.strictEqual(result.purpose, "side-chat");
|
|
154
|
+
assert.strictEqual(result.text, `${kind} isolated side response`);
|
|
155
|
+
assert.deepStrictEqual(forbiddenEffects, []);
|
|
156
|
+
|
|
157
|
+
const capture = JSON.parse(fs.readFileSync(capturePath, "utf8"));
|
|
158
|
+
const argvText = capture.argv.join("\n");
|
|
159
|
+
assert.ok(!argvText.includes("claude-main-session"));
|
|
160
|
+
assert.ok(!argvText.includes("codex-main-thread"));
|
|
161
|
+
assert.ok(!argvText.includes("changed-claude-session"));
|
|
162
|
+
assert.ok(!argvText.includes("changed-codex-thread"));
|
|
163
|
+
const expectedBundle = await createSideChatPromptBuilder(() => "SIDE CORE SENTINEL")(
|
|
164
|
+
request.originalMessage,
|
|
165
|
+
{ runContext: request.runContext, provider },
|
|
166
|
+
);
|
|
167
|
+
const protectedPrompt = `${expectedBundle.dynamicContext}\n\n${expectedBundle.userPrompt}`;
|
|
168
|
+
assert.match(protectedPrompt, /side question/);
|
|
169
|
+
assert.match(protectedPrompt, /heavy .* task/);
|
|
170
|
+
assert.ok(protectedPrompt.length < 12000, "active-task context stays bounded");
|
|
171
|
+
assert.ok(!argvText.includes("side question"), "private side question is absent from argv");
|
|
172
|
+
assert.deepStrictEqual(capture.stdin, {
|
|
173
|
+
present: true,
|
|
174
|
+
bytes: Buffer.byteLength(protectedPrompt),
|
|
175
|
+
sha256: crypto.createHash("sha256").update(protectedPrompt).digest("hex"),
|
|
176
|
+
}, "side-chat fixture captures only protected prompt size/hash");
|
|
177
|
+
if (kind === "claude") {
|
|
178
|
+
assert.strictEqual(capture.envPresence.ANTHROPIC_API_KEY, true);
|
|
179
|
+
assert.strictEqual(capture.envPresence.OPENAI_API_KEY, false);
|
|
180
|
+
assert.ok(capture.argv.includes("--no-session-persistence"));
|
|
181
|
+
assert.ok(!capture.argv.includes("--resume"));
|
|
182
|
+
assert.ok(!capture.argv.includes("--continue"));
|
|
183
|
+
assert.deepStrictEqual(capture.argv.slice(capture.argv.indexOf("--permission-mode"), capture.argv.indexOf("--permission-mode") + 2), ["--permission-mode", "plan"]);
|
|
184
|
+
} else {
|
|
185
|
+
assert.strictEqual(capture.envPresence.OPENAI_API_KEY, true);
|
|
186
|
+
assert.strictEqual(capture.envPresence.ANTHROPIC_API_KEY, false);
|
|
187
|
+
assert.strictEqual(capture.envPresence.CLAUDE_CODE_OAUTH_TOKEN, false);
|
|
188
|
+
assert.strictEqual(capture.argv[0], "exec");
|
|
189
|
+
assert.notStrictEqual(capture.argv[1], "resume");
|
|
190
|
+
assert.ok(capture.argv.includes("--ephemeral"));
|
|
191
|
+
assert.deepStrictEqual(capture.argv.slice(capture.argv.indexOf("--sandbox"), capture.argv.indexOf("--sandbox") + 2), ["--sandbox", "read-only"]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const expected = structuredClone(before);
|
|
195
|
+
expected.settings.backend = state.settings.backend;
|
|
196
|
+
expected.currentSession = state.currentSession;
|
|
197
|
+
expected.activeSessions = state.activeSessions;
|
|
198
|
+
assert.deepStrictEqual(state, expected, "side execution must not mutate main state");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function deferred() {
|
|
202
|
+
let resolve;
|
|
203
|
+
const promise = new Promise((res) => { resolve = res; });
|
|
204
|
+
return { promise, resolve };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function coordinatorProbe() {
|
|
208
|
+
const state = mainState("claude");
|
|
209
|
+
const active = captureMainRun(state, "long main operation", "active-main");
|
|
210
|
+
const first = captureMainRun(state, "what is its status?", "side-one");
|
|
211
|
+
const second = captureMainRun(state, "also include tests", "side-two");
|
|
212
|
+
const gate = deferred();
|
|
213
|
+
const runs = [];
|
|
214
|
+
const deliveries = [];
|
|
215
|
+
const enqueued = [];
|
|
216
|
+
const tokens = ["token-one", "token-two"];
|
|
217
|
+
let clock = 1000;
|
|
218
|
+
const coordinator = new SideChatCoordinator({
|
|
219
|
+
tokenFactory: () => tokens.shift(),
|
|
220
|
+
now: () => clock,
|
|
221
|
+
runSideChat: async (request) => {
|
|
222
|
+
runs.push(request);
|
|
223
|
+
await gate.promise;
|
|
224
|
+
return { ok: true, text: "fresh side answer", diagnostic: null };
|
|
225
|
+
},
|
|
226
|
+
deliver: async (payload) => { deliveries.push(payload); return { ok: true }; },
|
|
227
|
+
enqueueMain: (request, store, lineage) => {
|
|
228
|
+
enqueued.push({ request, store, lineage });
|
|
229
|
+
return { queued: true, position: 1 };
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const firstPending = coordinator.respond({ runContext: first, activeRunContext: active, store: { id: "origin-one" } });
|
|
234
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
235
|
+
assert.strictEqual(runs.length, 1);
|
|
236
|
+
assert.strictEqual(coordinator.isActive("fixture-user"), true);
|
|
237
|
+
|
|
238
|
+
const busy = await coordinator.respond({ runContext: second, activeRunContext: active, store: { id: "origin-two" } });
|
|
239
|
+
assert.strictEqual(busy.status, "busy");
|
|
240
|
+
assert.strictEqual(runs.length, 1, "only one side provider run is allowed per user");
|
|
241
|
+
assert.strictEqual(deliveries.length, 1);
|
|
242
|
+
assert.strictEqual(deliveries[0].action.callbackData, "sc:q:token-two");
|
|
243
|
+
|
|
244
|
+
gate.resolve();
|
|
245
|
+
const firstResult = await firstPending;
|
|
246
|
+
assert.strictEqual(firstResult.ok, true);
|
|
247
|
+
assert.strictEqual(coordinator.isActive("fixture-user"), false);
|
|
248
|
+
assert.strictEqual(deliveries.length, 2);
|
|
249
|
+
assert.strictEqual(deliveries[1].text, "fresh side answer");
|
|
250
|
+
assert.strictEqual(deliveries[1].action.callbackData, "sc:q:token-one");
|
|
251
|
+
|
|
252
|
+
coordinator.noteMainResult(active, { ok: true, sessionId: "main-minted-session" });
|
|
253
|
+
const denied = await coordinator.enqueue("token-one", "different-user");
|
|
254
|
+
assert.strictEqual(denied.status, "forbidden");
|
|
255
|
+
assert.strictEqual(enqueued.length, 0);
|
|
256
|
+
const accepted = await coordinator.enqueue("token-one", "fixture-user");
|
|
257
|
+
assert.strictEqual(accepted.status, "queued");
|
|
258
|
+
assert.strictEqual(enqueued.length, 1);
|
|
259
|
+
assert.strictEqual(enqueued[0].request.originalRunContext.runId, "side-one");
|
|
260
|
+
assert.strictEqual(enqueued[0].request.originalRunContext.provider, "claude");
|
|
261
|
+
assert.strictEqual(enqueued[0].request.originalRunContext.project.name, "project-a");
|
|
262
|
+
assert.strictEqual(enqueued[0].request.originalRunContext.origin.transport, "telegram");
|
|
263
|
+
assert.deepStrictEqual(enqueued[0].store, { id: "origin-one" });
|
|
264
|
+
assert.deepStrictEqual(enqueued[0].lineage, { mainRunId: "active-main", mainSessionId: "main-minted-session" });
|
|
265
|
+
const replay = await coordinator.enqueue("token-one", "fixture-user");
|
|
266
|
+
assert.strictEqual(replay.status, "expired");
|
|
267
|
+
clock += 20 * 60 * 1000;
|
|
268
|
+
const expired = await coordinator.enqueue("token-two", "fixture-user");
|
|
269
|
+
assert.strictEqual(expired.status, "expired");
|
|
270
|
+
|
|
271
|
+
const deliveryAttempts = [];
|
|
272
|
+
const failingDelivery = new SideChatCoordinator({
|
|
273
|
+
tokenFactory: () => "delivery-failure-token",
|
|
274
|
+
runSideChat: async () => ({ ok: true, text: "must be delivered once" }),
|
|
275
|
+
deliver: async (payload) => { deliveryAttempts.push(payload); throw new Error("fixture delivery failed"); },
|
|
276
|
+
enqueueMain: () => ({ queued: true }),
|
|
277
|
+
});
|
|
278
|
+
const failed = await failingDelivery.respond({ runContext: first, activeRunContext: active });
|
|
279
|
+
assert.strictEqual(failed.ok, false);
|
|
280
|
+
assert.strictEqual(deliveryAttempts.length, 1, "delivery failure must not trigger a second send");
|
|
281
|
+
assert.strictEqual(failingDelivery.isActive("fixture-user"), false);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function modeAndLineageProbe() {
|
|
285
|
+
const modeDir = path.join(root, "mode");
|
|
286
|
+
fs.mkdirSync(modeDir);
|
|
287
|
+
const modeFile = path.join(modeDir, ".bot-mode");
|
|
288
|
+
assert.strictEqual(readRuntimeMode(modeDir), "direct");
|
|
289
|
+
fs.writeFileSync(modeFile, "invalid\n");
|
|
290
|
+
assert.strictEqual(readRuntimeMode(modeDir), "direct");
|
|
291
|
+
fs.writeFileSync(modeFile, "direct\n");
|
|
292
|
+
assert.strictEqual(readRuntimeMode(modeDir), "direct");
|
|
293
|
+
fs.writeFileSync(modeFile, "agent\n");
|
|
294
|
+
assert.strictEqual(readRuntimeMode(modeDir), "agent");
|
|
295
|
+
|
|
296
|
+
const state = mainState("claude");
|
|
297
|
+
state.runningProcess = { pid: 123 };
|
|
298
|
+
const incoming = captureMainRun(state, "queue me", "lineage-incoming");
|
|
299
|
+
const active = captureMainRun(state, "fresh main", "lineage-main");
|
|
300
|
+
state.activeRunContext = active;
|
|
301
|
+
assert.strictEqual(shouldStartSideChat(state, incoming, {}, { enabled: () => false }), false);
|
|
302
|
+
assert.strictEqual(shouldStartSideChat(state, incoming, {}, { enabled: () => true }), true);
|
|
303
|
+
assert.strictEqual(shouldStartSideChat(state, incoming, { sideChat: false }, { enabled: () => true }), false);
|
|
304
|
+
|
|
305
|
+
const request = createSideChatRequest({ runContext: incoming, activeRunContext: active, token: "lineage-token" });
|
|
306
|
+
const pending = createSideChatQueueItem(request, { id: "origin" }, {
|
|
307
|
+
mainRunId: active.runId,
|
|
308
|
+
mainSessionId: null,
|
|
309
|
+
}, state, { resolve() {}, reject() {}, promise: Promise.resolve() });
|
|
310
|
+
assert.strictEqual(pending.runContext, null, "pre-completion action remains a pending intent, not a mutable admitted run");
|
|
311
|
+
assert.strictEqual(finalizeSideChatQueueItem(pending, active, { ok: true, sessionId: "fresh-main-minted" }), true);
|
|
312
|
+
assert.ok(Object.isFrozen(pending.runContext));
|
|
313
|
+
assert.strictEqual(pending.runContext.provider, "claude");
|
|
314
|
+
assert.strictEqual(pending.runContext.project.name, "project-a");
|
|
315
|
+
assert.strictEqual(pending.runContext.resumeSessionId, "fresh-main-minted");
|
|
316
|
+
assert.strictEqual(pending.runContext.fresh, false);
|
|
317
|
+
|
|
318
|
+
const afterCompletion = createSideChatQueueItem(request, null, {
|
|
319
|
+
mainRunId: active.runId,
|
|
320
|
+
mainSessionId: "already-minted",
|
|
321
|
+
}, { ...state, activeRunContext: null }, { promise: Promise.resolve() });
|
|
322
|
+
assert.strictEqual(afterCompletion.runContext.resumeSessionId, "already-minted");
|
|
323
|
+
assert.ok(Object.isFrozen(afterCompletion.runContext));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function main() {
|
|
327
|
+
await providerProbe("claude");
|
|
328
|
+
await providerProbe("codex");
|
|
329
|
+
await coordinatorProbe();
|
|
330
|
+
modeAndLineageProbe();
|
|
331
|
+
console.log("provider side chat OK");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
main().catch((error) => {
|
|
335
|
+
console.error(error.stack || error.message);
|
|
336
|
+
process.exitCode = 1;
|
|
337
|
+
}).finally(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const assert = require("assert");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { spawnSync } = require("child_process");
|
|
10
|
+
|
|
11
|
+
const root = __dirname;
|
|
12
|
+
const fixturesRoot = path.join(root, "test-fixtures", "migrations");
|
|
13
|
+
const apiConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-state-api-"));
|
|
14
|
+
process.env.OPEN_CLAUDIA_CONFIG_DIR = apiConfigDir;
|
|
15
|
+
const fixtureNames = [
|
|
16
|
+
"claude-only",
|
|
17
|
+
"dual-provider",
|
|
18
|
+
"cursor-selected",
|
|
19
|
+
"multi-user",
|
|
20
|
+
"missing-project",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Keep state/config imports after the fixture-independent environment setup in
|
|
24
|
+
// the child integration probe below. The pure migration API must not write.
|
|
25
|
+
const stateApi = require("./core/state");
|
|
26
|
+
const migration = require("./core/migration-backup");
|
|
27
|
+
|
|
28
|
+
function readFixture(name) {
|
|
29
|
+
return JSON.parse(fs.readFileSync(path.join(fixturesRoot, name, "state.json"), "utf8"));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function onlyUser(document) {
|
|
33
|
+
const entries = Object.entries(document.users || {});
|
|
34
|
+
assert.strictEqual(entries.length, 1);
|
|
35
|
+
return entries[0][1];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fixtureMigrationProbe() {
|
|
39
|
+
for (const name of fixtureNames) {
|
|
40
|
+
const raw = readFixture(name);
|
|
41
|
+
const migrated = stateApi.migrateStateDocument(raw, {
|
|
42
|
+
defaultUserId: "telegram:fixture",
|
|
43
|
+
canonicalizeUserId: (value) => String(value),
|
|
44
|
+
});
|
|
45
|
+
assert.strictEqual(migrated.schemaVersion, stateApi.STATE_SCHEMA_VERSION, `${name}: schema version`);
|
|
46
|
+
assert.deepStrictEqual(
|
|
47
|
+
stateApi.migrateStateDocument(migrated, {
|
|
48
|
+
defaultUserId: "telegram:fixture",
|
|
49
|
+
canonicalizeUserId: (value) => String(value),
|
|
50
|
+
}),
|
|
51
|
+
migrated,
|
|
52
|
+
`${name}: migration is idempotent`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const claude = onlyUser(stateApi.migrateStateDocument(readFixture("claude-only"), {
|
|
57
|
+
defaultUserId: "telegram:fixture",
|
|
58
|
+
canonicalizeUserId: (value) => String(value),
|
|
59
|
+
}));
|
|
60
|
+
assert.strictEqual(claude.activeSessions.alpha.claude, "claude-session-alpha");
|
|
61
|
+
assert.strictEqual(claude.providerSettings.claude.model, "claude-sonnet");
|
|
62
|
+
assert.strictEqual(claude.settings.backend, "claude");
|
|
63
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(claude.settings, "model"));
|
|
64
|
+
|
|
65
|
+
const dual = stateApi.migrateStateDocument(readFixture("dual-provider"), {
|
|
66
|
+
defaultUserId: "telegram:fixture",
|
|
67
|
+
canonicalizeUserId: (value) => String(value),
|
|
68
|
+
}).users["owner@example.test"];
|
|
69
|
+
assert.deepStrictEqual(dual.activeSessions.parity, { claude: "claude-session-parity" });
|
|
70
|
+
assert.strictEqual(dual.legacyProviderState.unresolvedSessionPointers.codex, "codex-thread-parity",
|
|
71
|
+
"legacy process-global Codex pointers are not guessed into currentSession");
|
|
72
|
+
assert.strictEqual(dual.providerSettings.codex.model, "gpt-fixture");
|
|
73
|
+
|
|
74
|
+
const cursor = onlyUser(stateApi.migrateStateDocument(readFixture("cursor-selected"), {
|
|
75
|
+
defaultUserId: "telegram:fixture",
|
|
76
|
+
canonicalizeUserId: (value) => String(value),
|
|
77
|
+
}));
|
|
78
|
+
assert.strictEqual(cursor.settings.backend, null);
|
|
79
|
+
assert.deepStrictEqual(cursor.activeSessions, { "legacy-cursor": { claude: "claude-neighbor" } });
|
|
80
|
+
assert.strictEqual(cursor.legacyProviderState.archivedSessionPointers.cursor, "cursor-session-unresolved");
|
|
81
|
+
assert.strictEqual(cursor.legacyProviderState.archivedActiveSessions["legacy-cursor"].cursor, "cursor-session-unresolved");
|
|
82
|
+
assert.strictEqual(cursor.legacyProviderState.archivedProviderSettings.cursor.model, "cursor-model");
|
|
83
|
+
assert.strictEqual(cursor.legacyProviderState.providerSelectionRequired, true);
|
|
84
|
+
|
|
85
|
+
const missing = onlyUser(stateApi.migrateStateDocument(readFixture("missing-project"), {
|
|
86
|
+
defaultUserId: "telegram:fixture",
|
|
87
|
+
canonicalizeUserId: (value) => String(value),
|
|
88
|
+
}));
|
|
89
|
+
assert.deepStrictEqual(missing.activeSessions, {});
|
|
90
|
+
assert.strictEqual(missing.legacyProviderState.unresolvedSessionPointers.claude, "orphan-session");
|
|
91
|
+
|
|
92
|
+
const multi = stateApi.migrateStateDocument(readFixture("multi-user"), {
|
|
93
|
+
defaultUserId: "telegram:fixture",
|
|
94
|
+
canonicalizeUserId: (value) => String(value),
|
|
95
|
+
});
|
|
96
|
+
assert.strictEqual(multi.users["telegram:100"].activeSessions.one.claude, "session-one");
|
|
97
|
+
assert.deepStrictEqual(multi.users["person@example.test"].activeSessions, {});
|
|
98
|
+
assert.strictEqual(multi.users["person@example.test"].legacyProviderState.unresolvedSessionPointers.codex, "thread-two");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function helperAndBoundProbe() {
|
|
102
|
+
const usageBySession = {};
|
|
103
|
+
for (let index = 0; index < stateApi.MAX_USAGE_ENTRIES + 15; index += 1) {
|
|
104
|
+
usageBySession[`project-${index}\0claude\0session-${index}`] = { turns: index };
|
|
105
|
+
}
|
|
106
|
+
const migrated = stateApi.migrateStateDocument({
|
|
107
|
+
users: {
|
|
108
|
+
owner: {
|
|
109
|
+
currentSession: { name: "alpha", dir: "/workspace/alpha" },
|
|
110
|
+
activeSessions: { alpha: { claude: "claude-a", codex: "codex-a" } },
|
|
111
|
+
providerSettings: { claude: { model: "sonnet" }, codex: { model: "gpt" } },
|
|
112
|
+
settings: { backend: "codex", compactWindow: 123, recallEngine: "classic" },
|
|
113
|
+
usageBySession,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
}, { canonicalizeUserId: (value) => String(value) });
|
|
117
|
+
const saved = migrated.users.owner;
|
|
118
|
+
assert.ok(Object.keys(saved.usageBySession).length <= stateApi.MAX_USAGE_ENTRIES);
|
|
119
|
+
assert.ok(saved.usageBySession[`project-${stateApi.MAX_USAGE_ENTRIES + 14}\0claude\0session-${stateApi.MAX_USAGE_ENTRIES + 14}`]);
|
|
120
|
+
|
|
121
|
+
const recentUsageState = { usageBySession: structuredClone(saved.usageBySession) };
|
|
122
|
+
const oldestRetainedKey = Object.keys(recentUsageState.usageBySession)[0];
|
|
123
|
+
const [oldestProject, oldestProvider, oldestSession] = oldestRetainedKey.split("\0");
|
|
124
|
+
stateApi.getUsageForSession(recentUsageState, oldestProject, oldestProvider, oldestSession);
|
|
125
|
+
stateApi.getUsageForSession(recentUsageState, "new-project", "claude", "new-session");
|
|
126
|
+
assert.ok(recentUsageState.usageBySession[oldestRetainedKey], "access refreshes an active usage entry before bounding");
|
|
127
|
+
|
|
128
|
+
const state = {
|
|
129
|
+
settings: { backend: "codex" },
|
|
130
|
+
currentSession: { name: "alpha", dir: "/workspace/alpha" },
|
|
131
|
+
activeSessions: structuredClone(saved.activeSessions),
|
|
132
|
+
providerSettings: structuredClone(saved.providerSettings),
|
|
133
|
+
usageBySession: {},
|
|
134
|
+
};
|
|
135
|
+
assert.strictEqual(stateApi.getActiveProvider(state), "codex");
|
|
136
|
+
assert.strictEqual(stateApi.getProjectKey(state), "alpha");
|
|
137
|
+
assert.strictEqual(stateApi.getProviderSession(state, "claude", "alpha"), "claude-a");
|
|
138
|
+
assert.strictEqual(stateApi.getProviderSession(state, "codex", "alpha"), "codex-a");
|
|
139
|
+
stateApi.setProviderSession(state, "codex", "alpha", "codex-next");
|
|
140
|
+
assert.strictEqual(stateApi.getProviderSession(state, "codex", "alpha"), "codex-next");
|
|
141
|
+
stateApi.clearProviderSession(state, "codex", "alpha");
|
|
142
|
+
assert.strictEqual(stateApi.getProviderSession(state, "codex", "alpha"), null);
|
|
143
|
+
assert.strictEqual(stateApi.getProviderSettings(state, "claude").model, "sonnet");
|
|
144
|
+
assert.strictEqual(stateApi.getActiveUsage(state).turns, 0);
|
|
145
|
+
assert.ok(Object.keys(state.usageBySession)[0].includes("alpha\0codex\0new"));
|
|
146
|
+
|
|
147
|
+
const legacyRuntime = onlyUser(stateApi.migrateStateDocument({
|
|
148
|
+
currentSession: { name: "alpha", dir: "/workspace/alpha" },
|
|
149
|
+
codexSessionId: "codex-a",
|
|
150
|
+
codexUsageSnapshots: { "codex-a": { input_tokens: 50, output_tokens: 5 } },
|
|
151
|
+
sessionUsage: { turns: 4, inputTokens: 40, outputTokens: 8 },
|
|
152
|
+
lastCompactedAt: 123456,
|
|
153
|
+
settings: {
|
|
154
|
+
backend: "codex",
|
|
155
|
+
model: "gpt-fixture",
|
|
156
|
+
effort: "high",
|
|
157
|
+
budget: 2,
|
|
158
|
+
permissionMode: "plan",
|
|
159
|
+
worktree: true,
|
|
160
|
+
compactWindow: 200000,
|
|
161
|
+
recallEngine: "discoverer",
|
|
162
|
+
showToolTrace: true,
|
|
163
|
+
},
|
|
164
|
+
}, { defaultUserId: "owner", canonicalizeUserId: (value) => String(value) }));
|
|
165
|
+
assert.deepStrictEqual(legacyRuntime.legacyProviderState.unresolvedCodexUsageSnapshots["codex-a"], {
|
|
166
|
+
input_tokens: 50,
|
|
167
|
+
output_tokens: 5,
|
|
168
|
+
});
|
|
169
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(legacyRuntime, "codexUsageSnapshots"));
|
|
170
|
+
assert.deepStrictEqual(legacyRuntime.legacyProviderState.unresolvedUsage, {
|
|
171
|
+
turns: 4,
|
|
172
|
+
inputTokens: 40,
|
|
173
|
+
outputTokens: 8,
|
|
174
|
+
});
|
|
175
|
+
assert.strictEqual(legacyRuntime.legacyProviderState.unresolvedSessionPointers.codex, "codex-a");
|
|
176
|
+
assert.ok(!Object.keys(legacyRuntime.usageBySession).some((key) => key.includes("codex-a")));
|
|
177
|
+
assert.deepStrictEqual(legacyRuntime.providerSettings.codex, {
|
|
178
|
+
model: "gpt-fixture",
|
|
179
|
+
effort: "high",
|
|
180
|
+
budget: 2,
|
|
181
|
+
permissionMode: "plan",
|
|
182
|
+
worktree: true,
|
|
183
|
+
});
|
|
184
|
+
assert.strictEqual(legacyRuntime.settings.compactWindow, 200000);
|
|
185
|
+
assert.strictEqual(legacyRuntime.settings.recallEngine, "discoverer");
|
|
186
|
+
assert.strictEqual(legacyRuntime.settings.showToolTrace, true);
|
|
187
|
+
assert.strictEqual(legacyRuntime.lastCompactedAt, 123456);
|
|
188
|
+
|
|
189
|
+
const provenCodexOwner = onlyUser(stateApi.migrateStateDocument({
|
|
190
|
+
currentSession: { name: "other", dir: "/workspace/other" },
|
|
191
|
+
activeSessions: { proven: { codex: "codex-proven" } },
|
|
192
|
+
codexSessionId: "codex-proven",
|
|
193
|
+
codexUsageSnapshots: { "codex-proven": { input_tokens: 7 } },
|
|
194
|
+
sessionUsage: { turns: 2 },
|
|
195
|
+
settings: { backend: "codex" },
|
|
196
|
+
}, { defaultUserId: "owner", canonicalizeUserId: (value) => String(value) }));
|
|
197
|
+
assert.strictEqual(provenCodexOwner.activeSessions.proven.codex, "codex-proven");
|
|
198
|
+
assert.strictEqual(provenCodexOwner.usageBySession["proven\0codex\0codex-proven"].turns, 2);
|
|
199
|
+
assert.strictEqual(
|
|
200
|
+
provenCodexOwner.usageBySession["proven\0codex\0codex-proven"].providerUsageSnapshot.input_tokens,
|
|
201
|
+
7,
|
|
202
|
+
);
|
|
203
|
+
assert.throws(
|
|
204
|
+
() => stateApi.migrateStateDocument({ schemaVersion: stateApi.STATE_SCHEMA_VERSION + 1, users: {} }),
|
|
205
|
+
(error) => error.code === "UNSUPPORTED_STATE_SCHEMA",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function atomicIntegrationProbe() {
|
|
210
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-state-migration-"));
|
|
211
|
+
try {
|
|
212
|
+
const configDir = path.join(temp, "config");
|
|
213
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
214
|
+
fs.cpSync(path.join(fixturesRoot, "claude-only"), configDir, { recursive: true });
|
|
215
|
+
const original = fs.readFileSync(path.join(configDir, "state.json"));
|
|
216
|
+
const childScript = [
|
|
217
|
+
"const state = require('./core/state');",
|
|
218
|
+
"state.currentState();",
|
|
219
|
+
"if (!state.saveState({ strict: true })) process.exit(2);",
|
|
220
|
+
].join("\n");
|
|
221
|
+
const env = {
|
|
222
|
+
...process.env,
|
|
223
|
+
OPEN_CLAUDIA_CONFIG_DIR: configDir,
|
|
224
|
+
TELEGRAM_CHAT_ID: "fixture",
|
|
225
|
+
CHANNELS: "telegram",
|
|
226
|
+
OPEN_CLAUDIA_TEST: "1",
|
|
227
|
+
};
|
|
228
|
+
const first = spawnSync(process.execPath, ["-e", childScript], { cwd: root, env, encoding: "utf8" });
|
|
229
|
+
assert.strictEqual(first.status, 0, first.stderr || first.stdout);
|
|
230
|
+
|
|
231
|
+
const live = JSON.parse(fs.readFileSync(path.join(configDir, "state.json"), "utf8"));
|
|
232
|
+
assert.strictEqual(live.schemaVersion, stateApi.STATE_SCHEMA_VERSION);
|
|
233
|
+
assert.strictEqual(live.users["telegram:fixture"].activeSessions.alpha.claude, "claude-session-alpha");
|
|
234
|
+
const journal = migration.readMigrationJournal(configDir);
|
|
235
|
+
assert.ok(journal.components.state, "state component must activate after the atomic schema write");
|
|
236
|
+
const snapshotDir = path.join(migration.migrationRoot(configDir), ...journal.snapshot.split("/"));
|
|
237
|
+
migration.validateMigrationSnapshot(snapshotDir);
|
|
238
|
+
assert.deepStrictEqual(fs.readFileSync(path.join(snapshotDir, "originals", "state.json")), original);
|
|
239
|
+
|
|
240
|
+
// A restart between component migrations must keep the original rollback
|
|
241
|
+
// snapshot even though state.json now intentionally differs from it.
|
|
242
|
+
const resumed = migration.ensureMigrationSnapshot({
|
|
243
|
+
configDir,
|
|
244
|
+
sources: migration.defaultMigrationSources({ configDir }),
|
|
245
|
+
});
|
|
246
|
+
assert.strictEqual(resumed.snapshotDir, snapshotDir);
|
|
247
|
+
assert.strictEqual(resumed.reused, true);
|
|
248
|
+
|
|
249
|
+
const afterFirst = fs.readFileSync(path.join(configDir, "state.json"));
|
|
250
|
+
const second = spawnSync(process.execPath, ["-e", childScript], { cwd: root, env, encoding: "utf8" });
|
|
251
|
+
assert.strictEqual(second.status, 0, second.stderr || second.stdout);
|
|
252
|
+
assert.deepStrictEqual(fs.readFileSync(path.join(configDir, "state.json")), afterFirst);
|
|
253
|
+
assert.strictEqual(migration.readMigrationJournal(configDir).snapshot, journal.snapshot);
|
|
254
|
+
} finally {
|
|
255
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function interruptedActivationProbe() {
|
|
260
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "open-claudia-state-interrupt-"));
|
|
261
|
+
try {
|
|
262
|
+
const configDir = path.join(temp, "config");
|
|
263
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
264
|
+
fs.cpSync(path.join(fixturesRoot, "dual-provider"), configDir, { recursive: true });
|
|
265
|
+
const original = fs.readFileSync(path.join(configDir, "state.json"));
|
|
266
|
+
const env = {
|
|
267
|
+
...process.env,
|
|
268
|
+
OPEN_CLAUDIA_CONFIG_DIR: configDir,
|
|
269
|
+
TELEGRAM_CHAT_ID: "fixture",
|
|
270
|
+
CHANNELS: "telegram",
|
|
271
|
+
OPEN_CLAUDIA_TEST: "1",
|
|
272
|
+
};
|
|
273
|
+
const interrupted = spawnSync(process.execPath, ["-e", [
|
|
274
|
+
"const state = require('./core/state');",
|
|
275
|
+
"state.currentState();",
|
|
276
|
+
"try {",
|
|
277
|
+
" state.saveState({ strict: true, faultInjector(point) { if (point === 'after-state-write') throw Object.assign(new Error('stop after rename'), { code: 'FIXTURE_INTERRUPTION' }); } });",
|
|
278
|
+
" process.exit(3);",
|
|
279
|
+
"} catch (error) { if (error.code !== 'FIXTURE_INTERRUPTION') throw error; }",
|
|
280
|
+
].join("\n")], { cwd: root, env, encoding: "utf8" });
|
|
281
|
+
assert.strictEqual(interrupted.status, 0, interrupted.stderr || interrupted.stdout);
|
|
282
|
+
const pending = migration.readMigrationJournal(configDir);
|
|
283
|
+
assert.ok(pending.pendingComponents.state);
|
|
284
|
+
assert.strictEqual(pending.components.state, null);
|
|
285
|
+
assert.strictEqual(JSON.parse(fs.readFileSync(path.join(configDir, "state.json"), "utf8")).schemaVersion, stateApi.STATE_SCHEMA_VERSION);
|
|
286
|
+
|
|
287
|
+
// This is the same pre-state-import check bot.js performs on restart.
|
|
288
|
+
const resumedSnapshot = migration.ensureMigrationSnapshot({
|
|
289
|
+
configDir,
|
|
290
|
+
sources: migration.defaultMigrationSources({ configDir }),
|
|
291
|
+
});
|
|
292
|
+
assert.strictEqual(resumedSnapshot.journal.snapshot, pending.snapshot);
|
|
293
|
+
const snapshotDir = resumedSnapshot.snapshotDir;
|
|
294
|
+
assert.deepStrictEqual(fs.readFileSync(path.join(snapshotDir, "originals", "state.json")), original);
|
|
295
|
+
|
|
296
|
+
const completed = spawnSync(process.execPath, ["-e", [
|
|
297
|
+
"const state = require('./core/state');",
|
|
298
|
+
"state.currentState();",
|
|
299
|
+
"state.saveState({ strict: true });",
|
|
300
|
+
].join("\n")], { cwd: root, env, encoding: "utf8" });
|
|
301
|
+
assert.strictEqual(completed.status, 0, completed.stderr || completed.stdout);
|
|
302
|
+
const journal = migration.readMigrationJournal(configDir);
|
|
303
|
+
assert.ok(journal.components.state);
|
|
304
|
+
assert.ok(!journal.pendingComponents.state);
|
|
305
|
+
assert.strictEqual(journal.snapshot, pending.snapshot);
|
|
306
|
+
} finally {
|
|
307
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
fixtureMigrationProbe();
|
|
312
|
+
helperAndBoundProbe();
|
|
313
|
+
atomicIntegrationProbe();
|
|
314
|
+
interruptedActivationProbe();
|
|
315
|
+
fs.rmSync(apiConfigDir, { recursive: true, force: true });
|
|
316
|
+
console.log("provider state migration OK");
|