mixdog 0.9.23 → 0.9.25
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/package.json +2 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +327 -9
- package/scripts/channel-daemon-stub.mjs +12 -1
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/tool-smoke.mjs +38 -30
- package/src/defaults/cycle3-review-prompt.md +11 -4
- package/src/defaults/memory-promote-prompt.md +9 -0
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
- package/src/runtime/channels/backends/discord.mjs +10 -3
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
- package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +9 -28
- package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
- package/src/runtime/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +130 -2
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +19 -0
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +175 -15
- package/src/session-runtime/mcp-glue.mjs +30 -0
- package/src/session-runtime/runtime-core.mjs +91 -7
- package/src/session-runtime/session-turn-api.mjs +42 -16
- package/src/session-runtime/tool-catalog.mjs +44 -0
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +3 -1
- package/src/standalone/channel-daemon-transport.mjs +202 -8
- package/src/standalone/channel-daemon.mjs +54 -17
- package/src/standalone/channel-worker.mjs +18 -7
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/tui/App.jsx +2 -2
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +14 -2
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +246 -47
- package/src/tui/engine/agent-job-feed.mjs +47 -3
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +6 -1
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +31 -12
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +15 -5
- package/src/workflows/default/WORKFLOW.md +29 -38
- package/src/workflows/solo/WORKFLOW.md +15 -20
- package/src/runtime/channels/lib/seat-lock.mjs +0 -196
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.25",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Standalone mixdog coding-agent CLI/TUI workspace.",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
],
|
|
34
34
|
"scripts": {
|
|
35
35
|
"prepack": "npm run build:tui",
|
|
36
|
+
"prepublishOnly": "node -e \"if(!process.env.CI){console.error('local npm publish is disabled — run the Release workflow: gh workflow run release -f bump=patch');process.exit(1)}\"",
|
|
36
37
|
"start": "node src/cli.mjs",
|
|
37
38
|
"smoke": "node scripts/smoke.mjs",
|
|
38
39
|
"smoke:all": "npm run smoke && npm run smoke:boot && npm run smoke:tools && npm run smoke:output && npm run smoke:tui && npm run smoke:freevars && npm run smoke:logguard",
|
package/scripts/boot-smoke.mjs
CHANGED
|
@@ -64,7 +64,7 @@ const rows = [
|
|
|
64
64
|
try {
|
|
65
65
|
const status = runtime.toolsStatus();
|
|
66
66
|
const active = new Set(status.activeTools || []);
|
|
67
|
-
for (const name of ['read','code_graph','grep','find','glob','list','apply_patch','explore','agent','shell','task','cwd','recall','search','web_fetch','Skill','
|
|
67
|
+
for (const name of ['read','code_graph','grep','find','glob','list','apply_patch','explore','agent','shell','task','cwd','recall','search','web_fetch','Skill','load_tool']) {
|
|
68
68
|
if (!active.has(name)) throw new Error('missing ' + name + ' in ' + [...active].join(','));
|
|
69
69
|
}
|
|
70
70
|
for (const name of ['bash']) {
|
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
// Run: node scripts/channel-daemon-smoke.mjs
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
14
16
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
17
|
import { fileURLToPath } from 'node:url';
|
|
16
18
|
import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
|
|
17
19
|
import { attachToDaemon } from '../src/standalone/channel-daemon-client.mjs';
|
|
20
|
+
import { createParentBridge, setChannelNotifySink } from '../src/runtime/channels/lib/parent-bridge.mjs';
|
|
18
21
|
|
|
19
22
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
20
23
|
const STUB_ENTRY = path.join(HERE, 'channel-daemon-stub.mjs');
|
|
@@ -70,18 +73,20 @@ async function main() {
|
|
|
70
73
|
const rB = await clientB.call('fetch', { q: 2 });
|
|
71
74
|
check('client B call round-trips', rB?.ok === true && rB?.name === 'fetch');
|
|
72
75
|
|
|
73
|
-
// (3)
|
|
74
|
-
|
|
76
|
+
// (3) LAST-WINS register: the NEWEST registrant (B) steals the ownership seat,
|
|
77
|
+
// so an inbound notify targets B — NOT the first registrant. (Under the old
|
|
78
|
+
// first-wins model this went to A.)
|
|
79
|
+
transport.notify('notifications/claude/channel', { content: 'to-B' });
|
|
75
80
|
await delay(120);
|
|
76
|
-
check('notify #1
|
|
77
|
-
|
|
81
|
+
check('notify #1 targets newest registrant (B) only',
|
|
82
|
+
notB.length === 1 && notB[0]?.params?.content === 'to-B' && notA.length === 0);
|
|
78
83
|
|
|
79
|
-
// (4) bind-intent call moves the pointer to
|
|
80
|
-
await
|
|
81
|
-
transport.notify('notifications/claude/channel', { content: 'to-
|
|
84
|
+
// (4) bind-intent call moves the pointer back to A.
|
|
85
|
+
await clientA.call('rebind_current_transcript', { transcriptPath: '/tmp/a.jsonl' });
|
|
86
|
+
transport.notify('notifications/claude/channel', { content: 'to-A' });
|
|
82
87
|
await delay(120);
|
|
83
|
-
check('notify #2 delivered to
|
|
84
|
-
|
|
88
|
+
check('notify #2 delivered to A only after rebind',
|
|
89
|
+
notA.length === 1 && notA[0]?.params?.content === 'to-A' && notB.length === 1);
|
|
85
90
|
|
|
86
91
|
// (fix 1) idempotent replay: two /call with the SAME callId (a retried
|
|
87
92
|
// transport failure) must run the non-idempotent side-effect exactly once.
|
|
@@ -93,6 +98,54 @@ async function main() {
|
|
|
93
98
|
check('idempotent replay: same callId → exactly one side-effect',
|
|
94
99
|
sideEffects === 1 && d1?.ok === true && d2?.ok === true);
|
|
95
100
|
|
|
101
|
+
// (targeted) the 'acquired' badge goes ONLY to the current owner (pointer=A
|
|
102
|
+
// after the rebind), never broadcast — a displaced/non-owner TUI (B) must not
|
|
103
|
+
// light its remote badge. Still sticky for a late owner attach (below).
|
|
104
|
+
const remoteBefore = { A: notA.length, B: notB.length };
|
|
105
|
+
transport.notify('notifications/mixdog/remote', { state: 'acquired' });
|
|
106
|
+
await delay(120);
|
|
107
|
+
const gotA = notA.slice(remoteBefore.A).filter((m) => m?.method === 'notifications/mixdog/remote');
|
|
108
|
+
const gotB = notB.slice(remoteBefore.B).filter((m) => m?.method === 'notifications/mixdog/remote');
|
|
109
|
+
check('remote-state acquired targets owner (A) only, not broadcast',
|
|
110
|
+
gotA.length === 1 && gotB.length === 0);
|
|
111
|
+
|
|
112
|
+
// A late client that registers becomes the NEWEST owner (steals the pointer),
|
|
113
|
+
// so on attach it replays the sticky 'acquired' badge for itself.
|
|
114
|
+
const notC = [];
|
|
115
|
+
const clientC = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'C', onNotify: (m) => notC.push(m) });
|
|
116
|
+
await waitFor(() => notC.some((m) => m?.method === 'notifications/mixdog/remote'), 1000);
|
|
117
|
+
check('late owner receives replayed remote-state on attach',
|
|
118
|
+
notC.filter((m) => m?.method === 'notifications/mixdog/remote' && m?.params?.state === 'acquired').length === 1);
|
|
119
|
+
await clientC.close();
|
|
120
|
+
|
|
121
|
+
// (replay) 'superseded' CLEARS the sticky: a client attaching after it must
|
|
122
|
+
// receive NO remote-state replay (else it would wrongly stop a fresh client).
|
|
123
|
+
transport.notify('notifications/mixdog/remote', { state: 'superseded' });
|
|
124
|
+
await delay(60);
|
|
125
|
+
const notD = [];
|
|
126
|
+
const clientD = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'D', onNotify: (m) => notD.push(m) });
|
|
127
|
+
await delay(150);
|
|
128
|
+
check('attach after superseded receives no remote-state replay',
|
|
129
|
+
notD.filter((m) => m?.method === 'notifications/mixdog/remote').length === 0);
|
|
130
|
+
await clientD.close();
|
|
131
|
+
|
|
132
|
+
// (sink) daemon-mode wiring: owned-runtime routes remote-state through the
|
|
133
|
+
// parent bridge's sendNotifyToParent (NOT raw process.send). The daemon
|
|
134
|
+
// installs a sink -> transport.notify (channel-daemon.mjs:106). Assert an
|
|
135
|
+
// 'acquired' emitted via that sink reaches the transport and replays to a
|
|
136
|
+
// late client — the exact daemon-mode path a raw process.send would drop.
|
|
137
|
+
const { sendNotifyToParent } = createParentBridge({ getInstanceId: () => 'smoke' });
|
|
138
|
+
setChannelNotifySink((method, params) => transport.notify(method, params));
|
|
139
|
+
sendNotifyToParent('notifications/mixdog/remote', { state: 'acquired' });
|
|
140
|
+
await delay(60);
|
|
141
|
+
const notE = [];
|
|
142
|
+
const clientE = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'E', onNotify: (m) => notE.push(m) });
|
|
143
|
+
await waitFor(() => notE.some((m) => m?.method === 'notifications/mixdog/remote'), 1000);
|
|
144
|
+
check('daemon-mode acquire via notify sink reaches transport + replays',
|
|
145
|
+
notE.filter((m) => m?.method === 'notifications/mixdog/remote' && m?.params?.state === 'acquired').length === 1);
|
|
146
|
+
await clientE.close();
|
|
147
|
+
setChannelNotifySink(null);
|
|
148
|
+
|
|
96
149
|
// (5) deregister both → self-shutdown fires after the client grace window.
|
|
97
150
|
await clientA.close();
|
|
98
151
|
await clientB.close();
|
|
@@ -103,6 +156,10 @@ async function main() {
|
|
|
103
156
|
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
104
157
|
|
|
105
158
|
await flipTest();
|
|
159
|
+
await pointerStealTest();
|
|
160
|
+
await reattachBufferTest();
|
|
161
|
+
await pointerFailoverTest();
|
|
162
|
+
await pointerReconnectFollowsTest();
|
|
106
163
|
|
|
107
164
|
console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
|
|
108
165
|
process.exit(failures === 0 ? 0 : 1);
|
|
@@ -162,4 +219,265 @@ async function flipTest() {
|
|
|
162
219
|
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
163
220
|
}
|
|
164
221
|
|
|
222
|
+
// Last-wins ownership steal coverage with DISTINCT alive leadPids (two sleeper
|
|
223
|
+
// child processes) — the only way to observe the targeted 'superseded' frame,
|
|
224
|
+
// which is skipped when old/new client share a leadPid (same-TUI rebind).
|
|
225
|
+
async function pointerStealTest() {
|
|
226
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-steal-smoke-'));
|
|
227
|
+
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
228
|
+
const transport = createChannelDaemonTransport({
|
|
229
|
+
handleCall: async (name, args, ctx) => ({ ok: true, name, leadPid: ctx.leadPid }),
|
|
230
|
+
discoveryPath,
|
|
231
|
+
clientGraceMs: 2000,
|
|
232
|
+
sweepMs: 5000,
|
|
233
|
+
onClientsEmpty: () => {},
|
|
234
|
+
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[steal] ${m}`),
|
|
235
|
+
});
|
|
236
|
+
const { port, token } = await transport.start();
|
|
237
|
+
const discovery = { port, token, pid: process.pid };
|
|
238
|
+
const REMOTE = 'notifications/mixdog/remote';
|
|
239
|
+
|
|
240
|
+
// Two long-lived children give us two DISTINCT alive lead pids.
|
|
241
|
+
const sleeperA = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
242
|
+
const sleeperB = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
243
|
+
await delay(80);
|
|
244
|
+
|
|
245
|
+
const notA = [];
|
|
246
|
+
const notB = [];
|
|
247
|
+
const clientA = await attachToDaemon({ discovery, leadPid: sleeperA.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
|
|
248
|
+
await delay(100); // A registers → A is the owner
|
|
249
|
+
const clientB = await attachToDaemon({ discovery, leadPid: sleeperB.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
|
|
250
|
+
await delay(150); // B registers → steals the seat, A gets targeted superseded
|
|
251
|
+
|
|
252
|
+
check('steal: newest registrant (B) becomes the owner pointer',
|
|
253
|
+
transport._resolveTargetForTest()?.leadPid === sleeperB.pid);
|
|
254
|
+
check('steal: displaced owner (A) got a TARGETED superseded on register',
|
|
255
|
+
notA.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
|
|
256
|
+
check('steal: new owner (B) got no superseded',
|
|
257
|
+
notB.filter((m) => m?.method === REMOTE).length === 0);
|
|
258
|
+
|
|
259
|
+
// 'acquired' now targets the owner (B) only — never broadcast to A.
|
|
260
|
+
transport.notify(REMOTE, { state: 'acquired' });
|
|
261
|
+
await delay(120);
|
|
262
|
+
check('steal: acquired targets owner (B) only, not broadcast',
|
|
263
|
+
notB.filter((m) => m?.params?.state === 'acquired').length === 1 &&
|
|
264
|
+
notA.filter((m) => m?.params?.state === 'acquired').length === 0);
|
|
265
|
+
|
|
266
|
+
// A bind-intent /call from A steals the seat back → B (displaced) superseded.
|
|
267
|
+
await clientA.call('activate_channel_bridge', {});
|
|
268
|
+
await delay(120);
|
|
269
|
+
check('steal: bind /call moves pointer back to A',
|
|
270
|
+
transport._resolveTargetForTest()?.leadPid === sleeperA.pid);
|
|
271
|
+
check('steal: displaced owner (B) got targeted superseded on bind /call',
|
|
272
|
+
notB.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
|
|
273
|
+
|
|
274
|
+
// superseded via notify() (seat lost to another daemon) still BROADCASTS to
|
|
275
|
+
// every live client, regardless of pointer.
|
|
276
|
+
const aBefore = notA.length;
|
|
277
|
+
const bBefore = notB.length;
|
|
278
|
+
transport.notify(REMOTE, { state: 'superseded' });
|
|
279
|
+
await delay(120);
|
|
280
|
+
check('steal: superseded via notify broadcasts to ALL live clients',
|
|
281
|
+
notA.slice(aBefore).some((m) => m?.params?.state === 'superseded') &&
|
|
282
|
+
notB.slice(bBefore).some((m) => m?.params?.state === 'superseded'));
|
|
283
|
+
|
|
284
|
+
await clientA.close();
|
|
285
|
+
await clientB.close();
|
|
286
|
+
try { sleeperA.kill(); } catch {}
|
|
287
|
+
try { sleeperB.kill(); } catch {}
|
|
288
|
+
await transport.stop();
|
|
289
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Low-level helpers: register/attach WITHOUT the auto-attach client, so a
|
|
293
|
+
// client can be registered but leave its SSE stream closed (to exercise the
|
|
294
|
+
// buffered-superseded + reattach paths that attachToDaemon hides).
|
|
295
|
+
function rawPost(port, serverToken, p, body) {
|
|
296
|
+
return new Promise((resolve, reject) => {
|
|
297
|
+
const payload = JSON.stringify(body);
|
|
298
|
+
const req = http.request({
|
|
299
|
+
hostname: '127.0.0.1', port, path: p, method: 'POST',
|
|
300
|
+
headers: { 'X-Mixdog-Daemon-Token': serverToken, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
301
|
+
}, (res) => { let d = ''; res.setEncoding('utf8'); res.on('data', (c) => { d += c; }); res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({}); } }); });
|
|
302
|
+
req.on('error', reject); req.write(payload); req.end();
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
function rawSse(port, serverToken, clientToken, onNotify) {
|
|
306
|
+
const req = http.request({
|
|
307
|
+
hostname: '127.0.0.1', port,
|
|
308
|
+
path: `/events?token=${encodeURIComponent(clientToken)}&server_token=${encodeURIComponent(serverToken)}`,
|
|
309
|
+
method: 'GET', headers: { Accept: 'text/event-stream', 'X-Mixdog-Daemon-Token': serverToken },
|
|
310
|
+
}, (res) => {
|
|
311
|
+
res.setEncoding('utf8'); let buf = '';
|
|
312
|
+
res.on('data', (chunk) => {
|
|
313
|
+
buf += chunk; let idx;
|
|
314
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
315
|
+
const raw = buf.slice(0, idx); buf = buf.slice(idx + 2);
|
|
316
|
+
for (const line of raw.split('\n')) {
|
|
317
|
+
if (!line.startsWith('data:')) continue;
|
|
318
|
+
const j = line.slice(5).trim(); if (!j) continue;
|
|
319
|
+
let m = null; try { m = JSON.parse(j); } catch { continue; }
|
|
320
|
+
if (m?.type === 'notify') onNotify(m);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
req.end(); return req;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Fix coverage: (1) an SSE-reconnect re-register must NOT steal ownership;
|
|
329
|
+
// (2) a targeted 'superseded' emitted while the displaced client has no live
|
|
330
|
+
// stream is buffered and flushed when its stream (re)attaches.
|
|
331
|
+
async function reattachBufferTest() {
|
|
332
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-reattach-smoke-'));
|
|
333
|
+
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
334
|
+
const transport = createChannelDaemonTransport({
|
|
335
|
+
handleCall: async () => ({ ok: true }),
|
|
336
|
+
discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
337
|
+
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[reattach] ${m}`),
|
|
338
|
+
});
|
|
339
|
+
const { port, token } = await transport.start();
|
|
340
|
+
const REMOTE = 'notifications/mixdog/remote';
|
|
341
|
+
const s1 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
342
|
+
const s2 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
343
|
+
await delay(80);
|
|
344
|
+
|
|
345
|
+
// (1) reconnect with the SAME leadPid: the pointer FOLLOWS to the fresh token
|
|
346
|
+
// (its old dead-stream entry is dropped), never stealing from a DIFFERENT
|
|
347
|
+
// owner. Register X (fresh) → owner; re-register reattach:true → pointer moves
|
|
348
|
+
// to the new token and the old entry is gone (no dead-stream blackhole).
|
|
349
|
+
const regX = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X' });
|
|
350
|
+
await delay(40);
|
|
351
|
+
check('reattach: fresh register X becomes owner',
|
|
352
|
+
transport._resolveTargetForTest()?.token === regX.token);
|
|
353
|
+
const regReconnect = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X', reattach: true });
|
|
354
|
+
await delay(40);
|
|
355
|
+
check('reattach: reconnect (same pid) pointer follows fresh token, old dropped',
|
|
356
|
+
transport._resolveTargetForTest()?.token === regReconnect.token &&
|
|
357
|
+
!transport._clientsForTest.has(regX.token));
|
|
358
|
+
|
|
359
|
+
// (2) X's current entry never opened a stream; a fresh register Y steals the
|
|
360
|
+
// seat → X's targeted 'superseded' is BUFFERED, then delivered on late attach.
|
|
361
|
+
const regY = await rawPost(port, token, '/client/register', { leadPid: s2.pid, cwd: 'Y' });
|
|
362
|
+
await delay(40);
|
|
363
|
+
check('reattach: fresh Y steals the seat from X',
|
|
364
|
+
transport._resolveTargetForTest()?.token === regY.token);
|
|
365
|
+
const notX = [];
|
|
366
|
+
const sseX = rawSse(port, token, regReconnect.token, (m) => notX.push(m));
|
|
367
|
+
await waitFor(() => notX.some((m) => m?.method === REMOTE && m?.params?.state === 'superseded'), 1000);
|
|
368
|
+
check('reattach: buffered superseded flushed to X after late SSE attach',
|
|
369
|
+
notX.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
|
|
370
|
+
|
|
371
|
+
try { sseX.destroy(); } catch {}
|
|
372
|
+
try { s1.kill(); } catch {}
|
|
373
|
+
try { s2.kill(); } catch {}
|
|
374
|
+
await transport.stop();
|
|
375
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
376
|
+
}
|
|
377
|
+
|
|
165
378
|
main().catch((err) => { console.error(err); process.exit(1); });
|
|
379
|
+
|
|
380
|
+
// Pointer-failover coverage: the pointer client dies (deregister) while a
|
|
381
|
+
// live client remains. The transport must move the pointer to the survivor
|
|
382
|
+
// (reason 'failover'), deliver the sticky 'acquired' badge to it, and
|
|
383
|
+
// re-dispatch the survivor's stored bind intent via the injected dispatchBind
|
|
384
|
+
// so the output forwarder rebinds to the survivor's transcript.
|
|
385
|
+
async function pointerFailoverTest() {
|
|
386
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-failover-smoke-'));
|
|
387
|
+
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
388
|
+
const REMOTE = 'notifications/mixdog/remote';
|
|
389
|
+
const rebinds = []; // failover re-dispatches routed through dispatchBind
|
|
390
|
+
const handleCall = async (name, args, ctx) => ({ ok: true, name, args, leadPid: ctx.leadPid });
|
|
391
|
+
const dispatchBind = (name, args, ctx) => {
|
|
392
|
+
rebinds.push({ name, args, leadPid: ctx.leadPid });
|
|
393
|
+
return handleCall(name, args, ctx);
|
|
394
|
+
};
|
|
395
|
+
const transport = createChannelDaemonTransport({
|
|
396
|
+
handleCall,
|
|
397
|
+
dispatchBind,
|
|
398
|
+
discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
399
|
+
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[failover] ${m}`),
|
|
400
|
+
});
|
|
401
|
+
const { port, token } = await transport.start();
|
|
402
|
+
const discovery = { port, token, pid: process.pid };
|
|
403
|
+
|
|
404
|
+
const sleeperA = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
405
|
+
const sleeperB = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
406
|
+
await delay(80);
|
|
407
|
+
|
|
408
|
+
const notA = [];
|
|
409
|
+
const notB = [];
|
|
410
|
+
const clientA = await attachToDaemon({ discovery, leadPid: sleeperA.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
|
|
411
|
+
await clientA.call('rebind_current_transcript', { transcriptPath: '/tmp/a.jsonl' }); // A stores bind intent
|
|
412
|
+
const clientB = await attachToDaemon({ discovery, leadPid: sleeperB.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
|
|
413
|
+
await clientB.call('rebind_current_transcript', { transcriptPath: '/tmp/b.jsonl' }); // B owner + bind intent
|
|
414
|
+
await delay(120);
|
|
415
|
+
check('failover: newest binder (B) owns the pointer', transport._resolveTargetForTest()?.leadPid === sleeperB.pid);
|
|
416
|
+
|
|
417
|
+
const aBefore = notA.length;
|
|
418
|
+
await clientB.close(); // pointer client dies → failover to survivor A
|
|
419
|
+
await waitFor(() => transport._resolveTargetForTest()?.leadPid === sleeperA.pid, 1000);
|
|
420
|
+
check('failover: pointer moves to surviving client (A)',
|
|
421
|
+
transport._resolveTargetForTest()?.leadPid === sleeperA.pid);
|
|
422
|
+
await waitFor(() => notA.slice(aBefore).some((m) => m?.method === REMOTE && m?.params?.state === 'acquired'), 1000);
|
|
423
|
+
check('failover: survivor (A) receives the sticky acquired badge',
|
|
424
|
+
notA.slice(aBefore).filter((m) => m?.method === REMOTE && m?.params?.state === 'acquired').length === 1);
|
|
425
|
+
await waitFor(() => rebinds.some((r) => r.leadPid === sleeperA.pid), 1000);
|
|
426
|
+
check('failover: survivor bind intent re-dispatched to its transcript',
|
|
427
|
+
rebinds.some((r) => r.name === 'rebind_current_transcript'
|
|
428
|
+
&& r.args?.transcriptPath === '/tmp/a.jsonl' && r.leadPid === sleeperA.pid));
|
|
429
|
+
|
|
430
|
+
await clientA.close();
|
|
431
|
+
try { sleeperA.kill(); } catch {}
|
|
432
|
+
try { sleeperB.kill(); } catch {}
|
|
433
|
+
await transport.stop();
|
|
434
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Reattach-after-pointer-loss: the pointer client's SSE drops and it
|
|
438
|
+
// re-registers (reattach:true, SAME leadPid) with a FRESH token. The pointer
|
|
439
|
+
// must FOLLOW to the new token (old dead-stream entry dropped, no failover/
|
|
440
|
+
// self-superseded), and a notify must reach the NEW stream — not blackhole on
|
|
441
|
+
// the old entry.
|
|
442
|
+
async function pointerReconnectFollowsTest() {
|
|
443
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-reconnect-smoke-'));
|
|
444
|
+
const discoveryPath = path.join(tmp, 'channel-daemon.json');
|
|
445
|
+
const transport = createChannelDaemonTransport({
|
|
446
|
+
handleCall: async () => ({ ok: true }),
|
|
447
|
+
discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
|
|
448
|
+
log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[reconnect] ${m}`),
|
|
449
|
+
});
|
|
450
|
+
const { port, token } = await transport.start();
|
|
451
|
+
const s1 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
|
|
452
|
+
await delay(80);
|
|
453
|
+
|
|
454
|
+
// Register X (fresh) → owner; open its SSE, then let the stream drop.
|
|
455
|
+
const regX = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X' });
|
|
456
|
+
const notOld = [];
|
|
457
|
+
const sseOld = rawSse(port, token, regX.token, (m) => notOld.push(m));
|
|
458
|
+
await delay(60);
|
|
459
|
+
try { sseOld.destroy(); } catch {}
|
|
460
|
+
await delay(40);
|
|
461
|
+
|
|
462
|
+
// Reconnect re-register (same leadPid) → fresh token; pointer must FOLLOW it.
|
|
463
|
+
const regNew = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X', reattach: true });
|
|
464
|
+
await delay(40);
|
|
465
|
+
check('reconnect: pointer follows the fresh reconnect token',
|
|
466
|
+
transport._resolveTargetForTest()?.token === regNew.token && regNew.token !== regX.token);
|
|
467
|
+
check('reconnect: dead old entry was dropped', !transport._clientsForTest.has(regX.token));
|
|
468
|
+
|
|
469
|
+
// A notify must reach the NEW stream (no blackhole on the old dead entry).
|
|
470
|
+
const notNew = [];
|
|
471
|
+
const sseNew = rawSse(port, token, regNew.token, (m) => notNew.push(m));
|
|
472
|
+
await delay(60);
|
|
473
|
+
transport.notify('notifications/claude/channel', { content: 'to-reconnect' });
|
|
474
|
+
await waitFor(() => notNew.some((m) => m?.params?.content === 'to-reconnect'), 1000);
|
|
475
|
+
check('reconnect: notify reaches the new stream, not the dead old entry',
|
|
476
|
+
notNew.some((m) => m?.params?.content === 'to-reconnect') &&
|
|
477
|
+
!notOld.some((m) => m?.params?.content === 'to-reconnect'));
|
|
478
|
+
|
|
479
|
+
try { sseNew.destroy(); } catch {}
|
|
480
|
+
try { s1.kill(); } catch {}
|
|
481
|
+
await transport.stop();
|
|
482
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
483
|
+
}
|
|
@@ -8,7 +8,7 @@ process.env.MIXDOG_WORKER_MODE = process.env.MIXDOG_WORKER_MODE || '1';
|
|
|
8
8
|
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
-
import { mkdirSync } from 'node:fs';
|
|
11
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { claimSingletonOwner, releaseSingletonOwner } from '../src/runtime/shared/singleton-owner.mjs';
|
|
13
13
|
import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
|
|
14
14
|
|
|
@@ -40,6 +40,17 @@ async function main() {
|
|
|
40
40
|
if (!claim.owned) { process.exit(0); } // race loser → spawner attaches to winner
|
|
41
41
|
process.on('exit', () => { try { releaseSingletonOwner(OWNER_PATH, process.pid); } catch {} });
|
|
42
42
|
|
|
43
|
+
// Optional env dump for the tool smoke: proves daemonEnv() spawned us with
|
|
44
|
+
// the daemon-mode flags (MIXDOG_CHANNEL_DAEMON=1, MIXDOG_CLI_OWNED=0).
|
|
45
|
+
if (process.env.SMOKE_CHANNEL_ENV_OUT) {
|
|
46
|
+
try {
|
|
47
|
+
writeFileSync(process.env.SMOKE_CHANNEL_ENV_OUT, JSON.stringify({
|
|
48
|
+
cliOwned: process.env.MIXDOG_CLI_OWNED,
|
|
49
|
+
daemon: process.env.MIXDOG_CHANNEL_DAEMON,
|
|
50
|
+
}));
|
|
51
|
+
} catch { /* smoke-only, best-effort */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
// Stub runtime: echo the call + caller identity, and (for 'fetch') emit a
|
|
44
55
|
// notify AFTER responding so the smoke can assert targeted routing.
|
|
45
56
|
const handleCall = async (name, args, ctx) => {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Proves the debounced skills save persists through the ASYNC config path and
|
|
3
|
+
// that the debounce-timer flush routes through async I/O (async icacls), never
|
|
4
|
+
// the sync icacls, on the timer path.
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join, resolve, dirname } from 'node:path';
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const SRC = resolve(__dirname, '../src');
|
|
14
|
+
|
|
15
|
+
test('patchSkillsDisabledAsync persists agent.skills.disabled to disk (async path)', async () => {
|
|
16
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-skills-async-'));
|
|
17
|
+
process.env.MIXDOG_DATA_DIR = dataDir;
|
|
18
|
+
// Keep the test hermetic: skip the user-data backup copy tree.
|
|
19
|
+
process.env.MIXDOG_SKIP_USER_DATA_BACKUP = '1';
|
|
20
|
+
try {
|
|
21
|
+
// Import AFTER env is set: shared/config.mjs resolves DATA_DIR at load.
|
|
22
|
+
const cfg = await import(pathToFileURL(join(SRC, 'runtime/agent/orchestrator/config.mjs')).href);
|
|
23
|
+
const out = await cfg.patchSkillsDisabledAsync(['zeta', 'alpha']);
|
|
24
|
+
assert.deepEqual(out.disabled, ['alpha', 'zeta']); // normalized + sorted
|
|
25
|
+
const onDisk = JSON.parse(readFileSync(join(dataDir, 'mixdog-config.json'), 'utf8'));
|
|
26
|
+
assert.deepEqual(onDisk.agent.skills.disabled, ['alpha', 'zeta']);
|
|
27
|
+
// A second async patch must last-writer-win, not append.
|
|
28
|
+
await cfg.patchSkillsDisabledAsync(['beta']);
|
|
29
|
+
const onDisk2 = JSON.parse(readFileSync(join(dataDir, 'mixdog-config.json'), 'utf8'));
|
|
30
|
+
assert.deepEqual(onDisk2.agent.skills.disabled, ['beta']);
|
|
31
|
+
} finally {
|
|
32
|
+
try { rmSync(dataDir, { recursive: true, force: true }); } catch {}
|
|
33
|
+
delete process.env.MIXDOG_DATA_DIR;
|
|
34
|
+
delete process.env.MIXDOG_SKIP_USER_DATA_BACKUP;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('debounce timer path routes through the async flush (async icacls), sync flush retained', () => {
|
|
39
|
+
const lifecycle = readFileSync(`${SRC}/session-runtime/config-lifecycle.mjs`, 'utf8');
|
|
40
|
+
// Skills timer schedules the ASYNC flush, and the async runner uses the async
|
|
41
|
+
// persist twin — so the timer path never hits the sync icacls RMW.
|
|
42
|
+
assert.match(lifecycle, /scheduleSkillsSave\(names\)[\s\S]*?setTimeout\(\(\) => \{ flushSkillsSaveAsync\(\); \}/);
|
|
43
|
+
assert.match(lifecycle, /runSkillsFlushAsync[\s\S]*?cfgMod\.patchSkillsDisabledAsync\(names\)/);
|
|
44
|
+
// Config/backend/outputStyle timers likewise fire the async flush.
|
|
45
|
+
assert.match(lifecycle, /setTimeout\(\(\) => \{ flushConfigSaveAsync\(\); \}/);
|
|
46
|
+
assert.match(lifecycle, /setTimeout\(\(\) => \{ flushBackendSaveAsync\(\); \}/);
|
|
47
|
+
assert.match(lifecycle, /setTimeout\(\(\) => \{ flushOutputStyleSaveAsync\(\); \}/);
|
|
48
|
+
// Sync flushes stay for reloadFullConfig/teardown durability.
|
|
49
|
+
assert.match(lifecycle, /function flushConfigSave\(\)/);
|
|
50
|
+
assert.match(lifecycle, /function flushSkillsSave\(\)/);
|
|
51
|
+
assert.match(lifecycle, /reloadFullConfig[\s\S]*?flushConfigSave\(\);/);
|
|
52
|
+
|
|
53
|
+
const atomic = readFileSync(`${SRC}/runtime/shared/atomic-file.mjs`, 'utf8');
|
|
54
|
+
// The async writer enforces the owner-only ACL via the async icacls variant.
|
|
55
|
+
assert.match(atomic, /export async function writeFileAtomicAsync[\s\S]*?await _enforceOwnerOnlyAclWin32Async\(tmp\)/);
|
|
56
|
+
assert.match(atomic, /_enforceOwnerOnlyAclWin32Async[\s\S]*?_execFileAsync\(icacls/);
|
|
57
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { runExplore } from '../src/standalone/explore-tool.mjs';
|
|
2
|
+
const queries = [
|
|
3
|
+
'standalone explore tool implementation entry point',
|
|
4
|
+
'V4A patch format parsing/conversion implementation',
|
|
5
|
+
'recall memory entries persistent store (sqlite) read/query implementation',
|
|
6
|
+
'output styles: how style definitions are loaded and applied at session runtime',
|
|
7
|
+
'Discord backend outbound message send implementation',
|
|
8
|
+
'shell command destructive/danger policy scan before execution',
|
|
9
|
+
'where does mixdog store per-user data and sessions on this machine (out of repo)',
|
|
10
|
+
'channel runtime crash logging write path',
|
|
11
|
+
];
|
|
12
|
+
const t0 = Date.now();
|
|
13
|
+
const res = await runExplore({ query: queries, cwd: 'C:/Project/mixdog' }, { callerCwd: 'C:/Project/mixdog' });
|
|
14
|
+
console.log('BATCH_ELAPSED_MS', Date.now() - t0);
|
|
15
|
+
const txt = res.content?.[0]?.text || '';
|
|
16
|
+
console.log('FAILED_COUNT', (txt.match(/EXPLORATION_FAILED/g) || []).length);
|
|
17
|
+
process.exit(0);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fuzzyRank } from '../src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs';
|
|
7
|
+
import { executeFuzzyFindTool } from '../src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs';
|
|
8
|
+
import { runRg } from '../src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs';
|
|
9
|
+
|
|
10
|
+
// ── fuzzyRank score floor ────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
test('fuzzyRank keeps an exact basename/substring hit and drops scattered noise', () => {
|
|
13
|
+
const items = [
|
|
14
|
+
{ path: '.mixdog/data/tool-events.log' },
|
|
15
|
+
// subsequence-only junk: the query chars appear in order but scattered
|
|
16
|
+
// mid-word across dirs, no contiguity or word boundaries.
|
|
17
|
+
{ path: 'pgadmin/xtx/oxoxl/exvxexnxtx/sxlxoxg/records.dat' },
|
|
18
|
+
];
|
|
19
|
+
const ranked = fuzzyRank('tool-events.log', items);
|
|
20
|
+
assert.ok(ranked.length >= 1, 'the real file must survive the floor');
|
|
21
|
+
assert.equal(ranked[0].item.path, '.mixdog/data/tool-events.log');
|
|
22
|
+
assert.ok(
|
|
23
|
+
!ranked.some((r) => r.item.path.startsWith('pgadmin/')),
|
|
24
|
+
'scattered subsequence junk must be filtered out',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('fuzzyRank floor drops a purely scattered subsequence but keeps a substring', () => {
|
|
29
|
+
const strong = { path: 'src/abcdef.txt' };
|
|
30
|
+
const junk = { path: 'xax/xbxcx/xdxexfx/z.bin' };
|
|
31
|
+
const ranked = fuzzyRank('abcdef', [strong, junk]);
|
|
32
|
+
assert.deepEqual(ranked.map((r) => r.item.path), ['src/abcdef.txt']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ── hidden-directory discovery via the find tool ─────────────────────────
|
|
36
|
+
|
|
37
|
+
function makeRepo() {
|
|
38
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-find-hidden-'));
|
|
39
|
+
mkdirSync(join(root, '.mixdog', 'data'), { recursive: true });
|
|
40
|
+
writeFileSync(join(root, '.mixdog', 'data', 'tool-events.log'), 'x\n');
|
|
41
|
+
mkdirSync(join(root, 'src'), { recursive: true });
|
|
42
|
+
writeFileSync(join(root, 'src', 'unrelated.txt'), 'x\n');
|
|
43
|
+
// .git noise must stay pruned even with hidden default true.
|
|
44
|
+
mkdirSync(join(root, '.git'), { recursive: true });
|
|
45
|
+
writeFileSync(join(root, '.git', 'tool-events.log'), 'x\n');
|
|
46
|
+
return root;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('find surfaces a file under a dot-directory top-ranked (hidden default true)', async () => {
|
|
50
|
+
const root = makeRepo();
|
|
51
|
+
try {
|
|
52
|
+
const out = await executeFuzzyFindTool({ query: 'tool-events.log' }, root);
|
|
53
|
+
const lines = out.split('\n').filter(Boolean);
|
|
54
|
+
assert.ok(lines.length >= 1, `expected a hit, got: ${out}`);
|
|
55
|
+
assert.equal(lines[0], '.mixdog/data/tool-events.log');
|
|
56
|
+
assert.ok(!out.includes('.git/'), '.git must stay pruned as noise');
|
|
57
|
+
} finally {
|
|
58
|
+
rmSync(root, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('find hidden:false skips dot-directories', async () => {
|
|
63
|
+
const root = makeRepo();
|
|
64
|
+
try {
|
|
65
|
+
const out = await executeFuzzyFindTool({ query: 'tool-events.log', hidden: false }, root);
|
|
66
|
+
assert.ok(!out.includes('.mixdog'), `hidden:false must not descend dot-dirs: ${out}`);
|
|
67
|
+
} finally {
|
|
68
|
+
rmSync(root, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ── narrowed-pass merge / dedup backstop ─────────────────────────────────
|
|
73
|
+
|
|
74
|
+
test('exact-name hit survives among many decoys and is not duplicated', async () => {
|
|
75
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-find-merge-'));
|
|
76
|
+
try {
|
|
77
|
+
// A deep exact-name target plus a pile of unrelated decoys. The target
|
|
78
|
+
// matches both the broad enumeration and the query-narrowed pass, so it
|
|
79
|
+
// exercises the merge+dedup path.
|
|
80
|
+
mkdirSync(join(root, 'deep', 'nested', 'here'), { recursive: true });
|
|
81
|
+
writeFileSync(join(root, 'deep', 'nested', 'here', 'tool-events.log'), 'x\n');
|
|
82
|
+
mkdirSync(join(root, 'noise'), { recursive: true });
|
|
83
|
+
for (let i = 0; i < 200; i++) {
|
|
84
|
+
writeFileSync(join(root, 'noise', `decoy-${i}.bin`), 'x\n');
|
|
85
|
+
}
|
|
86
|
+
const out = await executeFuzzyFindTool({ query: 'tool-events.log' }, root);
|
|
87
|
+
const lines = out.split('\n').filter(Boolean);
|
|
88
|
+
assert.equal(lines[0], 'deep/nested/here/tool-events.log');
|
|
89
|
+
const hits = lines.filter((l) => l === 'deep/nested/here/tool-events.log');
|
|
90
|
+
assert.equal(hits.length, 1, `merge must dedup, got ${hits.length}: ${out}`);
|
|
91
|
+
} finally {
|
|
92
|
+
rmSync(root, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── truncation warning surfaces when the broad pass reports .truncated ────
|
|
97
|
+
|
|
98
|
+
test('truncated broad enumeration emits a visible warning, still ranks the exact hit, and is not cached', async () => {
|
|
99
|
+
const root = makeRepo();
|
|
100
|
+
try {
|
|
101
|
+
// Test-only seam: wrap the real runRg so the BROAD pass (the one without
|
|
102
|
+
// `--iglob`) reports truncation via the boxed-String contract the tool
|
|
103
|
+
// relies on (.truncated=true), while the narrowed pass is left intact.
|
|
104
|
+
// Production options never carry __runRg, so the real path is unchanged.
|
|
105
|
+
const truncatingRunRg = async (argsList, execOptions) => {
|
|
106
|
+
const out = await runRg(argsList, execOptions);
|
|
107
|
+
if (argsList.includes('--iglob')) return out; // narrowed backstop unchanged
|
|
108
|
+
const boxed = new String(String(out));
|
|
109
|
+
boxed.truncated = true;
|
|
110
|
+
return boxed;
|
|
111
|
+
};
|
|
112
|
+
const out = await executeFuzzyFindTool(
|
|
113
|
+
{ query: 'tool-events.log' }, root, { __runRg: truncatingRunRg },
|
|
114
|
+
);
|
|
115
|
+
assert.ok(out.includes('[warning]'), `truncated broad pass must warn: ${out}`);
|
|
116
|
+
assert.ok(out.includes('truncated at 20MB cap'), `warning text must surface: ${out}`);
|
|
117
|
+
const lines = out.split('\n').filter(Boolean);
|
|
118
|
+
assert.equal(lines[0], '.mixdog/data/tool-events.log', 'exact hit must still rank first');
|
|
119
|
+
// Not cached: a normal follow-up run (no injected truncation) must NOT
|
|
120
|
+
// return the warning — proving the truncated result was never cached.
|
|
121
|
+
const out2 = await executeFuzzyFindTool({ query: 'tool-events.log' }, root);
|
|
122
|
+
assert.ok(!out2.includes('[warning]'), `truncated result must not be cached/served: ${out2}`);
|
|
123
|
+
} finally {
|
|
124
|
+
rmSync(root, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── narrowed backstop treats the query as a literal (glob metachars) ──────
|
|
129
|
+
|
|
130
|
+
test('a query with glob metachars still gets the narrowed backstop', async () => {
|
|
131
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-find-glob-'));
|
|
132
|
+
try {
|
|
133
|
+
// "[slug].tsx" contains a character-class metachar; a raw *[slug].tsx*
|
|
134
|
+
// iglob would match one of s/l/u/g, not the literal filename. The tool
|
|
135
|
+
// must escape it so the exact-name file is still surfaced.
|
|
136
|
+
mkdirSync(join(root, 'app'), { recursive: true });
|
|
137
|
+
writeFileSync(join(root, 'app', '[slug].tsx'), 'x\n');
|
|
138
|
+
writeFileSync(join(root, 'app', 'slug.tsx'), 'x\n'); // class-match decoy
|
|
139
|
+
const out = await executeFuzzyFindTool({ query: '[slug].tsx' }, root);
|
|
140
|
+
const lines = out.split('\n').filter(Boolean);
|
|
141
|
+
assert.ok(lines.includes('app/[slug].tsx'), `literal metachar hit must surface: ${out}`);
|
|
142
|
+
} finally {
|
|
143
|
+
rmSync(root, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|