@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/src/main/host/runtime.js
CHANGED
|
@@ -3,7 +3,7 @@ const { promises: fs } = require("node:fs");
|
|
|
3
3
|
const path = require("node:path");
|
|
4
4
|
const { CapabilityManager } = require('../protocol-core/capability-manager');
|
|
5
5
|
const { normalizeCapabilities } = require('../harness-adapter/manifest');
|
|
6
|
-
const {
|
|
6
|
+
const { ThreadStore } = require('./thread-store');
|
|
7
7
|
const { buildAdapters } = require("../adapters");
|
|
8
8
|
const { ReviewController } = require('../workspace/review-controller');
|
|
9
9
|
const { ReviewStore } = require('../workspace/review');
|
|
@@ -13,7 +13,9 @@ const { SessionHistory } = require('./session-history');
|
|
|
13
13
|
const { Integrations } = require('./integrations');
|
|
14
14
|
const { buildHandoffContext, composeHandoffEnvelope } = require('./handoff');
|
|
15
15
|
const { HandoffCheckpoints } = require('./handoff-checkpoints');
|
|
16
|
-
const { HandoffAccess } = require('./handoff-access');
|
|
16
|
+
const { HandoffAccess } = require('./handoff-access');
|
|
17
|
+
const { VerificationGates } = require('./verification-gates');
|
|
18
|
+
const { storageProjection } = require('./thread-storage');
|
|
17
19
|
const { createWorkspace, reviewWorkspace, applyWorkspace, removeWorkspace, discardWorkspace, pushWorkspace } = require('./collaboration-worktree');
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -24,7 +26,7 @@ const { createWorkspace, reviewWorkspace, applyWorkspace, removeWorkspace, disca
|
|
|
24
26
|
*/
|
|
25
27
|
class HostRuntime {
|
|
26
28
|
constructor({ dataDirectory, observer = null }) {
|
|
27
|
-
this.store = new
|
|
29
|
+
this.store = new ThreadStore(dataDirectory);
|
|
28
30
|
this.threads = [];
|
|
29
31
|
this.sessions = new Map(); // threadId -> { adapter, ...session }
|
|
30
32
|
this.listeners = new Set();
|
|
@@ -44,7 +46,8 @@ class HostRuntime {
|
|
|
44
46
|
this.history = new SessionHistory(this);
|
|
45
47
|
this.integrations = new Integrations(this);
|
|
46
48
|
this.handoffs = new HandoffCheckpoints(this);
|
|
47
|
-
this.handoffAccess = new HandoffAccess(this);
|
|
49
|
+
this.handoffAccess = new HandoffAccess(this);
|
|
50
|
+
this.verificationGates = new VerificationGates(this);
|
|
48
51
|
this.reviewController = new ReviewController(this, { save: () => this.#save(), broadcast: () => this.#broadcast() });
|
|
49
52
|
this.execution = new CoreSession();
|
|
50
53
|
this.core = this.execution.core;
|
|
@@ -52,7 +55,7 @@ class HostRuntime {
|
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
async initialize() {
|
|
55
|
-
this.threads = await this.store.
|
|
58
|
+
this.threads = await this.store.loadIndex();
|
|
56
59
|
await this.collaboration.initialize();
|
|
57
60
|
await this.handoffs.initialize();
|
|
58
61
|
const emit = (event) => this.#applyEvent(event);
|
|
@@ -64,7 +67,15 @@ class HostRuntime {
|
|
|
64
67
|
// Resolve renamed adapters without touching native identities or transcripts.
|
|
65
68
|
thread.harnessId = this.resolveHarnessId(thread.harnessId) || thread.harnessId;
|
|
66
69
|
for (const entry of thread.harnessChain || []) entry.harnessId = this.resolveHarnessId(entry.harnessId) || entry.harnessId;
|
|
67
|
-
if (thread.pendingHandoff) thread.pendingHandoff.fromHarnessId = this.resolveHarnessId(thread.pendingHandoff.fromHarnessId) || thread.pendingHandoff.fromHarnessId;
|
|
70
|
+
if (thread.pendingHandoff) thread.pendingHandoff.fromHarnessId = this.resolveHarnessId(thread.pendingHandoff.fromHarnessId) || thread.pendingHandoff.fromHarnessId;
|
|
71
|
+
if (thread._storageStub) {
|
|
72
|
+
if (isDefaultTitle(thread.title) && thread.preview) {
|
|
73
|
+
const derived = deriveThreadTitle(thread.preview, [], { isWorktree: thread.workspace?.mode === 'worktree' });
|
|
74
|
+
if (derived && !isDefaultTitle(derived)) thread.title = derived;
|
|
75
|
+
}
|
|
76
|
+
if (thread.status === 'interrupted') thread.error = '宿主异常退出,任务已中断;打开任务后即可继续。';
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
68
79
|
thread.connectionStatus = 'ready';
|
|
69
80
|
if (thread.nativeSessionId && thread.messages?.length) thread.restore = true;
|
|
70
81
|
if (thread.status === "working") {
|
|
@@ -127,9 +138,9 @@ class HostRuntime {
|
|
|
127
138
|
return null;
|
|
128
139
|
}
|
|
129
140
|
|
|
130
|
-
snapshot() {
|
|
141
|
+
snapshot() {
|
|
131
142
|
return {
|
|
132
|
-
threads: this.threads.map(({ coreState, ...thread }) => ({ ...thread, capabilities: this.getCapabilities(thread.harnessId), coreEnabled: true })),
|
|
143
|
+
threads: this.threads.map(({ coreState, _storageStub, ...thread }) => ({ ...thread, capabilities: this.getCapabilities(thread.harnessId), coreEnabled: true })),
|
|
133
144
|
adapters: [...this.adapters.values()].map((a) => ({ id: a.manifest.id, name: a.manifest.name, icon: a.manifest.icon, capabilities: a.manifest.capabilities, coreCapabilities: this.getCapabilities(a.manifest.id), ...this.status[a.manifest.id] })),
|
|
134
145
|
};
|
|
135
146
|
}
|
|
@@ -190,8 +201,13 @@ class HostRuntime {
|
|
|
190
201
|
const switchTargets = thread && !thread.parentThreadId
|
|
191
202
|
? [...this.adapters.values()].filter(a => a.manifest.id !== thread.harnessId && this.status[a.manifest.id]?.available).map(a => a.manifest.name)
|
|
192
203
|
: [];
|
|
193
|
-
const hostCommands = [
|
|
194
|
-
{ id: 'delegate', label: '/delegate', description: '委派子任务给其他 Harness:/delegate <Harness 名> <任务>', action: 'insert', text: '/delegate ' },
|
|
204
|
+
const hostCommands = [
|
|
205
|
+
{ id: 'delegate', label: '/delegate', description: '委派子任务给其他 Harness:/delegate <Harness 名> <任务>', action: 'insert', text: '/delegate ' },
|
|
206
|
+
{ id: 'verify', label: '/verify', description: '立即运行当前任务的验证门禁', action: 'execute' },
|
|
207
|
+
{ id: 'gate', label: '/gate', description: '配置门禁:/gate required|advisory|off [--auto] [--clean] [-- 验证命令]', action: 'insert', text: '/gate required ' },
|
|
208
|
+
{ id: 'gate-required', label: '/gate-required', description: '启用强制验证门禁;未通过时禁止合并或推送', action: 'execute' },
|
|
209
|
+
{ id: 'gate-advisory', label: '/gate-advisory', description: '启用建议型验证门禁;失败只报告、不阻止交付', action: 'execute' },
|
|
210
|
+
{ id: 'gate-off', label: '/gate-off', description: '关闭当前任务的验证门禁', action: 'execute' },
|
|
195
211
|
...(switchTargets.length ? [
|
|
196
212
|
{ id: 'switch', label: '/switch', description: `原地切换 Harness 继续当前会话(历史与文件现场保留):/switch <Harness 名> [备注](可用:${switchTargets.join('、')})`, action: 'insert', text: '/switch ' },
|
|
197
213
|
] : []),
|
|
@@ -202,8 +218,19 @@ class HostRuntime {
|
|
|
202
218
|
return [...native.filter(c => !hostCommands.some(h => h.id === c.id)), ...hostCommands];
|
|
203
219
|
}
|
|
204
220
|
|
|
205
|
-
async executeCommand(threadId, commandId) {
|
|
206
|
-
const thread = this.#requireThread(threadId);
|
|
221
|
+
async executeCommand(threadId, commandId) {
|
|
222
|
+
const thread = this.#requireThread(threadId);
|
|
223
|
+
if (commandId === 'verify') {
|
|
224
|
+
await this.runVerification(threadId);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (commandId === 'gate-required' || commandId === 'gate-advisory' || commandId === 'gate-off') {
|
|
228
|
+
const mode = commandId === 'gate-required' ? 'required' : commandId === 'gate-advisory' ? 'advisory' : 'off';
|
|
229
|
+
const previous = this.verificationGates.policy(thread);
|
|
230
|
+
await this.configureVerification(threadId, { ...previous, mode });
|
|
231
|
+
this.#notify('status', mode === 'off' ? '验证门禁已关闭' : `验证门禁已设为 ${mode}`, thread.id);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
207
234
|
if (commandId === 'apply-worktree') {
|
|
208
235
|
await this.applyThreadWorkspace(threadId);
|
|
209
236
|
return;
|
|
@@ -224,10 +251,11 @@ class HostRuntime {
|
|
|
224
251
|
return reviewWorkspace(thread.workspace);
|
|
225
252
|
}
|
|
226
253
|
|
|
227
|
-
async applyThreadWorkspace(threadId, digest) {
|
|
254
|
+
async applyThreadWorkspace(threadId, digest) {
|
|
228
255
|
const thread = this.#requireThread(threadId);
|
|
229
256
|
if (thread.workspace?.mode !== 'worktree') throw new Error('该任务未使用 Worktree 隔离工作区');
|
|
230
|
-
if (this.execution.isRunning(thread.id) || thread.reviewPending) throw new Error('请等待任务完成后再合并隔离分支');
|
|
257
|
+
if (this.execution.isRunning(thread.id) || thread.reviewPending) throw new Error('请等待任务完成后再合并隔离分支');
|
|
258
|
+
this.verificationGates.assertSatisfied(thread, '合并隔离分支');
|
|
231
259
|
const review = await reviewWorkspace(thread.workspace);
|
|
232
260
|
const result = await applyWorkspace(thread.workspace, digest || review.digest);
|
|
233
261
|
this.#notify('status', '已成功将隔离分支改动应用到主项目', thread.id);
|
|
@@ -248,10 +276,11 @@ class HostRuntime {
|
|
|
248
276
|
return result;
|
|
249
277
|
}
|
|
250
278
|
|
|
251
|
-
async pushThreadWorkspace(threadId, { remote = 'origin', branch } = {}) {
|
|
279
|
+
async pushThreadWorkspace(threadId, { remote = 'origin', branch } = {}) {
|
|
252
280
|
const thread = this.#requireThread(threadId);
|
|
253
281
|
if (thread.workspace?.mode !== 'worktree') throw new Error('该任务未使用 Worktree 隔离工作区');
|
|
254
|
-
if (this.execution.isRunning(thread.id) || thread.reviewPending) throw new Error('请等待任务完成后再推送分支');
|
|
282
|
+
if (this.execution.isRunning(thread.id) || thread.reviewPending) throw new Error('请等待任务完成后再推送分支');
|
|
283
|
+
this.verificationGates.assertSatisfied(thread, '推送分支');
|
|
255
284
|
const result = await pushWorkspace(thread.workspace, remote, branch);
|
|
256
285
|
this.#notify('status', `已成功将分支 ${result.branch} 推送到远程 ${result.remote}`, thread.id);
|
|
257
286
|
return result;
|
|
@@ -278,10 +307,17 @@ class HostRuntime {
|
|
|
278
307
|
return this.delegateTask({ fromThreadId: thread.id, harnessId: target, task, displayText: typed });
|
|
279
308
|
}
|
|
280
309
|
// Host 级切换指令:/switch <harness> [备注](原地换 Harness;会话历史保留,下条消息携带一次性上下文信封)
|
|
281
|
-
if (!commandId && !delegateOf && !collaborationOf && /^\/(switch|切换)(\s|$)/.test(typed)) {
|
|
310
|
+
if (!commandId && !delegateOf && !collaborationOf && /^\/(switch|切换)(\s|$)/.test(typed)) {
|
|
282
311
|
const { target, note } = parseSwitchCommand(typed);
|
|
283
|
-
return this.switchHarness(thread.id, target, { note });
|
|
284
|
-
}
|
|
312
|
+
return this.switchHarness(thread.id, target, { note });
|
|
313
|
+
}
|
|
314
|
+
if (!commandId && !delegateOf && !collaborationOf && /^\/gate(\s|$)/.test(typed)) {
|
|
315
|
+
const policy = parseVerificationCommand(typed, this.verificationGates.policy(thread));
|
|
316
|
+
return this.configureVerification(thread.id, policy);
|
|
317
|
+
}
|
|
318
|
+
if (!commandId && !delegateOf && !collaborationOf && /^\/verify\s*$/.test(typed)) {
|
|
319
|
+
return this.runVerification(thread.id);
|
|
320
|
+
}
|
|
285
321
|
// 首个真实输入让预热(ephemeral)线程转正为持久会话
|
|
286
322
|
if (thread.ephemeral) delete thread.ephemeral;
|
|
287
323
|
// 自动为默认标题任务派生语义标题
|
|
@@ -346,7 +382,8 @@ class HostRuntime {
|
|
|
346
382
|
}
|
|
347
383
|
}
|
|
348
384
|
}
|
|
349
|
-
|
|
385
|
+
this.verificationGates.invalidate(thread);
|
|
386
|
+
thread.messages.push({ id: randomUUID(), role: "user", text: text ?? '', at: Date.now(), ...(prepared.meta.length ? { attachments: prepared.meta } : {}), ...(hasConcurrentTurn ? { concurrent: true } : {}) });
|
|
350
387
|
thread.updatedAt = Date.now();
|
|
351
388
|
delete thread.error;
|
|
352
389
|
this.execution.turnStarted(thread, displayPrompt);
|
|
@@ -792,12 +829,49 @@ class HostRuntime {
|
|
|
792
829
|
return thread;
|
|
793
830
|
}
|
|
794
831
|
|
|
795
|
-
async setThreadArchived(threadId, archived) {
|
|
832
|
+
async setThreadArchived(threadId, archived) {
|
|
796
833
|
const thread = this.#requireThread(threadId);
|
|
797
834
|
if (this.execution.isRunning(thread.id)) throw new Error('任务执行中,不能归档');
|
|
798
835
|
thread.archived = Boolean(archived);
|
|
799
836
|
await this.#save(); this.#broadcast();
|
|
800
|
-
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
getThread(threadId) { return this.#requireThread(threadId); }
|
|
840
|
+
|
|
841
|
+
verificationState(threadId) {
|
|
842
|
+
return this.verificationGates.inspect(this.#requireThread(threadId));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async configureVerification(threadId, policy) {
|
|
846
|
+
const thread = this.#requireThread(threadId);
|
|
847
|
+
if (this.execution.isRunning(thread.id)) throw new Error('任务执行中不能修改验证门禁');
|
|
848
|
+
this.verificationGates.configure(thread, policy);
|
|
849
|
+
await this.#save();
|
|
850
|
+
this.#broadcast();
|
|
851
|
+
return this.verificationGates.inspect(thread);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async runVerification(threadId) {
|
|
855
|
+
const thread = this.#requireThread(threadId);
|
|
856
|
+
if (this.execution.isRunning(thread.id) || thread.reviewPending) throw new Error('请等待任务与文件审查结算后再运行验证');
|
|
857
|
+
const report = await this.verificationGates.run(thread);
|
|
858
|
+
await this.#save();
|
|
859
|
+
this.#broadcast();
|
|
860
|
+
this.#notify(report.status === 'passed' ? 'status' : 'error', report.status === 'passed' ? '验证门禁已通过' : '验证门禁未通过', thread.id);
|
|
861
|
+
return report;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async inspectStorage() {
|
|
865
|
+
const result = storageProjection(this.threads);
|
|
866
|
+
return { ...result, ...await this.store.inspectFiles(), backupFile: `${this.store.legacyFile}.bak` };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async optimizeStorage() {
|
|
870
|
+
const before = await this.inspectStorage();
|
|
871
|
+
await this.#save();
|
|
872
|
+
const after = await this.inspectStorage();
|
|
873
|
+
return { before, after };
|
|
874
|
+
}
|
|
801
875
|
|
|
802
876
|
async setThreadSection(threadId, section, beforeThreadId) {
|
|
803
877
|
const thread = this.#requireThread(threadId);
|
|
@@ -819,9 +893,10 @@ class HostRuntime {
|
|
|
819
893
|
if (thread.workspace?.mode === 'worktree') {
|
|
820
894
|
await removeWorkspace(thread.workspace).catch(() => {});
|
|
821
895
|
}
|
|
822
|
-
this.threads = this.threads.filter((t) => t.id !== threadId);
|
|
823
|
-
await this.#save();
|
|
824
|
-
this
|
|
896
|
+
this.threads = this.threads.filter((t) => t.id !== threadId);
|
|
897
|
+
await this.#save();
|
|
898
|
+
await this.store.remove(threadId);
|
|
899
|
+
this.#broadcast();
|
|
825
900
|
}
|
|
826
901
|
|
|
827
902
|
/** 移动任务到另一个项目:关闭当前原生进程,下次发送在新目录惰性恢复(项目级会话的原生历史可能不跟随) */
|
|
@@ -860,10 +935,19 @@ class HostRuntime {
|
|
|
860
935
|
return adapter;
|
|
861
936
|
}
|
|
862
937
|
|
|
863
|
-
#requireThread(threadId) {
|
|
864
|
-
const thread = this.threads.find((t) => t.id === threadId);
|
|
865
|
-
if (!thread) throw new Error("任务不存在");
|
|
866
|
-
|
|
938
|
+
#requireThread(threadId) {
|
|
939
|
+
const thread = this.threads.find((t) => t.id === threadId);
|
|
940
|
+
if (!thread) throw new Error("任务不存在");
|
|
941
|
+
if (thread._storageStub) {
|
|
942
|
+
this.store.hydrateInto(thread);
|
|
943
|
+
thread.harnessId = this.resolveHarnessId(thread.harnessId) || thread.harnessId;
|
|
944
|
+
thread.connectionStatus = 'ready';
|
|
945
|
+
if (thread.nativeSessionId && thread.messages?.length) thread.restore = true;
|
|
946
|
+
thread.reviewPending = false;
|
|
947
|
+
thread.pendingApprovals = [];
|
|
948
|
+
this.execution.threadCreated(thread);
|
|
949
|
+
}
|
|
950
|
+
return thread;
|
|
867
951
|
}
|
|
868
952
|
|
|
869
953
|
async #assertCwd(cwd) {
|
|
@@ -951,7 +1035,14 @@ class HostRuntime {
|
|
|
951
1035
|
const message = thread.messages.find(m => m.coreTurnId === turn.id);
|
|
952
1036
|
const activeChildren = [...this.collaboration.jobs.values()].some(job => job.owner === thread.id && job.status === 'running');
|
|
953
1037
|
if (activeChildren) thread.reviewPending = true;
|
|
954
|
-
const
|
|
1038
|
+
const settle = activeChildren ? this.collaboration.cancelOwner(thread.id).catch(() => {}).then(() => this.#settleReview(thread, message)) : this.#settleReview(thread, message);
|
|
1039
|
+
const task = Promise.resolve(settle).then(async () => {
|
|
1040
|
+
if (this.verificationGates.policy(thread).autoRun) {
|
|
1041
|
+
await this.verificationGates.run(thread, { turnId: turn.id });
|
|
1042
|
+
await this.#save();
|
|
1043
|
+
this.#broadcast();
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
955
1046
|
this.reviewTasks.add(task);
|
|
956
1047
|
void task.finally(() => this.reviewTasks.delete(task));
|
|
957
1048
|
}
|
|
@@ -1037,10 +1128,16 @@ class HostRuntime {
|
|
|
1037
1128
|
|
|
1038
1129
|
#syncCore(thread) { this.execution.sync(thread); }
|
|
1039
1130
|
|
|
1040
|
-
#save() {
|
|
1041
|
-
for (const thread of this.threads)
|
|
1042
|
-
|
|
1043
|
-
|
|
1131
|
+
#save() {
|
|
1132
|
+
for (const thread of this.threads) {
|
|
1133
|
+
if (thread._storageStub) continue;
|
|
1134
|
+
this.#syncCore(thread);
|
|
1135
|
+
thread.coreState = this.execution.checkpoint(thread);
|
|
1136
|
+
}
|
|
1137
|
+
const saving = this.store.save(this.threads);
|
|
1138
|
+
for (const thread of this.threads) delete thread.coreState;
|
|
1139
|
+
return saving;
|
|
1140
|
+
}
|
|
1044
1141
|
|
|
1045
1142
|
#saveSoon() {
|
|
1046
1143
|
if (this.saveTimer) return;
|
|
@@ -1059,11 +1156,28 @@ function attachSession(adapter, session, threadId) {
|
|
|
1059
1156
|
}
|
|
1060
1157
|
|
|
1061
1158
|
/** /delegate <harness> <任务> 指令解析(宿主级协作入口,支持中文别名;目标名解析见 resolveHarnessId) */
|
|
1062
|
-
function parseDelegationCommand(text) {
|
|
1159
|
+
function parseDelegationCommand(text) {
|
|
1063
1160
|
const match = /^\/(?:delegate|委派)\s+(\S+)\s+([\s\S]+)/.exec(text);
|
|
1064
1161
|
if (!match) throw new Error('用法:/delegate <harness> <任务>,例如 /delegate <目标 Harness> 审查 src/ 的改动');
|
|
1065
1162
|
return { target: match[1], task: match[2].trim() };
|
|
1066
|
-
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function parseVerificationCommand(text, previous) {
|
|
1166
|
+
const match = /^\/gate\s+(off|advisory|required)([\s\S]*)$/i.exec(text);
|
|
1167
|
+
if (!match) throw new Error('用法:/gate required|advisory|off [--auto] [--clean] [-- 验证命令]');
|
|
1168
|
+
const tail = match[2].trim();
|
|
1169
|
+
const separator = tail.indexOf('-- ');
|
|
1170
|
+
const flags = (separator >= 0 ? tail.slice(0, separator) : tail).trim().split(/\s+/).filter(Boolean);
|
|
1171
|
+
if (flags.some(flag => !['--auto', '--clean'].includes(flag))) throw new Error(`未知门禁选项:${flags.find(flag => !['--auto', '--clean'].includes(flag))}`);
|
|
1172
|
+
const command = separator >= 0 ? tail.slice(separator + 3).trim() : '';
|
|
1173
|
+
return {
|
|
1174
|
+
...previous,
|
|
1175
|
+
mode: match[1].toLowerCase(),
|
|
1176
|
+
autoRun: flags.includes('--auto'),
|
|
1177
|
+
checks: { ...previous.checks, cleanWorkingTree: flags.includes('--clean') },
|
|
1178
|
+
commands: command ? [{ id: 'custom', command }] : previous.commands,
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1067
1181
|
|
|
1068
1182
|
/** /switch <harness> [备注] 指令解析(宿主级切换入口,支持中文别名;目标名解析见 resolveHarnessId) */
|
|
1069
1183
|
function parseSwitchCommand(text) {
|
package/src/main/host/store.js
CHANGED
|
@@ -3,9 +3,14 @@ const path = require("node:path");
|
|
|
3
3
|
|
|
4
4
|
/** 线程记录的 JSON 持久化:折叠排队保存 + tmp 文件原子替换,避免中间状态内存堆积 */
|
|
5
5
|
class Store {
|
|
6
|
-
constructor(directory, filename = "threads.json") {
|
|
6
|
+
constructor(directory, filename = "threads.json", options = {}) {
|
|
7
7
|
this.directory = directory;
|
|
8
8
|
this.file = path.join(directory, filename);
|
|
9
|
+
this.schemaVersion = options.schemaVersion ?? null;
|
|
10
|
+
this.serialize = options.serialize ?? ((value) => value);
|
|
11
|
+
this.deserialize = options.deserialize ?? ((value) => value);
|
|
12
|
+
this.backup = options.backup === true;
|
|
13
|
+
this.backupPending = false;
|
|
9
14
|
this.writing = false;
|
|
10
15
|
this.pendingData = null;
|
|
11
16
|
this.pendingResolvers = [];
|
|
@@ -21,17 +26,24 @@ class Store {
|
|
|
21
26
|
}
|
|
22
27
|
await fs.mkdir(this.directory, { recursive: true });
|
|
23
28
|
try {
|
|
24
|
-
const
|
|
25
|
-
if (!Array.isArray(
|
|
26
|
-
|
|
29
|
+
const parsed = JSON.parse(await fs.readFile(this.file, "utf8"));
|
|
30
|
+
if (!Array.isArray(parsed) && this.schemaVersion != null && parsed?.schemaVersion !== this.schemaVersion) {
|
|
31
|
+
throw new Error(`Unsupported thread store schema: ${parsed?.schemaVersion ?? 'missing'}; original file preserved`);
|
|
32
|
+
}
|
|
33
|
+
const threads = Array.isArray(parsed) ? parsed : parsed?.threads;
|
|
34
|
+
if (!Array.isArray(threads)) throw new Error('Invalid thread store; original file preserved');
|
|
35
|
+
if (this.backup && Array.isArray(parsed)) this.backupPending = true;
|
|
36
|
+
return threads.map((thread) => this.deserialize(thread));
|
|
27
37
|
} catch (error) {
|
|
28
38
|
if (error.code === 'ENOENT') return [];
|
|
29
39
|
throw error;
|
|
30
40
|
}
|
|
31
41
|
}
|
|
32
42
|
|
|
33
|
-
save(threads) {
|
|
34
|
-
|
|
43
|
+
save(threads) {
|
|
44
|
+
// Capture the serializable projection now. Callers may release heavyweight
|
|
45
|
+
// in-memory checkpoint fields immediately after save() returns.
|
|
46
|
+
this.pendingData = threads.map((value) => this.serialize(value));
|
|
35
47
|
const promise = new Promise((resolve, reject) => {
|
|
36
48
|
this.pendingResolvers.push(resolve);
|
|
37
49
|
this.pendingRejecters.push(reject);
|
|
@@ -52,16 +64,25 @@ class Store {
|
|
|
52
64
|
this.pendingRejecters = [];
|
|
53
65
|
const tmpFile = `${this.file}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
54
66
|
try {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
67
|
+
const payload = this.schemaVersion == null
|
|
68
|
+
? data
|
|
69
|
+
: { schemaVersion: this.schemaVersion, savedAt: Date.now(), threads: data };
|
|
70
|
+
const contents = JSON.stringify(payload, null, 2);
|
|
71
|
+
await fs.mkdir(this.directory, { recursive: true });
|
|
72
|
+
await fs.writeFile(tmpFile, contents);
|
|
73
|
+
if (this.backupPending) {
|
|
74
|
+
await fs.copyFile(this.file, `${this.file}.bak`).catch((error) => {
|
|
75
|
+
if (error.code !== 'ENOENT') throw error;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
58
78
|
for (let attempt = 0; ; attempt++) {
|
|
59
|
-
try { await fs.rename(tmpFile, this.file); break; }
|
|
79
|
+
try { await fs.rename(tmpFile, this.file); break; }
|
|
60
80
|
catch (error) {
|
|
61
81
|
if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt === 9) throw error;
|
|
62
82
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
63
83
|
}
|
|
64
|
-
}
|
|
84
|
+
}
|
|
85
|
+
this.backupPending = false;
|
|
65
86
|
for (const r of resolvers) r();
|
|
66
87
|
} catch (error) {
|
|
67
88
|
await fs.unlink(tmpFile).catch(() => {});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const { Buffer } = require('node:buffer');
|
|
2
|
+
|
|
3
|
+
const DERIVED_THREAD_FIELDS = [
|
|
4
|
+
'tools', 'pendingApprovals', 'interactions', 'currentTurn', 'coreThread',
|
|
5
|
+
'coreUsage', 'usage', 'capabilities', 'coreEnabled',
|
|
6
|
+
];
|
|
7
|
+
const DERIVED_MESSAGE_FIELDS = [
|
|
8
|
+
'coreTurn', 'coreItems', 'text', 'thinking', 'artifacts', 'at', 'endedAt',
|
|
9
|
+
'streaming', 'stopReason', 'items',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function compactThread(thread) {
|
|
13
|
+
// Use structural sharing while serializing. A deep clone here would recreate the
|
|
14
|
+
// same large transient allocation this format is intended to avoid.
|
|
15
|
+
const value = { ...thread };
|
|
16
|
+
if (value.coreState?.version !== 1) return value;
|
|
17
|
+
for (const key of DERIVED_THREAD_FIELDS) delete value[key];
|
|
18
|
+
value.messages = (value.messages ?? []).map((message) => {
|
|
19
|
+
if (message.role !== 'assistant' || !message.coreTurnId) return message;
|
|
20
|
+
const compact = { ...message };
|
|
21
|
+
for (const key of DERIVED_MESSAGE_FIELDS) delete compact[key];
|
|
22
|
+
return compact;
|
|
23
|
+
});
|
|
24
|
+
value.storage = { schemaVersion: 2 };
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hydrateThread(thread) {
|
|
29
|
+
const value = { ...thread };
|
|
30
|
+
delete value.storage;
|
|
31
|
+
value.messages ??= [];
|
|
32
|
+
value.tools ??= [];
|
|
33
|
+
value.pendingApprovals ??= [];
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function byteLength(value) {
|
|
38
|
+
return Buffer.byteLength(JSON.stringify(value));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function storageProjection(threads) {
|
|
42
|
+
const rows = threads.map((thread) => {
|
|
43
|
+
if (thread._storageStub) return {
|
|
44
|
+
threadId: thread.id, title: thread.title, updatedAt: thread.updatedAt ?? thread.createdAt ?? 0,
|
|
45
|
+
archived: thread.archived === true, messageCount: thread.messageCount ?? null,
|
|
46
|
+
logicalBytes: null, persistedBytes: thread.recordBytes ?? null, savedBytes: null, loaded: false,
|
|
47
|
+
};
|
|
48
|
+
const logicalBytes = byteLength(thread);
|
|
49
|
+
const persistedBytes = byteLength(compactThread(thread));
|
|
50
|
+
return {
|
|
51
|
+
threadId: thread.id,
|
|
52
|
+
title: thread.title,
|
|
53
|
+
updatedAt: thread.updatedAt ?? thread.createdAt ?? 0,
|
|
54
|
+
archived: thread.archived === true,
|
|
55
|
+
messageCount: thread.messages?.length ?? 0,
|
|
56
|
+
logicalBytes,
|
|
57
|
+
persistedBytes,
|
|
58
|
+
savedBytes: Math.max(0, logicalBytes - persistedBytes),
|
|
59
|
+
loaded: true,
|
|
60
|
+
};
|
|
61
|
+
}).sort((a, b) => b.persistedBytes - a.persistedBytes);
|
|
62
|
+
return {
|
|
63
|
+
schemaVersion: 2,
|
|
64
|
+
threadCount: rows.length,
|
|
65
|
+
loadedThreadCount: rows.filter(row => row.loaded).length,
|
|
66
|
+
logicalBytes: rows.reduce((sum, row) => sum + (row.logicalBytes ?? 0), 0),
|
|
67
|
+
persistedBytes: rows.reduce((sum, row) => sum + (row.persistedBytes ?? 0), 0),
|
|
68
|
+
savedBytes: rows.reduce((sum, row) => sum + (row.savedBytes ?? 0), 0),
|
|
69
|
+
largestThreads: rows.filter(row => row.persistedBytes != null).sort((a, b) => b.persistedBytes - a.persistedBytes).slice(0, 20),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { compactThread, hydrateThread, storageProjection };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const fsSync = require('node:fs');
|
|
2
|
+
const { promises: fs } = fsSync;
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { compactThread, hydrateThread } = require('./thread-storage');
|
|
5
|
+
|
|
6
|
+
const SCHEMA_VERSION = 3;
|
|
7
|
+
|
|
8
|
+
async function writeAtomic(file, value) {
|
|
9
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
10
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
11
|
+
const contents = JSON.stringify(value, null, 2);
|
|
12
|
+
await fs.writeFile(tmp, contents);
|
|
13
|
+
for (let attempt = 0; ; attempt++) {
|
|
14
|
+
try { await fs.rename(tmp, file); return Buffer.byteLength(contents); }
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt === 9) { await fs.unlink(tmp).catch(() => {}); throw error; }
|
|
17
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function recordName(id) { return `${encodeURIComponent(id)}.json`; }
|
|
23
|
+
|
|
24
|
+
function summarize(thread) {
|
|
25
|
+
const firstUser = (thread.messages ?? []).find(message => message.role === 'user');
|
|
26
|
+
const fields = ['id', 'harnessId', 'title', 'cwd', 'originalCwd', 'nativeSessionId', 'status', 'connectionStatus', 'createdAt', 'updatedAt', 'archived', 'section', 'sectionEnteredAt', 'projectId', 'parentThreadId', 'ephemeral', 'gitInfo', 'workspace', 'isolation', 'harnessChain', 'pendingHandoff', 'options', 'model'];
|
|
27
|
+
const summary = Object.fromEntries(fields.filter(key => thread[key] !== undefined).map(key => [key, thread[key]]));
|
|
28
|
+
summary.preview = firstUser?.text || thread.preview || thread.title || '';
|
|
29
|
+
summary.messageCount = thread.messages?.length ?? thread.messageCount ?? 0;
|
|
30
|
+
if (thread.recordBytes != null) summary.recordBytes = thread.recordBytes;
|
|
31
|
+
return summary;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class ThreadStore {
|
|
35
|
+
constructor(directory) {
|
|
36
|
+
this.directory = directory;
|
|
37
|
+
this.legacyFile = path.join(directory, 'threads.json');
|
|
38
|
+
this.shardDirectory = path.join(directory, 'threads');
|
|
39
|
+
this.recordsDirectory = path.join(this.shardDirectory, 'records');
|
|
40
|
+
this.indexFile = path.join(this.shardDirectory, 'index.json');
|
|
41
|
+
this.file = this.indexFile;
|
|
42
|
+
this.writing = false;
|
|
43
|
+
this.pending = null;
|
|
44
|
+
this.waiters = [];
|
|
45
|
+
}
|
|
46
|
+
async #readIndex() {
|
|
47
|
+
try {
|
|
48
|
+
const value = JSON.parse(await fs.readFile(this.indexFile, 'utf8'));
|
|
49
|
+
if (value?.schemaVersion !== SCHEMA_VERSION || !Array.isArray(value.threads)) throw new Error('Unsupported sharded thread store; original files preserved');
|
|
50
|
+
return value;
|
|
51
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
52
|
+
}
|
|
53
|
+
async #readLegacy() {
|
|
54
|
+
try {
|
|
55
|
+
const value = JSON.parse(await fs.readFile(this.legacyFile, 'utf8'));
|
|
56
|
+
const threads = Array.isArray(value) ? value : value?.threads;
|
|
57
|
+
if (!Array.isArray(threads)) throw new Error('Invalid legacy thread store; original file preserved');
|
|
58
|
+
return threads.map(hydrateThread);
|
|
59
|
+
} catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
60
|
+
}
|
|
61
|
+
async loadIndex() {
|
|
62
|
+
const index = await this.#readIndex();
|
|
63
|
+
if (!index) return this.#readLegacy();
|
|
64
|
+
return index.threads.map(summary => ({
|
|
65
|
+
...summary,
|
|
66
|
+
status: summary.status === 'working' ? 'interrupted' : summary.status === 'opening' ? 'ready' : summary.status,
|
|
67
|
+
connectionStatus: 'ready',
|
|
68
|
+
messages: [], tools: [], pendingApprovals: [], _storageStub: true,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
async load() {
|
|
72
|
+
const index = await this.#readIndex();
|
|
73
|
+
if (!index) return this.#readLegacy();
|
|
74
|
+
return Promise.all(index.threads.map(async summary => hydrateThread(JSON.parse(await fs.readFile(path.join(this.recordsDirectory, recordName(summary.id)), 'utf8')))));
|
|
75
|
+
}
|
|
76
|
+
hydrateInto(thread) {
|
|
77
|
+
if (!thread?._storageStub) return thread;
|
|
78
|
+
const record = hydrateThread(JSON.parse(fsSync.readFileSync(path.join(this.recordsDirectory, recordName(thread.id)), 'utf8')));
|
|
79
|
+
for (const key of Object.keys(thread)) delete thread[key];
|
|
80
|
+
Object.assign(thread, record);
|
|
81
|
+
return thread;
|
|
82
|
+
}
|
|
83
|
+
save(threads) {
|
|
84
|
+
this.pending = {
|
|
85
|
+
index: { schemaVersion: SCHEMA_VERSION, savedAt: Date.now(), threads: threads.map(summarize) },
|
|
86
|
+
records: threads.filter(thread => !thread._storageStub).map(thread => ({ id: thread.id, value: compactThread(thread) })),
|
|
87
|
+
};
|
|
88
|
+
const promise = new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
|
|
89
|
+
void this.#drain();
|
|
90
|
+
return promise;
|
|
91
|
+
}
|
|
92
|
+
async #drain() {
|
|
93
|
+
if (this.writing) return;
|
|
94
|
+
this.writing = true;
|
|
95
|
+
while (this.pending) {
|
|
96
|
+
const snapshot = this.pending; this.pending = null;
|
|
97
|
+
const waiters = this.waiters; this.waiters = [];
|
|
98
|
+
try {
|
|
99
|
+
await fs.mkdir(this.recordsDirectory, { recursive: true });
|
|
100
|
+
if (snapshot.records.length && !fsSync.existsSync(`${this.legacyFile}.bak`)) await fs.copyFile(this.legacyFile, `${this.legacyFile}.bak`).catch(error => { if (error.code !== 'ENOENT') throw error; });
|
|
101
|
+
for (const record of snapshot.records) {
|
|
102
|
+
const bytes = await writeAtomic(path.join(this.recordsDirectory, recordName(record.id)), record.value);
|
|
103
|
+
const summary = snapshot.index.threads.find(thread => thread.id === record.id);
|
|
104
|
+
if (summary) summary.recordBytes = bytes;
|
|
105
|
+
}
|
|
106
|
+
await writeAtomic(this.indexFile, snapshot.index);
|
|
107
|
+
for (const waiter of waiters) waiter.resolve();
|
|
108
|
+
} catch (error) { for (const waiter of waiters) waiter.reject(error); }
|
|
109
|
+
}
|
|
110
|
+
this.writing = false;
|
|
111
|
+
}
|
|
112
|
+
remove(threadId) { return fs.unlink(path.join(this.recordsDirectory, recordName(threadId))).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
|
|
113
|
+
async inspectFiles() {
|
|
114
|
+
const index = await fs.stat(this.indexFile).catch(() => null);
|
|
115
|
+
const legacy = await fs.stat(this.legacyFile).catch(() => null);
|
|
116
|
+
const records = await fs.readdir(this.recordsDirectory, { withFileTypes: true }).catch(() => []);
|
|
117
|
+
let recordBytes = 0;
|
|
118
|
+
for (const entry of records) if (entry.isFile() && entry.name.endsWith('.json')) recordBytes += (await fs.stat(path.join(this.recordsDirectory, entry.name))).size;
|
|
119
|
+
return { storageSchemaVersion: SCHEMA_VERSION, indexFile: this.indexFile, indexBytes: index?.size ?? 0, recordsDirectory: this.recordsDirectory, recordCount: records.filter(entry => entry.isFile() && entry.name.endsWith('.json')).length, recordBytes, legacyFile: this.legacyFile, legacyBytes: legacy?.size ?? 0 };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { ThreadStore, summarize, SCHEMA_VERSION };
|