diff --git a/package.json b/package.json index e952509..6382966 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "ali-oss": "^6.23.0", "archiver": "^7.0.1", "better-sqlite3": "^12.8.0", + "cheerio": "^1.0.0", "express-fileupload": "^1.5.2", "mammoth": "^1.11.0", "module-alias": "^2.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d6a62b..66cdd58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: better-sqlite3: specifier: ^12.8.0 version: 12.8.0 + cheerio: + specifier: ^1.0.0 + version: 1.2.0 express-fileupload: specifier: ^1.5.2 version: 1.5.2 @@ -769,6 +772,9 @@ packages: '@protobufjs/codegen@2.0.4': resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + '@protobufjs/eventemitter@1.1.0': resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} @@ -781,6 +787,9 @@ packages: '@protobufjs/inquire@1.1.0': resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + '@protobufjs/inquire@1.1.1': + resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} + '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -790,6 +799,9 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -943,15 +955,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/long@4.0.2': - resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@10.17.60': - resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} - '@types/node@25.3.2': resolution: {integrity: sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==} @@ -1042,10 +1048,6 @@ packages: link-preview-js: optional: true - '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': - resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67} - version: 2.0.1 - '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -1219,6 +1221,9 @@ packages: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@1.9.4: resolution: {integrity: sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==} @@ -1275,6 +1280,13 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -1360,6 +1372,13 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1445,6 +1464,19 @@ packages: dom-scroll-into-view@2.0.1: resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -1478,6 +1510,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -1485,6 +1520,14 @@ packages: resolution: {integrity: sha512-OclLMSug+k2A0JKuf494im25ANRBVW8qsjmwbgX7lQ8P82H21PQ1PWkoYwb9y5yMBS69BPlwtzdIFClo3+7kOQ==} engines: {node: '>= 0.11.14'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -1700,6 +1743,9 @@ packages: hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1805,6 +1851,10 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7: + resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7} + version: 6.0.0 + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -1848,9 +1898,6 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - long@4.0.0: - resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} - long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -2011,6 +2058,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2072,6 +2122,15 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -2171,14 +2230,14 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} - protobufjs@6.8.8: - resolution: {integrity: sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==} - hasBin: true - protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} + protobufjs@7.5.8: + resolution: {integrity: sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2577,6 +2636,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unescape@1.0.1: resolution: {integrity: sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==} engines: {node: '>=0.10.0'} @@ -2755,6 +2818,15 @@ packages: warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} @@ -3261,6 +3333,8 @@ snapshots: '@protobufjs/codegen@2.0.4': {} + '@protobufjs/codegen@2.0.5': {} + '@protobufjs/eventemitter@1.1.0': {} '@protobufjs/fetch@1.1.0': @@ -3272,12 +3346,16 @@ snapshots: '@protobufjs/inquire@1.1.0': {} + '@protobufjs/inquire@1.1.1': {} + '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} '@protobufjs/utf8@1.1.0': {} + '@protobufjs/utf8@1.1.1': {} + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -3384,12 +3462,8 @@ snapshots: '@types/estree@1.0.8': {} - '@types/long@4.0.2': {} - '@types/ms@2.1.0': {} - '@types/node@10.17.60': {} - '@types/node@25.3.2': dependencies: undici-types: 7.18.2 @@ -3501,7 +3575,7 @@ snapshots: '@cacheable/node-cache': 1.7.6 '@hapi/boom': 9.1.4 async-mutex: 0.5.0 - libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' + libsignal: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7 lru-cache: 11.2.6 music-metadata: 11.12.1 p-queue: 9.1.0 @@ -3514,11 +3588,6 @@ snapshots: - supports-color - utf-8-validate - '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': - dependencies: - curve25519-js: 0.0.4 - protobufjs: 6.8.8 - '@xmldom/xmldom@0.8.11': {} abort-controller@3.0.0: @@ -3740,6 +3809,8 @@ snapshots: transitivePeerDependencies: - supports-color + boolbase@1.0.0: {} + bowser@1.9.4: {} brace-expansion@2.0.2: @@ -3797,6 +3868,29 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.25.0 + whatwg-mimetype: 4.0.0 + chownr@1.1.4: {} cliui@6.0.0: @@ -3891,6 +3985,16 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + csstype@3.2.3: {} curve25519-js@0.0.4: {} @@ -3943,6 +4047,24 @@ snapshots: dom-scroll-into-view@2.0.1: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@16.6.1: {} dottie@2.0.7: {} @@ -3971,12 +4093,21 @@ snapshots: encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + end-of-stream@1.4.5: dependencies: once: 1.4.0 end-or-error@1.0.1: {} + entities@4.5.0: {} + + entities@6.0.1: {} + entities@7.0.1: {} es-define-property@1.0.1: {} @@ -4254,6 +4385,13 @@ snapshots: hookified@1.15.1: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -4365,6 +4503,11 @@ snapshots: dependencies: readable-stream: 2.3.8 + libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7: + dependencies: + curve25519-js: 0.0.4 + protobufjs: 7.5.8 + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -4397,8 +4540,6 @@ snapshots: lodash@4.17.23: {} - long@4.0.0: {} - long@5.3.2: {} loose-envify@1.4.0: @@ -4548,6 +4689,10 @@ snapshots: normalize-path@3.0.0: {} + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -4596,6 +4741,19 @@ snapshots: pako@1.0.11: {} + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -4697,7 +4855,7 @@ snapshots: process@0.11.10: {} - protobufjs@6.8.8: + protobufjs@7.5.4: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -4709,22 +4867,21 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/long': 4.0.2 - '@types/node': 10.17.60 - long: 4.0.0 + '@types/node': 25.3.2 + long: 5.3.2 - protobufjs@7.5.4: + protobufjs@7.5.8: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.4 + '@protobufjs/codegen': 2.0.5 '@protobufjs/eventemitter': 1.1.0 '@protobufjs/fetch': 1.1.0 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.0 + '@protobufjs/inquire': 1.1.1 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.0 + '@protobufjs/utf8': 1.1.1 '@types/node': 25.3.2 long: 5.3.2 @@ -5198,6 +5355,8 @@ snapshots: undici-types@7.18.2: {} + undici@7.25.0: {} + unescape@1.0.1: dependencies: extend-shallow: 2.0.1 @@ -5327,6 +5486,12 @@ snapshots: dependencies: loose-envify: 1.4.0 + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + which-module@2.0.1: {} which@2.0.2: diff --git a/server/services/devloop/bug-tracker.js b/server/services/devloop/bug-tracker.js new file mode 100644 index 0000000..61d6362 --- /dev/null +++ b/server/services/devloop/bug-tracker.js @@ -0,0 +1,274 @@ +/** + * DevLoop · bugTracker + * + * bugs.ndjson 的 CRUD + 状态机(P-9)。 + * - bugs.ndjson 是 append-only 流。一条 bug 的「当前态」= 该 id 的最新一行。 + * - dedupKey = "{unit_id}:{test_name}"。open() 时同 key 已存在 open/fixing → 不重复,返旧 id。 + * - transition() 走 P-9 状态机校验,非法转换抛错。 + * + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-16 + * docs/prd/newfunction/_spec/sdd-model.md · M-14 + * docs/prd/newfunction/_spec/sdd-process.md · P-9 + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const pathResolver = require('./path-resolver'); + +// 可选依赖:timelineLogger / sseBroker。LoopController 启动时 attach。 +let timelineLogger = null; +let sseBroker = null; +function attachTimelineLogger(t) { timelineLogger = t; } +function attachSseBroker(b) { sseBroker = b; } + +// ──────────────────────────────────────────────────────────────── +// 状态机 +// ──────────────────────────────────────────────────────────────── + +const VALID_TRANSITIONS = { + open: ['fixing', 'wontfix', 'duplicate'], + fixing: ['fixed', 'open'], + fixed: ['verified', 'open'], + verified: ['open'], + wontfix: [], + duplicate: [], +}; + +function canTransition(from, to) { + if (!VALID_TRANSITIONS[from]) return false; + return VALID_TRANSITIONS[from].includes(to); +} + +class InvalidBugTransitionError extends Error { + constructor(from, to, bugId) { + super(`Invalid bug transition for ${bugId}: ${from} → ${to}`); + this.name = 'InvalidBugTransitionError'; + this.from = from; + this.to = to; + this.bugId = bugId; + } +} + +// ──────────────────────────────────────────────────────────────── +// 核心 +// ──────────────────────────────────────────────────────────────── + +function dedupKey(bug) { + const parts = [bug.unit_id || 'unknown', bug.test_name || bug.title || '']; + return parts.join(':'); +} + +function _genBugId(now = new Date()) { + const ts = now.toISOString().replace(/[-:T.Z]/g, '').slice(0, 14); // YYYYMMDDHHMMSS + const tag = crypto.randomBytes(2).toString('hex'); + return `bug-${ts.slice(0,8)}-${ts.slice(8,12)}-${tag}`; +} + +function _now() { return new Date().toISOString(); } + +function _logPath(pid) { return pathResolver.resolveBugsLog(pid); } + +function _appendLine(pid, obj) { + const p = _logPath(pid); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.appendFileSync(p, JSON.stringify(obj) + '\n'); +} + +/** 流式读全部行(小项目 OK;大项目可优化为 readline 流式) */ +function _readAll(pid) { + const p = _logPath(pid); + if (!fs.existsSync(p)) return []; + const raw = fs.readFileSync(p, 'utf8'); + return raw.split('\n').filter(Boolean).map(l => { + try { return JSON.parse(l); } catch { return null; } + }).filter(Boolean); +} + +/** + * 按 bug id 聚合:最后一行 = 终态。 + * @returns Map + */ +function _aggregateLatest(pid) { + const lines = _readAll(pid); + const map = new Map(); + for (const line of lines) { + if (!line.id) continue; + map.set(line.id, line); + } + return map; +} + +// ──────────────────────────────────────────────────────────────── +// open / transition +// ──────────────────────────────────────────────────────────────── + +/** + * 开 bug。如 dedupKey 命中已存在 status=open|fixing 的 bug → 不重复,返旧 bug。 + * + * @param {string} pid + * @param {object} bug - 必填: source, unit_id?, run_id?, test_name?, category, severity, title, detail, affected_units + * @returns Bug (含 id) + */ +function open(pid, bug) { + // dedup 检查 + const key = dedupKey(bug); + const all = _aggregateLatest(pid); + for (const b of all.values()) { + if ((b.status === 'open' || b.status === 'fixing') && + dedupKey(b) === key) { + return b; + } + } + + const id = _genBugId(); + const now = _now(); + const full = { + id, + discovered_at: now, + source: bug.source || 'unit_test', + unit_id: bug.unit_id, + run_id: bug.run_id, + test_name: bug.test_name, + category: bug.category || 'unknown', + severity: bug.severity || 'medium', + title: (bug.title || '(no title)').slice(0, 120), + detail: bug.detail || '', + affected_units: bug.affected_units || (bug.unit_id ? [bug.unit_id] : []), + status: 'open', + status_history: [{ + ts: now, from: '(none)', to: 'open', + by: bug.by || 'tester', + note: bug.note, + }], + fix_refs: [], + }; + _appendLine(pid, full); + + if (timelineLogger) { + timelineLogger.append(pid, { + source: bug.source || 'tester', + kind: 'bug_opened', + ref: id, + summary: full.title, + }); + } + if (sseBroker) { + sseBroker.emit(pid, 'bug.opened', { id, category: full.category, severity: full.severity, title: full.title }); + } + + return full; +} + +/** + * 状态转换。同步发 SSE + timeline。 + */ +function transition(pid, bugId, { to, by, note, fix_refs, duplicate_of } = {}) { + if (!to) throw new Error('transition requires `to` field'); + const map = _aggregateLatest(pid); + const b = map.get(bugId); + if (!b) throw new Error(`bug ${bugId} not found`); + + if (!canTransition(b.status, to)) { + throw new InvalidBugTransitionError(b.status, to, bugId); + } + + const now = _now(); + // 复制一份 + 应用变更 + const next = { + ...b, + status: to, + status_history: [ + ...(b.status_history || []), + { ts: now, from: b.status, to, by: by || 'system', note }, + ], + fix_refs: fix_refs ? [...(b.fix_refs || []), ...fix_refs] : (b.fix_refs || []), + ...(duplicate_of ? { duplicate_of } : {}), + }; + _appendLine(pid, next); + + if (timelineLogger) { + timelineLogger.append(pid, { + source: by || 'system', + kind: to === 'open' ? 'bug_reopened' : 'bug_transitioned', + ref: bugId, + summary: `${b.status} → ${to}`, + }); + } + if (sseBroker) { + sseBroker.emit(pid, 'bug.transitioned', { id: bugId, from: b.status, to, by }); + } + + return next; +} + +// ──────────────────────────────────────────────────────────────── +// 读 +// ──────────────────────────────────────────────────────────────── + +/** + * 列 bug(每个 id 的最新态)。可按 filter 过滤。 + * @param {object} filter - { status?: string[], severity?: string[], unit_id?, since?: ISO date } + */ +function list(pid, filter = {}) { + let out = Array.from(_aggregateLatest(pid).values()); + if (filter.status?.length) { + out = out.filter(b => filter.status.includes(b.status)); + } + if (filter.severity?.length) { + out = out.filter(b => filter.severity.includes(b.severity)); + } + if (filter.unit_id) { + out = out.filter(b => b.unit_id === filter.unit_id); + } + if (filter.since) { + const t = new Date(filter.since).getTime(); + out = out.filter(b => new Date(b.discovered_at).getTime() >= t); + } + return out; +} + +function get(pid, bugId) { + return _aggregateLatest(pid).get(bugId) || null; +} + +/** 完整 status_history(所有 history 条目,含 open 时的初始条目) */ +function history(pid, bugId) { + const b = get(pid, bugId); + if (!b) return []; + return b.status_history || []; +} + +function stats(pid) { + const all = list(pid); + const out = { + open: 0, fixing: 0, fixed: 0, verified: 0, wontfix: 0, duplicate: 0, + by_severity: { critical: 0, high: 0, medium: 0, low: 0 }, + total: all.length, + }; + for (const b of all) { + if (out[b.status] !== undefined) out[b.status]++; + if (out.by_severity[b.severity] !== undefined) out.by_severity[b.severity]++; + } + return out; +} + +module.exports = { + // 写 + open, + transition, + // 读 + list, + get, + history, + stats, + // 工具 + dedupKey, + canTransition, + VALID_TRANSITIONS, + InvalidBugTransitionError, + // 依赖注入 + attachTimelineLogger, + attachSseBroker, +}; diff --git a/server/services/devloop/changelog-writer.js b/server/services/devloop/changelog-writer.js new file mode 100644 index 0000000..dd7e6fb --- /dev/null +++ b/server/services/devloop/changelog-writer.js @@ -0,0 +1,77 @@ +/** + * DevLoop · changelogWriter + * + * 跨 PRD/SDD/units 的版本变更流(M-15)。append-only NDJSON。 + * - 由 DocStore 在 writePRD/writeSDD/writeUnits 成功后调用 + * - 外部 Agent 不直接调 + * + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-17 + * docs/prd/newfunction/_spec/sdd-model.md · M-15 + */ + +const fs = require('fs'); +const path = require('path'); + +const pathResolver = require('./path-resolver'); + +function _logPath(pid) { return pathResolver.resolveChangelog(pid); } + +const VALID_KINDS = ['prd_rev', 'sdd_rev', 'units_rev', 'integration_spec_rev']; + +function append(pid, entry) { + if (!VALID_KINDS.includes(entry.kind)) { + throw new Error(`changelogWriter.append: invalid kind "${entry.kind}"`); + } + if (typeof entry.rev !== 'number') { + throw new Error('changelogWriter.append: rev (number) required'); + } + if (!entry.summary) { + throw new Error('changelogWriter.append: summary required'); + } + + const full = { + ts: new Date().toISOString(), + kind: entry.kind, + rev: entry.rev, + by: entry.by || 'system', + trigger: entry.trigger || 'unknown', + summary: entry.summary, + diff: entry.diff || { + sections_added: [], sections_changed: [], sections_removed: [], + units_added: [], units_changed: [], units_removed: [], + }, + archive_ref: entry.archive_ref || null, + }; + + const line = JSON.stringify(full) + '\n'; + const p = _logPath(pid); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.appendFileSync(p, line); + return full; +} + +function read(pid, opts = {}) { + const p = _logPath(pid); + if (!fs.existsSync(p)) return []; + const limit = opts.limit || 100; + const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean); + + const entries = []; + for (let i = lines.length - 1; i >= 0; i--) { + let e; + try { e = JSON.parse(lines[i]); } catch { continue; } + if (opts.kind && e.kind !== opts.kind) continue; + if (opts.since && new Date(e.ts).getTime() < new Date(opts.since).getTime()) break; + entries.push(e); + if (entries.length >= limit) break; + } + return entries.reverse(); +} + +/** 取某 kind 最近一条 */ +function latest(pid, kind) { + const recent = read(pid, { kind, limit: 1 }); + return recent[0] || null; +} + +module.exports = { append, read, latest, VALID_KINDS }; diff --git a/server/services/devloop/doc-store.js b/server/services/devloop/doc-store.js new file mode 100644 index 0000000..bc502ca --- /dev/null +++ b/server/services/devloop/doc-store.js @@ -0,0 +1,438 @@ +/** + * DevLoop · docStore + * + * PRD / SDD / units.json / integration/spec.json 的读写、归档、校验。 + * 所有 Agent 不直接 fs.writeFile 这些文件 — 必须走 docStore,确保: + * 1. cheerio HTML 校验在写盘前跑(不合规拒收 → ValidationError) + * 2. 旧版自动归档到 history/{kind}-rev-{N}/(或单文件 .json) + * 3. 写完后自动调 changelogWriter.append(如 changelogWriter 已就位) + * + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-9 + * docs/prd/newfunction/_spec/sdd-model.md · M-3 / M-4 / M-2 / M-6 + * + * 注意:prd / sdd 现在是多页 HTML 站点目录而非单文件。 + * - writePRD(pid, files) 接受 [{relPath, content}],逐个校验逐个落盘 + * - readPRD(pid, relPath) 单文件读 + * - listPrdFiles(pid) 列文件 + * 单文件写法(writePRDChapter)也保留为常用 helper。 + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const cheerio = require('cheerio'); + +const pathResolver = require('./path-resolver'); + +// ──────────────────────────────────────────────────────────────── +// Validation +// ──────────────────────────────────────────────────────────────── + +class ValidationError extends Error { + constructor(message, details = []) { + super(message); + this.name = 'ValidationError'; + this.details = details; + } +} + +/** + * 校验单页 PRD/SDD HTML 是否符合规范。 + * - 必含顶级
且 data-rev 数字 + * - 每个 section 必含

