@jjh32456/dsh-longlive 1.6.8 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. package/CHANGELOG.md +69 -1539
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +33 -12
  4. package/README.en.md +17 -7
  5. package/README.md +29 -10
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +17 -12
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +3 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +143 -37
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +66 -29
  16. package/docs/MIGRATION-1.6.0.md +2 -2
  17. package/docs/OPERATIONS.md +6 -6
  18. package/docs/PANEL-INTEGRATION.md +6 -0
  19. package/docs/PRIVACY.md +88 -0
  20. package/docs/QUICKSTART.md +5 -5
  21. package/docs/README.en.md +3 -2
  22. package/docs/RESIDENT-WATCHER.md +1 -1
  23. package/docs/SESSION-WATCH.md +6 -6
  24. package/docs/TROUBLESHOOTING.md +4 -4
  25. package/package.json +42 -117
  26. package/runbook.md +2 -2
  27. package/scripts/backfill-auto-cards.mjs +98 -0
  28. package/scripts/install-surgery-task.ps1 +5 -2
  29. package/scripts/lib/session-watch-fixture.mjs +1 -1
  30. package/scripts/longlive-doctor.mjs +6 -0
  31. package/scripts/migrate-180.mjs +163 -0
  32. package/scripts/recall-index-build.mjs +128 -0
  33. package/scripts/session-watch-drill.mjs +1 -1
  34. package/scripts/surgery-run.cmd +1 -1
  35. package/scripts/unattended-surgery.ps1 +5 -2
  36. package/scripts/verify-card-anchors.mjs +138 -0
  37. package/src/actions/audit.js +125 -0
  38. package/src/actions/evidence.js +122 -0
  39. package/src/actions/fs.js +162 -0
  40. package/src/activity/index.js +129 -18
  41. package/src/activity/model.js +11 -1
  42. package/src/activity/store.js +22 -3
  43. package/src/activity/thinking.js +176 -0
  44. package/src/adapters/constants.js +11 -0
  45. package/src/adapters/engram/data/aliases.json +51 -0
  46. package/src/adapters/engram/data/policy.json +8 -0
  47. package/src/adapters/engram/data/routing-rules.json +546 -0
  48. package/src/adapters/engram/index.js +235 -0
  49. package/src/adapters/registry.js +315 -0
  50. package/src/answer/audit.js +193 -0
  51. package/src/answer/evidence.js +30 -0
  52. package/src/answer/guard.js +1370 -0
  53. package/src/answer/index.js +1067 -0
  54. package/src/answer/query.js +111 -0
  55. package/src/answer/second-hop.mjs +101 -0
  56. package/src/auto-truncate.js +1 -1
  57. package/src/config.js +145 -48
  58. package/src/deps.js +6 -4
  59. package/src/features.js +158 -8
  60. package/src/identity/index.js +13 -5
  61. package/src/identity/inject-position.js +2 -2
  62. package/src/index.js +97 -7
  63. package/src/lifecycle/gap-clocks.js +328 -0
  64. package/src/lifecycle/index.js +269 -0
  65. package/src/memory/activity-capture.js +229 -0
  66. package/src/memory/anchors.js +194 -0
  67. package/src/memory/audit-records.js +222 -0
  68. package/src/memory/auto-cards.js +401 -0
  69. package/src/memory/capture.js +442 -0
  70. package/src/memory/card-anchors.js +203 -0
  71. package/src/memory/conflicts.js +106 -0
  72. package/src/memory/decay.js +311 -0
  73. package/src/memory/distill.js +471 -0
  74. package/src/memory/event-index.js +917 -0
  75. package/src/memory/evidence-chain.js +295 -0
  76. package/src/memory/evidence.js +145 -0
  77. package/src/memory/filter-runner.js +1 -1
  78. package/src/memory/filter.js +5 -1
  79. package/src/memory/forget-v2.js +410 -0
  80. package/src/memory/forget.js +71 -0
  81. package/src/memory/frames-activation.js +247 -0
  82. package/src/memory/fts-query.js +174 -0
  83. package/src/memory/governance.js +604 -0
  84. package/src/memory/index.js +1 -0
  85. package/src/memory/l2-gate.js +169 -0
  86. package/src/memory/lock.js +121 -0
  87. package/src/memory/migrate.js +139 -0
  88. package/src/memory/recall-v2.js +223 -0
  89. package/src/memory/recall.js +65 -2
  90. package/src/memory/records.js +425 -0
  91. package/src/memory/retention.js +128 -0
  92. package/src/memory/review.js +124 -0
  93. package/src/memory/trust.js +88 -0
  94. package/src/memory/version-chain.js +264 -0
  95. package/src/memory/wal.js +137 -0
  96. package/src/memory/working-frame.js +516 -0
  97. package/src/metrics/classify.mjs +5 -3
  98. package/src/metrics/index.mjs +21 -1
  99. package/src/metrics/prestep-latency.mjs +300 -0
  100. package/src/metrics/store.mjs +25 -0
  101. package/src/policy-profile.js +18 -3
  102. package/src/recall/anaphora.mjs +87 -0
  103. package/src/recall/cards.mjs +7 -0
  104. package/src/recall/embedding.mjs +8 -8
  105. package/src/recall/entities.mjs +112 -0
  106. package/src/recall/index.js +235 -19
  107. package/src/recall/inject-order.mjs +10 -8
  108. package/src/recall/privacy.mjs +1 -1
  109. package/src/recall/recall-core.mjs +116 -10
  110. package/src/recall/shared-visibility.js +302 -0
  111. package/src/recall/time-normalizer.mjs +196 -0
  112. package/src/role/activation.js +191 -0
  113. package/src/role/attention.js +318 -0
  114. package/src/role/base.js +36 -0
  115. package/src/role/channels.js +423 -0
  116. package/src/role/index.js +425 -0
  117. package/src/role/interfaces.js +115 -0
  118. package/src/role/personality.js +370 -0
  119. package/src/role/readers.js +151 -0
  120. package/src/role/relationships.js +477 -0
  121. package/src/role/store.js +202 -0
  122. package/src/role/writeprotect.js +373 -0
  123. package/src/router/aliases.json +176 -25
  124. package/src/router/background/agent-runner.mjs +1 -1
  125. package/src/router/background/anchor.mjs +1 -1
  126. package/src/router/background/audit.mjs +2 -2
  127. package/src/router/background/budget-ledger.mjs +2 -2
  128. package/src/router/background/host-sim.mjs +1 -1
  129. package/src/router/background/memory.mjs +4 -4
  130. package/src/router/background/queue.mjs +1 -1
  131. package/src/router/background/retry.mjs +1 -1
  132. package/src/router/background/router.mjs +1 -1
  133. package/src/router/background/stream.mjs +1 -1
  134. package/src/router/background/tool-policy.mjs +1 -1
  135. package/src/router/design-headings.json +5 -5
  136. package/src/router/face/config.mjs +1 -1
  137. package/src/router/face/identity.mjs +3 -3
  138. package/src/router/judge.mjs +8 -7
  139. package/src/router/policies/generic.json +21 -4
  140. package/src/router/route.js +109 -22
  141. package/src/router/router.mjs +10 -6
  142. package/src/router/routing-rules.json +260 -445
  143. package/src/router/run-sim.mjs +8 -8
  144. package/src/router/runtime.js +23 -12
  145. package/src/router/service/config.mjs +5 -5
  146. package/src/router/service/context.mjs +1 -1
  147. package/src/router/service/queue.mjs +1 -1
  148. package/src/router/shadow.mjs +4 -3
  149. package/src/surgery-recovery.js +2 -2
  150. package/src/tool-output.js +2 -2
  151. package/src/tools.js +39 -7
  152. package/src/truncate-visible.js +2 -2
  153. package/src/ui/actions.js +1312 -0
  154. package/src/ui/panel-api.js +367 -0
  155. package/src/ui/panel-auth.js +251 -0
  156. package/src/ui/panel-integrity.js +243 -0
  157. package/src/ui/routes-readonly.js +695 -0
  158. package/src/ui/routes.js +3 -0
  159. package/docs/DEV.md +0 -279
  160. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  161. package/docs/PRESENCE-BUS.md +0 -694
  162. package/docs/SURGERY.md +0 -186
  163. package/scripts/mark-core-cards.mjs +0 -289
  164. package/scripts/pack.mjs +0 -392
  165. package/scripts/router-proposals.mjs +0 -386
  166. package/src/router/fixtures/M1.json +0 -16
  167. package/src/router/fixtures/M2.json +0 -16
  168. package/src/router/fixtures/M3.json +0 -20
  169. package/src/router/fixtures/M4.json +0 -20
  170. package/src/router/fixtures/M5.json +0 -45
  171. package/src/router/fixtures/M6.json +0 -53
  172. package/src/router/fixtures/M7.json +0 -46
  173. package/src/router/fixtures/S1.json +0 -20
  174. package/src/router/fixtures/S2.json +0 -23
  175. package/src/router/fixtures/S3.json +0 -20
  176. package/src/router/fixtures/S4.json +0 -34
  177. package/src/router/fixtures/S5.json +0 -43
  178. package/src/router/fixtures/S6.json +0 -47
  179. package/src/router/fixtures/T1.json +0 -23
  180. package/src/router/fixtures/T2.json +0 -16
  181. package/src/router/fixtures/T3.json +0 -19
  182. package/src/router/fixtures/T4.json +0 -16
  183. package/src/router/fixtures/X1.json +0 -17
  184. package/src/router/fixtures/X2.json +0 -20
  185. package/src/router/gen-design-headings.mjs +0 -107
  186. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  187. package/test/activity-realtime.test.mjs +0 -959
  188. package/test/activity-stream.test.mjs +0 -512
  189. package/test/auto-recall.test.mjs +0 -474
  190. package/test/auto-truncate.test.mjs +0 -931
  191. package/test/autobind.test.mjs +0 -399
  192. package/test/bus.test.mjs +0 -1572
  193. package/test/cli-guard.test.mjs +0 -198
  194. package/test/conclusion.test.mjs +0 -408
  195. package/test/config-schema.test.mjs +0 -210
  196. package/test/d6-output.test.mjs +0 -194
  197. package/test/d6.test.mjs +0 -395
  198. package/test/deps.test.mjs +0 -342
  199. package/test/doctor-setup.test.mjs +0 -297
  200. package/test/embedding-recall.test.mjs +0 -347
  201. package/test/feature-flags.test.mjs +0 -585
  202. package/test/filter-shadow.test.mjs +0 -170
  203. package/test/fixtures/activity-append-child.mjs +0 -29
  204. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  205. package/test/fixtures/d6-header-tools.json +0 -1
  206. package/test/fixtures/lock-child.mjs +0 -67
  207. package/test/fixtures/pre-step-container.mjs +0 -100
  208. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  209. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  210. package/test/fixtures/profile-session-longlive-config.json +0 -20
  211. package/test/fixtures/proposal-cas-child.mjs +0 -66
  212. package/test/host-engine-status.test.mjs +0 -307
  213. package/test/hot-truncate.test.mjs +0 -1349
  214. package/test/identity-card-marker.test.mjs +0 -307
  215. package/test/identity-router/run.mjs +0 -721
  216. package/test/integration-pre-step.test.mjs +0 -553
  217. package/test/live.test.mjs +0 -1023
  218. package/test/maintenance-schedule.test.mjs +0 -538
  219. package/test/memory.test.mjs +0 -2828
  220. package/test/metrics.test.mjs +0 -478
  221. package/test/orchestrator.test.mjs +0 -266
  222. package/test/p1_probe.mjs +0 -97
  223. package/test/packaging.test.mjs +0 -184
  224. package/test/portability.test.mjs +0 -343
  225. package/test/rc4-regressions.test.mjs +0 -562
  226. package/test/rc5-regressions.test.mjs +0 -197
  227. package/test/rc6-chinese.test.mjs +0 -394
  228. package/test/router-1100/apply-off.test.mjs +0 -175
  229. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  230. package/test/router-1100/config-context.test.mjs +0 -211
  231. package/test/router-1100/helpers.mjs +0 -129
  232. package/test/router-1100/hint-envelope.test.mjs +0 -150
  233. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  234. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  235. package/test/router-1100/router-apply.test.mjs +0 -372
  236. package/test/router-1100/router-fixb.test.mjs +0 -602
  237. package/test/router-1100/router-proposals.test.mjs +0 -405
  238. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  239. package/test/router-1100/router-security-cr.test.mjs +0 -333
  240. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  241. package/test/router-1100/tools-contract.test.mjs +0 -164
  242. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  243. package/test/router-1100/v6-contract.test.mjs +0 -158
  244. package/test/router-1100/v6b-contract.test.mjs +0 -293
  245. package/test/router-1100/v6c-contract.test.mjs +0 -249
  246. package/test/router-1100/wal-rotation.test.mjs +0 -375
  247. package/test/router-1100/wp1-config.test.mjs +0 -170
  248. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  249. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  250. package/test/router-1100/wp1-status.test.mjs +0 -220
  251. package/test/router-1100/wp1-tools.test.mjs +0 -146
  252. package/test/router-1100/wp5-cli.test.mjs +0 -240
  253. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  254. package/test/router-1100/wp6-merge.test.mjs +0 -386
  255. package/test/router-1100/wp6-wake.test.mjs +0 -502
  256. package/test/router-background/audit.test.mjs +0 -37
  257. package/test/router-background/budget-ledger.test.mjs +0 -330
  258. package/test/router-background/cross-process.test.mjs +0 -222
  259. package/test/router-background/fixtures/cross-child.mjs +0 -85
  260. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  261. package/test/router-background/router.test.mjs +0 -1076
  262. package/test/router-background/stream-retry.test.mjs +0 -358
  263. package/test/router-background/tool-agent.test.mjs +0 -384
  264. package/test/router-face/bridge.test.mjs +0 -169
  265. package/test/router-face/face.test.mjs +0 -316
  266. package/test/router-failclosed.test.mjs +0 -466
  267. package/test/router-identity.test.mjs +0 -384
  268. package/test/router-l1-l2.contract.test.mjs +0 -1075
  269. package/test/router-memory-tools.test.mjs +0 -262
  270. package/test/router-policy.test.mjs +0 -225
  271. package/test/router-runtime.test.mjs +0 -438
  272. package/test/router-shadow.test.mjs +0 -287
  273. package/test/router.test.mjs +0 -903
  274. package/test/run-rc.mjs +0 -315
  275. package/test/run.mjs +0 -2469
  276. package/test/safety-defaults.test.mjs +0 -181
  277. package/test/seeded-load.test.mjs +0 -436
  278. package/test/session-original.test.mjs +0 -338
  279. package/test/state-truth.test.mjs +0 -937
  280. package/test/surgery-recovery.test.mjs +0 -936
  281. package/test/t7_drive.mjs +0 -151
  282. package/test/tool-output.test.mjs +0 -393
  283. package/test/tool-schema.test.mjs +0 -177
  284. package/test/tools_contract.test.mjs +0 -716
  285. package/test/truncate-visible.test.mjs +0 -1150
  286. package/test/ui-flags.test.mjs +0 -477
  287. package/test/v4-hardening.test.mjs +0 -427
  288. package/test/version-consistency.test.mjs +0 -286
