mixdog 0.9.49 → 0.9.50

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/README.md CHANGED
@@ -152,12 +152,12 @@ current Anthropic models.
152
152
  ## Scripts
153
153
 
154
154
  ```bash
155
- npm run smoke # basic smoke check
156
- npm run smoke:all # core smoke suite
157
- npm run smoke:tui # TUI render smoke
158
- npm run smoke:tools # tool smoke suite
159
- npm run build:tui # build the bundled Ink TUI
160
- npm run audit:models # inspect model catalog metadata
155
+ npm run smoke # fast core feature smoke
156
+ npm run smoke:all # feature-surface smoke suite
157
+ npm run smoke:tui # TUI feature smoke
158
+ npm run test:tool-contracts # optional tool contract suite
159
+ npm run build:tui # build the bundled Ink TUI
160
+ npm run audit:models # inspect model catalog metadata
161
161
  ```
162
162
 
163
163
  Additional diagnostics and benchmarks live under `scripts/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.49",
3
+ "version": "0.9.50",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -36,14 +36,14 @@
36
36
  "prepack": "npm run build:tui",
37
37
  "prepublishOnly": "node -e \"if(!process.env.CI){console.error('local npm publish is disabled — run npm run release:patch');process.exit(1)}\"",
38
38
  "start": "node src/cli.mjs",
39
- "smoke": "node scripts/smoke.mjs && node --test scripts/ship-mode-test.mjs && npm run test:memory-routing && node --test scripts/explore-prompt-policy-test.mjs && node --test scripts/routing-corpus-test.mjs",
40
- "smoke:all": "npm run smoke && npm run smoke:boot && npm run smoke:tools && npm run smoke:patch && npm run smoke:output && npm run smoke:tui && npm run smoke:freevars && npm run smoke:logguard && npm run smoke:live-worker && npm run test:session",
39
+ "smoke": "node scripts/smoke.mjs",
40
+ "smoke:all": "npm run smoke && npm run smoke:boot && npm run smoke:patch && npm run smoke:output && npm run smoke:tui && npm run smoke:live-worker",
41
41
  "smoke:boot": "node scripts/boot-smoke.mjs",
42
42
  "smoke:compact": "node scripts/compact-smoke.mjs",
43
43
  "smoke:loop": "node scripts/smoke-loop.mjs",
44
- "smoke:loop:final": "node scripts/smoke-loop-report.mjs --require-complete --min-elapsed 5h --min-iterations 400 --max-gap 60s --max-smoke-ms 10000 --max-avg-smoke-ms 8500 --max-step-ms boot-smoke.mjs=8000 --max-step-ms tool-smoke.mjs=4000 --max-rss-mb 140 --max-rss-growth-mb 50",
44
+ "smoke:loop:final": "node scripts/smoke-loop-report.mjs --require-complete --min-elapsed 5h --min-iterations 400 --max-gap 60s --max-smoke-ms 10000 --max-avg-smoke-ms 8500 --max-step-ms smoke.mjs=4000 --max-step-ms boot-smoke.mjs=8000 --max-rss-mb 140 --max-rss-growth-mb 50",
45
45
  "smoke:loop:report": "node scripts/smoke-loop-report.mjs",
46
- "smoke:tools": "node scripts/tool-smoke.mjs",
46
+ "test:tool-contracts": "node scripts/tool-smoke.mjs",
47
47
  "smoke:patch": "node scripts/apply-patch-edit-smoke.mjs",
48
48
  "smoke:output": "node scripts/output-style-smoke.mjs",
49
49
  "smoke:tui": "node scripts/tui-render-smoke.mjs && npm run test:tui-input-render && npm run test:tui-streaming-window && npm run test:tui-queue",
@@ -62,14 +62,14 @@
62
62
  "test:anthropic-oauth-race": "node --test scripts/anthropic-oauth-refresh-race-test.mjs",
63
63
  "test:grok-oauth-race": "node --test scripts/grok-oauth-refresh-race-test.mjs",
64
64
  "test:atomiclock": "node --test scripts/atomic-lock-tryonce-test.mjs",
65
- "test:memory-routing": "node --test scripts/memory-cycle-routing-test.mjs scripts/memory-rule-contract-test.mjs scripts/maintenance-default-routes-test.mjs",
65
+ "test:memory-routing": "node --test scripts/memory-cycle-routing-test.mjs scripts/maintenance-default-routes-test.mjs scripts/embedding-worker-exit-test.mjs",
66
66
  "test:code-graph-dispatch": "node --test scripts/code-graph-dispatch-test.mjs",
67
67
  "test:code-graph-clean-cache": "node --test scripts/code-graph-dispatch-test.mjs",
68
68
  "test:tui-queue": "node --test scripts/submit-commandbusy-race-test.mjs scripts/steering-drain-buckets-test.mjs scripts/abort-recovery-test.mjs scripts/execution-pending-resume-kick-test.mjs scripts/execution-resume-esc-integration-test.mjs",
69
69
  "test:tui-input-render": "node --test scripts/prompt-immediate-render-test.mjs",
70
70
  "test:tui-streaming-window": "node --test scripts/streaming-tail-window-test.mjs",
71
71
  "test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/verify-release-assets-test.mjs && node --test scripts/verify-release-assets-test.mjs",
72
- "test:release-focused": "npm run test:release-assets && npm run test:patch-binary-cache && npm run test:providers && npm run test:deferred-tools && npm run smoke:compact && npm run smoke:tools && node --test scripts/code-graph-aggregate-cwd-test.mjs && npm run test:code-graph-dispatch && node --test scripts/code-graph-disk-hit-test.mjs && npm run test:shellhardening && node --test scripts/windows-hide-spawn-options-test.mjs && node --test scripts/tui-transcript-perf-test.mjs",
72
+ "test:release-focused": "npm run test:release-assets && npm run test:patch-binary-cache && npm run test:providers && npm run test:deferred-tools && npm run smoke:compact && node --test scripts/code-graph-aggregate-cwd-test.mjs && npm run test:code-graph-dispatch && node --test scripts/code-graph-disk-hit-test.mjs && npm run test:shellhardening && node --test scripts/windows-hide-spawn-options-test.mjs && node --test scripts/tui-transcript-perf-test.mjs",
73
73
  "test:native-edit-wire": "node --test scripts/native-edit-wire-test.mjs",
74
74
  "test:patch-binary-cache": "node --test scripts/patch-binary-cache-test.mjs",
75
75
  "test:session": "node --test scripts/session-orphan-sweep-test.mjs",
@@ -0,0 +1,76 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { spawn } from 'node:child_process'
4
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+
8
+ const providerUrl = new URL('../src/runtime/memory/lib/embedding-provider.mjs', import.meta.url)
9
+ const CHILD_DEADLINE_MS = 3_000
10
+
11
+ async function createExitWorkerFixture() {
12
+ const tempDir = await mkdtemp(join(tmpdir(), 'mixdog-embed-worker-exit-'))
13
+ const workerPath = join(tempDir, 'exit-before-reply.mjs')
14
+ await writeFile(workerPath, `
15
+ import { parentPort } from 'node:worker_threads'
16
+ parentPort.once('message', () => process.exit(0))
17
+ `)
18
+ const source = await readFile(providerUrl, 'utf8')
19
+ const instrumented = source
20
+ .replace("'./model-profile.mjs'", JSON.stringify(new URL('../src/runtime/memory/lib/model-profile.mjs', import.meta.url).href))
21
+ .replace("'./embedding-model-config.mjs'", JSON.stringify(new URL('../src/runtime/memory/lib/embedding-model-config.mjs', import.meta.url).href))
22
+ .replace(/^const WORKER_PATH = .*$/m, `const WORKER_PATH = ${JSON.stringify(workerPath)}`)
23
+ const providerModule = `data:text/javascript;base64,${Buffer.from(instrumented).toString('base64')}#${Date.now()}`
24
+ const fixturePath = join(tempDir, 'run-exit-test.mjs')
25
+ await writeFile(fixturePath, `
26
+ import { embedText } from ${JSON.stringify(providerModule)}
27
+ const started = Date.now()
28
+ const settled = await Promise.allSettled([embedText('first'), embedText('second')])
29
+ if (Date.now() - started >= 2_000) throw new Error('worker exit did not reject pending embeds promptly')
30
+ if (settled.some(({ status }) => status !== 'rejected')) throw new Error('worker exit resolved a pending embed')
31
+ for (const result of settled) {
32
+ if (result.reason?.message !== 'Worker exited with code 0') throw result.reason
33
+ }
34
+ process.stdout.write('ok')
35
+ `)
36
+ return { fixturePath, tempDir }
37
+ }
38
+
39
+ function runFixture(fixturePath) {
40
+ return new Promise((resolve, reject) => {
41
+ const child = spawn(process.execPath, [fixturePath], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
42
+ let stdout = ''
43
+ let stderr = ''
44
+ let timedOut = false
45
+ const deadline = setTimeout(() => {
46
+ timedOut = true
47
+ child.kill()
48
+ }, CHILD_DEADLINE_MS)
49
+ child.stdout.on('data', (chunk) => { stdout += chunk })
50
+ child.stderr.on('data', (chunk) => { stderr += chunk })
51
+ child.once('error', (error) => {
52
+ clearTimeout(deadline)
53
+ reject(error)
54
+ })
55
+ child.once('close', (code, signal) => {
56
+ clearTimeout(deadline)
57
+ if (timedOut) {
58
+ reject(new Error(`exit-worker fixture exceeded ${CHILD_DEADLINE_MS}ms and was killed`))
59
+ return
60
+ }
61
+ resolve({ code, signal, stdout, stderr })
62
+ })
63
+ })
64
+ }
65
+
66
+ test('code-zero worker exit before reply promptly rejects every pending embed', async () => {
67
+ const { fixturePath, tempDir } = await createExitWorkerFixture()
68
+ try {
69
+ const result = await runFixture(fixturePath)
70
+ assert.equal(result.code, 0, result.stderr)
71
+ assert.equal(result.signal, null, result.stderr)
72
+ assert.equal(result.stdout, 'ok')
73
+ } finally {
74
+ await rm(tempDir, { recursive: true, force: true })
75
+ }
76
+ })
@@ -58,7 +58,7 @@ function runNode(args, label, timeoutMs = 180_000) {
58
58
  function runSmokeAll(iteration) {
59
59
  let totalMs = 0;
60
60
  const steps = [];
61
- for (const script of ['scripts/boot-smoke.mjs', 'scripts/tool-smoke.mjs']) {
61
+ for (const script of ['scripts/smoke.mjs', 'scripts/boot-smoke.mjs']) {
62
62
  const result = runNode([script], `${script} iteration ${iteration}`);
63
63
  totalMs += result.ms;
64
64
  steps.push({
@@ -161,8 +161,14 @@ while (Date.now() < deadline && iteration < maxIterations) {
161
161
  const iterStartedAt = Date.now();
162
162
  const smoke = runSmokeAll(iteration);
163
163
  smokeTimes.push(smoke.ms);
164
- const failure = runNode(['scripts/tool-failures.mjs', '--since', since, '--limit', '1'], `failures iteration ${iteration}`, 60_000);
165
- if (!/tool failures:\s+0\/0 shown/.test(failure.stdout)) {
164
+ const failure = runNode(['scripts/tool-failures.mjs', '--since', since, '--limit', '1', '--json'], `failures iteration ${iteration}`, 60_000);
165
+ let failureSummary;
166
+ try {
167
+ failureSummary = JSON.parse(failure.stdout);
168
+ } catch {
169
+ throw new Error(`tool failures returned malformed JSON:\n${failure.stdout}`);
170
+ }
171
+ if (failureSummary?.actionable_failures?.matched !== 0) {
166
172
  throw new Error(`tool failures appeared after loop start:\n${failure.stdout}`);
167
173
  }
168
174
  const currentRss = rssMb();
package/scripts/smoke.mjs CHANGED
@@ -19,120 +19,19 @@ function runNode(args, label, options = {}) {
19
19
  return child;
20
20
  }
21
21
 
22
- const child = runNode(['src/cli.mjs', '--help'], 'help smoke');
23
- const isolatedStatuslineEnv = {
24
- MIXDOG_DATA_DIR: resolve(root, '.mixdog-smoke-data-empty'),
25
- MIXDOG_HOME: resolve(root, '.mixdog-smoke-home-empty'),
26
- MIXDOG_CONFIG_DIR: resolve(root, '.mixdog-smoke-config-empty'),
27
- };
28
-
29
- if (!child.stdout.includes('standalone mixdog CLI/TUI coding agent')) {
30
- process.stderr.write(`unexpected help output:\n${child.stdout}`);
31
- process.exit(1);
32
- }
22
+ runNode(['src/cli.mjs', '--help'], 'help smoke');
33
23
 
34
24
  runNode(['--input-type=module', '-e', `
35
25
  const mod = await import('./src/tui/dist/index.mjs');
36
26
  if (typeof mod.runTui !== 'function') throw new Error('runTui export missing');
37
27
  `], 'tui bundle import smoke');
38
28
 
39
- runNode(['--input-type=module', '-e', `
40
- process.stdout.columns = 120;
41
- const { renderStatusline } = await import('./src/ui/statusline.mjs');
42
- const line = await renderStatusline({
43
- provider: 'openai',
44
- model: 'gpt-5.5',
45
- contextWindow: 1000000,
46
- stats: { currentContextTokens: 999 },
47
- });
48
- if (/▓/.test(line) || !line.includes('░') || !line.includes('0.1%')) throw new Error('sub-1% context bar should stay empty: ' + JSON.stringify(line));
49
- `], 'statusline sub-percent context smoke', { env: isolatedStatuslineEnv });
50
-
51
- runNode(['--input-type=module', '-e', `
52
- process.stdout.columns = 120;
53
- const { renderStatusline } = await import('./src/ui/statusline.mjs');
54
- const line = await renderStatusline({
55
- provider: 'openai',
56
- model: 'gpt-5.5',
57
- contextWindow: 950000,
58
- rawContextWindow: 1000000,
59
- stats: { currentContextTokens: 900000 },
60
- });
61
- if (!line.includes('94%') || line.includes('100%')) throw new Error('statusline context% should use effective compact capacity, not the 90% auto-compact trigger: ' + JSON.stringify(line));
62
- `], 'statusline compact-capacity context smoke', { env: isolatedStatuslineEnv });
63
-
64
- runNode(['--input-type=module', '-e', `
65
- process.stdout.columns = 120;
66
- const { renderStatusline } = await import('./src/ui/statusline.mjs');
67
- const line = await renderStatusline({
68
- provider: 'openai',
69
- model: 'gpt-5.5',
70
- contextWindow: 950000,
71
- stats: { currentContextTokens: 350000, currentContextSource: 'estimated' },
72
- });
73
- if (line.includes('37%') || !line.includes('0%')) throw new Error('statusline must not show local estimated context as session usage: ' + JSON.stringify(line));
74
- `], 'statusline estimated-context isolation smoke', { env: isolatedStatuslineEnv });
75
-
76
- runNode(['--input-type=module', '-e', `
77
- process.stdout.columns = 120;
78
- const { renderStatusline } = await import('./src/ui/statusline.mjs');
79
- const line = await renderStatusline({
80
- provider: 'openai',
81
- model: 'gpt-5.5',
82
- contextWindow: 950000,
83
- stats: { currentContextTokens: 360000, currentContextSource: 'post_compact_estimate' },
84
- });
85
- if (!line.includes('37%')) throw new Error('statusline must show post-compact estimated context after stale API usage: ' + JSON.stringify(line));
86
- `], 'statusline post-compact context smoke', { env: isolatedStatuslineEnv });
87
-
88
- runNode(['--input-type=module', '-e', `
89
- process.stdout.columns = 120;
90
- const { renderStatusline } = await import('./src/ui/statusline.mjs');
91
- const line = await renderStatusline({
92
- provider: 'openai',
93
- model: 'gpt-5.5',
94
- contextWindow: 950000,
95
- stats: { currentContextTokens: 0 },
96
- agentJobs: [{ task_id: 'task_statusline_smoke', status: 'running', tag: 'bench-agent', startedAt: new Date().toISOString() }],
97
- });
98
- if (!line.includes('Running 1 Agent') || line.includes('bench-agent')) throw new Error('statusline must render live agent count without task tags: ' + JSON.stringify(line));
99
- `], 'statusline live agent task smoke', { env: isolatedStatuslineEnv });
100
-
101
- runNode(['--input-type=module', '-e', `
102
- const { mkdtempSync, mkdirSync, writeFileSync } = await import('node:fs');
103
- const { tmpdir } = await import('node:os');
104
- const { join } = await import('node:path');
105
- const root = mkdtempSync(join(tmpdir(), 'mixdog-status-smoke-'));
106
- mkdirSync(join(root, 'runtime'), { recursive: true });
107
- writeFileSync(join(root, 'runtime', 'active-instance.json'), JSON.stringify({
108
- gateway_port: 3468,
109
- gateway_server_pid: process.pid,
110
- gateway_provider: 'openai',
111
- gateway_model: 'gpt-5.5',
112
- gateway_context_window: 950000,
113
- gateway_raw_context_window: 1000000,
114
- gateway_auto_compact_token_limit: 950000,
115
- gateway_context_used_pct: 37,
116
- gateway_cc_session_id: 'other-session',
117
- gateway_updated_at: Date.now()
118
- }));
119
- process.env.MIXDOG_RUNTIME_ROOT = join(root, 'runtime');
120
- const { loadGatewayStatus } = await import('./src/vendor/statusline/bin/statusline-route.mjs');
121
- const status = loadGatewayStatus({ sessionId: 'fresh-session', clientHostPid: process.pid, activeContextTokens: 0 });
122
- if (status?.contextUsedPct === 37) throw new Error('statusline leaked active-instance context from another session');
123
- `], 'statusline session metrics isolation smoke', { env: isolatedStatuslineEnv });
124
-
125
- const boot = spawnSync(process.execPath, ['src/cli.mjs', '--help'], {
126
- cwd: root,
127
- env: { ...process.env, MIXDOG_BOOT_PROFILE: '1' },
128
- encoding: 'utf8',
129
- stdio: ['ignore', 'pipe', 'pipe'],
130
- timeout: 10000,
29
+ const boot = runNode(['src/cli.mjs', '--help'], 'boot profile smoke', {
30
+ env: { MIXDOG_BOOT_PROFILE: '1' },
131
31
  });
132
-
133
- if (boot.status !== 0 || !boot.stderr.includes('[mixdog-boot]') || !boot.stderr.includes('app:run:start')) {
32
+ if (!boot.stderr.includes('[mixdog-boot]')) {
134
33
  process.stderr.write(boot.stderr || boot.stdout || 'boot profile smoke failed\n');
135
- process.exit(boot.status || 1);
34
+ process.exit(1);
136
35
  }
137
36
 
138
37
  process.stdout.write('smoke passed ✓\n');
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { createHash } from 'node:crypto';
3
- import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import test from 'node:test';
@@ -59,303 +59,6 @@ function graphFixture() {
59
59
  };
60
60
  }
61
61
 
62
- function yamlScalar(value) {
63
- const trimmed = value.trim();
64
- if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
65
- return trimmed.slice(1, -1).replaceAll("''", "'");
66
- }
67
- if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
68
- return JSON.parse(trimmed);
69
- }
70
- return trimmed.replace(/\s+#.*$/, '').trim();
71
- }
72
-
73
- function yamlBlockEnd(lines, start, indent) {
74
- for (let index = start + 1; index < lines.length; index += 1) {
75
- const text = lines[index];
76
- if (/^\s*(?:#.*)?$/.test(text)) continue;
77
- if (text.match(/^\s*/)[0].length <= indent) return index;
78
- }
79
- return lines.length;
80
- }
81
-
82
- function yamlMappingEntry(text, indent) {
83
- if (text.match(/^\s*/)[0].length !== indent) return undefined;
84
- const match = text.slice(indent).match(/^("(?:\\.|[^"])*"|'(?:''|[^'])*'|[A-Za-z0-9_-]+):\s*(.*)$/);
85
- if (!match) return undefined;
86
- return { key: yamlScalar(match[1]), rawValue: match[2] };
87
- }
88
-
89
- function yamlChildren(lines, start, end, parentIndent) {
90
- let childIndent;
91
- const children = [];
92
- for (let index = start + 1; index < end; index += 1) {
93
- const text = lines[index];
94
- if (/^\s*(?:#.*)?$/.test(text)) continue;
95
- const indent = text.match(/^\s*/)[0].length;
96
- if (indent <= parentIndent) break;
97
- childIndent ??= indent;
98
- if (indent !== childIndent) continue;
99
- const entry = yamlMappingEntry(text, indent);
100
- if (entry) children.push({
101
- ...entry,
102
- index,
103
- indent,
104
- end: yamlBlockEnd(lines, index, indent),
105
- });
106
- }
107
- return children;
108
- }
109
-
110
- function yamlEnvironment(lines, parent) {
111
- const env = {};
112
- for (const entry of yamlChildren(lines, parent.index, parent.end, parent.indent)) {
113
- env[entry.key] = yamlScalar(entry.rawValue);
114
- }
115
- return env;
116
- }
117
-
118
- function yamlSteps(lines, parent) {
119
- const itemIndexes = [];
120
- let itemIndent;
121
- for (let index = parent.index + 1; index < parent.end; index += 1) {
122
- const match = lines[index].match(/^(\s*)-\s+(.*)$/);
123
- if (!match || /^\s*(?:#.*)?$/.test(lines[index])) continue;
124
- const indent = match[1].length;
125
- if (itemIndent === undefined) itemIndent = indent;
126
- if (indent === itemIndent) itemIndexes.push(index);
127
- }
128
-
129
- return itemIndexes.map((start, position) => {
130
- const end = itemIndexes[position + 1] ?? parent.end;
131
- const fieldIndent = itemIndent + 2;
132
- const fields = { env: {} };
133
- for (let index = start; index < end; index += 1) {
134
- const source = index === start
135
- ? lines[index].replace(/^\s*-\s+/, '')
136
- : lines[index].slice(fieldIndent);
137
- if (index !== start && lines[index].match(/^\s*/)[0].length !== fieldIndent) continue;
138
- const field = source.match(/^(name|run|uses|env):\s*(.*)$/);
139
- if (!field) continue;
140
- const [, key, rawValue] = field;
141
- if (key === 'env' && /^(?:#.*)?$/.test(rawValue)) {
142
- fields.env = yamlEnvironment(lines, {
143
- index,
144
- indent: fieldIndent,
145
- end: yamlBlockEnd(lines, index, fieldIndent),
146
- });
147
- } else if (key === 'run' && /^[|>][-+0-9]*\s*(?:#.*)?$/.test(rawValue)) {
148
- const commands = [];
149
- for (let blockIndex = index + 1; blockIndex < end; blockIndex += 1) {
150
- const blockLine = lines[blockIndex];
151
- if (blockLine.trim() === '' || /^\s*#/.test(blockLine)) continue;
152
- if (blockLine.match(/^\s*/)[0].length <= fieldIndent) break;
153
- commands.push(blockLine.trim());
154
- }
155
- fields.run = commands.join('\n');
156
- } else {
157
- fields[key] = yamlScalar(rawValue);
158
- }
159
- }
160
- return fields;
161
- });
162
- }
163
-
164
- function parseWorkflowSubset(workflow) {
165
- const lines = workflow.replaceAll('\r\n', '\n').split('\n');
166
- const roots = lines
167
- .map((line, index) => ({ line, index }))
168
- .filter(({ line }) => yamlMappingEntry(line, 0)?.key === 'jobs');
169
- assert.equal(roots.length, 1, 'workflow must define exactly one jobs mapping');
170
- const jobsBlock = {
171
- index: roots[0].index,
172
- indent: 0,
173
- end: yamlBlockEnd(lines, roots[0].index, 0),
174
- };
175
- const jobs = yamlChildren(lines, jobsBlock.index, jobsBlock.end, jobsBlock.indent).map((entry) => {
176
- const children = yamlChildren(lines, entry.index, entry.end, entry.indent);
177
- const envEntry = children.find((child) => child.key === 'env');
178
- const stepsEntry = children.find((child) => child.key === 'steps');
179
- const strategyEntry = children.find((child) => child.key === 'strategy');
180
- const matrixEntry = strategyEntry
181
- ? yamlChildren(lines, strategyEntry.index, strategyEntry.end, strategyEntry.indent)
182
- .find((child) => child.key === 'matrix')
183
- : undefined;
184
- const matrixLines = matrixEntry
185
- ? lines.slice(matrixEntry.index + 1, matrixEntry.end)
186
- .filter((line) => !/^\s*(?:#.*)?$/.test(line))
187
- .map((line) => line.trim())
188
- : [];
189
- const includeEntry = matrixEntry
190
- ? yamlChildren(lines, matrixEntry.index, matrixEntry.end, matrixEntry.indent)
191
- .find((child) => child.key === 'include')
192
- : undefined;
193
- const matrixInclude = includeEntry
194
- ? lines.slice(includeEntry.index + 1, includeEntry.end)
195
- .filter((line) => /^\s*-\s+/.test(line) && !/^\s*-\s*#/.test(line))
196
- .map((line) => line.replace(/^\s*-\s+/, '').trim())
197
- : [];
198
- return {
199
- name: entry.key,
200
- env: envEntry ? yamlEnvironment(lines, envEntry) : {},
201
- matrix: matrixEntry ? { lines: matrixLines, include: matrixInclude } : undefined,
202
- steps: stepsEntry ? yamlSteps(lines, stepsEntry) : [],
203
- };
204
- });
205
- return { jobs };
206
- }
207
-
208
- function workflowJob(workflow, name) {
209
- const matches = parseWorkflowSubset(workflow).jobs.filter((job) => job.name === name);
210
- assert.equal(matches.length, 1, `workflow must define exactly one jobs.${name}`);
211
- return matches[0];
212
- }
213
-
214
- function namedStep(job, name) {
215
- const matches = job.steps.filter((step) => step.name === name);
216
- assert.equal(matches.length, 1, `jobs.${job.name} must contain exactly one "${name}" step`);
217
- return matches[0];
218
- }
219
-
220
- function extractReleaseSteps(workflow) {
221
- return workflowJob(workflow, 'release').steps;
222
- }
223
-
224
- function runLines(step) {
225
- return step.run?.split('\n') ?? [];
226
- }
227
-
228
- function assertRunLine(step, line, message) {
229
- assert.ok(runLines(step).includes(line), message);
230
- }
231
-
232
- function matrixRowsWithKey(matrix, key) {
233
- return matrix.include.filter((row) => {
234
- const fields = row.match(/^\{(.*)\}$/)?.[1] ?? row;
235
- return new RegExp(`(?:^|,)\\s*${key}\\s*:`).test(fields);
236
- });
237
- }
238
-
239
- function effectiveStepEnv(job, step, key) {
240
- if (Object.hasOwn(step.env, key)) return step.env[key];
241
- return job.env[key];
242
- }
243
-
244
- function cargoBuildCommands(step) {
245
- return runLines(step).filter((line) => /\bcargo\s+build(?:\s|$)/.test(line));
246
- }
247
-
248
- function assertRepeatedCommand(commands, expected, count, message) {
249
- assert.equal(commands.length, count, message);
250
- for (const command of commands) {
251
- assert.equal(command, expected, message);
252
- }
253
- }
254
-
255
- function assertProvenanceDigestLinkage(step) {
256
- for (const line of [
257
- 'FIRST=$(sha256 "_asset/$ASSET")',
258
- 'SECOND=$(sha256 "$BIN")',
259
- 'cmp "_asset/$ASSET" "$BIN"',
260
- 'test "$FIRST" = "$SECOND"',
261
- 'ASSET_DIGEST=$FIRST \\',
262
- 'assetDigest: process.env.ASSET_DIGEST,',
263
- ]) {
264
- assertRunLine(step, line, `reproducibility step must preserve digest/provenance linkage: ${line}`);
265
- }
266
- }
267
-
268
- function assertMatrixRustflags(job) {
269
- assert.ok(job.matrix, 'jobs.build must define a strategy matrix');
270
- assert.ok(job.matrix.include.length > 0, 'jobs.build matrix must define include rows');
271
- assert.equal(
272
- matrixRowsWithKey(job.matrix, 'rustflags').length,
273
- job.matrix.include.length,
274
- 'every jobs.build matrix include row must define rustflags',
275
- );
276
- assert.equal(
277
- job.env.RUSTFLAGS,
278
- '${{ matrix.rustflags }}',
279
- 'jobs.build must set RUSTFLAGS from matrix.rustflags',
280
- );
281
- }
282
-
283
- function assertProtocolStep(job) {
284
- const protocol = namedStep(job, 'Test protocol');
285
- assert.equal(
286
- protocol.env.RUSTFLAGS,
287
- '',
288
- 'protocol tests must explicitly clear RUSTFLAGS for loadable host proc-macro dylibs',
289
- );
290
- assertRunLine(
291
- protocol,
292
- 'cargo test --locked --manifest-path native/mixdog-graph/Cargo.toml',
293
- 'Test protocol step must own the locked graph protocol test command',
294
- );
295
- }
296
-
297
- function assertReproducibilityStep(job) {
298
- const reproducibility = namedStep(job, 'Build twice and compare');
299
- assert.equal(
300
- effectiveStepEnv(job, reproducibility, 'RUSTFLAGS'),
301
- '${{ matrix.rustflags }}',
302
- 'reproducibility builds must preserve matrix RUSTFLAGS',
303
- );
304
- assertRepeatedCommand(
305
- cargoBuildCommands(reproducibility),
306
- 'cargo build --locked --release --target "${{ matrix.target }}" --manifest-path native/mixdog-graph/Cargo.toml',
307
- 2,
308
- 'Build twice and compare must own exactly two targeted locked release cargo build commands',
309
- );
310
- assertProvenanceDigestLinkage(reproducibility);
311
- }
312
-
313
- function assertGraphReleaseRustflagsContract(workflow) {
314
- const build = workflowJob(workflow, 'build');
315
- assertMatrixRustflags(build);
316
- assertProtocolStep(build);
317
- assertReproducibilityStep(build);
318
- }
319
-
320
- function assertReleaseWorkflowOrdering(workflow) {
321
- const steps = extractReleaseSteps(workflow);
322
- const required = [
323
- ['Verify bundled release assets', 'node scripts/verify-release-assets.mjs'],
324
- ['Install', 'npm ci'],
325
- ['Execute code graph from a clean cache', 'npm run test:code-graph-clean-cache'],
326
- ['Focused release regressions', 'npm run test:release-focused'],
327
- ['Smoke', 'npm run smoke'],
328
- ['Publish', 'npm publish --provenance --access public'],
329
- ];
330
- const indexes = {};
331
- for (const [name, command] of required) {
332
- const matches = steps
333
- .map((step, index) => ({ step, index }))
334
- .filter(({ step }) => step.name === name);
335
- assert.equal(matches.length, 1, `release workflow must contain exactly one "${name}" step`);
336
- assert.ok(
337
- matches[0].step.run?.split('\n').includes(command),
338
- `"${name}" step must run ${command}`,
339
- );
340
- indexes[name] = matches[0].index;
341
- }
342
-
343
- const asset = indexes['Verify bundled release assets'];
344
- const install = indexes.Install;
345
- const cleanCache = indexes['Execute code graph from a clean cache'];
346
- const focused = indexes['Focused release regressions'];
347
- const smoke = indexes.Smoke;
348
- const publish = indexes.Publish;
349
- assert.ok(asset < install, 'release asset blocker must run before Install');
350
- assert.ok(asset < cleanCache, 'release asset blocker must run before clean-cache graph execution');
351
- assert.ok(install < cleanCache, 'Install must run before clean-cache graph execution');
352
- assert.ok(cleanCache < focused, 'clean-cache graph execution must run before Focused release regressions');
353
- assert.ok(focused < smoke, 'Focused release regressions must run before Smoke');
354
- assert.ok(smoke < publish, 'Smoke must run before Publish');
355
- assert.ok(asset < publish, 'release asset blocker must run before Publish');
356
- assert.ok(cleanCache < publish, 'clean-cache graph execution must run before Publish');
357
- }
358
-
359
62
  test('accepts independent strict patch, runtime, app, and graph versions', () => {
360
63
  assert.equal(validatePatchManifest(patchFixture(), `[package]\nversion = "${VERSION}"\n`).version, VERSION);
361
64
  assert.equal(validateRuntimeManifest(runtimeFixture()).release_tag, 'runtime-v1.2.3');
@@ -534,6 +237,13 @@ test('cancels immediately when a stream exceeds its declared size below the abso
534
237
 
535
238
  test('full guard reads deterministic fixtures and downloads every declared asset', async () => {
536
239
  const dir = await mkdtemp(join(tmpdir(), 'mixdog-release-assets-'));
240
+ const patch = patchFixture();
241
+ const runtime = runtimeFixture();
242
+ const graph = graphFixture();
243
+ const expectedUrls = new Set(
244
+ [...Object.values(patch.assets), ...Object.values(runtime.assets), ...Object.values(graph.assets)]
245
+ .map(({ url }) => url),
246
+ );
537
247
  const paths = {
538
248
  patchManifestPath: join(dir, 'patch.json'),
539
249
  cargoPath: join(dir, 'Cargo.toml'),
@@ -542,21 +252,23 @@ test('full guard reads deterministic fixtures and downloads every declared asset
542
252
  packagePath: join(dir, 'package.json'),
543
253
  };
544
254
  await Promise.all([
545
- writeFile(paths.patchManifestPath, JSON.stringify(patchFixture())),
255
+ writeFile(paths.patchManifestPath, JSON.stringify(patch)),
546
256
  writeFile(paths.cargoPath, `[package]\nversion = "${VERSION}"\n`),
547
- writeFile(paths.runtimeManifestPath, JSON.stringify(runtimeFixture())),
548
- writeFile(paths.graphManifestPath, JSON.stringify(graphFixture())),
257
+ writeFile(paths.runtimeManifestPath, JSON.stringify(runtime)),
258
+ writeFile(paths.graphManifestPath, JSON.stringify(graph)),
549
259
  writeFile(paths.packagePath, JSON.stringify({ version: APP_VERSION })),
550
260
  ]);
551
261
  let downloads = 0;
262
+ const requestedUrls = [];
552
263
  try {
553
264
  await verifyReleaseAssets({
554
265
  ...paths,
555
266
  downloadOptions: {
556
267
  attempts: 1,
557
268
  timeoutMs: 1000,
558
- fetchImpl: async () => {
269
+ fetchImpl: async (url) => {
559
270
  downloads += 1;
271
+ requestedUrls.push(url);
560
272
  return new Response(bytes);
561
273
  },
562
274
  },
@@ -565,83 +277,5 @@ test('full guard reads deterministic fixtures and downloads every declared asset
565
277
  await rm(dir, { recursive: true, force: true });
566
278
  }
567
279
  assert.equal(downloads, 15);
568
- });
569
-
570
- test('release workflow orders the asset and clean-cache blockers before consumers', async () => {
571
- const workflow = await readFile(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8');
572
- assertReleaseWorkflowOrdering(workflow);
573
-
574
- const quotedNames = workflow
575
- .replace('- name: Install', '- name: "Install"')
576
- .replace('- name: Smoke', "- name: 'Smoke'");
577
- assertReleaseWorkflowOrdering(quotedNames);
578
-
579
- const wrongStep = workflow
580
- .replace('run: npm run smoke', 'run: echo smoke')
581
- .replace('run: npm run test:release-focused', 'run: npm run test:release-focused\n # npm run smoke');
582
- assert.throws(() => assertReleaseWorkflowOrdering(wrongStep), /"Smoke" step must run npm run smoke/);
583
-
584
- const otherJob = workflow
585
- .replace('run: npm run smoke', 'run: echo smoke')
586
- .replace(
587
- '\n release:',
588
- '\n decoy:\n steps:\n - name: Smoke\n run: npm run smoke\n release:',
589
- );
590
- assert.throws(() => assertReleaseWorkflowOrdering(otherJob), /"Smoke" step must run npm run smoke/);
591
- });
592
-
593
- test('graph release clears test RUSTFLAGS but keeps targeted reproducibility builds', async () => {
594
- const workflow = await readFile(new URL('../.github/workflows/graph-release.yml', import.meta.url), 'utf8');
595
- assertGraphReleaseRustflagsContract(workflow);
596
-
597
- const quotedNames = workflow
598
- .replace('- name: Test protocol', '- name: "Test protocol"')
599
- .replace('- name: Build twice and compare', "- name: 'Build twice and compare'");
600
- assertGraphReleaseRustflagsContract(quotedNames);
601
-
602
- assert.throws(
603
- () => assertGraphReleaseRustflagsContract(workflow.replace(' RUSTFLAGS: ""', ' RUSTFLAGS: "-C unsafe-for-host"')),
604
- /protocol tests must explicitly clear RUSTFLAGS/,
605
- );
606
- assert.throws(
607
- () => assertGraphReleaseRustflagsContract(workflow.replace('--release --target', '--release')),
608
- /exactly two targeted locked release cargo build commands/,
609
- );
610
-
611
- const renamedBuild = workflow
612
- .replace('\n build:\n', '\n renamed-build:\n')
613
- .replace(
614
- '\n gate:',
615
- '\n decoy-build:\n env:\n RUSTFLAGS: ${{ matrix.rustflags }}\n'
616
- + ' steps:\n - name: Test protocol\n'
617
- + ' run: cargo test --locked --manifest-path native/mixdog-graph/Cargo.toml\n gate:',
618
- );
619
- assert.throws(
620
- () => assertGraphReleaseRustflagsContract(renamedBuild),
621
- /exactly one jobs\.build/,
622
- );
623
-
624
- const commentedBuild = workflow.replace(
625
- ' cargo build --locked --release --target "${{ matrix.target }}" --manifest-path native/mixdog-graph/Cargo.toml',
626
- ' # cargo build --locked --release --target "${{ matrix.target }}" --manifest-path native/mixdog-graph/Cargo.toml',
627
- );
628
- assert.throws(
629
- () => assertGraphReleaseRustflagsContract(commentedBuild),
630
- /exactly two targeted locked release cargo build commands/,
631
- );
632
-
633
- const commandInWrongStep = workflow
634
- .replace(
635
- ' run: cargo test --locked --manifest-path native/mixdog-graph/Cargo.toml',
636
- ' run: echo protocol command moved',
637
- )
638
- .replace(
639
- ' test "$(git rev-parse HEAD)" = "${{ needs.gate.outputs.source_sha }}"',
640
- ' cargo test --locked --manifest-path native/mixdog-graph/Cargo.toml\n'
641
- + ' test "$(git rev-parse HEAD)" = "${{ needs.gate.outputs.source_sha }}"',
642
- );
643
- assert.throws(
644
- () => assertGraphReleaseRustflagsContract(commandInWrongStep),
645
- /Test protocol step must own/,
646
- );
280
+ assert.deepEqual(new Set(requestedUrls), expectedUrls);
647
281
  });
@@ -104,14 +104,15 @@ function ensureWorker() {
104
104
  _restartCount++
105
105
  })
106
106
  worker.on('exit', (code) => {
107
+ const exitError = new Error(`Worker exited with code ${code}`)
107
108
  if (code !== 0) {
108
109
  __mixdogMemoryLog(`[embed] worker exited with code ${code}\n`)
109
- for (const [, p] of _pending) p.reject(new Error(`Worker exited with code ${code}`))
110
- _pending.clear()
111
110
  _restartCount++
112
111
  } else {
113
112
  _restartCount = 0
114
113
  }
114
+ for (const [, p] of _pending) p.reject(exitError)
115
+ _pending.clear()
115
116
  worker = null
116
117
  _modelReady = false
117
118
  })