+

+ * - PRD 的 data-rev 单调递增(仅检查升序,跳号警告但不报错) + * + * 不强求 8 类信息覆盖(那是 PM/Architect 自检层的事,不在 DocStore 阻塞) + * + * @param {string} html + * @param {'prd'|'sdd'} kind + * @returns {{ ok: boolean, errors: string[] }} + */ +function validateHTML(html, kind) { + const errors = []; + if (typeof html !== 'string' || html.length === 0) { + return { ok: false, errors: ['empty html'] }; + } + + let $; + try { + $ = cheerio.load(html, { decodeEntities: false }); + } catch (e) { + return { ok: false, errors: [`cheerio parse failed: ${e.message}`] }; + } + + const article = $(`article[data-doc="${kind}"]`); + if (article.length === 0) { + errors.push(`missing

`); + return { ok: false, errors }; + } + if (article.length > 1) { + errors.push(`multiple
elements`); + } + + const articleRev = article.first().attr('data-rev'); + if (!articleRev || !/^\d+$/.test(articleRev)) { + errors.push(`article missing or invalid data-rev: "${articleRev}"`); + } + + const sections = article.first().find('section'); + if (sections.length === 0) { + errors.push('article has no
'); + } + + const revs = []; + sections.each((i, el) => { + const $sec = $(el); + if ($sec.find('h3').length === 0) { + errors.push(`section[${i}] missing

`); + } + if ($sec.find('p').length === 0) { + errors.push(`section[${i}] missing

`); + } + const rev = $sec.attr('data-rev'); + if (rev && /^\d+$/.test(rev)) revs.push(parseInt(rev, 10)); + }); + + // section data-rev 单调递增检查(允许相等,因为可能多 section 同 rev) + for (let i = 1; i < revs.length; i++) { + if (revs[i] < revs[i - 1]) { + errors.push(`data-rev not monotonic at section[${i}]: ${revs[i - 1]} → ${revs[i]}`); + } + } + + return { ok: errors.length === 0, errors }; +} + +/** + * 校验 units.json schema(M-2)+ DAG 无环 + * @returns {{ ok, errors }} + */ +function validateUnitsSchema(json) { + const errors = []; + if (!json || typeof json !== 'object') { + return { ok: false, errors: ['units json must be object'] }; + } + for (const k of ['project_id', 'units']) { + if (!json[k]) errors.push(`missing field: ${k}`); + } + if (!Array.isArray(json.units)) { + errors.push('units must be array'); + return { ok: false, errors }; + } + + const idPattern = /^[a-z]+(-[a-z]+)*-(be|fe|mw|db)$/; + const validStatuses = ['pending', 'coding', 'testing', 'failed', 'passed', 'blocked']; + const validTypes = ['be', 'fe', 'mw', 'db']; + const seen = new Set(); + + for (const u of json.units) { + if (!u.id) { + errors.push('unit missing id'); + continue; + } + if (!idPattern.test(u.id)) { + errors.push(`unit id "${u.id}" violates naming pattern`); + } + if (seen.has(u.id)) { + errors.push(`duplicate unit id: ${u.id}`); + } + seen.add(u.id); + if (!validTypes.includes(u.type)) { + errors.push(`unit ${u.id} invalid type: ${u.type}`); + } + if (!validStatuses.includes(u.status)) { + errors.push(`unit ${u.id} invalid status: ${u.status}`); + } + if (!Array.isArray(u.depends_on)) { + errors.push(`unit ${u.id} depends_on must be array`); + } + } + + // 引用完整性 + DAG 无环 + const ids = new Set(json.units.map(u => u.id)); + for (const u of json.units) { + for (const dep of u.depends_on || []) { + if (!ids.has(dep)) { + errors.push(`unit ${u.id} depends_on unknown unit: ${dep}`); + } + } + } + if (errors.length === 0 && hasCycle(json.units)) { + errors.push('units DAG has cycle'); + } + + return { ok: errors.length === 0, errors }; +} + +function hasCycle(units) { + const idx = new Map(units.map((u, i) => [u.id, i])); + const color = new Array(units.length).fill(0); // 0=white, 1=gray, 2=black + let cycle = false; + + function visit(i) { + if (cycle) return; + if (color[i] === 2) return; + if (color[i] === 1) { + cycle = true; + return; + } + color[i] = 1; + for (const dep of units[i].depends_on || []) { + if (idx.has(dep)) visit(idx.get(dep)); + } + color[i] = 2; + } + for (let i = 0; i < units.length && !cycle; i++) visit(i); + return cycle; +} + +// ──────────────────────────────────────────────────────────────── +// PRD / SDD multi-file site +// ──────────────────────────────────────────────────────────────── + +/** 列项目下 prd/ 站点内所有 html 章节相对路径 */ +function listPrdFiles(pid) { + return _listSiteHtmls(pathResolver.resolvePrdSite(pid)); +} + +function listSddFiles(pid) { + return _listSiteHtmls(pathResolver.resolveSddSite(pid)); +} + +function _listSiteHtmls(siteRoot) { + const out = []; + if (!fs.existsSync(siteRoot)) return out; + function walk(dir, prefix) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${e.name}` : e.name; + const full = path.join(dir, e.name); + if (e.isDirectory()) { + if (e.name === 'assets') continue; // assets 不计入 chapter 列表 + walk(full, rel); + } else if (e.name.endsWith('.html')) { + out.push(rel); + } + } + } + walk(siteRoot, ''); + return out.sort(); +} + +/** 读单个 PRD/SDD 章节 */ +function readPrdChapter(pid, relPath) { + const file = path.join(pathResolver.resolvePrdSite(pid), relPath); + if (!fs.existsSync(file)) throw new Error(`PRD chapter not found: ${relPath}`); + return fs.readFileSync(file, 'utf8'); +} + +function readSddChapter(pid, relPath) { + const file = path.join(pathResolver.resolveSddSite(pid), relPath); + if (!fs.existsSync(file)) throw new Error(`SDD chapter not found: ${relPath}`); + return fs.readFileSync(file, 'utf8'); +} + +/** + * 写一个 PRD chapter(HTML)。校验通过 + 旧版(如有)归档 + 落盘。 + * @returns { rev: number, path: string } + */ +function writePrdChapter(pid, relPath, html, opts = {}) { + return _writeChapter(pid, 'prd', relPath, html, opts); +} + +function writeSddChapter(pid, relPath, html, opts = {}) { + return _writeChapter(pid, 'sdd', relPath, html, opts); +} + +function _writeChapter(pid, kind, relPath, html, opts) { + const validation = validateHTML(html, kind); + if (!validation.ok && !opts.skipValidation) { + throw new ValidationError( + `${kind}/${relPath} validation failed`, validation.errors); + } + const siteRoot = kind === 'prd' + ? pathResolver.resolvePrdSite(pid) + : pathResolver.resolveSddSite(pid); + const target = path.join(siteRoot, relPath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + + // 归档旧版(如有) + if (fs.existsSync(target)) { + archiveFile(pid, kind, relPath, fs.readFileSync(target, 'utf8')); + } + + fs.writeFileSync(target, html); + + // 解析 article data-rev 作为新 rev + let rev = 1; + try { + const $ = cheerio.load(html); + const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev'); + if (r && /^\d+$/.test(r)) rev = parseInt(r, 10); + } catch { /* ignore */ } + + return { rev, path: target }; +} + +/** 把旧文件归档到 history/{kind}-rev-{N}/{relPath} */ +function archiveFile(pid, kind, relPath, oldContent) { + // 取旧版的 rev(从 oldContent 解析 article data-rev) + let oldRev = 0; + try { + const $ = cheerio.load(oldContent); + const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev'); + if (r && /^\d+$/.test(r)) oldRev = parseInt(r, 10); + } catch { /* ignore */ } + + const archiveDir = path.join( + pathResolver.resolveHistoryDir(pid), + `${kind}-rev-${oldRev || 'unknown'}` + ); + const archiveTarget = path.join(archiveDir, relPath); + fs.mkdirSync(path.dirname(archiveTarget), { recursive: true }); + fs.writeFileSync(archiveTarget, oldContent); +} + +// ──────────────────────────────────────────────────────────────── +// units.json / integration/spec.json +// ──────────────────────────────────────────────────────────────── + +function readUnits(pid) { + const p = pathResolver.resolveUnitsJson(pid); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeUnits(pid, json, opts = {}) { + const validation = validateUnitsSchema(json); + if (!validation.ok && !opts.skipValidation) { + throw new ValidationError('units.json validation failed', validation.errors); + } + const p = pathResolver.resolveUnitsJson(pid); + + // 归档旧版 + if (fs.existsSync(p)) { + const old = JSON.parse(fs.readFileSync(p, 'utf8')); + const oldRev = old.sdd_rev || 'unknown'; + const archivePath = path.join( + pathResolver.resolveHistoryDir(pid), + `units-rev-${oldRev}.json` + ); + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + fs.writeFileSync(archivePath, JSON.stringify(old, null, 2)); + } + + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(json, null, 2)); + return { rev: json.sdd_rev || 1, path: p }; +} + +function readIntegrationSpec(pid) { + const p = path.join(pathResolver.resolveIntegrationDir(pid), 'spec.json'); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeIntegrationSpec(pid, json) { + const p = path.join(pathResolver.resolveIntegrationDir(pid), 'spec.json'); + fs.mkdirSync(path.dirname(p), { recursive: true }); + + // 校验 involves ⊆ units.json + const units = readUnits(pid); + if (units && Array.isArray(json.cases)) { + const unitIds = new Set(units.units.map(u => u.id)); + const errors = []; + for (const c of json.cases) { + for (const u of c.involves || []) { + if (!unitIds.has(u)) { + errors.push(`case ${c.id} involves unknown unit: ${u}`); + } + } + } + if (errors.length > 0) { + throw new ValidationError('integration spec references unknown units', errors); + } + } + + fs.writeFileSync(p, JSON.stringify(json, null, 2)); + return { path: p }; +} + +// ──────────────────────────────────────────────────────────────── +// Project meta + utilities +// ──────────────────────────────────────────────────────────────── + +function readProjectMeta(pid) { + const p = pathResolver.resolveProjectJson(pid); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, 'utf8')); +} + +function writeProjectMeta(pid, meta) { + const p = pathResolver.resolveProjectJson(pid); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(meta, null, 2)); + return { path: p }; +} + +/** + * canonical JSON + sha256 截 16 hex 字符 + * @param {object} contract + * @returns {string} "sha256:..." + */ +function hashContract(contract) { + if (!contract || typeof contract !== 'object') { + throw new Error('hashContract: contract must be object'); + } + const body = contract.exports || contract; + const normalized = canonical(body); + const full = crypto.createHash('sha256').update(normalized).digest('hex'); + return 'sha256:' + full.slice(0, 16); +} + +function canonical(v) { + if (v === null || typeof v !== 'object') return JSON.stringify(v); + if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']'; + const keys = Object.keys(v).sort(); + return '{' + keys.map(k => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}'; +} + +/** 验证某 Unit 的 contract_hash 与重算一致 */ +function verifyContractHash(unit, contract) { + const recomputed = hashContract(contract); + return { + ok: recomputed === unit.contract_hash, + expected: unit.contract_hash, + actual: recomputed, + }; +} + +module.exports = { + // 校验 + validateHTML, + validateUnitsSchema, + ValidationError, + + // PRD/SDD 多页站点 + listPrdFiles, + listSddFiles, + readPrdChapter, + readSddChapter, + writePrdChapter, + writeSddChapter, + + // units / integration + readUnits, + writeUnits, + readIntegrationSpec, + writeIntegrationSpec, + + // project meta + readProjectMeta, + writeProjectMeta, + + // utils + hashContract, + verifyContractHash, + canonical, + hasCycle, +}; diff --git a/server/services/devloop/git-vault.js b/server/services/devloop/git-vault.js new file mode 100644 index 0000000..c7e6f4c --- /dev/null +++ b/server/services/devloop/git-vault.js @@ -0,0 +1,312 @@ +/** + * DevLoop · gitVault + * + * 每项目一个独立 git repo,LoopController 在每次 Agent 成功完成时调 + * gitVault.commit(...) 落盘一个版本。Agent 自身不动 git。 + * + * - 实现用 child_process.spawn('git', ...);不依赖第三方 npm 包 + * - 所有方法按 pid 串行化(per-pid mutex),避免同项目并发 git 操作打架 + * - commit author 标 Agent 名("PM Agent ") + * - nothing-to-commit 不抛错,返 { ok: true, skipped: true } + * + * 规格:docs/prd/newfunction/_spec/arch-git-versioning.md + * docs/prd/newfunction/_spec/sdd-control.md · C-18 + * docs/prd/newfunction/_spec/sdd-process.md · P-10 + */ + +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const pathResolver = require('./path-resolver'); + +const DEFAULT_GITIGNORE = `# DevLoop project .gitignore + +# 高频写的运行态,不需要版本 +agent-state/ + +# git 本身就是 history +history/ + +# 大日志 +stream.log +units/*/runs/*/stdout.txt +units/*/runs/*/stderr.txt +integration/runs/*/process.log +integration/runs/*/http-trace.ndjson.gz + +# Node / 依赖 +node_modules/ +*.log +.DS_Store +.cache/ +.tmp/ +.coverage/ + +# 测试中间文件 +units/*/.vitest-cache/ +`; + +// per-pid mutex(简单 Promise chain) +const mutexes = new Map(); +function _withLock(pid, fn) { + const prev = mutexes.get(pid) || Promise.resolve(); + const next = prev.then(fn, fn); // 不管前一个成功失败,继续跑下一个 + mutexes.set(pid, next.catch(() => {})); // 防止 unhandled rejection + return next; +} + +// 运行单条 git 命令;返回 { ok, code, stdout, stderr } +function _run(cwd, args, opts = {}) { + return new Promise((resolve) => { + const child = spawn('git', args, { + cwd, + env: { ...process.env, ...(opts.env || {}) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = '', stderr = ''; + child.stdout.on('data', c => { stdout += c.toString(); }); + child.stderr.on('data', c => { stderr += c.toString(); }); + child.on('close', code => { + resolve({ ok: code === 0, code, stdout, stderr }); + }); + child.on('error', err => { + resolve({ ok: false, code: -1, stdout, stderr: err.message }); + }); + }); +} + +// ──────────────────────────────────────────────────────────────── +// API +// ──────────────────────────────────────────────────────────────── + +/** 在项目根 git init + 写 .gitignore + 首个 commit */ +async function init(pid, opts = {}) { + return _withLock(pid, async () => { + const root = pathResolver.resolveProjectRoot(pid); + + // 已是 repo 则跳 + if (fs.existsSync(path.join(root, '.git'))) { + return { ok: true, head: await _head(root), already_exists: true }; + } + + // 1. git init + const r1 = await _run(root, ['init', '-q', '-b', opts.initialBranch || 'main']); + if (!r1.ok) throw new Error(`git init failed: ${r1.stderr}`); + + // 2. 配 author identity(commit 需要) + const userName = opts.name || 'DevLoop'; + const userEmail = opts.email || 'devloop@lemonboss.local'; + await _run(root, ['config', 'user.name', userName]); + await _run(root, ['config', 'user.email', userEmail]); + // 不签名(v1 不强求 GPG) + await _run(root, ['config', 'commit.gpgsign', 'false']); + + // 3. 写 .gitignore + const gitignorePath = path.join(root, '.gitignore'); + if (!fs.existsSync(gitignorePath)) { + fs.writeFileSync(gitignorePath, DEFAULT_GITIGNORE); + } + + // 4. 首个 commit + const r2 = await _run(root, ['add', '.']); + if (!r2.ok) throw new Error(`git add failed: ${r2.stderr}`); + + const initialMsg = opts.initialMessage || + `init: project ${pid}` + (opts.summary ? ` · ${opts.summary}` : ''); + const r3 = await _run(root, ['commit', '-q', '-m', initialMsg, '--allow-empty']); + if (!r3.ok) throw new Error(`initial commit failed: ${r3.stderr}`); + + return { ok: true, head: await _head(root) }; + }); +} + +/** + * Commit current changes. + * @param {string} pid + * @param {object} spec + * - kind: 'init'|'prd'|'sdd'|'code'|'triage'|'integ'|'ship'|'chore' + * - unit_id?: string (code 时填,作为 message 一部分) + * - run_id?: string (integ/triage 时填) + * - summary: string (必填) + * - files?: string[] (限定 add 的路径,默认 git add -A) + * - author?: { name, email } (commit author) + * - skipEmpty?: boolean (默认 true,nothing-to-commit 返 skipped) + * @returns { ok, sha?, skipped? } + */ +async function commit(pid, spec) { + return _withLock(pid, async () => { + const root = pathResolver.resolveProjectRoot(pid); + if (!fs.existsSync(path.join(root, '.git'))) { + throw new Error(`not a git repo: ${root} (call init first)`); + } + if (!spec.summary) throw new Error('commit: summary required'); + + // 1. 加 files(默认 -A,但受 .gitignore 控制) + const addArgs = ['add', '--']; + if (Array.isArray(spec.files) && spec.files.length) { + addArgs.push(...spec.files); + } else { + // -A:把所有变更(包括删除)加进 index + const rA = await _run(root, ['add', '-A']); + if (!rA.ok) throw new Error(`git add -A failed: ${rA.stderr}`); + } + + // 2. 检查是否有可 commit 变化 + const status = await _run(root, ['status', '--porcelain']); + const dirty = status.stdout.trim().length > 0; + if (!dirty) { + if (spec.skipEmpty === false) { + throw new Error('nothing to commit and skipEmpty=false'); + } + return { ok: true, skipped: true }; + } + + // 3. commit + const env = {}; + if (spec.author?.name) env.GIT_AUTHOR_NAME = spec.author.name; + if (spec.author?.email) env.GIT_AUTHOR_EMAIL = spec.author.email; + if (spec.author?.name) env.GIT_COMMITTER_NAME = spec.author.name; + if (spec.author?.email) env.GIT_COMMITTER_EMAIL = spec.author.email; + + const r = await _run(root, ['commit', '-q', '-m', spec.summary], { env }); + if (!r.ok) { + // 兜底:检测 "nothing to commit" 文案 + if (/nothing to commit/i.test(r.stdout + r.stderr)) { + return { ok: true, skipped: true }; + } + throw new Error(`git commit failed: ${r.stderr || r.stdout}`); + } + + const sha = await _head(root); + return { ok: true, sha, kind: spec.kind, summary: spec.summary }; + }); +} + +async function tag(pid, tagName, opts = {}) { + return _withLock(pid, async () => { + const root = pathResolver.resolveProjectRoot(pid); + const args = ['tag']; + if (opts.message) { + args.push('-a', tagName, '-m', opts.message); + } else { + args.push(tagName); + } + if (opts.sha) args.push(opts.sha); + const r = await _run(root, args); + if (!r.ok) throw new Error(`git tag failed: ${r.stderr}`); + return { ok: true, tag: tagName }; + }); +} + +async function log(pid, opts = {}) { + const root = pathResolver.resolveProjectRoot(pid); + const args = ['log', '--format=%H%x09%an%x09%ae%x09%at%x09%s']; + if (opts.limit) args.push(`-n${opts.limit}`); + if (opts.since) args.push(`--since=${opts.since}`); + if (opts.grep) args.push(`--grep=${opts.grep}`); + const r = await _run(root, args); + if (!r.ok) { + // 空仓库 (no commits) → 返空 + if (/does not have any commits/i.test(r.stderr)) return []; + throw new Error(`git log failed: ${r.stderr}`); + } + return r.stdout.trim().split('\n').filter(Boolean).map(line => { + const [sha, author_name, author_email, ts, ...rest] = line.split('\t'); + return { + sha, author_name, author_email, + ts: parseInt(ts, 10), + summary: rest.join('\t'), + }; + }); +} + +async function diff(pid, refA, refB, opts = {}) { + const root = pathResolver.resolveProjectRoot(pid); + const args = ['diff', `${refA}..${refB}`]; + if (opts.path) args.push('--', opts.path); + const r = await _run(root, args); + if (!r.ok) throw new Error(`git diff failed: ${r.stderr}`); + return r.stdout; +} + +async function show(pid, sha) { + const root = pathResolver.resolveProjectRoot(pid); + const r = await _run(root, ['show', '--format=%H%x09%an%x09%ae%x09%at%x09%s%n', sha]); + if (!r.ok) throw new Error(`git show failed: ${r.stderr}`); + return { sha, output: r.stdout }; +} + +async function listTags(pid) { + const root = pathResolver.resolveProjectRoot(pid); + const r = await _run(root, ['tag', '--list', '--sort=-creatordate']); + if (!r.ok) throw new Error(`git tag --list failed: ${r.stderr}`); + return r.stdout.trim().split('\n').filter(Boolean); +} + +async function status(pid) { + const root = pathResolver.resolveProjectRoot(pid); + if (!fs.existsSync(path.join(root, '.git'))) { + return { dirty: false, files: [], no_repo: true }; + } + const r = await _run(root, ['status', '--porcelain']); + if (!r.ok) throw new Error(`git status failed: ${r.stderr}`); + const files = r.stdout.trim().split('\n').filter(Boolean).map(l => l.trim().split(/\s+/, 2)); + return { dirty: files.length > 0, files }; +} + +async function resetWorkingTree(pid, ref = 'HEAD') { + return _withLock(pid, async () => { + const root = pathResolver.resolveProjectRoot(pid); + const r1 = await _run(root, ['reset', '--hard', ref]); + if (!r1.ok) throw new Error(`git reset failed: ${r1.stderr}`); + const r2 = await _run(root, ['clean', '-fd']); + if (!r2.ok) throw new Error(`git clean failed: ${r2.stderr}`); + return { ok: true }; + }); +} + +async function revert(pid, sha) { + return _withLock(pid, async () => { + const root = pathResolver.resolveProjectRoot(pid); + const r = await _run(root, ['revert', '--no-edit', sha]); + if (!r.ok) throw new Error(`git revert failed: ${r.stderr}`); + return { ok: true, newSha: await _head(root) }; + }); +} + +async function archive(pid, ref = 'HEAD', format = 'zip') { + const root = pathResolver.resolveProjectRoot(pid); + const outPath = path.join('/tmp', `${pid}-${ref}.${format}`); + // git archive --format=zip -o /tmp/x.zip HEAD + const r = await _run(root, ['archive', `--format=${format}`, `-o`, outPath, ref]); + if (!r.ok) throw new Error(`git archive failed: ${r.stderr}`); + return { path: outPath }; +} + +// 工具 +async function _head(root) { + const r = await _run(root, ['rev-parse', 'HEAD']); + if (!r.ok) return null; + return r.stdout.trim(); +} + +function _resetMutexesForTesting() { + mutexes.clear(); +} + +module.exports = { + init, + commit, + tag, + log, + diff, + show, + listTags, + status, + resetWorkingTree, + revert, + archive, + DEFAULT_GITIGNORE, + _resetMutexesForTesting, +}; diff --git a/server/services/devloop/path-resolver.js b/server/services/devloop/path-resolver.js new file mode 100644 index 0000000..f76d365 --- /dev/null +++ b/server/services/devloop/path-resolver.js @@ -0,0 +1,325 @@ +/** + * DevLoop · pathResolver + * + * 所有 DevLoop 产物路径的唯一真源。其他服务(docStore / unitRegistry / + * bugTracker / sseBroker / gitVault)都通过这里查路径,绝不硬编码 + * `data/companies/...`。 + * + * 支持三种 mode(见 arch-project-root.html §②): + * - sandbox : 自动生成 root_path = `{dataDir}/companies/{cid}/projects/{pid}/` + * - linked-new : 用户指定 root_path(绝对路径),注册到 ~/.devloop/linked-projects.json + * - linked-existing : v2,本文件预留接口但 register 阶段拒收 + * + * 详尽规格:docs/prd/newfunction/_spec/arch-project-root.md + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const DATA_DIR = path.resolve(__dirname, '../../../data'); +const LINKED_REGISTRY = path.join(os.homedir(), '.devloop', 'linked-projects.json'); + +// 系统敏感路径黑名单(不允许作为 linked-new 的 target_path) +// 注意:os.tmpdir() (e.g. /var/folders/... on macOS, /tmp on Linux) 是允许的, +// 即使前缀匹配 /var;isSensitivePath 内部会先检查 tmpdir 例外。 +const SENSITIVE_PREFIXES = [ + '/etc', '/usr', '/bin', '/sbin', '/boot', + '/var/log', '/var/lib', '/var/db', '/var/spool', '/var/cache', + '/System', '/Library/System', '/private/etc', '/private/var/log', + path.join(os.homedir(), '.ssh'), + path.join(os.homedir(), '.aws'), + path.join(os.homedir(), '.gnupg'), + path.join(os.homedir(), '.config'), + path.join(os.homedir(), 'Downloads'), +]; + +const ALLOWED_EXEMPTIONS = [ + os.tmpdir(), // /var/folders/... (mac) 或 /tmp (linux) + '/tmp', +]; + +// 内存缓存:pid → { root_path, mode, company_id } +let cache = null; + +function loadLinkedRegistry() { + if (!fs.existsSync(LINKED_REGISTRY)) return {}; + try { + return JSON.parse(fs.readFileSync(LINKED_REGISTRY, 'utf8')); + } catch { + return {}; + } +} + +function saveLinkedRegistry(reg) { + fs.mkdirSync(path.dirname(LINKED_REGISTRY), { recursive: true }); + fs.writeFileSync(LINKED_REGISTRY, JSON.stringify(reg, null, 2)); +} + +/** 全量扫描 + linked 注册表,构造内存索引。LoopController 启动时调一次。 */ +function init() { + cache = new Map(); + + // 1. 扫 sandbox 项目(data/companies/*/projects/*/project.json) + if (fs.existsSync(DATA_DIR)) { + const companiesDir = path.join(DATA_DIR, 'companies'); + if (fs.existsSync(companiesDir)) { + for (const cid of fs.readdirSync(companiesDir)) { + const projectsDir = path.join(companiesDir, cid, 'projects'); + if (!fs.existsSync(projectsDir)) continue; + for (const pid of fs.readdirSync(projectsDir)) { + const root = path.join(projectsDir, pid); + const meta = path.join(root, 'project.json'); + if (fs.existsSync(meta)) { + cache.set(pid, { root_path: root, mode: 'sandbox', company_id: cid }); + } + } + } + } + } + + // 2. 叠加 linked 项目 + const linked = loadLinkedRegistry(); + for (const [pid, entry] of Object.entries(linked)) { + cache.set(pid, { + root_path: entry.root_path, + mode: entry.mode, + company_id: entry.company_id, + }); + } + + return { count: cache.size }; +} + +function ensureInit() { + if (cache === null) init(); +} + +function resolveProjectRoot(pid) { + ensureInit(); + const entry = cache.get(pid); + if (!entry) throw new Error(`Unknown project: ${pid}`); + return entry.root_path; +} + +function resolveProjectMeta(pid) { + ensureInit(); + const entry = cache.get(pid); + if (!entry) throw new Error(`Unknown project: ${pid}`); + return { ...entry }; +} + +function findProjectByPath(abs) { + ensureInit(); + for (const [pid, entry] of cache.entries()) { + if (entry.root_path === abs) return { pid, ...entry }; + } + return null; +} + +// ── 派生路径 ── + +function resolveUnitDir(pid, unitId) { + return path.join(resolveProjectRoot(pid), 'units', unitId); +} + +function resolvePrdSite(pid) { + return path.join(resolveProjectRoot(pid), 'prd'); +} + +function resolveSddSite(pid) { + return path.join(resolveProjectRoot(pid), 'sdd'); +} + +function resolveAgentState(pid, agent) { + return path.join(resolveProjectRoot(pid), 'agent-state', `${agent}.json`); +} + +function resolveAgentTodolist(pid, agent) { + return path.join(resolveProjectRoot(pid), 'agent-state', `${agent}-todo.json`); +} + +function resolveAgentSnapshotDir(pid) { + return path.join(resolveProjectRoot(pid), 'agent-state', 'snapshots'); +} + +function resolveTimelineLog(pid) { + return path.join(resolveProjectRoot(pid), 'timeline.ndjson'); +} + +function resolveBugsLog(pid) { + return path.join(resolveProjectRoot(pid), 'bugs.ndjson'); +} + +function resolveChangelog(pid) { + return path.join(resolveProjectRoot(pid), 'changelog.ndjson'); +} + +function resolveStreamLog(pid) { + return path.join(resolveProjectRoot(pid), 'stream.log'); +} + +function resolveHistoryDir(pid) { + return path.join(resolveProjectRoot(pid), 'history'); +} + +function resolveIntegrationDir(pid) { + return path.join(resolveProjectRoot(pid), 'integration'); +} + +function resolveUnitsJson(pid) { + return path.join(resolveProjectRoot(pid), 'units.json'); +} + +function resolveProjectJson(pid) { + return path.join(resolveProjectRoot(pid), 'project.json'); +} + +/** Memory 永远在 company 级(不跟项目根走,详见 arch-project-root §⑧) */ +function resolveMemoryDir(companyId, agent) { + return path.join(DATA_DIR, 'companies', companyId, 'memory', 'devloop', agent); +} + +function resolveCompanyDir(companyId) { + return path.join(DATA_DIR, 'companies', companyId); +} + +// ── 创建项目 ── + +function generatePid() { + // p- + 6 位 base36 + const rand = Math.random().toString(36).slice(2, 8).padEnd(6, '0'); + return `p-${rand}`; +} + +function isSensitivePath(abs) { + const normalized = path.resolve(abs); + // 1. 明确允许的目录(tmpdir 等)→ 即使前缀匹配也放行 + for (const ex of ALLOWED_EXEMPTIONS) { + const e = path.resolve(ex); + if (normalized === e || normalized.startsWith(e + path.sep)) return false; + } + // 2. 不允许直接放在根 / + if (normalized === '/') return true; + // 3. 检查黑名单前缀 + return SENSITIVE_PREFIXES.some(prefix => { + const p = path.resolve(prefix); + return normalized === p || normalized.startsWith(p + path.sep); + }); +} + +/** + * 在文件系统上分配一个 root_path,但不写任何业务文件。 + * 仅 mkdir + 校验路径合法。 + * 业务文件(project.json / .gitignore / git init)由 LoopController.createProject 完成。 + * + * @returns { pid, root_path, mode } + */ +function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { + if (!company_id) throw new Error('company_id required'); + + const pid = generatePid(); + + let root_path; + if (mode === 'sandbox') { + root_path = path.join(DATA_DIR, 'companies', company_id, 'projects', pid); + fs.mkdirSync(root_path, { recursive: true }); + } else if (mode === 'linked-new') { + if (!target_path) throw new Error('target_path required for linked-new'); + if (!path.isAbsolute(target_path)) { + throw new Error('target_path must be absolute'); + } + if (isSensitivePath(target_path)) { + const e = new Error(`sensitive_path: ${target_path} is in blacklist`); + e.code = 'SENSITIVE_PATH'; + throw e; + } + // 父目录必须存在 + const parent = path.dirname(target_path); + if (!fs.existsSync(parent)) { + throw new Error(`parent dir does not exist: ${parent}`); + } + // 必须不存在或为空 + if (fs.existsSync(target_path)) { + const entries = fs.readdirSync(target_path); + if (entries.length > 0) { + throw new Error(`target_path is not empty: ${target_path}`); + } + } else { + fs.mkdirSync(target_path, { recursive: false }); + } + root_path = path.resolve(target_path); + // 注册到 linked-projects + const reg = loadLinkedRegistry(); + reg[pid] = { + root_path, + mode: 'linked-new', + company_id, + registered_at: new Date().toISOString(), + }; + saveLinkedRegistry(reg); + } else if (mode === 'linked-existing') { + const e = new Error('linked-existing mode is v2; not supported in v1'); + e.code = 'NOT_IMPLEMENTED'; + throw e; + } else { + throw new Error(`unknown mode: ${mode}`); + } + + // 立即注入缓存 + ensureInit(); + cache.set(pid, { root_path, mode, company_id }); + + return { pid, root_path, mode }; +} + +function unregisterLinkedProject(pid) { + const reg = loadLinkedRegistry(); + if (reg[pid]) { + delete reg[pid]; + saveLinkedRegistry(reg); + } + if (cache) cache.delete(pid); +} + +function listProjects() { + ensureInit(); + return Array.from(cache.entries()).map(([pid, entry]) => ({ pid, ...entry })); +} + +/** 测试用:重置缓存,强制下次重新扫描 */ +function _resetForTesting() { + cache = null; +} + +module.exports = { + init, + resolveProjectRoot, + resolveProjectMeta, + findProjectByPath, + resolveUnitDir, + resolvePrdSite, + resolveSddSite, + resolveAgentState, + resolveAgentTodolist, + resolveAgentSnapshotDir, + resolveTimelineLog, + resolveBugsLog, + resolveChangelog, + resolveStreamLog, + resolveHistoryDir, + resolveIntegrationDir, + resolveUnitsJson, + resolveProjectJson, + resolveMemoryDir, + resolveCompanyDir, + allocateProjectRoot, + unregisterLinkedProject, + listProjects, + isSensitivePath, + _resetForTesting, + // 常量 + DATA_DIR, + LINKED_REGISTRY, + SENSITIVE_PREFIXES, +}; diff --git a/server/services/devloop/sse-broker.js b/server/services/devloop/sse-broker.js new file mode 100644 index 0000000..08e7a42 --- /dev/null +++ b/server/services/devloop/sse-broker.js @@ -0,0 +1,156 @@ +/** + * DevLoop · sseBroker + * + * 每项目一个广播通道,多订阅者。 + * - subscribe(pid, res, opts) 把 HTTP res 注册为订阅者 + * - emit(pid, event, data) 写一行到 stream.log + 广播给所有订阅者 + * - 心跳:每 25s 一条 `:keepalive\n\n` 注释 + * - 断线重连:客户端发 Last-Event-ID header,replay 从 stream.log 读 > 该 id 的行 + * + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-11 + * docs/prd/newfunction/_spec/api-sse.md + * docs/prd/newfunction/_spec/sdd-process.md · P-5 + */ + +const fs = require('fs'); +const path = require('path'); +const pathResolver = require('./path-resolver'); + +const HEARTBEAT_MS = 25_000; + +// pid → Set<{ res, heartbeatTimer }> +const subscriptions = new Map(); + +// 单调递增的事件 id(跨进程不严格保证;崩溃重启从 stream.log 读最大值续) +let eventCounter = 0; + +function _streamLogPath(pid) { + return pathResolver.resolveStreamLog(pid); +} + +function _initCounterFromLog(pid) { + // 启动时如果 stream.log 已存在,找最大 id 续号 + const p = _streamLogPath(pid); + if (!fs.existsSync(p)) return; + const raw = fs.readFileSync(p, 'utf8'); + const matches = raw.match(/^id: (\d+)$/gm) || []; + for (const m of matches) { + const n = parseInt(m.slice(4), 10); + if (n > eventCounter) eventCounter = n; + } +} + +function _ensureCounterInit(pid) { + // 全局单调即可;不严格 per-pid(避免每次启动都全扫) + if (eventCounter === 0) _initCounterFromLog(pid); +} + +/** + * @param {string} pid + * @param {http.ServerResponse} res - 调用方应已 writeHead(200, SSE headers) + * @param {object} opts - { replayFromOffset?: string | number (Last-Event-ID) } + */ +function subscribe(pid, res, opts = {}) { + _ensureCounterInit(pid); + + // Replay:从 stream.log 读 > replayFromOffset 的行 + if (opts.replayFromOffset != null) { + const fromId = parseInt(opts.replayFromOffset, 10) || 0; + _replayTo(res, pid, fromId); + } + + // 心跳 + const heartbeat = setInterval(() => { + try { res.write(':keepalive\n\n'); } catch { /* ignore broken pipe */ } + }, HEARTBEAT_MS); + + // 加订阅 + if (!subscriptions.has(pid)) subscriptions.set(pid, new Set()); + const entry = { res, heartbeatTimer: heartbeat }; + subscriptions.get(pid).add(entry); + + // 客户端断开 + res.on('close', () => { + clearInterval(heartbeat); + subscriptions.get(pid)?.delete(entry); + if (subscriptions.get(pid)?.size === 0) subscriptions.delete(pid); + }); +} + +function _replayTo(res, pid, fromId) { + const p = _streamLogPath(pid); + if (!fs.existsSync(p)) return; + + const raw = fs.readFileSync(p, 'utf8'); + // stream.log 格式为 SSE 帧:`id: N\nevent: X\ndata: {...}\n\n` + const blocks = raw.split('\n\n').filter(Boolean); + for (const block of blocks) { + const idMatch = block.match(/^id: (\d+)/m); + if (!idMatch) continue; + const id = parseInt(idMatch[1], 10); + if (id <= fromId) continue; + try { res.write(block + '\n\n'); } catch { return; } + } +} + +/** + * 广播一条事件 + 落盘 stream.log。 + * @param {string} pid + * @param {string} event - 事件名,如 "unit.state" / "bug.opened" + * @param {any} data + */ +function emit(pid, event, data) { + _ensureCounterInit(pid); + const id = ++eventCounter; + const payload = JSON.stringify(data == null ? {} : data); + const line = `id: ${id}\nevent: ${event}\ndata: ${payload}\n\n`; + + // 1. 落盘 + const p = _streamLogPath(pid); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.appendFileSync(p, line); + + // 2. 广播 + const subs = subscriptions.get(pid); + if (subs) { + for (const entry of subs) { + try { entry.res.write(line); } catch { /* ignore broken pipe */ } + } + } + + return { id, event }; +} + +/** 项目结束:发 close + 关所有订阅。 */ +function close(pid) { + const subs = subscriptions.get(pid); + if (!subs) return; + for (const entry of subs) { + try { entry.res.write('event: close\ndata: {}\n\n'); } catch { /* ignore */ } + try { entry.res.end(); } catch { /* ignore */ } + clearInterval(entry.heartbeatTimer); + } + subscriptions.delete(pid); +} + +/** 测试用 */ +function _resetForTesting() { + for (const subs of subscriptions.values()) { + for (const entry of subs) clearInterval(entry.heartbeatTimer); + } + subscriptions.clear(); + eventCounter = 0; +} + +function _activeSubscribers(pid) { + return subscriptions.get(pid)?.size || 0; +} + +module.exports = { + subscribe, + emit, + close, + HEARTBEAT_MS, + _resetForTesting, + _activeSubscribers, +}; diff --git a/server/services/devloop/timeline-logger.js b/server/services/devloop/timeline-logger.js new file mode 100644 index 0000000..8b4b775 --- /dev/null +++ b/server/services/devloop/timeline-logger.js @@ -0,0 +1,100 @@ +/** + * DevLoop · timelineLogger + * + * 全流程统一事件日志(M-13 ProcessTimeline)。append-only NDJSON。 + * - 每行一个事件 + * - 每条 ≤ 1KB(summary 自动截 280 字符;payload > 4KB 强制 ref 形式) + * - 同步原子写(appendFileSync) + * + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-15 + * docs/prd/newfunction/_spec/sdd-model.md · M-13 + */ + +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); + +const pathResolver = require('./path-resolver'); + +const SUMMARY_MAX = 280; +const PAYLOAD_MAX_BYTES = 4096; + +function _logPath(pid) { return pathResolver.resolveTimelineLog(pid); } + +function _truncate(s, n) { + if (typeof s !== 'string') s = String(s); + if (s.length <= n) return s; + return s.slice(0, n - 1) + '…'; +} + +/** + * Append 一条事件。同步写。 + * @param {string} pid + * @param {object} ev - { source, kind, ref?, summary, payload? } + */ +function append(pid, ev) { + if (!ev || !ev.source || !ev.kind || !ev.summary) { + throw new Error('timelineLogger.append: source/kind/summary required'); + } + + const event = { + ts: Math.floor(Date.now() / 1000), + source: ev.source, + kind: ev.kind, + ref: ev.ref || null, + summary: _truncate(ev.summary, SUMMARY_MAX), + }; + + // payload 超大 → 强制改 ref 形式(不写盘外部文件,仅写入 ref 占位) + if (ev.payload !== undefined) { + const json = JSON.stringify(ev.payload); + if (json.length > PAYLOAD_MAX_BYTES) { + event.payload = { oversize: true, original_bytes: json.length }; + } else { + event.payload = ev.payload; + } + } + + const line = JSON.stringify(event) + '\n'; + const p = _logPath(pid); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.appendFileSync(p, line); + return event; +} + +/** + * 读全部 / 过滤事件。 + * @param {object} opts - { limit?, since?: unix ts, source?, kind? } + */ +function read(pid, opts = {}) { + const p = _logPath(pid); + if (!fs.existsSync(p)) return []; + + const limit = opts.limit || 100; + const since = opts.since; + const filterSource = opts.source; + const filterKind = opts.kind; + + const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean); + const events = []; + + // 倒序读,遇到 limit 停(避免 load 整个 file) + for (let i = lines.length - 1; i >= 0; i--) { + let ev; + try { ev = JSON.parse(lines[i]); } catch { continue; } + if (since && ev.ts < since) break; // 由于 ts 单调,可以提前 break + if (filterSource && ev.source !== filterSource) continue; + if (filterKind && ev.kind !== filterKind) continue; + events.push(ev); + if (events.length >= limit) break; + } + + return events.reverse(); // 输出按时间正序 +} + +/** 最近 N 条(已按时间正序) */ +function tail(pid, n = 10) { + return read(pid, { limit: n }); +} + +module.exports = { append, read, tail }; diff --git a/server/services/devloop/unit-registry.js b/server/services/devloop/unit-registry.js new file mode 100644 index 0000000..616a1e4 --- /dev/null +++ b/server/services/devloop/unit-registry.js @@ -0,0 +1,279 @@ +/** + * DevLoop · unitRegistry + * + * Unit 状态机 (M-2 + P-1) 的唯一真源。所有 markCoding/markTesting/pass/fail/ + * block/reset 都走 FSM 校验,非法转换抛 InvalidTransitionError。 + * + * 持久化:通过 docStore 读写 units.json(不直接 fs IO)。 + * 事件:每次合法状态转换发 SSE `unit.state`(若 sseBroker 注入)。 + * + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-10 + * docs/prd/newfunction/_spec/sdd-process.md · P-1 + * docs/prd/newfunction/_spec/sdd-model.md · M-2 + */ + +const docStore = require('./doc-store'); +const pathResolver = require('./path-resolver'); + +class InvalidTransitionError extends Error { + constructor(from, to, unitId) { + super(`Invalid state transition for ${unitId}: ${from} → ${to}`); + this.name = 'InvalidTransitionError'; + this.from = from; + this.to = to; + this.unitId = unitId; + } +} + +// FSM 合法转换表(来自 sdd-process P-1) +// from → allowed [to] 集合 +const TRANSITIONS = { + pending: ['coding'], + coding: ['testing', 'failed'], + testing: ['passed', 'failed'], + failed: ['coding', 'blocked'], + passed: ['pending'], // L2 reset + blocked: ['pending'], // 人类干预后 +}; + +function canTransition(from, to) { + if (!TRANSITIONS[from]) return false; + return TRANSITIONS[from].includes(to); +} + +// sseBroker 是可选依赖。LoopController 启动时 attach 进来。 +let sseBroker = null; +function attachSseBroker(broker) { + sseBroker = broker; +} + +// ──────────────────────────────────────────────────────────────── +// 读 +// ──────────────────────────────────────────────────────────────── + +function _loadUnits(pid) { + const u = docStore.readUnits(pid); + if (!u) throw new Error(`units.json not found for ${pid}`); + return u; +} + +function list(pid) { + return _loadUnits(pid).units; +} + +function get(pid, unitId) { + const u = list(pid).find(x => x.id === unitId); + if (!u) throw new Error(`unit ${unitId} not found in project ${pid}`); + return u; +} + +function pending(pid) { return list(pid).filter(u => u.status === 'pending'); } +function passed(pid) { return list(pid).filter(u => u.status === 'passed'); } +function blocked(pid) { return list(pid).filter(u => u.status === 'blocked'); } +function failed(pid) { return list(pid).filter(u => u.status === 'failed'); } +function anyBlocked(pid) { return blocked(pid).length > 0; } + +/** + * L2 闸门:全部 unit status=passed 且 contract_hash 与当前 SDD 一致。 + * 这里只检查 status;contract_hash 一致性由 LoopController 调用前先 verifyContractHash + * (这是更深的检查,需要读每个 unit 的 contract.json,太重不放这里)。 + */ +function l2GateOpen(pid) { + const units = list(pid); + if (units.length === 0) return false; + return units.every(u => u.status === 'passed'); +} + +// ──────────────────────────────────────────────────────────────── +// 写(FSM 转换) +// ──────────────────────────────────────────────────────────────── + +function _writeBack(pid, unitsJson) { + docStore.writeUnits(pid, unitsJson, { skipValidation: false }); +} + +function _transition(pid, unitId, to, extra = {}) { + const unitsJson = _loadUnits(pid); + const unit = unitsJson.units.find(u => u.id === unitId); + if (!unit) throw new Error(`unit ${unitId} not found in project ${pid}`); + + const from = unit.status; + if (!canTransition(from, to)) { + throw new InvalidTransitionError(from, to, unitId); + } + + unit.status = to; + Object.assign(unit, extra); + _writeBack(pid, unitsJson); + + if (sseBroker) { + sseBroker.emit(pid, 'unit.state', { + unit_id: unitId, + from, to, + retries: unit.retries, + reason: extra.reason || null, + }); + } + + return unit; +} + +function markCoding(pid, unitId) { + return _transition(pid, unitId, 'coding'); +} + +function markTesting(pid, unitId) { + return _transition(pid, unitId, 'testing'); +} + +function pass(pid, unitId, contractHash) { + return _transition(pid, unitId, 'passed', { contract_hash: contractHash }); +} + +function fail(pid, unitId, reason) { + // failed 时累加 retries + const unitsJson = _loadUnits(pid); + const unit = unitsJson.units.find(u => u.id === unitId); + if (!unit) throw new Error(`unit ${unitId} not found`); + + if (!canTransition(unit.status, 'failed')) { + throw new InvalidTransitionError(unit.status, 'failed', unitId); + } + + const from = unit.status; + unit.status = 'failed'; + unit.retries = (unit.retries || 0) + 1; + unit.last_failure_reason = reason; + _writeBack(pid, unitsJson); + + if (sseBroker) { + sseBroker.emit(pid, 'unit.state', { + unit_id: unitId, from, to: 'failed', + retries: unit.retries, reason, + }); + } + + return unit; +} + +function block(pid, unitId, reason) { + return _transition(pid, unitId, 'blocked', { block_reason: reason }); +} + +/** + * 重置一批 Unit 到 pending(L2 修复后或 L3 增量后)。 + * 允许 passed → pending 和 blocked → pending;其他状态保留(warning log)。 + */ +function reset(pid, unitIds) { + const unitsJson = _loadUnits(pid); + const reset_units = []; + const skipped = []; + for (const id of unitIds) { + const u = unitsJson.units.find(x => x.id === id); + if (!u) { + skipped.push({ id, reason: 'not_found' }); + continue; + } + const from = u.status; + if (!canTransition(from, 'pending')) { + skipped.push({ id, reason: `cannot_reset_from_${from}` }); + continue; + } + u.status = 'pending'; + u.retries = 0; // 重置 retry counter + reset_units.push({ id, from }); + } + _writeBack(pid, unitsJson); + + if (sseBroker && reset_units.length) { + for (const r of reset_units) { + sseBroker.emit(pid, 'unit.state', { + unit_id: r.id, from: r.from, to: 'pending', retries: 0, reason: 'reset', + }); + } + } + return { reset: reset_units, skipped }; +} + +// ──────────────────────────────────────────────────────────────── +// L3 增量 diff +// ──────────────────────────────────────────────────────────────── + +/** + * 对比新旧 units.json,返回 added/changed/removed 三类 unit id。 + * - added : 新 list 有,旧 list 无 + * - changed: 双方都有但 contract_hash 不同 + * - removed: 旧 list 有,新 list 无 + */ +function diffUnits(oldUnitsList, newUnitsList) { + const oldMap = new Map((oldUnitsList || []).map(u => [u.id, u])); + const newMap = new Map((newUnitsList || []).map(u => [u.id, u])); + + const added = []; + const changed = []; + const removed = []; + + for (const [id, n] of newMap) { + if (!oldMap.has(id)) { + added.push(id); + } else if (oldMap.get(id).contract_hash !== n.contract_hash) { + changed.push(id); + } + } + for (const [id] of oldMap) { + if (!newMap.has(id)) removed.push(id); + } + return { added, changed, removed }; +} + +// ──────────────────────────────────────────────────────────────── +// 初始化 units.json 骨架 +// ──────────────────────────────────────────────────────────────── + +/** + * Architect 第一次生成 SDD 时,构造 units.json 骨架。 + * 各 unit 自带默认 retry_budget=5、status=pending、retries=0。 + */ +function buildUnitsJson({ project_id, prd_rev, sdd_rev, units, l2_budget = 3 }) { + return { + project_id, + prd_rev: String(prd_rev), + sdd_rev: String(sdd_rev), + l2_rounds: 0, + l2_budget, + units: units.map(u => ({ + id: u.id, + type: u.type, + status: 'pending', + depends_on: u.depends_on || [], + retries: 0, + retry_budget: u.retry_budget || 5, + contract_hash: u.contract_hash, + ...(u.last_run ? { last_run: u.last_run } : {}), + })), + }; +} + +module.exports = { + // 读 + list, get, + pending, passed, blocked, failed, anyBlocked, + l2GateOpen, + + // 写 + markCoding, markTesting, pass, fail, block, reset, + + // L3 + diffUnits, + + // 工具 + buildUnitsJson, + canTransition, + TRANSITIONS, + + // 错误 + InvalidTransitionError, + + // 依赖注入 + attachSseBroker, +}; diff --git a/server/tests/devloop/bug-tracker.test.js b/server/tests/devloop/bug-tracker.test.js new file mode 100644 index 0000000..078ba90 --- /dev/null +++ b/server/tests/devloop/bug-tracker.test.js @@ -0,0 +1,105 @@ +/** + * bugTracker 单测 + */ +const fs = require('fs'); + +const pathResolver = require('../../services/devloop/path-resolver'); +const bugTracker = require('../../services/devloop/bug-tracker'); + +describe('bugTracker', () => { + let pid, root; + + beforeEach(() => { + pathResolver._resetForTesting(); + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); + pid = r.pid; + root = r.root_path; + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + describe('open + dedup', () => { + it('open new bug returns id', () => { + const b = bugTracker.open(pid, { + source: 'unit_test', unit_id: 'auth-be', + test_name: 'login.invalid-pwd', severity: 'high', + title: 'expected 401 got 500', + }); + expect(b.id).toMatch(/^bug-/); + expect(b.status).toBe('open'); + }); + + it('same unit + test → dedup returns existing id', () => { + const b1 = bugTracker.open(pid, { + unit_id: 'auth-be', test_name: 'login.invalid-pwd', title: 'T1', + }); + const b2 = bugTracker.open(pid, { + unit_id: 'auth-be', test_name: 'login.invalid-pwd', title: 'T2', + }); + expect(b2.id).toBe(b1.id); + }); + }); + + describe('transition', () => { + it('open → fixing → fixed → verified', () => { + const b = bugTracker.open(pid, { + unit_id: 'auth-be', test_name: 'x', title: 'T', + }); + bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' }); + expect(bugTracker.get(pid, b.id).status).toBe('fixing'); + bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' }); + expect(bugTracker.get(pid, b.id).status).toBe('fixed'); + bugTracker.transition(pid, b.id, { to: 'verified', by: 'integrator' }); + expect(bugTracker.get(pid, b.id).status).toBe('verified'); + }); + + it('open → verified (skip) throws', () => { + const b = bugTracker.open(pid, { unit_id: 'x-be', test_name: 'y', title: 'T' }); + expect(() => bugTracker.transition(pid, b.id, { to: 'verified', by: 'system' })) + .toThrow(bugTracker.InvalidBugTransitionError); + }); + + it('verified → open (reopen) ok', () => { + const b = bugTracker.open(pid, { unit_id: 'x-be', test_name: 'y', title: 'T' }); + bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' }); + bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' }); + bugTracker.transition(pid, b.id, { to: 'verified', by: 'integrator' }); + bugTracker.transition(pid, b.id, { to: 'open', by: 'system' }); + expect(bugTracker.get(pid, b.id).status).toBe('open'); + }); + }); + + describe('list + stats', () => { + it('filter by status', () => { + const b1 = bugTracker.open(pid, { unit_id: 'a-be', test_name: 't1', title: 'T1' }); + const b2 = bugTracker.open(pid, { unit_id: 'b-be', test_name: 't2', title: 'T2' }); + bugTracker.transition(pid, b2.id, { to: 'fixing', by: 'coder' }); + const open = bugTracker.list(pid, { status: ['open'] }); + const fixing = bugTracker.list(pid, { status: ['fixing'] }); + expect(open.map(b => b.id)).toContain(b1.id); + expect(fixing.map(b => b.id)).toContain(b2.id); + }); + + it('stats counts by status', () => { + bugTracker.open(pid, { unit_id: 'a-be', test_name: 't1', title: 'T', severity: 'high' }); + bugTracker.open(pid, { unit_id: 'b-be', test_name: 't2', title: 'T', severity: 'low' }); + const s = bugTracker.stats(pid); + expect(s.open).toBe(2); + expect(s.by_severity.high).toBe(1); + expect(s.by_severity.low).toBe(1); + }); + }); + + describe('history', () => { + it('tracks all transitions', () => { + const b = bugTracker.open(pid, { unit_id: 'a-be', test_name: 't', title: 'T' }); + bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' }); + bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' }); + const h = bugTracker.history(pid, b.id); + // 含 open 时的 history (1) + 2 次 transition = 3 条 + expect(h.length).toBeGreaterThanOrEqual(3); + }); + }); +}); diff --git a/server/tests/devloop/doc-store.test.js b/server/tests/devloop/doc-store.test.js new file mode 100644 index 0000000..18c1cb1 --- /dev/null +++ b/server/tests/devloop/doc-store.test.js @@ -0,0 +1,128 @@ +/** + * docStore 单测 + */ +const docStore = require('../../services/devloop/doc-store'); + +describe('docStore', () => { + describe('validateHTML', () => { + it('rejects empty', () => { + const r = docStore.validateHTML('', 'prd'); + expect(r.ok).toBe(false); + }); + + it('rejects missing article', () => { + const r = docStore.validateHTML('

hi

', 'prd'); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('article'); + }); + + it('rejects wrong doc kind', () => { + const r = docStore.validateHTML('

x

y

', 'prd'); + expect(r.ok).toBe(false); + }); + + it('accepts valid PRD chapter', () => { + const html = `
+

Title

body

+

Other

more

+
`; + const r = docStore.validateHTML(html, 'prd'); + expect(r.ok).toBe(true); + }); + + it('detects section without h3', () => { + const html = `
+

only p

+
`; + const r = docStore.validateHTML(html, 'prd'); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('h3'); + }); + + it('detects non-monotonic data-rev', () => { + const html = `
+

x

y

+

x

y

+
`; + const r = docStore.validateHTML(html, 'prd'); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('monotonic'); + }); + }); + + describe('validateUnitsSchema', () => { + const validUnit = { + id: 'auth-be', type: 'be', status: 'pending', + depends_on: [], retries: 0, retry_budget: 5, + contract_hash: 'sha256:abc', + }; + + it('accepts valid units.json', () => { + const json = { + project_id: 'p-test', prd_rev: '1', sdd_rev: '1', + units: [validUnit], l2_rounds: 0, l2_budget: 3, + }; + const r = docStore.validateUnitsSchema(json); + expect(r.ok).toBe(true); + }); + + it('rejects invalid unit id', () => { + const json = { + project_id: 'p', units: [{ ...validUnit, id: 'AuthBE' }], + }; + const r = docStore.validateUnitsSchema(json); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('naming'); + }); + + it('rejects duplicate ids', () => { + const json = { + project_id: 'p', units: [validUnit, validUnit], + }; + const r = docStore.validateUnitsSchema(json); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('duplicate'); + }); + + it('rejects unknown depends_on', () => { + const json = { + project_id: 'p', + units: [{ ...validUnit, depends_on: ['ghost-be'] }], + }; + const r = docStore.validateUnitsSchema(json); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('unknown unit'); + }); + + it('detects cycle', () => { + const a = { id: 'a-be', type: 'be', status: 'pending', depends_on: ['b-be'], retries: 0, retry_budget: 5, contract_hash: 'sha256:1' }; + const b = { id: 'b-be', type: 'be', status: 'pending', depends_on: ['a-be'], retries: 0, retry_budget: 5, contract_hash: 'sha256:2' }; + const json = { project_id: 'p', units: [a, b] }; + const r = docStore.validateUnitsSchema(json); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toContain('cycle'); + }); + }); + + describe('hashContract', () => { + it('deterministic for same content', () => { + const c = { unit_id: 'x', exports: { http: [{ path: '/x' }] } }; + const h1 = docStore.hashContract(c); + const h2 = docStore.hashContract(c); + expect(h1).toBe(h2); + expect(h1).toMatch(/^sha256:[a-f0-9]{16}$/); + }); + + it('different content → different hash', () => { + const c1 = { unit_id: 'x', exports: { http: [{ path: '/x' }] } }; + const c2 = { unit_id: 'x', exports: { http: [{ path: '/y' }] } }; + expect(docStore.hashContract(c1)).not.toBe(docStore.hashContract(c2)); + }); + + it('canonical: key ordering does not affect hash', () => { + const c1 = { exports: { http: [{ a: 1, b: 2 }] } }; + const c2 = { exports: { http: [{ b: 2, a: 1 }] } }; + expect(docStore.hashContract(c1)).toBe(docStore.hashContract(c2)); + }); + }); +}); diff --git a/server/tests/devloop/git-vault.test.js b/server/tests/devloop/git-vault.test.js new file mode 100644 index 0000000..0b9a2f7 --- /dev/null +++ b/server/tests/devloop/git-vault.test.js @@ -0,0 +1,110 @@ +/** + * gitVault 单测 + * + * 需要系统装了 git。如果没装则 skip 所有用例。 + */ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const pathResolver = require('../../services/devloop/path-resolver'); +const gitVault = require('../../services/devloop/git-vault'); +const docStore = require('../../services/devloop/doc-store'); + +let gitAvailable = false; +try { execSync('git --version', { stdio: 'ignore' }); gitAvailable = true; } catch { /* skip */ } + +(gitAvailable ? describe : describe.skip)('gitVault', () => { + let pid, root; + + beforeEach(() => { + pathResolver._resetForTesting(); + gitVault._resetMutexesForTesting(); + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); + pid = r.pid; + root = r.root_path; + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('init creates .git + initial commit', async () => { + const r = await gitVault.init(pid); + expect(r.ok).toBe(true); + expect(fs.existsSync(path.join(root, '.git'))).toBe(true); + expect(fs.existsSync(path.join(root, '.gitignore'))).toBe(true); + + const logs = await gitVault.log(pid); + expect(logs.length).toBeGreaterThanOrEqual(1); + expect(logs[0].summary).toMatch(/^init: project p-/); + }); + + it('init is idempotent', async () => { + await gitVault.init(pid); + const r = await gitVault.init(pid); + expect(r.already_exists).toBe(true); + }); + + it('commit with content creates new sha', async () => { + await gitVault.init(pid); + // 写一个真实的文件再 commit (PRD/HTML 校验严格,这里直接 raw) + fs.writeFileSync(path.join(root, 'README.md'), 'hello\n'); + const r = await gitVault.commit(pid, { + kind: 'chore', summary: 'add README', + author: { name: 'PM Agent', email: 'pm@devloop.local' }, + }); + expect(r.ok).toBe(true); + expect(r.skipped).toBeFalsy(); + expect(r.sha).toMatch(/^[a-f0-9]{40}$/); + + // author 被记录 + const logs = await gitVault.log(pid, { limit: 1 }); + expect(logs[0].author_name).toBe('PM Agent'); + expect(logs[0].author_email).toBe('pm@devloop.local'); + }); + + it('commit with nothing-to-commit returns skipped', async () => { + await gitVault.init(pid); + const r = await gitVault.commit(pid, { + kind: 'chore', summary: 'empty commit attempt', + }); + expect(r.ok).toBe(true); + expect(r.skipped).toBe(true); + }); + + it('tag + listTags', async () => { + await gitVault.init(pid); + await gitVault.tag(pid, 'shipped-v1'); + const tags = await gitVault.listTags(pid); + expect(tags).toContain('shipped-v1'); + }); + + it('status detects dirty tree', async () => { + await gitVault.init(pid); + let s = await gitVault.status(pid); + expect(s.dirty).toBe(false); + + fs.writeFileSync(path.join(root, 'untracked.txt'), 'x'); + s = await gitVault.status(pid); + expect(s.dirty).toBe(true); + }); + + it('resetWorkingTree clears dirty', async () => { + await gitVault.init(pid); + fs.writeFileSync(path.join(root, 'dirt.txt'), 'x'); + expect((await gitVault.status(pid)).dirty).toBe(true); + + await gitVault.resetWorkingTree(pid); + expect((await gitVault.status(pid)).dirty).toBe(false); + }); + + it('archive produces zip', async () => { + await gitVault.init(pid); + fs.writeFileSync(path.join(root, 'kept.txt'), 'x'); + await gitVault.commit(pid, { kind: 'chore', summary: 'add file' }); + const r = await gitVault.archive(pid); + expect(fs.existsSync(r.path)).toBe(true); + fs.rmSync(r.path); + }); +}); diff --git a/server/tests/devloop/path-resolver.test.js b/server/tests/devloop/path-resolver.test.js new file mode 100644 index 0000000..b5e74db --- /dev/null +++ b/server/tests/devloop/path-resolver.test.js @@ -0,0 +1,149 @@ +/** + * pathResolver 单测 + */ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const pathResolver = require('../../services/devloop/path-resolver'); + +// 用 tmpdir 隔离测试 +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-test-')); +process.env.HOME = TEST_HOME; // 让 LINKED_REGISTRY 写到 tmp + +describe('pathResolver', () => { + beforeEach(() => { + pathResolver._resetForTesting(); + }); + + describe('isSensitivePath', () => { + it('rejects /etc', () => { + expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true); + }); + it('rejects /usr/local/bin', () => { + expect(pathResolver.isSensitivePath('/usr/local/bin')).toBe(true); + }); + it('rejects /etc/passwd absolute', () => { + expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true); + }); + it('rejects /var/log/something', () => { + expect(pathResolver.isSensitivePath('/var/log/anything')).toBe(true); + }); + it('accepts ~/code/project', () => { + expect(pathResolver.isSensitivePath(path.join(os.homedir(), 'code', 'project'))).toBe(false); + }); + it('accepts /tmp/anything', () => { + expect(pathResolver.isSensitivePath('/tmp/anything')).toBe(false); + }); + }); + + describe('allocateProjectRoot · sandbox', () => { + it('creates root under data/companies//projects/', () => { + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); + expect(r.pid).toMatch(/^p-[a-z0-9]{6}$/); + expect(r.mode).toBe('sandbox'); + expect(r.root_path).toContain('data/companies/c-test/projects/'); + expect(fs.existsSync(r.root_path)).toBe(true); + // cleanup + fs.rmSync(r.root_path, { recursive: true, force: true }); + }); + }); + + describe('allocateProjectRoot · linked-new', () => { + it('rejects when target_path missing', () => { + expect(() => + pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-new' }) + ).toThrow(/target_path required/); + }); + + it('rejects relative target_path', () => { + expect(() => + pathResolver.allocateProjectRoot({ + company_id: 'c-test', mode: 'linked-new', target_path: 'relative/path', + }) + ).toThrow(/must be absolute/); + }); + + it('rejects sensitive path', () => { + expect(() => + pathResolver.allocateProjectRoot({ + company_id: 'c-test', mode: 'linked-new', target_path: '/etc/foo', + }) + ).toThrow(/sensitive_path/); + }); + + it('rejects when parent does not exist', () => { + expect(() => + pathResolver.allocateProjectRoot({ + company_id: 'c-test', mode: 'linked-new', + target_path: '/tmp/devloop-test-nonexistent-parent/proj', + }) + ).toThrow(/parent dir does not exist/); + }); + + it('rejects when target_path is non-empty dir', () => { + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-')); + fs.writeFileSync(path.join(target, 'existing.txt'), 'x'); + expect(() => + pathResolver.allocateProjectRoot({ + company_id: 'c-test', mode: 'linked-new', target_path: target, + }) + ).toThrow(/not empty/); + fs.rmSync(target, { recursive: true, force: true }); + }); + + it('accepts empty existing dir', () => { + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-')); + const r = pathResolver.allocateProjectRoot({ + company_id: 'c-test', mode: 'linked-new', target_path: target, + }); + expect(r.mode).toBe('linked-new'); + expect(r.root_path).toBe(target); + // 检查 linked-registry + const reg = JSON.parse(fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8')); + expect(reg[r.pid]).toBeDefined(); + expect(reg[r.pid].mode).toBe('linked-new'); + + fs.rmSync(target, { recursive: true, force: true }); + pathResolver.unregisterLinkedProject(r.pid); + }); + }); + + describe('allocateProjectRoot · linked-existing rejected in v1', () => { + it('throws NOT_IMPLEMENTED', () => { + expect(() => + pathResolver.allocateProjectRoot({ + company_id: 'c-test', mode: 'linked-existing', target_path: '/tmp', + }) + ).toThrow(/v2/); + }); + }); + + describe('resolveProjectRoot + 派生路径', () => { + it('throws on unknown pid', () => { + expect(() => pathResolver.resolveProjectRoot('p-nonexistent')).toThrow(/Unknown project/); + }); + + it('after allocate, all resolvers work', () => { + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); + expect(pathResolver.resolveProjectRoot(r.pid)).toBe(r.root_path); + expect(pathResolver.resolveUnitDir(r.pid, 'auth-be')) + .toBe(path.join(r.root_path, 'units', 'auth-be')); + expect(pathResolver.resolvePrdSite(r.pid)).toBe(path.join(r.root_path, 'prd')); + expect(pathResolver.resolveSddSite(r.pid)).toBe(path.join(r.root_path, 'sdd')); + expect(pathResolver.resolveTimelineLog(r.pid)).toBe(path.join(r.root_path, 'timeline.ndjson')); + expect(pathResolver.resolveBugsLog(r.pid)).toBe(path.join(r.root_path, 'bugs.ndjson')); + expect(pathResolver.resolveAgentState(r.pid, 'pm')) + .toBe(path.join(r.root_path, 'agent-state', 'pm.json')); + fs.rmSync(r.root_path, { recursive: true, force: true }); + }); + }); + + describe('memory dir 在 company 级', () => { + it('does not depend on pid', () => { + const m1 = pathResolver.resolveMemoryDir('c-test', 'pm'); + expect(m1).toContain('companies/c-test/memory/devloop/pm'); + expect(m1).not.toContain('projects/'); + }); + }); +}); diff --git a/server/tests/devloop/timeline-logger.test.js b/server/tests/devloop/timeline-logger.test.js new file mode 100644 index 0000000..ce34483 --- /dev/null +++ b/server/tests/devloop/timeline-logger.test.js @@ -0,0 +1,115 @@ +/** + * timelineLogger + changelogWriter 单测 + */ +const fs = require('fs'); + +const pathResolver = require('../../services/devloop/path-resolver'); +const timelineLogger = require('../../services/devloop/timeline-logger'); +const changelogWriter = require('../../services/devloop/changelog-writer'); + +describe('timelineLogger', () => { + let pid, root; + + beforeEach(() => { + pathResolver._resetForTesting(); + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); + pid = r.pid; + root = r.root_path; + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('append writes NDJSON line', () => { + timelineLogger.append(pid, { + source: 'system', kind: 'project_created', ref: pid, + summary: 'test project', + }); + const events = timelineLogger.read(pid); + expect(events).toHaveLength(1); + expect(events[0].kind).toBe('project_created'); + }); + + it('append rejects missing fields', () => { + expect(() => timelineLogger.append(pid, {})).toThrow(); + expect(() => timelineLogger.append(pid, { source: 'x' })).toThrow(); + }); + + it('summary truncated to 280 chars', () => { + const longSummary = 'x'.repeat(500); + timelineLogger.append(pid, { + source: 'system', kind: 'test', summary: longSummary, + }); + const events = timelineLogger.read(pid); + expect(events[0].summary.length).toBeLessThanOrEqual(280); + expect(events[0].summary).toMatch(/…$/); + }); + + it('oversized payload reduced to ref marker', () => { + const big = { huge: 'x'.repeat(5000) }; + timelineLogger.append(pid, { + source: 'system', kind: 'test', summary: 's', payload: big, + }); + const events = timelineLogger.read(pid); + expect(events[0].payload.oversize).toBe(true); + }); + + it('tail returns last N', () => { + for (let i = 0; i < 15; i++) { + timelineLogger.append(pid, { source: 'system', kind: 'k', summary: `${i}` }); + } + const last5 = timelineLogger.tail(pid, 5); + expect(last5).toHaveLength(5); + // 时间正序,最后一条是最新 + expect(last5[4].summary).toBe('14'); + }); + + it('filter by kind', () => { + timelineLogger.append(pid, { source: 'pm', kind: 'prd_drafted', summary: 'a' }); + timelineLogger.append(pid, { source: 'arch', kind: 'sdd_generated', summary: 'b' }); + timelineLogger.append(pid, { source: 'pm', kind: 'prd_drafted', summary: 'c' }); + const r = timelineLogger.read(pid, { kind: 'prd_drafted' }); + expect(r).toHaveLength(2); + }); +}); + +describe('changelogWriter', () => { + let pid, root; + + beforeEach(() => { + pathResolver._resetForTesting(); + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); + pid = r.pid; + root = r.root_path; + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('append writes entry', () => { + changelogWriter.append(pid, { + kind: 'prd_rev', rev: 1, by: 'pm-agent', + trigger: 'init', summary: 'first PRD', + }); + const all = changelogWriter.read(pid); + expect(all).toHaveLength(1); + expect(all[0].kind).toBe('prd_rev'); + expect(all[0].rev).toBe(1); + }); + + it('rejects invalid kind', () => { + expect(() => changelogWriter.append(pid, { + kind: 'wrong-kind', rev: 1, summary: 's', + })).toThrow(); + }); + + it('latest returns most recent of kind', () => { + changelogWriter.append(pid, { kind: 'prd_rev', rev: 1, summary: 's1' }); + changelogWriter.append(pid, { kind: 'sdd_rev', rev: 1, summary: 's2' }); + changelogWriter.append(pid, { kind: 'prd_rev', rev: 2, summary: 's3' }); + const r = changelogWriter.latest(pid, 'prd_rev'); + expect(r.rev).toBe(2); + }); +}); diff --git a/server/tests/devloop/unit-registry.test.js b/server/tests/devloop/unit-registry.test.js new file mode 100644 index 0000000..c69e0e7 --- /dev/null +++ b/server/tests/devloop/unit-registry.test.js @@ -0,0 +1,157 @@ +/** + * unitRegistry 单测 — 跑 FSM 转换 + */ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const pathResolver = require('../../services/devloop/path-resolver'); +const docStore = require('../../services/devloop/doc-store'); +const unitRegistry = require('../../services/devloop/unit-registry'); + +describe('unitRegistry', () => { + let pid; + + beforeEach(() => { + pathResolver._resetForTesting(); + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); + pid = r.pid; + // 准备 units.json + const units = { + project_id: pid, prd_rev: '1', sdd_rev: '1', l2_rounds: 0, l2_budget: 3, + units: [ + { + id: 'auth-be', type: 'be', status: 'pending', depends_on: [], + retries: 0, retry_budget: 5, contract_hash: 'sha256:aaaaaaaaaaaaaaaa', + }, + { + id: 'auth-fe', type: 'fe', status: 'pending', depends_on: ['auth-be'], + retries: 0, retry_budget: 5, contract_hash: 'sha256:bbbbbbbbbbbbbbbb', + }, + ], + }; + docStore.writeUnits(pid, units); + }); + + afterEach(() => { + const root = pathResolver.resolveProjectRoot(pid); + fs.rmSync(root, { recursive: true, force: true }); + }); + + describe('list / pending / passed', () => { + it('lists all units', () => { + expect(unitRegistry.list(pid)).toHaveLength(2); + }); + it('all start pending', () => { + expect(unitRegistry.pending(pid)).toHaveLength(2); + expect(unitRegistry.passed(pid)).toHaveLength(0); + }); + }); + + describe('canTransition', () => { + it('pending → coding ok', () => { + expect(unitRegistry.canTransition('pending', 'coding')).toBe(true); + }); + it('coding → testing ok', () => { + expect(unitRegistry.canTransition('coding', 'testing')).toBe(true); + }); + it('pending → passed FORBIDDEN', () => { + expect(unitRegistry.canTransition('pending', 'passed')).toBe(false); + }); + it('passed → pending ok (L2 reset)', () => { + expect(unitRegistry.canTransition('passed', 'pending')).toBe(true); + }); + it('blocked → pending ok (人类干预)', () => { + expect(unitRegistry.canTransition('blocked', 'pending')).toBe(true); + }); + }); + + describe('markCoding / markTesting / pass', () => { + it('full L1 happy path', () => { + unitRegistry.markCoding(pid, 'auth-be'); + expect(unitRegistry.get(pid, 'auth-be').status).toBe('coding'); + + unitRegistry.markTesting(pid, 'auth-be'); + expect(unitRegistry.get(pid, 'auth-be').status).toBe('testing'); + + unitRegistry.pass(pid, 'auth-be', 'sha256:newhash00000000'); + const u = unitRegistry.get(pid, 'auth-be'); + expect(u.status).toBe('passed'); + expect(u.contract_hash).toBe('sha256:newhash00000000'); + }); + + it('pending → testing throws', () => { + expect(() => unitRegistry.markTesting(pid, 'auth-be')) + .toThrow(unitRegistry.InvalidTransitionError); + }); + }); + + describe('fail + retries', () => { + it('coding/testing → failed accumulates retries', () => { + unitRegistry.markCoding(pid, 'auth-be'); + unitRegistry.markTesting(pid, 'auth-be'); + unitRegistry.fail(pid, 'auth-be', 'test red'); + expect(unitRegistry.get(pid, 'auth-be').retries).toBe(1); + + unitRegistry.markCoding(pid, 'auth-be'); + unitRegistry.markTesting(pid, 'auth-be'); + unitRegistry.fail(pid, 'auth-be', 'test red 2'); + expect(unitRegistry.get(pid, 'auth-be').retries).toBe(2); + }); + }); + + describe('block + reset', () => { + it('failed → blocked then reset', () => { + unitRegistry.markCoding(pid, 'auth-be'); + unitRegistry.markTesting(pid, 'auth-be'); + unitRegistry.fail(pid, 'auth-be', 'red'); + unitRegistry.block(pid, 'auth-be', 'budget exceeded'); + expect(unitRegistry.get(pid, 'auth-be').status).toBe('blocked'); + + const r = unitRegistry.reset(pid, ['auth-be']); + expect(r.reset).toHaveLength(1); + expect(unitRegistry.get(pid, 'auth-be').status).toBe('pending'); + expect(unitRegistry.get(pid, 'auth-be').retries).toBe(0); + }); + + it('reset skips coding/testing units with reason', () => { + unitRegistry.markCoding(pid, 'auth-be'); + const r = unitRegistry.reset(pid, ['auth-be', 'ghost']); + expect(r.reset).toHaveLength(0); + expect(r.skipped.find(s => s.id === 'auth-be').reason).toMatch(/coding/); + expect(r.skipped.find(s => s.id === 'ghost').reason).toBe('not_found'); + }); + }); + + describe('l2GateOpen', () => { + it('false when any not passed', () => { + expect(unitRegistry.l2GateOpen(pid)).toBe(false); + }); + it('true when all passed', () => { + for (const id of ['auth-be', 'auth-fe']) { + unitRegistry.markCoding(pid, id); + unitRegistry.markTesting(pid, id); + unitRegistry.pass(pid, id, 'sha256:0000000000000000'); + } + expect(unitRegistry.l2GateOpen(pid)).toBe(true); + }); + }); + + describe('diffUnits', () => { + it('detects added/changed/removed', () => { + const oldList = [ + { id: 'a-be', contract_hash: 'sha256:1' }, + { id: 'b-be', contract_hash: 'sha256:2' }, + ]; + const newList = [ + { id: 'a-be', contract_hash: 'sha256:1' }, // same + { id: 'b-be', contract_hash: 'sha256:CHANGED' }, // changed + { id: 'c-be', contract_hash: 'sha256:3' }, // added + ]; + const r = unitRegistry.diffUnits(oldList, newList); + expect(r.added).toEqual(['c-be']); + expect(r.changed).toEqual(['b-be']); + expect(r.removed).toEqual([]); + }); + }); +});