@harness-mix/cli 0.1.6 → 0.1.8
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 +86 -56
- package/docs/storage-verification-design.md +49 -0
- package/output/native-build/renderer-extension.js +2743 -2330
- package/package.json +3 -2
- package/scripts/codebuddy-migration-test.cjs +1 -1
- package/scripts/core-runtime-test.cjs +9 -1
- package/scripts/native-protocol-test.cjs +11 -1
- package/scripts/native-ui-smoke.cjs +23 -4
- package/scripts/projector-test.cjs +13 -0
- package/scripts/storage-verification-test.cjs +78 -0
- package/scripts/switch-harness-test.cjs +1 -1
- package/src/main/host/collaboration.js +2 -0
- package/src/main/host/core-session.js +7 -1
- package/src/main/host/handoff-checkpoints.js +8 -1
- package/src/main/host/runtime.js +150 -36
- package/src/main/host/store.js +32 -11
- package/src/main/host/thread-storage.js +73 -0
- package/src/main/host/thread-store.js +123 -0
- package/src/main/host/verification-gates.js +118 -0
- package/src/main/native/protocol.js +20 -5
- package/src/main/protocol-core/projector.js +20 -3
- package/src/main/shared-contracts/event.js +2 -1
- package/src/main/shared-contracts/item.js +1 -0
- package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts +2 -2
- package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts.map +1 -1
- 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-model-client.d.ts +5 -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-settings-lifecycle.d.ts +2 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +5 -3
- package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/storage-page.d.ts +20 -0
- package/src/native-ui/renderer-extension/dist/types/settings/storage-page.d.ts.map +1 -0
- package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/renderer-extension/src/harness-mix-settings.ts +8 -89
- package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +5 -1
- package/src/native-ui/renderer-extension/src/renderer-model-client.ts +4 -0
- package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +12 -0
- package/src/native-ui/renderer-extension/src/settings/icons.ts +6 -3
- package/src/native-ui/renderer-extension/src/settings/localization.ts +4 -2
- package/src/native-ui/renderer-extension/src/settings/pages.ts +12 -5
- package/src/native-ui/renderer-extension/src/settings/shell.css +8 -3
- package/src/native-ui/renderer-extension/src/settings/storage-page.ts +74 -0
- package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +2 -0
- package/src/native-ui/renderer-extension/test/settings/harness-settings.test.ts +5 -0
- package/src/native-ui/renderer-extension/test/settings/localization.test.ts +1 -1
- package/src/native-ui/renderer-extension/test/settings/pages.test.ts +50 -4
- package/src/native-ui/renderer-extension/test/settings/shell.test.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harness-mix/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Harness Mix native Codex UI bridge for Codex, Pi, Claude Code, DeepSeek Harness, Antigravity, CodeBuddy, Kiro CLI, Cursor CLI and other native coding harnesses.",
|
|
6
6
|
"keywords": [
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
"typecheck:native-ui": "tsc -b src/native-ui/shared-contracts/tsconfig.json src/native-ui/desktop-control/tsconfig.json src/native-ui/renderer-extension/tsconfig.json --pretty false",
|
|
84
84
|
"test:native-ui": "vitest run --config vitest.config.mjs",
|
|
85
85
|
"test:ci": "npm run test:docs && npm run check && npm run typecheck:native-ui && npm run test:native-ui && npm run test:core-all && npm run test:native-protocol && npm run test:native-compatibility && npm run test:platform",
|
|
86
|
+
"test:storage-verification": "node scripts/storage-verification-test.cjs",
|
|
86
87
|
"test:collaboration": "node scripts/collaboration-test.cjs",
|
|
87
88
|
"test:switch-harness": "node scripts/switch-harness-test.cjs",
|
|
88
89
|
"smoke:collaboration-ui": "electron scripts/collaboration-ui-smoke.cjs",
|
|
@@ -121,7 +122,7 @@
|
|
|
121
122
|
"test:core-concurrency": "node scripts/core-concurrency-test.cjs",
|
|
122
123
|
"test:thread-title": "node scripts/thread-title-test.cjs",
|
|
123
124
|
"test:handoff": "node scripts/handoff-checkpoint-test.cjs && node scripts/switch-harness-test.cjs",
|
|
124
|
-
"test:core-all": "node scripts/contracts-test.cjs && node scripts/projector-test.cjs && node scripts/turn-manager-test.cjs && node scripts/sequence-test.cjs && node scripts/shadow-test.cjs && node scripts/architecture-test.cjs && node scripts/adapters-test.cjs && node scripts/dsh-adapter-test.cjs && node scripts/codex-adapter-test.cjs && node scripts/codex-accounts-test.cjs && node scripts/antigravity-adapter-test.cjs && node scripts/openclaw-adapter-test.cjs && node scripts/core-replay-test.cjs && node scripts/determinism-test.cjs && node scripts/core-runtime-test.cjs && node scripts/core-services-test.cjs && node scripts/native-file-change-test.cjs && node scripts/native-updater-test.cjs && node scripts/native-update-apply-test.cjs && node scripts/native-vendor-adapters-test.cjs && node scripts/core-concurrency-test.cjs && node scripts/concurrency-layers-test.cjs && node scripts/tool-concurrency-regression-test.cjs && node scripts/handoff-checkpoint-test.cjs && node scripts/switch-harness-test.cjs && node scripts/native-acp-depth-test.cjs && node scripts/thread-title-test.cjs && node scripts/native-job-object-test.cjs && node scripts/integrations-test.cjs",
|
|
125
|
+
"test:core-all": "node scripts/contracts-test.cjs && node scripts/projector-test.cjs && node scripts/turn-manager-test.cjs && node scripts/sequence-test.cjs && node scripts/shadow-test.cjs && node scripts/architecture-test.cjs && node scripts/adapters-test.cjs && node scripts/dsh-adapter-test.cjs && node scripts/codex-adapter-test.cjs && node scripts/codex-accounts-test.cjs && node scripts/antigravity-adapter-test.cjs && node scripts/openclaw-adapter-test.cjs && node scripts/core-replay-test.cjs && node scripts/determinism-test.cjs && node scripts/core-runtime-test.cjs && node scripts/core-services-test.cjs && node scripts/native-file-change-test.cjs && node scripts/native-updater-test.cjs && node scripts/native-update-apply-test.cjs && node scripts/native-vendor-adapters-test.cjs && node scripts/core-concurrency-test.cjs && node scripts/concurrency-layers-test.cjs && node scripts/tool-concurrency-regression-test.cjs && node scripts/handoff-checkpoint-test.cjs && node scripts/switch-harness-test.cjs && node scripts/native-acp-depth-test.cjs && node scripts/thread-title-test.cjs && node scripts/native-job-object-test.cjs && node scripts/integrations-test.cjs && node scripts/storage-verification-test.cjs",
|
|
125
126
|
"test:codex-accounts": "node scripts/codex-accounts-test.cjs",
|
|
126
127
|
"smoke:codex-accounts-ui": "electron scripts/codex-accounts-ui-smoke.cjs",
|
|
127
128
|
"test:transcript": "node scripts/transcript-test.cjs",
|
|
@@ -26,7 +26,7 @@ registry.buildAdapters = savedBuilder;
|
|
|
26
26
|
assert.equal(restored.harnessChain[0].nativeSessionId, 'previous-native');
|
|
27
27
|
assert.equal(restored.pendingHandoff.fromHarnessId, 'codebuddy');
|
|
28
28
|
assert.equal(runtime.resolveHarnessId('Workbuddy'), 'codebuddy');
|
|
29
|
-
assert.equal(
|
|
29
|
+
assert.equal((await runtime.store.load())[0].harnessId, 'codebuddy');
|
|
30
30
|
const history = require('../src/main/adapters/codebuddy-history');
|
|
31
31
|
const savedRoot = process.env.CODEBUDDY_CONFIG_DIR;
|
|
32
32
|
process.env.CODEBUDDY_CONFIG_DIR = path.join(directory, 'native-config');
|
|
@@ -91,7 +91,7 @@ async function until(fn) {
|
|
|
91
91
|
assert.deepEqual(rt.shadowReport().errors, []);
|
|
92
92
|
await rt.close();
|
|
93
93
|
const saved = await rt.store.load();
|
|
94
|
-
assert.equal(saved[0].
|
|
94
|
+
assert.equal(saved[0].coreState.turns.find(turn => turn.id === first).status, 'completed');
|
|
95
95
|
const restored = new HostRuntime({ dataDirectory: directory });
|
|
96
96
|
restored.threads = await restored.store.load();
|
|
97
97
|
for (const record of restored.threads) restored.execution.threadCreated(record);
|
|
@@ -99,6 +99,14 @@ async function until(fn) {
|
|
|
99
99
|
assert.ok(restored.core.getItemsForTurn(first).some(i => i.type === 'file_change' && i.undone));
|
|
100
100
|
assert.equal(restored.shadowReport().enabled, false);
|
|
101
101
|
await restored.close();
|
|
102
|
+
const lazy = new HostRuntime({ dataDirectory: directory });
|
|
103
|
+
lazy.threads = await lazy.store.loadIndex();
|
|
104
|
+
assert.equal(lazy.threads[0]._storageStub, true, 'cold start reads only the thread index');
|
|
105
|
+
assert.equal(lazy.core.getThread(lazy.threads[0].id), null, 'unopened transcript is absent from Core memory');
|
|
106
|
+
const hydrated = lazy.getThread(lazy.threads[0].id);
|
|
107
|
+
assert.equal(hydrated._storageStub, undefined);
|
|
108
|
+
assert.equal(lazy.core.getTurn(first).status, 'completed', 'opening one task restores only its Core checkpoint');
|
|
109
|
+
await lazy.close();
|
|
102
110
|
console.log('core-runtime: independent turns, waiting/responses, failure, cancel races, persistence passed');
|
|
103
111
|
} finally { await rt.close(); }
|
|
104
112
|
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -118,6 +118,16 @@ async function main() {
|
|
|
118
118
|
assert.equal(started.thread.model, 'codexhost/pi-native');
|
|
119
119
|
const { includesThread } = require('../src/main/native/thread-list');
|
|
120
120
|
const storedThread = runtime.threads.find(t => t.id === threadId);
|
|
121
|
+
const hostCommands = await bridge.request('codexhost/thread/commands/inspect', { threadId });
|
|
122
|
+
assert.ok(hostCommands.commands.some(command => command.id === 'verify'));
|
|
123
|
+
assert.ok(hostCommands.commands.some(command => command.id === 'gate'));
|
|
124
|
+
const gate = await bridge.request('codexhost/thread/verification/configure', { threadId, policy: { mode: 'required', checks: { cleanWorkingTree: false } } });
|
|
125
|
+
assert.equal(gate.policy.mode, 'required');
|
|
126
|
+
assert.equal(gate.satisfied, false);
|
|
127
|
+
assert.equal((await bridge.request('codexhost/thread/verification/get', { threadId })).policy.mode, 'required');
|
|
128
|
+
const storage = await bridge.request('codexhost/storage/inspect');
|
|
129
|
+
assert.equal(storage.schemaVersion, 2);
|
|
130
|
+
assert.ok(storage.threadCount >= 1);
|
|
121
131
|
assert.equal(includesThread(storedThread, { sectionId: section.id }), false);
|
|
122
132
|
await bridge.request('thread/section/move', { threadId, sectionId: section.id });
|
|
123
133
|
assert.deepEqual((await bridge.request('thread/read', { threadId })).thread.section, section);
|
|
@@ -169,7 +179,7 @@ async function main() {
|
|
|
169
179
|
assert.equal(events.filter(e => e.method === 'turn/completed' && e.params.threadId === threadId).length, 1);
|
|
170
180
|
const history = await bridge.request('thread/read', { threadId });
|
|
171
181
|
assert.equal(history.thread.turns[0].status, 'completed');
|
|
172
|
-
const projectedChange = history.thread.turns[0].items.
|
|
182
|
+
const projectedChange = history.thread.turns[0].items.flatMap(i => i.type === 'fileChange' ? i.changes : []).find(change => change.path === 'a.txt');
|
|
173
183
|
assert.equal(projectedChange?.kind.type, 'add', 'fileChange item projects the added kind');
|
|
174
184
|
assert.ok(projectedChange?.diff.includes('diff --git a/a.txt b/a.txt') && projectedChange.diff.includes('@@ -0,0 +1,1 @@'), 'fileChange item carries a full unified diff');
|
|
175
185
|
await bridge.request('thread/name/set', { threadId, name: 'Local Core' });
|
|
@@ -12,13 +12,20 @@ app.whenReady().then(async () => {
|
|
|
12
12
|
const icons = getAllIconsDictionary();
|
|
13
13
|
icons.harnesses['claude-code'] = icons.harnesses.claude;
|
|
14
14
|
icons.harnesses['deepseek-harness'] = icons.harnesses.dsh;
|
|
15
|
-
const bundle = await esbuild.build({ stdin: { contents: `export { createRendererAgentIcon } from './src/native-ui/renderer-extension/src/renderer-agent-icon.ts'; export { mountRendererAgentPicker, renderRendererAgentPicker } from './src/native-ui/renderer-extension/src/renderer-agent-picker.ts'; export { mountRendererModelPicker, renderRendererModelPicker } from './src/native-ui/renderer-extension/src/renderer-model-picker.ts'; export { mountRendererHarnessHandoff } from './src/native-ui/renderer-extension/src/renderer-harness-handoff.ts'; export { restoredThreadOwnership } from './src/native-ui/renderer-extension/src/renderer-binding-probe.ts'; export { installRendererSidebarAgentIcons } from './src/native-ui/renderer-extension/src/renderer-sidebar-agent-icons.ts'; export { createAccountsSettingsPage } from './src/native-ui/renderer-extension/src/settings/accounts-page.ts'; export { aboutPage } from './src/native-ui/renderer-extension/src/settings/pages.ts'; export { rendererSettingsMessages } from './src/native-ui/renderer-extension/src/settings/localization.ts'; export { installRendererSettingsShell } from './src/native-ui/renderer-extension/src/settings/shell.ts';`, resolveDir: process.cwd() }, bundle: true, alias: { '@codexhost/shared-contracts': path.resolve('src/native-ui/shared-contracts/src/index.ts') }, platform: 'browser', format: 'iife', globalName: 'NativeUI', write: false, loader: { '.svg': 'dataurl', '.png': 'dataurl', '.css': 'text' }, logLevel: 'silent' });
|
|
15
|
+
const bundle = await esbuild.build({ stdin: { contents: `export { createRendererAgentIcon } from './src/native-ui/renderer-extension/src/renderer-agent-icon.ts'; export { mountRendererAgentPicker, renderRendererAgentPicker } from './src/native-ui/renderer-extension/src/renderer-agent-picker.ts'; export { mountRendererModelPicker, renderRendererModelPicker } from './src/native-ui/renderer-extension/src/renderer-model-picker.ts'; export { mountRendererHarnessHandoff } from './src/native-ui/renderer-extension/src/renderer-harness-handoff.ts'; export { restoredThreadOwnership } from './src/native-ui/renderer-extension/src/renderer-binding-probe.ts'; export { installRendererSidebarAgentIcons } from './src/native-ui/renderer-extension/src/renderer-sidebar-agent-icons.ts'; export { installRendererSettingsLifecycle } from './src/native-ui/renderer-extension/src/harness-mix-settings.ts'; export { createAccountsSettingsPage } from './src/native-ui/renderer-extension/src/settings/accounts-page.ts'; export { createStorageSettingsPage } from './src/native-ui/renderer-extension/src/settings/storage-page.ts'; export { aboutPage } from './src/native-ui/renderer-extension/src/settings/pages.ts'; export { rendererSettingsMessages } from './src/native-ui/renderer-extension/src/settings/localization.ts'; export { installRendererSettingsShell } from './src/native-ui/renderer-extension/src/settings/shell.ts';`, resolveDir: process.cwd() }, bundle: true, alias: { '@codexhost/shared-contracts': path.resolve('src/native-ui/shared-contracts/src/index.ts') }, platform: 'browser', format: 'iife', globalName: 'NativeUI', write: false, loader: { '.svg': 'dataurl', '.png': 'dataurl', '.css': 'text' }, logLevel: 'silent' });
|
|
16
16
|
const win = new BrowserWindow({ show: false, width: 900, height: 650, webPreferences: { contextIsolation: true, sandbox: true, offscreen: true, backgroundThrottling: false } });
|
|
17
17
|
await win.loadURL('data:text/html,<html><head><style>body{background:%2317191d;color:white;font:16px Arial;padding:40px}button{background:%23272a30;color:white;border:0;padding:12px}img{vertical-align:middle}main{display:flex;gap:25px;margin-bottom:40px}</style></head><body><h2>Harness Mix native components</h2><main></main></body></html>');
|
|
18
18
|
await win.webContents.executeJavaScript(`globalThis.__HARNESS_MIX_ICONS__=${JSON.stringify(icons)};globalThis.__HARNESS_MIX_MODEL_FAMILIES__=${JSON.stringify(MODEL_FAMILIES.map(f => ({ id: f.id, pattern: f.regex.source })))};${bundle.outputFiles[0].text}`);
|
|
19
|
+
const liveSettingsPages = await win.webContents.executeJavaScript(`(() => {
|
|
20
|
+
const lifecycle = NativeUI.installRendererSettingsLifecycle(window, {});
|
|
21
|
+
const pageIds = globalThis.__codexhostSettingsShellV1.registry.pages.map(page => page.id);
|
|
22
|
+
lifecycle.dispose();
|
|
23
|
+
return pageIds;
|
|
24
|
+
})()`);
|
|
25
|
+
assert.deepEqual(liveSettingsPages, ['connections', 'accounts', 'mcp', 'skills', 'session-import', 'storage', 'updates', 'about']);
|
|
19
26
|
const { NativeProtocol } = require('../src/main/native/protocol');
|
|
20
27
|
const nativeThreads = ['pi', 'claude', 'dsh', 'antigravity'].map((harnessId, index) => ({ id: 'sidebar-' + index, harnessId, model: { id: 'model-' + index, provider: 'native' }, options: {} }));
|
|
21
|
-
const protocol = new NativeProtocol({ threads: nativeThreads, subscribe: () => () => {}, core: { subscribe: () => () => {} }, describe: async () => ({}), getCapabilities: () => ({}) }, () => {});
|
|
28
|
+
const protocol = new NativeProtocol({ threads: nativeThreads, getThread: id => nativeThreads.find(thread => thread.id === id), subscribe: () => () => {}, core: { subscribe: () => () => {} }, describe: async () => ({}), getCapabilities: () => ({}) }, () => {});
|
|
22
29
|
const inspections = await Promise.all(nativeThreads.map(thread => protocol.request('codexhost/thread/inspect', { threadId: thread.id })));
|
|
23
30
|
await win.webContents.executeJavaScript(`globalThis.testInspections=${JSON.stringify(inspections)}`);
|
|
24
31
|
const result = await win.webContents.executeJavaScript(`(async () => {
|
|
@@ -126,6 +133,10 @@ app.whenReady().then(async () => {
|
|
|
126
133
|
};
|
|
127
134
|
const shell = NativeUI.installRendererSettingsShell([
|
|
128
135
|
NativeUI.createAccountsSettingsPage(messages, () => client),
|
|
136
|
+
NativeUI.createStorageSettingsPage(messages, () => ({
|
|
137
|
+
inspectStorage:async () => ({storageSchemaVersion:3,threadCount:12,loadedThreadCount:2,indexBytes:2048,recordBytes:5242880,recordCount:12,legacyBytes:0}),
|
|
138
|
+
optimizeStorage:async () => ({before:{storageSchemaVersion:3,threadCount:12,loadedThreadCount:2,indexBytes:2048,recordBytes:5242880,recordCount:12,legacyBytes:0},after:{storageSchemaVersion:3,threadCount:12,loadedThreadCount:2,indexBytes:2048,recordBytes:4194304,recordCount:12,legacyBytes:0}}),
|
|
139
|
+
})),
|
|
129
140
|
NativeUI.aboutPage(messages, () => ({ readCurrentVersion:async () => ({version:'0.1.4'}), checkUpdate:async () => { throw new Error('not expected'); } })),
|
|
130
141
|
], messages, document);
|
|
131
142
|
shell.openSettings(undefined, 'accounts');
|
|
@@ -135,8 +146,16 @@ app.whenReady().then(async () => {
|
|
|
135
146
|
})()`);
|
|
136
147
|
assert.match(settingsResult.text, /native.*example\.com/s);
|
|
137
148
|
assert.match(settingsResult.text, /Plus/);
|
|
138
|
-
assert.equal(settingsResult.addHidden,
|
|
149
|
+
assert.equal(settingsResult.addHidden, false, 'multi-account Add Account action remains visible');
|
|
139
150
|
fs.writeFileSync(path.join(out, 'settings-account.png'), (await win.webContents.capturePage()).toPNG());
|
|
151
|
+
const storageText = await win.webContents.executeJavaScript(`(async () => {
|
|
152
|
+
globalThis.__codexhostSettingsShellV1.openSettings(undefined, 'storage');
|
|
153
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
154
|
+
return globalThis.__codexhostSettingsShellV1.root.shadowRoot.textContent;
|
|
155
|
+
})()`);
|
|
156
|
+
assert.match(storageText, /Schema v3/);
|
|
157
|
+
assert.match(storageText, /12 个任务/);
|
|
158
|
+
fs.writeFileSync(path.join(out, 'settings-storage.png'), (await win.webContents.capturePage()).toPNG());
|
|
140
159
|
const aboutText = await win.webContents.executeJavaScript(`(async () => {
|
|
141
160
|
globalThis.__codexhostSettingsShellV1.openSettings(undefined, 'about');
|
|
142
161
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
@@ -145,6 +164,6 @@ app.whenReady().then(async () => {
|
|
|
145
164
|
assert.match(aboutText, /v0\.1\.4/);
|
|
146
165
|
fs.writeFileSync(path.join(out, 'settings-about.png'), (await win.webContents.capturePage()).toPNG());
|
|
147
166
|
win.destroy();
|
|
148
|
-
console.log('PASS: real Electron native picker, graphical Harness handoff, official Account/Usage, live About version and all project Harness/model SVGs load');
|
|
167
|
+
console.log('PASS: real Electron native picker, graphical Harness handoff, official Account/Usage, sharded Storage metrics, live About version and all project Harness/model SVGs load');
|
|
149
168
|
app.quit();
|
|
150
169
|
}).catch(error => { console.error(error); app.exit(1); });
|
|
@@ -129,6 +129,19 @@ test('usage.updated:upsert usage item + thread.usage 合并', () => {
|
|
|
129
129
|
assert.deepStrictEqual(thread.usage, { inputTokens: 10, outputTokens: 5 });
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
test('verification.updated:终态 Turn 可追加并更新可审计验证报告', () => {
|
|
133
|
+
const { core } = coreWithThread();
|
|
134
|
+
const turn = core.createTurn({ threadId: 'thread_1' });
|
|
135
|
+
core.dispatch({ threadId: 'thread_1', turnId: turn.id, type: 'turn.started' });
|
|
136
|
+
core.dispatch({ threadId: 'thread_1', turnId: turn.id, type: 'turn.completed' });
|
|
137
|
+
core.dispatch({ threadId: 'thread_1', turnId: turn.id, type: 'verification.updated', payload: { report: { reportId: 'r1', status: 'failed' } } });
|
|
138
|
+
const item = core.getItemsForTurn(turn.id).find(candidate => candidate.type === 'verification_report');
|
|
139
|
+
assert.strictEqual(item.report.status, 'failed');
|
|
140
|
+
core.dispatch({ threadId: 'thread_1', turnId: turn.id, type: 'verification.updated', payload: { report: { reportId: 'r2', status: 'passed' } } });
|
|
141
|
+
assert.strictEqual(core.getItemsForTurn(turn.id).filter(candidate => candidate.type === 'verification_report').length, 1);
|
|
142
|
+
assert.strictEqual(item.report.status, 'passed');
|
|
143
|
+
});
|
|
144
|
+
|
|
132
145
|
test('snapshot / reset', () => {
|
|
133
146
|
const { core } = coreWithThread();
|
|
134
147
|
const turn = core.createTurn({ threadId: 'thread_1' });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { promises: fs } = require('node:fs');
|
|
5
|
+
const { Store } = require('../src/main/host/store');
|
|
6
|
+
const { compactThread, hydrateThread, storageProjection } = require('../src/main/host/thread-storage');
|
|
7
|
+
const { VerificationGates, normalizePolicy } = require('../src/main/host/verification-gates');
|
|
8
|
+
const { ThreadStore } = require('../src/main/host/thread-store');
|
|
9
|
+
|
|
10
|
+
(async () => {
|
|
11
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'harness-mix-storage-'));
|
|
12
|
+
try {
|
|
13
|
+
const turn = { id: 'turn-1', threadId: 'thread-1', status: 'completed', itemIds: ['answer-1'], createdAt: 1, updatedAt: 2, startedAt: 1, completedAt: 2, lastSequence: 2, nativeTurnRef: {} };
|
|
14
|
+
const item = { id: 'answer-1', threadId: 'thread-1', turnId: 'turn-1', type: 'agent_message', status: 'completed', content: 'large answer '.repeat(1000), createdAt: 1, updatedAt: 2, nativeRef: {} };
|
|
15
|
+
const coreThread = { id: 'thread-1', workspaceId: root, harnessId: 'fixture', status: 'idle', activeTurnId: null, nativeSessionRef: {}, metadata: {}, usage: {}, createdAt: 1, updatedAt: 2 };
|
|
16
|
+
const thread = { id: 'thread-1', title: 'fixture', cwd: root, harnessId: 'fixture', nativeSessionId: 'native-1', status: 'ready', connectionStatus: 'ready', createdAt: 1,
|
|
17
|
+
messages: [{ id: 'user-1', role: 'user', text: 'hello', at: 1 }, { id: 'turn-1', role: 'assistant', coreTurnId: 'turn-1', coreTurn: turn, coreItems: [item], text: item.content, thinking: '', at: 1, endedAt: 2, stopReason: 'completed' }],
|
|
18
|
+
tools: [], pendingApprovals: [], coreThread, currentTurn: turn, coreState: { version: 1, thread: coreThread, turns: [turn], items: [item] } };
|
|
19
|
+
await fs.writeFile(path.join(root, 'threads.json'), JSON.stringify([thread]));
|
|
20
|
+
const store = new Store(root, 'threads.json', { schemaVersion: 2, serialize: compactThread, deserialize: hydrateThread, backup: true });
|
|
21
|
+
const loaded = await store.load();
|
|
22
|
+
assert.equal(loaded[0].messages[1].text, item.content, 'legacy array remains readable');
|
|
23
|
+
await store.save(loaded);
|
|
24
|
+
const persisted = JSON.parse(await fs.readFile(path.join(root, 'threads.json'), 'utf8'));
|
|
25
|
+
assert.equal(persisted.schemaVersion, 2);
|
|
26
|
+
assert.equal(persisted.threads[0].messages[1].text, undefined, 'derived assistant text is not duplicated');
|
|
27
|
+
assert.equal(persisted.threads[0].coreState.items[0].content, item.content, 'authoritative Core content is preserved');
|
|
28
|
+
assert.ok((await fs.stat(path.join(root, 'threads.json.bak'))).size > 0, 'legacy store is backed up during migration');
|
|
29
|
+
const projection = storageProjection(loaded);
|
|
30
|
+
assert.ok(projection.savedBytes > 0);
|
|
31
|
+
const shardedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'harness-mix-sharded-'));
|
|
32
|
+
await fs.writeFile(path.join(shardedRoot, 'threads.json'), JSON.stringify({ schemaVersion: 2, threads: [compactThread(thread)] }));
|
|
33
|
+
const sharded = new ThreadStore(shardedRoot);
|
|
34
|
+
const migration = await sharded.loadIndex();
|
|
35
|
+
assert.equal(migration[0]._storageStub, undefined, 'legacy records load fully for one-time migration');
|
|
36
|
+
migration[0].status = 'working';
|
|
37
|
+
migration[0].connectionStatus = 'busy';
|
|
38
|
+
await sharded.save(migration);
|
|
39
|
+
const stubs = await sharded.loadIndex();
|
|
40
|
+
assert.equal(stubs[0]._storageStub, true);
|
|
41
|
+
assert.equal(stubs[0].status, 'interrupted', 'cold startup never presents a stale running task');
|
|
42
|
+
assert.equal(stubs[0].connectionStatus, 'ready');
|
|
43
|
+
assert.equal(stubs[0].messages.length, 0, 'cold index does not load transcript bodies');
|
|
44
|
+
sharded.hydrateInto(stubs[0]);
|
|
45
|
+
assert.equal(stubs[0].coreState.items[0].content, item.content);
|
|
46
|
+
assert.equal((await sharded.inspectFiles()).recordCount, 1);
|
|
47
|
+
assert.ok((await fs.stat(path.join(shardedRoot, 'threads.json.bak'))).size > 0, 'monolithic source remains recoverable');
|
|
48
|
+
await fs.rm(shardedRoot, { recursive: true, force: true });
|
|
49
|
+
const unsupportedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'harness-mix-storage-schema-'));
|
|
50
|
+
await fs.writeFile(path.join(unsupportedRoot, 'threads.json'), JSON.stringify({ schemaVersion: 99, threads: [] }));
|
|
51
|
+
await assert.rejects(new Store(unsupportedRoot, 'threads.json', { schemaVersion: 2 }).load(), /Unsupported thread store schema/);
|
|
52
|
+
await fs.rm(unsupportedRoot, { recursive: true, force: true });
|
|
53
|
+
|
|
54
|
+
const fakeRuntime = {
|
|
55
|
+
core: { getTurn: () => turn, getItemsForTurn: () => [item], dispatch: () => {} },
|
|
56
|
+
execution: { lastTurn: () => turn, sync: () => {} },
|
|
57
|
+
};
|
|
58
|
+
const gates = new VerificationGates(fakeRuntime);
|
|
59
|
+
gates.configure(thread, { mode: 'required', checks: { cleanWorkingTree: false }, commands: [{ id: 'node', command: `"${process.execPath}" -e "process.exit(0)"`, timeoutMs: 10_000 }] });
|
|
60
|
+
assert.throws(() => gates.assertSatisfied(thread, '交付'), /未通过/);
|
|
61
|
+
const passed = await gates.run(thread);
|
|
62
|
+
assert.equal(passed.status, 'passed');
|
|
63
|
+
assert.match(passed.contentDigest, /^[a-f0-9]{64}$/);
|
|
64
|
+
assert.equal(gates.inspect(thread).satisfied, true);
|
|
65
|
+
const stale = gates.invalidate(thread, 'test state changed');
|
|
66
|
+
assert.equal(stale.status, 'stale');
|
|
67
|
+
assert.equal(gates.inspect(thread).satisfied, false, 'required reports become stale after task state changes');
|
|
68
|
+
gates.configure(thread, { mode: 'advisory', commands: [`"${process.execPath}" -e "process.exit(3)"`] });
|
|
69
|
+
const failed = await gates.run(thread);
|
|
70
|
+
assert.equal(failed.status, 'failed');
|
|
71
|
+
assert.equal(gates.inspect(thread).satisfied, true, 'advisory failures do not block delivery');
|
|
72
|
+
assert.throws(() => normalizePolicy({ commands: [{ command: '' }] }), /Invalid verification command/);
|
|
73
|
+
assert.throws(() => normalizePolicy({ commands: [{ command: 'tool --token sk-1234567890abcdef' }] }), /credential-like data/);
|
|
74
|
+
console.log('PASS: schema v2 compact migration, backup, storage metrics and configurable verification gates');
|
|
75
|
+
} finally {
|
|
76
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -154,7 +154,7 @@ async function main() {
|
|
|
154
154
|
|
|
155
155
|
// 8) 持久化:threads.json 里切换链完整(含原生会话引用,供重启后惰性恢复)
|
|
156
156
|
await rt.store.save(rt.threads);
|
|
157
|
-
const persisted =
|
|
157
|
+
const persisted = (await rt.store.load()).find(t => t.id === thread.id);
|
|
158
158
|
assert.equal(persisted.harnessId, 'a');
|
|
159
159
|
assert.deepEqual(persisted.harnessChain.map(e => e.harnessId), ['a', 'b']);
|
|
160
160
|
assert.equal(persisted.harnessChain[0].nativeSessionId, aNativeId);
|
|
@@ -65,6 +65,7 @@ class Collaboration {
|
|
|
65
65
|
if (job.appliedDigest) throw new Error('此任务已应用;后续修改请创建新任务');
|
|
66
66
|
const parent = this.runtime.threads.find(t => t.id === job.owner);
|
|
67
67
|
if (!parent || this.runtime.threads.some(t => (this.runtime.execution.isRunning(t.id) || t.reviewPending) && [parent.cwd, job.workspace?.cwd].includes(t.cwd))) throw new Error('请等待主任务和工作区任务结算后再应用');
|
|
68
|
+
if (job.childId) this.runtime.verificationGates.assertSatisfied(this.runtime.getThread(job.childId), '应用子任务改动');
|
|
68
69
|
job.applying = true;
|
|
69
70
|
try {
|
|
70
71
|
const result = await applyWorkspace(job.workspace, digest);
|
|
@@ -89,6 +90,7 @@ class Collaboration {
|
|
|
89
90
|
const job = this.jobs.get(id);
|
|
90
91
|
if (!job) throw new Error('子任务不存在');
|
|
91
92
|
if (job.status === 'running') throw new Error('请等待子任务完成后再推送分支');
|
|
93
|
+
if (job.childId) this.runtime.verificationGates.assertSatisfied(this.runtime.getThread(job.childId), '推送子任务分支');
|
|
92
94
|
return pushWorkspace(job.workspace, remote, branch);
|
|
93
95
|
}
|
|
94
96
|
|
|
@@ -17,6 +17,9 @@ class CoreSession {
|
|
|
17
17
|
const turns = this.core.turns.turnsForThread(thread.id);
|
|
18
18
|
if (turns.length) this.lastTurns.set(thread.id, turns.at(-1).id);
|
|
19
19
|
this.sync(thread);
|
|
20
|
+
// The authoritative state now lives in ProtocolCore. Retaining the loaded
|
|
21
|
+
// checkpoint would keep a second complete copy of every item in memory.
|
|
22
|
+
delete thread.coreState;
|
|
20
23
|
}
|
|
21
24
|
lastTurn(id) { return this.core.getTurn(this.lastTurns.get(id)); }
|
|
22
25
|
isRunning(id) { return Boolean(active(this.lastTurn(id))); }
|
|
@@ -73,7 +76,10 @@ class CoreSession {
|
|
|
73
76
|
delete message.items;
|
|
74
77
|
thread.tools.push(...items.filter(i => i.type === 'tool_call').map(i => ({ ...i, id: i.nativeRef?.toolCallId ?? i.id, messageId: message.id, at: i.createdAt, endedAt: i.updatedAt })));
|
|
75
78
|
}
|
|
76
|
-
|
|
79
|
+
}
|
|
80
|
+
checkpoint(thread) {
|
|
81
|
+
const coreThread = this.core.getThread(thread.id);
|
|
82
|
+
return coreThread ? { version: 1, thread: structuredClone(coreThread), turns: structuredClone(this.core.turns.turnsForThread(thread.id)), items: structuredClone([...this.core.projector.items.values()].filter(i => i.threadId === thread.id)) } : null;
|
|
77
83
|
}
|
|
78
84
|
snapshot() { return this.core.snapshot(); }
|
|
79
85
|
}
|
|
@@ -42,6 +42,7 @@ function conversationSnapshot(thread) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function evidenceKind(item) {
|
|
45
|
+
if (item.type === 'verification_report') return 'verification';
|
|
45
46
|
const title = String(item.title || '').toLowerCase();
|
|
46
47
|
if (/test|vitest|jest|pytest|测试/.test(title)) return 'test';
|
|
47
48
|
if (/build|compile|tsc|构建|编译/.test(title)) return 'build';
|
|
@@ -51,7 +52,13 @@ function evidenceKind(item) {
|
|
|
51
52
|
|
|
52
53
|
function evidenceSnapshot(thread) {
|
|
53
54
|
const items = (thread.messages || []).flatMap(message => message.coreItems || []);
|
|
54
|
-
return items.filter(item => item.type === 'tool_call' && item.state !== 'running').slice(-LIMITS.evidence).map(item => {
|
|
55
|
+
return items.filter(item => (item.type === 'tool_call' && item.state !== 'running') || item.type === 'verification_report').slice(-LIMITS.evidence).map(item => {
|
|
56
|
+
if (item.type === 'verification_report') {
|
|
57
|
+
const report = redact(item.report ?? {});
|
|
58
|
+
const content = { kind: 'verification', title: 'Verification gate', state: report.status ?? 'unknown', inputExcerpt: report.mode ?? '', outputExcerpt: JSON.stringify(report).slice(0, LIMITS.excerpt), createdAt: item.updatedAt };
|
|
59
|
+
const contentDigest = digest(content);
|
|
60
|
+
return { evidenceId: `evidence_${contentDigest.slice(0, 24)}`, ...content, contentDigest };
|
|
61
|
+
}
|
|
55
62
|
const content = {
|
|
56
63
|
kind: evidenceKind(item),
|
|
57
64
|
sourceHarnessId: thread.harnessId,
|