mixdog 1.1.0 → 1.1.1
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 +13 -6
- package/package.json +1 -1
- package/scripts/latest-guard.mjs +63 -0
- package/scripts/lib/run-node-tests.mjs +103 -27
- package/scripts/lib/test-failure-summary.mjs +7 -2
- package/scripts/merge-mac-update-feed.mjs +107 -0
- package/scripts/mobile-release.mjs +210 -0
- package/scripts/post-release-check.mjs +353 -0
- package/scripts/publish-asset-release.mjs +43 -0
- package/scripts/release-guard.mjs +63 -0
- package/scripts/test.mjs +29 -7
- package/src/defaults/shipped-history.json +2 -0
- package/src/defaults/skills/computer-use/SKILL.md +1 -1
- package/src/defaults/skills/setup/references/actions.md +1 -0
- package/src/lib/keychain-cjs.cjs +582 -17
- package/src/runtime/agent/orchestrator/config-storage.mjs +32 -4
- package/src/runtime/agent/orchestrator/config.mjs +18 -0
- package/src/runtime/agent/orchestrator/context/instruction-files.mjs +130 -0
- package/src/runtime/agent/orchestrator/context/role-instructions.mjs +4 -0
- package/src/runtime/agent/orchestrator/providers/lib/oauth-token-utils.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/lib/open-login-url.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-login.mjs +254 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +218 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +239 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +233 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth-login.mjs +247 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +225 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +542 -0
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +25 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +13 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +14 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +99 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +4 -8
- package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +2 -7
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +8 -0
- package/src/runtime/agent/orchestrator/providers/registry.mjs +4 -0
- package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +2 -0
- package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +15 -1
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-call-ids.mjs +90 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +19 -11
- package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/tool-batch/finalize.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/transcript-restore/tool-result-status.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/turn-effort.mjs +18 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +11 -8
- package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +9 -4
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/bashism-rewrite.mjs +245 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/command-preflight.mjs +13 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/completed-result.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/result-format.mjs +49 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -7
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +61 -8
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +28 -3
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +73 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-guards.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +17 -6
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +23 -5
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +93 -43
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +32 -6
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/lib/shell-run-background/promote-job.mjs +12 -15
- package/src/runtime/agent/orchestrator/tools/lib/shell-run-background.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +4 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -81
- package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +19 -4
- package/src/runtime/computer-bridge/client.mjs +25 -1
- package/src/runtime/computer-bridge/linux-action-capabilities.mjs +84 -0
- package/src/runtime/computer-bridge/linux-readiness.d.mts +52 -0
- package/src/runtime/computer-bridge/linux-readiness.mjs +411 -0
- package/src/runtime/computer-bridge/test-linux-display.mjs +5 -0
- package/src/runtime/effort-judge/compact-tokenizer.mjs +13 -8
- package/src/runtime/effort-judge/judge-input.mjs +5 -2
- package/src/runtime/effort-judge/model-install.mjs +9 -3
- package/src/runtime/effort-judge/model-manifest.json +7 -7
- package/src/runtime/memory/lib/embedding-worker.mjs +9 -3
- package/src/runtime/memory/lib/pg/supervisor.mjs +3 -0
- package/src/runtime/shared/config.mjs +50 -2
- package/src/runtime/shared/developer-options.mjs +7 -0
- package/src/runtime/shared/instruction-files-mode.mjs +9 -0
- package/src/runtime/shared/llm/model-catalog.mjs +2 -1
- package/src/runtime/shared/llm/openai-daybreak.mjs +79 -0
- package/src/runtime/shared/pristine-execution-contract.json +9 -0
- package/src/runtime/shared/pristine-execution.mjs +1 -1
- package/src/runtime/shared/provider-accounts.mjs +2 -0
- package/src/runtime/shared/resource-admission.mjs +10 -3
- package/src/runtime/shared/schedule-model-ref.mjs +11 -0
- package/src/runtime/shared/session-approval-wait.mjs +35 -0
- package/src/runtime/shared/shell-display.mjs +4 -2
- package/src/runtime/shared/turn-worktree-snapshot.mjs +11 -10
- package/src/session-runtime/boot/apis.mjs +5 -0
- package/src/session-runtime/model-recency.mjs +3 -0
- package/src/session-runtime/provider-auth/login.mjs +10 -10
- package/src/session-runtime/provider-auth/setup.mjs +22 -2
- package/src/session-runtime/provider-models/row-hydration.mjs +12 -4
- package/src/session-runtime/provider-readiness.mjs +4 -0
- package/src/session-runtime/runtime-facade.mjs +5 -0
- package/src/session-runtime/schedule-session-run.mjs +3 -1
- package/src/session-runtime/services/agent-tool/index-write-queue.mjs +55 -14
- package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +15 -3
- package/src/session-runtime/services/agent-tool/job-views/session-progress.mjs +2 -0
- package/src/session-runtime/services/agent-tool/worker-index/approval-wait.mjs +97 -0
- package/src/session-runtime/services/agent-tool/worker-index/mutation-batch.mjs +4 -2
- package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -2
- package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +3 -0
- package/src/session-runtime/services/agent-tool/worker-index/row-store.mjs +58 -2
- package/src/session-runtime/services/agent-tool/worker-index.mjs +69 -15
- package/src/session-runtime/services/agent-tool/worker-rows.mjs +2 -0
- package/src/session-runtime/services/channel-admin.mjs +4 -2
- package/src/session-runtime/services/provider-admin.mjs +80 -17
- package/src/session-runtime/services/seeds.mjs +7 -10
- package/src/session-runtime/services/usage-dashboard-model.mjs +6 -0
- package/src/session-runtime/settings-builtin-tools-api.mjs +15 -0
- package/src/session-runtime/setup-tool/executor.mjs +5 -0
- package/src/session-runtime/setup-tool/tool-defs.mjs +8 -0
- package/src/session-runtime/tool-policy-refresh.mjs +3 -0
- package/src/session-runtime/webhook-session-run.mjs +3 -1
- package/src/standalone/session-protocol.mjs +1 -0
- package/src/tui/app/model-options.mjs +13 -1
- package/src/tui/app/model-picker/route-selection/fast-parameters.mjs +10 -4
- package/src/tui/app/provider-setup/oauth-actions.mjs +5 -0
- package/src/tui/dist/index.mjs +84 -34
- package/src/tui/session/context-state.mjs +2 -0
- package/src/tui/session/live-share/owner.mjs +17 -0
- package/src/tui/session/oauth-flows.mjs +2 -0
- package/src/tui/session/session-api/integrations.mjs +8 -8
- package/src/tui/session/session-api/routes.mjs +2 -2
- package/src/tui/session/session-api/settings.mjs +1 -0
- package/src/tui/session/tool-card-results/grouped-result.mjs +11 -2
- package/src/tui/session/transcript-spill/spill-dir.mjs +22 -6
- package/src/tui/spinner-meta.mjs +26 -10
package/README.md
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
<a href="https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-win-x64.exe"><img src="https://raw.githubusercontent.com/tribgames/mixdog/main/docs/assets/download-windows.svg" alt="Download for Windows" height="48"></a>
|
|
13
13
|
|
|
14
14
|
<a href="https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-mac-arm64.dmg"><img src="https://raw.githubusercontent.com/tribgames/mixdog/main/docs/assets/download-macos.svg" alt="Download for macOS" height="48"></a>
|
|
15
|
+
|
|
16
|
+
<a href="https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-linux-x86_64.AppImage"><img src="https://raw.githubusercontent.com/tribgames/mixdog/main/docs/assets/download-linux.svg" alt="Download for Linux" height="48"></a>
|
|
15
17
|
<br>
|
|
16
|
-
<sub>macOS button is for Apple Silicon (M1 and later). Intel Mac? <a href="https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-mac-x64.dmg">Download for Intel Mac</a
|
|
18
|
+
<sub>macOS button is for Apple Silicon (M1 and later). Intel Mac? <a href="https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-mac-x64.dmg">Download for Intel Mac</a>. Linux button is the x64 AppImage; .deb and ARM64 builds are listed below.</sub>
|
|
17
19
|
</p>
|
|
18
20
|
|
|
19
21
|
<p align="center">
|
|
@@ -35,9 +37,9 @@ https://github.com/user-attachments/assets/af515dc9-b0fb-4b6a-aaa1-1d82db792ed0
|
|
|
35
37
|
- [63% fewer tokens](#benchmarks) than Codex CLI on the same model, at the
|
|
36
38
|
same success rate.
|
|
37
39
|
- [**Auto reasoning effort**](docs/auto-reasoning-effort.md) — each message
|
|
38
|
-
and tool step gets the effort it needs, judged in about 0.1 s by a
|
|
40
|
+
and tool step gets the effort it needs, judged in about 0.1 s by a 150 MB
|
|
39
41
|
model that runs locally on the CPU. On by default.
|
|
40
|
-
- Use the **Claude, ChatGPT, or
|
|
42
|
+
- Use the **Claude, ChatGPT, Grok, or Muse Code** subscription you already have, API
|
|
41
43
|
keys, or local models on your own GPU.
|
|
42
44
|
- Mix models by role and scale from **Solo to Swarm** — a strong model leads
|
|
43
45
|
while cheaper ones search, edit, and review in parallel.
|
|
@@ -47,7 +49,7 @@ https://github.com/user-attachments/assets/af515dc9-b0fb-4b6a-aaa1-1d82db792ed0
|
|
|
47
49
|
continues from your phone, end-to-end encrypted.
|
|
48
50
|
- Goes beyond code with **browser and desktop control**, Office and PDF
|
|
49
51
|
documents, and image and video generation.
|
|
50
|
-
- Runs on Windows and
|
|
52
|
+
- Runs on Windows, macOS, and Linux. No config files.
|
|
51
53
|
|
|
52
54
|
## Installation
|
|
53
55
|
|
|
@@ -58,8 +60,12 @@ Download the desktop app:
|
|
|
58
60
|
| Windows (x64) | [`mixdog-desktop-win-x64.exe`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-win-x64.exe) |
|
|
59
61
|
| macOS (Apple Silicon) | [`mixdog-desktop-mac-arm64.dmg`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-mac-arm64.dmg) |
|
|
60
62
|
| macOS (Intel) | [`mixdog-desktop-mac-x64.dmg`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-mac-x64.dmg) |
|
|
63
|
+
| Linux (x64, AppImage) | [`mixdog-desktop-linux-x86_64.AppImage`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-linux-x86_64.AppImage) |
|
|
64
|
+
| Linux (x64, .deb) | [`mixdog-desktop-linux-amd64.deb`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-linux-amd64.deb) |
|
|
65
|
+
| Linux (ARM64, AppImage) | [`mixdog-desktop-linux-arm64.AppImage`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-linux-arm64.AppImage) |
|
|
66
|
+
| Linux (ARM64, .deb) | [`mixdog-desktop-linux-arm64.deb`](https://github.com/tribgames/mixdog/releases/latest/download/mixdog-desktop-linux-arm64.deb) |
|
|
61
67
|
|
|
62
|
-
Then sign in with a ChatGPT, Claude, or
|
|
68
|
+
Then sign in with a ChatGPT, Claude, Grok, or Muse Code account, or paste an API key. A
|
|
63
69
|
five-step tutorial walks you through the rest.
|
|
64
70
|
|
|
65
71
|
> [!NOTE]
|
|
@@ -80,11 +86,12 @@ mixdog
|
|
|
80
86
|
- OpenAI API keys and ChatGPT/Codex account OAuth
|
|
81
87
|
- Google Gemini API keys
|
|
82
88
|
- xAI API keys and Grok account OAuth
|
|
89
|
+
- Muse Code (Meta) account OAuth
|
|
83
90
|
- OpenRouter, DeepSeek, and OpenCode Go
|
|
84
91
|
- Built-in **Local Provider** — download and run models in the app
|
|
85
92
|
(Windows x64 with an NVIDIA GPU)
|
|
86
93
|
|
|
87
|
-
Cursor
|
|
94
|
+
Cursor, Antigravity (Gemini), and MiMo OAuth are off by default under
|
|
88
95
|
**Settings → Developer**; using them through OAuth risks account restrictions.
|
|
89
96
|
|
|
90
97
|
</details>
|
package/package.json
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Keep GitHub's "Latest" release on the newest published app release.
|
|
3
|
+
// Run by .github/workflows/latest-guard.yml. When Latest points elsewhere (or
|
|
4
|
+
// nowhere) it is set back with `gh release edit <tag> --latest`, the job
|
|
5
|
+
// summary and an issue describe what was wrong, and the job fails so the
|
|
6
|
+
// drift is visible even though it has been repaired.
|
|
7
|
+
|
|
8
|
+
import { appendFileSync } from 'node:fs';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { currentLatestTag, gh, listReleases, selectLatestAppRelease } from './release-guard.mjs';
|
|
11
|
+
|
|
12
|
+
const ISSUE_TITLE = 'Latest release pointer drifted from the newest app release';
|
|
13
|
+
|
|
14
|
+
function report(text) {
|
|
15
|
+
console.log(text);
|
|
16
|
+
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${text}\n`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function openIssue(repo, body) {
|
|
20
|
+
const existing = JSON.parse(gh([
|
|
21
|
+
'issue', 'list', '--repo', repo, '--state', 'open', '--search', `"${ISSUE_TITLE}" in:title`,
|
|
22
|
+
'--json', 'number,title',
|
|
23
|
+
])).find((issue) => issue.title === ISSUE_TITLE);
|
|
24
|
+
if (existing) {
|
|
25
|
+
gh(['issue', 'comment', String(existing.number), '--repo', repo, '--body', body]);
|
|
26
|
+
return `commented on issue #${existing.number}`;
|
|
27
|
+
}
|
|
28
|
+
gh(['issue', 'create', '--repo', repo, '--title', ISSUE_TITLE, '--body', body]);
|
|
29
|
+
return 'opened an issue';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function main(repo = process.env.GITHUB_REPOSITORY) {
|
|
33
|
+
const expected = selectLatestAppRelease(listReleases(repo));
|
|
34
|
+
if (!expected) {
|
|
35
|
+
report('No published app release (vX.Y.Z) exists; nothing to guard.');
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
const actual = currentLatestTag(repo);
|
|
39
|
+
if (actual === expected.tag_name) {
|
|
40
|
+
report(`Latest is ${actual}, the newest app release. OK.`);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const problem = `GitHub Latest was \`${actual ?? 'none'}\` but the newest published app release is \`${expected.tag_name}\`.`;
|
|
45
|
+
gh(['release', 'edit', expected.tag_name, '--repo', repo, '--latest']);
|
|
46
|
+
const repaired = currentLatestTag(repo);
|
|
47
|
+
const outcome = repaired === expected.tag_name
|
|
48
|
+
? `Set Latest back to \`${expected.tag_name}\`.`
|
|
49
|
+
: `Could not set Latest to \`${expected.tag_name}\`; it is now \`${repaired ?? 'none'}\`.`;
|
|
50
|
+
const detail = `${problem}\n${outcome}\n\nWorkflow run: ${process.env.GITHUB_SERVER_URL ?? ''}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID ?? ''}\n`
|
|
51
|
+
+ 'Check which release or workflow published a non-app release without `--latest=false` / `make_latest: false`.';
|
|
52
|
+
report(`## Latest release guard\n${detail}`);
|
|
53
|
+
try {
|
|
54
|
+
report(`Issue: ${openIssue(repo, detail)}.`);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
report(`Issue not filed: ${error.message}`);
|
|
57
|
+
}
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
62
|
+
process.exit(main());
|
|
63
|
+
}
|
|
@@ -70,19 +70,68 @@ const positiveInt = (value) => {
|
|
|
70
70
|
// `node --test` defaults to availableParallelism()-1 file processes. When the
|
|
71
71
|
// run is one of several concurrent shell commands (the Mixdog shell tool
|
|
72
72
|
// exports the admission cap as MIXDOG_SHELL_CONCURRENCY_CAP), that multiplies
|
|
73
|
-
// across peers, so
|
|
73
|
+
// across peers, so a capped run leaves `cap` cores to them: cores - cap, never
|
|
74
|
+
// below 4. Dividing the cores by the cap left a 20-core machine with 2 file
|
|
75
|
+
// processes and tripled the wall time (desktop 289 s vs 63.5 s). Precedence: an
|
|
74
76
|
// explicit --test-concurrency flag, then MIXDOG_TEST_CONCURRENCY, then the
|
|
75
|
-
// share; with none of them Node's default stands
|
|
76
|
-
//
|
|
77
|
+
// capped share; with none of them Node's default (cores-1) stands.
|
|
78
|
+
// Returns the flag to add, or null.
|
|
77
79
|
export function testConcurrencyArg(nodeArgs, env = process.env, parallelism = availableParallelism()) {
|
|
78
|
-
if (nodeArgs.some(
|
|
80
|
+
if (nodeArgs.some(concurrencyFlag)) return null;
|
|
79
81
|
const explicit = positiveInt(env.MIXDOG_TEST_CONCURRENCY);
|
|
80
82
|
if (explicit) return `--test-concurrency=${explicit}`;
|
|
81
83
|
const cap = positiveInt(env.MIXDOG_SHELL_CONCURRENCY_CAP);
|
|
82
84
|
if (!cap) return null;
|
|
83
|
-
return `--test-concurrency=${Math.max(
|
|
85
|
+
return `--test-concurrency=${Math.max(4, parallelism - cap)}`;
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
// Every test file process is bounded: node:test fails a file (and records it
|
|
89
|
+
// like any other failure) that is still running after --test-timeout, so a hang
|
|
90
|
+
// costs minutes instead of waiting for the batch watchdog. A --test-timeout
|
|
91
|
+
// flag wins, then MIXDOG_TEST_TIMEOUT_MS (0 turns the bound off), then the
|
|
92
|
+
// caller's default. Returns the flag to add, or null.
|
|
93
|
+
export function testTimeoutArg(nodeArgs, env = process.env, defaultMs = DEFAULT_TEST_TIMEOUT_MS) {
|
|
94
|
+
if (nodeArgs.some((arg) => arg === '--test-timeout' || arg.startsWith('--test-timeout='))) return null;
|
|
95
|
+
const raw = env.MIXDOG_TEST_TIMEOUT_MS;
|
|
96
|
+
if (raw !== undefined && String(raw).trim() === '0') return null;
|
|
97
|
+
const ms = positiveInt(raw) ?? defaultMs;
|
|
98
|
+
return ms ? `--test-timeout=${ms}` : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const concurrencyFlag = (arg) => arg === '--test-concurrency' || arg.startsWith('--test-concurrency=');
|
|
102
|
+
|
|
103
|
+
// The file-process count a run was given (flag or env), else Node's default.
|
|
104
|
+
function concurrencyOf(runArgs, fileArgs) {
|
|
105
|
+
const args = [...runArgs, ...fileArgs];
|
|
106
|
+
const index = args.findIndex(concurrencyFlag);
|
|
107
|
+
const value = index < 0 ? null : positiveInt(args[index].includes('=') ? args[index].split('=')[1] : args[index + 1]);
|
|
108
|
+
return value ?? Math.max(1, availableParallelism() - 1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function withoutConcurrency(args) {
|
|
112
|
+
return args.filter((arg, i) => !concurrencyFlag(arg) && !(i > 0 && args[i - 1] === '--test-concurrency'));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// What keeps a test process off the signed-in user's machine state.
|
|
116
|
+
export function testIsolationEnv(scratchDir) {
|
|
117
|
+
return {
|
|
118
|
+
TEMP: scratchDir,
|
|
119
|
+
TMP: scratchDir,
|
|
120
|
+
TMPDIR: scratchDir,
|
|
121
|
+
// A fixture must never write into the signed-in app's data (~/.mixdog):
|
|
122
|
+
// schedule/webhook logs, design-library bindings and the like resolve the
|
|
123
|
+
// default data dir. The desktop preload already does this per process.
|
|
124
|
+
MIXDOG_DATA_DIR: join(scratchDir, 'data'),
|
|
125
|
+
// Any session a test composes would otherwise read the real home's
|
|
126
|
+
// AGENTS.md / CLAUDE.md whenever the stored setting is on; tests that
|
|
127
|
+
// exercise the feature clear this and pass their own home.
|
|
128
|
+
MIXDOG_BOOT_INSTRUCTION_FILES: '0',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const DEFAULT_TEST_TIMEOUT_MS = 120_000;
|
|
133
|
+
export const LONG_TEST_TIMEOUT_MS = 600_000;
|
|
134
|
+
|
|
86
135
|
// A batch that has not exited by then is killed with its process tree and
|
|
87
136
|
// fails the run; MIXDOG_TEST_BATCH_TIMEOUT_MS overrides the default.
|
|
88
137
|
const DEFAULT_BATCH_TIMEOUT_MS = 60 * 60 * 1000;
|
|
@@ -185,8 +234,11 @@ export function ownedProcesses(snapshot, anchors, { batchStart, scratchDir, plat
|
|
|
185
234
|
}
|
|
186
235
|
}
|
|
187
236
|
const fold = (path) => (platform === 'win32' ? path.toLowerCase() : path);
|
|
188
|
-
|
|
189
|
-
|
|
237
|
+
// An empty scratchDir skips the sweep: batches running side by side share
|
|
238
|
+
// one scratch root, so one batch's exit must not claim its peers' processes.
|
|
239
|
+
const scratchPrefix = scratchDir ? fold(join(scratchDir, '/')) : null;
|
|
240
|
+
if (scratchPrefix)
|
|
241
|
+
for (const entry of snapshot) if (entry.exe && fold(entry.exe).startsWith(scratchPrefix)) claim(entry);
|
|
190
242
|
while (queue.length > 0) {
|
|
191
243
|
const parent = queue.pop();
|
|
192
244
|
for (const entry of childrenOf.get(parent.pid) ?? []) {
|
|
@@ -219,6 +271,10 @@ async function reapLeftovers(child, { batchStart, ledger, scratchDir }) {
|
|
|
219
271
|
}
|
|
220
272
|
}
|
|
221
273
|
const anchors = [...(child.pid ? [{ pid: child.pid, since: batchStart }] : []), ...(await readSpawnLedger(ledger))];
|
|
274
|
+
await killOwned(anchors, { batchStart, scratchDir });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function killOwned(anchors, { batchStart, scratchDir }) {
|
|
222
278
|
const leftovers = ownedProcesses(await processSnapshot(), anchors, { batchStart, scratchDir });
|
|
223
279
|
if (leftovers.length === 0) return;
|
|
224
280
|
const pids = leftovers.map((entry) => entry.pid);
|
|
@@ -237,6 +293,7 @@ async function reapLeftovers(child, { batchStart, ledger, scratchDir }) {
|
|
|
237
293
|
}
|
|
238
294
|
|
|
239
295
|
// Resolves { code, hung } once the batch exited and its leftovers are gone.
|
|
296
|
+
// `scratchDir` '' = leave the scratch-root sweep to the caller (side-by-side batches).
|
|
240
297
|
function spawnBatch(args, env, { files, ledger, scratchDir, timeoutMs }) {
|
|
241
298
|
return new Promise((resolve) => {
|
|
242
299
|
const batchStart = Date.now();
|
|
@@ -313,10 +370,12 @@ async function readFailureRecords(path) {
|
|
|
313
370
|
// flag was passed, does not fail the run; a test that fails every re-run
|
|
314
371
|
// still exits 1. Every run ends with the failure summary block, so a filtered
|
|
315
372
|
// log can still name what failed.
|
|
373
|
+
//
|
|
374
|
+
// `testTimeoutMs` is the per-file --test-timeout default (see testTimeoutArg).
|
|
316
375
|
export async function runNodeTests(
|
|
317
376
|
nodeArgs,
|
|
318
377
|
fileArgs,
|
|
319
|
-
{ argBudget = ARG_BUDGET, coverage = false, rerunFailed = 0 } = {}
|
|
378
|
+
{ argBudget = ARG_BUDGET, coverage = false, rerunFailed = 0, testTimeoutMs = DEFAULT_TEST_TIMEOUT_MS } = {}
|
|
320
379
|
) {
|
|
321
380
|
await pruneStaleTestLogs(tmpdir());
|
|
322
381
|
const logPath = join(await mkdtemp(join(tmpdir(), TEST_LOG_PREFIX)), 'full.log');
|
|
@@ -330,9 +389,11 @@ export async function runNodeTests(
|
|
|
330
389
|
const scratchDir = await mkdtemp(join(tmpdir(), 'mixdog-test-scratch-'));
|
|
331
390
|
// The explicit entry also accepts Node options alongside its file paths.
|
|
332
391
|
const concurrencyArg = testConcurrencyArg([...nodeArgs, ...fileArgs]);
|
|
392
|
+
const timeoutArg = testTimeoutArg([...nodeArgs, ...fileArgs], process.env, testTimeoutMs);
|
|
333
393
|
const runArgs = [
|
|
334
394
|
...nodeArgs,
|
|
335
395
|
...(concurrencyArg ? [concurrencyArg] : []),
|
|
396
|
+
...(timeoutArg ? [timeoutArg] : []),
|
|
336
397
|
// Node's test runner passes --import on to every test file process.
|
|
337
398
|
`--import=${SPAWN_LEDGER}`,
|
|
338
399
|
`--test-reporter=${SUMMARY_REPORTER}`,
|
|
@@ -343,8 +404,19 @@ export async function runNodeTests(
|
|
|
343
404
|
argCost(process.execPath) +
|
|
344
405
|
runArgs.reduce((total, arg) => total + argCost(arg), 0) +
|
|
345
406
|
argCost(`--test-reporter-destination=${logPath}`) +
|
|
346
|
-
argCost('.999')
|
|
407
|
+
argCost('.999') + // the per-batch log suffix
|
|
408
|
+
argCost('--test-concurrency=999'); // the per-batch share when batches overlap
|
|
347
409
|
const batches = chunkFileArgs(fileArgs, Math.max(argBudget - fixedCost, 1));
|
|
410
|
+
// Batches run side by side (a sequential second batch idled the machine
|
|
411
|
+
// behind the first one's slowest file), so the run's concurrency is split
|
|
412
|
+
// across them instead of multiplied.
|
|
413
|
+
const overlapped = batches.length > 1;
|
|
414
|
+
const totalConcurrency = overlapped ? concurrencyOf(runArgs, fileArgs) : 0;
|
|
415
|
+
const batchRunArgs = () => {
|
|
416
|
+
if (!overlapped) return runArgs;
|
|
417
|
+
const share = Math.max(1, Math.ceil(totalConcurrency / batches.length));
|
|
418
|
+
return [...withoutConcurrency(runArgs), `--test-concurrency=${share}`];
|
|
419
|
+
};
|
|
348
420
|
const coverageDir = coverage ? await mkdtemp(join(tmpdir(), 'mixdog-v8-coverage-')) : '';
|
|
349
421
|
// Node 24's test runner rebuilds each test process's execArgv from its option
|
|
350
422
|
// table and drops V8 flags on the way, so a heap cap passed to the runner no
|
|
@@ -356,13 +428,7 @@ export async function runNodeTests(
|
|
|
356
428
|
// already budgeted, and a third reporter would warn about listeners.
|
|
357
429
|
const childEnv = (recordsPath) => ({
|
|
358
430
|
...process.env,
|
|
359
|
-
|
|
360
|
-
TMP: scratchDir,
|
|
361
|
-
TMPDIR: scratchDir,
|
|
362
|
-
// A fixture must never write into the signed-in app's data (~/.mixdog):
|
|
363
|
-
// schedule/webhook logs, design-library bindings and the like resolve the
|
|
364
|
-
// default data dir. The desktop preload already does this per process.
|
|
365
|
-
MIXDOG_DATA_DIR: join(scratchDir, 'data'),
|
|
431
|
+
...testIsolationEnv(scratchDir),
|
|
366
432
|
// Only the immutable validator binary is shared; fixture data stays private.
|
|
367
433
|
// A gate supplies one directory across lanes; a standalone run owns its own.
|
|
368
434
|
MIXDOG_TEST_OOXML_CACHE_DIR: process.env.MIXDOG_TEST_OOXML_CACHE_DIR || join(scratchDir, 'ooxml-cache'),
|
|
@@ -370,23 +436,33 @@ export async function runNodeTests(
|
|
|
370
436
|
...(coverageDir ? { NODE_V8_COVERAGE: coverageDir } : {}),
|
|
371
437
|
...(nodeOptions ? { NODE_OPTIONS: nodeOptions } : {}),
|
|
372
438
|
});
|
|
373
|
-
const batchOptions = (files, recordsPath) => ({
|
|
439
|
+
const batchOptions = (files, recordsPath, sweepScratch = true) => ({
|
|
374
440
|
files,
|
|
375
441
|
ledger: spawnLedgerPath(recordsPath),
|
|
376
|
-
scratchDir,
|
|
442
|
+
scratchDir: sweepScratch ? scratchDir : '',
|
|
377
443
|
timeoutMs: positiveInt(process.env.MIXDOG_TEST_BATCH_TIMEOUT_MS) ?? DEFAULT_BATCH_TIMEOUT_MS,
|
|
378
444
|
});
|
|
379
445
|
let status = 0;
|
|
380
446
|
const failures = [];
|
|
381
447
|
const hung = [];
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
448
|
+
const batchLogOf = (index) => (batches.length === 1 ? logPath : `${logPath}.${index + 1}`);
|
|
449
|
+
const batchFailuresOf = (index) => join(failureDir, `batch-${index + 1}.jsonl`);
|
|
450
|
+
const outcomes = await Promise.all(
|
|
451
|
+
batches.map((batch, index) =>
|
|
452
|
+
spawnBatch(
|
|
453
|
+
[...batchRunArgs(), `--test-reporter-destination=${batchLogOf(index)}`, ...batch],
|
|
454
|
+
childEnv(batchFailuresOf(index)),
|
|
455
|
+
batchOptions(batch, batchFailuresOf(index), !overlapped)
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
);
|
|
459
|
+
// Peers were still running while each batch exited, so the scratch root's
|
|
460
|
+
// own processes (e.g. a postgres a test started) are swept once, here.
|
|
461
|
+
if (overlapped) await killOwned([], { batchStart: Date.now(), scratchDir });
|
|
462
|
+
// Logs and records merge in batch order, whatever order the batches finished.
|
|
463
|
+
for (const [index, outcome] of outcomes.entries()) {
|
|
464
|
+
const batchLog = batchLogOf(index);
|
|
465
|
+
const batchFailures = batchFailuresOf(index);
|
|
390
466
|
const { code } = outcome;
|
|
391
467
|
if (outcome.hung) hung.push(outcome.hung);
|
|
392
468
|
if (batchLog !== logPath) {
|
|
@@ -443,5 +519,5 @@ export async function runNodeTests(
|
|
|
443
519
|
await rm(coverageDir, { recursive: true, force: true });
|
|
444
520
|
}
|
|
445
521
|
// Last line of the run, after every reporter and the coverage report.
|
|
446
|
-
process.stdout.write(`\n${formatFailureSummary({ failed, flaky, rerunFailed, hung })}`);
|
|
522
|
+
process.stdout.write(`\n${formatFailureSummary({ failed, flaky, rerunFailed, hung, exitCode: status })}`);
|
|
447
523
|
}
|
|
@@ -45,10 +45,15 @@ export function policyLine(rerunFailed) {
|
|
|
45
45
|
|
|
46
46
|
// `hung` holds one "hung after N min: <files>" message per batch the runner's
|
|
47
47
|
// watchdog killed; such a batch fails the run whatever its tests reported.
|
|
48
|
-
|
|
48
|
+
// A non-zero `exitCode` with no failed test, flaky test or hung batch behind it
|
|
49
|
+
// (a bad node flag, a crashed runner) is its own failure, never "0 failed".
|
|
50
|
+
export function formatFailureSummary({ failed = [], flaky = [], rerunFailed = 0, hung = [], exitCode = 0 }) {
|
|
49
51
|
const hungCount = hung.length > 0 ? `, ${hung.length} hung batch(es)` : '';
|
|
52
|
+
const unexplained = exitCode !== 0 && failed.length === 0 && hung.length === 0;
|
|
50
53
|
const lines = [
|
|
51
|
-
|
|
54
|
+
unexplained
|
|
55
|
+
? `${SUMMARY_TAG} RUN FAILED: exit code ${exitCode} with no failed test recorded (bad argument or runner error; see output above)`
|
|
56
|
+
: `${SUMMARY_TAG} ${failed.length} failed, ${flaky.length} flaky${hungCount} (every failed test is named below)`,
|
|
52
57
|
];
|
|
53
58
|
for (const message of hung) lines.push(`${SUMMARY_TAG} HUNG ${message}`);
|
|
54
59
|
for (const record of failed) {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// electron-builder writes one latest-mac.yml per macOS build, and each runner
|
|
3
|
+
// builds a single architecture, so each feed lists only its own zip and dmg.
|
|
4
|
+
// electron-updater on macOS always reads the single latest-mac.yml of the
|
|
5
|
+
// latest release and picks the zip whose name matches the machine's
|
|
6
|
+
// architecture, so the release needs one feed that lists both builds.
|
|
7
|
+
//
|
|
8
|
+
// The publish job runs without installing dependencies, so this reads the
|
|
9
|
+
// fixed shape electron-builder emits (top-level scalars plus a `files:` list
|
|
10
|
+
// of `- url:` items) directly and fails on anything else.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { pathToFileURL } from 'node:url';
|
|
14
|
+
|
|
15
|
+
export const MAC_ARCHES = Object.freeze(['arm64', 'x64']);
|
|
16
|
+
// Legacy top-level path/sha512 (read only by electron-updater 1.x) point at
|
|
17
|
+
// the x64 zip, which also runs on Apple silicon through Rosetta.
|
|
18
|
+
const PRIMARY_ARCH = 'x64';
|
|
19
|
+
const TOP_LEVEL_KEYS = ['version', 'path', 'sha512', 'releaseDate'];
|
|
20
|
+
|
|
21
|
+
function zipArch(url) {
|
|
22
|
+
const match = /^mixdog-desktop-mac-([a-z0-9]+)\.zip$/.exec(url);
|
|
23
|
+
return match ? match[1] : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Parse one electron-builder latest-mac.yml. File items keep their raw lines. */
|
|
27
|
+
export function parseMacFeed(text, label) {
|
|
28
|
+
const top = {};
|
|
29
|
+
const files = [];
|
|
30
|
+
let inFiles = false;
|
|
31
|
+
for (const line of text.split(/\r?\n/)) {
|
|
32
|
+
if (!line.trim()) continue;
|
|
33
|
+
if (/^\S/.test(line)) {
|
|
34
|
+
const match = /^([A-Za-z][A-Za-z0-9]*):(?:\s+(.*))?$/.exec(line);
|
|
35
|
+
if (!match) throw new Error(`${label}: unsupported line: ${line}`);
|
|
36
|
+
inFiles = match[1] === 'files';
|
|
37
|
+
if (!inFiles) top[match[1]] = line;
|
|
38
|
+
} else if (inFiles && /^ {2}- url: \S+$/.test(line)) {
|
|
39
|
+
files.push({ url: line.replace(/^ {2}- url:\s+/, ''), lines: [line] });
|
|
40
|
+
} else if (inFiles && files.length > 0 && /^ {4}[A-Za-z][A-Za-z0-9]*: \S.*$/.test(line)) {
|
|
41
|
+
files[files.length - 1].lines.push(line);
|
|
42
|
+
} else {
|
|
43
|
+
throw new Error(`${label}: unsupported line: ${line}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const key of TOP_LEVEL_KEYS) {
|
|
47
|
+
if (!top[key]) throw new Error(`${label}: missing ${key}`);
|
|
48
|
+
}
|
|
49
|
+
const value = (key) => top[key].slice(key.length + 1).trim();
|
|
50
|
+
return { version: value('version'), top, files };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Merge the per-architecture feeds into one feed listing both zips. */
|
|
54
|
+
export function mergeMacFeeds(inputs, expectedVersion) {
|
|
55
|
+
const feeds = inputs.map(({ label, text }) => {
|
|
56
|
+
const feed = parseMacFeed(text, label);
|
|
57
|
+
if (expectedVersion && feed.version !== expectedVersion) {
|
|
58
|
+
throw new Error(`${label}: version ${feed.version} is not ${expectedVersion}`);
|
|
59
|
+
}
|
|
60
|
+
const zips = feed.files.filter((file) => zipArch(file.url));
|
|
61
|
+
if (zips.length !== 1) throw new Error(`${label}: expected exactly one mac zip, found ${zips.length}`);
|
|
62
|
+
const [zip] = zips;
|
|
63
|
+
for (const field of ['sha512', 'size']) {
|
|
64
|
+
if (!zip.lines.some((line) => line.startsWith(` ${field}: `))) {
|
|
65
|
+
throw new Error(`${label}: ${zip.url} has no ${field}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { ...feed, arch: zipArch(zip.url) };
|
|
69
|
+
});
|
|
70
|
+
const arches = feeds.map((feed) => feed.arch).sort();
|
|
71
|
+
if (JSON.stringify(arches) !== JSON.stringify(MAC_ARCHES)) {
|
|
72
|
+
throw new Error(`Expected one feed per ${MAC_ARCHES.join(' + ')}, found: ${arches.join(', ')}`);
|
|
73
|
+
}
|
|
74
|
+
if (feeds[0].version !== feeds[1].version) {
|
|
75
|
+
throw new Error(`Feed versions differ: ${feeds[0].version} and ${feeds[1].version}`);
|
|
76
|
+
}
|
|
77
|
+
feeds.sort((a, b) => MAC_ARCHES.indexOf(a.arch) - MAC_ARCHES.indexOf(b.arch));
|
|
78
|
+
const entries = feeds.flatMap((feed) => feed.files);
|
|
79
|
+
const urls = entries.map((file) => file.url);
|
|
80
|
+
if (new Set(urls).size !== urls.length) throw new Error(`Duplicate feed files: ${urls.join(', ')}`);
|
|
81
|
+
const primary = feeds.find((feed) => feed.arch === PRIMARY_ARCH);
|
|
82
|
+
return [
|
|
83
|
+
primary.top.version,
|
|
84
|
+
'files:',
|
|
85
|
+
...entries.flatMap((file) => file.lines),
|
|
86
|
+
primary.top.path,
|
|
87
|
+
primary.top.sha512,
|
|
88
|
+
primary.top.releaseDate,
|
|
89
|
+
'',
|
|
90
|
+
].join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
94
|
+
const args = process.argv.slice(2);
|
|
95
|
+
const option = (name) => args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
96
|
+
const out = option('out');
|
|
97
|
+
const inputs = args.filter((arg) => !arg.startsWith('--'));
|
|
98
|
+
if (!out || inputs.length === 0) {
|
|
99
|
+
throw new Error('usage: merge-mac-update-feed.mjs --out=latest-mac.yml [--version=X.Y.Z] <feed.yml>...');
|
|
100
|
+
}
|
|
101
|
+
const merged = mergeMacFeeds(
|
|
102
|
+
inputs.map((path) => ({ label: path, text: readFileSync(path, 'utf8') })),
|
|
103
|
+
option('version')
|
|
104
|
+
);
|
|
105
|
+
writeFileSync(out, merged);
|
|
106
|
+
console.log(`Wrote ${out} from ${inputs.length} feeds.`);
|
|
107
|
+
}
|