@piagent/platform 1.3.0 → 1.4.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 (304) hide show
  1. package/CHANGELOG.md +56 -3
  2. package/README.md +3 -3
  3. package/architecture/layers.json +96 -1
  4. package/docs/README.md +1 -0
  5. package/docs/architecture.md +1 -0
  6. package/docs/authority-manifest.md +1 -0
  7. package/docs/auto-delegation-policy.md +1 -0
  8. package/docs/capability-packs.md +10 -2
  9. package/docs/command-reference-vietnamese.md +5 -4
  10. package/docs/context-engine.md +1 -0
  11. package/docs/context-window-policy.md +1 -0
  12. package/docs/distribution-standard.md +8 -7
  13. package/docs/herdr-workflow.md +1 -0
  14. package/docs/mcp-and-tools.md +14 -9
  15. package/docs/memory-policy.md +1 -0
  16. package/docs/model-options.md +1 -0
  17. package/docs/oauth-providers.md +1 -0
  18. package/docs/operator-manual-vietnamese.md +15 -12
  19. package/docs/package-architecture-notes.md +1 -0
  20. package/docs/project-adapters.md +1 -0
  21. package/docs/project-onboarding.md +1 -0
  22. package/docs/publishing-for-teams.md +1 -0
  23. package/docs/quality-benchmark.md +1 -0
  24. package/docs/quickstart-vietnamese.md +3 -2
  25. package/docs/release-install-policy.md +11 -7
  26. package/docs/runtime-harness-standard.md +1 -0
  27. package/docs/runtime-policy-design.md +1 -0
  28. package/docs/runtime-quality-baseline.md +1 -0
  29. package/docs/security-redaction-benchmark.md +1 -0
  30. package/docs/security-threat-model.md +2 -1
  31. package/docs/subagent-orchestration-capabilities.md +1 -0
  32. package/docs/subagents-and-multiagent.md +1 -0
  33. package/docs/task-implementation-contract.md +1 -0
  34. package/docs/task-lifecycle-vietnamese.md +1 -0
  35. package/docs/team-onboarding.md +3 -2
  36. package/docs/third-party-neutrality.md +26 -0
  37. package/docs/usage-observability.md +1 -0
  38. package/docs/vercel-docs-site.md +1 -0
  39. package/docs/workflow-recipes.md +1 -0
  40. package/docs-site/architecture.html +1 -1
  41. package/docs-site/benchmark.html +1 -1
  42. package/docs-site/commands.html +1 -1
  43. package/docs-site/context-engine.html +1 -1
  44. package/docs-site/en/architecture.html +1 -1
  45. package/docs-site/en/benchmark.html +1 -1
  46. package/docs-site/en/commands.html +1 -1
  47. package/docs-site/en/context-engine.html +1 -1
  48. package/docs-site/en/index.html +1 -1
  49. package/docs-site/en/mcp-approval.html +1 -1
  50. package/docs-site/en/mcp-auth.html +1 -1
  51. package/docs-site/en/mcp-servers.html +1 -1
  52. package/docs-site/en/mcp.html +1 -1
  53. package/docs-site/en/permissions.html +1 -1
  54. package/docs-site/en/profiles.html +1 -1
  55. package/docs-site/en/quickstart.html +1 -1
  56. package/docs-site/en/runtime.html +1 -1
  57. package/docs-site/en/scope.html +1 -1
  58. package/docs-site/en/security.html +1 -1
  59. package/docs-site/en/team.html +1 -1
  60. package/docs-site/en/whats-new.html +1 -1
  61. package/docs-site/en/workflows.html +1 -1
  62. package/docs-site/index.html +1 -1
  63. package/docs-site/mcp-approval.html +1 -1
  64. package/docs-site/mcp-auth.html +1 -1
  65. package/docs-site/mcp-servers.html +1 -1
  66. package/docs-site/mcp.html +1 -1
  67. package/docs-site/permissions.html +1 -1
  68. package/docs-site/profiles.html +1 -1
  69. package/docs-site/quickstart.html +1 -1
  70. package/docs-site/runtime.html +1 -1
  71. package/docs-site/scope.html +1 -1
  72. package/docs-site/security.html +1 -1
  73. package/docs-site/team.html +1 -1
  74. package/docs-site/whats-new.html +1 -1
  75. package/docs-site/workflows.html +1 -1
  76. package/evals/fixtures/mutation-provenance-record.invalid.json +17 -0
  77. package/evals/fixtures/mutation-provenance-record.valid.json +27 -0
  78. package/evals/fixtures/piagent-webui/approval-v1.invalid.json +54 -0
  79. package/evals/fixtures/piagent-webui/approval-v1.valid.json +58 -0
  80. package/evals/fixtures/piagent-webui/attachment-v1.invalid.json +25 -0
  81. package/evals/fixtures/piagent-webui/attachment-v1.valid.json +25 -0
  82. package/evals/fixtures/piagent-webui/capabilities-v1.invalid.json +51 -0
  83. package/evals/fixtures/piagent-webui/capabilities-v1.valid.json +122 -0
  84. package/evals/fixtures/piagent-webui/commit-summary-v1.invalid.json +10 -0
  85. package/evals/fixtures/piagent-webui/commit-summary-v1.valid.json +25 -0
  86. package/evals/fixtures/piagent-webui/control-command-v1.invalid.json +37 -0
  87. package/evals/fixtures/piagent-webui/control-command-v1.valid.json +35 -0
  88. package/evals/fixtures/piagent-webui/diff-v1.invalid.json +13 -0
  89. package/evals/fixtures/piagent-webui/diff-v1.valid.json +141 -0
  90. package/evals/fixtures/piagent-webui/gateway-capabilities-v1.invalid.json +35 -0
  91. package/evals/fixtures/piagent-webui/gateway-capabilities-v1.valid.json +35 -0
  92. package/evals/fixtures/piagent-webui/gateway-protocol-v1.invalid.json +14 -0
  93. package/evals/fixtures/piagent-webui/gateway-protocol-v1.valid.json +14 -0
  94. package/evals/fixtures/piagent-webui/handoff-history-v1.invalid.json +16 -0
  95. package/evals/fixtures/piagent-webui/handoff-history-v1.valid.json +16 -0
  96. package/evals/fixtures/piagent-webui/model-catalog-v1.invalid.json +31 -0
  97. package/evals/fixtures/piagent-webui/model-catalog-v1.valid.json +43 -0
  98. package/evals/fixtures/piagent-webui/queue-v1.invalid.json +29 -0
  99. package/evals/fixtures/piagent-webui/queue-v1.valid.json +44 -0
  100. package/evals/fixtures/piagent-webui/recovery-history-v1.invalid.json +17 -0
  101. package/evals/fixtures/piagent-webui/recovery-history-v1.valid.json +20 -0
  102. package/evals/fixtures/piagent-webui/release-monitor-v1.invalid.json +12 -0
  103. package/evals/fixtures/piagent-webui/release-monitor-v1.valid.json +29 -0
  104. package/evals/fixtures/piagent-webui/review-state-v1.invalid.json +25 -0
  105. package/evals/fixtures/piagent-webui/review-state-v1.valid.json +38 -0
  106. package/evals/fixtures/piagent-webui/runtime-event-v2.invalid.json +49 -0
  107. package/evals/fixtures/piagent-webui/runtime-event-v2.valid.json +49 -0
  108. package/evals/fixtures/piagent-webui/session-catalog-v1.invalid.json +17 -0
  109. package/evals/fixtures/piagent-webui/session-catalog-v1.valid.json +58 -0
  110. package/evals/fixtures/piagent-webui/session-command-v1.invalid.json +19 -0
  111. package/evals/fixtures/piagent-webui/session-command-v1.valid.json +19 -0
  112. package/evals/fixtures/piagent-webui/snapshot-v1.invalid.json +5 -0
  113. package/evals/fixtures/piagent-webui/snapshot-v1.valid.json +191 -0
  114. package/evals/fixtures/piagent-webui/source-change-v1.invalid.json +57 -0
  115. package/evals/fixtures/piagent-webui/source-change-v1.valid.json +98 -0
  116. package/evals/fixtures/piagent-webui/source-mutation-v1.invalid.json +19 -0
  117. package/evals/fixtures/piagent-webui/source-mutation-v1.valid.json +38 -0
  118. package/evals/fixtures/piagent-webui/source-revert-v1.invalid.json +20 -0
  119. package/evals/fixtures/piagent-webui/source-revert-v1.valid.json +62 -0
  120. package/evals/fixtures/piagent-webui/subagent-tree-v1.invalid.json +26 -0
  121. package/evals/fixtures/piagent-webui/subagent-tree-v1.valid.json +39 -0
  122. package/evals/fixtures/piagent-webui/task-index-v1.invalid.json +15 -0
  123. package/evals/fixtures/piagent-webui/task-index-v1.valid.json +15 -0
  124. package/evals/fixtures/piagent-webui/task-timeline-v1.invalid.json +14 -0
  125. package/evals/fixtures/piagent-webui/task-timeline-v1.valid.json +16 -0
  126. package/evals/fixtures/piagent-webui/transcript-v1.invalid.json +35 -0
  127. package/evals/fixtures/piagent-webui/transcript-v1.valid.json +55 -0
  128. package/evals/fixtures/task-baseline-manifest.invalid.json +16 -0
  129. package/evals/fixtures/task-baseline-manifest.valid.json +41 -0
  130. package/evals/fixtures/verifier-file-snapshot.invalid.json +21 -0
  131. package/evals/fixtures/verifier-file-snapshot.valid.json +21 -0
  132. package/package.json +30 -3
  133. package/packages/piagent-core/benchmark/benchmark-candidate.d.ts +1 -0
  134. package/packages/piagent-core/benchmark/benchmark-forensics.d.ts +1 -0
  135. package/packages/piagent-core/benchmark/benchmark-ledger.d.ts +4 -0
  136. package/packages/piagent-core/benchmark/benchmark-record-validation.d.ts +1 -0
  137. package/packages/piagent-core/benchmark/benchmark-runtime.d.ts +1 -0
  138. package/packages/piagent-core/capabilities/capability-core.js +2 -2
  139. package/packages/piagent-core/capabilities/capability-verification-cache.js +280 -0
  140. package/packages/piagent-core/capabilities/runtime-integrity.js +153 -1
  141. package/packages/piagent-core/extensions/document-intake.ts +24 -10
  142. package/packages/piagent-core/extensions/piagent-guard.ts +90 -73
  143. package/packages/piagent-core/extensions/redaction-core.d.ts +2 -0
  144. package/packages/piagent-core/extensions/state-retention.js +77 -44
  145. package/packages/piagent-core/extensions/task-journal.js +30 -1
  146. package/packages/piagent-core/extensions/task-state.js +33 -36
  147. package/packages/piagent-core/mcp/mcp-server-catalog.js +5 -5
  148. package/packages/piagent-core/package.json +1 -1
  149. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -2
  150. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +5 -2
  151. package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +20 -19
  152. package/packages/piagent-core/runtime/hooks/tool-result-metadata.ts +13 -0
  153. package/packages/piagent-core/runtime/input/chat-images.ts +2 -2
  154. package/packages/piagent-core/runtime/inspection/activity-event-adapter.ts +94 -0
  155. package/packages/piagent-core/runtime/inspection/approval-broker.ts +318 -0
  156. package/packages/piagent-core/runtime/inspection/commit-summary-projection.ts +59 -0
  157. package/packages/piagent-core/runtime/inspection/context-telemetry-inspection.ts +37 -0
  158. package/packages/piagent-core/runtime/inspection/criteria-links.ts +295 -0
  159. package/packages/piagent-core/runtime/inspection/diff-projection.ts +499 -0
  160. package/packages/piagent-core/runtime/inspection/git-filter-safety.ts +38 -0
  161. package/packages/piagent-core/runtime/inspection/git-numstat-adapter.ts +71 -0
  162. package/packages/piagent-core/runtime/inspection/git-status-adapter.ts +493 -0
  163. package/packages/piagent-core/runtime/inspection/git-tree-adapter.ts +108 -0
  164. package/packages/piagent-core/runtime/inspection/mcp-control.ts +10 -0
  165. package/packages/piagent-core/runtime/inspection/mutation-provenance-contract.ts +131 -0
  166. package/packages/piagent-core/runtime/inspection/mutation-provenance-recorder.ts +48 -0
  167. package/packages/piagent-core/runtime/inspection/mutation-provenance-store.ts +161 -0
  168. package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +36 -0
  169. package/packages/piagent-core/runtime/inspection/review-state-contract.ts +108 -0
  170. package/packages/piagent-core/runtime/inspection/review-state-projection.ts +93 -0
  171. package/packages/piagent-core/runtime/inspection/review-state-store.ts +112 -0
  172. package/packages/piagent-core/runtime/inspection/runtime-event-store.ts +262 -0
  173. package/packages/piagent-core/runtime/inspection/same-process-bridge-proof.ts +103 -0
  174. package/packages/piagent-core/runtime/inspection/source-change-projection.ts +471 -0
  175. package/packages/piagent-core/runtime/inspection/source-evidence-contract.ts +139 -0
  176. package/packages/piagent-core/runtime/inspection/source-evidence-store.ts +315 -0
  177. package/packages/piagent-core/runtime/inspection/source-handoff-store.ts +70 -0
  178. package/packages/piagent-core/runtime/inspection/source-inspection-plan.ts +34 -0
  179. package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +164 -0
  180. package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +168 -0
  181. package/packages/piagent-core/runtime/inspection/source-open-target.ts +38 -0
  182. package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +147 -0
  183. package/packages/piagent-core/runtime/inspection/task-baseline-start-capture.ts +22 -0
  184. package/packages/piagent-core/runtime/inspection/task-compaction-history.ts +160 -0
  185. package/packages/piagent-core/runtime/inspection/task-control-journal.ts +131 -0
  186. package/packages/piagent-core/runtime/inspection/task-handoff-history.ts +95 -0
  187. package/packages/piagent-core/runtime/inspection/task-provenance-projection.ts +60 -0
  188. package/packages/piagent-core/runtime/inspection/task-recovery-timeline.ts +88 -0
  189. package/packages/piagent-core/runtime/inspection/task-run-index.ts +87 -0
  190. package/packages/piagent-core/runtime/inspection/task-source-projection.ts +401 -0
  191. package/packages/piagent-core/runtime/inspection/task-subagent-tree.ts +101 -0
  192. package/packages/piagent-core/runtime/inspection/text-diff.ts +60 -0
  193. package/packages/piagent-core/runtime/inspection/verifier-snapshot-contract.ts +114 -0
  194. package/packages/piagent-core/runtime/inspection/verifier-snapshot-store.ts +189 -0
  195. package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +320 -0
  196. package/packages/piagent-core/runtime/inspection/workspace-file-reader.ts +132 -0
  197. package/packages/piagent-core/runtime/inspection/workspace-source-projection.ts +96 -0
  198. package/packages/piagent-core/runtime/inspection/zero-turn-conformance.ts +175 -0
  199. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +68 -4
  200. package/packages/piagent-core/runtime/policy/source-index-transaction.ts +173 -0
  201. package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +42 -0
  202. package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +184 -0
  203. package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +105 -0
  204. package/packages/piagent-core/runtime/product/activity-inspector-footer.ts +5 -3
  205. package/packages/piagent-core/runtime/product/activity-inspector.ts +85 -343
  206. package/packages/piagent-core/runtime/recovery/resume-state.ts +38 -1
  207. package/packages/piagent-core/runtime/registration/activity-inspector-command.ts +68 -14
  208. package/packages/piagent-core/runtime/registration/task-start-tool.ts +13 -14
  209. package/packages/piagent-core/runtime/session/model-authorship-state.ts +31 -3
  210. package/packages/piagent-core/runtime/session/runtime-state.ts +11 -1
  211. package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +172 -0
  212. package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +1 -0
  213. package/packages/piagent-webui/dist/client/index.html +16 -0
  214. package/packages/piagent-webui/extension/attachment-store.ts +323 -0
  215. package/packages/piagent-webui/extension/held-message-queue.ts +311 -0
  216. package/packages/piagent-webui/extension/lifecycle-controller.ts +408 -0
  217. package/packages/piagent-webui/extension/lifecycle-event-adapter.ts +23 -0
  218. package/packages/piagent-webui/extension/piagent-webui.ts +470 -0
  219. package/packages/piagent-webui/extension/review-controller.ts +160 -0
  220. package/packages/piagent-webui/extension/same-session-bridge.ts +500 -0
  221. package/packages/piagent-webui/extension/session-options-controller.ts +281 -0
  222. package/packages/piagent-webui/extension/session-stream-adapter.ts +249 -0
  223. package/packages/piagent-webui/extension/source-mutation-controller.ts +215 -0
  224. package/packages/piagent-webui/extension/source-open-controller.ts +136 -0
  225. package/packages/piagent-webui/extension/source-revert-controller.ts +189 -0
  226. package/packages/piagent-webui/extension/terminal-session-adapter.ts +73 -0
  227. package/packages/piagent-webui/extension/vscode-handoff.ts +51 -0
  228. package/packages/piagent-webui/gateway/control-socket.ts +92 -0
  229. package/packages/piagent-webui/gateway/gateway-events.ts +60 -0
  230. package/packages/piagent-webui/gateway/gateway-protocol-service.ts +64 -0
  231. package/packages/piagent-webui/gateway/gateway-service.ts +215 -0
  232. package/packages/piagent-webui/gateway/gateway-session-stream.ts +110 -0
  233. package/packages/piagent-webui/gateway/mcp-auth-broker.ts +158 -0
  234. package/packages/piagent-webui/gateway/native-project-picker.ts +29 -0
  235. package/packages/piagent-webui/gateway/pi-host.ts +41 -0
  236. package/packages/piagent-webui/gateway/profile-state.ts +1 -0
  237. package/packages/piagent-webui/gateway/project-registry.ts +108 -0
  238. package/packages/piagent-webui/gateway/provider-auth-broker.ts +172 -0
  239. package/packages/piagent-webui/gateway/session-catalog.ts +128 -0
  240. package/packages/piagent-webui/gateway/session-command-controller.ts +237 -0
  241. package/packages/piagent-webui/gateway/session-command-store.ts +224 -0
  242. package/packages/piagent-webui/gateway/session-inspection-registry.ts +204 -0
  243. package/packages/piagent-webui/gateway/session-lease-store.ts +1 -0
  244. package/packages/piagent-webui/gateway/session-metadata-store.ts +152 -0
  245. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +485 -0
  246. package/packages/piagent-webui/ownership/gateway-runtime-context.ts +8 -0
  247. package/packages/piagent-webui/ownership/profile-state.ts +117 -0
  248. package/packages/piagent-webui/ownership/session-lease-store.ts +329 -0
  249. package/packages/piagent-webui/ownership/session-refs.ts +8 -0
  250. package/packages/piagent-webui/package.json +33 -0
  251. package/packages/piagent-webui/server/README.md +4 -0
  252. package/packages/piagent-webui/server/benchmark-release-monitor.ts +230 -0
  253. package/packages/piagent-webui/server/core-inspection-provider.ts +403 -0
  254. package/packages/piagent-webui/server/gateway-websocket.ts +143 -0
  255. package/packages/piagent-webui/server/http-security.ts +27 -0
  256. package/packages/piagent-webui/server/ipc-read-model-client.ts +89 -0
  257. package/packages/piagent-webui/server/loopback-server.ts +368 -0
  258. package/packages/piagent-webui/server/read-model-provider.ts +37 -0
  259. package/packages/piagent-webui/server/read-only-router.ts +105 -0
  260. package/packages/piagent-webui/server/session-auth.ts +88 -0
  261. package/packages/piagent-webui/server/sidecar-main.ts +86 -0
  262. package/packages/piagent-webui/server/sse-hub.ts +76 -0
  263. package/packages/piagent-webui/server/static-bundle.ts +40 -0
  264. package/packages/piagent-webui/server/transcript-projection.ts +110 -0
  265. package/schemas/mutation-provenance-record.schema.json +100 -0
  266. package/schemas/piagent-webui/approval-v1.schema.json +654 -0
  267. package/schemas/piagent-webui/attachment-v1.schema.json +267 -0
  268. package/schemas/piagent-webui/capabilities-v1.schema.json +729 -0
  269. package/schemas/piagent-webui/catalog-v1.json +240 -0
  270. package/schemas/piagent-webui/commit-summary-v1.schema.json +63 -0
  271. package/schemas/piagent-webui/common-v1.schema.json +479 -0
  272. package/schemas/piagent-webui/control-command-v1.schema.json +1780 -0
  273. package/schemas/piagent-webui/diff-v1.schema.json +826 -0
  274. package/schemas/piagent-webui/gateway-capabilities-v1.schema.json +136 -0
  275. package/schemas/piagent-webui/gateway-protocol-v1.schema.json +456 -0
  276. package/schemas/piagent-webui/handoff-history-v1.schema.json +92 -0
  277. package/schemas/piagent-webui/model-catalog-v1.schema.json +95 -0
  278. package/schemas/piagent-webui/queue-v1.schema.json +99 -0
  279. package/schemas/piagent-webui/recovery-history-v1.schema.json +144 -0
  280. package/schemas/piagent-webui/release-monitor-v1.schema.json +173 -0
  281. package/schemas/piagent-webui/review-state-v1.schema.json +206 -0
  282. package/schemas/piagent-webui/runtime-event-v2.schema.json +5206 -0
  283. package/schemas/piagent-webui/session-catalog-v1.schema.json +222 -0
  284. package/schemas/piagent-webui/session-command-v1.schema.json +390 -0
  285. package/schemas/piagent-webui/snapshot-v1.schema.json +2622 -0
  286. package/schemas/piagent-webui/source-change-v1.schema.json +763 -0
  287. package/schemas/piagent-webui/source-mutation-v1.schema.json +96 -0
  288. package/schemas/piagent-webui/source-revert-v1.schema.json +130 -0
  289. package/schemas/piagent-webui/subagent-tree-v1.schema.json +88 -0
  290. package/schemas/piagent-webui/task-index-v1.schema.json +74 -0
  291. package/schemas/piagent-webui/task-timeline-v1.schema.json +49 -0
  292. package/schemas/piagent-webui/transcript-v1.schema.json +243 -0
  293. package/schemas/task-baseline-manifest.schema.json +122 -0
  294. package/schemas/verifier-file-snapshot.schema.json +81 -0
  295. package/scripts/check-architecture.mjs +1 -0
  296. package/scripts/check-doc-languages.mjs +19 -2
  297. package/scripts/check-third-party-neutrality.mjs +43 -0
  298. package/scripts/install-global.sh +1 -1
  299. package/scripts/piagent-cli.mjs +17 -3
  300. package/scripts/piagent-dashboard.mjs +208 -0
  301. package/scripts/piagent-webui-launcher.mjs +121 -0
  302. package/scripts/typescript-loader.mjs +26 -1
  303. package/scripts/verify-local.sh +253 -0
  304. package/scripts/verify-release-identity.mjs +4 -1
