mixdog 0.7.11 → 0.7.12

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 (41) hide show
  1. package/.claude-plugin/marketplace.json +5 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +193 -249
  4. package/bin/statusline-launcher.mjs +5 -1
  5. package/bin/statusline-lib.mjs +14 -6
  6. package/bin/statusline.mjs +14 -6
  7. package/hooks/lib/settings-loader.cjs +4 -3
  8. package/hooks/pre-tool-subagent.cjs +7 -2
  9. package/hooks/session-start.cjs +52 -24
  10. package/lib/mixdog-debug.cjs +163 -0
  11. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  12. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  13. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  14. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  15. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  16. package/package.json +1 -1
  17. package/scripts/builtin-utils-smoke.mjs +14 -8
  18. package/scripts/bump.mjs +80 -0
  19. package/scripts/doctor.mjs +8 -3
  20. package/scripts/mutation-io-smoke.mjs +17 -1
  21. package/scripts/permission-eval-smoke.mjs +18 -1
  22. package/scripts/statusline-launcher-smoke.mjs +2 -2
  23. package/scripts/webhook-selfheal-smoke.mjs +1 -3
  24. package/server-main.mjs +57 -3
  25. package/setup/install.mjs +574 -574
  26. package/setup/setup-server.mjs +10 -2
  27. package/setup/setup.html +43 -8
  28. package/src/agent/orchestrator/providers/openai-oauth.mjs +9 -2
  29. package/src/agent/orchestrator/providers/openai-ws.mjs +23 -0
  30. package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +29 -8
  31. package/src/agent/orchestrator/tools/graph-manifest.json +11 -11
  32. package/src/agent/orchestrator/tools/patch-manifest.json +11 -11
  33. package/src/channels/index.mjs +27 -8
  34. package/src/channels/lib/event-queue.mjs +24 -1
  35. package/src/channels/lib/hook-pipe-server.mjs +21 -8
  36. package/src/channels/lib/webhook.mjs +142 -20
  37. package/src/memory/lib/memory-cycle1.mjs +7 -3
  38. package/src/memory/lib/memory-recall-store.mjs +27 -10
  39. package/src/search/lib/backends/openai-oauth.mjs +6 -2
  40. package/src/search/lib/cache.mjs +55 -7
  41. package/scripts/test-config-rmw-restore.mjs +0 -122
@@ -4,7 +4,7 @@ import { join } from "path";
4
4
  import { spawn, spawnSync, execSync } from "child_process";
5
5
  import { DATA_DIR, isInQuietWindow } from "./config.mjs";
6
6
  import { getWebhookAuthtoken } from "../../shared/config.mjs";
7
- import { appendFileSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync, statSync, existsSync, watch as fsWatch } from "fs";
7
+ import { appendFileSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync, statSync, existsSync, renameSync, watch as fsWatch } from "fs";
8
8
  import { randomUUID } from "crypto";
9
9
  const WEBHOOKS_DIR = join(DATA_DIR, "webhooks");
10
10
  const WEBHOOK_LOG = join(DATA_DIR, "webhook.log");
