dsh-config-manager 0.1.73 → 0.1.76

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 (49) hide show
  1. package/lib/client.d.ts +8 -4
  2. package/lib/client.js +814 -682
  3. package/lib/core/rollback-ledger.d.ts +29 -3
  4. package/lib/core/rollback-ledger.js +125 -7
  5. package/lib/core/rollback-point.d.ts +221 -3
  6. package/lib/core/rollback-point.js +790 -153
  7. package/lib/index.d.ts +1 -1
  8. package/lib/index.js +1 -1
  9. package/lib/sync/messages.d.ts +1 -0
  10. package/lib/sync/messages.js +3 -0
  11. package/lib/sync/sync-engine.js +56 -7
  12. package/lib/ui/i18n.d.ts +1 -1
  13. package/lib/ui/i18n.js +2 -2
  14. package/lib/utils/bundle-scan.d.ts +5 -3
  15. package/lib/utils/bundle-scan.js +5 -3
  16. package/lib/utils/line-diff.d.ts +7 -1
  17. package/lib/utils/line-diff.js +8 -2
  18. package/package.json +1 -1
  19. package/src/client/chat/ChatEntryView.tsx +38 -9
  20. package/src/client/chat/RollbackFileIcon.tsx +7 -45
  21. package/src/client/chat/chat-entry-view.test.ts +164 -1
  22. package/src/client/chat/chat-entry-view.ts +127 -10
  23. package/src/client/chat/chat-node-registration.ts +40 -6
  24. package/src/client/common/DiffTable.tsx +12 -23
  25. package/src/client/common/host-primitives.ts +78 -0
  26. package/src/client/config-manager.module.css +33 -12
  27. package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
  28. package/src/client/index.ts +7 -1
  29. package/src/client/library/RestorePlanView.tsx +0 -1
  30. package/src/client/locales.ts +0 -2
  31. package/src/client/recovery/RollbackDiffPane.tsx +13 -6
  32. package/src/client/recovery/recovery-locales.ts +2 -4
  33. package/src/client/recovery/rollback-file-changes.test.ts +37 -1
  34. package/src/client/sync/PushPreviewCard.tsx +57 -0
  35. package/src/client/sync/SyncPanel.tsx +2 -37
  36. package/src/client/sync/SyncSettingsView.test.ts +3 -0
  37. package/src/client/sync/sync-push-preview.test.ts +14 -0
  38. package/src/client/sync/sync-view.ts +9 -1
  39. package/src/core/rollback-ledger.ts +123 -7
  40. package/src/core/rollback-point.test.ts +592 -17
  41. package/src/core/rollback-point.ts +861 -155
  42. package/src/index.ts +1 -1
  43. package/src/sync/messages.ts +3 -0
  44. package/src/sync/sync-engine.ts +55 -7
  45. package/src/sync/sync-preview-push.test.ts +158 -0
  46. package/src/ui/i18n.ts +2 -2
  47. package/src/utils/bundle-scan.ts +5 -3
  48. package/src/utils/line-diff.test.ts +12 -0
  49. package/src/utils/line-diff.ts +9 -3
@@ -47,6 +47,7 @@ import type {
47
47
  RollbackSegmentSummary,
48
48
  RollbackSessionPayload,
49
49
  RollbackStateKind,
50
+ RollbackWorkspaceFile,
50
51
  RollbackWorkspacePayload,
51
52
  } from './rollback-point.ts'
52
53
 
@@ -133,6 +134,12 @@ export interface RollbackLedgerManifestEntry {
133
134
  payload: string | null
134
135
  /** 载荷文件字节数(**同一哈希只算一次**,预算与统计用) */
135
136
  payloadBytes: number
137
+ /**
138
+ * 这份点**解析所需的全部载荷哈希**(自身在前、基准依次在后)。
139
+ * 增量载荷依赖基准 ⇒ 回收(prunePayloads)必须按链保活,否则链一断这个点就读不出来了。
140
+ * 老清单没有该字段 ⇒ 视为 `[payload]`(老载荷都是全量、无依赖)。
141
+ */
142
+ payloadChain?: string[]
136
143
  }
