@harness-mix/cli 0.2.3 → 0.3.0
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/CHANGELOG.md +24 -0
- package/README.md +469 -467
- package/docs/harness-management.md +1 -1
- package/docs/multi-agent-collaboration.md +3 -1
- package/docs/native-acp.md +138 -127
- package/output/native-build/desktop-controller.mjs +1 -1
- package/output/native-build/renderer-extension.js +172 -23
- package/package.json +13 -9
- package/scripts/acp-image-test.cjs +69 -0
- package/scripts/adapters-test.cjs +25 -0
- package/scripts/antigravity-adapter-test.cjs +647 -626
- package/scripts/codex-accounts-test.cjs +34 -0
- package/scripts/codex-adapter-test.cjs +238 -127
- package/scripts/collaboration-test.cjs +577 -262
- package/scripts/collaboration-ui-smoke.cjs +25 -3
- package/scripts/e2e-delegate.cjs +1 -1
- package/scripts/e2e-hermes-image.cjs +60 -0
- package/scripts/jsonl-stdin-test.cjs +40 -31
- package/scripts/kiro-cursor-adapters-test.cjs +124 -100
- package/scripts/native-acp-depth-test.cjs +30 -5
- package/scripts/native-update-apply-test.cjs +269 -215
- package/scripts/native-update.cjs +78 -0
- package/scripts/native-vendor-adapters-test.cjs +196 -154
- package/scripts/openclaw-adapter-test.cjs +121 -3
- package/scripts/openclaw-mcp-probe.cjs +100 -0
- package/scripts/openclaw-mcp-tool-probe.cjs +55 -0
- package/scripts/openclaw-thinking-probe.cjs +73 -0
- package/scripts/salvage-rollout-writes.cjs +72 -0
- package/scripts/storage-verification-test.cjs +11 -0
- package/scripts/zcode-adapter-test.cjs +329 -0
- package/scripts/zcode-live-probe.cjs +66 -0
- package/src/main/adapters/antigravity.js +1428 -1418
- package/src/main/adapters/claude.js +15 -7
- package/src/main/adapters/codex-app-server.js +29 -11
- package/src/main/adapters/codex.js +690 -649
- package/src/main/adapters/native-acp-command.js +51 -48
- package/src/main/adapters/native-acp.js +47 -12
- package/src/main/adapters/omp.js +7 -2
- package/src/main/adapters/openclaw.js +534 -343
- package/src/main/adapters/pi-family.js +35 -8
- package/src/main/adapters/qoder.js +12 -8
- package/src/main/adapters/zcode.js +925 -10
- package/src/main/host/collaboration-tools.js +1 -1
- package/src/main/host/collaboration.js +971 -714
- package/src/main/host/jsonl.js +130 -120
- package/src/main/host/runtime.js +9 -3
- package/src/main/host/verification-gates.js +14 -2
- package/src/main/native/codex-accounts.js +15 -4
- package/src/main/native/config.js +9 -9
- package/src/main/native/launcher.js +252 -237
- package/src/main/native/process-utils.js +157 -57
- package/src/main/native/protocol.js +1227 -1187
- package/src/main/native/update-state.js +123 -110
- package/src/main/native/updater.js +460 -394
- package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js +3 -1
- package/src/native-ui/desktop-control/dist/renderer-cdp-control-session.js.map +1 -1
- package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/desktop-control/src/renderer-cdp-control-session.ts +358 -358
- package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts +1 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-collab-cards.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +29 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts +4 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3224 -3181
- package/src/native-ui/renderer-extension/src/renderer-collab-cards.ts +25 -0
- package/src/native-ui/renderer-extension/src/renderer-model-client.ts +27 -0
- package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +93 -13
- package/src/native-ui/renderer-extension/src/settings/connections-page.ts +2 -2
- package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +47 -1
|
@@ -1,215 +1,269 @@
|
|
|
1
|
-
// Update v2 coverage: state file / lock / channel detection / semver, plus the
|
|
2
|
-
// runUpdateFlow orchestration with mocked deps. Fully offline; no real npm/git.
|
|
3
|
-
const assert = require('node:assert/strict');
|
|
4
|
-
const fs = require('node:fs');
|
|
5
|
-
const os = require('node:os');
|
|
6
|
-
const path = require('node:path');
|
|
7
|
-
const {
|
|
8
|
-
acquireLock, compareVersions, detectChannel, markBootOk, readState, writeState,
|
|
9
|
-
} = require('../src/main/native/update-state');
|
|
10
|
-
const { fetchLatestVersion, runUpdateFlow } = require('../src/main/native/updater');
|
|
11
|
-
|
|
12
|
-
const tmp = prefix => fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
13
|
-
const quiet = () => {};
|
|
14
|
-
const delay = () => Promise.resolve();
|
|
15
|
-
const fetchVersion = version => async () => ({
|
|
16
|
-
ok: true, status: 200,
|
|
17
|
-
json: async () => ({ 'dist-tags': { latest: version } }),
|
|
18
|
-
headers: { get: () => null },
|
|
19
|
-
});
|
|
20
|
-
const npmFixture = (version = '0.1.0') => {
|
|
21
|
-
const root = path.join(tmp('hm-npm-'), 'node_modules', 'harness-mix');
|
|
22
|
-
fs.mkdirSync(root, { recursive: true });
|
|
23
|
-
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'harness-mix', version }));
|
|
24
|
-
return root;
|
|
25
|
-
};
|
|
26
|
-
const npmRun = (root, { install = { status: 0 } } = {}) => (cmd, args) => {
|
|
27
|
-
if (args[0] === 'root') return { status: 0, stdout: `${path.dirname(root)}\n` };
|
|
28
|
-
if (args[0] === 'config') return { status: 0, stdout: 'https://registry.test/\n' };
|
|
29
|
-
if (args[0] === 'install') return install;
|
|
30
|
-
return { status: 0, stdout: '' };
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
(async () => {
|
|
34
|
-
// --- semver / channel detection ---
|
|
35
|
-
assert.equal(compareVersions('0.2.0', '0.1.1'), 1);
|
|
36
|
-
assert.equal(compareVersions('0.1.1', '0.1.1'), 0);
|
|
37
|
-
assert.equal(compareVersions('0.1.10', '0.1.9'), 1);
|
|
38
|
-
assert.equal(compareVersions('0.10.0', '0.9.9'), 1);
|
|
39
|
-
assert.equal(compareVersions('0.2.0-rc.1', '0.2.0'), -1);
|
|
40
|
-
assert.equal(compareVersions('0.2.0', '0.2.0-rc.1'), 1);
|
|
41
|
-
|
|
42
|
-
const gitRoot = tmp('hm-git-');
|
|
43
|
-
fs.mkdirSync(path.join(gitRoot, '.git'));
|
|
44
|
-
assert.equal(detectChannel(gitRoot), 'git');
|
|
45
|
-
assert.equal(detectChannel(npmFixture()), 'npm');
|
|
46
|
-
assert.equal(detectChannel(tmp('hm-plain-')), 'portable');
|
|
47
|
-
|
|
48
|
-
// --- state file tolerates corruption; lock lifecycle incl. stale reap ---
|
|
49
|
-
const data = tmp('hm-data-');
|
|
50
|
-
fs.writeFileSync(path.join(data, 'update-state.json'), '{not json');
|
|
51
|
-
assert.equal(readState(data).phase, 'idle');
|
|
52
|
-
const release = acquireLock(data, 'test');
|
|
53
|
-
assert.throws(() => acquireLock(data, 'test'), /更新锁/);
|
|
54
|
-
release();
|
|
55
|
-
acquireLock(data, 'test')();
|
|
56
|
-
fs.writeFileSync(path.join(data, 'update.lock'), JSON.stringify({ pid: process.pid, ts: Date.now() - 31 * 60 * 1000, op: 'stale' }));
|
|
57
|
-
acquireLock(data, 'test')(); // stale locks are reaped even when the pid still lives
|
|
58
|
-
writeState(data, { ...readState(data), appliedVersion: '0.2.0', appliedAt: Date.now() - 1000, attempts: 3 });
|
|
59
|
-
markBootOk(data);
|
|
60
|
-
assert.equal(readState(data).attempts, 0);
|
|
61
|
-
assert.ok(readState(data).lastBootOkAt > 0);
|
|
62
|
-
|
|
63
|
-
// --- fetchLatestVersion: etag cache answers 304 from disk ---
|
|
64
|
-
{
|
|
65
|
-
const dir = tmp('hm-etag-');
|
|
66
|
-
const first = await fetchLatestVersion({
|
|
67
|
-
registry: 'https://registry.test/', dataDir: dir,
|
|
68
|
-
fetchImpl: async () => ({ ok: true, status: 200, json: async () => ({ 'dist-tags': { latest: '9.9.9' } }), headers: { get: name => (name === 'etag' ? '"e1"' : null) } }),
|
|
69
|
-
});
|
|
70
|
-
assert.equal(first, '9.9.9');
|
|
71
|
-
const second = await fetchLatestVersion({
|
|
72
|
-
registry: 'https://registry.test/', dataDir: dir,
|
|
73
|
-
fetchImpl: async () => ({ ok: false, status: 304, headers: { get: () => null } }),
|
|
74
|
-
});
|
|
75
|
-
assert.equal(second, '9.9.9');
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// --- npm: update available -> applied, restart required ---
|
|
79
|
-
{
|
|
80
|
-
const root = npmFixture();
|
|
81
|
-
const dir = tmp('hm-flow-');
|
|
82
|
-
const calls = [];
|
|
83
|
-
const run = (cmd, args) => { calls.push(args.join(' ')); return npmRun(root)(cmd, args); };
|
|
84
|
-
let stopped = 0;
|
|
85
|
-
const outcome = await runUpdateFlow({
|
|
86
|
-
root, dataDir: dir, log: quiet, stopDesktop: async () => { stopped += 1; }, mode: 'apply',
|
|
87
|
-
deps: { run, fetchImpl: fetchVersion('0.2.0'), delay, env: {} },
|
|
88
|
-
});
|
|
89
|
-
assert.equal(outcome.updated, true);
|
|
90
|
-
assert.equal(outcome.to, '0.2.0');
|
|
91
|
-
assert.equal(outcome.restartRequired, true);
|
|
92
|
-
assert.equal(stopped, 1);
|
|
93
|
-
assert.ok(calls.some(call => call.startsWith('install -g harness-mix@0.2.0')));
|
|
94
|
-
const state = readState(dir);
|
|
95
|
-
assert.equal(state.appliedVersion, '0.2.0');
|
|
96
|
-
assert.equal(state.prevVersion, '0.1.0');
|
|
97
|
-
assert.equal(state.phase, 'idle');
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// --- npm: install failure -> pending for the next launch, never blocks ---
|
|
101
|
-
{
|
|
102
|
-
const root = npmFixture();
|
|
103
|
-
const dir = tmp('hm-flow-');
|
|
104
|
-
const outcome = await runUpdateFlow({
|
|
105
|
-
root, dataDir: dir, log: quiet, stopDesktop: async () => {}, mode: 'apply',
|
|
106
|
-
deps: { run: npmRun(root, { install: { status: 1, stderr: 'EBUSY: file locked' } }), fetchImpl: fetchVersion('0.2.0'), delay, env: {} },
|
|
107
|
-
});
|
|
108
|
-
assert.equal(outcome.updated, false);
|
|
109
|
-
assert.equal(outcome.pending, '0.2.0');
|
|
110
|
-
assert.equal(outcome.restartRequired, undefined);
|
|
111
|
-
assert.equal(readState(dir).pendingVersion, '0.2.0');
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// --- npm: registry offline -> failed, launch continues ---
|
|
115
|
-
{
|
|
116
|
-
const root = npmFixture();
|
|
117
|
-
const dir = tmp('hm-flow-');
|
|
118
|
-
const outcome = await runUpdateFlow({
|
|
119
|
-
root, dataDir: dir, log: quiet, stopDesktop: async () => {}, mode: 'apply',
|
|
120
|
-
deps: { run: npmRun(root), fetchImpl: async () => { throw new Error('getaddrinfo ENOTFOUND'); }, delay, env: {} },
|
|
121
|
-
});
|
|
122
|
-
assert.equal(outcome.failed, true);
|
|
123
|
-
assert.equal(outcome.restartRequired, undefined);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// --- update lock held elsewhere -> skip without touching the desktop ---
|
|
127
|
-
{
|
|
128
|
-
const root = npmFixture();
|
|
129
|
-
const dir = tmp('hm-flow-');
|
|
130
|
-
fs.writeFileSync(path.join(dir, 'update.lock'), JSON.stringify({ pid: process.pid, ts: Date.now(), op: 'other' }));
|
|
131
|
-
let stopped = 0;
|
|
132
|
-
const outcome = await runUpdateFlow({
|
|
133
|
-
root, dataDir: dir, log: quiet, stopDesktop: async () => { stopped += 1; }, mode: 'apply',
|
|
134
|
-
deps: { run: npmRun(root), fetchImpl: fetchVersion('0.2.0'), delay, env: {} },
|
|
135
|
-
});
|
|
136
|
-
assert.equal(outcome.failed, true);
|
|
137
|
-
assert.equal(outcome.reason, 'lock');
|
|
138
|
-
assert.equal(stopped, 0);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// --- crash loop: second failed boot rolls back to prevVersion ---
|
|
142
|
-
{
|
|
143
|
-
const root = npmFixture('0.2.0'); // the installed tree currently runs 0.2.0
|
|
144
|
-
const dir = tmp('hm-flow-');
|
|
145
|
-
writeState(dir, { ...readState(dir), channel: 'npm', appliedVersion: '0.2.0', prevVersion: '0.1.0', appliedAt: Date.now() - 1000, lastBootOkAt: 0, attempts: 1 });
|
|
146
|
-
const calls = [];
|
|
147
|
-
const run = (cmd, args) => { calls.push(args.join(' ')); return npmRun(root)(cmd, args); };
|
|
148
|
-
const outcome = await runUpdateFlow({
|
|
149
|
-
root, dataDir: dir, log: quiet, stopDesktop: async () => {}, mode: 'apply',
|
|
150
|
-
deps: { run, fetchImpl: async () => { throw new Error('offline'); }, delay, env: {} },
|
|
151
|
-
});
|
|
152
|
-
assert.equal(outcome.rolledBack, true);
|
|
153
|
-
assert.equal(outcome.restartRequired, true);
|
|
154
|
-
assert.ok(calls.some(call => call.startsWith('install -g harness-mix@0.1.0')));
|
|
155
|
-
const state = readState(dir);
|
|
156
|
-
assert.equal(state.appliedVersion, null);
|
|
157
|
-
assert.equal(state.prevVersion, null);
|
|
158
|
-
assert.equal(state.attempts, 0);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// --- git: fast-forward applies and asks for a restart ---
|
|
162
|
-
{
|
|
163
|
-
const root = tmp('hm-git-flow-');
|
|
164
|
-
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
|
|
165
|
-
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'harness-mix', version: '0.1.1' }));
|
|
166
|
-
const dir = tmp('hm-flow-');
|
|
167
|
-
const script = {
|
|
168
|
-
'rev-parse HEAD': 'aaa111\n',
|
|
169
|
-
'rev-parse --abbrev-ref': 'origin/main\n',
|
|
170
|
-
fetch: '',
|
|
171
|
-
'rev-parse origin/main': 'bbb222\n',
|
|
172
|
-
'merge-base': 'aaa111\n',
|
|
173
|
-
status: '',
|
|
174
|
-
'diff --name-only': 'docs/x.md\n',
|
|
175
|
-
'merge --ff-only': '',
|
|
176
|
-
};
|
|
177
|
-
const calls = [];
|
|
178
|
-
const exec = (cmd, args) => {
|
|
179
|
-
const key = args.join(' ');
|
|
180
|
-
calls.push(key);
|
|
181
|
-
for (const [prefix, stdout] of Object.entries(script)) if (key.startsWith(prefix)) return { status: 0, stdout, stderr: '' };
|
|
182
|
-
return { status: 0, stdout: '', stderr: '' };
|
|
183
|
-
};
|
|
184
|
-
const outcome = await runUpdateFlow({
|
|
185
|
-
root, dataDir: dir, log: quiet, stopDesktop: async () => {}, mode: 'apply',
|
|
186
|
-
deps: { gitExec: exec, delay, env: {} },
|
|
187
|
-
});
|
|
188
|
-
assert.equal(outcome.updated, true);
|
|
189
|
-
assert.equal(outcome.restartRequired, true);
|
|
190
|
-
assert.ok(calls.some(call => call.startsWith('merge --ff-only')));
|
|
191
|
-
assert.equal(readState(dir).appliedVersion, 'bbb222');
|
|
192
|
-
assert.equal(readState(dir).prevVersion, 'aaa111');
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// --- git: interrupted apply is repaired from preUpdateHead ---
|
|
196
|
-
{
|
|
197
|
-
const root = tmp('hm-git-repair-');
|
|
198
|
-
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
|
|
199
|
-
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'harness-mix', version: '0.1.1' }));
|
|
200
|
-
const dir = tmp('hm-flow-');
|
|
201
|
-
writeState(dir, { ...readState(dir), channel: 'git', phase: 'applying', preUpdateHead: 'aaa111', appliedVersion: 'bbb222' });
|
|
202
|
-
const calls = [];
|
|
203
|
-
const exec = (cmd, args) => { calls.push(args.join(' ')); return { status: 0, stdout: '', stderr: '' }; };
|
|
204
|
-
const outcome = await runUpdateFlow({
|
|
205
|
-
root, dataDir: dir, log: quiet, stopDesktop: async () => {}, mode: 'apply',
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
assert.equal(outcome.
|
|
210
|
-
assert.
|
|
211
|
-
assert.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
1
|
+
// Update v2 coverage: state file / lock / channel detection / semver, plus the
|
|
2
|
+
// runUpdateFlow orchestration with mocked deps. Fully offline; no real npm/git.
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const {
|
|
8
|
+
acquireLock, compareVersions, detectChannel, markBootOk, readState, writeState,
|
|
9
|
+
} = require('../src/main/native/update-state');
|
|
10
|
+
const { fetchLatestVersion, runUpdateFlow } = require('../src/main/native/updater');
|
|
11
|
+
|
|
12
|
+
const tmp = prefix => fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
13
|
+
const quiet = () => {};
|
|
14
|
+
const delay = () => Promise.resolve();
|
|
15
|
+
const fetchVersion = version => async () => ({
|
|
16
|
+
ok: true, status: 200,
|
|
17
|
+
json: async () => ({ 'dist-tags': { latest: version } }),
|
|
18
|
+
headers: { get: () => null },
|
|
19
|
+
});
|
|
20
|
+
const npmFixture = (version = '0.1.0') => {
|
|
21
|
+
const root = path.join(tmp('hm-npm-'), 'node_modules', 'harness-mix');
|
|
22
|
+
fs.mkdirSync(root, { recursive: true });
|
|
23
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'harness-mix', version }));
|
|
24
|
+
return root;
|
|
25
|
+
};
|
|
26
|
+
const npmRun = (root, { install = { status: 0 } } = {}) => (cmd, args) => {
|
|
27
|
+
if (args[0] === 'root') return { status: 0, stdout: `${path.dirname(root)}\n` };
|
|
28
|
+
if (args[0] === 'config') return { status: 0, stdout: 'https://registry.test/\n' };
|
|
29
|
+
if (args[0] === 'install') return install;
|
|
30
|
+
return { status: 0, stdout: '' };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
(async () => {
|
|
34
|
+
// --- semver / channel detection ---
|
|
35
|
+
assert.equal(compareVersions('0.2.0', '0.1.1'), 1);
|
|
36
|
+
assert.equal(compareVersions('0.1.1', '0.1.1'), 0);
|
|
37
|
+
assert.equal(compareVersions('0.1.10', '0.1.9'), 1);
|
|
38
|
+
assert.equal(compareVersions('0.10.0', '0.9.9'), 1);
|
|
39
|
+
assert.equal(compareVersions('0.2.0-rc.1', '0.2.0'), -1);
|
|
40
|
+
assert.equal(compareVersions('0.2.0', '0.2.0-rc.1'), 1);
|
|
41
|
+
|
|
42
|
+
const gitRoot = tmp('hm-git-');
|
|
43
|
+
fs.mkdirSync(path.join(gitRoot, '.git'));
|
|
44
|
+
assert.equal(detectChannel(gitRoot), 'git');
|
|
45
|
+
assert.equal(detectChannel(npmFixture()), 'npm');
|
|
46
|
+
assert.equal(detectChannel(tmp('hm-plain-')), 'portable');
|
|
47
|
+
|
|
48
|
+
// --- state file tolerates corruption; lock lifecycle incl. stale reap ---
|
|
49
|
+
const data = tmp('hm-data-');
|
|
50
|
+
fs.writeFileSync(path.join(data, 'update-state.json'), '{not json');
|
|
51
|
+
assert.equal(readState(data).phase, 'idle');
|
|
52
|
+
const release = acquireLock(data, 'test');
|
|
53
|
+
assert.throws(() => acquireLock(data, 'test'), /更新锁/);
|
|
54
|
+
release();
|
|
55
|
+
acquireLock(data, 'test')();
|
|
56
|
+
fs.writeFileSync(path.join(data, 'update.lock'), JSON.stringify({ pid: process.pid, ts: Date.now() - 31 * 60 * 1000, op: 'stale' }));
|
|
57
|
+
acquireLock(data, 'test')(); // stale locks are reaped even when the pid still lives
|
|
58
|
+
writeState(data, { ...readState(data), appliedVersion: '0.2.0', appliedAt: Date.now() - 1000, attempts: 3 });
|
|
59
|
+
markBootOk(data);
|
|
60
|
+
assert.equal(readState(data).attempts, 0);
|
|
61
|
+
assert.ok(readState(data).lastBootOkAt > 0);
|
|
62
|
+
|
|
63
|
+
// --- fetchLatestVersion: etag cache answers 304 from disk ---
|
|
64
|
+
{
|
|
65
|
+
const dir = tmp('hm-etag-');
|
|
66
|
+
const first = await fetchLatestVersion({
|
|
67
|
+
registry: 'https://registry.test/', dataDir: dir,
|
|
68
|
+
fetchImpl: async () => ({ ok: true, status: 200, json: async () => ({ 'dist-tags': { latest: '9.9.9' } }), headers: { get: name => (name === 'etag' ? '"e1"' : null) } }),
|
|
69
|
+
});
|
|
70
|
+
assert.equal(first, '9.9.9');
|
|
71
|
+
const second = await fetchLatestVersion({
|
|
72
|
+
registry: 'https://registry.test/', dataDir: dir,
|
|
73
|
+
fetchImpl: async () => ({ ok: false, status: 304, headers: { get: () => null } }),
|
|
74
|
+
});
|
|
75
|
+
assert.equal(second, '9.9.9');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- npm: update available -> applied, restart required ---
|
|
79
|
+
{
|
|
80
|
+
const root = npmFixture();
|
|
81
|
+
const dir = tmp('hm-flow-');
|
|
82
|
+
const calls = [];
|
|
83
|
+
const run = (cmd, args) => { calls.push(args.join(' ')); return npmRun(root)(cmd, args); };
|
|
84
|
+
let stopped = 0;
|
|
85
|
+
const outcome = await runUpdateFlow({
|
|
86
|
+
root, dataDir: dir, log: quiet, stopDesktop: async () => { stopped += 1; }, mode: 'apply',
|
|
87
|
+
deps: { run, fetchImpl: fetchVersion('0.2.0'), delay, env: {} },
|
|
88
|
+
});
|
|
89
|
+
assert.equal(outcome.updated, true);
|
|
90
|
+
assert.equal(outcome.to, '0.2.0');
|
|
91
|
+
assert.equal(outcome.restartRequired, true);
|
|
92
|
+
assert.equal(stopped, 1);
|
|
93
|
+
assert.ok(calls.some(call => call.startsWith('install -g harness-mix@0.2.0')));
|
|
94
|
+
const state = readState(dir);
|
|
95
|
+
assert.equal(state.appliedVersion, '0.2.0');
|
|
96
|
+
assert.equal(state.prevVersion, '0.1.0');
|
|
97
|
+
assert.equal(state.phase, 'idle');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- npm: install failure -> pending for the next launch, never blocks ---
|
|
101
|
+
{
|
|
102
|
+
const root = npmFixture();
|
|
103
|
+
const dir = tmp('hm-flow-');
|
|
104
|
+
const outcome = await runUpdateFlow({
|
|
105
|
+
root, dataDir: dir, log: quiet, stopDesktop: async () => {}, mode: 'apply',
|
|
106
|
+
deps: { run: npmRun(root, { install: { status: 1, stderr: 'EBUSY: file locked' } }), fetchImpl: fetchVersion('0.2.0'), delay, env: {} },
|
|
107
|
+
});
|
|
108
|
+
assert.equal(outcome.updated, false);
|
|
109
|
+
assert.equal(outcome.pending, '0.2.0');
|
|
110
|
+
assert.equal(outcome.restartRequired, undefined);
|
|
111
|
+
assert.equal(readState(dir).pendingVersion, '0.2.0');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- npm: registry offline -> failed, launch continues ---
|
|
115
|
+
{
|
|
116
|
+
const root = npmFixture();
|
|
117
|
+
const dir = tmp('hm-flow-');
|
|
118
|
+
const outcome = await runUpdateFlow({
|
|
119
|
+
root, dataDir: dir, log: quiet, stopDesktop: async () => {}, mode: 'apply',
|
|
120
|
+
deps: { run: npmRun(root), fetchImpl: async () => { throw new Error('getaddrinfo ENOTFOUND'); }, delay, env: {} },
|
|
121
|
+
});
|
|
122
|
+
assert.equal(outcome.failed, true);
|
|
123
|
+
assert.equal(outcome.restartRequired, undefined);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- update lock held elsewhere -> skip without touching the desktop ---
|
|
127
|
+
{
|
|
128
|
+
const root = npmFixture();
|
|
129
|
+
const dir = tmp('hm-flow-');
|
|
130
|
+
fs.writeFileSync(path.join(dir, 'update.lock'), JSON.stringify({ pid: process.pid, ts: Date.now(), op: 'other' }));
|
|
131
|
+
let stopped = 0;
|
|
132
|
+
const outcome = await runUpdateFlow({
|
|
133
|
+
root, dataDir: dir, log: quiet, stopDesktop: async () => { stopped += 1; }, mode: 'apply',
|
|
134
|
+
deps: { run: npmRun(root), fetchImpl: fetchVersion('0.2.0'), delay, env: {} },
|
|
135
|
+
});
|
|
136
|
+
assert.equal(outcome.failed, true);
|
|
137
|
+
assert.equal(outcome.reason, 'lock');
|
|
138
|
+
assert.equal(stopped, 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- crash loop: second failed boot rolls back to prevVersion ---
|
|
142
|
+
{
|
|
143
|
+
const root = npmFixture('0.2.0'); // the installed tree currently runs 0.2.0
|
|
144
|
+
const dir = tmp('hm-flow-');
|
|
145
|
+
writeState(dir, { ...readState(dir), channel: 'npm', appliedVersion: '0.2.0', prevVersion: '0.1.0', appliedAt: Date.now() - 1000, lastBootOkAt: 0, attempts: 1 });
|
|
146
|
+
const calls = [];
|
|
147
|
+
const run = (cmd, args) => { calls.push(args.join(' ')); return npmRun(root)(cmd, args); };
|
|
148
|
+
const outcome = await runUpdateFlow({
|
|
149
|
+
root, dataDir: dir, log: quiet, stopDesktop: async () => {}, mode: 'apply',
|
|
150
|
+
deps: { run, fetchImpl: async () => { throw new Error('offline'); }, delay, env: {} },
|
|
151
|
+
});
|
|
152
|
+
assert.equal(outcome.rolledBack, true);
|
|
153
|
+
assert.equal(outcome.restartRequired, true);
|
|
154
|
+
assert.ok(calls.some(call => call.startsWith('install -g harness-mix@0.1.0')));
|
|
155
|
+
const state = readState(dir);
|
|
156
|
+
assert.equal(state.appliedVersion, null);
|
|
157
|
+
assert.equal(state.prevVersion, null);
|
|
158
|
+
assert.equal(state.attempts, 0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- git: fast-forward applies and asks for a restart ---
|
|
162
|
+
{
|
|
163
|
+
const root = tmp('hm-git-flow-');
|
|
164
|
+
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
|
|
165
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'harness-mix', version: '0.1.1' }));
|
|
166
|
+
const dir = tmp('hm-flow-');
|
|
167
|
+
const script = {
|
|
168
|
+
'rev-parse HEAD': 'aaa111\n',
|
|
169
|
+
'rev-parse --abbrev-ref': 'origin/main\n',
|
|
170
|
+
fetch: '',
|
|
171
|
+
'rev-parse origin/main': 'bbb222\n',
|
|
172
|
+
'merge-base': 'aaa111\n',
|
|
173
|
+
status: '',
|
|
174
|
+
'diff --name-only': 'docs/x.md\n',
|
|
175
|
+
'merge --ff-only': '',
|
|
176
|
+
};
|
|
177
|
+
const calls = [];
|
|
178
|
+
const exec = (cmd, args) => {
|
|
179
|
+
const key = args.join(' ');
|
|
180
|
+
calls.push(key);
|
|
181
|
+
for (const [prefix, stdout] of Object.entries(script)) if (key.startsWith(prefix)) return { status: 0, stdout, stderr: '' };
|
|
182
|
+
return { status: 0, stdout: '', stderr: '' };
|
|
183
|
+
};
|
|
184
|
+
const outcome = await runUpdateFlow({
|
|
185
|
+
root, dataDir: dir, log: quiet, stopDesktop: async () => {}, mode: 'apply',
|
|
186
|
+
deps: { gitExec: exec, delay, env: {} },
|
|
187
|
+
});
|
|
188
|
+
assert.equal(outcome.updated, true);
|
|
189
|
+
assert.equal(outcome.restartRequired, true);
|
|
190
|
+
assert.ok(calls.some(call => call.startsWith('merge --ff-only')));
|
|
191
|
+
assert.equal(readState(dir).appliedVersion, 'bbb222');
|
|
192
|
+
assert.equal(readState(dir).prevVersion, 'aaa111');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- git: interrupted apply is repaired from preUpdateHead + rebuilt ---
|
|
196
|
+
{
|
|
197
|
+
const root = tmp('hm-git-repair-');
|
|
198
|
+
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
|
|
199
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'harness-mix', version: '0.1.1' }));
|
|
200
|
+
const dir = tmp('hm-flow-');
|
|
201
|
+
writeState(dir, { ...readState(dir), channel: 'git', phase: 'applying', preUpdateHead: 'aaa111', appliedVersion: 'bbb222' });
|
|
202
|
+
const calls = [];
|
|
203
|
+
const exec = (cmd, args) => { calls.push(args.join(' ')); return { status: 0, stdout: '', stderr: '' }; };
|
|
204
|
+
const outcome = await runUpdateFlow({
|
|
205
|
+
root, dataDir: dir, log: quiet, stopDesktop: async () => {}, mode: 'apply',
|
|
206
|
+
hooks: { build: () => calls.push('build-hook') },
|
|
207
|
+
deps: { gitExec: exec, delay, env: {} },
|
|
208
|
+
});
|
|
209
|
+
assert.equal(outcome.repaired, true);
|
|
210
|
+
assert.equal(outcome.restartRequired, true);
|
|
211
|
+
assert.ok(calls.some(call => call.startsWith('reset --hard aaa111')));
|
|
212
|
+
assert.ok(calls.includes('build-hook'), 'repair must rebuild so binaries match the reset sources');
|
|
213
|
+
assert.equal(readState(dir).phase, 'idle');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- deferred build (desktop-held locks) completes at the next boot ---
|
|
217
|
+
{
|
|
218
|
+
const root = tmp('hm-git-defer-');
|
|
219
|
+
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
|
|
220
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'harness-mix', version: '0.1.2' }));
|
|
221
|
+
const dir = tmp('hm-flow-');
|
|
222
|
+
writeState(dir, { ...readState(dir), channel: 'git', appliedVersion: 'bbb222', prevVersion: 'aaa111', pendingBuild: { since: Date.now() - 1000 }, preUpdateHead: 'aaa111' });
|
|
223
|
+
const calls = [];
|
|
224
|
+
const exec = (cmd, args) => { calls.push(args.join(' ')); return { status: 0, stdout: '', stderr: '' }; };
|
|
225
|
+
let stopped = 0;
|
|
226
|
+
const outcome = await runUpdateFlow({
|
|
227
|
+
root, dataDir: dir, log: quiet, mode: 'apply', completePendingBuild: true,
|
|
228
|
+
stopDesktop: async () => { stopped += 1; },
|
|
229
|
+
hooks: { build: () => calls.push('build-hook') },
|
|
230
|
+
deps: { gitExec: exec, delay, env: {} },
|
|
231
|
+
});
|
|
232
|
+
assert.equal(outcome.repaired, true);
|
|
233
|
+
assert.equal(outcome.restartRequired, undefined);
|
|
234
|
+
assert.ok(calls.includes('build-hook'));
|
|
235
|
+
assert.equal(stopped, 1, 'boot completion stops the desktop before rebuilding');
|
|
236
|
+
assert.equal(readState(dir).pendingBuild, null);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- deferred build completion failure rolls back to the pre-update head ---
|
|
240
|
+
{
|
|
241
|
+
const root = tmp('hm-git-defer-fail-');
|
|
242
|
+
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
|
|
243
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'harness-mix', version: '0.1.2' }));
|
|
244
|
+
const dir = tmp('hm-flow-');
|
|
245
|
+
writeState(dir, { ...readState(dir), channel: 'git', appliedVersion: 'bbb222', prevVersion: 'aaa111', pendingBuild: { since: Date.now() - 1000 }, preUpdateHead: 'aaa111' });
|
|
246
|
+
const calls = [];
|
|
247
|
+
const exec = (cmd, args) => { calls.push(args.join(' ')); return { status: 0, stdout: '', stderr: '' }; };
|
|
248
|
+
const outcome = await runUpdateFlow({
|
|
249
|
+
root, dataDir: dir, log: quiet, mode: 'apply', completePendingBuild: true,
|
|
250
|
+
stopDesktop: async () => {},
|
|
251
|
+
hooks: { build: () => { throw new Error('cargo failed'); } },
|
|
252
|
+
deps: { gitExec: exec, delay, env: {} },
|
|
253
|
+
});
|
|
254
|
+
assert.equal(outcome.failed, true);
|
|
255
|
+
assert.ok(calls.some(call => call.startsWith('reset --hard aaa111')));
|
|
256
|
+
const state = readState(dir);
|
|
257
|
+
assert.equal(state.pendingBuild, null);
|
|
258
|
+
assert.equal(state.appliedVersion, null);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- a corrupt lock file is reaped, not treated as a live holder ---
|
|
262
|
+
{
|
|
263
|
+
const data = tmp('hm-lock-');
|
|
264
|
+
fs.writeFileSync(path.join(data, 'update.lock'), '{not json');
|
|
265
|
+
acquireLock(data, 'test')();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
console.log('native-update-apply: semver/channel/state/lock/etag/npm-apply/pending/offline/lock-skip/crash-loop-rollback/git-ff/git-repair+build/deferred-build/defer-fail/corrupt-lock all covered');
|
|
269
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Desktop-triggered update helper, spawned detached by the native host
|
|
2
|
+
// (harnessmix/update/start). Everything here runs outside the host process:
|
|
3
|
+
// - host stdout is the JSONL protocol channel; updater and child output must
|
|
4
|
+
// never mix into it, so this process writes <dataDir>/update.log instead;
|
|
5
|
+
// - the host sits inside the shim's kill-on-close job: stopping the Desktop
|
|
6
|
+
// from inside the host would kill the update together with the host, so the
|
|
7
|
+
// flow applies with stopDesktop disabled and stops the Desktop only as its
|
|
8
|
+
// last act, and only when work was parked for the next `npm start`;
|
|
9
|
+
// - native binaries locked by the running Desktop defer their rebuild via the
|
|
10
|
+
// pendingBuild state; the launcher completes it at the next boot.
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const { nativeEnvironment } = require('../src/main/native/config');
|
|
14
|
+
const { runUpdateFlow, defaultHooks } = require('../src/main/native/updater');
|
|
15
|
+
const { readState, updateState } = require('../src/main/native/update-state');
|
|
16
|
+
const { inspect, stopDesktopProcesses } = require('../src/main/native/launcher');
|
|
17
|
+
|
|
18
|
+
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
19
|
+
// build-native.cjs surfaces EBUSY as "... is locked by a running Codex Desktop";
|
|
20
|
+
// EPERM covers antivirus transient holds on the same files.
|
|
21
|
+
const LOCKED_BUILD = /locked by a running Codex Desktop|EBUSY|EPERM|being used by another process/i;
|
|
22
|
+
|
|
23
|
+
const dataDir = nativeEnvironment().HARNESSMIX_DATA_DIR;
|
|
24
|
+
const logFile = path.join(dataDir, 'update.log');
|
|
25
|
+
const log = message => {
|
|
26
|
+
try { fs.appendFileSync(logFile, `[${new Date().toISOString()}] ${message}\n`); } catch { /* best-effort */ }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
31
|
+
log('--- 桌面触发的更新开始 ---');
|
|
32
|
+
const base = defaultHooks(REPO_ROOT);
|
|
33
|
+
const hooks = {
|
|
34
|
+
install: () => base.install(),
|
|
35
|
+
build: () => {
|
|
36
|
+
try {
|
|
37
|
+
base.build();
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (!LOCKED_BUILD.test(String((error && error.message) || ''))) throw error;
|
|
40
|
+
updateState(dataDir, { pendingBuild: { since: Date.now() } });
|
|
41
|
+
log(`[Harness Mix] 原生组件被运行中的 Desktop 锁定,构建推迟到下次启动:${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
let outcome;
|
|
46
|
+
try {
|
|
47
|
+
outcome = await runUpdateFlow({
|
|
48
|
+
root: REPO_ROOT,
|
|
49
|
+
dataDir,
|
|
50
|
+
mode: 'apply',
|
|
51
|
+
log,
|
|
52
|
+
stopDesktop: async () => {},
|
|
53
|
+
hooks,
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
log(`[Harness Mix] 更新流程异常退出:${error.message}`);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
log(`[Harness Mix] 更新流程结束:${JSON.stringify(outcome)}`);
|
|
61
|
+
const state = readState(dataDir);
|
|
62
|
+
// Parked work (deferred build / npm pendingVersion) can only complete once
|
|
63
|
+
// the Desktop releases its file locks, i.e. at the next `npm start` boot.
|
|
64
|
+
if (state.pendingBuild || state.pendingVersion) {
|
|
65
|
+
log('[Harness Mix] 存在待下次启动完成的工作,正在关闭 Codex Desktop…');
|
|
66
|
+
try {
|
|
67
|
+
stopDesktopProcesses(await inspect());
|
|
68
|
+
log('[Harness Mix] 已请求关闭 Codex Desktop;请重新运行 npm start 完成更新。');
|
|
69
|
+
} catch (error) {
|
|
70
|
+
log(`[Harness Mix] 关闭 Desktop 失败(可手动关闭后运行 npm start):${error.message}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
main().catch(error => {
|
|
76
|
+
log(`[Harness Mix] 更新助手崩溃:${(error && error.stack) || error}`);
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
});
|