@@ -0,0 +1,1312 @@
1
+ // src/ui/actions.js — M2-G 面板动作面(preview → apply → audit → undo)
2
+ //
3
+ // 契约(MEMORY-RECALL-V2-PROPOSAL v2.6.3 §12.4 / §12.7):
4
+ // - 16 个动作名(唯一权威词表,未知动作 400 `E_ACTION_UNKNOWN`);
5
+ // - 四段:preview(60s TTL,不落库)→ apply(CAS + 幂等 + 私有目录执行 + HMAC 审计)
6
+ // → audit(GET 只读链)→ undo(24h 窗口、不可再 undo);
7
+ // - 待判队列状态机:pending→approved|rejected|returned → applied|failed → undone;
8
+ // pending→expired(超审批窗,保护态只读);expired→pending 仅人工 review.reopen;
9
+ // - 权限:HTTP/工具/CLI 同一 `actionGate()`(能力令牌 + actor 绑定 + subagent 403);
10
+ // - 完整性:每次动作前跑 `integrity.checkBeforeAction()`,失败 401 `E_PANEL_INTEGRITY`;
11
+ // - evidence 私有写:`.longlive/evidence/latest.json`,仅宿主签名可写;
12
+ // - 面板未装/无有效令牌 → 动作队列 `paused`,禁止自动 apply(R12)。
13
+ //
14
+ // 所有落盘都在宿主私有目录 `<memoryRoot>/.longlive/` 下,绝不写 profile/第三方目录。
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ import { hashActorId } from '../actions/audit.js';
20
+ import {
21
+ actionsDir,
22
+ newId,
23
+ readJsonFile,
24
+ readJsonl,
25
+ sha256Hex,
26
+ stableStringify,
27
+ writeJsonAtomic,
28
+ writeJsonlAtomic,
29
+ } from '../actions/fs.js';
30
+ import { EvidenceError } from '../actions/evidence.js';
31
+ import { RELATIONSHIP_TIERS, readRelationships, restoreRelationship, updateRelationship } from '../role/relationships.js';
32
+ import { readClaims } from '../memory/governance.js';
33
+
34
+ /** The only 18 action names the panel may send (§12.7-G7; relationship.update @ v2.8 §16.3). */
35
+ export const ACTION_NAMES = Object.freeze([
36
+ 'approve',
37
+ 'reject',
38
+ 'return',
39
+ 'promote',
40
+ 'merge',
41
+ 'distill',
42
+ 'archive',
43
+ 'restore',
44
+ 'undo',
45
+ 'apply',
46
+ 'annotate',
47
+ 'dry-run',
48
+ 'distill.rollback',
49
+ 'protection.add',
50
+ 'protection.remove',
51
+ 'review.reopen',
52
+ 'conflict.arbitrate',
53
+ 'relationship.update',
54
+ ]);
55
+
56
+ export const REVIEW_STATES = Object.freeze([
57
+ 'pending', 'approved', 'rejected', 'returned', 'applied', 'failed', 'undone', 'expired',
58
+ ]);
59
+
60
+ export const PREVIEW_TTL_MS = 60_000;
61
+ export const UNDO_TTL_MS = 24 * 60 * 60 * 1000;
62
+ export const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
63
+ export const IDEMPOTENCY_MAX = 1000;
64
+ export const REVIEW_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
65
+ export const ACTIONS_SCHEMA = 'actions/v1';
66
+ export const REVIEW_SCHEMA = 'review/v1';
67
+
68
+ /** Actions that may not be undone (§12.7-G7). */
69
+ const NOT_UNDOABLE = new Set(['restore', 'undo', 'annotate', 'dry-run', 'distill.rollback', 'review.reopen']);
70
+
71
+ /** reason must be ≥10 chars for the human-decided actions. */
72
+ const REASON_ACTIONS = new Set(['reject', 'return', 'distill.rollback', 'protection.add', 'protection.remove', 'review.reopen', 'relationship.update']);
73
+
74
+ export class PanelActionError extends Error {
75
+ constructor(code, message, status = 400, details = undefined) {
76
+ super(message);
77
+ this.name = 'PanelActionError';
78
+ this.code = code;
79
+ this.status = status;
80
+ if (details !== undefined) this.details = details;
81
+ }
82
+ }
83
+
84
+ function nowIso(clock) {
85
+ try {
86
+ return clock().toISOString();
87
+ } catch {
88
+ return new Date().toISOString();
89
+ }
90
+ }
91
+
92
+ function hashOf(value) {
93
+ return sha256Hex(stableStringify(value ?? null));
94
+ }
95
+
96
+ function requireObject(value, label) {
97
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
98
+ throw new PanelActionError('E_PARAMS', `${label} 必须是对象。`, 422);
99
+ }
100
+ }
101
+
102
+ function requireString(value, label, min = 1) {
103
+ if (typeof value !== 'string' || value.length < min) {
104
+ throw new PanelActionError('E_PARAMS', `${label} 缺失或长度不足(≥${min})。`, 422);
105
+ }
106
+ return value;
107
+ }
108
+
109
+ function parseVersion(value) {
110
+ if (value === undefined || value === null) return 0;
111
+ if (!Number.isInteger(value) || value < 0) {
112
+ throw new PanelActionError('E_PARAMS', 'expectedVersion 必须是非负整数。', 422);
113
+ }
114
+ return value;
115
+ }
116
+
117
+ /** Validate params for one action. Throws `E_PARAMS` / `E_TARGET_*` per §12.7/§12.10-R7. */
118
+ export function validateActionParams(action, target, params = {}) {
119
+ if (!ACTION_NAMES.includes(action)) {
120
+ throw new PanelActionError('E_ACTION_UNKNOWN', `未知动作:${action}`, 400);
121
+ }
122
+ requireObject(target ?? {}, 'target');
123
+ requireObject(params, 'params');
124
+ const p = params;
125
+ if (REASON_ACTIONS.has(action)) {
126
+ if (typeof p.reason !== 'string' || p.reason.trim().length < 10) {
127
+ throw new PanelActionError('E_PARAMS', `${action} 需要 reason(≥10 字)。`, 422);
128
+ }
129
+ }
130
+ switch (action) {
131
+ case 'approve':
132
+ case 'reject':
133
+ case 'return':
134
+ requireString(p.itemId ?? target.id, `${action}.itemId`);
135
+ break;
136
+ case 'review.reopen':
137
+ requireString(p.reviewId ?? target.id, 'review.reopen.reviewId');
138
+ break;
139
+ case 'promote':
140
+ requireString(p.distillId ?? target.id, 'promote.distillId');
141
+ requireString(p.targetKind, 'promote.targetKind');
142
+ break;
143
+ case 'merge': {
144
+ const eventIds = p.eventIds ?? target.eventIds;
145
+ if (!Array.isArray(eventIds) || eventIds.length < 2 || eventIds.length > 10) {
146
+ throw new PanelActionError('E_TARGET_EVENTIDS', 'merge.eventIds 必须是 2..10 个事件 id。', 422);
147
+ }
148
+ const primaryId = p.primaryId ?? target.primaryId;
149
+ if (typeof primaryId !== 'string' || !eventIds.includes(primaryId)) {
150
+ throw new PanelActionError('E_TARGET_EVENTIDS', 'merge.primaryId 必须是 eventIds 之一。', 422);
151
+ }
152
+ break;
153
+ }
154
+ case 'distill': {
155
+ const sourceIds = p.sourceIds ?? target.sourceIds;
156
+ if (!Array.isArray(sourceIds) || sourceIds.length < 1 || sourceIds.length > 20) {
157
+ throw new PanelActionError('E_PARAMS', 'distill.sourceIds 必须是 1..20 个来源 id。', 422);
158
+ }
159
+ if (p.mode !== undefined && p.mode !== 'extractive') {
160
+ throw new PanelActionError('E_PARAMS', 'distill.mode 只支持 extractive。', 422);
161
+ }
162
+ break;
163
+ }
164
+ case 'archive': {
165
+ const recordId = p.recordId ?? target.recordId ?? target.id;
166
+ if (typeof recordId !== 'string' || recordId === '') {
167
+ throw new PanelActionError('E_TARGET_INCOMPLETE', 'archive.recordId 缺失(面板按钮禁用)。', 422);
168
+ }
169
+ const report = p.archiveReport ?? target.archiveReport;
170
+ if (report === undefined) {
171
+ throw new PanelActionError('E_TARGET_INCOMPLETE', 'archive.archiveReport 缺失(三问未填)。', 422);
172
+ }
173
+ for (const key of ['reason', 'affects', 'stillUseful', 'recoverable']) {
174
+ if (report?.[key] === undefined || report?.[key] === null || report?.[key] === '') {
175
+ throw new PanelActionError('E_ARCHIVE_REPORT_INCOMPLETE', `archiveReport.${key} 缺失。`, 422);
176
+ }
177
+ }
178
+ if (typeof report.recoverable !== 'boolean') {
179
+ throw new PanelActionError('E_ARCHIVE_REPORT_INCOMPLETE', 'archiveReport.recoverable 必须是布尔值。', 422);
180
+ }
181
+ break;
182
+ }
183
+ case 'restore':
184
+ requireString(p.archiveId ?? target.id, 'restore.archiveId');
185
+ break;
186
+ case 'undo':
187
+ requireString(p.actionId ?? target.id, 'undo.actionId');
188
+ return; // undo endpoint path; no CAS on target
189
+ case 'apply':
190
+ requireString(p.previewId ?? target.previewId, 'apply.previewId');
191
+ break;
192
+ case 'annotate':
193
+ requireString(p.note, 'annotate.note');
194
+ if (p.note.length > 500) throw new PanelActionError('E_PARAMS', 'annotate.note 超过 500 字。', 422);
195
+ if (p.visibility !== undefined && !['panel', 'audit'].includes(p.visibility)) {
196
+ throw new PanelActionError('E_PARAMS', 'annotate.visibility 只能是 panel|audit。', 422);
197
+ }
198
+ break;
199
+ case 'dry-run':
200
+ if (typeof p.action !== 'string' || !ACTION_NAMES.includes(p.action)) {
201
+ throw new PanelActionError('E_ACTION_UNKNOWN', 'dry-run.action 必须是已知动作。', 400);
202
+ }
203
+ break;
204
+ case 'distill.rollback':
205
+ requireString(p.distillId ?? target.id, 'distill.rollback.distillId');
206
+ break;
207
+ case 'protection.add':
208
+ requireString(p.id ?? target.id, 'protection.add.id');
209
+ requireString(p.kind ?? target.kind ?? 'record', 'protection.add.kind');
210
+ break;
211
+ case 'protection.remove':
212
+ requireString(p.id ?? target.id, 'protection.remove.id');
213
+ break;
214
+ case 'conflict.arbitrate': {
215
+ requireString(p.claimA ?? target.claimA, 'conflict.arbitrate.claimA');
216
+ requireString(p.claimB ?? target.claimB, 'conflict.arbitrate.claimB');
217
+ const resolution = p.resolution ?? target.resolution;
218
+ if (!['preferA', 'preferB', 'both', 'disputed'].includes(resolution)) {
219
+ throw new PanelActionError('E_PARAMS', 'conflict.arbitrate.resolution 必须是 preferA|preferB|both|disputed。', 422);
220
+ }
221
+ const expectedVersion = p.expectedVersion ?? target.expectedVersion;
222
+ if (!Number.isInteger(expectedVersion)) {
223
+ throw new PanelActionError('E_PARAMS', 'conflict.arbitrate 需要 expectedVersion(CAS 版本号;无版本源=422)。', 422);
224
+ }
225
+ break;
226
+ }
227
+ case 'relationship.update': {
228
+ requireString(p.personId ?? target.personId ?? target.id, 'relationship.update.personId');
229
+ const tier = p.tier ?? target.tier;
230
+ if (!RELATIONSHIP_TIERS.includes(tier)) {
231
+ throw new PanelActionError('E_PARAMS', `relationship.update.tier 必须是 ${RELATIONSHIP_TIERS.join('|')}。`, 422);
232
+ }
233
+ break;
234
+ }
235
+ default:
236
+ break;
237
+ }
238
+ }
239
+
240
+ /** Initial review row schema is §12.7-G1; incomplete/unknown rows stay visible but flagged. */
241
+ export function normalizeReviewRow(row) {
242
+ const rawState = typeof row?.state === 'string' && row.state !== '' ? row.state : 'pending';
243
+ return {
244
+ id: typeof row?.id === 'string' ? row.id : '',
245
+ kind: typeof row?.kind === 'string' ? row.kind : 'memory',
246
+ salience: Number.isInteger(row?.salience) ? row.salience : 3,
247
+ lastUsed: row?.lastUsed ?? null,
248
+ target: row?.target ?? null,
249
+ // Unknown states are preserved (never silently coerced): transitions on
250
+ // them are rejected + audited (§12.4 "未知状态=FAIL").
251
+ state: rawState,
252
+ expiry: row?.expiry ?? null,
253
+ decision: row?.decision ?? null,
254
+ createdAt: row?.createdAt ?? null,
255
+ source: row?.source ?? null,
256
+ version: Number.isInteger(row?.version) ? row.version : 1,
257
+ completeness: row?.target && typeof row.target === 'object' && row.target.version !== undefined ? 'complete' : 'partial',
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Create the action service.
263
+ *
264
+ * @param {object} options
265
+ * @param {string} options.memoryRoot
266
+ * @param {ReturnType<import('./panel-auth.js').createPanelAuth>} options.auth
267
+ * @param {ReturnType<import('./panel-integrity.js').createIntegrityGuard>} options.integrity
268
+ * @param {ReturnType<import('../actions/audit.js').createAuditLog>} options.audit
269
+ * @param {ReturnType<import('../actions/evidence.js').createEvidenceWriter>} [options.evidence]
270
+ * @param {() => Date} [options.now]
271
+ */
272
+ export function createActionService(options = {}) {
273
+ const memoryRoot = String(options.memoryRoot ?? '');
274
+ const auth = options.auth;
275
+ const integrity = options.integrity;
276
+ const audit = options.audit;
277
+ const evidence = options.evidence;
278
+ // Role-layer data files are sealed with the host auditKey when the service has
279
+ // one (panel path); otherwise the role layer falls back to the host key file.
280
+ const roleAuditKey = options.auditKey ?? null;
281
+ const now = typeof options.now === 'function' ? options.now : () => new Date();
282
+ // P1-2: all TTLs (preview 60s / undo 24h / review 7d) are enforced on a
283
+ // monotonic clock anchored at service boot. A wall-clock rollback cannot
284
+ // revive an expired preview/undo, and a forward jump cannot shorten or
285
+ // extend an existing TTL. Wall time is only used for display/audit stamps,
286
+ // plus the lastSeenWall watermark for rollback detection.
287
+ const monoNow = typeof options.monoNow === 'function'
288
+ ? options.monoNow
289
+ : () => {
290
+ const [seconds, nanoseconds] = process.hrtime();
291
+ return seconds * 1000 + nanoseconds / 1e6;
292
+ };
293
+ const CLOCK_TOLERANCE_MS = Number.isFinite(options.clockToleranceMs) && options.clockToleranceMs >= 0
294
+ ? options.clockToleranceMs
295
+ : 5000;
296
+ const bootMono = monoNow();
297
+ const base = path.join(String(memoryRoot ?? ''), '.longlive');
298
+ const reviewFile = path.join(base, 'review', 'queue.jsonl');
299
+ const stateFile = path.join(base, 'actions', 'state.json');
300
+ const protectionFile = path.join(base, 'protected.json');
301
+ const conflictStateFile = path.join(base, 'conflicts', 'state.json');
302
+ const idempotencyFile = path.join(base, 'actions', 'idempotency.jsonl');
303
+ const queueFile = path.join(base, 'actions', 'queue.jsonl');
304
+ const annotationsFile = path.join(base, 'annotations.jsonl');
305
+ const distillDir = path.join(base, 'distill');
306
+ const tombstonesDir = path.join(base, 'archive', 'tombstones');
307
+
308
+ /** previewId → { action, target, params, diff, risks, expiresAt, createdAt } */
309
+ const previews = new Map();
310
+ /** idempotencyKey → { at, result } */
311
+ const idempotency = new Map();
312
+
313
+ function loadIdempotency() {
314
+ const read = readJsonl(idempotencyFile);
315
+ for (const row of read.rows) {
316
+ if (typeof row?.key === 'string' && row.result !== undefined) {
317
+ idempotency.set(row.key, { at: Number(row.at) || 0, result: row.result });
318
+ }
319
+ }
320
+ }
321
+ loadIdempotency();
322
+
323
+ function readState() {
324
+ const read = readJsonFile(stateFile);
325
+ const state = read.ok && read.value && typeof read.value === 'object' ? read.value : {};
326
+ return {
327
+ v: 1,
328
+ records: state.records && typeof state.records === 'object' ? state.records : {},
329
+ distills: state.distills && typeof state.distills === 'object' ? state.distills : {},
330
+ archives: state.archives && typeof state.archives === 'object' ? state.archives : {},
331
+ actionRecords: state.actionRecords && typeof state.actionRecords === 'object' ? state.actionRecords : {},
332
+ protectionVersion: Number.isInteger(state.protectionVersion) ? state.protectionVersion : 1,
333
+ lastSeenWallMs: Number.isFinite(state.lastSeenWallMs) ? state.lastSeenWallMs : 0,
334
+ };
335
+ }
336
+
337
+ function writeState(state) {
338
+ writeJsonAtomic(stateFile, {
339
+ v: 1,
340
+ ...state,
341
+ lastSeenWallMs: Math.max(Number(state.lastSeenWallMs) || 0, lastSeenWallMs),
342
+ updatedAt: nowIso(now),
343
+ });
344
+ }
345
+
346
+ // Boot anchor: persisted lastSeen wall watermark (from the previous boot) and
347
+ // the current wall time; effective wall = anchor + monotonic elapsed.
348
+ let lastSeenWallMs = 0;
349
+ let bootWallMs = 0;
350
+ {
351
+ const initial = readState();
352
+ bootWallMs = Math.max(Number(initial.lastSeenWallMs) || 0, now().getTime());
353
+ lastSeenWallMs = bootWallMs;
354
+ }
355
+
356
+ function monoElapsedMs() {
357
+ const value = monoNow();
358
+ return Number.isFinite(value) ? Math.max(0, value - bootMono) : 0;
359
+ }
360
+
361
+ /** Wall time that cannot be moved by setting the OS clock. */
362
+ function effectiveWallMs() {
363
+ return bootWallMs + monoElapsedMs();
364
+ }
365
+
366
+ /**
367
+ * Persisted wall watermark vs current wall: an obvious rollback fails closed
368
+ * (E_CLOCK_ROLLBACK) and is audited. Small NTP corrections within tolerance
369
+ * are accepted; monotonic TTLs make them harmless anyway.
370
+ */
371
+ function clockGuard() {
372
+ const wall = now().getTime();
373
+ if (Number.isFinite(wall) && lastSeenWallMs > 0 && wall + CLOCK_TOLERANCE_MS < lastSeenWallMs) {
374
+ try {
375
+ appendAudit('clock.rollback', { kind: 'clock', id: 'system-wall' }, { lastSeenWallMs }, { wall, rejected: true });
376
+ } catch {
377
+ /* audit best effort; still fail closed */
378
+ }
379
+ throw new PanelActionError(
380
+ 'E_CLOCK_ROLLBACK',
381
+ `检测到系统时钟回拨(now=${new Date(wall).toISOString()} < lastSeen=${new Date(lastSeenWallMs).toISOString()}):动作已拒绝并审计。`,
382
+ 409,
383
+ { wall, lastSeenWallMs },
384
+ );
385
+ }
386
+ if (Number.isFinite(wall) && wall > lastSeenWallMs) lastSeenWallMs = wall;
387
+ return wall;
388
+ }
389
+
390
+ function readReview() {
391
+ const read = readJsonl(reviewFile);
392
+ return { rows: read.rows.map(normalizeReviewRow), bad: read.bad };
393
+ }
394
+
395
+ function writeReview(rows) {
396
+ writeJsonlAtomic(reviewFile, rows);
397
+ }
398
+
399
+ function currentReviewState(row) {
400
+ if (row.state === 'pending' && row.expiry !== null && row.expiry !== undefined) {
401
+ const expiryMs = Date.parse(String(row.expiry));
402
+ // effectiveWallMs() is boot-anchored + monotonic: setting the OS clock
403
+ // back does not un-expire a review item, and a forward jump does not
404
+ // expire it early.
405
+ if (Number.isFinite(expiryMs) && effectiveWallMs() > expiryMs) return 'expired';
406
+ }
407
+ return row.state;
408
+ }
409
+
410
+ function findReviewRow(id) {
411
+ const { rows } = readReview();
412
+ const index = rows.findIndex((row) => row.id === id);
413
+ return { rows, index, row: index >= 0 ? rows[index] : null };
414
+ }
415
+
416
+ function readProtection() {
417
+ const read = readJsonFile(protectionFile);
418
+ const value = read.ok && read.value && typeof read.value === 'object' ? read.value : {};
419
+ return {
420
+ version: Number.isInteger(value.version) ? value.version : 1,
421
+ default: value.default === 'protect' ? 'protect' : (value.default ?? 'protect'),
422
+ entries: Array.isArray(value.entries) ? value.entries : [],
423
+ };
424
+ }
425
+
426
+ function writeProtection(protection) {
427
+ writeJsonAtomic(protectionFile, {
428
+ version: Number.isInteger(protection.version) ? protection.version : 1,
429
+ default: protection.default ?? 'protect',
430
+ entries: protection.entries ?? [],
431
+ updatedAt: nowIso(now),
432
+ });
433
+ }
434
+
435
+ function conflictKey(claimA, claimB) {
436
+ return [String(claimA), String(claimB)].sort().join('|');
437
+ }
438
+
439
+ function readConflictState() {
440
+ const read = readJsonFile(conflictStateFile);
441
+ const value = read.ok && read.value && typeof read.value === 'object' ? read.value : {};
442
+ return {
443
+ version: Number.isInteger(value.version) ? value.version : 0,
444
+ items: value.items && typeof value.items === 'object' ? value.items : {},
445
+ };
446
+ }
447
+
448
+ function writeConflictState(state) {
449
+ writeJsonAtomic(conflictStateFile, {
450
+ version: Number.isInteger(state.version) ? state.version : 1,
451
+ items: state.items ?? {},
452
+ updatedAt: nowIso(now),
453
+ });
454
+ }
455
+
456
+ function currentConflictVersion(claimA, claimB) {
457
+ const state = readConflictState();
458
+ const item = state.items[conflictKey(claimA, claimB)];
459
+ return item && Number.isInteger(item.version) ? item.version : 0;
460
+ }
461
+
462
+ function resolveConflictEffect(claimA, claimB, resolution, actor) {
463
+ const claims = readClaims({ memoryRoot, actor: { kind: 'host' } }).items;
464
+ const hitA = claims.find((claim) => claim.id === claimA);
465
+ const hitB = claims.find((claim) => claim.id === claimB);
466
+ if (!hitA || !hitB) {
467
+ throw new PanelActionError('E_TARGET_INCOMPLETE', `冲突双方必须都是已存在 Claim(${claimA} / ${claimB})。`, 404);
468
+ }
469
+ const state = readConflictState();
470
+ const key = conflictKey(claimA, claimB);
471
+ const previous = state.items[key] ?? null;
472
+ const next = {
473
+ claimA,
474
+ claimB,
475
+ resolution,
476
+ // 仲裁 ≠ 验证:这里只记录仲裁结论,绝不把 claims.state 改成 verified。
477
+ arbitrationOnly: true,
478
+ version: (previous && Number.isInteger(previous.version) ? previous.version : 0) + 1,
479
+ resolvedAt: nowIso(now),
480
+ actor: actor ?? 'host',
481
+ history: [
482
+ ...((previous && Array.isArray(previous.history)) ? previous.history.slice(-19) : []),
483
+ { resolution, at: nowIso(now) },
484
+ ],
485
+ };
486
+ state.items[key] = next;
487
+ state.version = (Number.isInteger(state.version) ? state.version : 0) + 1;
488
+ writeConflictState(state);
489
+ return { key, previous, next };
490
+ }
491
+
492
+ function auditTarget(target) {
493
+ return {
494
+ kind: target?.kind ?? null,
495
+ id: target?.id ?? null,
496
+ version: target?.version ?? null,
497
+ };
498
+ }
499
+
500
+ function appendAudit(action, target, before, after) {
501
+ return audit.append({
502
+ actorId: hashActorId('host'),
503
+ action,
504
+ target: auditTarget(target),
505
+ beforeHash: hashOf(before),
506
+ afterHash: hashOf(after),
507
+ });
508
+ }
509
+
510
+ function gate(request) {
511
+ if (!auth || typeof auth.actionGate !== 'function') {
512
+ throw new PanelActionError('E_NO_CAP', '宿主权限门未装配。', 401);
513
+ }
514
+ let verdict;
515
+ try {
516
+ verdict = auth.actionGate({
517
+ source: request?.source ?? 'http',
518
+ headers: request?.headers ?? {},
519
+ actorKind: request?.actorKind ?? 'host',
520
+ });
521
+ } catch (error) {
522
+ throw new PanelActionError(error.code ?? 'E_NO_CAP', error.message ?? '权限校验失败。', error.status ?? 401);
523
+ }
524
+ // P1-2: wall-clock rollback fails closed before any state change (and is
525
+ // audited); forward jumps never shorten/extend monotonic TTLs.
526
+ clockGuard();
527
+ if (integrity !== undefined && typeof integrity.checkBeforeAction === 'function') {
528
+ try {
529
+ integrity.checkBeforeAction();
530
+ } catch (error) {
531
+ throw new PanelActionError('E_PANEL_INTEGRITY', error.message ?? '面板完整性校验失败。', 401, error.statusPayload);
532
+ }
533
+ }
534
+ // P1-2: actor 必须由宿主令牌注入;缺省拒绝,绝不回退成 'host' 默认值。
535
+ if (typeof verdict?.actor !== 'string' || verdict.actor === '') {
536
+ throw new PanelActionError('E_ACTOR_REQUIRED', 'actor 必须由宿主注入(令牌绑定),缺省拒绝。', 401);
537
+ }
538
+ return { actor: verdict.actor, source: verdict.source ?? 'http' };
539
+ }
540
+
541
+ function idempotentReplay(key) {
542
+ if (typeof key !== 'string' || key === '') return null;
543
+ const hit = idempotency.get(key);
544
+ if (!hit) return null;
545
+ // Monotonic when the entry was recorded in this process; wall clock is only
546
+ // a fallback for rows loaded from a previous boot.
547
+ const expired = Number.isFinite(hit.atMono)
548
+ ? monoNow() - hit.atMono > IDEMPOTENCY_TTL_MS
549
+ : now().getTime() - hit.at > IDEMPOTENCY_TTL_MS;
550
+ if (expired) {
551
+ idempotency.delete(key);
552
+ return null;
553
+ }
554
+ return { ...hit.result, replayed: true };
555
+ }
556
+
557
+ function rememberIdempotent(key, result) {
558
+ if (typeof key !== 'string' || key === '') return;
559
+ const at = now().getTime();
560
+ const atMono = monoNow();
561
+ idempotency.set(key, { at, atMono, result });
562
+ try {
563
+ writeJsonAtomic(idempotencyFile, {
564
+ key,
565
+ at,
566
+ atMono,
567
+ result,
568
+ });
569
+ } catch {
570
+ /* best effort; memory copy still prevents in-process replay */
571
+ }
572
+ if (idempotency.size > IDEMPOTENCY_MAX) {
573
+ const oldest = [...idempotency.entries()].sort((a, b) => (a[1].atMono ?? a[1].at) - (b[1].atMono ?? b[1].at));
574
+ for (const [oldKey] of oldest.slice(0, idempotency.size - IDEMPOTENCY_MAX)) idempotency.delete(oldKey);
575
+ }
576
+ }
577
+
578
+ function recordAction(record) {
579
+ const state = readState();
580
+ state.actionRecords[record.actionId] = record;
581
+ writeState(state);
582
+ }
583
+
584
+ function readAction(actionId) {
585
+ const state = readState();
586
+ return state.actionRecords[actionId] ?? null;
587
+ }
588
+
589
+ function planDiffFor(action, target, params) {
590
+ const before = {
591
+ action,
592
+ target: auditTarget(target),
593
+ params: { ...params },
594
+ };
595
+ const after = {
596
+ action,
597
+ effect: describeEffect(action, target, params),
598
+ };
599
+ const risks = [];
600
+ if (['archive', 'promote', 'merge', 'distill'].includes(action)) risks.push('会改写记忆状态(可通过 undo 回滚)');
601
+ if (action === 'protection.remove') risks.push('保护名单移除(重要事件永禁移除)');
602
+ if (action === 'review.reopen') risks.push('仅允许 expired→pending');
603
+ return { diff: { before, after }, risks };
604
+ }
605
+
606
+ function describeEffect(action, target, params) {
607
+ switch (action) {
608
+ case 'approve': return { reviewState: 'approved', itemId: params.itemId ?? target.id };
609
+ case 'reject': return { reviewState: 'rejected', itemId: params.itemId ?? target.id };
610
+ case 'return': return { reviewState: 'returned', itemId: params.itemId ?? target.id };
611
+ case 'review.reopen': return { reviewState: 'pending', reviewId: params.reviewId ?? target.id };
612
+ case 'promote': return { promote: params.distillId ?? target.id, targetKind: params.targetKind };
613
+ case 'merge': return { merge: params.eventIds ?? target.eventIds, primaryId: params.primaryId ?? target.primaryId };
614
+ case 'distill': return { distill: params.sourceIds ?? target.sourceIds, mode: 'extractive' };
615
+ case 'archive': return { archive: params.recordId ?? target.recordId ?? target.id };
616
+ case 'restore': return { restore: params.archiveId ?? target.id };
617
+ case 'apply': return { apply: params.previewId ?? target.previewId };
618
+ case 'annotate': return { annotate: target?.id ?? null, visibility: params.visibility ?? 'panel' };
619
+ case 'distill.rollback': return { rollback: params.distillId ?? target.id };
620
+ case 'protection.add': return { protection: 'add', id: params.id ?? target.id };
621
+ case 'protection.remove': return { protection: 'remove', id: params.id ?? target.id };
622
+ case 'conflict.arbitrate': return { conflict: [params.claimA, params.claimB].sort().join('|'), resolution: params.resolution, arbitrationOnly: true };
623
+ case 'relationship.update': return {
624
+ relationship: {
625
+ personId: params.personId ?? target.personId ?? target.id,
626
+ tier: params.tier ?? target.tier,
627
+ },
628
+ };
629
+ case 'undo': return { undo: params.actionId ?? target.id };
630
+ case 'dry-run': return { dryRun: params.action };
631
+ default: return { noop: action };
632
+ }
633
+ }
634
+
635
+ function currentVersionOf(action, target, params) {
636
+ const id = params[idFieldFor(action)] ?? target?.id;
637
+ if (['approve', 'reject', 'return', 'review.reopen'].includes(action)) {
638
+ const { row } = findReviewRow(typeof id === 'string' ? id : '');
639
+ return row ? row.version : undefined;
640
+ }
641
+ if (action === 'protection.add' || action === 'protection.remove') {
642
+ const protection = readProtection();
643
+ const entryId = params.id ?? target?.id;
644
+ const hit = protection.entries.find((entry) => entry.id === entryId);
645
+ return hit ? (entryVersion(hit)) : protection.version;
646
+ }
647
+ if (['promote', 'merge', 'distill', 'archive', 'restore'].includes(action)) {
648
+ const state = readState();
649
+ const recordId = params[idFieldFor(action)] ?? target?.id;
650
+ const record = state.records[recordId] ?? state.distills[params.distillId ?? target?.id] ?? null;
651
+ if (record !== null) return Number.isInteger(record.version) ? record.version : 1;
652
+ return Number.isInteger(target?.version) ? (target.version - 1) : 0;
653
+ }
654
+ if (action === 'conflict.arbitrate') {
655
+ const claimA = params.claimA ?? target?.claimA;
656
+ const claimB = params.claimB ?? target?.claimB;
657
+ return currentConflictVersion(claimA, claimB);
658
+ }
659
+ if (action === 'relationship.update') {
660
+ const personId = params.personId ?? target?.personId ?? target?.id;
661
+ // 同一张关系图可能由 panel auditKey 或 host key file 封签;CAS 读要透传服务 key,缺 key 时仍 fail-safe。
662
+ const graph = readRelationships({ memoryRoot, auditKey: roleAuditKey });
663
+ const node = graph.nodes.find((item) => item.personId === String(personId ?? ''));
664
+ return node ? node.version : 0;
665
+ }
666
+ if (action === 'annotate') {
667
+ // annotate does not change the record state; CAS is against the version
668
+ // the panel saw in `target.version`.
669
+ return Number.isInteger(target?.version) ? target.version : 0;
670
+ }
671
+ return Number.isInteger(target?.version) ? target.version : 0;
672
+ }
673
+
674
+ function idFieldFor(action) {
675
+ switch (action) {
676
+ case 'approve':
677
+ case 'reject':
678
+ case 'return': return 'itemId';
679
+ case 'review.reopen': return 'reviewId';
680
+ case 'promote': return 'distillId';
681
+ case 'distill': return 'sourceIds';
682
+ case 'archive': return 'recordId';
683
+ case 'restore': return 'archiveId';
684
+ case 'distill.rollback': return 'distillId';
685
+ case 'protection.add':
686
+ case 'protection.remove': return 'id';
687
+ case 'conflict.arbitrate': return 'claimA';
688
+ case 'relationship.update': return 'personId';
689
+ case 'annotate': return 'targetId';
690
+ default: return 'id';
691
+ }
692
+ }
693
+
694
+ function entryVersion(entry) {
695
+ return Number.isInteger(entry?.version) ? entry.version : 1;
696
+ }
697
+
698
+ function assertCas(action, target, params, expectedVersion) {
699
+ const current = currentVersionOf(action, target, params);
700
+ if (Number.isInteger(current) && expectedVersion !== current) {
701
+ throw new PanelActionError('E_CAS_CONFLICT', `版本冲突:expectedVersion=${expectedVersion} 当前=${current}`, 409, { current, expectedVersion });
702
+ }
703
+ return current;
704
+ }
705
+
706
+ function applyEffect(action, target, params, actor) {
707
+ const timestamp = nowIso(now);
708
+ switch (action) {
709
+ case 'approve':
710
+ case 'reject':
711
+ case 'return': {
712
+ const itemId = params.itemId ?? target.id;
713
+ const map = { approve: 'approved', reject: 'rejected', return: 'returned' };
714
+ const { rows, index, row } = findReviewRow(itemId);
715
+ if (index < 0) throw new PanelActionError('E_TARGET_INCOMPLETE', `review 条目不存在:${itemId}`, 404);
716
+ const currentState = currentReviewState(row);
717
+ if (!REVIEW_STATES.includes(currentState)) {
718
+ // Unknown state = FAIL: audit the rejection, never transition it.
719
+ appendAudit('review.invalid-state', target, { row }, { row, rejected: true });
720
+ throw new PanelActionError('E_ILLEGAL_STATE', `review 条目状态未知:${currentState}(已审计并拒绝)。`, 409, { state: currentState });
721
+ }
722
+ if (currentState !== 'pending') {
723
+ throw new PanelActionError('E_ILLEGAL_STATE', `review 条目状态 ${currentState} 不允许 ${action}(仅 pending)。`, 409, { state: currentState });
724
+ }
725
+ const before = { row: { ...row }, state: currentState };
726
+ rows[index] = {
727
+ ...row,
728
+ state: map[action],
729
+ version: row.version + 1,
730
+ decision: { by: actor, at: timestamp, reason: params.reason ?? '' },
731
+ };
732
+ writeReview(rows);
733
+ return { before, after: rows[index], version: rows[index].version, undoable: true };
734
+ }
735
+ case 'review.reopen': {
736
+ const reviewId = params.reviewId ?? target.id;
737
+ const { rows, index, row } = findReviewRow(reviewId);
738
+ if (index < 0) throw new PanelActionError('E_TARGET_INCOMPLETE', `review 条目不存在:${reviewId}`, 404);
739
+ const currentState = currentReviewState(row);
740
+ if (!REVIEW_STATES.includes(currentState)) {
741
+ appendAudit('review.invalid-state', target, { row }, { row, rejected: true });
742
+ throw new PanelActionError('E_ILLEGAL_STATE', `review 条目状态未知:${currentState}(已审计并拒绝)。`, 409, { state: currentState });
743
+ }
744
+ if (currentState !== 'expired') {
745
+ throw new PanelActionError('E_ILLEGAL_STATE', `review.reopen 只允许 expired→pending(当前 ${currentState})。`, 409, { state: currentState });
746
+ }
747
+ const before = { row: { ...row }, state: currentState };
748
+ rows[index] = {
749
+ ...row,
750
+ state: 'pending',
751
+ version: row.version + 1,
752
+ // Renewed window is anchored to the effective (monotonic) wall, so a
753
+ // wall-clock rollback cannot re-expire/re-open items by itself.
754
+ expiry: new Date(effectiveWallMs() + REVIEW_EXPIRY_MS).toISOString(),
755
+ decision: { by: actor, at: timestamp, reason: params.reason ?? '' },
756
+ };
757
+ writeReview(rows);
758
+ return { before, after: rows[index], version: rows[index].version, undoable: false };
759
+ }
760
+ case 'protect-add': // internal alias, never exposed
761
+ case 'protection.add': {
762
+ const entryId = params.id ?? target.id;
763
+ const protection = readProtection();
764
+ if (protection.entries.some((entry) => entry.id === entryId)) {
765
+ throw new PanelActionError('E_ILLEGAL_STATE', `保护名单已包含 ${entryId}。`, 409);
766
+ }
767
+ const before = { protection: { ...protection, entries: protection.entries.map((entry) => ({ ...entry })) } };
768
+ protection.entries.push({
769
+ id: entryId,
770
+ kind: params.kind ?? target.kind ?? 'record',
771
+ reason: params.reason,
772
+ by: actor,
773
+ at: timestamp,
774
+ version: 1,
775
+ salience: Number.isInteger(params.salience) ? params.salience : 3,
776
+ locked: params.locked === true,
777
+ });
778
+ protection.version += 1;
779
+ writeProtection(protection);
780
+ return { before, after: { protection }, version: protection.version, undoable: true };
781
+ }
782
+ case 'protection.remove': {
783
+ const entryId = params.id ?? target.id;
784
+ const protection = readProtection();
785
+ const hit = protection.entries.find((entry) => entry.id === entryId);
786
+ if (!hit) throw new PanelActionError('E_TARGET_INCOMPLETE', `保护名单不存在 ${entryId}。`, 404);
787
+ if (hit.salience === 5 || hit.locked === true || hit.important === true) {
788
+ throw new PanelActionError('E_PROTECTED', `重要事件永禁移除:${entryId}(salience==5/locked/important)。`, 409);
789
+ }
790
+ const before = { protection: { ...protection, entries: protection.entries.map((entry) => ({ ...entry })) } };
791
+ protection.entries = protection.entries.filter((entry) => entry.id !== entryId);
792
+ protection.version += 1;
793
+ writeProtection(protection);
794
+ return { before, after: { protection }, version: protection.version, undoable: true };
795
+ }
796
+ case 'archive': {
797
+ const state = readState();
798
+ const recordId = params.recordId ?? target.recordId ?? target.id;
799
+ const record = state.records[recordId];
800
+ if (!record) throw new PanelActionError('E_TARGET_INCOMPLETE', `记录不存在:${recordId}`, 404);
801
+ const report = params.archiveReport ?? target.archiveReport;
802
+ const before = { record: { ...record } };
803
+ const archiveId = newId('arc');
804
+ state.archives[archiveId] = {
805
+ id: archiveId,
806
+ recordId,
807
+ report,
808
+ state: 'archived',
809
+ createdAt: timestamp,
810
+ };
811
+ state.records[recordId] = { ...record, state: 'archived', version: (record.version ?? 1) + 1, archiveId };
812
+ writeState(state);
813
+ try {
814
+ writeJsonAtomic(path.join(tombstonesDir, `${archiveId}.json`), state.archives[archiveId]);
815
+ } catch {
816
+ /* private best effort; state is the source of truth for undo */
817
+ }
818
+ return { before, after: { record: state.records[recordId], archive: state.archives[archiveId] }, version: state.records[recordId].version, undoable: true, extra: { archiveId } };
819
+ }
820
+ case 'restore': {
821
+ const state = readState();
822
+ const archiveId = params.archiveId ?? target.id;
823
+ const archive = state.archives[archiveId];
824
+ if (!archive) throw new PanelActionError('E_TARGET_INCOMPLETE', `归档不存在:${archiveId}`, 404);
825
+ const record = state.records[archive.recordId];
826
+ if (!record) throw new PanelActionError('E_TARGET_INCOMPLETE', `归档指向的记录不存在:${archive.recordId}`, 404);
827
+ const before = { record: { ...record }, archive: { ...archive } };
828
+ state.records[archive.recordId] = { ...record, state: 'restored', version: (record.version ?? 1) + 1 };
829
+ state.archives[archiveId] = { ...archive, state: 'restored', restoredAt: timestamp };
830
+ writeState(state);
831
+ return { before, after: { record: state.records[archive.recordId], archive: state.archives[archiveId] }, version: state.records[archive.recordId].version, undoable: false };
832
+ }
833
+ case 'promote': {
834
+ const state = readState();
835
+ const distillId = params.distillId ?? target.id;
836
+ const distill = state.distills[distillId];
837
+ if (!distill) throw new PanelActionError('E_TARGET_INCOMPLETE', `distill 不存在:${distillId}`, 404);
838
+ const recordId = newId('rec');
839
+ const before = { record: null, distill: { ...distill } };
840
+ state.records[recordId] = {
841
+ id: recordId,
842
+ kind: params.targetKind ?? 'memory',
843
+ version: 1,
844
+ state: 'active',
845
+ derivedFrom: distillId,
846
+ createdAt: timestamp,
847
+ };
848
+ writeState(state);
849
+ return { before, after: { record: state.records[recordId] }, version: 1, undoable: true, extra: { recordId } };
850
+ }
851
+ case 'merge': {
852
+ const state = readState();
853
+ const eventIds = params.eventIds ?? target.eventIds;
854
+ const primaryId = params.primaryId ?? target.primaryId;
855
+ const recordId = newId('rec');
856
+ const before = { record: null };
857
+ state.records[recordId] = {
858
+ id: recordId,
859
+ kind: 'merge',
860
+ version: 1,
861
+ state: 'active',
862
+ eventIds,
863
+ primaryId,
864
+ createdAt: timestamp,
865
+ };
866
+ writeState(state);
867
+ return { before, after: { record: state.records[recordId] }, version: 1, undoable: true, extra: { recordId } };
868
+ }
869
+ case 'distill': {
870
+ const state = readState();
871
+ const sourceIds = params.sourceIds ?? target.sourceIds;
872
+ const distillId = newId('dst');
873
+ const payload = {
874
+ id: distillId,
875
+ sources: sourceIds,
876
+ mode: 'extractive',
877
+ derivedFrom: sourceIds,
878
+ version: 1,
879
+ createdAt: timestamp,
880
+ };
881
+ state.distills[distillId] = { ...payload, state: 'active' };
882
+ writeState(state);
883
+ try {
884
+ writeJsonAtomic(path.join(distillDir, `${distillId}.json`), payload);
885
+ } catch {
886
+ /* private best effort */
887
+ }
888
+ return { before: { distill: null }, after: { distill: state.distills[distillId] }, version: 1, undoable: true, extra: { distillId } };
889
+ }
890
+ case 'distill.rollback': {
891
+ const state = readState();
892
+ const distillId = params.distillId ?? target.id;
893
+ const distill = state.distills[distillId];
894
+ if (!distill) throw new PanelActionError('E_TARGET_INCOMPLETE', `distill 不存在:${distillId}`, 404);
895
+ const before = { distill: { ...distill } };
896
+ state.distills[distillId] = { ...distill, state: 'rolled_back', version: (distill.version ?? 1) + 1, rolledBackAt: timestamp };
897
+ writeState(state);
898
+ try {
899
+ fs.rmSync(path.join(distillDir, `${distillId}.json`), { force: true });
900
+ } catch {
901
+ /* private best effort */
902
+ }
903
+ return { before, after: { distill: state.distills[distillId] }, version: state.distills[distillId].version, undoable: false };
904
+ }
905
+ case 'annotate': {
906
+ const note = {
907
+ id: newId('ann'),
908
+ target: auditTarget(target),
909
+ note: params.note,
910
+ visibility: params.visibility ?? 'panel',
911
+ by: actor,
912
+ at: timestamp,
913
+ };
914
+ writeJsonAtomic(path.join(base, 'annotations.latest.json'), note);
915
+ try {
916
+ fs.appendFileSync(annotationsFile, `${JSON.stringify(note)}\n`, 'utf8');
917
+ } catch {
918
+ /* annotations are additive best effort */
919
+ }
920
+ return { before: { note: null }, after: { note }, version: null, undoable: false };
921
+ }
922
+ case 'apply': {
923
+ const state = readState();
924
+ const recordId = params.applyId ?? target.id;
925
+ const record = state.records[recordId];
926
+ if (!record) throw new PanelActionError('E_TARGET_INCOMPLETE', `待应用记录不存在:${recordId}`, 404);
927
+ const before = { record: { ...record } };
928
+ state.records[recordId] = { ...record, state: 'applied', version: (record.version ?? 1) + 1 };
929
+ writeState(state);
930
+ return { before, after: { record: state.records[recordId] }, version: state.records[recordId].version, undoable: true };
931
+ }
932
+ case 'conflict.arbitrate': {
933
+ const claimA = params.claimA ?? target.claimA;
934
+ const claimB = params.claimB ?? target.claimB;
935
+ const resolution = params.resolution ?? target.resolution;
936
+ const existing = readConflictState().items[conflictKey(claimA, claimB)];
937
+ const before = { conflict: existing ? { ...existing } : { claimA, claimB, resolution: null, version: 0 } };
938
+ const result = resolveConflictEffect(claimA, claimB, resolution, actor);
939
+ return {
940
+ before,
941
+ after: { conflict: result.next },
942
+ version: result.next.version,
943
+ undoable: true,
944
+ extra: { conflictKey: result.key, claimA, claimB },
945
+ };
946
+ }
947
+ case 'relationship.update': {
948
+ const personId = params.personId ?? target.personId ?? target.id;
949
+ const result = updateRelationship({
950
+ memoryRoot,
951
+ personId,
952
+ tier: params.tier ?? target.tier,
953
+ reason: params.reason,
954
+ expectedVersion: currentVersionOf('relationship.update', target, params),
955
+ actor,
956
+ appellations: params.appellations,
957
+ edges: params.edges,
958
+ now,
959
+ auditKey: roleAuditKey,
960
+ });
961
+ return {
962
+ before: { relationship: { personId, tier: result.previous.tier, version: result.previous.version } },
963
+ after: { relationship: { personId, tier: result.node.tier, version: result.node.version } },
964
+ version: result.node.version,
965
+ undoable: true,
966
+ extra: { personId, auditSeq: result.auditSeq },
967
+ };
968
+ }
969
+ case 'undo': {
970
+ throw new PanelActionError('E_ACTION_UNKNOWN', 'undo 请使用 POST /api/longlive/actions/undo。', 400);
971
+ }
972
+ default:
973
+ throw new PanelActionError('E_ACTION_UNKNOWN', `未知动作:${action}`, 400);
974
+ }
975
+ }
976
+
977
+ function inverseEffect(action, record) {
978
+ switch (action) {
979
+ case 'approve':
980
+ case 'reject':
981
+ case 'return': {
982
+ const itemId = record.params.itemId ?? record.target.id;
983
+ const { rows, index, row } = findReviewRow(itemId);
984
+ if (index < 0 || !row) throw new PanelActionError('E_TARGET_INCOMPLETE', `review 条目不存在:${itemId}`, 404);
985
+ const restoredState = record.effect.before.state;
986
+ rows[index] = {
987
+ ...row,
988
+ state: restoredState,
989
+ version: row.version + 1,
990
+ decision: { by: 'host', at: nowIso(now), reason: 'undo' },
991
+ };
992
+ writeReview(rows);
993
+ return { rows };
994
+ }
995
+ case 'protection.add': {
996
+ const entryId = record.params.id ?? record.target.id;
997
+ const protection = readProtection();
998
+ protection.entries = protection.entries.filter((entry) => entry.id !== entryId);
999
+ protection.version += 1;
1000
+ writeProtection(protection);
1001
+ return { protection };
1002
+ }
1003
+ case 'protection.remove': {
1004
+ const beforeEntries = record.effect.before.protection.entries;
1005
+ const protection = readProtection();
1006
+ protection.entries = beforeEntries;
1007
+ protection.version += 1;
1008
+ writeProtection(protection);
1009
+ return { protection };
1010
+ }
1011
+ case 'archive': {
1012
+ const state = readState();
1013
+ const archiveId = record.effect.after.archive.id;
1014
+ const archive = state.archives[archiveId];
1015
+ if (archive) {
1016
+ state.records[archive.recordId] = { ...record.effect.before.record, version: (record.effect.before.record.version ?? 1) + 1, state: 'restored' };
1017
+ state.archives[archiveId] = { ...archive, state: 'restored', restoredAt: nowIso(now) };
1018
+ writeState(state);
1019
+ try {
1020
+ fs.rmSync(path.join(tombstonesDir, `${archiveId}.json`), { force: true });
1021
+ } catch {
1022
+ /* best effort */
1023
+ }
1024
+ }
1025
+ return { state };
1026
+ }
1027
+ case 'promote':
1028
+ case 'merge': {
1029
+ const state = readState();
1030
+ const recordId = record.effect.extra?.recordId ?? record.effect.after.record?.id;
1031
+ if (recordId && state.records[recordId]) {
1032
+ state.records[recordId] = { ...state.records[recordId], state: 'rolled_back', version: (state.records[recordId].version ?? 1) + 1 };
1033
+ writeState(state);
1034
+ }
1035
+ return { state };
1036
+ }
1037
+ case 'distill': {
1038
+ const state = readState();
1039
+ const distillId = record.effect.extra?.distillId ?? record.effect.after.distill?.id;
1040
+ if (distillId && state.distills[distillId]) {
1041
+ state.distills[distillId] = { ...state.distills[distillId], state: 'undone', version: (state.distills[distillId].version ?? 1) + 1 };
1042
+ writeState(state);
1043
+ try {
1044
+ fs.rmSync(path.join(distillDir, `${distillId}.json`), { force: true });
1045
+ } catch {
1046
+ /* best effort */
1047
+ }
1048
+ }
1049
+ return { state };
1050
+ }
1051
+ case 'conflict.arbitrate': {
1052
+ const params = record.params ?? {};
1053
+ const claimA = params.claimA ?? record.target?.claimA;
1054
+ const claimB = params.claimB ?? record.target?.claimB;
1055
+ const before = record.effect?.before?.conflict ?? null;
1056
+ const state = readConflictState();
1057
+ const key = conflictKey(claimA, claimB);
1058
+ if (before && before.resolution) {
1059
+ state.items[key] = { ...before, version: (before.version ?? 0) + 1, resolvedAt: nowIso(now), actor: 'host', arbitrationOnly: true };
1060
+ } else {
1061
+ delete state.items[key];
1062
+ }
1063
+ state.version = (Number.isInteger(state.version) ? state.version : 0) + 1;
1064
+ writeConflictState(state);
1065
+ return { conflicts: state };
1066
+ }
1067
+ case 'relationship.update': {
1068
+ const params = record.params ?? {};
1069
+ const personId = params.personId ?? record.target?.personId ?? record.target?.id;
1070
+ const previousTier = record.effect?.before?.relationship?.tier ?? 'stranger';
1071
+ const restored = restoreRelationship({
1072
+ memoryRoot,
1073
+ personId,
1074
+ tier: previousTier,
1075
+ reason: '按审计记录回退 relationship.update(undo)。',
1076
+ expectedVersion: currentVersionOf('relationship.update', record.target ?? {}, params),
1077
+ actor: 'host',
1078
+ now,
1079
+ auditKey: roleAuditKey,
1080
+ });
1081
+ return { relationship: restored.node };
1082
+ }
1083
+ case 'restore':
1084
+ case 'annotate':
1085
+ case 'dry-run':
1086
+ case 'distill.rollback':
1087
+ case 'review.reopen':
1088
+ case 'undo':
1089
+ throw new PanelActionError('E_UNDO_NOT_ALLOWED', `${action} 不可 undo。`, 400);
1090
+ default:
1091
+ throw new PanelActionError('E_ACTION_UNKNOWN', `未知动作:${action}`, 400);
1092
+ }
1093
+ }
1094
+
1095
+ function preview(request, body = {}) {
1096
+ const identity = gate(request);
1097
+ const action = body.action;
1098
+ const target = body.target ?? {};
1099
+ const params = body.params ?? {};
1100
+ validateActionParams(action, target, params);
1101
+ const timestamp = nowIso(now);
1102
+ if (action === 'dry-run') {
1103
+ const plan = planDiffFor(params.action, target, params);
1104
+ const auditRecord = appendAudit('dry-run', target, plan.diff.before, plan.diff.after);
1105
+ return {
1106
+ ok: true,
1107
+ dryRun: true,
1108
+ action: params.action,
1109
+ diff: plan.diff,
1110
+ risks: plan.risks,
1111
+ auditSeq: auditRecord.seq,
1112
+ actor: identity.actor,
1113
+ };
1114
+ }
1115
+ const plan = planDiffFor(action, target, params);
1116
+ const currentVersion = currentVersionOf(action, target, params);
1117
+ const previewId = newId('pvw');
1118
+ const expiresMono = monoNow() + PREVIEW_TTL_MS;
1119
+ // Display-only wall timestamp derived from the boot-anchored effective wall.
1120
+ const expiresAt = new Date(effectiveWallMs() + PREVIEW_TTL_MS).toISOString();
1121
+ previews.set(previewId, {
1122
+ action,
1123
+ target,
1124
+ params,
1125
+ diff: plan.diff,
1126
+ risks: plan.risks,
1127
+ currentVersion,
1128
+ actor: identity.actor,
1129
+ createdAt: timestamp,
1130
+ expiresAt,
1131
+ expiresMono,
1132
+ });
1133
+ return {
1134
+ ok: true,
1135
+ previewId,
1136
+ action,
1137
+ diff: plan.diff,
1138
+ risks: plan.risks,
1139
+ expiresAt,
1140
+ actor: identity.actor,
1141
+ currentVersion,
1142
+ };
1143
+ }
1144
+
1145
+ function apply(request, body = {}) {
1146
+ const identity = gate(request);
1147
+ const replay = idempotentReplay(body.idempotencyKey);
1148
+ if (replay) return replay;
1149
+ const previewId = typeof body.previewId === 'string' ? body.previewId : '';
1150
+ const previewEntry = previews.get(previewId);
1151
+ if (!previewEntry) throw new PanelActionError('E_PREVIEW_UNKNOWN', 'previewId 不存在或已使用。', 409);
1152
+ // Monotonic TTL: wall-clock rollback cannot revive an expired preview and a
1153
+ // forward jump cannot expire it early.
1154
+ if (Number.isFinite(previewEntry.expiresMono) && monoNow() > previewEntry.expiresMono) {
1155
+ previews.delete(previewId);
1156
+ throw new PanelActionError('E_PREVIEW_EXPIRED', 'preview 已过期(60s TTL),请重新 preview。', 409);
1157
+ }
1158
+ previews.delete(previewId);
1159
+ const expectedVersion = parseVersion(body.expectedVersion);
1160
+ const { action, target, params } = previewEntry;
1161
+ if (action === 'undo') throw new PanelActionError('E_ACTION_UNKNOWN', 'undo 请使用 POST /api/longlive/actions/undo。', 400);
1162
+ assertCas(action, target, params, expectedVersion);
1163
+ let effect;
1164
+ try {
1165
+ effect = applyEffect(action, target, params, identity.actor);
1166
+ } catch (error) {
1167
+ if (error instanceof PanelActionError) throw error;
1168
+ throw new PanelActionError('E_ACTION_FAILED', error?.message ?? String(error), 500);
1169
+ }
1170
+ const actionId = newId('act');
1171
+ const auditRecord = appendAudit(action, target, effect.before, effect.after);
1172
+ const record = {
1173
+ actionId,
1174
+ action,
1175
+ target: auditTarget(target),
1176
+ before: effect.before,
1177
+ after: effect.after,
1178
+ effect,
1179
+ params,
1180
+ actor: identity.actor,
1181
+ state: 'applied',
1182
+ undoable: effect.undoable !== false && !NOT_UNDOABLE.has(action),
1183
+ undoExpiresMono: monoNow() + UNDO_TTL_MS,
1184
+ undoExpiresAt: new Date(effectiveWallMs() + UNDO_TTL_MS).toISOString(),
1185
+ auditSeq: auditRecord.seq,
1186
+ createdAt: nowIso(now),
1187
+ newVersion: effect.version,
1188
+ };
1189
+ recordAction(record);
1190
+ const result = {
1191
+ ok: true,
1192
+ actionId,
1193
+ status: 'applied',
1194
+ newVersion: record.newVersion,
1195
+ auditSeq: auditRecord.seq,
1196
+ replayed: false,
1197
+ actor: identity.actor,
1198
+ };
1199
+ rememberIdempotent(body.idempotencyKey, result);
1200
+ try {
1201
+ fs.appendFileSync(queueFile, `${JSON.stringify({ actionId, action, at: record.createdAt, status: 'applied', auditSeq: auditRecord.seq })}\n`, 'utf8');
1202
+ } catch {
1203
+ /* queue is a replay aid; audit is the source of truth */
1204
+ }
1205
+ return result;
1206
+ }
1207
+
1208
+ function undo(request, body = {}) {
1209
+ const identity = gate(request);
1210
+ const replay = idempotentReplay(body.idempotencyKey);
1211
+ if (replay) return replay;
1212
+ const actionId = typeof body.actionId === 'string' ? body.actionId : '';
1213
+ const record = readAction(actionId);
1214
+ if (!record) throw new PanelActionError('E_ACTION_UNKNOWN', `动作不存在:${actionId}`, 404);
1215
+ if (record.state === 'undone') throw new PanelActionError('E_ILLEGAL_STATE', '该动作已 undo 过(不可再 undo)。', 409);
1216
+ if (record.undoable !== true) throw new PanelActionError('E_UNDO_NOT_ALLOWED', `${record.action} 不可 undo。`, 400);
1217
+ // Monotonic undo window: wall-clock rollback cannot revive an expired undo.
1218
+ if (!Number.isFinite(record.undoExpiresMono) || monoNow() > record.undoExpiresMono) {
1219
+ throw new PanelActionError('E_UNDO_EXPIRED', 'undo 窗口已过(24h)。', 409);
1220
+ }
1221
+ let effect;
1222
+ try {
1223
+ effect = inverseEffect(record.action, record);
1224
+ } catch (error) {
1225
+ if (error instanceof PanelActionError) throw error;
1226
+ throw new PanelActionError('E_ACTION_FAILED', error?.message ?? String(error), 500);
1227
+ }
1228
+ const auditRecord = appendAudit('undo', record.target, { action: record.action }, { action: record.action, undone: true });
1229
+ const state = readState();
1230
+ state.actionRecords[actionId] = { ...record, state: 'undone', undoneAt: nowIso(now), undoAuditSeq: auditRecord.seq };
1231
+ writeState(state);
1232
+ const result = {
1233
+ ok: true,
1234
+ actionId,
1235
+ status: 'undone',
1236
+ auditSeq: auditRecord.seq,
1237
+ replayed: false,
1238
+ actor: identity.actor,
1239
+ undoEffect: effect,
1240
+ };
1241
+ rememberIdempotent(body.idempotencyKey, result);
1242
+ return result;
1243
+ }
1244
+
1245
+ function listActions(request, query = {}) {
1246
+ gate(request);
1247
+ const state = query.state;
1248
+ const { rows } = readReview();
1249
+ const normalized = rows.map((row) => ({ ...row, state: currentReviewState(row) }));
1250
+ const filtered = typeof state === 'string' && state !== '' ? normalized.filter((row) => row.state === state) : normalized;
1251
+ return { ok: true, schema: REVIEW_SCHEMA, items: filtered, total: filtered.length };
1252
+ }
1253
+
1254
+ function readAudit(request, query = {}) {
1255
+ gate(request);
1256
+ const rows = audit.readAll();
1257
+ const verified = audit.verify(rows.rows);
1258
+ const since = Number.isInteger(query.seqSince) ? query.seqSince : (Number.parseInt(String(query.seqSince ?? ''), 10) || 0);
1259
+ const entries = rows.rows.filter((row) => Number.isInteger(row?.seq) && row.seq > since);
1260
+ return {
1261
+ ok: true,
1262
+ schema: 'audit/v1',
1263
+ chainOk: verified.ok,
1264
+ brokenAt: verified.ok ? null : verified.at,
1265
+ entries,
1266
+ lastSeq: verified.ok ? verified.lastSeq : null,
1267
+ };
1268
+ }
1269
+
1270
+ function writeEvidence(request, pkg) {
1271
+ gate(request);
1272
+ if (!evidence || typeof evidence.submitSigned !== 'function') {
1273
+ throw new PanelActionError('E_NO_CAP', 'evidence 写入路径未装配。', 401);
1274
+ }
1275
+ const signature = hmacForEvidence(pkg);
1276
+ try {
1277
+ return evidence.submitSigned({ pkg, signature });
1278
+ } catch (error) {
1279
+ if (error instanceof EvidenceError) {
1280
+ throw new PanelActionError(error.code, error.message, error.status ?? 403);
1281
+ }
1282
+ throw error;
1283
+ }
1284
+ }
1285
+
1286
+ // Host-internal: evidence signing goes through the same in-memory key the
1287
+ // audit chain uses; the model-facing `submitSigned` path never sees it.
1288
+ function hmacForEvidence(pkg) {
1289
+ if (!evidence || typeof evidence.signPackage !== 'function') {
1290
+ throw new PanelActionError('E_NO_CAP', 'evidence 签名 key 不可用。', 401);
1291
+ }
1292
+ return evidence.signPackage(pkg);
1293
+ }
1294
+
1295
+ return {
1296
+ preview,
1297
+ apply,
1298
+ undo,
1299
+ listActions,
1300
+ readAudit,
1301
+ writeEvidence,
1302
+ // test seams
1303
+ previews,
1304
+ idempotency,
1305
+ reviewFile,
1306
+ queueFile,
1307
+ stateFile,
1308
+ now,
1309
+ previewTtlMs: PREVIEW_TTL_MS,
1310
+ undoWindowMs: UNDO_TTL_MS,
1311
+ };
1312
+ }