@@ -144,39 +144,160 @@ function loadEndpointConfig(name) {
144
144
  // then {status:"done"|"failed"|"dedup"}. Earlier fields (payloadPreview,
145
145
  // headersSummary) are kept on the first line only; later status updates
146
146
  // reference the same `id` and are merged latest-wins at read time.
147
+ const DELIVERY_INDEX_MAX_IDS = 2000;
148
+ const DELIVERY_LOG_MAX_LINES = 10_000;
149
+ /** @type {Map<string, Map<string, object>>} */
150
+ const _deliveryIndexByEndpoint = new Map();
151
+ /** @type {Set<string>} */
152
+ const _deliveryIndexWarmed = new Set();
153
+ /** @type {Map<string, number>} */
154
+ const _deliveryLogLineCountByEndpoint = new Map();
155
+ /** @type {Map<string, number>} distinct-id count at last warm/compaction; drives the redundancy-based compaction trigger. */
156
+ const _deliveryKeptCountByEndpoint = new Map();
147
157
  function _deliveriesPath(name) {
148
158
  return join(WEBHOOKS_DIR, name, "deliveries.jsonl");
149
159
  }
150
- function appendDelivery(name, entry) {
151
- try {
152
- const dir = join(WEBHOOKS_DIR, name);
153
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
154
- const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
155
- appendFileSync(_deliveriesPath(name), line);
156
- return true;
157
- } catch (err) {
158
- logWebhook(`${name}: deliveries append failed: ${err?.message ?? err}`);
159
- return false;
160
+ function _mergeDeliveryRows(prior, entry) {
161
+ return prior ? { ...prior, ...entry } : entry;
162
+ }
163
+ function _isBlockingDeliveryStatus(status) {
164
+ return status === "received" || status === "processing" || status === "done";
165
+ }
166
+ function _deliveryIndexFor(name) {
167
+ let map = _deliveryIndexByEndpoint.get(name);
168
+ if (!map) {
169
+ map = new Map();
170
+ _deliveryIndexByEndpoint.set(name, map);
160
171
  }
172
+ return map;
161
173
  }
162
- function readDeliveries(name) {
174
+ function _pruneDeliveryIndexMap(byId) {
175
+ if (byId.size <= DELIVERY_INDEX_MAX_IDS) return;
176
+ const rows = [...byId.entries()];
177
+ const blocking = rows.filter(([, e]) => _isBlockingDeliveryStatus(e.status));
178
+ const nonBlocking = rows.filter(([, e]) => !_isBlockingDeliveryStatus(e.status));
179
+ nonBlocking.sort((a, b) => String(b[1].ts || "").localeCompare(String(a[1].ts || "")));
180
+ const keepIds = new Set(blocking.map(([id]) => id));
181
+ for (const [id] of nonBlocking) {
182
+ if (keepIds.size >= DELIVERY_INDEX_MAX_IDS) break;
183
+ keepIds.add(id);
184
+ }
185
+ for (const id of byId.keys()) {
186
+ if (!keepIds.has(id)) byId.delete(id);
187
+ }
188
+ }
189
+ function _deliveryLogLineCount(name) {
190
+ return _deliveryLogLineCountByEndpoint.get(name) ?? 0;
191
+ }
192
+ function _setDeliveryLogLineCount(name, n) {
193
+ _deliveryLogLineCountByEndpoint.set(name, Math.max(0, n));
194
+ }
195
+ function _bumpDeliveryLogLineCount(name, delta = 1) {
196
+ _setDeliveryLogLineCount(name, _deliveryLogLineCount(name) + delta);
197
+ }
198
+ function _readDeliveriesFileMerged(name) {
163
199
  const p = _deliveriesPath(name);
164
- if (!existsSync(p)) return [];
165
200
  const byId = new Map();
201
+ let lineCount = 0;
202
+ if (!existsSync(p)) return { byId, lineCount };
166
203
  try {
167
204
  const raw = readFileSync(p, "utf8");
168
205
  for (const line of raw.split("\n")) {
169
206
  if (!line) continue;
207
+ lineCount++;
170
208
  try {
171
209
  const entry = JSON.parse(line);
172
- if (!entry || !entry.id) continue;
173
- const prior = byId.get(entry.id);
174
- const merged = prior ? { ...prior, ...entry } : entry;
175
- byId.set(entry.id, merged);
210
+ if (!entry?.id) continue;
211
+ byId.set(entry.id, _mergeDeliveryRows(byId.get(entry.id), entry));
176
212
  } catch {}
177
213
  }
178
214
  } catch {}
179
- return [...byId.values()];
215
+ return { byId, lineCount };
216
+ }
217
+ function _ingestDeliveriesFileIntoIndex(name) {
218
+ const { byId: merged, lineCount } = _readDeliveriesFileMerged(name);
219
+ const byId = _deliveryIndexFor(name);
220
+ byId.clear();
221
+ for (const [id, row] of merged) byId.set(id, row);
222
+ _pruneDeliveryIndexMap(byId);
223
+ _setDeliveryLogLineCount(name, lineCount);
224
+ _deliveryKeptCountByEndpoint.set(name, merged.size);
225
+ }
226
+ function _ensureDeliveryIndex(name) {
227
+ if (_deliveryIndexWarmed.has(name)) return;
228
+ _deliveryIndexWarmed.add(name);
229
+ _ingestDeliveriesFileIntoIndex(name);
230
+ }
231
+ function _applyDeliveryEntryToIndex(name, entry) {
232
+ if (!entry?.id) return;
233
+ const byId = _deliveryIndexFor(name);
234
+ byId.set(entry.id, _mergeDeliveryRows(byId.get(entry.id), entry));
235
+ _pruneDeliveryIndexMap(byId);
236
+ }
237
+ function _compactDeliveriesLogIfNeeded(name) {
238
+ // Redundancy-based trigger: compact only when the log holds meaningfully more
239
+ // lines than distinct ids (i.e. there are status-update rows to collapse).
240
+ // The threshold scales with the distinct-id count so an endpoint with many
241
+ // legitimate blocking ids does NOT re-compact on every append (which would
242
+ // re-read the whole log permanently once distinct > DELIVERY_LOG_MAX_LINES).
243
+ const kept = _deliveryKeptCountByEndpoint.get(name) ?? _deliveryIndexFor(name).size;
244
+ const threshold = Math.max(DELIVERY_LOG_MAX_LINES, kept * 2);
245
+ if (_deliveryLogLineCount(name) <= threshold) return;
246
+ const { byId: merged } = _readDeliveriesFileMerged(name);
247
+ const rows = [...merged.values()];
248
+ const blocking = rows.filter((e) => _isBlockingDeliveryStatus(e.status));
249
+ const nonBlocking = rows.filter((e) => !_isBlockingDeliveryStatus(e.status));
250
+ nonBlocking.sort((a, b) => String(b.ts || "").localeCompare(String(a.ts || "")));
251
+ const keep = new Map();
252
+ for (const e of blocking) keep.set(e.id, e);
253
+ for (const e of nonBlocking) {
254
+ if (keep.size >= DELIVERY_INDEX_MAX_IDS) break;
255
+ if (!keep.has(e.id)) keep.set(e.id, e);
256
+ }
257
+ const lines = [...keep.values()]
258
+ .sort((a, b) => String(a.ts || "").localeCompare(String(b.ts || "")))
259
+ .map((e) => JSON.stringify(e) + "\n")
260
+ .join("");
261
+ const p = _deliveriesPath(name);
262
+ const tmp = `${p}.compact-${process.pid}-${Date.now()}.tmp`;
263
+ try {
264
+ writeFileSync(tmp, lines);
265
+ renameSync(tmp, p);
266
+ // Refresh index + counters by RE-READING the post-rename file (not the
267
+ // pre-rename `keep` snapshot). The webhook daemon is the single writer and
268
+ // append+compact run synchronously in one process, so no append can
269
+ // interleave between the fresh read and the rename; re-reading keeps the
270
+ // warmed state exactly matching on-disk content.
271
+ _ingestDeliveriesFileIntoIndex(name);
272
+ } catch (err) {
273
+ logWebhook(`${name}: deliveries compact failed: ${err?.message ?? err}`);
274
+ try {
275
+ if (existsSync(tmp)) unlinkSync(tmp);
276
+ } catch {}
277
+ }
278
+ }
279
+ function appendDelivery(name, entry) {
280
+ try {
281
+ const dir = join(WEBHOOKS_DIR, name);
282
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
283
+ const full = { ts: new Date().toISOString(), ...entry };
284
+ const line = JSON.stringify(full) + "\n";
285
+ appendFileSync(_deliveriesPath(name), line);
286
+ const wasWarmed = _deliveryIndexWarmed.has(name);
287
+ _ensureDeliveryIndex(name);
288
+ if (wasWarmed) _bumpDeliveryLogLineCount(name, 1);
289
+ _applyDeliveryEntryToIndex(name, full);
290
+ _compactDeliveriesLogIfNeeded(name);
291
+ return true;
292
+ } catch (err) {
293
+ logWebhook(`${name}: deliveries append failed: ${err?.message ?? err}`);
294
+ return false;
295
+ }
296
+ }
297
+ function readDeliveries(name) {
298
+ _ensureDeliveryIndex(name);
299
+ const byId = _deliveryIndexByEndpoint.get(name);
300
+ return byId ? [...byId.values()] : [];
180
301
  }
181
302
  // Dedup gate against a still-active claim or a successful prior delivery.
182
303
  // Only rows with status "received" (non-terminal claim) or "done"
@@ -186,12 +307,13 @@ function readDeliveries(name) {
186
307
  // scoping, every prior row would permanently dedup the id and stop
187
308
  // legit redelivery.
188
309
  function deliveryExists(name, id) {
189
- const list = readDeliveries(name);
190
310
  // "processing" must also dedup: a delegate dispatch in flight (up to
191
311
  // DISPATCH_TIMEOUT_MS = 10 min) would otherwise be duplicated by a
192
312
  // retried delivery of the same id while the first handler is still
193
313
  // running. Block on any non-terminal status.
194
- return list.some((e) => e.id === id && (e.status === "received" || e.status === "processing" || e.status === "done"));
314
+ _ensureDeliveryIndex(name);
315
+ const entry = _deliveryIndexFor(name).get(id);
316
+ return Boolean(entry && _isBlockingDeliveryStatus(entry.status));
195
317
  }
196
318
  function extractDeliveryId(headers) {
197
319
  return headers["x-github-delivery"]
@@ -545,9 +545,13 @@ async function _runCycle1Impl(db, config = {}, options = {}, dataDir = null) {
545
545
  }
546
546
  }
547
547
 
548
- // Fire every window at once: windowCount is bounded by fetchLimit / batchSize
549
- // (<= session_cap), so there is no need to throttle the fan-out.
550
- const sem = createSemaphore(Math.max(1, windows.length))
548
+ // Cap fan-out concurrency so a large batch (or a manual run) doesn't fire all
549
+ // window LLM calls at once and spike the provider / collide with the global
550
+ // agent-IPC limit. Small batches (<= cap) still run fully parallel.
551
+ const cycle1Concurrency = Math.max(1, Number(
552
+ config.cycle1_concurrency ?? config.concurrency ?? options.concurrency ?? options.maxConcurrent ?? 4,
553
+ ))
554
+ const sem = createSemaphore(Math.min(Math.max(1, windows.length), cycle1Concurrency))
551
555
  const settled = await Promise.allSettled(
552
556
  windows.map((rows, idx) => sem(() => {
553
557
  throwIfAborted(signal)
@@ -504,6 +504,32 @@ LEFT JOIN exact x ON x.id = c.id`
504
504
  const rootIdsForReturn = []
505
505
  const seen = new Set()
506
506
 
507
+ // Batch-resolve member-chunk roots in ONE query (was an N+1: a per-row SELECT
508
+ // inside the loop below). Collect the distinct in-scope chunk_root ids, fetch
509
+ // all matching roots at once, then resolve each member from rootById.
510
+ const memberRootIds = []
511
+ const memberRootSeen = new Set()
512
+ for (const { id } of filtered) {
513
+ const r0 = byId.get(id)
514
+ if (!r0 || r0.is_root === 1) continue
515
+ if (r0.chunk_root != null && r0.chunk_root !== r0.id) {
516
+ const rid = Number(r0.chunk_root)
517
+ if (!memberRootSeen.has(rid)) { memberRootSeen.add(rid); memberRootIds.push(rid) }
518
+ }
519
+ }
520
+ const rootById = new Map()
521
+ if (memberRootIds.length > 0) {
522
+ const { clause: rootScopeClause, params: rootScopeParams } = buildScopeClause(2)
523
+ const { rows: rootRows } = await recallReadQuery(
524
+ db,
525
+ `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
526
+ element, category, summary, project_id, status, score, last_seen_at
527
+ FROM entries WHERE id = ANY($1::bigint[]) AND is_root = 1 ${rootScopeClause}`,
528
+ [memberRootIds, ...rootScopeParams],
529
+ )
530
+ for (const rr of rootRows) rootById.set(Number(rr.id), rr)
531
+ }
532
+
507
533
  for (const { id, rrf, retrievalScore } of filtered) {
508
534
  const row = byId.get(id)
509
535
  if (!row) continue
@@ -511,16 +537,7 @@ LEFT JOIN exact x ON x.id = c.id`
511
537
  if (row.is_root === 1) {
512
538
  targetRow = row
513
539
  } else if (row.chunk_root != null && row.chunk_root !== row.id) {
514
- // $1 = chunk_root id, scope param (if any) = $2
515
- const { clause: rootScopeClause, params: rootScopeParams } = buildScopeClause(2)
516
- const { rows: rootRows } = await recallReadQuery(
517
- db,
518
- `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
519
- element, category, summary, project_id, status, score, last_seen_at
520
- FROM entries WHERE id = $1 AND is_root = 1 ${rootScopeClause}`,
521
- [row.chunk_root, ...rootScopeParams],
522
- )
523
- const r = rootRows[0]
540
+ const r = rootById.get(Number(row.chunk_root))
524
541
  if (!r) continue
525
542
  memberHitRootIds.add(r.id)
526
543
  targetRow = r
@@ -5,7 +5,11 @@
5
5
  * Calls Codex WebSocket endpoint via sendViaWebSocket with web_search server
6
6
  * tool. Model is config-driven (search.models.openai default 'gpt-5.4-mini').
7
7
  */
8
- import { OpenAIOAuthProvider, ensureLatestCodexModel } from '../../../agent/orchestrator/providers/openai-oauth.mjs'
8
+ import {
9
+ OpenAIOAuthProvider,
10
+ ensureLatestCodexModel,
11
+ codexModelSupportsServiceTier,
12
+ } from '../../../agent/orchestrator/providers/openai-oauth.mjs'
9
13
  import {
10
14
  OPENAI_SEARCH_SYSTEM_INSTRUCTIONS,
11
15
  buildOpenAISearchPrompt,
@@ -66,7 +70,7 @@ export async function searchViaOpenAIOAuth({
66
70
  parallel_tool_calls: false,
67
71
  tools: [buildOpenAIWebSearchTool({ site, type, locale, contextSize })],
68
72
  }
69
- if (fast === true) body.service_tier = 'priority'
73
+ if (fast === true && codexModelSupportsServiceTier(useModel, 'priority')) body.service_tier = 'priority'
70
74
  // Route through provider.send() (not sendViaWebSocket directly) so the search
71
75
  // request inherits the 401/403 force-refresh retry + HTTP/SSE fallback. A
72
76
  // stale token or unhealthy WebSocket then recovers instead of hard-failing.
@@ -5,6 +5,52 @@ const DEFAULT_CACHE_STATE = {
5
5
  entries: {},
6
6
  }
7
7
 
8
+ // Size bounds on top of TTL expiry so cache.local.json can't grow unbounded.
9
+ const MAX_CACHE_ENTRIES = 500
10
+ const MAX_CACHE_BYTES = 8 * 1024 * 1024
11
+
12
+ // Approximate serialized size of ONE entry (never the whole map), so insert and
13
+ // evict stay cheap. A running total on the state (`__approxBytes`) lets the byte
14
+ // cap be checked without re-serializing every entry on each set.
15
+ function approxEntryBytes(key, entry) {
16
+ try { return String(key).length + JSON.stringify(entry).length } catch { return String(key).length }
17
+ }
18
+ function cacheApproxBytes(state) {
19
+ if (typeof state.__approxBytes === 'number') return state.__approxBytes
20
+ let total = 0
21
+ for (const [k, e] of Object.entries(state.entries)) total += approxEntryBytes(k, e)
22
+ state.__approxBytes = total
23
+ return total
24
+ }
25
+ function removeCacheEntry(state, key) {
26
+ const e = state.entries[key]
27
+ if (e === undefined) return
28
+ if (typeof state.__approxBytes === 'number') state.__approxBytes -= approxEntryBytes(key, e)
29
+ delete state.entries[key]
30
+ }
31
+ function evictOldestCacheEntry(state) {
32
+ let oldestKey = null
33
+ let oldestAt = Infinity
34
+ for (const [k, e] of Object.entries(state.entries)) {
35
+ const at = e?.cachedAt ?? 0
36
+ if (at < oldestAt) { oldestAt = at; oldestKey = k }
37
+ }
38
+ if (oldestKey == null) return false
39
+ removeCacheEntry(state, oldestKey)
40
+ return true
41
+ }
42
+ // Best-effort: evict oldest entries until under both the count and byte caps.
43
+ function enforceCacheSizeBounds(state) {
44
+ try {
45
+ while (Object.keys(state.entries).length > MAX_CACHE_ENTRIES) {
46
+ if (!evictOldestCacheEntry(state)) break
47
+ }
48
+ while (cacheApproxBytes(state) > MAX_CACHE_BYTES && Object.keys(state.entries).length > 0) {
49
+ if (!evictOldestCacheEntry(state)) break
50
+ }
51
+ } catch { /* size bounding is best-effort */ }
52
+ }
53
+
8
54
  const FLUSH_DELAY_MS = 5000
9
55
 
10
56
  let cacheDirty = false
@@ -75,6 +121,8 @@ export function loadCacheState() {
75
121
  _instance = state
76
122
  activeCacheState = state
77
123
  pruneExpiredEntries(state)
124
+ cacheApproxBytes(state)
125
+ enforceCacheSizeBounds(state)
78
126
  return state
79
127
  }
80
128
 
@@ -90,7 +138,7 @@ export function getCachedEntry(state, key) {
90
138
  const entry = state.entries[key]
91
139
  if (!entry) return null
92
140
  if (entry.expiresAt && entry.expiresAt <= nowMs()) {
93
- delete state.entries[key]
141
+ removeCacheEntry(state, key)
94
142
  scheduleCacheFlush(state)
95
143
  return null
96
144
  }
@@ -99,11 +147,11 @@ export function getCachedEntry(state, key) {
99
147
 
100
148
  export function setCachedEntry(state, key, payload, ttlMs) {
101
149
  const cachedAt = nowMs()
102
- state.entries[key] = {
103
- cachedAt,
104
- expiresAt: cachedAt + ttlMs,
105
- payload,
106
- }
150
+ if (state.entries[key] !== undefined) removeCacheEntry(state, key)
151
+ const entry = { cachedAt, expiresAt: cachedAt + ttlMs, payload }
152
+ state.entries[key] = entry
153
+ if (typeof state.__approxBytes === 'number') state.__approxBytes += approxEntryBytes(key, entry)
154
+ enforceCacheSizeBounds(state)
107
155
  scheduleCacheFlush(state)
108
156
  return state.entries[key]
109
157
  }
@@ -121,7 +169,7 @@ function pruneExpiredEntries(state) {
121
169
  let dirty = false
122
170
  for (const [key, entry] of Object.entries(state.entries)) {
123
171
  if (entry?.expiresAt && entry.expiresAt <= current) {
124
- delete state.entries[key]
172
+ removeCacheEntry(state, key)
125
173
  dirty = true
126
174
  }
127
175
  }
@@ -1,122 +0,0 @@
1
- /**
2
- * Repro: malformed mixdog-config.json + writeSection('search', …) must not
3
- * wipe channels/memory/agent; restores from backup or throws.
4
- */
5
- import assert from 'node:assert/strict';
6
- import {
7
- mkdtempSync,
8
- mkdirSync,
9
- writeFileSync,
10
- readFileSync,
11
- rmSync,
12
- } from 'fs';
13
- import { tmpdir } from 'os';
14
- import { join, dirname } from 'path';
15
- import { fileURLToPath } from 'url';
16
-
17
- const __dirname = dirname(fileURLToPath(import.meta.url));
18
-
19
- async function loadConfigModule(dataDir, backupRoot) {
20
- process.env.CLAUDE_PLUGIN_DATA = dataDir;
21
- process.env.MIXDOG_USER_DATA_BACKUP_ROOT = backupRoot;
22
- process.env.MIXDOG_SKIP_USER_DATA_BACKUP = '1';
23
- const url = new URL(`../src/shared/config.mjs?run=${Date.now()}`, import.meta.url).href;
24
- return import(url);
25
- }
26
-
27
- function writeConfig(dataDir, obj) {
28
- writeFileSync(
29
- join(dataDir, 'mixdog-config.json'),
30
- JSON.stringify(obj, null, 2) + '\n',
31
- 'utf8',
32
- );
33
- }
34
-
35
- async function main() {
36
- const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-config-rmw-'));
37
- const backupRoot = mkdtempSync(join(tmpdir(), 'mixdog-config-backup-'));
38
-
39
- const prior = {
40
- channels: { guild: '111' },
41
- memory: { enabled: true },
42
- agent: { presets: { default: { model: 'x' } } },
43
- };
44
- writeConfig(dataDir, prior);
45
-
46
- process.env.CLAUDE_PLUGIN_DATA = dataDir;
47
- process.env.MIXDOG_USER_DATA_BACKUP_ROOT = backupRoot;
48
- const guardUrl = new URL(`../src/shared/user-data-guard.mjs?t=${Date.now()}`, import.meta.url).href;
49
- const { backupUserData, markUserDataInitialized } = await import(guardUrl);
50
- const snap = backupUserData(dataDir, 'test-fixture');
51
- assert.ok(snap.dir, 'backup fixture should copy mixdog-config.json');
52
-
53
- writeFileSync(join(dataDir, 'mixdog-config.json'), '{ not valid json\n', 'utf8');
54
-
55
- const { writeSection } = await loadConfigModule(dataDir, backupRoot);
56
- writeSection('search', { provider: 'brave' });
57
-
58
- const onDisk = JSON.parse(readFileSync(join(dataDir, 'mixdog-config.json'), 'utf8'));
59
- assert.deepEqual(onDisk.channels, prior.channels);
60
- assert.deepEqual(onDisk.memory, prior.memory);
61
- assert.deepEqual(onDisk.agent, prior.agent);
62
- assert.deepEqual(onDisk.search, { provider: 'brave' });
63
-
64
- const freshDir = mkdtempSync(join(tmpdir(), 'mixdog-config-fresh-'));
65
- const { writeSection: writeFresh } = await loadConfigModule(freshDir, backupRoot);
66
- writeFresh('search', { only: true });
67
- const freshDisk = JSON.parse(readFileSync(join(freshDir, 'mixdog-config.json'), 'utf8'));
68
- assert.deepEqual(freshDisk, { search: { only: true } });
69
-
70
- const noBackupDir = mkdtempSync(join(tmpdir(), 'mixdog-config-noback-'));
71
- markUserDataInitialized(noBackupDir);
72
- writeFileSync(join(noBackupDir, 'mixdog-config.json'), '[]', 'utf8');
73
- const { writeSection: writeNoBackup } = await loadConfigModule(
74
- noBackupDir,
75
- mkdtempSync(join(tmpdir(), 'empty-backup-')),
76
- );
77
- let threw = false;
78
- try {
79
- writeNoBackup('search', { x: 1 });
80
- } catch (err) {
81
- threw = true;
82
- assert.match(String(err.message), /refusing section write/);
83
- }
84
- assert.equal(threw, true, 'malformed config with init marker and no backup must throw');
85
-
86
- const pickRoot = mkdtempSync(join(tmpdir(), 'mixdog-config-pick-'));
87
- const fullCfg = {
88
- channels: { guild: '222' },
89
- memory: { enabled: false },
90
- agent: { presets: {} },
91
- };
92
- const oldDir = join(pickRoot, '2026-06-03T19-00-00-000Z-old-full');
93
- const newDir = join(pickRoot, '2026-06-03T21-00-00-000Z-new-degenerate');
94
- mkdirSync(oldDir, { recursive: true });
95
- mkdirSync(newDir, { recursive: true });
96
- writeFileSync(join(oldDir, 'mixdog-config.json'), JSON.stringify(fullCfg) + '\n', 'utf8');
97
- writeFileSync(
98
- join(newDir, 'mixdog-config.json'),
99
- JSON.stringify({ search: { provider: 'tavily' } }) + '\n',
100
- 'utf8',
101
- );
102
- process.env.MIXDOG_USER_DATA_BACKUP_ROOT = pickRoot;
103
- const pickUrl = new URL(`../src/shared/user-data-guard.mjs?pick=${Date.now()}`, import.meta.url).href;
104
- const { loadLatestMixdogConfigFromBackup } = await import(pickUrl);
105
- const picked = loadLatestMixdogConfigFromBackup(null);
106
- assert.deepEqual(picked?.channels, fullCfg.channels);
107
- assert.deepEqual(picked?.agent, fullCfg.agent);
108
- assert.equal(picked?.search, undefined, 'must not restore newest search-only snapshot');
109
- rmSync(pickRoot, { recursive: true, force: true });
110
-
111
- rmSync(dataDir, { recursive: true, force: true });
112
- rmSync(backupRoot, { recursive: true, force: true });
113
- rmSync(freshDir, { recursive: true, force: true });
114
- rmSync(noBackupDir, { recursive: true, force: true });
115
-
116
- console.log('test-config-rmw-restore: ok');
117
- }
118
-
119
- main().catch((err) => {
120
- console.error(err);
121
- process.exit(1);
122
- });