@@ -0,0 +1,168 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { ensurePrivateStateDirectory, resolveLocalStatePath } from "../../extensions/local-state-path.js";
6
+ import { canonicalReviewValue } from "./review-state-contract.ts";
7
+ import { validateSourceMutationTarget, type SourceMutationAction, type SourceMutationTarget } from "./source-mutation-projection.ts";
8
+ import { validateSourceRevertTarget, type SourceRevertTarget } from "./source-revert-projection.ts";
9
+ import { readTaskBaselineManifest, taskBaselineRetentionState } from "./source-evidence-store.ts";
10
+
11
+ const MAX_RECORDS = 4_000, MAX_RECORD_BYTES = 96 * 1024;
12
+ const REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/, DIGEST = /^sha256:[a-f0-9]{64}$/;
13
+ const REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/, REVISION_KEYS = ["runtimeRevision", "taskRevision", "controlRevision",
14
+ "workspaceRevision", "indexRevision", "approvalRevision", "sessionOptionRevision", "queueRevision"];
15
+ export const SOURCE_MUTATION_EVIDENCE_VERSION = "piagent-webui-source-mutation-evidence-v1";
16
+
17
+ export type SourceMutationEvidenceRecord = {
18
+ schemaVersion: 1;
19
+ version: typeof SOURCE_MUTATION_EVIDENCE_VERSION;
20
+ recordId: string;
21
+ evidenceRef: string;
22
+ sequence: number;
23
+ phase: "requested" | "settled" | "rejected" | "uncertain";
24
+ resultCode: "mutation-requested" | "staged" | "unstaged" | "reverted" | "mutation-rejected" | "effect-unknown";
25
+ failureCode: string | null;
26
+ projectRef: string;
27
+ runtimeInstanceId: string;
28
+ sessionRef: string;
29
+ taskId: string;
30
+ taskRunId: string;
31
+ commandId: string;
32
+ idempotencyKeyDigest: string;
33
+ actionDigest: string;
34
+ action: SourceMutationAction | "source.revert";
35
+ target: SourceMutationTarget | SourceRevertTarget;
36
+ selectedHunkRefs: string[];
37
+ requestedAt: string;
38
+ recordedAt: string;
39
+ retentionUntil: string;
40
+ beforeIndexPreimage: string;
41
+ afterIndexPreimage: string | null;
42
+ beforeWorkspacePreimage: string;
43
+ afterWorkspacePreimage: string | null;
44
+ observedRevisionsBefore: Record<string, string | null>;
45
+ observedRevisionsAfter: Record<string, string | null> | null;
46
+ integrityDigest: string;
47
+ };
48
+
49
+ type AppendOptions = Omit<SourceMutationEvidenceRecord, "schemaVersion" | "version" | "recordId" | "evidenceRef" | "sequence" | "retentionUntil" | "integrityDigest"> & { projectRoot: string };
50
+
51
+ function sha(value: string): string { return createHash("sha256").update(value).digest("hex"); }
52
+ function evidenceDigest(value: unknown): string { return `sha256:${sha(canonicalReviewValue(value))}`; }
53
+ function timestamp(value: unknown): value is string {
54
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return false;
55
+ const parsed = Date.parse(value); return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
56
+ }
57
+ function directory(projectRoot: string, taskRunId: string): string {
58
+ return path.join(projectRoot, ".pi", "piagent-state", "source-evidence", `run-${sha(taskRunId)}`, "source-actions");
59
+ }
60
+ function readPrivate(projectRoot: string, file: string): Buffer {
61
+ const safe = resolveLocalStatePath(projectRoot, file, { label: "Source mutation evidence", kind: "file" });
62
+ const descriptor = fs.openSync(safe, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
63
+ try {
64
+ const stat = fs.fstatSync(descriptor);
65
+ if (!stat.isFile() || stat.size > MAX_RECORD_BYTES) throw new Error("mutation-record-oversized");
66
+ return fs.readFileSync(descriptor);
67
+ } finally { fs.closeSync(descriptor); }
68
+ }
69
+ function publish(projectRoot: string, target: string, bytes: Buffer): void {
70
+ const parent = ensurePrivateStateDirectory(projectRoot, path.dirname(target), "Source mutation evidence directory");
71
+ const safeTarget = resolveLocalStatePath(projectRoot, target, { label: "Source mutation evidence" });
72
+ const temporary = path.join(parent, `${path.basename(target)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
73
+ const descriptor = fs.openSync(temporary, "wx", 0o600);
74
+ try { fs.writeFileSync(descriptor, bytes); fs.fsyncSync(descriptor); fs.fchmodSync(descriptor, 0o600); }
75
+ finally { fs.closeSync(descriptor); }
76
+ try { fs.linkSync(temporary, safeTarget); }
77
+ catch (error) {
78
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST" || !readPrivate(projectRoot, safeTarget).equals(bytes)) throw error;
79
+ } finally { fs.rmSync(temporary, { force: true }); }
80
+ }
81
+
82
+ export function validateSourceMutationEvidence(value: unknown): SourceMutationEvidenceRecord {
83
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("mutation-record-invalid");
84
+ const record = value as SourceMutationEvidenceRecord;
85
+ const keys = ["schemaVersion", "version", "recordId", "evidenceRef", "sequence", "phase", "resultCode", "projectRef", "runtimeInstanceId", "sessionRef",
86
+ "taskId", "taskRunId", "commandId", "idempotencyKeyDigest", "actionDigest", "action", "target", "selectedHunkRefs", "failureCode", "requestedAt", "recordedAt", "retentionUntil",
87
+ "beforeIndexPreimage", "afterIndexPreimage", "beforeWorkspacePreimage", "afterWorkspacePreimage", "observedRevisionsBefore", "observedRevisionsAfter", "integrityDigest"];
88
+ if (canonicalReviewValue(Object.keys(record).sort()) !== canonicalReviewValue(keys.sort()) || record.schemaVersion !== 1
89
+ || record.version !== SOURCE_MUTATION_EVIDENCE_VERSION || ![record.recordId, record.evidenceRef, record.projectRef, record.runtimeInstanceId,
90
+ record.sessionRef, record.taskId, record.taskRunId, record.commandId].every((item) => REF.test(item))
91
+ || !Number.isInteger(record.sequence) || record.sequence < 1 || record.sequence > MAX_RECORDS
92
+ || ![record.idempotencyKeyDigest, record.actionDigest, record.beforeIndexPreimage, record.integrityDigest].every((item) => DIGEST.test(item))
93
+ || !/^wt-content-v2:[a-f0-9]{64}$/.test(record.beforeWorkspacePreimage)
94
+ || record.afterIndexPreimage !== null && !DIGEST.test(record.afterIndexPreimage)
95
+ || record.afterWorkspacePreimage !== null && !/^wt-content-v2:[a-f0-9]{64}$/.test(record.afterWorkspacePreimage)
96
+ || !Array.isArray(record.selectedHunkRefs) || record.selectedHunkRefs.length > 128
97
+ || new Set(record.selectedHunkRefs).size !== record.selectedHunkRefs.length
98
+ || record.selectedHunkRefs.some((item) => !REF.test(item) || !record.target.hunkRefs.includes(item))
99
+ || ![record.requestedAt, record.recordedAt, record.retentionUntil].every(timestamp)
100
+ || !(record.failureCode === null || /^[a-z][a-z0-9.-]{0,95}$/.test(record.failureCode))
101
+ || !["source.stage", "source.unstage", "source.revert"].includes(record.action)) throw new Error("mutation-record-invalid");
102
+ const settledResult = record.action === "source.stage" ? "staged" : record.action === "source.unstage" ? "unstaged" : "reverted";
103
+ const phaseResult = { requested: "mutation-requested", settled: settledResult,
104
+ rejected: "mutation-rejected", uncertain: "effect-unknown" }[record.phase];
105
+ if (record.resultCode !== phaseResult || record.phase === "requested" && (record.afterIndexPreimage !== null || record.afterWorkspacePreimage !== null)
106
+ || record.phase === "settled" && (!record.afterIndexPreimage || !record.afterWorkspacePreimage)
107
+ || ["requested", "settled"].includes(record.phase) !== (record.failureCode === null)) throw new Error("mutation-record-phase-invalid");
108
+ if (record.action === "source.revert") validateSourceRevertTarget(record.target); else validateSourceMutationTarget(record.target);
109
+ for (const revisions of [record.observedRevisionsBefore, record.observedRevisionsAfter].filter(Boolean) as Array<Record<string, string | null>>) {
110
+ if (canonicalReviewValue(Object.keys(revisions).sort()) !== canonicalReviewValue([...REVISION_KEYS].sort())
111
+ || !REVISION.test(String(revisions.runtimeRevision ?? "")) || REVISION_KEYS.slice(1).some((key) => revisions[key] !== null && !REVISION.test(String(revisions[key]))))
112
+ throw new Error("mutation-record-revisions-invalid");
113
+ }
114
+ if (record.phase === "requested" ? record.observedRevisionsAfter !== null : record.observedRevisionsAfter === null) throw new Error("mutation-record-revisions-invalid");
115
+ const { integrityDigest, ...payload } = record;
116
+ if (evidenceDigest(payload) !== integrityDigest) throw new Error("mutation-record-integrity-invalid");
117
+ return structuredClone(record);
118
+ }
119
+
120
+ export function readSourceMutationEvidence(projectRoot: string, taskRunId: string): { records: SourceMutationEvidenceRecord[]; corruptions: string[] } {
121
+ const root = directory(projectRoot, taskRunId);
122
+ let files: string[];
123
+ try { files = fs.readdirSync(resolveLocalStatePath(projectRoot, root, { label: "Source mutation evidence", kind: "directory" })).filter((file) => file.endsWith(".json")).sort(); }
124
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? { records: [], corruptions: [] } : { records: [], corruptions: ["mutation-evidence-directory-unavailable"] }; }
125
+ if (files.length > MAX_RECORDS) return { records: [], corruptions: ["mutation-evidence-record-limit"] };
126
+ const records: SourceMutationEvidenceRecord[] = [], corruptions: string[] = [];
127
+ for (const file of files) try {
128
+ const record = validateSourceMutationEvidence(JSON.parse(readPrivate(projectRoot, path.join(root, file)).toString("utf8")));
129
+ if (file !== `${record.recordId}.json` || record.taskRunId !== taskRunId) throw new Error("mutation-record-identity-invalid");
130
+ records.push(record);
131
+ } catch { corruptions.push(`mutation.${sha(file)}`); }
132
+ return { records: corruptions.length ? [] : records.sort((left, right) => left.sequence - right.sequence), corruptions };
133
+ }
134
+
135
+ export function appendSourceMutationEvidence(options: AppendOptions): SourceMutationEvidenceRecord {
136
+ const manifest = readTaskBaselineManifest(options.projectRoot, options.taskRunId);
137
+ if (!manifest || manifest.taskId !== options.taskId || manifest.captureState !== "current"
138
+ || taskBaselineRetentionState(manifest, new Date(options.recordedAt)) !== "active") throw new Error("mutation-baseline-unavailable");
139
+ const existing = readSourceMutationEvidence(options.projectRoot, options.taskRunId);
140
+ if (existing.corruptions.length || existing.records.length >= MAX_RECORDS) throw new Error("mutation-evidence-unavailable");
141
+ const identity = evidenceDigest({ commandId: options.commandId, actionDigest: options.actionDigest, phase: options.phase });
142
+ const recordId = `mutation.${identity.slice(7)}`;
143
+ const duplicate = existing.records.find((record) => record.recordId === recordId);
144
+ if (duplicate) {
145
+ const expected = { phase: options.phase, resultCode: options.resultCode, failureCode: options.failureCode, selectedHunkRefs: options.selectedHunkRefs,
146
+ commandId: options.commandId, idempotencyKeyDigest: options.idempotencyKeyDigest,
147
+ actionDigest: options.actionDigest, action: options.action, target: options.target, beforeIndexPreimage: options.beforeIndexPreimage,
148
+ afterIndexPreimage: options.afterIndexPreimage, beforeWorkspacePreimage: options.beforeWorkspacePreimage,
149
+ afterWorkspacePreimage: options.afterWorkspacePreimage, observedRevisionsBefore: options.observedRevisionsBefore,
150
+ observedRevisionsAfter: options.observedRevisionsAfter };
151
+ const actual = Object.fromEntries(Object.keys(expected).map((key) => [key, (duplicate as any)[key]]));
152
+ if (canonicalReviewValue(actual) !== canonicalReviewValue(expected)) throw new Error("mutation-evidence-identity-collision");
153
+ return validateSourceMutationEvidence(duplicate);
154
+ }
155
+ const payload = { schemaVersion: 1 as const, version: SOURCE_MUTATION_EVIDENCE_VERSION, recordId, evidenceRef: `mutation-evidence.${identity.slice(7)}`,
156
+ sequence: (existing.records.at(-1)?.sequence ?? 0) + 1, phase: options.phase, resultCode: options.resultCode, failureCode: options.failureCode,
157
+ projectRef: options.projectRef, runtimeInstanceId: options.runtimeInstanceId, sessionRef: options.sessionRef, taskId: options.taskId,
158
+ taskRunId: options.taskRunId, commandId: options.commandId, idempotencyKeyDigest: options.idempotencyKeyDigest, actionDigest: options.actionDigest,
159
+ action: options.action, target: structuredClone(options.target), selectedHunkRefs: [...options.selectedHunkRefs], requestedAt: options.requestedAt, recordedAt: options.recordedAt,
160
+ retentionUntil: manifest.retentionUntil, beforeIndexPreimage: options.beforeIndexPreimage, afterIndexPreimage: options.afterIndexPreimage,
161
+ beforeWorkspacePreimage: options.beforeWorkspacePreimage, afterWorkspacePreimage: options.afterWorkspacePreimage,
162
+ observedRevisionsBefore: structuredClone(options.observedRevisionsBefore), observedRevisionsAfter: structuredClone(options.observedRevisionsAfter) };
163
+ const record = validateSourceMutationEvidence({ ...payload, integrityDigest: evidenceDigest(payload) });
164
+ const bytes = Buffer.from(`${JSON.stringify(record, null, 2)}\n`);
165
+ if (bytes.length > MAX_RECORD_BYTES) throw new Error("mutation-record-oversized");
166
+ publish(options.projectRoot, path.join(directory(options.projectRoot, options.taskRunId), `${record.recordId}.json`), bytes);
167
+ return record;
168
+ }
@@ -0,0 +1,38 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { collectSelectedSourceFileState, type SourceChangeDocument, type SourceProjectionOptions, type WebUiIdentity } from "./source-change-projection.ts";
5
+
6
+ const REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/, REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/,
7
+ DIGEST = /^sha256:[a-f0-9]{64}$/;
8
+
9
+ export type SourceOpenTarget = { fileRef: string; taskRevision: string; workspaceRevision: string; fileRevision: string; contentDigest: string };
10
+ export type SourceOpenAuthority = { projectRoot: string; repoRoot: string; repoPath: string; absolutePath: string; target: SourceOpenTarget };
11
+
12
+ function inside(root: string, candidate: string): boolean {
13
+ const relative = path.relative(root, candidate);
14
+ return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
15
+ }
16
+
17
+ export async function resolveSourceOpenTarget(options: { cwd: string; identity: WebUiIdentity; sourceView: SourceChangeDocument;
18
+ fileRef: string; taskRevision: string; workspaceRevision: string; generatedAt?: string;
19
+ isProtectedPath?: SourceProjectionOptions["isProtectedPath"]; timeoutMs?: number; maxGitBytes?: number }): Promise<SourceOpenAuthority | null> {
20
+ if (options.sourceView.view !== "working-tree" || options.sourceView.availability.state !== "current" || !REF.test(options.fileRef)
21
+ || !REVISION.test(options.taskRevision) || !REVISION.test(options.workspaceRevision)) return null;
22
+ const file = options.sourceView.files.find((candidate: any) => candidate.fileRef === options.fileRef) as Record<string, any> | undefined;
23
+ if (!file || file.pathDisplay !== "exact-safe" || file.oldPath !== null || ["D", "R", "C"].includes(String(file.status))
24
+ || file.content?.access !== "available" || file.content?.kind !== "text" || !DIGEST.test(String(file.currentDigest ?? ""))) return null;
25
+ const selected = await collectSelectedSourceFileState({ cwd: options.cwd, identity: options.identity, taskRevision: options.taskRevision,
26
+ generatedAt: options.generatedAt, isProtectedPath: options.isProtectedPath, timeoutMs: options.timeoutMs, maxGitBytes: options.maxGitBytes },
27
+ "working-tree", options.fileRef, String(file.basisRef), [file.path]);
28
+ const selectedContent = selected?.file.content as { access?: unknown; kind?: unknown } | undefined;
29
+ if (!selected || selected.file.fileRevision !== file.fileRevision || selected.record.kind !== "ordinary" || selected.record.oldPath
30
+ || selectedContent?.access !== "available" || selectedContent.kind !== "text") return null;
31
+ const repoPath = selected.record.path.value; if (!repoPath || options.isProtectedPath?.(selected.snapshot.repoRoot, repoPath) === true) return null;
32
+ const projectRoot = fs.realpathSync.native(options.cwd), repoRoot = fs.realpathSync.native(selected.snapshot.repoRoot);
33
+ const unresolved = path.resolve(repoRoot, repoPath); if (!inside(projectRoot, unresolved) || !inside(repoRoot, unresolved)) return null;
34
+ const stat = fs.lstatSync(unresolved); if (!stat.isFile() || stat.isSymbolicLink()) return null;
35
+ const absolutePath = fs.realpathSync.native(unresolved); if (absolutePath !== unresolved || !inside(projectRoot, absolutePath)) return null;
36
+ return { projectRoot, repoRoot, repoPath, absolutePath, target: { fileRef: options.fileRef, taskRevision: options.taskRevision,
37
+ workspaceRevision: options.workspaceRevision, fileRevision: String(file.fileRevision), contentDigest: String(file.currentDigest) } };
38
+ }
@@ -0,0 +1,147 @@
1
+ import { createHmac } from "node:crypto";
2
+
3
+ import { canonicalReviewValue } from "./review-state-contract.ts";
4
+ import { collectExactGitPatchAuthority, type DiffDocument, type ExactGitPatchAuthority } from "./diff-projection.ts";
5
+ import { deriveReviewTarget } from "./review-state-projection.ts";
6
+ import { collectSelectedSourceFileState, type SourceChangeDocument, type SourceProjectionOptions, type WebUiIdentity } from "./source-change-projection.ts";
7
+ import { collectIndexPreimage, collectSelectedWorkspacePreimage } from "./source-mutation-projection.ts";
8
+
9
+ const REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/, REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/,
10
+ DIGEST = /^sha256:[a-f0-9]{64}$/, WORKSPACE = /^wt-content-v2:[a-f0-9]{64}$/,
11
+ SAFE_PATH = /^(?!\/)(?![A-Za-z]:)(?!.*(?:^|\/)\.\.(?:\/|$))[^\u0000-\u001F\u007F\\]{1,1024}$/;
12
+
13
+ export type SourceRevertTarget = {
14
+ view: "working-tree"; repoRef: string; fileRef: string; diffRef: string; status: "M"; path: string; oldPath: null;
15
+ effect: "restore-worktree-from-index"; taskRevision: string; workspaceRevision: string; indexRevision: string;
16
+ viewRevision: string; fileRevision: string; workspacePreimage: string; indexPreimage: string; patchPreimage: string;
17
+ contentDigest: string; hunkRefs: string[]; previewRef: string; confirmedPreviewDigest: string; expiresAt: string;
18
+ summary: { files: 1; hunks: number; additionsDiscarded: number; deletionsRestored: number;
19
+ effect: "discard-unstaged-keep-index"; recovery: "not-guaranteed" };
20
+ };
21
+ export type SourceRevertProjection = {
22
+ schemaVersion: 1; version: "piagent-webui-source-revert-v1"; generatedAt: string; identity: WebUiIdentity;
23
+ action: "source.revert"; state: "ready" | "unavailable"; target: SourceRevertTarget | null;
24
+ preview: { basis: "index-to-working-tree"; hunks: RevertPreviewHunk[]; truncated: false } | null; reasonCode: string | null;
25
+ health: { state: "ok" | "unavailable" | "error"; reasonCode: string | null; message: string | null };
26
+ };
27
+ type RevertPreviewLine = { kind: "added" | "deleted" | "context" | "meta"; marker: "+" | "-" | " " | "\\"; text: string };
28
+ type RevertPreviewHunk = { hunkRef: string; header: string; lines: RevertPreviewLine[] };
29
+ export type SourceRevertAuthority = {
30
+ repoRoot: string; repoPath: string; indexObject: string; indexMode: "100644" | "100755";
31
+ target: SourceRevertTarget; patchAuthority: ExactGitPatchAuthority;
32
+ };
33
+ type CollectOptions = {
34
+ cwd: string; identity: WebUiIdentity; sourceView: SourceChangeDocument; taskView: SourceChangeDocument | null; diff: DiffDocument;
35
+ taskRevision: string; workspaceRevision: string; indexRevision: string; selectedHunkRefs?: string[]; generatedAt?: string;
36
+ confirmationKey: Buffer;
37
+ isProtectedPath?: SourceProjectionOptions["isProtectedPath"]; guardAvailable?: boolean; timeoutMs?: number; maxGitBytes?: number;
38
+ };
39
+
40
+ function confirmationDigest(key: Buffer, value: unknown): string {
41
+ return `sha256:${createHmac("sha256", key).update(canonicalReviewValue(value)).digest("hex")}`;
42
+ }
43
+ function previewHunk(hunk: ExactGitPatchAuthority["hunks"][number]): RevertPreviewHunk | null {
44
+ const text = hunk.bytes.toString("utf8");
45
+ if (!Buffer.from(text, "utf8").equals(hunk.bytes)) return null;
46
+ const raw = text.split("\n"); if (raw.at(-1) === "") raw.pop();
47
+ const header = raw.shift() ?? ""; if (!/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(header) || header.length > 1024) return null;
48
+ const lines: RevertPreviewLine[] = [];
49
+ for (const line of raw) {
50
+ const marker = line.startsWith("+") ? "+" : line.startsWith("-") ? "-" : line.startsWith("\\") ? "\\" : " ";
51
+ const value = line.slice(1); if (value.length > 16_384 || /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(value)) return null;
52
+ lines.push({ kind: marker === "+" ? "added" : marker === "-" ? "deleted" : marker === "\\" ? "meta" : "context", marker, text: value });
53
+ }
54
+ return { hunkRef: hunk.hunkRef, header, lines };
55
+ }
56
+ function unavailable(options: CollectOptions, reasonCode: string): { projection: SourceRevertProjection; authority: null } {
57
+ const message = "A confirmed exact Working Tree revert is unavailable";
58
+ return { projection: { schemaVersion: 1, version: "piagent-webui-source-revert-v1", generatedAt: options.generatedAt ?? new Date().toISOString(),
59
+ identity: structuredClone(options.identity), action: "source.revert", state: "unavailable", target: null, preview: null, reasonCode,
60
+ health: { state: "unavailable", reasonCode, message } }, authority: null };
61
+ }
62
+
63
+ export function validateSourceRevertTarget(value: unknown): SourceRevertTarget {
64
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("source-revert-target-invalid");
65
+ const target = value as SourceRevertTarget;
66
+ const keys = ["view", "repoRef", "fileRef", "diffRef", "status", "path", "oldPath", "effect", "taskRevision", "workspaceRevision",
67
+ "indexRevision", "viewRevision", "fileRevision", "workspacePreimage", "indexPreimage", "patchPreimage", "contentDigest", "hunkRefs",
68
+ "previewRef", "confirmedPreviewDigest", "expiresAt", "summary"];
69
+ if (canonicalReviewValue(Object.keys(target).sort()) !== canonicalReviewValue(keys.sort()) || target.view !== "working-tree" || target.status !== "M"
70
+ || target.oldPath !== null || target.effect !== "restore-worktree-from-index" || !SAFE_PATH.test(target.path)
71
+ || ![target.repoRef, target.fileRef, target.diffRef, target.previewRef].every((item) => REF.test(item))
72
+ || ![target.taskRevision, target.workspaceRevision, target.indexRevision, target.viewRevision, target.fileRevision].every((item) => REVISION.test(item))
73
+ || !WORKSPACE.test(target.workspacePreimage) || ![target.indexPreimage, target.patchPreimage, target.contentDigest, target.confirmedPreviewDigest].every((item) => DIGEST.test(item))
74
+ || !Array.isArray(target.hunkRefs) || target.hunkRefs.length > 1 || new Set(target.hunkRefs).size !== target.hunkRefs.length
75
+ || target.hunkRefs.some((item) => !REF.test(item)) || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(target.expiresAt)
76
+ || !target.summary || target.summary.files !== 1 || !Number.isInteger(target.summary.hunks) || target.summary.hunks < 1 || target.summary.hunks > 128
77
+ || ![target.summary.additionsDiscarded, target.summary.deletionsRestored].every((item) => Number.isInteger(item) && item >= 0 && item <= 65_536)
78
+ || target.summary.effect !== "discard-unstaged-keep-index" || target.summary.recovery !== "not-guaranteed") throw new Error("source-revert-target-invalid");
79
+ const parsed = Date.parse(target.expiresAt); if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== target.expiresAt) throw new Error("source-revert-target-invalid");
80
+ return structuredClone(target);
81
+ }
82
+
83
+ export async function collectSourceRevertPreview(options: CollectOptions): Promise<{ projection: SourceRevertProjection; authority: SourceRevertAuthority | null }> {
84
+ const selectedHunkRefs = options.selectedHunkRefs ?? [];
85
+ if (!Buffer.isBuffer(options.confirmationKey) || options.confirmationKey.length < 32) return unavailable(options, "revert-confirmation-authority-unavailable");
86
+ if (options.guardAvailable !== true) return unavailable(options, "mutation-guard-unavailable");
87
+ if (options.sourceView.view !== "working-tree" || options.sourceView.availability.state !== "current" || !options.identity.taskId || !options.identity.taskRunId
88
+ || !options.taskRevision || !options.workspaceRevision || !options.indexRevision || selectedHunkRefs.length > 1 || new Set(selectedHunkRefs).size !== selectedHunkRefs.length)
89
+ return unavailable(options, "revert-authority-unavailable");
90
+ const file = options.sourceView.files.find((candidate: any) => candidate.fileRef === (options.diff as any).file?.fileRef) as Record<string, any> | undefined;
91
+ if (!file || file.status !== "M" || file.git?.worktreeStatus !== "M" || file.oldPath !== null || file.pathDisplay !== "exact-safe"
92
+ || file.content?.kind !== "text" || file.content?.access !== "available") return unavailable(options, "revert-target-unavailable");
93
+ const taskFile = options.taskView?.files.find((candidate: any) => candidate.path === file.path && candidate.currentDigest === file.currentDigest) as Record<string, any> | undefined;
94
+ if (!taskFile || taskFile.provenance?.classification !== "runtime-observed-agent" || taskFile.provenance?.evidence !== "exact"
95
+ || !Array.isArray(taskFile.provenance?.mutationEvidenceRefs) || taskFile.provenance.mutationEvidenceRefs.length === 0)
96
+ return unavailable(options, "revert-provenance-unavailable");
97
+ const review = deriveReviewTarget({ diff: options.diff, taskId: options.identity.taskId, taskRunId: options.identity.taskRunId,
98
+ taskRevision: options.taskRevision, workspaceRevision: options.workspaceRevision, indexRevision: options.indexRevision });
99
+ if (!review || review.view !== "working-tree" || review.fileRef !== file.fileRef || (options.diff as any).truncation?.truncated !== false
100
+ || (options.diff as any).redaction?.applied !== false) return unavailable(options, "revert-preview-incomplete");
101
+ const selected = await collectSelectedSourceFileState({ cwd: options.cwd, identity: options.identity, taskRevision: options.taskRevision,
102
+ generatedAt: options.generatedAt, isProtectedPath: options.isProtectedPath, timeoutMs: options.timeoutMs, maxGitBytes: options.maxGitBytes },
103
+ "working-tree", file.fileRef, String(file.basisRef), [file.path]);
104
+ if ((selected?.file as Record<string, any> | undefined)?.content?.access === "protected") return unavailable(options, "protected-path");
105
+ if (!selected || selected.file.fileRevision !== file.fileRevision || selected.record.kind !== "ordinary" || selected.record.oldPath
106
+ || selected.record.worktreeStatus !== "M" || !selected.record.indexObject || !["100644", "100755"].includes(String(selected.record.indexMode)))
107
+ return unavailable(options, "revert-target-stale");
108
+ const repoPath = selected.record.path.value;
109
+ if (!repoPath || options.isProtectedPath?.(selected.snapshot.repoRoot, repoPath) === true) return unavailable(options, "protected-path");
110
+ try {
111
+ const patchAuthority = await collectExactGitPatchAuthority({ snapshot: selected.snapshot, record: selected.record, view: "unstaged",
112
+ fileRef: String(file.fileRef), timeoutMs: options.timeoutMs, maxBytes: options.maxGitBytes });
113
+ if (!patchAuthority || patchAuthority.hunks.length === 0 || patchAuthority.hunks.length > 128
114
+ || selectedHunkRefs.some((item) => !patchAuthority.hunks.some((hunk) => hunk.hunkRef === item))) return unavailable(options, "revert-hunk-unavailable");
115
+ const selectedAuthorityHunks = patchAuthority.hunks.filter((hunk) => selectedHunkRefs.length === 0 || selectedHunkRefs.includes(hunk.hunkRef));
116
+ const selectedRefs = selectedAuthorityHunks.map((hunk) => hunk.hunkRef);
117
+ if (selectedHunkRefs.length && canonicalReviewValue(selectedRefs) !== canonicalReviewValue(selectedHunkRefs)) return unavailable(options, "revert-hunk-unavailable");
118
+ const previewHunks = selectedAuthorityHunks.map(previewHunk);
119
+ if (previewHunks.some((hunk) => !hunk) || previewHunks.reduce((count, hunk) => count + (hunk?.lines.length ?? 0), 0) > 5_000)
120
+ return unavailable(options, "revert-preview-incomplete");
121
+ const exactPreviewHunks = previewHunks as RevertPreviewHunk[];
122
+ const workspacePreimage = collectSelectedWorkspacePreimage(selected.snapshot.repoRoot, [repoPath]);
123
+ const indexPreimage = await collectIndexPreimage(selected.snapshot.repoRoot, options.timeoutMs, options.maxGitBytes);
124
+ const generatedAt = options.generatedAt ?? new Date().toISOString(), expiresAt = new Date(Date.parse(generatedAt) + 5 * 60_000).toISOString();
125
+ const confirmation = { projectRef: options.identity.projectRef, sessionRef: options.identity.sessionRef, taskRunId: options.identity.taskRunId,
126
+ fileRef: file.fileRef, hunkRefs: selectedHunkRefs, taskRevision: options.taskRevision, workspaceRevision: options.workspaceRevision,
127
+ indexRevision: options.indexRevision, viewRevision: review.viewRevision, fileRevision: review.fileRevision,
128
+ workspacePreimage, indexPreimage, patchPreimage: review.patchPreimage, contentDigest: review.contentDigest };
129
+ const confirmedPreviewDigest = confirmationDigest(options.confirmationKey,
130
+ { domain: "piagent-webui-source-revert-preview-v1", confirmation });
131
+ const target: SourceRevertTarget = { view: "working-tree", repoRef: String(file.repoRef), fileRef: String(file.fileRef), diffRef: review.diffRef,
132
+ status: "M", path: String(file.path), oldPath: null, effect: "restore-worktree-from-index", taskRevision: options.taskRevision,
133
+ workspaceRevision: options.workspaceRevision, indexRevision: options.indexRevision, viewRevision: review.viewRevision,
134
+ fileRevision: review.fileRevision, workspacePreimage, indexPreimage, patchPreimage: review.patchPreimage, contentDigest: review.contentDigest,
135
+ hunkRefs: [...selectedHunkRefs], previewRef: `revert-preview.${confirmedPreviewDigest.slice(7)}`, confirmedPreviewDigest, expiresAt,
136
+ summary: { files: 1, hunks: exactPreviewHunks.length,
137
+ additionsDiscarded: exactPreviewHunks.reduce((count, hunk) => count + hunk.lines.filter((line) => line.kind === "added").length, 0),
138
+ deletionsRestored: exactPreviewHunks.reduce((count, hunk) => count + hunk.lines.filter((line) => line.kind === "deleted").length, 0),
139
+ effect: "discard-unstaged-keep-index", recovery: "not-guaranteed" } };
140
+ validateSourceRevertTarget(target);
141
+ return { projection: { schemaVersion: 1, version: "piagent-webui-source-revert-v1", generatedAt, identity: structuredClone(options.identity),
142
+ action: "source.revert", state: "ready", target, preview: { basis: "index-to-working-tree", hunks: exactPreviewHunks, truncated: false },
143
+ reasonCode: null, health: { state: "ok", reasonCode: null, message: null } },
144
+ authority: { repoRoot: selected.snapshot.repoRoot, repoPath, indexObject: selected.record.indexObject,
145
+ indexMode: selected.record.indexMode as "100644" | "100755", target, patchAuthority } };
146
+ } catch { return unavailable(options, "revert-preimage-unavailable"); }
147
+ }
@@ -0,0 +1,22 @@
1
+ import { captureTaskBaselineManifest } from "./source-evidence-store.ts";
2
+
3
+ export async function captureTaskStartBaseline(input: {
4
+ projectRoot: string;
5
+ taskId: string;
6
+ taskRunId: string;
7
+ sessionId: string;
8
+ capturedAt: string;
9
+ baselineTreeDigest: string;
10
+ protectedPaths: string[];
11
+ matchesProtectedPath: (path: string, patterns: string[]) => unknown;
12
+ }): Promise<void> {
13
+ await captureTaskBaselineManifest({
14
+ projectRoot: input.projectRoot,
15
+ taskId: input.taskId,
16
+ taskRunId: input.taskRunId,
17
+ sessionId: input.sessionId,
18
+ capturedAt: input.capturedAt,
19
+ baselineTreeDigest: input.baselineTreeDigest,
20
+ isProtectedProjectPath: (projectPath) => Boolean(input.matchesProtectedPath(projectPath, input.protectedPaths))
21
+ });
22
+ }
@@ -0,0 +1,160 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { redactSensitiveText } from "../../extensions/redaction-core.js";
4
+ import { inspectTaskResumeState } from "../recovery/resume-state.ts";
5
+ import { inspectBoundedContextTelemetry } from "./context-telemetry-inspection.ts";
6
+ import { taskRunOpaqueRef } from "./task-run-index.ts";
7
+
8
+ const MAX_EVENTS = 300;
9
+ const MAX_TELEMETRY = 5_000;
10
+
11
+ type Task = {
12
+ taskId: string;
13
+ taskRunId: string;
14
+ sessionId: string;
15
+ [key: string]: any;
16
+ };
17
+ type Identity = {
18
+ projectRef: string;
19
+ runtimeInstanceId: string;
20
+ sessionRef: string;
21
+ taskId: string;
22
+ taskRunId: string;
23
+ agentOperationId: null;
24
+ toolCallId: null;
25
+ };
26
+
27
+ function opaque(prefix: string, value: string): string {
28
+ return `${prefix}.${createHash("sha256").update(`piagent-webui-recovery-history-v1\0${value}`).digest("hex").slice(0, 48)}`;
29
+ }
30
+
31
+ function display(value: unknown, maximum = 160): string {
32
+ return redactSensitiveText(String(value ?? "")).text
33
+ .replace(/[\u0000-\u001f\u007f]+/g, " ")
34
+ .replace(/\s+/g, " ")
35
+ .trim()
36
+ .slice(0, maximum);
37
+ }
38
+
39
+ function timestamp(value: unknown): value is string {
40
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)
41
+ && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
42
+ }
43
+
44
+ function boundedInteger(value: unknown, maximum = 1_000_000_000): number | null {
45
+ return Number.isInteger(value) && Number(value) >= 0 ? Math.min(Number(value), maximum) : null;
46
+ }
47
+
48
+ function recoverySummary(cwd: string, task: Task) {
49
+ const resume = inspectTaskResumeState(cwd, task as any, task.sessionId);
50
+ const latestCheckpointRef = resume.latestCheckpoint
51
+ ? opaque("checkpoint", `${task.taskRunId}\0${resume.latestCheckpoint.checkpointId}`)
52
+ : null;
53
+ return {
54
+ decision: resume.decision,
55
+ enforcementSafe: resume.enforcementSafe,
56
+ latestCheckpointRef,
57
+ verifierState: resume.verifierEvidenceCurrent ? "current" : resume.staleVerifierEvidence ? "stale" : "not-current",
58
+ invalidatedFileCount: Math.min(100_000, resume.invalidatedVerifierFiles.length),
59
+ invalidatedFilesKnown: resume.invalidatedVerifierFilesKnown,
60
+ handoffState: !resume.handoff.exists ? "none" : resume.handoff.valid ? "available" : "invalid",
61
+ reasonCode: `recovery-${resume.decision}`
62
+ };
63
+ }
64
+
65
+ function unavailable(identity: Identity, runRef: string, generatedAt: string, reasonCode = "recovery-history-unavailable") {
66
+ return {
67
+ schemaVersion: 1,
68
+ version: "piagent-webui-recovery-history-v1",
69
+ generatedAt,
70
+ identity: structuredClone(identity),
71
+ runRef,
72
+ state: "unavailable",
73
+ historyRevision: null,
74
+ completeness: "unknown",
75
+ summary: { contextCompactions: 0, toolResultCompactions: 0, compactedToolResults: 0 },
76
+ recovery: { decision: "unknown", enforcementSafe: null, latestCheckpointRef: null, verifierState: "unknown",
77
+ invalidatedFileCount: null, invalidatedFilesKnown: false, handoffState: "unknown", reasonCode },
78
+ retainedContent: { access: "omitted", exposed: false, reasonCode: "protected-runtime-evidence" },
79
+ events: [],
80
+ page: { total: 0, returned: 0, truncated: false },
81
+ warnings: [],
82
+ health: { state: "unavailable", reasonCode, message: "Compaction and recovery history is unavailable." }
83
+ };
84
+ }
85
+
86
+ export function projectTaskCompactionHistory(input: {
87
+ cwd: string;
88
+ task: Task;
89
+ identity: Identity;
90
+ generatedAt?: string;
91
+ }): Record<string, any> {
92
+ const generatedAt = input.generatedAt ?? new Date().toISOString();
93
+ const runRef = taskRunOpaqueRef(input.task.taskRunId);
94
+ try {
95
+ const inspected = inspectBoundedContextTelemetry(input.cwd, { limit: MAX_TELEMETRY });
96
+ let malformed = inspected.corruptions;
97
+ const matching = inspected.records.flatMap((record: any, ordinal: number) => {
98
+ if (record?.taskId !== input.task.taskId || record?.taskRunId !== input.task.taskRunId || record?.sessionId !== input.task.sessionId) return [];
99
+ if (record.schemaVersion !== 1 || !timestamp(record.recordedAt)) { malformed += 1; return []; }
100
+ if (record.event === "session_compact") {
101
+ if (typeof record.willRetry !== "boolean" || typeof record.fromExtension !== "boolean") { malformed += 1; return []; }
102
+ return [{ ordinal, recordedAt: record.recordedAt, kind: "context-compaction", title: "Context đã được compact",
103
+ detail: display(record.reason, 180) || null, toolName: null, originalChars: null, originalLines: null, captureCount: null,
104
+ willRetry: record.willRetry, fromExtension: record.fromExtension }];
105
+ }
106
+ if (record.event === "tool_result" && record.compacted === true) {
107
+ const captureCount = boundedInteger(record.compactedCaptures, 10_000);
108
+ if (captureCount === null || captureCount < 1) { malformed += 1; return []; }
109
+ return [{ ordinal, recordedAt: record.recordedAt, kind: "tool-result-compaction", title: "Tool result đã được rút gọn",
110
+ detail: null, toolName: display(record.toolName, 120) || "tool", originalChars: boundedInteger(record.outputChars),
111
+ originalLines: boundedInteger(record.outputLines), captureCount, willRetry: null, fromExtension: null }];
112
+ }
113
+ return [];
114
+ });
115
+ const projected = matching.map((event: any, index: number) => {
116
+ const fact = { ...event }; delete fact.ordinal;
117
+ const digest = createHash("sha256").update(JSON.stringify({ taskRunId: input.task.taskRunId, ordinal: event.ordinal, fact })).digest("hex");
118
+ return { eventRef: opaque("compaction", `${input.task.taskRunId}\0${digest}`), evidenceRef: opaque("telemetry", digest),
119
+ sequence: index + 1, ...fact };
120
+ });
121
+ const events = projected.slice(-MAX_EVENTS);
122
+ const contextCompactions = projected.filter((event: any) => event.kind === "context-compaction").length;
123
+ const toolResultCompactions = projected.filter((event: any) => event.kind === "tool-result-compaction").length;
124
+ const compactedToolResults = projected.reduce((sum: number, event: any) => sum + (event.captureCount ?? 0), 0);
125
+ const warnings: Array<{ code: string; count: number; message: string }> = [];
126
+ if (!inspected.exists) warnings.push({ code: "telemetry-missing", count: 1, message: "No bounded compaction telemetry was found for this project." });
127
+ if (malformed > 0) warnings.push({ code: "telemetry-corrupt", count: malformed, message: `${malformed} telemetry record(s) could not be validated.` });
128
+ if (inspected.recoverableTailBytes > 0) warnings.push({ code: "telemetry-incomplete-tail", count: inspected.recoverableTailBytes,
129
+ message: "An incomplete telemetry tail may omit the latest compaction fact." });
130
+ if (inspected.inputTruncated) warnings.push({ code: "telemetry-input-truncated", count: 1,
131
+ message: "Older telemetry is outside the bounded inspection window." });
132
+ if (projected.length > events.length) warnings.push({ code: "history-truncated", count: projected.length - events.length,
133
+ message: `${projected.length - events.length} older compaction event(s) are outside this bounded page.` });
134
+ const recovery = recoverySummary(input.cwd, input.task);
135
+ const completeness = !inspected.exists ? "missing" : malformed || inspected.recoverableTailBytes || inspected.inputTruncated ? "partial" : "complete";
136
+ const summary = { contextCompactions, toolResultCompactions, compactedToolResults };
137
+ const historyRevision = `recovery-history.${createHash("sha256").update(JSON.stringify({ events, summary, recovery, completeness })).digest("hex")}`;
138
+ return {
139
+ schemaVersion: 1,
140
+ version: "piagent-webui-recovery-history-v1",
141
+ generatedAt,
142
+ identity: structuredClone(input.identity),
143
+ runRef,
144
+ state: "ready",
145
+ historyRevision,
146
+ completeness,
147
+ summary,
148
+ recovery,
149
+ retainedContent: { access: "omitted", exposed: false, reasonCode: "protected-runtime-evidence" },
150
+ events,
151
+ page: { total: projected.length, returned: events.length, truncated: projected.length > events.length },
152
+ warnings,
153
+ health: warnings.length
154
+ ? { state: "degraded", reasonCode: "recovery-history-warning", message: "Some compaction or recovery facts are unavailable or omitted." }
155
+ : { state: "ok", reasonCode: null, message: null }
156
+ };
157
+ } catch {
158
+ return unavailable(input.identity, runRef, generatedAt);
159
+ }
160
+ }