@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.
Files changed (52) hide show
  1. package/README.md +86 -56
  2. package/docs/storage-verification-design.md +49 -0
  3. package/output/native-build/renderer-extension.js +2743 -2330
  4. package/package.json +3 -2
  5. package/scripts/codebuddy-migration-test.cjs +1 -1
  6. package/scripts/core-runtime-test.cjs +9 -1
  7. package/scripts/native-protocol-test.cjs +11 -1
  8. package/scripts/native-ui-smoke.cjs +23 -4
  9. package/scripts/projector-test.cjs +13 -0
  10. package/scripts/storage-verification-test.cjs +78 -0
  11. package/scripts/switch-harness-test.cjs +1 -1
  12. package/src/main/host/collaboration.js +2 -0
  13. package/src/main/host/core-session.js +7 -1
  14. package/src/main/host/handoff-checkpoints.js +8 -1
  15. package/src/main/host/runtime.js +150 -36
  16. package/src/main/host/store.js +32 -11
  17. package/src/main/host/thread-storage.js +73 -0
  18. package/src/main/host/thread-store.js +123 -0
  19. package/src/main/host/verification-gates.js +118 -0
  20. package/src/main/native/protocol.js +20 -5
  21. package/src/main/protocol-core/projector.js +20 -3
  22. package/src/main/shared-contracts/event.js +2 -1
  23. package/src/main/shared-contracts/item.js +1 -0
  24. package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts +2 -2
  25. package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts.map +1 -1
  26. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  27. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +5 -0
  28. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  29. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts +2 -1
  30. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
  31. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts +1 -1
  32. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts.map +1 -1
  33. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
  34. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +5 -3
  35. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
  36. package/src/native-ui/renderer-extension/dist/types/settings/storage-page.d.ts +20 -0
  37. package/src/native-ui/renderer-extension/dist/types/settings/storage-page.d.ts.map +1 -0
  38. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  39. package/src/native-ui/renderer-extension/src/harness-mix-settings.ts +8 -89
  40. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +5 -1
  41. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +4 -0
  42. package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +12 -0
  43. package/src/native-ui/renderer-extension/src/settings/icons.ts +6 -3
  44. package/src/native-ui/renderer-extension/src/settings/localization.ts +4 -2
  45. package/src/native-ui/renderer-extension/src/settings/pages.ts +12 -5
  46. package/src/native-ui/renderer-extension/src/settings/shell.css +8 -3
  47. package/src/native-ui/renderer-extension/src/settings/storage-page.ts +74 -0
  48. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +2 -0
  49. package/src/native-ui/renderer-extension/test/settings/harness-settings.test.ts +5 -0
  50. package/src/native-ui/renderer-extension/test/settings/localization.test.ts +1 -1
  51. package/src/native-ui/renderer-extension/test/settings/pages.test.ts +50 -4
  52. package/src/native-ui/renderer-extension/test/settings/shell.test.ts +3 -0
@@ -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 { Store } = require("./store");
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 Store(dataDirectory);
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.load();
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
- thread.messages.push({ id: randomUUID(), role: "user", text: text ?? '', at: Date.now(), ...(prepared.meta.length ? { attachments: prepared.meta } : {}), ...(hasConcurrentTurn ? { concurrent: true } : {}) });
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.#broadcast();
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
- return thread;
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 task = activeChildren ? this.collaboration.cancelOwner(thread.id).catch(() => {}).then(() => this.#settleReview(thread, message)) : this.#settleReview(thread, message);
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) this.#syncCore(thread);
1042
- return this.store.save(this.threads);
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) {
@@ -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 threads = JSON.parse(await fs.readFile(this.file, "utf8"));
25
- if (!Array.isArray(threads)) throw new Error('Invalid thread store; original file preserved');
26
- return threads;
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
- this.pendingData = threads;
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 contents = JSON.stringify(data, null, 2);
56
- await fs.mkdir(this.directory, { recursive: true });
57
- await fs.writeFile(tmpFile, contents);
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 };