@tequila99/githuman 0.12.0 → 0.13.1

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 (191) hide show
  1. package/README.md +12 -0
  2. package/dist/cli/config.js +3 -0
  3. package/dist/cli/server-runtime.js +4 -0
  4. package/dist/server/app.js +3 -0
  5. package/dist/server/db/artifact-validation-migrations.js +6 -0
  6. package/dist/server/errors/artifacts.js +1 -0
  7. package/dist/server/hooks/access/local-request.js +5 -0
  8. package/dist/server/hooks/access/require-access.js +5 -2
  9. package/dist/server/repositories/artifact-validation.repo.js +28 -0
  10. package/dist/server/routes/artifact-actions.js +10 -1
  11. package/dist/server/routes/artifact-agent.js +26 -2
  12. package/dist/server/services/agent/session-registry.js +31 -9
  13. package/dist/server/services/artifact-authorization.service.js +26 -1
  14. package/dist/server/services/artifact-graph-validation.js +48 -4
  15. package/dist/server/services/artifact-lifecycle.service.js +95 -7
  16. package/dist/server/services/artifact-storage.service.js +2 -2
  17. package/dist/server/services/artifact-transition.service.js +2 -1
  18. package/dist/server/services/artifact-validation-jobs.service.js +441 -78
  19. package/dist/shared/agents/schemas.js +3 -0
  20. package/dist/shared/artifact-tools/descriptions.js +8 -0
  21. package/dist/shared/artifact-tools/schemas.js +14 -2
  22. package/dist/shared/artifacts/schemas.js +69 -4
  23. package/dist/web/assets/{AccessPage-Dvj0aCkm.js → AccessPage-Dmu1-YCC.js} +1 -1
  24. package/dist/web/assets/{AddButton-CMC9qf51.js → AddButton-C58xe78T.js} +1 -1
  25. package/dist/web/assets/AddToAgentButton-Caehgd43.js +1 -0
  26. package/dist/web/assets/{ArtifactsPage-Cu1xdYNf.css → ArtifactsPage-D46E5HIK.css} +1 -1
  27. package/dist/web/assets/ArtifactsPage-DTZTA4AU.js +2 -0
  28. package/dist/web/assets/ClosePopup-Bd5a-Lu_.js +1 -0
  29. package/dist/web/assets/{CreateReviewFabButton-BCg1TvFh.js → CreateReviewFabButton-DVSiNykk.js} +1 -1
  30. package/dist/web/assets/DialogHeader-zLgPQKrk.js +1 -0
  31. package/dist/web/assets/IndexPage-D_h8rKHo.js +1 -0
  32. package/dist/web/assets/{MainLayout-Dy74m_q1.css → MainLayout-C3Ie5yOo.css} +1 -1
  33. package/dist/web/assets/MainLayout-ycdfACNB.js +7 -0
  34. package/dist/web/assets/{NoteVisualEditor-5rHBQ3j0.js → NoteVisualEditor-CQqVJBsl.js} +3 -3
  35. package/dist/web/assets/NotesPage-CtxHzvJG.js +4 -0
  36. package/dist/web/assets/{PdfPreview-2NQ5gkjh.js → PdfPreview-CFMP6Aiw.js} +1 -1
  37. package/dist/web/assets/{ProjectFilePage-B-WUUxnq.js → ProjectFilePage-1ZoTdWKE.js} +1 -1
  38. package/dist/web/assets/QFab-DJhLqo9i.js +1 -0
  39. package/dist/web/assets/QScrollArea-qf2G1s2r.js +1 -0
  40. package/dist/web/assets/QSeparator-B8uLaDiI.js +1 -0
  41. package/dist/web/assets/{QSpace-C7iAnGlU.js → QSpace-BmrklWw-.js} +1 -1
  42. package/dist/web/assets/QSplitter-C8fuuOAP.js +1 -0
  43. package/dist/web/assets/QTabs-Bl_rIz4_.js +1 -0
  44. package/dist/web/assets/QVirtualScroll-BeEkuL6x.js +1 -0
  45. package/dist/web/assets/ReviewDetailPage-B9XuqZLn.js +1 -0
  46. package/dist/web/assets/ReviewsPage-D0tj2bZD.js +1 -0
  47. package/dist/web/assets/{ArtifactDefinitionPicker-D3qdrxlC.css → SaveButton-C_Uk-Adf.css} +1 -1
  48. package/dist/web/assets/SaveButton-R_HjdOWt.js +3 -0
  49. package/dist/web/assets/{SearchInput-BRyN93Z1.js → SearchInput-DapVXrFj.js} +1 -1
  50. package/dist/web/assets/StatusSwitcher-fsgwyZPd.js +1 -0
  51. package/dist/web/assets/{TagFilterField-Bl3FBkB5.js → TagFilterField-CmfKZPHR.js} +1 -1
  52. package/dist/web/assets/{TerminalView-tEyLM5qZ.js → TerminalView-BIknwuLc.js} +1 -1
  53. package/dist/web/assets/{abnfDiagram-O67JEVCF-DN9xiV9p.js → abnfDiagram-O67JEVCF-aCHmj_Pp.js} +1 -1
  54. package/dist/web/assets/{agent-store-Grbx4jkz.js → agent-store-BV2OAJzn.js} +2 -2
  55. package/dist/web/assets/architecture-7GRP2DOG-BhXPG-R4.js +1 -0
  56. package/dist/web/assets/{architectureDiagram-NJMV4G6O-BVAJOucG.js → architectureDiagram-NJMV4G6O-CFeMX15R.js} +1 -1
  57. package/dist/web/assets/{blockDiagram-BEXU5L5S-TJAfSX44.js → blockDiagram-BEXU5L5S-DNKI8TuU.js} +1 -1
  58. package/dist/web/assets/{c4Diagram-YGBWAQC7-5UHyT_tW.js → c4Diagram-YGBWAQC7-ChXo3l8L.js} +1 -1
  59. package/dist/web/assets/channel-Cu3Nk4LM.js +1 -0
  60. package/dist/web/assets/{chunk-3FUC2YCW-CelgLyJi.js → chunk-3FUC2YCW-0nm_NDgT.js} +1 -1
  61. package/dist/web/assets/{chunk-5DYCD2WN-bhEAVIbQ.js → chunk-5DYCD2WN-C58aRWSu.js} +1 -1
  62. package/dist/web/assets/{chunk-742MDFTN-ByLz4EcD.js → chunk-742MDFTN-C6l3D224.js} +1 -1
  63. package/dist/web/assets/{chunk-7INBJB4K-CK2i0csa.js → chunk-7INBJB4K-DLyHAyF6.js} +1 -1
  64. package/dist/web/assets/{chunk-7M6MHVWA-CoCiujjC.js → chunk-7M6MHVWA-CbFCWaeF.js} +1 -1
  65. package/dist/web/assets/{chunk-7PRAP22T-CY4NCrCY.js → chunk-7PRAP22T-uKx0VBKu.js} +1 -1
  66. package/dist/web/assets/{chunk-GTNCS2PH-DMB7bQGB.js → chunk-GTNCS2PH-BfITava8.js} +1 -1
  67. package/dist/web/assets/{chunk-GWA4HPMP-F6qclPTR.js → chunk-GWA4HPMP-r1dIv3WY.js} +1 -1
  68. package/dist/web/assets/{chunk-MBY4JIJT-CSiiBtlX.js → chunk-MBY4JIJT-B32SiYZc.js} +1 -1
  69. package/dist/web/assets/{chunk-NETBCI7D-CBh7GChL.js → chunk-NETBCI7D-BJDpswNY.js} +1 -1
  70. package/dist/web/assets/{chunk-O7XYJQB3-B2aqz0sN.js → chunk-O7XYJQB3-oJNN75W6.js} +1 -1
  71. package/dist/web/assets/{chunk-UA2S7LBM-B_-P_eR0.js → chunk-UA2S7LBM-6edzXzPi.js} +1 -1
  72. package/dist/web/assets/{chunk-WEXAMYUT-19nMFxzK.js → chunk-WEXAMYUT-DyPRB2PO.js} +1 -1
  73. package/dist/web/assets/{chunk-Z7XXMR3K-Co7gzRY1.js → chunk-Z7XXMR3K-oLw57LFn.js} +1 -1
  74. package/dist/web/assets/{chunk-ZIGJFQKS-Eoi4K5bK.js → chunk-ZIGJFQKS-CJBzn5zg.js} +1 -1
  75. package/dist/web/assets/{classDiagram-v2-NBCMYWYE-CfFGzL-A.js → classDiagram-v2-NBCMYWYE-CGtUk3Eo.js} +1 -1
  76. package/dist/web/assets/copy-code-XoEu_Yh6.js +1 -0
  77. package/dist/web/assets/{cynefin-OW5HDTMX-DdZXl3KU.js → cynefin-OW5HDTMX-B2067LSs.js} +1 -1
  78. package/dist/web/assets/{cynefinDiagram-VND7K2PF-WU21nYH-.js → cynefinDiagram-VND7K2PF-BTclidTB.js} +1 -1
  79. package/dist/web/assets/{dagre-6A5THRUB-48z3F8Pl.js → dagre-6A5THRUB-DB4EkNP1.js} +1 -1
  80. package/dist/web/assets/date-CGiGHbuG.js +1 -0
  81. package/dist/web/assets/{devices-store-Bfk2wdrQ.js → devices-store-B8mC7cJR.js} +1 -1
  82. package/dist/web/assets/{diagram-22UHCM2B-B0AZgJW9.js → diagram-22UHCM2B-BZBpUYfR.js} +1 -1
  83. package/dist/web/assets/{diagram-3UASUU5V-CzEzrxuO.js → diagram-3UASUU5V-B3HmzPy0.js} +1 -1
  84. package/dist/web/assets/{diagram-ATOU4E4O-w6h1bD3w.js → diagram-ATOU4E4O-qofz53Y-.js} +1 -1
  85. package/dist/web/assets/{diagram-CDSNMT55-BdTHQd_o.js → diagram-CDSNMT55-tTpd7EcE.js} +1 -1
  86. package/dist/web/assets/{diagram-MLGK6HIB-pB4cj6c2.js → diagram-MLGK6HIB-CedlreDP.js} +1 -1
  87. package/dist/web/assets/{diagram-MPIPVDR6-C7C06qyV.js → diagram-MPIPVDR6-CTlN_TMA.js} +1 -1
  88. package/dist/web/assets/{diff-store-DBDL5Hw5.js → diff-store-DCqGPv2R.js} +1 -1
  89. package/dist/web/assets/{dist-DqSUdjG9.js → dist-BHgrth9o.js} +1 -1
  90. package/dist/web/assets/{dist-B8i8y3GY.js → dist-BS_doyKJ.js} +1 -1
  91. package/dist/web/assets/{dist-CJ7isxli.js → dist-BuXYTGDD.js} +1 -1
  92. package/dist/web/assets/{dist-DWCdhk-O.js → dist-CF9ODvoZ.js} +1 -1
  93. package/dist/web/assets/{dist-B7Cu0FBW.js → dist-CFdeFDlk.js} +1 -1
  94. package/dist/web/assets/{dist-DNpwgDba.js → dist-Cv-Fe2Re.js} +1 -1
  95. package/dist/web/assets/{dist-BWoPV5P3.js → dist-DAm4VXST.js} +1 -1
  96. package/dist/web/assets/{dist-Dy9K9LOR.js → dist-DlzjGKj2.js} +1 -1
  97. package/dist/web/assets/{dist-BNgPhnKo.js → dist-DwGuvnij.js} +1 -1
  98. package/dist/web/assets/{dist-CTJJYvYG.js → dist-SkTbIIlQ.js} +1 -1
  99. package/dist/web/assets/{dist-CZz0KXAA.js → dist-fjYccora.js} +1 -1
  100. package/dist/web/assets/{ebnfDiagram-ZINNZB2B-75vt7539.js → ebnfDiagram-ZINNZB2B-Di4zjxQE.js} +1 -1
  101. package/dist/web/assets/{elk-276RUBZZ-DXDjJVTy.js → elk-276RUBZZ-CjtSBFK8.js} +1 -1
  102. package/dist/web/assets/{erDiagram-OPXOYQCR-tNNInTmk.js → erDiagram-OPXOYQCR-BAOsBVor.js} +1 -1
  103. package/dist/web/assets/eventmodeling-NTZA5JFV-BTRmtj-P.js +1 -0
  104. package/dist/web/assets/flowDiagram-KWPJA3E3-BnACTh86.js +1 -0
  105. package/dist/web/assets/{ganttDiagram-FUAMR5RP-h3E8lnCd.js → ganttDiagram-FUAMR5RP-CdIWLsvk.js} +1 -1
  106. package/dist/web/assets/{gitGraph-4MIJSDKK-BUwY-iv2.js → gitGraph-4MIJSDKK-CiEyJnox.js} +1 -1
  107. package/dist/web/assets/{gitGraphDiagram-X574FWY7-C0AgQH5k.js → gitGraphDiagram-X574FWY7-Dqbm_rnl.js} +1 -1
  108. package/dist/web/assets/{i18n-CfTd46OX.js → i18n-DQIEbAYL.js} +1 -1
  109. package/dist/web/assets/{icons-BoaZfGBj.js → icons-32lVhhPQ.js} +1 -1
  110. package/dist/web/assets/index-CstRcwnO.js +2 -0
  111. package/dist/web/assets/{info-A6RAGUB7-DVMZ4hdK.js → info-A6RAGUB7-B7ToPgJe.js} +1 -1
  112. package/dist/web/assets/{infoDiagram-VRGFBTTK-kLaGXGQ2.js → infoDiagram-VRGFBTTK-BG80lPJs.js} +1 -1
  113. package/dist/web/assets/{init-app-Bxc_l2yT.js → init-app-CJnqt1pf.js} +1 -1
  114. package/dist/web/assets/{ishikawaDiagram-OU5B5YK6-Oj7bZlyo.js → ishikawaDiagram-OU5B5YK6-npZEyIq2.js} +1 -1
  115. package/dist/web/assets/{journeyDiagram-ZHPQQLJL-BG7V2Srs.js → journeyDiagram-ZHPQQLJL-B-tnjlCW.js} +1 -1
  116. package/dist/web/assets/{kanban-definition-PNTS6WVX-CIJiDE6d.js → kanban-definition-PNTS6WVX-BD6VBvkF.js} +1 -1
  117. package/dist/web/assets/{line-D2vEKYRZ.js → line-B-rS1U0C.js} +1 -1
  118. package/dist/web/assets/{markdown-Bm2wfvok.js → markdown-B6YvDSFL.js} +1 -1
  119. package/dist/web/assets/{mdi-Byw1NZrZ.js → mdi-Du7sIqtH.js} +1 -1
  120. package/dist/web/assets/{mermaid-parser.core-BcBEWjW6.js → mermaid-parser.core-kANV9P4V.js} +3 -3
  121. package/dist/web/assets/{mermaid.core-DobO0deo.js → mermaid.core-ImeEME4Y.js} +4 -4
  122. package/dist/web/assets/{mindmap-definition-NLK3R4M7-y7Qz5gBF.js → mindmap-definition-NLK3R4M7-Cg9Hkqnh.js} +1 -1
  123. package/dist/web/assets/{packet-AYTQ26CC-oRQgKr9d.js → packet-AYTQ26CC-dKpEZS3R.js} +1 -1
  124. package/dist/web/assets/{pegDiagram-GJSIUBJH-RIU8XOHq.js → pegDiagram-GJSIUBJH-DSTd7SKp.js} +1 -1
  125. package/dist/web/assets/{pie-WAS4IAKB-qmOlsLkx.js → pie-WAS4IAKB-NrwitlRB.js} +1 -1
  126. package/dist/web/assets/{pieDiagram-5QR66LMP-BM932TDA.js → pieDiagram-5QR66LMP-vWxrQ_73.js} +1 -1
  127. package/dist/web/assets/pinia-C077q3zy.js +1 -0
  128. package/dist/web/assets/{preview-store-D7qF6SgX.js → preview-store-C47JAa3k.js} +1 -1
  129. package/dist/web/assets/{project-files-store-OhGs_H7U.js → project-files-store-DPvUB6jk.js} +1 -1
  130. package/dist/web/assets/{quadrantDiagram-O4NWA36T-qQZYwTy7.js → quadrantDiagram-O4NWA36T-BrazmucR.js} +1 -1
  131. package/dist/web/assets/{radar-RG4KPBEZ-Kdglk6eY.js → radar-RG4KPBEZ-Ltw4_W0Z.js} +1 -1
  132. package/dist/web/assets/{railroad-74A4TZTK-kVhMYx4I.js → railroad-74A4TZTK-Bsvkcqwe.js} +1 -1
  133. package/dist/web/assets/railroad-abnf-HS5TGJTU-DzeS0TZG.js +1 -0
  134. package/dist/web/assets/railroad-ebnf-LZEXJU2U-DOMFcjan.js +1 -0
  135. package/dist/web/assets/railroad-peg-WCYAUIDC-Boo02G-z.js +1 -0
  136. package/dist/web/assets/{railroadDiagram-XR7U4H2S-K9SrOGcT.js → railroadDiagram-XR7U4H2S-Drf-1KoA.js} +1 -1
  137. package/dist/web/assets/{requirementDiagram-PLB6GJNP-CtA9ZKEx.js → requirementDiagram-PLB6GJNP-RQQXWoKw.js} +1 -1
  138. package/dist/web/assets/{safe-storage-CvVvrm4v.js → safe-storage-CCnof8j6.js} +1 -1
  139. package/dist/web/assets/{sankeyDiagram-IPEJSGJF-BH_t4exx.js → sankeyDiagram-IPEJSGJF-DjkRjZ96.js} +1 -1
  140. package/dist/web/assets/{sequenceDiagram-PO4LG4MO-hPNPqpDa.js → sequenceDiagram-PO4LG4MO-BnYL3Nyj.js} +1 -1
  141. package/dist/web/assets/{stateDiagram-v2-GCMORJYK-BVmjZDRD.js → stateDiagram-v2-GCMORJYK-Cx-m_iwa.js} +1 -1
  142. package/dist/web/assets/{swimlanes-2SLR337P-Tgk8-6jj.js → swimlanes-2SLR337P-Eq9rHxT4.js} +1 -1
  143. package/dist/web/assets/swimlanesDiagram-TC7HE7FX-C1gvms2M.js +8 -0
  144. package/dist/web/assets/{terminal-store-CKgJjtC6.js → terminal-store-CDMBoz3b.js} +1 -1
  145. package/dist/web/assets/theme-DFHUOPmw.js +1 -0
  146. package/dist/web/assets/{timeline-definition-EJHVYXUP-lAIHEGe8.js → timeline-definition-EJHVYXUP-DZ8AD5x7.js} +1 -1
  147. package/dist/web/assets/{treeView-Q6P3EWNA-CdvzKtWf.js → treeView-Q6P3EWNA-PHr7W0fs.js} +1 -1
  148. package/dist/web/assets/{treemap-WGGIJYW6-BxDSJfrJ.js → treemap-WGGIJYW6-cMDLpLd1.js} +1 -1
  149. package/dist/web/assets/{use-add-agent-context-BSHGL5iV.js → use-add-agent-context-ZAOlkMkH.js} +1 -1
  150. package/dist/web/assets/use-app-theme-Cp7uwqoM.js +1 -0
  151. package/dist/web/assets/use-row-height-FkeCYR5M.js +1 -0
  152. package/dist/web/assets/{use-workspace-navigation-AaRMWmjQ.js → use-workspace-navigation-BX_9XPWm.js} +1 -1
  153. package/dist/web/assets/{usecaseDiagram-POWQR4AR-BH3KvMpz.js → usecaseDiagram-POWQR4AR-oTDoBpor.js} +1 -1
  154. package/dist/web/assets/{vennDiagram-UO4OBE2U-Dfi-raKp.js → vennDiagram-UO4OBE2U-BSLtd6XL.js} +1 -1
  155. package/dist/web/assets/{wardley-WFR3VGLG-DmbtSM24.js → wardley-WFR3VGLG-C4C0SgxA.js} +1 -1
  156. package/dist/web/assets/{wardleyDiagram-VNRHLVJA-DIzhGvI7.js → wardleyDiagram-VNRHLVJA-RjwVQTqN.js} +1 -1
  157. package/dist/web/assets/{workspace-navigation-xfxULfM7.js → workspace-navigation-kGtTYXtT.js} +1 -1
  158. package/dist/web/assets/{xychartDiagram-PMCCYNJV-XgqSHH_N.js → xychartDiagram-PMCCYNJV-BPvXe6DY.js} +1 -1
  159. package/dist/web/index.html +2 -1
  160. package/package.json +1 -1
  161. package/dist/web/assets/AddToAgentButton-BitTdCEg.js +0 -1
  162. package/dist/web/assets/ArtifactDefinitionPicker-BQOoU60k.js +0 -3
  163. package/dist/web/assets/ArtifactsPage-BJR-XYYs.js +0 -2
  164. package/dist/web/assets/ClosePopup-BtDeDZsk.js +0 -1
  165. package/dist/web/assets/DialogHeader-CQvAZVx7.js +0 -1
  166. package/dist/web/assets/IndexPage-DMTZyWQA.js +0 -1
  167. package/dist/web/assets/MainLayout-DWJ_-EMV.js +0 -7
  168. package/dist/web/assets/NotesPage-CjaxjLBW.js +0 -4
  169. package/dist/web/assets/QFab-CxpedhPI.js +0 -1
  170. package/dist/web/assets/QScrollArea-js5dkUz1.js +0 -1
  171. package/dist/web/assets/QTabs-DIy5gWMb.js +0 -1
  172. package/dist/web/assets/QVirtualScroll-DkD0TczV.js +0 -1
  173. package/dist/web/assets/ReviewDetailPage-Bmc8DmSu.js +0 -1
  174. package/dist/web/assets/ReviewsPage-DR1IWCmu.js +0 -1
  175. package/dist/web/assets/StatusSwitcher-CpkH8BuC.js +0 -1
  176. package/dist/web/assets/architecture-7GRP2DOG-DmaYCP8T.js +0 -1
  177. package/dist/web/assets/channel-B4_5H-fb.js +0 -1
  178. package/dist/web/assets/copy-code-DaqEfdXL.js +0 -1
  179. package/dist/web/assets/date-DZIuZp-w.js +0 -1
  180. package/dist/web/assets/eventmodeling-NTZA5JFV-QMRehQtM.js +0 -1
  181. package/dist/web/assets/flowDiagram-KWPJA3E3-DJAgsbXC.js +0 -1
  182. package/dist/web/assets/index-BTpokWxr.js +0 -2
  183. package/dist/web/assets/pinia-Cmp1GI8k.js +0 -1
  184. package/dist/web/assets/railroad-abnf-HS5TGJTU-MiKgjOXr.js +0 -1
  185. package/dist/web/assets/railroad-ebnf-LZEXJU2U-BFL9h7Zx.js +0 -1
  186. package/dist/web/assets/railroad-peg-WCYAUIDC-ChGGfIn7.js +0 -1
  187. package/dist/web/assets/swimlanesDiagram-TC7HE7FX-W1zDrK61.js +0 -8
  188. package/dist/web/assets/theme-slSxLNv6.js +0 -1
  189. package/dist/web/assets/use-app-theme-fmVV2QM8.js +0 -1
  190. package/dist/web/assets/use-panel-width-BYOPuHat.js +0 -1
  191. package/dist/web/assets/use-row-height-BK8kNihW.js +0 -1