137
144
  session: RollbackLedgerTrackHeader & {
138
145
  /** 会话字节副本的对象仓键;null = 没有副本(回收对象时用,不必读记录文件) */
@@ -562,6 +569,7 @@ export function normalizeManifestEntry(value: unknown): RollbackLedgerManifestEn
562
569
  if (sessionObject !== null && (typeof sessionObject !== 'string' || !OBJECT_KEY_RE.test(sessionObject))) return null
563
570
  const rawRecordBytes = value['recordBytes']
564
571
  const rawPayloadBytes = isRecord(workspaceRaw) ? workspaceRaw['payloadBytes'] : 0
572
+ const rawPayloadChain = isRecord(workspaceRaw) ? workspaceRaw['payloadChain'] : null
565
573
  return {
566
574
  id,
567
575
  kind,
@@ -579,6 +587,10 @@ export function normalizeManifestEntry(value: unknown): RollbackLedgerManifestEn
579
587
  ...workspace,
580
588
  payload: typeof wsPayload === 'string' ? wsPayload : null,
581
589
  payloadBytes: typeof rawPayloadBytes === 'number' && Number.isFinite(rawPayloadBytes) && rawPayloadBytes >= 0 ? rawPayloadBytes : 0,
590
+ // 只保留形状合法的哈希;读不回来的链由解析器如实失败(绝不猜)
591
+ ...(Array.isArray(rawPayloadChain)
592
+ ? { payloadChain: rawPayloadChain.filter((h) => typeof h === 'string' && OBJECT_KEY_RE.test(h)) }
593
+ : {}),
582
594
  },
583
595
  session: { ...session, object: typeof sessionObject === 'string' ? sessionObject : null },
584
596
  segments: [first, second],
@@ -601,6 +613,22 @@ function normalizeWorkspacePayload(value: unknown): RollbackWorkspacePayload | n
601
613
  return value as unknown as RollbackWorkspacePayload
602
614
  }
603
615
 
