mixdog 0.9.47 → 0.9.49
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 +14 -4
- package/scripts/agent-parallel-smoke.mjs +4 -1
- package/scripts/agent-route-batch-test.mjs +40 -0
- package/scripts/code-graph-aggregate-cwd-test.mjs +154 -0
- package/scripts/code-graph-description-contract.mjs +185 -0
- package/scripts/code-graph-disk-hit-test.mjs +55 -0
- package/scripts/code-graph-dispatch-test.mjs +96 -0
- package/scripts/compact-pressure-test.mjs +40 -0
- package/scripts/deferred-tool-loading-test.mjs +233 -0
- package/scripts/execution-completion-dedup-test.mjs +48 -0
- package/scripts/explore-prompt-policy-test.mjs +88 -3
- package/scripts/live-worker-smoke.mjs +68 -16
- package/scripts/memory-core-input-test.mjs +33 -13
- package/scripts/native-edit-wire-test.mjs +152 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +294 -16
- package/scripts/patch-binary-cache-test.mjs +181 -0
- package/scripts/prompt-immediate-render-test.mjs +89 -0
- package/scripts/provider-toolcall-test.mjs +280 -15
- package/scripts/shell-failure-diagnostics-test.mjs +211 -0
- package/scripts/statusline-quota-hysteresis-test.mjs +26 -1
- package/scripts/streaming-tail-window-test.mjs +29 -0
- package/scripts/tool-failures.mjs +21 -3
- package/scripts/tool-smoke.mjs +263 -38
- package/scripts/tool-tui-presentation-test.mjs +17 -1
- package/scripts/tui-perf-run.ps1 +26 -0
- package/scripts/tui-transcript-perf-test.mjs +7 -1
- package/scripts/verify-release-assets-test.mjs +647 -0
- package/scripts/verify-release-assets.mjs +293 -0
- package/scripts/windows-hide-spawn-options-test.mjs +19 -0
- package/src/cli.mjs +1 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +16 -5
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +35 -11
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -11
- package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +28 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +34 -34
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +45 -38
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -11
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -6
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +0 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +13 -8
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +30 -16
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +25 -8
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +69 -4
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +12 -12
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +12 -7
- package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +77 -18
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +99 -24
- package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
- package/src/runtime/memory/tool-defs.mjs +1 -2
- package/src/session-runtime/context-status.mjs +25 -16
- package/src/session-runtime/model-route-api.mjs +2 -0
- package/src/session-runtime/runtime-core.mjs +2 -2
- package/src/session-runtime/session-turn-api.mjs +4 -1
- package/src/session-runtime/tool-catalog.mjs +113 -19
- package/src/session-runtime/tool-defs.mjs +1 -0
- package/src/standalone/agent-tool/helpers.mjs +25 -6
- package/src/standalone/agent-tool.mjs +75 -41
- package/src/tui/App.jsx +4 -0
- package/src/tui/app/input-parsers.mjs +8 -9
- package/src/tui/components/Markdown.jsx +6 -1
- package/src/tui/components/Message.jsx +11 -2
- package/src/tui/components/PromptInput.jsx +19 -21
- package/src/tui/components/Spinner.jsx +4 -4
- package/src/tui/components/StatusLine.jsx +6 -6
- package/src/tui/components/TranscriptItem.jsx +2 -2
- package/src/tui/components/prompt-input/immediate-render.mjs +47 -0
- package/src/tui/components/tool-execution/surface-detail.mjs +14 -9
- package/src/tui/dist/index.mjs +130 -45
- package/src/tui/engine/agent-job-feed.mjs +21 -2
- package/src/tui/engine/turn.mjs +12 -1
- package/src/tui/markdown/measure-rendered-rows.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +20 -0
- package/src/ui/statusline.mjs +5 -5
- package/src/vendor/statusline/src/gateway/session-routes.mjs +22 -10
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
GRAPH_PLATFORMS,
|
|
10
|
+
PATCH_PLATFORMS,
|
|
11
|
+
validateGraphManifest,
|
|
12
|
+
validatePatchManifest,
|
|
13
|
+
validateRuntimeManifest,
|
|
14
|
+
verifyAssetDownloads,
|
|
15
|
+
verifyReleaseAssets,
|
|
16
|
+
} from './verify-release-assets.mjs';
|
|
17
|
+
|
|
18
|
+
const VERSION = '1.2.3';
|
|
19
|
+
const APP_VERSION = '0.9.49';
|
|
20
|
+
const GRAPH_VERSION = '0.1.0';
|
|
21
|
+
const bytes = Buffer.from('local release fixture');
|
|
22
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
23
|
+
|
|
24
|
+
function patchFixture() {
|
|
25
|
+
return {
|
|
26
|
+
version: VERSION,
|
|
27
|
+
_comment: 'test fixture',
|
|
28
|
+
assets: Object.fromEntries(Object.entries(PATCH_PLATFORMS).map(([platform, filename]) => [
|
|
29
|
+
platform,
|
|
30
|
+
{
|
|
31
|
+
url: `https://github.com/tribgames/mixdog/releases/download/patch-v${VERSION}/${filename}`,
|
|
32
|
+
sha256,
|
|
33
|
+
},
|
|
34
|
+
])),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function runtimeFixture() {
|
|
39
|
+
return {
|
|
40
|
+
release_tag: 'runtime-v1.2.3',
|
|
41
|
+
assets: Object.fromEntries(Object.keys(PATCH_PLATFORMS).map((platform) => [
|
|
42
|
+
platform,
|
|
43
|
+
{ url: `https://fixtures.invalid/${platform}`, sha256, size: bytes.length },
|
|
44
|
+
])),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function graphFixture() {
|
|
49
|
+
return {
|
|
50
|
+
version: GRAPH_VERSION,
|
|
51
|
+
_comment: 'test fixture',
|
|
52
|
+
assets: Object.fromEntries(Object.entries(GRAPH_PLATFORMS).map(([platform, filename]) => [
|
|
53
|
+
platform,
|
|
54
|
+
{
|
|
55
|
+
url: `https://github.com/tribgames/mixdog/releases/download/graph-v${GRAPH_VERSION}/${filename}`,
|
|
56
|
+
sha256,
|
|
57
|
+
},
|
|
58
|
+
])),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
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
|
+
test('accepts independent strict patch, runtime, app, and graph versions', () => {
|
|
360
|
+
assert.equal(validatePatchManifest(patchFixture(), `[package]\nversion = "${VERSION}"\n`).version, VERSION);
|
|
361
|
+
assert.equal(validateRuntimeManifest(runtimeFixture()).release_tag, 'runtime-v1.2.3');
|
|
362
|
+
assert.equal(validateGraphManifest(graphFixture(), { version: APP_VERSION }).version, GRAPH_VERSION);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('rejects stale Cargo version, partial schema, and wrong patch tag URL', () => {
|
|
366
|
+
assert.throws(
|
|
367
|
+
() => validatePatchManifest(patchFixture(), '[package]\nversion = "1.2.2"\n'),
|
|
368
|
+
/does not match manifest version/,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const partial = patchFixture();
|
|
372
|
+
delete partial.assets['linux-arm64'];
|
|
373
|
+
assert.throws(
|
|
374
|
+
() => validatePatchManifest(partial, `[package]\nversion = "${VERSION}"\n`),
|
|
375
|
+
/keys must be exactly/,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const wrongTag = patchFixture();
|
|
379
|
+
wrongTag.assets['linux-x64'].url = wrongTag.assets['linux-x64'].url.replace('patch-v1.2.3', 'patch-v1.2.2');
|
|
380
|
+
assert.throws(
|
|
381
|
+
() => validatePatchManifest(wrongTag, `[package]\nversion = "${VERSION}"\n`),
|
|
382
|
+
/patch-v1\.2\.3/,
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const extraPatch = patchFixture();
|
|
386
|
+
extraPatch.assets['freebsd-x64'] = extraPatch.assets['linux-x64'];
|
|
387
|
+
assert.throws(
|
|
388
|
+
() => validatePatchManifest(extraPatch, `[package]\nversion = "${VERSION}"\n`),
|
|
389
|
+
/keys must be exactly/,
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const extraRuntime = runtimeFixture();
|
|
393
|
+
extraRuntime.assets['freebsd-x64'] = extraRuntime.assets['linux-x64'];
|
|
394
|
+
assert.throws(() => validateRuntimeManifest(extraRuntime), /keys must be exactly/);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('rejects stale, noncanonical, partial, and malformed graph manifests', () => {
|
|
398
|
+
const partial = graphFixture();
|
|
399
|
+
delete partial.assets['darwin-arm64'];
|
|
400
|
+
assert.throws(() => validateGraphManifest(partial, { version: APP_VERSION }), /keys must be exactly/);
|
|
401
|
+
|
|
402
|
+
for (const replacement of [
|
|
403
|
+
'https://github.com/tribgames/mixdog/releases/download/v0.7.18/mixdog-graph-linux-x64',
|
|
404
|
+
'https://github.com/tribgames/mixdog/releases/download/graph-v0.1.1/mixdog-graph-linux-x64',
|
|
405
|
+
'https://example.com/tribgames/mixdog/releases/download/graph-v0.1.0/mixdog-graph-linux-x64',
|
|
406
|
+
'https://github.com/tribgames/mixdog/releases/download/graph-v0.1.0/mixdog-graph-wrong',
|
|
407
|
+
]) {
|
|
408
|
+
const noncanonical = graphFixture();
|
|
409
|
+
noncanonical.assets['linux-x64'].url = replacement;
|
|
410
|
+
assert.throws(
|
|
411
|
+
() => validateGraphManifest(noncanonical, { version: APP_VERSION }),
|
|
412
|
+
/graph asset URL must be/,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const malformedDigest = graphFixture();
|
|
417
|
+
malformedDigest.assets['win32-x64'].sha256 = 'not-a-digest';
|
|
418
|
+
assert.throws(
|
|
419
|
+
() => validateGraphManifest(malformedDigest, { version: APP_VERSION }),
|
|
420
|
+
/invalid graph asset sha256/,
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const malformedVersion = graphFixture();
|
|
424
|
+
malformedVersion.version = '0.1';
|
|
425
|
+
assert.throws(
|
|
426
|
+
() => validateGraphManifest(malformedVersion, { version: APP_VERSION }),
|
|
427
|
+
/not strict MAJOR\.MINOR\.PATCH/,
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('downloads local fixture responses, retries, and checks sha256 without network', async () => {
|
|
432
|
+
let calls = 0;
|
|
433
|
+
await verifyAssetDownloads(
|
|
434
|
+
{ fixture: { url: 'https://fixtures.invalid/asset', sha256, size: bytes.length } },
|
|
435
|
+
{
|
|
436
|
+
fetchImpl: async () => {
|
|
437
|
+
calls += 1;
|
|
438
|
+
if (calls === 1) throw new Error('fixture transient failure');
|
|
439
|
+
return new Response(bytes);
|
|
440
|
+
},
|
|
441
|
+
retryDelay: async () => {},
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
assert.equal(calls, 2);
|
|
445
|
+
|
|
446
|
+
calls = 0;
|
|
447
|
+
await assert.rejects(
|
|
448
|
+
verifyAssetDownloads(
|
|
449
|
+
{ fixture: { url: 'https://fixtures.invalid/asset', sha256: '0'.repeat(64) } },
|
|
450
|
+
{
|
|
451
|
+
fetchImpl: async () => {
|
|
452
|
+
calls += 1;
|
|
453
|
+
return new Response(bytes);
|
|
454
|
+
},
|
|
455
|
+
retryDelay: async () => {},
|
|
456
|
+
},
|
|
457
|
+
),
|
|
458
|
+
/verification failed after 3 attempts: sha256 mismatch/,
|
|
459
|
+
);
|
|
460
|
+
assert.equal(calls, 3);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test('cancels an undeclared-size patch stream immediately at the absolute ceiling', async () => {
|
|
464
|
+
let chunksProduced = 0;
|
|
465
|
+
let cancellations = 0;
|
|
466
|
+
let aborts = 0;
|
|
467
|
+
const stream = new ReadableStream(
|
|
468
|
+
{
|
|
469
|
+
pull(controller) {
|
|
470
|
+
chunksProduced += 1;
|
|
471
|
+
controller.enqueue(Buffer.from('abc'));
|
|
472
|
+
},
|
|
473
|
+
cancel() {
|
|
474
|
+
cancellations += 1;
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
{ highWaterMark: 0 },
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
await assert.rejects(
|
|
481
|
+
verifyAssetDownloads(
|
|
482
|
+
{ fixture: { url: 'https://fixtures.invalid/asset', sha256 } },
|
|
483
|
+
{
|
|
484
|
+
attempts: 1,
|
|
485
|
+
maxAssetBytes: 5,
|
|
486
|
+
fetchImpl: async (_url, { signal }) => {
|
|
487
|
+
signal.addEventListener('abort', () => { aborts += 1; });
|
|
488
|
+
return { ok: true, status: 200, body: stream };
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
),
|
|
492
|
+
/byte ceiling exceeded \(5 bytes\)/,
|
|
493
|
+
);
|
|
494
|
+
assert.equal(chunksProduced, 2);
|
|
495
|
+
assert.equal(cancellations, 1);
|
|
496
|
+
assert.equal(aborts, 1);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('cancels immediately when a stream exceeds its declared size below the absolute ceiling', async () => {
|
|
500
|
+
let chunksProduced = 0;
|
|
501
|
+
let cancellations = 0;
|
|
502
|
+
let aborts = 0;
|
|
503
|
+
const stream = new ReadableStream(
|
|
504
|
+
{
|
|
505
|
+
pull(controller) {
|
|
506
|
+
chunksProduced += 1;
|
|
507
|
+
controller.enqueue(Buffer.from('abc'));
|
|
508
|
+
},
|
|
509
|
+
cancel() {
|
|
510
|
+
cancellations += 1;
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
{ highWaterMark: 0 },
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
await assert.rejects(
|
|
517
|
+
verifyAssetDownloads(
|
|
518
|
+
{ fixture: { url: 'https://fixtures.invalid/asset', sha256, size: 5 } },
|
|
519
|
+
{
|
|
520
|
+
attempts: 1,
|
|
521
|
+
maxAssetBytes: 10,
|
|
522
|
+
fetchImpl: async (_url, { signal }) => {
|
|
523
|
+
signal.addEventListener('abort', () => { aborts += 1; });
|
|
524
|
+
return { ok: true, status: 200, body: stream };
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
),
|
|
528
|
+
/byte ceiling exceeded \(5 bytes\)/,
|
|
529
|
+
);
|
|
530
|
+
assert.equal(chunksProduced, 2);
|
|
531
|
+
assert.equal(cancellations, 1);
|
|
532
|
+
assert.equal(aborts, 1);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test('full guard reads deterministic fixtures and downloads every declared asset', async () => {
|
|
536
|
+
const dir = await mkdtemp(join(tmpdir(), 'mixdog-release-assets-'));
|
|
537
|
+
const paths = {
|
|
538
|
+
patchManifestPath: join(dir, 'patch.json'),
|
|
539
|
+
cargoPath: join(dir, 'Cargo.toml'),
|
|
540
|
+
runtimeManifestPath: join(dir, 'runtime.json'),
|
|
541
|
+
graphManifestPath: join(dir, 'graph.json'),
|
|
542
|
+
packagePath: join(dir, 'package.json'),
|
|
543
|
+
};
|
|
544
|
+
await Promise.all([
|
|
545
|
+
writeFile(paths.patchManifestPath, JSON.stringify(patchFixture())),
|
|
546
|
+
writeFile(paths.cargoPath, `[package]\nversion = "${VERSION}"\n`),
|
|
547
|
+
writeFile(paths.runtimeManifestPath, JSON.stringify(runtimeFixture())),
|
|
548
|
+
writeFile(paths.graphManifestPath, JSON.stringify(graphFixture())),
|
|
549
|
+
writeFile(paths.packagePath, JSON.stringify({ version: APP_VERSION })),
|
|
550
|
+
]);
|
|
551
|
+
let downloads = 0;
|
|
552
|
+
try {
|
|
553
|
+
await verifyReleaseAssets({
|
|
554
|
+
...paths,
|
|
555
|
+
downloadOptions: {
|
|
556
|
+
attempts: 1,
|
|
557
|
+
timeoutMs: 1000,
|
|
558
|
+
fetchImpl: async () => {
|
|
559
|
+
downloads += 1;
|
|
560
|
+
return new Response(bytes);
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
} finally {
|
|
565
|
+
await rm(dir, { recursive: true, force: true });
|
|
566
|
+
}
|
|
567
|
+
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
|
+
);
|
|
647
|
+
});
|