package/README.md CHANGED
@@ -197,6 +197,18 @@ node dist/cli/index.js list --db-prefix "" # .githuman/reviews.db (ор
197
197
  `--api-docs` (`pnpm run dev:server` включает его сам). Агентские маршруты
198
198
  попадают в спецификацию, только когда сервер их отдаёт (loopback-хост).
199
199
 
200
+ ### Отладка валидации артефактов
201
+
202
+ `githuman serve --debug-validation` не закрывает сессию валидатора после
203
+ завершения проверки и пишет в консоль сервера строки `[validation <id>]`:
204
+ статусы сессии, вызовы инструментов, решения политики прав (ALLOW/DENY с
205
+ причиной), причину завершения. Текст вывода инструментов обрезается до 500
206
+ символов. Флаг нужен для отладки: в обычном режиме сессия закрывается, а
207
+ причина остановки валидатора нигде не сохраняется. Оставленная открытой
208
+ сессия не занимает место в лимите чатов: новый чат закрывает самую старую
209
+ завершённую сессию валидатора. Ключ артефактной сессии такого валидатора
210
+ отзывается сразу при завершении проверки.
211
+
200
212
  ## Разработка
201
213
 
202
214
  Два процесса нужны одновременно — backend (Fastify, автоперезапуск при
@@ -88,6 +88,7 @@ export function parseServeArgs(argv) {
88
88
  'notes-dir': { type: 'string' },
89
89
  'db-prefix': { type: 'string' },
90
90
  'api-docs': { type: 'boolean', default: false },
91
+ 'debug-validation': { type: 'boolean', default: false },
91
92
  'allowed-host': { type: 'string', multiple: true }
92
93
  },
93
94
  strict: false
@@ -101,6 +102,7 @@ export function parseServeArgs(argv) {
101
102
  throw new Error(`Invalid --db-prefix value "${dbPrefix}": use at most 100 characters and none of < > : " / \\ | ? * or control characters. Set it with --db-prefix or GITHUMAN_DB_PREFIX.`);
102
103
  }
103
104
  const apiDocs = values['api-docs'] === true;
105
+ const debugValidation = values['debug-validation'] === true;
104
106
  const allowedHosts = resolveAllowedHosts(Array.isArray(values['allowed-host']) ? values['allowed-host'] : []);
105
107
  return {
106
108
  port,
@@ -108,6 +110,7 @@ export function parseServeArgs(argv) {
108
110
  open,
109
111
  dbPrefix,
110
112
  apiDocs,
113
+ debugValidation,
111
114
  allowedHosts,
112
115
  ...(typeof values['notes-dir'] === 'string'
113
116
  ? { notesDir: values['notes-dir'] }
@@ -69,6 +69,10 @@ export async function startServer(options) {
69
69
  artifactPrefix: resolveDbPrefix(options.dbPrefix),
70
70
  watchFiles: true,
71
71
  apiDocs: options.apiDocs === true,
72
+ // The server logs are off; validation debug lines go to the console.
73
+ ...(options.debugValidation === true
74
+ ? { onValidationDebug: (line) => console.log(line) }
75
+ : {}),
72
76
  allowedHosts: options.allowedHosts ?? [],
73
77
  onPairingRequest: device => console.log(`Pairing request from ${device.name} (${device.address}). Enter the code from the device in a localhost tab.`),
74
78
  terminalEnabled: true,
@@ -163,6 +163,9 @@ export function buildApp(options = {}) {
163
163
  });
164
164
  artifacts.lifecycle.transitions.setActorGuard(actor => actor.id === 'local' || devices.isTrusted(actor.id));
165
165
  artifacts.lifecycle.transitions.setErrorHandler(error => app.log.error({ err: error }, 'Artifact transition work failed'));
166
+ if (options.onValidationDebug) {
167
+ artifacts.lifecycle.jobs.setDebug(options.onValidationDebug);
168
+ }
166
169
  app.register(artifactTransitionRoutes, {
167
170
  service: artifacts.lifecycle.transitions,
168
171
  recheckAccess: createAccessHook({ devices, allowedHosts })
@@ -51,6 +51,11 @@ export function applyArtifactValidationMigrations(db) {
51
51
  operation_id TEXT NOT NULL, payload_json TEXT NOT NULL,
52
52
  UNIQUE(store_id,operation_id)
53
53
  );
54
+ CREATE TABLE IF NOT EXISTS artifact_validation_reports (
55
+ store_id TEXT NOT NULL, validation_id TEXT NOT NULL, sha256 TEXT NOT NULL,
56
+ bytes INTEGER NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL,
57
+ PRIMARY KEY(store_id,validation_id)
58
+ );
54
59
  CREATE TABLE IF NOT EXISTS artifact_lifecycle_commits (
55
60
  seq INTEGER PRIMARY KEY AUTOINCREMENT, store_id TEXT NOT NULL,
56
61
  request_id TEXT NOT NULL, payload_hash TEXT NOT NULL, result_json TEXT NOT NULL,
@@ -65,6 +70,7 @@ export function applyArtifactValidationMigrations(db) {
65
70
  artifact_transition_events: 'seq=NEW.seq',
66
71
  artifact_transition_requests: 'seq=NEW.seq OR (store_id=NEW.store_id AND request_id=NEW.request_id)',
67
72
  artifact_transition_consents: 'seq=NEW.seq OR (store_id=NEW.store_id AND operation_id=NEW.operation_id)',
73
+ artifact_validation_reports: 'store_id=NEW.store_id AND validation_id=NEW.validation_id',
68
74
  artifact_lifecycle_commits: 'seq=NEW.seq OR (store_id=NEW.store_id AND request_id=NEW.request_id)'
69
75
  });
70
76
  }
@@ -5,6 +5,7 @@ const BUSY = Symbol('artifact.busy');
5
5
  const LEASE_BUSY = Symbol('artifact.leaseBusy');
6
6
  export const ArtifactInputError = createError('GHT_ARTIFACT_INVALID_INPUT', '%s', 400);
7
7
  export const ArtifactNotFoundError = createError('GHT_ARTIFACT_NOT_FOUND', 'Artifact not found.', 404);
8
+ export const ArtifactReportTextNotFoundError = createError('GHT_ARTIFACT_REPORT_TEXT_NOT_FOUND', 'The validation has no report text.', 404);
8
9
  export const ArtifactConflictError = createError('GHT_ARTIFACT_CONFLICT', '%s', 409);
9
10
  function marked(message, marker) {
10
11
  return Object.assign(new ArtifactConflictError(message), { [marker]: true });
@@ -1,5 +1,10 @@
1
1
  import { isLoopbackHost } from "../../../shared/network/loopback.js";
2
2
  import { forwardedAddresses, isLoopbackAddress, parseHost, sameOrigin } from "../../utils/request-origin.js";
3
+ /** True when the `Host` header names this machine (`localhost`, `127.0.0.1`, `[::1]`). */
4
+ export function isLoopbackHostHeader(host) {
5
+ const target = parseHost(host);
6
+ return target !== undefined && isLoopbackHost(target.hostname);
7
+ }
3
8
  /**
4
9
  * True when the request comes from a page or a client on this machine
5
10
  * (ADR 0079). The `Host` header alone is not proof: with a bind on
@@ -2,7 +2,7 @@ import { ARTIFACT_BRIDGE_ROUTE } from "../../../shared/artifacts/mcp-constants.j
2
2
  import { DEVICE_IDLE_TTL_MS } from "../../../shared/devices/constants.js";
3
3
  import { ForbiddenError, UnauthorizedError } from "../../errors/http.js";
4
4
  import { clientAddress, sameOrigin } from "../../utils/request-origin.js";
5
- import { isLocalRequest } from "./local-request.js";
5
+ import { isLocalRequest, isLoopbackHostHeader } from "./local-request.js";
6
6
  // Methods that do not change state; CSRF checks the others.
7
7
  const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
8
8
  // API routes that a browser without access needs to request pairing.
@@ -50,7 +50,10 @@ export function createAccessHook(options) {
50
50
  const { host, origin } = request.headers;
51
51
  const local = isLocalRequest(request);
52
52
  if (!local && !allowedHosts.allows(host)) {
53
- throw new ForbiddenError(`Unknown Host "${host ?? '-'}". Open the app by the IP address of this machine, or start the server with --allowed-host.`);
53
+ // A loopback Host that fails here has a non-local Origin, proxy address or peer.
54
+ throw new ForbiddenError(isLoopbackHostHeader(host)
55
+ ? `The request to Host "${host}" is not local: its Origin, X-Forwarded-For or peer address is not on this machine.`
56
+ : `Unknown Host "${host ?? '-'}". Open the app by the IP address of this machine, or start the server with --allowed-host.`);
54
57
  }
55
58
  if (origin !== undefined &&
56
59
  !SAFE_METHODS.has(request.method) &&
@@ -52,6 +52,23 @@ export class ArtifactValidationRepository {
52
52
  .prepare('INSERT INTO artifact_validation_current VALUES(?,?,?) ON CONFLICT(store_id,id) DO UPDATE SET payload_json=excluded.payload_json')
53
53
  .run(this.storeId, value.id, payload);
54
54
  }
55
+ reportText(id) {
56
+ const row = this.db
57
+ .prepare('SELECT body FROM artifact_validation_reports WHERE store_id=? AND validation_id=?')
58
+ .get(this.storeId, id);
59
+ return row?.body;
60
+ }
61
+ /** Stores the text once; the same text again is a no-op. Returns false for different text. */
62
+ appendReportText(id, body, summary, now) {
63
+ const previous = this.reportText(id);
64
+ if (previous !== undefined) {
65
+ return previous === body;
66
+ }
67
+ this.db
68
+ .prepare('INSERT INTO artifact_validation_reports(store_id,validation_id,sha256,bytes,body,created_at) VALUES(?,?,?,?,?,?)')
69
+ .run(this.storeId, id, summary.sha256, summary.bytes, body, now);
70
+ return true;
71
+ }
55
72
  history(id, afterSeq = 0, limit = 50) {
56
73
  return this.db
57
74
  .prepare('SELECT seq,payload_json FROM artifact_validation_events WHERE store_id=? AND id=? AND seq>? ORDER BY seq LIMIT ?')
@@ -118,6 +135,17 @@ export class ArtifactValidationRepository {
118
135
  .prepare('INSERT INTO artifact_authorization_events(store_id,id,kind,payload_json) VALUES(?,?,?,?)')
119
136
  .run(this.storeId, id, kind, JSON.stringify(data));
120
137
  }
138
+ /** What the revoke of a pending action left behind: who asked and when it would expire. */
139
+ revoked(id) {
140
+ const row = this.db
141
+ .prepare("SELECT payload_json FROM artifact_authorization_events WHERE store_id=? AND id=? AND kind='revoked' ORDER BY seq DESC LIMIT 1")
142
+ .get(this.storeId, id);
143
+ if (!row) {
144
+ return null;
145
+ }
146
+ const value = JSON.parse(row.payload_json);
147
+ return typeof value === 'object' && value !== null ? value : {};
148
+ }
121
149
  consumed(id) {
122
150
  return Boolean(this.db
123
151
  .prepare("SELECT 1 FROM artifact_authorization_events WHERE store_id=? AND id=? AND kind='consumed'")
@@ -75,6 +75,15 @@ export async function artifactActionRoutes(app, options) {
75
75
  response: { 200: ArtifactReportChunkSchema, ...ERROR_RESPONSES }
76
76
  }
77
77
  }, request => service.jobs.report(request.params.id, request.query.offset));
78
+ app.get('/api/artifact-validations/:id/report-text', {
79
+ schema: {
80
+ tags: ['artifacts'],
81
+ summary: 'Read the Markdown report text of a validation in chunks',
82
+ params: ArtifactParams,
83
+ querystring: ArtifactValidationReportQuery,
84
+ response: { 200: ArtifactReportChunkSchema, ...ERROR_RESPONSES }
85
+ }
86
+ }, request => service.jobs.reportText(request.params.id, request.query.offset));
78
87
  app.get('/api/artifact-validations/:id/history', {
79
88
  schema: {
80
89
  tags: ['artifacts'],
@@ -92,7 +101,7 @@ export async function artifactActionRoutes(app, options) {
92
101
  }
93
102
  }, () => ({
94
103
  storeId: service.storage.storeId,
95
- items: service.authorization.list()
104
+ items: service.listActions()
96
105
  }));
97
106
  app.get('/api/artifact-actions/:id', {
98
107
  schema: {
@@ -19,6 +19,10 @@ export async function artifactAgentRoutes(app, { storage, registry }) {
19
19
  'artifacts.search',
20
20
  checked(ArtifactToolSchemas['artifacts.search'], p => storage.search.search(p))
21
21
  ],
22
+ [
23
+ 'change_sets.preview',
24
+ checked(ArtifactToolSchemas['change_sets.preview'], p => storage.graph.previewChangeSet(p))
25
+ ],
22
26
  [
23
27
  'change_sets.propose',
24
28
  checked(ArtifactToolSchemas['change_sets.propose'], (p, a) => storage.lifecycle.propose(p, a))
@@ -41,7 +45,19 @@ export async function artifactAgentRoutes(app, { storage, registry }) {
41
45
  ],
42
46
  [
43
47
  'validations.report',
44
- checked(ArtifactToolSchemas['validations.report'], p => storage.lifecycle.jobs.report(p.id, p.offset))
48
+ checked(ArtifactToolSchemas['validations.report'], (p, a) => {
49
+ const result = storage.lifecycle.jobs.report(p.id, p.offset);
50
+ storage.lifecycle.jobs.observe(p.id, a.id);
51
+ return result;
52
+ })
53
+ ],
54
+ [
55
+ 'validations.report_text',
56
+ checked(ArtifactToolSchemas['validations.report_text'], (p, a) => {
57
+ const result = storage.lifecycle.jobs.reportText(p.id, p.offset);
58
+ storage.lifecycle.jobs.observe(p.id, a.id);
59
+ return result;
60
+ })
45
61
  ],
46
62
  [
47
63
  'validations.history',
@@ -49,7 +65,11 @@ export async function artifactAgentRoutes(app, { storage, registry }) {
49
65
  ],
50
66
  [
51
67
  'validations.get',
52
- checked(ArtifactToolSchemas['validations.get'], p => storage.lifecycle.jobs.get(p.id))
68
+ checked(ArtifactToolSchemas['validations.get'], (p, a) => {
69
+ const result = storage.lifecycle.jobs.get(p.id);
70
+ storage.lifecycle.jobs.observe(p.id, a.id);
71
+ return result;
72
+ })
53
73
  ],
54
74
  [
55
75
  'validations.manifest',
@@ -59,6 +79,10 @@ export async function artifactAgentRoutes(app, { storage, registry }) {
59
79
  'validations.read',
60
80
  checked(ArtifactToolSchemas['validations.read'], (p, a) => storage.lifecycle.jobs.read(p.id, a.id, p.key, p.offset))
61
81
  ],
82
+ [
83
+ 'validations.read_next',
84
+ checked(ArtifactToolSchemas['validations.read_next'], (p, a) => storage.lifecycle.jobs.readNext(p.id, a.id))
85
+ ],
62
86
  [
63
87
  'validations.submit',
64
88
  checked(ArtifactToolSchemas['validations.submit'], ({ id, ...p }, a) => storage.lifecycle.jobs.submit(id, a.id, p))
@@ -11,6 +11,8 @@ import { describePreset } from "../../config/agents.js";
11
11
  export function createSessionRegistry(options) {
12
12
  const sessions = new Map();
13
13
  const unsubscribeSession = new Map();
14
+ // Finished sessions kept open for inspection; a new session may replace them.
15
+ const retired = new Set();
14
16
  const listeners = new Set();
15
17
  const keys = new Map();
16
18
  const sessionKeys = new Map();
@@ -70,6 +72,17 @@ export function createSessionRegistry(options) {
70
72
  unsubscribeSession.get(id)?.();
71
73
  unsubscribeSession.delete(id);
72
74
  sessions.delete(id);
75
+ retired.delete(id);
76
+ }
77
+ function closeSession(id) {
78
+ const session = sessions.get(id);
79
+ if (!session) {
80
+ return false;
81
+ }
82
+ session.close();
83
+ drop(id);
84
+ broadcast({ type: 'sessions' });
85
+ return true;
73
86
  }
74
87
  function closeAll() {
75
88
  for (const session of sessions.values()) {
@@ -118,6 +131,13 @@ export function createSessionRegistry(options) {
118
131
  if (!preset) {
119
132
  throw new UnknownAgentPresetError(`Unknown agent "${presetId}"`);
120
133
  }
134
+ if (sessions.size >= MAX_AGENT_SESSIONS) {
135
+ // A Set keeps insertion order, so the first retired session is the oldest.
136
+ const [oldest] = retired;
137
+ if (oldest !== undefined) {
138
+ closeSession(oldest);
139
+ }
140
+ }
121
141
  if (sessions.size >= MAX_AGENT_SESSIONS) {
122
142
  throw new TooManyAgentSessionsError(`At most ${MAX_AGENT_SESSIONS} chats can be open at once`);
123
143
  }
@@ -163,16 +183,18 @@ export function createSessionRegistry(options) {
163
183
  return session;
164
184
  },
165
185
  get: id => sessions.get(id),
166
- list: () => [...sessions.values()].map(session => session.info()),
167
- close(id) {
168
- const session = sessions.get(id);
169
- if (!session) {
170
- return false;
186
+ list: () => [...sessions.values()].map(session => ({
187
+ ...session.info(),
188
+ ...(retired.has(session.id) ? { retired: true } : {})
189
+ })),
190
+ close: closeSession,
191
+ retire(id) {
192
+ if (sessions.has(id)) {
193
+ retired.add(id);
194
+ // A retired validator stays open for inspection, but its key must stop working.
195
+ revoke(id);
196
+ broadcast({ type: 'sessions' });
171
197
  }
172
- session.close();
173
- drop(id);
174
- broadcast({ type: 'sessions' });
175
- return true;
176
198
  },
177
199
  closeAll,
178
200
  subscribe(listener, afterId = 0) {
@@ -142,6 +142,26 @@ export class ArtifactAuthorizationService {
142
142
  };
143
143
  }
144
144
  status(id, sessionId) {
145
+ const known = this.pending.get(id) ??
146
+ [...this.grants.values()].find(value => value.pendingActionId === id);
147
+ // The requester learns how its request ended, not only that it is gone.
148
+ if (known?.sessionId === sessionId && known.expiresAt <= this.now()) {
149
+ return {
150
+ id,
151
+ state: 'expired',
152
+ authorizationId: null,
153
+ expiresAt: known.expiresAt
154
+ };
155
+ }
156
+ const revoked = known ? null : this.repository.revoked(id);
157
+ if (revoked?.sessionId === sessionId) {
158
+ return {
159
+ id,
160
+ state: 'revoked',
161
+ authorizationId: null,
162
+ expiresAt: revoked.expiresAt ?? this.now()
163
+ };
164
+ }
145
165
  const value = this.describe(id);
146
166
  if (value.pending.sessionId !== sessionId)
147
167
  throw new ArtifactNotFoundError();
@@ -163,9 +183,14 @@ export class ArtifactAuthorizationService {
163
183
  at: this.now()
164
184
  });
165
185
  }
186
+ const target = this.pending.get(id) ?? grant;
166
187
  this.grants.delete(id);
167
188
  this.pending.delete(id);
168
- this.repository.authorizationEvent(id, 'revoked', { at: this.now() });
189
+ this.repository.authorizationEvent(id, 'revoked', {
190
+ at: this.now(),
191
+ sessionId: target?.sessionId,
192
+ expiresAt: target?.expiresAt
193
+ });
169
194
  this.changed();
170
195
  }
171
196
  }
@@ -1,6 +1,10 @@
1
1
  import { canonicalJson } from "../../shared/utils/canonical-json.js";
2
2
  import { sha256Hex } from "../utils/hash.js";
3
3
  import { allowedTransition } from "../../shared/artifact-transitions/transitions.js";
4
+ // A large store can give hundreds of issues. The full list is in the change set preview.
5
+ const MAX_REPORTED_GRAPH_ISSUES = 20;
6
+ // Limit for artifact IDs in one issue line; the rest show as a count.
7
+ const MAX_REPORTED_ISSUE_ARTIFACTS = 20;
4
8
  // Status that a lifecycle command gives to each of its subjects.
5
9
  const COMMAND_STATUS = {
6
10
  return_to_draft: 'draft',
@@ -10,6 +14,37 @@ const COMMAND_STATUS = {
10
14
  delete: 'deleted',
11
15
  restore: 'draft'
12
16
  };
17
+ /**
18
+ * Builds one error text that names the artifacts of each issue.
19
+ * Without the IDs and current versions, a caller cannot correct the change set.
20
+ * Issues with the same code and text merge into one line.
21
+ */
22
+ export function graphIssuesMessage(issues, versions = []) {
23
+ const current = new Map(versions.map(item => [item.id, item.version]));
24
+ const groups = new Map();
25
+ for (const issue of issues) {
26
+ const key = `${issue.code}\0${issue.message}`;
27
+ const group = groups.get(key) ?? { issue, ids: new Set() };
28
+ for (const id of issue.artifactIds)
29
+ group.ids.add(id);
30
+ groups.set(key, group);
31
+ }
32
+ const lines = [...groups.values()].map(({ issue, ids }) => {
33
+ const all = [...ids].map(id => current.has(id) ? `${id}@v${current.get(id)}` : id);
34
+ const shown = all.slice(0, MAX_REPORTED_ISSUE_ARTIFACTS);
35
+ if (all.length > shown.length) {
36
+ shown.push(`+${all.length - shown.length} more`);
37
+ }
38
+ return shown.length
39
+ ? `${issue.code} [${shown.join(', ')}]: ${issue.message}`
40
+ : `${issue.code}: ${issue.message}`;
41
+ });
42
+ const shown = lines.slice(0, MAX_REPORTED_GRAPH_ISSUES);
43
+ if (lines.length > shown.length) {
44
+ shown.push(`${lines.length - shown.length} more issues. Preview the change set for the full list.`);
45
+ }
46
+ return shown.join(' ');
47
+ }
13
48
  // Each kind is a separate graph that must stay acyclic.
14
49
  function edgeTargets(kind, artifact) {
15
50
  if (kind === 'lineage')
@@ -399,7 +434,7 @@ export function previewArtifactChangeSet(current, input, previouslySuperseded =
399
434
  replacements.set(relation.targetId, node.id);
400
435
  }
401
436
  }
402
- errors.push(...graphIssues([...post.values()], canonical.storeId));
437
+ const postIssues = graphIssues([...post.values()], canonical.storeId);
403
438
  // Compute the component in the union of the old and proposed graphs.
404
439
  // A references target enters the set for existence checks only. It does not expand the set.
405
440
  const referenceTargets = new Set();
@@ -407,7 +442,8 @@ export function previewArtifactChangeSet(current, input, previouslySuperseded =
407
442
  while (changed) {
408
443
  changed = false;
409
444
  const add = (id, viaReference = false) => {
410
- if (!nodes.has(id))
445
+ // Deleted documents were checked when deleted; graph rules still see their metadata.
446
+ if (!nodes.has(id) || nodes.get(id).status === 'deleted')
411
447
  return;
412
448
  if (!affected.has(id)) {
413
449
  affected.add(id);
@@ -443,12 +479,17 @@ export function previewArtifactChangeSet(current, input, previouslySuperseded =
443
479
  add(relation.targetId, true);
444
480
  }
445
481
  }
482
+ // An accepted document of the same task is context only: it does not expand the set (ADR 0119).
446
483
  if (node.status === 'accepted' &&
447
- (node.taskIds.length === 0 || node.taskIds.some(id => tasks.has(id)))) {
448
- add(node.id);
484
+ node.taskIds.some(id => tasks.has(id))) {
485
+ add(node.id, true);
449
486
  }
450
487
  }
451
488
  }
489
+ // An old issue in an unrelated part of the store does not block this change set.
490
+ const before = new Set(graphIssues(current, canonical.storeId).map(issueKey));
491
+ errors.push(...postIssues.filter(issue => !before.has(issueKey(issue)) ||
492
+ issue.artifactIds.some(id => affected.has(id))));
452
493
  for (const expected of canonical.expectedVersions) {
453
494
  if (nodes.get(expected.id)?.metadataVersion !== expected.version) {
454
495
  error('stale_version', [expected.id], 'Expected artifact version no longer matches.');
@@ -484,6 +525,9 @@ export function previewArtifactChangeSet(current, input, previouslySuperseded =
484
525
  includedIn
485
526
  };
486
527
  }
528
+ function issueKey(issue) {
529
+ return canonicalJson([issue.code, issue.artifactIds]);
530
+ }
487
531
  function isAncestor(id, artifact, nodes) {
488
532
  const visited = new Set();
489
533
  let parent = artifact.revisionOf[0];
@@ -7,17 +7,31 @@ import { sha256Hex } from "../utils/hash.js";
7
7
  import { HEARTBEAT_STALE_MS } from "../../shared/artifact-transitions/constants.js";
8
8
  import { hasErrorCode } from "../utils/system-error.js";
9
9
  import { ArtifactConflictError, ArtifactInputError } from "../errors/artifacts.js";
10
- import { canonicalChangeSet, previewArtifactChangeSet } from "./artifact-graph-validation.js";
10
+ import { canonicalChangeSet, graphIssuesMessage, previewArtifactChangeSet } from "./artifact-graph-validation.js";
11
11
  import { ArtifactValidationRepository } from "../repositories/artifact-validation.repo.js";
12
12
  import { ArtifactAuthorizationService, artifactActionDigest } from "./artifact-authorization.service.js";
13
13
  import { ArtifactValidationJobsService } from "./artifact-validation-jobs.service.js";
14
14
  import { ArtifactTransitionRepository } from "../repositories/artifact-transition.repo.js";
15
15
  import { ArtifactTransitionService } from "./artifact-transition.service.js";
16
16
  // Manifest changes invalidate all earlier semantic conclusions.
17
- const VALIDATION_RULES_VERSION = 2;
17
+ const VALIDATION_RULES_VERSION = 3;
18
+ // The server is the author of a proposal version that only follows the grown set (ADR 0121).
19
+ const REBASE_ACTOR = { kind: 'system', id: 'validation-rebase' };
20
+ // Preview errors that a rebase can fix: they only name versions of the set context.
21
+ const REBASE_CODES = new Set(['missing_expected_version', 'stale_version']);
22
+ // The reason in the request list is cut to this length; the full text stays in the proposal.
23
+ const ACTION_REASON_CHARS = 300;
18
24
  export function artifactSessionId(actor) {
19
25
  return actor.kind === 'user' ? `user:${actor.id}` : actor.id;
20
26
  }
27
+ /**
28
+ * The digest covers the affected set, not the store epoch. A store change outside
29
+ * the set does not make a running validation stale (ADR 0119).
30
+ */
31
+ function setDigest(manifest) {
32
+ const { validationEpoch: _epoch, ...content } = manifest;
33
+ return artifactActionDigest(content);
34
+ }
21
35
  export class ArtifactLifecycleService {
22
36
  repository;
23
37
  authorization;
@@ -89,7 +103,7 @@ export class ArtifactLifecycleService {
89
103
  }
90
104
  const preview = this.preview(input.changeSet);
91
105
  if (preview.errors.length) {
92
- throw new ArtifactInputError(preview.errors.map(item => item.message).join(' '));
106
+ throw new ArtifactInputError(graphIssuesMessage(preview.errors, preview.expectedVersions));
93
107
  }
94
108
  const previous = input.id ? this.repository.proposal(input.id) : undefined;
95
109
  if ((previous && previous.version !== input.expectedVersion) ||
@@ -137,6 +151,26 @@ export class ArtifactLifecycleService {
137
151
  };
138
152
  });
139
153
  }
154
+ /** The pending requests, each with a short summary so the user can tell them apart. */
155
+ listActions() {
156
+ return this.storage.catalog.readSnapshot(() => this.authorization.list().map(pending => {
157
+ try {
158
+ const { changeSet } = this.repository.proposalVersion(pending.action.changeSetId, pending.action.version);
159
+ return {
160
+ ...pending,
161
+ summary: {
162
+ command: changeSet.command,
163
+ reason: changeSet.reason.slice(0, ACTION_REASON_CHARS),
164
+ documentCount: changeSet.targetStatuses.length
165
+ }
166
+ };
167
+ }
168
+ catch {
169
+ // A proposal that cannot be read must not hide the request itself.
170
+ return { ...pending, summary: null };
171
+ }
172
+ }));
173
+ }
140
174
  inspectAction(id, actor) {
141
175
  return this.storage.catalog.readSnapshot(() => {
142
176
  const value = this.authorization.describe(id);
@@ -204,7 +238,7 @@ export class ArtifactLifecycleService {
204
238
  changeSetId,
205
239
  version,
206
240
  manifest,
207
- manifestDigest: artifactActionDigest(manifest),
241
+ manifestDigest: setDigest(manifest),
208
242
  state: 'prepared',
209
243
  validatorSessionId: null,
210
244
  owner: null,
@@ -216,7 +250,7 @@ export class ArtifactLifecycleService {
216
250
  manifest(proposal) {
217
251
  const preview = this.preview(proposal.changeSet);
218
252
  if (preview.errors.length) {
219
- throw new ArtifactConflictError(preview.errors.map(item => item.message).join(' '));
253
+ throw new ArtifactConflictError(graphIssuesMessage(preview.errors, preview.expectedVersions));
220
254
  }
221
255
  const artifacts = preview.affectedIds.map(id => this.storage.readVerifiedFile(id).artifact);
222
256
  return {
@@ -228,12 +262,66 @@ export class ArtifactLifecycleService {
228
262
  requiredContentIds: artifacts.map(item => item.id)
229
263
  };
230
264
  }
265
+ /**
266
+ * Gives the next proposal version and manifest of a running validation when only
267
+ * the context of its set changed (ADR 0121). The caller owns the transaction.
268
+ * Returns undefined when the change needs a decision of the proposal author.
269
+ */
270
+ rebaseInTransaction(validation) {
271
+ const proposal = this.repository.proposal(validation.changeSetId);
272
+ if (proposal.version !== validation.version ||
273
+ validation.manifest.rulesVersion !== VALIDATION_RULES_VERSION) {
274
+ return undefined;
275
+ }
276
+ const subjects = new Set(proposal.changeSet.subjectIds);
277
+ const preview = this.preview(proposal.changeSet);
278
+ const decided = proposal.changeSet.supplementDispositions
279
+ .map(item => item.artifactId)
280
+ .sort();
281
+ if (!preview.errors.every(issue => REBASE_CODES.has(issue.code) &&
282
+ issue.artifactIds.every(id => !subjects.has(id))) ||
283
+ canonicalJson(preview.requiredSupplementDispositions) !==
284
+ canonicalJson(decided)) {
285
+ return undefined;
286
+ }
287
+ const command = proposal.changeSet.command;
288
+ // A historical command has no current proposal form; its validation stays stale.
289
+ if (command === 'submit_review') {
290
+ return undefined;
291
+ }
292
+ let next = proposal;
293
+ if (preview.errors.length) {
294
+ next = this.proposeInTransaction({
295
+ requestId: `validation-rebase:${validation.id}:${proposal.version}`,
296
+ id: proposal.id,
297
+ expectedVersion: proposal.version,
298
+ changeSet: {
299
+ ...proposal.changeSet,
300
+ command,
301
+ expectedVersions: preview.expectedVersions
302
+ }
303
+ }, REBASE_ACTOR);
304
+ }
305
+ const manifest = this.manifest(next);
306
+ return { proposal: next, manifest, digest: setDigest(manifest) };
307
+ }
231
308
  assertFresh(validation) {
232
309
  const proposal = this.repository.proposal(validation.changeSetId);
310
+ let digest;
311
+ try {
312
+ digest = setDigest(this.manifest(proposal));
313
+ }
314
+ catch (error) {
315
+ // A store change can make the change set invalid, for example a new accepted
316
+ // artifact without tasks. That is staleness too, so the caller sees one error.
317
+ if (!(error instanceof ArtifactConflictError)) {
318
+ throw error;
319
+ }
320
+ throw new ArtifactConflictError(`VALIDATION_STALE: manifest changed (${error.message}); prepare a new validation.`);
321
+ }
233
322
  if (validation.manifest.rulesVersion !== VALIDATION_RULES_VERSION ||
234
323
  proposal.version !== validation.version ||
235
- artifactActionDigest(this.manifest(proposal)) !==
236
- validation.manifestDigest) {
324
+ digest !== validation.manifestDigest) {
237
325
  throw new ArtifactConflictError('VALIDATION_STALE: manifest changed; prepare a new validation.');
238
326
  }
239
327
  return proposal;
@@ -4,7 +4,7 @@ import { takeWithinBytes } from "../utils/take-within-bytes.js";
4
4
  import { ArtifactTypeService } from "./artifact-type.service.js";
5
5
  import { TaskService } from "./task.service.js";
6
6
  import { ArtifactGraphService } from "./artifact-graph.service.js";
7
- import { artifactIssues } from "./artifact-graph-validation.js";
7
+ import { artifactIssues, graphIssuesMessage } from "./artifact-graph-validation.js";
8
8
  import { ArtifactGraphRepository } from "../repositories/artifact-graph.repo.js";
9
9
  import { TaskRepository } from "../repositories/task.repo.js";
10
10
  import { normalizeArtifact } from "../../shared/artifacts/normalize.js";
@@ -622,7 +622,7 @@ export class ArtifactStorageService {
622
622
  }
623
623
  const issues = artifactIssues(artifact, id => this.catalog.get(id), this.storeId);
624
624
  if (issues.length) {
625
- throw new ArtifactInputError(issues.map(issue => issue.message).join(' '));
625
+ throw new ArtifactInputError(graphIssuesMessage(issues));
626
626
  }
627
627
  }
628
628
  assertLimit(limit) {
@@ -7,6 +7,7 @@ import { HEARTBEAT_STALE_MS, TRANSITION_CONSENT_TTL_MS, TRANSITION_PREVIEW_TTL_M
7
7
  import { isTerminalTransitionStage, transitionCommand, userTransitionTargets } from "../../shared/artifact-transitions/transitions.js";
8
8
  import { sha256Hex } from "../utils/hash.js";
9
9
  import { ArtifactConflictError, ArtifactInputError, artifactLeaseBusyError, isArtifactBusyError, isArtifactLeaseBusyError, ArtifactNotFoundError } from "../errors/artifacts.js";
10
+ import { graphIssuesMessage } from "./artifact-graph-validation.js";
10
11
  // Reconcile cadence; other processes cannot call this process, so it polls.
11
12
  const RECONCILE_INTERVAL_MS = 1000;
12
13
  // Memory bound for unconfirmed previews.
@@ -174,7 +175,7 @@ export class ArtifactTransitionService {
174
175
  }
175
176
  if (!preview.view.canConfirm) {
176
177
  throw new ArtifactConflictError('The transition is blocked: ' +
177
- preview.view.blockers.map(item => item.message).join(' '));
178
+ graphIssuesMessage(preview.view.blockers));
178
179
  }
179
180
  const presetState = this.lifecycle.jobs.presetState(input.presetId);
180
181
  if (presetState !== 'ok') {