616
+ /**
617
+ * **存盘**形状校验:完整清单(`files` 是数组)或增量视图(`delta` 是对象、`files` 故意留 null)。
618
+ * 与 {@link normalizeWorkspacePayload}(消费侧,要求物化后的完整形状)分开:增量载荷**绝不会**被当成完整清单读出去。
619
+ */
620
+ function normalizeStoredPayload(value: unknown): RollbackWorkspacePayload | null {
621
+ const base = normalizeWorkspacePayload(value)
622
+ if (base !== null) return base
623
+ if (!isRecord(value)) return null
624
+ const delta = value['delta']
625
+ if (!isRecord(delta)) return null
626
+ if (typeof delta['base'] !== 'string') return null
627
+ if (!Array.isArray(delta['added']) || !Array.isArray(delta['removed'])) return null
628
+ const clone = { ...value, files: [] }
629
+ return normalizeWorkspacePayload(clone)
630
+ }
631
+
604
632
  function normalizeSessionPayload(value: unknown): RollbackSessionPayload | null {
605
633
  if (!isRecord(value)) return null
606
634
  if (value['sessionId'] !== null && typeof value['sessionId'] !== 'string') return null
@@ -805,7 +833,10 @@ export class RollbackLedger {
805
833
  /* -------------------------------------------------------- 单条记录 */
806
834
 
807
835
  /** 把完整记录拆成「清单条目 + 记录文件内容 + 载荷文件内容」(**不做 IO**,纯函数) */
808
- prepareRecord(record: RollbackPointRecord): RollbackLedgerPrepared {
836
+ prepareRecord(
837
+ record: RollbackPointRecord,
838
+ reuse?: { hash: string; bytes: number; chain?: string[] },
839
+ ): RollbackLedgerPrepared {
809
840
  const summary = this.summarize(record)
810
841
  const workspaceTrack = record.workspace
811
842
  const sessionTrack = record.session
@@ -813,7 +844,11 @@ export class RollbackLedger {
813
844
  let payloadJson: string | null = null
814
845
  let payloadHash: string | null = null
815
846
  let payloadBytes = 0
816
- if (payload !== null) {
847
+ if (payload !== null && reuse !== undefined) {
848
+ // **复用**:这份工作区态与基准点逐条相同 ⇒ 直接引用它已经落盘的载荷(零额外字节,链不增长)
849
+ payloadHash = reuse.hash
850
+ payloadBytes = reuse.bytes
851
+ } else if (payload !== null) {
817
852
  payloadJson = JSON.stringify(payload)
818
853
  payloadHash = sha256Hex(payloadJson)
819
854
  payloadBytes = byteLength(payloadJson)
@@ -835,6 +870,14 @@ export class RollbackLedger {
835
870
  workspace: { ...trackHeader(workspaceTrack), payload: payloadHash },
836
871
  session: { ...trackHeader(sessionTrack), payload: sessionTrack.payload },
837
872
  })
873
+ // 增量视图 ⇒ 清单条目带上「解析所需的全部载荷哈希」(自身 + 基准链),回收按链保活
874
+ const payloadChain = payloadHash === null
875
+ ? undefined
876
+ : reuse !== undefined
877
+ ? (reuse.chain !== undefined && reuse.chain.length > 0 ? [...reuse.chain] : [payloadHash])
878
+ : payload?.delta === undefined
879
+ ? [payloadHash]
880
+ : [payloadHash, ...payload.delta.baseChain]
838
881
  const entry: RollbackLedgerManifestEntry = {
839
882
  id: record.id,
840
883
  kind: record.kind,
@@ -848,7 +891,7 @@ export class RollbackLedger {
848
891
  turnSeq: record.turnSeq,
849
892
  boundarySeq: record.boundarySeq,
850
893
  recordBytes: byteLength(recordJson),
851
- workspace: { ...trackHeader(workspaceTrack), payload: payloadHash, payloadBytes },
894
+ workspace: { ...trackHeader(workspaceTrack), payload: payloadHash, payloadBytes, ...(payloadChain === undefined ? {} : { payloadChain }) },
852
895
  session: { ...trackHeader(sessionTrack), object: sessionTrack.payload?.object ?? null },
853
896
  segments: summary.segments,
854
897
  }
@@ -875,7 +918,12 @@ export class RollbackLedger {
875
918
  }
876
919
 
877
920
  /** 读校验过的载荷(哈希不符 / 形状不符 -> error,绝不返回半个) */
878
- private async readVerifiedPayload(hash: string): Promise<{ payload?: RollbackWorkspacePayload; error?: string }> {
921
+ /**
922
+ * **原始**读取一份载荷(形状校验 + 内容寻址自证)。
923
+ * `files` 可能只是「相对基准的增量子集」(`payload.delta` 非空)—— **只给解析器用**,
924
+ * 消费者一律走 {@link resolvePayload} 拿物化后的完整清单。
925
+ */
926
+ private async readStoredPayload(hash: string): Promise<{ payload?: RollbackWorkspacePayload; error?: string }> {
879
927
  const target = this.payloadPath(hash)
880
928
  if (target === null) return { error: 'record-incomplete: payload ref invalid' }
881
929
  let raw: string
@@ -892,11 +940,60 @@ export class RollbackLedger {
892
940
  } catch (error) {
893
941
  return { error: 'record-incomplete: payload unreadable (' + errorDetail(error) + ')' }
894
942
  }
895
- const payload = normalizeWorkspacePayload(parsed)
943
+ const payload = normalizeStoredPayload(parsed)
896
944
  if (payload === null) return { error: 'record-incomplete: payload shape invalid' }
897
945
  return { payload }
898
946
  }
899
947
 
948
+
949
+ /**
950
+ * 解析一份载荷:增量视图**物化**成完整 `files`(含 `object` 字段补回),全量视图原样返回。
951
+ *
952
+ * 任何一环不成立(基准缺失 / 哈希不符 / 形状不符 / 链成环)⇒ **如实失败**:绝不返回半个清单 ——
953
+ * 半份清单会让「留点后新建」的判据把绝大多数文件当成新建的,回退就会删掉它们。
954
+ */
955
+ async resolvePayload(hash: string): Promise<{ payload?: RollbackWorkspacePayload; error?: string }> {
956
+ // **不缓存**:载荷被删/被改必须立刻看得见(缓存会把「存储已经坏了」蒙过去),
957
+ // 也守住「一次只驻留一份载荷」的内存护栏(issue #89 的 OOM 现场)。
958
+ return this.materializePayload(hash, new Set([hash]))
959
+ }
960
+
961
+ private async materializePayload(
962
+ hash: string,
963
+ seen: Set<string>,
964
+ ): Promise<{ payload?: RollbackWorkspacePayload; error?: string }> {
965
+ const stored = await this.readStoredPayload(hash)
966
+ if (stored.payload === undefined) return { error: stored.error ?? 'record-incomplete: payload unreadable' }
967
+ const payload = stored.payload
968
+ const delta = payload.delta
969
+ if (delta === undefined) return { payload: withObjectRefs(payload) }
970
+ if (typeof delta.base !== 'string' || !OBJECT_KEY_RE.test(delta.base)) {
971
+ return { error: 'record-incomplete: delta base ref invalid' }
972
+ }
973
+ if (seen.has(delta.base)) return { error: 'record-incomplete: delta chain cycle' }
974
+ const next = new Set(seen)
975
+ next.add(delta.base)
976
+ const base = await this.materializePayload(delta.base, next)
977
+ if (base.payload === undefined) {
978
+ return { error: 'record-incomplete: delta base unresolved (' + String(base.error ?? '') + ')' }
979
+ }
980
+ const removed = new Set<string>()
981
+ for (const p of Array.isArray(delta.removed) ? delta.removed : []) if (typeof p === 'string') removed.add(p)
982
+ const added = withObjectRefs({ ...payload, files: delta.added ?? [], delta: undefined }).files
983
+ const addedPaths = new Set<string>()
984
+ for (const file of added) if (typeof file?.path === 'string') addedPaths.add(file.path)
985
+ const files: RollbackWorkspaceFile[] = []
986
+ for (const file of base.payload.files) {
987
+ if (typeof file?.path !== 'string') continue
988
+ if (removed.has(file.path) || addedPaths.has(file.path)) continue
989
+ files.push(file)
990
+ }
991
+ for (const file of added) files.push(file)
992
+ // 与全量捕获同序(`payload.files.sort` 用的就是这个比较器)
993
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
994
+ return { payload: { ...payload, files, delta: undefined } }
995
+ }
996
+
900
997
  /** 读一条**完整**记录(引用解析对调用方透明);任何一步不成立就如实失败 */
901
998
  async readRecord(id: string): Promise<{ record?: RollbackPointRecord; error?: string }> {
902
999
  const target = this.recordPath(id)
@@ -923,7 +1020,7 @@ export class RollbackLedger {
923
1020
  if (typeof payloadRef !== 'string' || !OBJECT_KEY_RE.test(payloadRef)) {
924
1021
  return { error: 'record-incomplete: payload ref invalid' }
925
1022
  }
926
- const resolved = await this.readVerifiedPayload(payloadRef)
1023
+ const resolved = await this.resolvePayload(payloadRef)
927
1024
  if (resolved.payload === undefined) return { error: resolved.error ?? 'record-incomplete: payload unreadable' }
928
1025
  payload = resolved.payload
929
1026
  }
@@ -985,6 +1082,12 @@ export class RollbackLedger {
985
1082
  async prunePayloads(entries: readonly RollbackLedgerManifestEntry[]): Promise<{ removed: number; bytes: number }> {
986
1083
  const referenced = new Set<string>()
987
1084
  for (const entry of entries) {
1085
+ // 增量载荷依赖基准 ⇒ **按链保活**(否则淘汰/删点会把基准删掉,那个点就永远读不回来了)
1086
+ const chain = entry.workspace.payloadChain
1087
+ if (Array.isArray(chain) && chain.length > 0) {
1088
+ for (const hash of chain) referenced.add(hash)
1089
+ continue
1090
+ }
988
1091
  if (entry.workspace.payload !== null) referenced.add(entry.workspace.payload)
989
1092
  }
990
1093
  let names: string[]
@@ -1037,7 +1140,7 @@ export class RollbackLedger {
1037
1140
  for (const entry of entries) {
1038
1141
  const hash = entry.workspace.payload
1039
1142
  if (hash !== null) {
1040
- const resolved = await this.readVerifiedPayload(hash)
1143
+ const resolved = await this.resolvePayload(hash)
1041
1144
  if (resolved.payload === undefined) {
1042
1145
  return { removed: 0, bytes: 0, referencedPaths, aborted: resolved.error ?? 'payload unreadable' }
1043
1146
  }
@@ -1305,6 +1408,19 @@ function trackHeader(track: {
1305
1408
  }
1306
1409
  }
1307
1410
 
1411
+ /**
1412
+ * 补回**落盘时省掉的** `object` 字段:内容寻址的名字就是内容哈希 ⇒ `object ≡ sha256`。
1413
+ * 缺 `sha256` 的畸形条目保持原样(消费侧的防御判据照旧生效)。
1414
+ */
1415
+ function withObjectRefs(payload: RollbackWorkspacePayload): RollbackWorkspacePayload {
1416
+ for (const file of payload.files) {
1417
+ if (file === null || typeof file !== 'object') continue
1418
+ if (typeof file.object === 'string') continue
1419
+ if (typeof file.sha256 === 'string') file.object = file.sha256
1420
+ }
1421
+ return payload
1422
+ }
1423
+
1308
1424
  function errorDetail(error: unknown): string {
1309
1425
  if (error instanceof Error) {
1310
1426
  const code = (error as { code?: unknown }).code