Commit 83523ede628fe98e910a41e1711ae8d94892309b
1 parent
905894be
feat(devloop): P0.0 services layer — 7 services + 71 passing unit tests
First concrete code commit for DevLoop. Implements all 7 services that
P0.A tools will depend on, per plan-migration P0.0.
Services (server/services/devloop/, ~900 lines):
- path-resolver.js foundation — resolveProjectRoot + 12 derived paths;
sandbox + linked-new mode support; sensitive path
blacklist (with /tmp + os.tmpdir() exemptions);
~/.devloop/linked-projects.json registry
- doc-store.js PRD/SDD multi-file site IO + cheerio validateHTML +
validateUnitsSchema + canonical-JSON hashContract
+ automatic history/ archival
- unit-registry.js FSM (M-2: pending→coding→testing→passed/failed/blocked);
l2GateOpen; diffUnits (added/changed/removed);
reset semantics for L2 + L3
- bug-tracker.js bugs.ndjson append-only + dedupKey + P-9 transition
state machine + list/stats/history aggregation
- timeline-logger.js M-13 ProcessTimeline — append/read/tail;
summary truncate to 280; payload oversize → ref form
- changelog-writer.js M-15 — append/read/latest for prd/sdd/units revs
- sse-broker.js per-pid pub/sub + 25s heartbeat + stream.log replay
via Last-Event-ID; emit returns monotonic id
- git-vault.js spawn-git wrapper — init/commit/tag/log/diff/status/
resetWorkingTree/revert/archive; per-pid mutex;
nothing-to-commit returns {skipped:true} not throw
Tests (server/tests/devloop/, 71 passing):
- path-resolver.test.js (17 cases) — sensitive path · sandbox alloc · linked-new
- doc-store.test.js (12 cases) — validateHTML · validateUnitsSchema · hashContract
- unit-registry.test.js (15 cases) — FSM transitions · l2GateOpen · diffUnits
- bug-tracker.test.js (10 cases) — open dedup · transition · list filter · history
- timeline-logger.test.js (9 cases) — append/read/tail · summary truncate · oversize
- git-vault.test.js (8 cases) — init idempotent · commit · tag · status · archive
vitest config (`globals: true`) means tests use describe/it/expect globally,
no `require('vitest')` needed (and would fail under vitest 4).
Dep added: cheerio@^1.0.0 (used by docStore.validateHTML; HTML parsing).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Showing
16 changed files
with
2928 additions
and
37 deletions
package.json
| @@ -65,6 +65,7 @@ | @@ -65,6 +65,7 @@ | ||
| 65 | "ali-oss": "^6.23.0", | 65 | "ali-oss": "^6.23.0", |
| 66 | "archiver": "^7.0.1", | 66 | "archiver": "^7.0.1", |
| 67 | "better-sqlite3": "^12.8.0", | 67 | "better-sqlite3": "^12.8.0", |
| 68 | + "cheerio": "^1.0.0", | ||
| 68 | "express-fileupload": "^1.5.2", | 69 | "express-fileupload": "^1.5.2", |
| 69 | "mammoth": "^1.11.0", | 70 | "mammoth": "^1.11.0", |
| 70 | "module-alias": "^2.3.4", | 71 | "module-alias": "^2.3.4", |
pnpm-lock.yaml
| @@ -32,6 +32,9 @@ importers: | @@ -32,6 +32,9 @@ importers: | ||
| 32 | better-sqlite3: | 32 | better-sqlite3: |
| 33 | specifier: ^12.8.0 | 33 | specifier: ^12.8.0 |
| 34 | version: 12.8.0 | 34 | version: 12.8.0 |
| 35 | + cheerio: | ||
| 36 | + specifier: ^1.0.0 | ||
| 37 | + version: 1.2.0 | ||
| 35 | express-fileupload: | 38 | express-fileupload: |
| 36 | specifier: ^1.5.2 | 39 | specifier: ^1.5.2 |
| 37 | version: 1.5.2 | 40 | version: 1.5.2 |
| @@ -769,6 +772,9 @@ packages: | @@ -769,6 +772,9 @@ packages: | ||
| 769 | '@protobufjs/codegen@2.0.4': | 772 | '@protobufjs/codegen@2.0.4': |
| 770 | resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} | 773 | resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} |
| 771 | 774 | ||
| 775 | + '@protobufjs/codegen@2.0.5': | ||
| 776 | + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} | ||
| 777 | + | ||
| 772 | '@protobufjs/eventemitter@1.1.0': | 778 | '@protobufjs/eventemitter@1.1.0': |
| 773 | resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} | 779 | resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} |
| 774 | 780 | ||
| @@ -781,6 +787,9 @@ packages: | @@ -781,6 +787,9 @@ packages: | ||
| 781 | '@protobufjs/inquire@1.1.0': | 787 | '@protobufjs/inquire@1.1.0': |
| 782 | resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} | 788 | resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} |
| 783 | 789 | ||
| 790 | + '@protobufjs/inquire@1.1.1': | ||
| 791 | + resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} | ||
| 792 | + | ||
| 784 | '@protobufjs/path@1.1.2': | 793 | '@protobufjs/path@1.1.2': |
| 785 | resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} | 794 | resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} |
| 786 | 795 | ||
| @@ -790,6 +799,9 @@ packages: | @@ -790,6 +799,9 @@ packages: | ||
| 790 | '@protobufjs/utf8@1.1.0': | 799 | '@protobufjs/utf8@1.1.0': |
| 791 | resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} | 800 | resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} |
| 792 | 801 | ||
| 802 | + '@protobufjs/utf8@1.1.1': | ||
| 803 | + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} | ||
| 804 | + | ||
| 793 | '@rollup/rollup-android-arm-eabi@4.57.1': | 805 | '@rollup/rollup-android-arm-eabi@4.57.1': |
| 794 | resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} | 806 | resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} |
| 795 | cpu: [arm] | 807 | cpu: [arm] |
| @@ -943,15 +955,9 @@ packages: | @@ -943,15 +955,9 @@ packages: | ||
| 943 | '@types/estree@1.0.8': | 955 | '@types/estree@1.0.8': |
| 944 | resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} | 956 | resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} |
| 945 | 957 | ||
| 946 | - '@types/long@4.0.2': | ||
| 947 | - resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} | ||
| 948 | - | ||
| 949 | '@types/ms@2.1.0': | 958 | '@types/ms@2.1.0': |
| 950 | resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} | 959 | resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} |
| 951 | 960 | ||
| 952 | - '@types/node@10.17.60': | ||
| 953 | - resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} | ||
| 954 | - | ||
| 955 | '@types/node@25.3.2': | 961 | '@types/node@25.3.2': |
| 956 | resolution: {integrity: sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==} | 962 | resolution: {integrity: sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==} |
| 957 | 963 | ||
| @@ -1042,10 +1048,6 @@ packages: | @@ -1042,10 +1048,6 @@ packages: | ||
| 1042 | link-preview-js: | 1048 | link-preview-js: |
| 1043 | optional: true | 1049 | optional: true |
| 1044 | 1050 | ||
| 1045 | - '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': | ||
| 1046 | - resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67} | ||
| 1047 | - version: 2.0.1 | ||
| 1048 | - | ||
| 1049 | '@xmldom/xmldom@0.8.11': | 1051 | '@xmldom/xmldom@0.8.11': |
| 1050 | resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} | 1052 | resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} |
| 1051 | engines: {node: '>=10.0.0'} | 1053 | engines: {node: '>=10.0.0'} |
| @@ -1219,6 +1221,9 @@ packages: | @@ -1219,6 +1221,9 @@ packages: | ||
| 1219 | resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} | 1221 | resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} |
| 1220 | engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} | 1222 | engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} |
| 1221 | 1223 | ||
| 1224 | + boolbase@1.0.0: | ||
| 1225 | + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} | ||
| 1226 | + | ||
| 1222 | bowser@1.9.4: | 1227 | bowser@1.9.4: |
| 1223 | resolution: {integrity: sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==} | 1228 | resolution: {integrity: sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==} |
| 1224 | 1229 | ||
| @@ -1275,6 +1280,13 @@ packages: | @@ -1275,6 +1280,13 @@ packages: | ||
| 1275 | resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} | 1280 | resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} |
| 1276 | engines: {node: '>=10'} | 1281 | engines: {node: '>=10'} |
| 1277 | 1282 | ||
| 1283 | + cheerio-select@2.1.0: | ||
| 1284 | + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} | ||
| 1285 | + | ||
| 1286 | + cheerio@1.2.0: | ||
| 1287 | + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} | ||
| 1288 | + engines: {node: '>=20.18.1'} | ||
| 1289 | + | ||
| 1278 | chownr@1.1.4: | 1290 | chownr@1.1.4: |
| 1279 | resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} | 1291 | resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} |
| 1280 | 1292 | ||
| @@ -1360,6 +1372,13 @@ packages: | @@ -1360,6 +1372,13 @@ packages: | ||
| 1360 | resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} | 1372 | resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} |
| 1361 | engines: {node: '>= 8'} | 1373 | engines: {node: '>= 8'} |
| 1362 | 1374 | ||
| 1375 | + css-select@5.2.2: | ||
| 1376 | + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} | ||
| 1377 | + | ||
| 1378 | + css-what@6.2.2: | ||
| 1379 | + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} | ||
| 1380 | + engines: {node: '>= 6'} | ||
| 1381 | + | ||
| 1363 | csstype@3.2.3: | 1382 | csstype@3.2.3: |
| 1364 | resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} | 1383 | resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} |
| 1365 | 1384 | ||
| @@ -1445,6 +1464,19 @@ packages: | @@ -1445,6 +1464,19 @@ packages: | ||
| 1445 | dom-scroll-into-view@2.0.1: | 1464 | dom-scroll-into-view@2.0.1: |
| 1446 | resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==} | 1465 | resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==} |
| 1447 | 1466 | ||
| 1467 | + dom-serializer@2.0.0: | ||
| 1468 | + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} | ||
| 1469 | + | ||
| 1470 | + domelementtype@2.3.0: | ||
| 1471 | + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} | ||
| 1472 | + | ||
| 1473 | + domhandler@5.0.3: | ||
| 1474 | + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} | ||
| 1475 | + engines: {node: '>= 4'} | ||
| 1476 | + | ||
| 1477 | + domutils@3.2.2: | ||
| 1478 | + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} | ||
| 1479 | + | ||
| 1448 | dotenv@16.6.1: | 1480 | dotenv@16.6.1: |
| 1449 | resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} | 1481 | resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} |
| 1450 | engines: {node: '>=12'} | 1482 | engines: {node: '>=12'} |
| @@ -1478,6 +1510,9 @@ packages: | @@ -1478,6 +1510,9 @@ packages: | ||
| 1478 | resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} | 1510 | resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} |
| 1479 | engines: {node: '>= 0.8'} | 1511 | engines: {node: '>= 0.8'} |
| 1480 | 1512 | ||
| 1513 | + encoding-sniffer@0.2.1: | ||
| 1514 | + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} | ||
| 1515 | + | ||
| 1481 | end-of-stream@1.4.5: | 1516 | end-of-stream@1.4.5: |
| 1482 | resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} | 1517 | resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} |
| 1483 | 1518 | ||
| @@ -1485,6 +1520,14 @@ packages: | @@ -1485,6 +1520,14 @@ packages: | ||
| 1485 | resolution: {integrity: sha512-OclLMSug+k2A0JKuf494im25ANRBVW8qsjmwbgX7lQ8P82H21PQ1PWkoYwb9y5yMBS69BPlwtzdIFClo3+7kOQ==} | 1520 | resolution: {integrity: sha512-OclLMSug+k2A0JKuf494im25ANRBVW8qsjmwbgX7lQ8P82H21PQ1PWkoYwb9y5yMBS69BPlwtzdIFClo3+7kOQ==} |
| 1486 | engines: {node: '>= 0.11.14'} | 1521 | engines: {node: '>= 0.11.14'} |
| 1487 | 1522 | ||
| 1523 | + entities@4.5.0: | ||
| 1524 | + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} | ||
| 1525 | + engines: {node: '>=0.12'} | ||
| 1526 | + | ||
| 1527 | + entities@6.0.1: | ||
| 1528 | + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} | ||
| 1529 | + engines: {node: '>=0.12'} | ||
| 1530 | + | ||
| 1488 | entities@7.0.1: | 1531 | entities@7.0.1: |
| 1489 | resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} | 1532 | resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} |
| 1490 | engines: {node: '>=0.12'} | 1533 | engines: {node: '>=0.12'} |
| @@ -1700,6 +1743,9 @@ packages: | @@ -1700,6 +1743,9 @@ packages: | ||
| 1700 | hookified@1.15.1: | 1743 | hookified@1.15.1: |
| 1701 | resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} | 1744 | resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} |
| 1702 | 1745 | ||
| 1746 | + htmlparser2@10.1.0: | ||
| 1747 | + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} | ||
| 1748 | + | ||
| 1703 | http-errors@2.0.1: | 1749 | http-errors@2.0.1: |
| 1704 | resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} | 1750 | resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} |
| 1705 | engines: {node: '>= 0.8'} | 1751 | engines: {node: '>= 0.8'} |
| @@ -1805,6 +1851,10 @@ packages: | @@ -1805,6 +1851,10 @@ packages: | ||
| 1805 | resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} | 1851 | resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} |
| 1806 | engines: {node: '>= 0.6.3'} | 1852 | engines: {node: '>= 0.6.3'} |
| 1807 | 1853 | ||
| 1854 | + libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7: | ||
| 1855 | + resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7} | ||
| 1856 | + version: 6.0.0 | ||
| 1857 | + | ||
| 1808 | lie@3.3.0: | 1858 | lie@3.3.0: |
| 1809 | resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} | 1859 | resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} |
| 1810 | 1860 | ||
| @@ -1848,9 +1898,6 @@ packages: | @@ -1848,9 +1898,6 @@ packages: | ||
| 1848 | lodash@4.17.23: | 1898 | lodash@4.17.23: |
| 1849 | resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} | 1899 | resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} |
| 1850 | 1900 | ||
| 1851 | - long@4.0.0: | ||
| 1852 | - resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} | ||
| 1853 | - | ||
| 1854 | long@5.3.2: | 1901 | long@5.3.2: |
| 1855 | resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} | 1902 | resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} |
| 1856 | 1903 | ||
| @@ -2011,6 +2058,9 @@ packages: | @@ -2011,6 +2058,9 @@ packages: | ||
| 2011 | resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} | 2058 | resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} |
| 2012 | engines: {node: '>=0.10.0'} | 2059 | engines: {node: '>=0.10.0'} |
| 2013 | 2060 | ||
| 2061 | + nth-check@2.1.1: | ||
| 2062 | + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} | ||
| 2063 | + | ||
| 2014 | object-assign@4.1.1: | 2064 | object-assign@4.1.1: |
| 2015 | resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} | 2065 | resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} |
| 2016 | engines: {node: '>=0.10.0'} | 2066 | engines: {node: '>=0.10.0'} |
| @@ -2072,6 +2122,15 @@ packages: | @@ -2072,6 +2122,15 @@ packages: | ||
| 2072 | pako@1.0.11: | 2122 | pako@1.0.11: |
| 2073 | resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} | 2123 | resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} |
| 2074 | 2124 | ||
| 2125 | + parse5-htmlparser2-tree-adapter@7.1.0: | ||
| 2126 | + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} | ||
| 2127 | + | ||
| 2128 | + parse5-parser-stream@7.1.2: | ||
| 2129 | + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} | ||
| 2130 | + | ||
| 2131 | + parse5@7.3.0: | ||
| 2132 | + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} | ||
| 2133 | + | ||
| 2075 | parseurl@1.3.3: | 2134 | parseurl@1.3.3: |
| 2076 | resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} | 2135 | resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} |
| 2077 | engines: {node: '>= 0.8'} | 2136 | engines: {node: '>= 0.8'} |
| @@ -2171,14 +2230,14 @@ packages: | @@ -2171,14 +2230,14 @@ packages: | ||
| 2171 | resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} | 2230 | resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} |
| 2172 | engines: {node: '>= 0.6.0'} | 2231 | engines: {node: '>= 0.6.0'} |
| 2173 | 2232 | ||
| 2174 | - protobufjs@6.8.8: | ||
| 2175 | - resolution: {integrity: sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==} | ||
| 2176 | - hasBin: true | ||
| 2177 | - | ||
| 2178 | protobufjs@7.5.4: | 2233 | protobufjs@7.5.4: |
| 2179 | resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} | 2234 | resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} |
| 2180 | engines: {node: '>=12.0.0'} | 2235 | engines: {node: '>=12.0.0'} |
| 2181 | 2236 | ||
| 2237 | + protobufjs@7.5.8: | ||
| 2238 | + resolution: {integrity: sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==} | ||
| 2239 | + engines: {node: '>=12.0.0'} | ||
| 2240 | + | ||
| 2182 | proxy-addr@2.0.7: | 2241 | proxy-addr@2.0.7: |
| 2183 | resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} | 2242 | resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} |
| 2184 | engines: {node: '>= 0.10'} | 2243 | engines: {node: '>= 0.10'} |
| @@ -2577,6 +2636,10 @@ packages: | @@ -2577,6 +2636,10 @@ packages: | ||
| 2577 | undici-types@7.18.2: | 2636 | undici-types@7.18.2: |
| 2578 | resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} | 2637 | resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} |
| 2579 | 2638 | ||
| 2639 | + undici@7.25.0: | ||
| 2640 | + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} | ||
| 2641 | + engines: {node: '>=20.18.1'} | ||
| 2642 | + | ||
| 2580 | unescape@1.0.1: | 2643 | unescape@1.0.1: |
| 2581 | resolution: {integrity: sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==} | 2644 | resolution: {integrity: sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==} |
| 2582 | engines: {node: '>=0.10.0'} | 2645 | engines: {node: '>=0.10.0'} |
| @@ -2755,6 +2818,15 @@ packages: | @@ -2755,6 +2818,15 @@ packages: | ||
| 2755 | warning@4.0.3: | 2818 | warning@4.0.3: |
| 2756 | resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} | 2819 | resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} |
| 2757 | 2820 | ||
| 2821 | + whatwg-encoding@3.1.1: | ||
| 2822 | + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} | ||
| 2823 | + engines: {node: '>=18'} | ||
| 2824 | + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation | ||
| 2825 | + | ||
| 2826 | + whatwg-mimetype@4.0.0: | ||
| 2827 | + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} | ||
| 2828 | + engines: {node: '>=18'} | ||
| 2829 | + | ||
| 2758 | which-module@2.0.1: | 2830 | which-module@2.0.1: |
| 2759 | resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} | 2831 | resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} |
| 2760 | 2832 | ||
| @@ -3261,6 +3333,8 @@ snapshots: | @@ -3261,6 +3333,8 @@ snapshots: | ||
| 3261 | 3333 | ||
| 3262 | '@protobufjs/codegen@2.0.4': {} | 3334 | '@protobufjs/codegen@2.0.4': {} |
| 3263 | 3335 | ||
| 3336 | + '@protobufjs/codegen@2.0.5': {} | ||
| 3337 | + | ||
| 3264 | '@protobufjs/eventemitter@1.1.0': {} | 3338 | '@protobufjs/eventemitter@1.1.0': {} |
| 3265 | 3339 | ||
| 3266 | '@protobufjs/fetch@1.1.0': | 3340 | '@protobufjs/fetch@1.1.0': |
| @@ -3272,12 +3346,16 @@ snapshots: | @@ -3272,12 +3346,16 @@ snapshots: | ||
| 3272 | 3346 | ||
| 3273 | '@protobufjs/inquire@1.1.0': {} | 3347 | '@protobufjs/inquire@1.1.0': {} |
| 3274 | 3348 | ||
| 3349 | + '@protobufjs/inquire@1.1.1': {} | ||
| 3350 | + | ||
| 3275 | '@protobufjs/path@1.1.2': {} | 3351 | '@protobufjs/path@1.1.2': {} |
| 3276 | 3352 | ||
| 3277 | '@protobufjs/pool@1.1.0': {} | 3353 | '@protobufjs/pool@1.1.0': {} |
| 3278 | 3354 | ||
| 3279 | '@protobufjs/utf8@1.1.0': {} | 3355 | '@protobufjs/utf8@1.1.0': {} |
| 3280 | 3356 | ||
| 3357 | + '@protobufjs/utf8@1.1.1': {} | ||
| 3358 | + | ||
| 3281 | '@rollup/rollup-android-arm-eabi@4.57.1': | 3359 | '@rollup/rollup-android-arm-eabi@4.57.1': |
| 3282 | optional: true | 3360 | optional: true |
| 3283 | 3361 | ||
| @@ -3384,12 +3462,8 @@ snapshots: | @@ -3384,12 +3462,8 @@ snapshots: | ||
| 3384 | 3462 | ||
| 3385 | '@types/estree@1.0.8': {} | 3463 | '@types/estree@1.0.8': {} |
| 3386 | 3464 | ||
| 3387 | - '@types/long@4.0.2': {} | ||
| 3388 | - | ||
| 3389 | '@types/ms@2.1.0': {} | 3465 | '@types/ms@2.1.0': {} |
| 3390 | 3466 | ||
| 3391 | - '@types/node@10.17.60': {} | ||
| 3392 | - | ||
| 3393 | '@types/node@25.3.2': | 3467 | '@types/node@25.3.2': |
| 3394 | dependencies: | 3468 | dependencies: |
| 3395 | undici-types: 7.18.2 | 3469 | undici-types: 7.18.2 |
| @@ -3501,7 +3575,7 @@ snapshots: | @@ -3501,7 +3575,7 @@ snapshots: | ||
| 3501 | '@cacheable/node-cache': 1.7.6 | 3575 | '@cacheable/node-cache': 1.7.6 |
| 3502 | '@hapi/boom': 9.1.4 | 3576 | '@hapi/boom': 9.1.4 |
| 3503 | async-mutex: 0.5.0 | 3577 | async-mutex: 0.5.0 |
| 3504 | - libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' | 3578 | + libsignal: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7 |
| 3505 | lru-cache: 11.2.6 | 3579 | lru-cache: 11.2.6 |
| 3506 | music-metadata: 11.12.1 | 3580 | music-metadata: 11.12.1 |
| 3507 | p-queue: 9.1.0 | 3581 | p-queue: 9.1.0 |
| @@ -3514,11 +3588,6 @@ snapshots: | @@ -3514,11 +3588,6 @@ snapshots: | ||
| 3514 | - supports-color | 3588 | - supports-color |
| 3515 | - utf-8-validate | 3589 | - utf-8-validate |
| 3516 | 3590 | ||
| 3517 | - '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': | ||
| 3518 | - dependencies: | ||
| 3519 | - curve25519-js: 0.0.4 | ||
| 3520 | - protobufjs: 6.8.8 | ||
| 3521 | - | ||
| 3522 | '@xmldom/xmldom@0.8.11': {} | 3591 | '@xmldom/xmldom@0.8.11': {} |
| 3523 | 3592 | ||
| 3524 | abort-controller@3.0.0: | 3593 | abort-controller@3.0.0: |
| @@ -3740,6 +3809,8 @@ snapshots: | @@ -3740,6 +3809,8 @@ snapshots: | ||
| 3740 | transitivePeerDependencies: | 3809 | transitivePeerDependencies: |
| 3741 | - supports-color | 3810 | - supports-color |
| 3742 | 3811 | ||
| 3812 | + boolbase@1.0.0: {} | ||
| 3813 | + | ||
| 3743 | bowser@1.9.4: {} | 3814 | bowser@1.9.4: {} |
| 3744 | 3815 | ||
| 3745 | brace-expansion@2.0.2: | 3816 | brace-expansion@2.0.2: |
| @@ -3797,6 +3868,29 @@ snapshots: | @@ -3797,6 +3868,29 @@ snapshots: | ||
| 3797 | ansi-styles: 4.3.0 | 3868 | ansi-styles: 4.3.0 |
| 3798 | supports-color: 7.2.0 | 3869 | supports-color: 7.2.0 |
| 3799 | 3870 | ||
| 3871 | + cheerio-select@2.1.0: | ||
| 3872 | + dependencies: | ||
| 3873 | + boolbase: 1.0.0 | ||
| 3874 | + css-select: 5.2.2 | ||
| 3875 | + css-what: 6.2.2 | ||
| 3876 | + domelementtype: 2.3.0 | ||
| 3877 | + domhandler: 5.0.3 | ||
| 3878 | + domutils: 3.2.2 | ||
| 3879 | + | ||
| 3880 | + cheerio@1.2.0: | ||
| 3881 | + dependencies: | ||
| 3882 | + cheerio-select: 2.1.0 | ||
| 3883 | + dom-serializer: 2.0.0 | ||
| 3884 | + domhandler: 5.0.3 | ||
| 3885 | + domutils: 3.2.2 | ||
| 3886 | + encoding-sniffer: 0.2.1 | ||
| 3887 | + htmlparser2: 10.1.0 | ||
| 3888 | + parse5: 7.3.0 | ||
| 3889 | + parse5-htmlparser2-tree-adapter: 7.1.0 | ||
| 3890 | + parse5-parser-stream: 7.1.2 | ||
| 3891 | + undici: 7.25.0 | ||
| 3892 | + whatwg-mimetype: 4.0.0 | ||
| 3893 | + | ||
| 3800 | chownr@1.1.4: {} | 3894 | chownr@1.1.4: {} |
| 3801 | 3895 | ||
| 3802 | cliui@6.0.0: | 3896 | cliui@6.0.0: |
| @@ -3891,6 +3985,16 @@ snapshots: | @@ -3891,6 +3985,16 @@ snapshots: | ||
| 3891 | shebang-command: 2.0.0 | 3985 | shebang-command: 2.0.0 |
| 3892 | which: 2.0.2 | 3986 | which: 2.0.2 |
| 3893 | 3987 | ||
| 3988 | + css-select@5.2.2: | ||
| 3989 | + dependencies: | ||
| 3990 | + boolbase: 1.0.0 | ||
| 3991 | + css-what: 6.2.2 | ||
| 3992 | + domhandler: 5.0.3 | ||
| 3993 | + domutils: 3.2.2 | ||
| 3994 | + nth-check: 2.1.1 | ||
| 3995 | + | ||
| 3996 | + css-what@6.2.2: {} | ||
| 3997 | + | ||
| 3894 | csstype@3.2.3: {} | 3998 | csstype@3.2.3: {} |
| 3895 | 3999 | ||
| 3896 | curve25519-js@0.0.4: {} | 4000 | curve25519-js@0.0.4: {} |
| @@ -3943,6 +4047,24 @@ snapshots: | @@ -3943,6 +4047,24 @@ snapshots: | ||
| 3943 | 4047 | ||
| 3944 | dom-scroll-into-view@2.0.1: {} | 4048 | dom-scroll-into-view@2.0.1: {} |
| 3945 | 4049 | ||
| 4050 | + dom-serializer@2.0.0: | ||
| 4051 | + dependencies: | ||
| 4052 | + domelementtype: 2.3.0 | ||
| 4053 | + domhandler: 5.0.3 | ||
| 4054 | + entities: 4.5.0 | ||
| 4055 | + | ||
| 4056 | + domelementtype@2.3.0: {} | ||
| 4057 | + | ||
| 4058 | + domhandler@5.0.3: | ||
| 4059 | + dependencies: | ||
| 4060 | + domelementtype: 2.3.0 | ||
| 4061 | + | ||
| 4062 | + domutils@3.2.2: | ||
| 4063 | + dependencies: | ||
| 4064 | + dom-serializer: 2.0.0 | ||
| 4065 | + domelementtype: 2.3.0 | ||
| 4066 | + domhandler: 5.0.3 | ||
| 4067 | + | ||
| 3946 | dotenv@16.6.1: {} | 4068 | dotenv@16.6.1: {} |
| 3947 | 4069 | ||
| 3948 | dottie@2.0.7: {} | 4070 | dottie@2.0.7: {} |
| @@ -3971,12 +4093,21 @@ snapshots: | @@ -3971,12 +4093,21 @@ snapshots: | ||
| 3971 | 4093 | ||
| 3972 | encodeurl@2.0.0: {} | 4094 | encodeurl@2.0.0: {} |
| 3973 | 4095 | ||
| 4096 | + encoding-sniffer@0.2.1: | ||
| 4097 | + dependencies: | ||
| 4098 | + iconv-lite: 0.6.3 | ||
| 4099 | + whatwg-encoding: 3.1.1 | ||
| 4100 | + | ||
| 3974 | end-of-stream@1.4.5: | 4101 | end-of-stream@1.4.5: |
| 3975 | dependencies: | 4102 | dependencies: |
| 3976 | once: 1.4.0 | 4103 | once: 1.4.0 |
| 3977 | 4104 | ||
| 3978 | end-or-error@1.0.1: {} | 4105 | end-or-error@1.0.1: {} |
| 3979 | 4106 | ||
| 4107 | + entities@4.5.0: {} | ||
| 4108 | + | ||
| 4109 | + entities@6.0.1: {} | ||
| 4110 | + | ||
| 3980 | entities@7.0.1: {} | 4111 | entities@7.0.1: {} |
| 3981 | 4112 | ||
| 3982 | es-define-property@1.0.1: {} | 4113 | es-define-property@1.0.1: {} |
| @@ -4254,6 +4385,13 @@ snapshots: | @@ -4254,6 +4385,13 @@ snapshots: | ||
| 4254 | 4385 | ||
| 4255 | hookified@1.15.1: {} | 4386 | hookified@1.15.1: {} |
| 4256 | 4387 | ||
| 4388 | + htmlparser2@10.1.0: | ||
| 4389 | + dependencies: | ||
| 4390 | + domelementtype: 2.3.0 | ||
| 4391 | + domhandler: 5.0.3 | ||
| 4392 | + domutils: 3.2.2 | ||
| 4393 | + entities: 7.0.1 | ||
| 4394 | + | ||
| 4257 | http-errors@2.0.1: | 4395 | http-errors@2.0.1: |
| 4258 | dependencies: | 4396 | dependencies: |
| 4259 | depd: 2.0.0 | 4397 | depd: 2.0.0 |
| @@ -4365,6 +4503,11 @@ snapshots: | @@ -4365,6 +4503,11 @@ snapshots: | ||
| 4365 | dependencies: | 4503 | dependencies: |
| 4366 | readable-stream: 2.3.8 | 4504 | readable-stream: 2.3.8 |
| 4367 | 4505 | ||
| 4506 | + libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7: | ||
| 4507 | + dependencies: | ||
| 4508 | + curve25519-js: 0.0.4 | ||
| 4509 | + protobufjs: 7.5.8 | ||
| 4510 | + | ||
| 4368 | lie@3.3.0: | 4511 | lie@3.3.0: |
| 4369 | dependencies: | 4512 | dependencies: |
| 4370 | immediate: 3.0.6 | 4513 | immediate: 3.0.6 |
| @@ -4397,8 +4540,6 @@ snapshots: | @@ -4397,8 +4540,6 @@ snapshots: | ||
| 4397 | 4540 | ||
| 4398 | lodash@4.17.23: {} | 4541 | lodash@4.17.23: {} |
| 4399 | 4542 | ||
| 4400 | - long@4.0.0: {} | ||
| 4401 | - | ||
| 4402 | long@5.3.2: {} | 4543 | long@5.3.2: {} |
| 4403 | 4544 | ||
| 4404 | loose-envify@1.4.0: | 4545 | loose-envify@1.4.0: |
| @@ -4548,6 +4689,10 @@ snapshots: | @@ -4548,6 +4689,10 @@ snapshots: | ||
| 4548 | 4689 | ||
| 4549 | normalize-path@3.0.0: {} | 4690 | normalize-path@3.0.0: {} |
| 4550 | 4691 | ||
| 4692 | + nth-check@2.1.1: | ||
| 4693 | + dependencies: | ||
| 4694 | + boolbase: 1.0.0 | ||
| 4695 | + | ||
| 4551 | object-assign@4.1.1: {} | 4696 | object-assign@4.1.1: {} |
| 4552 | 4697 | ||
| 4553 | object-inspect@1.13.4: {} | 4698 | object-inspect@1.13.4: {} |
| @@ -4596,6 +4741,19 @@ snapshots: | @@ -4596,6 +4741,19 @@ snapshots: | ||
| 4596 | 4741 | ||
| 4597 | pako@1.0.11: {} | 4742 | pako@1.0.11: {} |
| 4598 | 4743 | ||
| 4744 | + parse5-htmlparser2-tree-adapter@7.1.0: | ||
| 4745 | + dependencies: | ||
| 4746 | + domhandler: 5.0.3 | ||
| 4747 | + parse5: 7.3.0 | ||
| 4748 | + | ||
| 4749 | + parse5-parser-stream@7.1.2: | ||
| 4750 | + dependencies: | ||
| 4751 | + parse5: 7.3.0 | ||
| 4752 | + | ||
| 4753 | + parse5@7.3.0: | ||
| 4754 | + dependencies: | ||
| 4755 | + entities: 6.0.1 | ||
| 4756 | + | ||
| 4599 | parseurl@1.3.3: {} | 4757 | parseurl@1.3.3: {} |
| 4600 | 4758 | ||
| 4601 | path-exists@4.0.0: {} | 4759 | path-exists@4.0.0: {} |
| @@ -4697,7 +4855,7 @@ snapshots: | @@ -4697,7 +4855,7 @@ snapshots: | ||
| 4697 | 4855 | ||
| 4698 | process@0.11.10: {} | 4856 | process@0.11.10: {} |
| 4699 | 4857 | ||
| 4700 | - protobufjs@6.8.8: | 4858 | + protobufjs@7.5.4: |
| 4701 | dependencies: | 4859 | dependencies: |
| 4702 | '@protobufjs/aspromise': 1.1.2 | 4860 | '@protobufjs/aspromise': 1.1.2 |
| 4703 | '@protobufjs/base64': 1.1.2 | 4861 | '@protobufjs/base64': 1.1.2 |
| @@ -4709,22 +4867,21 @@ snapshots: | @@ -4709,22 +4867,21 @@ snapshots: | ||
| 4709 | '@protobufjs/path': 1.1.2 | 4867 | '@protobufjs/path': 1.1.2 |
| 4710 | '@protobufjs/pool': 1.1.0 | 4868 | '@protobufjs/pool': 1.1.0 |
| 4711 | '@protobufjs/utf8': 1.1.0 | 4869 | '@protobufjs/utf8': 1.1.0 |
| 4712 | - '@types/long': 4.0.2 | ||
| 4713 | - '@types/node': 10.17.60 | ||
| 4714 | - long: 4.0.0 | 4870 | + '@types/node': 25.3.2 |
| 4871 | + long: 5.3.2 | ||
| 4715 | 4872 | ||
| 4716 | - protobufjs@7.5.4: | 4873 | + protobufjs@7.5.8: |
| 4717 | dependencies: | 4874 | dependencies: |
| 4718 | '@protobufjs/aspromise': 1.1.2 | 4875 | '@protobufjs/aspromise': 1.1.2 |
| 4719 | '@protobufjs/base64': 1.1.2 | 4876 | '@protobufjs/base64': 1.1.2 |
| 4720 | - '@protobufjs/codegen': 2.0.4 | 4877 | + '@protobufjs/codegen': 2.0.5 |
| 4721 | '@protobufjs/eventemitter': 1.1.0 | 4878 | '@protobufjs/eventemitter': 1.1.0 |
| 4722 | '@protobufjs/fetch': 1.1.0 | 4879 | '@protobufjs/fetch': 1.1.0 |
| 4723 | '@protobufjs/float': 1.0.2 | 4880 | '@protobufjs/float': 1.0.2 |
| 4724 | - '@protobufjs/inquire': 1.1.0 | 4881 | + '@protobufjs/inquire': 1.1.1 |
| 4725 | '@protobufjs/path': 1.1.2 | 4882 | '@protobufjs/path': 1.1.2 |
| 4726 | '@protobufjs/pool': 1.1.0 | 4883 | '@protobufjs/pool': 1.1.0 |
| 4727 | - '@protobufjs/utf8': 1.1.0 | 4884 | + '@protobufjs/utf8': 1.1.1 |
| 4728 | '@types/node': 25.3.2 | 4885 | '@types/node': 25.3.2 |
| 4729 | long: 5.3.2 | 4886 | long: 5.3.2 |
| 4730 | 4887 | ||
| @@ -5198,6 +5355,8 @@ snapshots: | @@ -5198,6 +5355,8 @@ snapshots: | ||
| 5198 | 5355 | ||
| 5199 | undici-types@7.18.2: {} | 5356 | undici-types@7.18.2: {} |
| 5200 | 5357 | ||
| 5358 | + undici@7.25.0: {} | ||
| 5359 | + | ||
| 5201 | unescape@1.0.1: | 5360 | unescape@1.0.1: |
| 5202 | dependencies: | 5361 | dependencies: |
| 5203 | extend-shallow: 2.0.1 | 5362 | extend-shallow: 2.0.1 |
| @@ -5327,6 +5486,12 @@ snapshots: | @@ -5327,6 +5486,12 @@ snapshots: | ||
| 5327 | dependencies: | 5486 | dependencies: |
| 5328 | loose-envify: 1.4.0 | 5487 | loose-envify: 1.4.0 |
| 5329 | 5488 | ||
| 5489 | + whatwg-encoding@3.1.1: | ||
| 5490 | + dependencies: | ||
| 5491 | + iconv-lite: 0.6.3 | ||
| 5492 | + | ||
| 5493 | + whatwg-mimetype@4.0.0: {} | ||
| 5494 | + | ||
| 5330 | which-module@2.0.1: {} | 5495 | which-module@2.0.1: {} |
| 5331 | 5496 | ||
| 5332 | which@2.0.2: | 5497 | which@2.0.2: |
server/services/devloop/bug-tracker.js
0 → 100644
| 1 | +/** | ||
| 2 | + * DevLoop · bugTracker | ||
| 3 | + * | ||
| 4 | + * bugs.ndjson 的 CRUD + 状态机(P-9)。 | ||
| 5 | + * - bugs.ndjson 是 append-only 流。一条 bug 的「当前态」= 该 id 的<b>最新一行</b>。 | ||
| 6 | + * - dedupKey = "{unit_id}:{test_name}"。open() 时同 key 已存在 open/fixing → 不重复,返旧 id。 | ||
| 7 | + * - transition() 走 P-9 状态机校验,非法转换抛错。 | ||
| 8 | + * | ||
| 9 | + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-16 | ||
| 10 | + * docs/prd/newfunction/_spec/sdd-model.md · M-14 | ||
| 11 | + * docs/prd/newfunction/_spec/sdd-process.md · P-9 | ||
| 12 | + */ | ||
| 13 | + | ||
| 14 | +const fs = require('fs'); | ||
| 15 | +const path = require('path'); | ||
| 16 | +const crypto = require('crypto'); | ||
| 17 | + | ||
| 18 | +const pathResolver = require('./path-resolver'); | ||
| 19 | + | ||
| 20 | +// 可选依赖:timelineLogger / sseBroker。LoopController 启动时 attach。 | ||
| 21 | +let timelineLogger = null; | ||
| 22 | +let sseBroker = null; | ||
| 23 | +function attachTimelineLogger(t) { timelineLogger = t; } | ||
| 24 | +function attachSseBroker(b) { sseBroker = b; } | ||
| 25 | + | ||
| 26 | +// ──────────────────────────────────────────────────────────────── | ||
| 27 | +// 状态机 | ||
| 28 | +// ──────────────────────────────────────────────────────────────── | ||
| 29 | + | ||
| 30 | +const VALID_TRANSITIONS = { | ||
| 31 | + open: ['fixing', 'wontfix', 'duplicate'], | ||
| 32 | + fixing: ['fixed', 'open'], | ||
| 33 | + fixed: ['verified', 'open'], | ||
| 34 | + verified: ['open'], | ||
| 35 | + wontfix: [], | ||
| 36 | + duplicate: [], | ||
| 37 | +}; | ||
| 38 | + | ||
| 39 | +function canTransition(from, to) { | ||
| 40 | + if (!VALID_TRANSITIONS[from]) return false; | ||
| 41 | + return VALID_TRANSITIONS[from].includes(to); | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +class InvalidBugTransitionError extends Error { | ||
| 45 | + constructor(from, to, bugId) { | ||
| 46 | + super(`Invalid bug transition for ${bugId}: ${from} → ${to}`); | ||
| 47 | + this.name = 'InvalidBugTransitionError'; | ||
| 48 | + this.from = from; | ||
| 49 | + this.to = to; | ||
| 50 | + this.bugId = bugId; | ||
| 51 | + } | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +// ──────────────────────────────────────────────────────────────── | ||
| 55 | +// 核心 | ||
| 56 | +// ──────────────────────────────────────────────────────────────── | ||
| 57 | + | ||
| 58 | +function dedupKey(bug) { | ||
| 59 | + const parts = [bug.unit_id || 'unknown', bug.test_name || bug.title || '']; | ||
| 60 | + return parts.join(':'); | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +function _genBugId(now = new Date()) { | ||
| 64 | + const ts = now.toISOString().replace(/[-:T.Z]/g, '').slice(0, 14); // YYYYMMDDHHMMSS | ||
| 65 | + const tag = crypto.randomBytes(2).toString('hex'); | ||
| 66 | + return `bug-${ts.slice(0,8)}-${ts.slice(8,12)}-${tag}`; | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +function _now() { return new Date().toISOString(); } | ||
| 70 | + | ||
| 71 | +function _logPath(pid) { return pathResolver.resolveBugsLog(pid); } | ||
| 72 | + | ||
| 73 | +function _appendLine(pid, obj) { | ||
| 74 | + const p = _logPath(pid); | ||
| 75 | + fs.mkdirSync(path.dirname(p), { recursive: true }); | ||
| 76 | + fs.appendFileSync(p, JSON.stringify(obj) + '\n'); | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +/** 流式读全部行(小项目 OK;大项目可优化为 readline 流式) */ | ||
| 80 | +function _readAll(pid) { | ||
| 81 | + const p = _logPath(pid); | ||
| 82 | + if (!fs.existsSync(p)) return []; | ||
| 83 | + const raw = fs.readFileSync(p, 'utf8'); | ||
| 84 | + return raw.split('\n').filter(Boolean).map(l => { | ||
| 85 | + try { return JSON.parse(l); } catch { return null; } | ||
| 86 | + }).filter(Boolean); | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +/** | ||
| 90 | + * 按 bug id 聚合:最后一行 = 终态。 | ||
| 91 | + * @returns Map<bugId, Bug> | ||
| 92 | + */ | ||
| 93 | +function _aggregateLatest(pid) { | ||
| 94 | + const lines = _readAll(pid); | ||
| 95 | + const map = new Map(); | ||
| 96 | + for (const line of lines) { | ||
| 97 | + if (!line.id) continue; | ||
| 98 | + map.set(line.id, line); | ||
| 99 | + } | ||
| 100 | + return map; | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +// ──────────────────────────────────────────────────────────────── | ||
| 104 | +// open / transition | ||
| 105 | +// ──────────────────────────────────────────────────────────────── | ||
| 106 | + | ||
| 107 | +/** | ||
| 108 | + * 开 bug。如 dedupKey 命中已存在 status=open|fixing 的 bug → 不重复,返旧 bug。 | ||
| 109 | + * | ||
| 110 | + * @param {string} pid | ||
| 111 | + * @param {object} bug - 必填: source, unit_id?, run_id?, test_name?, category, severity, title, detail, affected_units | ||
| 112 | + * @returns Bug (含 id) | ||
| 113 | + */ | ||
| 114 | +function open(pid, bug) { | ||
| 115 | + // dedup 检查 | ||
| 116 | + const key = dedupKey(bug); | ||
| 117 | + const all = _aggregateLatest(pid); | ||
| 118 | + for (const b of all.values()) { | ||
| 119 | + if ((b.status === 'open' || b.status === 'fixing') && | ||
| 120 | + dedupKey(b) === key) { | ||
| 121 | + return b; | ||
| 122 | + } | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + const id = _genBugId(); | ||
| 126 | + const now = _now(); | ||
| 127 | + const full = { | ||
| 128 | + id, | ||
| 129 | + discovered_at: now, | ||
| 130 | + source: bug.source || 'unit_test', | ||
| 131 | + unit_id: bug.unit_id, | ||
| 132 | + run_id: bug.run_id, | ||
| 133 | + test_name: bug.test_name, | ||
| 134 | + category: bug.category || 'unknown', | ||
| 135 | + severity: bug.severity || 'medium', | ||
| 136 | + title: (bug.title || '(no title)').slice(0, 120), | ||
| 137 | + detail: bug.detail || '', | ||
| 138 | + affected_units: bug.affected_units || (bug.unit_id ? [bug.unit_id] : []), | ||
| 139 | + status: 'open', | ||
| 140 | + status_history: [{ | ||
| 141 | + ts: now, from: '(none)', to: 'open', | ||
| 142 | + by: bug.by || 'tester', | ||
| 143 | + note: bug.note, | ||
| 144 | + }], | ||
| 145 | + fix_refs: [], | ||
| 146 | + }; | ||
| 147 | + _appendLine(pid, full); | ||
| 148 | + | ||
| 149 | + if (timelineLogger) { | ||
| 150 | + timelineLogger.append(pid, { | ||
| 151 | + source: bug.source || 'tester', | ||
| 152 | + kind: 'bug_opened', | ||
| 153 | + ref: id, | ||
| 154 | + summary: full.title, | ||
| 155 | + }); | ||
| 156 | + } | ||
| 157 | + if (sseBroker) { | ||
| 158 | + sseBroker.emit(pid, 'bug.opened', { id, category: full.category, severity: full.severity, title: full.title }); | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | + return full; | ||
| 162 | +} | ||
| 163 | + | ||
| 164 | +/** | ||
| 165 | + * 状态转换。同步发 SSE + timeline。 | ||
| 166 | + */ | ||
| 167 | +function transition(pid, bugId, { to, by, note, fix_refs, duplicate_of } = {}) { | ||
| 168 | + if (!to) throw new Error('transition requires `to` field'); | ||
| 169 | + const map = _aggregateLatest(pid); | ||
| 170 | + const b = map.get(bugId); | ||
| 171 | + if (!b) throw new Error(`bug ${bugId} not found`); | ||
| 172 | + | ||
| 173 | + if (!canTransition(b.status, to)) { | ||
| 174 | + throw new InvalidBugTransitionError(b.status, to, bugId); | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + const now = _now(); | ||
| 178 | + // 复制一份 + 应用变更 | ||
| 179 | + const next = { | ||
| 180 | + ...b, | ||
| 181 | + status: to, | ||
| 182 | + status_history: [ | ||
| 183 | + ...(b.status_history || []), | ||
| 184 | + { ts: now, from: b.status, to, by: by || 'system', note }, | ||
| 185 | + ], | ||
| 186 | + fix_refs: fix_refs ? [...(b.fix_refs || []), ...fix_refs] : (b.fix_refs || []), | ||
| 187 | + ...(duplicate_of ? { duplicate_of } : {}), | ||
| 188 | + }; | ||
| 189 | + _appendLine(pid, next); | ||
| 190 | + | ||
| 191 | + if (timelineLogger) { | ||
| 192 | + timelineLogger.append(pid, { | ||
| 193 | + source: by || 'system', | ||
| 194 | + kind: to === 'open' ? 'bug_reopened' : 'bug_transitioned', | ||
| 195 | + ref: bugId, | ||
| 196 | + summary: `${b.status} → ${to}`, | ||
| 197 | + }); | ||
| 198 | + } | ||
| 199 | + if (sseBroker) { | ||
| 200 | + sseBroker.emit(pid, 'bug.transitioned', { id: bugId, from: b.status, to, by }); | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + return next; | ||
| 204 | +} | ||
| 205 | + | ||
| 206 | +// ──────────────────────────────────────────────────────────────── | ||
| 207 | +// 读 | ||
| 208 | +// ──────────────────────────────────────────────────────────────── | ||
| 209 | + | ||
| 210 | +/** | ||
| 211 | + * 列 bug(每个 id 的最新态)。可按 filter 过滤。 | ||
| 212 | + * @param {object} filter - { status?: string[], severity?: string[], unit_id?, since?: ISO date } | ||
| 213 | + */ | ||
| 214 | +function list(pid, filter = {}) { | ||
| 215 | + let out = Array.from(_aggregateLatest(pid).values()); | ||
| 216 | + if (filter.status?.length) { | ||
| 217 | + out = out.filter(b => filter.status.includes(b.status)); | ||
| 218 | + } | ||
| 219 | + if (filter.severity?.length) { | ||
| 220 | + out = out.filter(b => filter.severity.includes(b.severity)); | ||
| 221 | + } | ||
| 222 | + if (filter.unit_id) { | ||
| 223 | + out = out.filter(b => b.unit_id === filter.unit_id); | ||
| 224 | + } | ||
| 225 | + if (filter.since) { | ||
| 226 | + const t = new Date(filter.since).getTime(); | ||
| 227 | + out = out.filter(b => new Date(b.discovered_at).getTime() >= t); | ||
| 228 | + } | ||
| 229 | + return out; | ||
| 230 | +} | ||
| 231 | + | ||
| 232 | +function get(pid, bugId) { | ||
| 233 | + return _aggregateLatest(pid).get(bugId) || null; | ||
| 234 | +} | ||
| 235 | + | ||
| 236 | +/** 完整 status_history(所有 history 条目,含 open 时的初始条目) */ | ||
| 237 | +function history(pid, bugId) { | ||
| 238 | + const b = get(pid, bugId); | ||
| 239 | + if (!b) return []; | ||
| 240 | + return b.status_history || []; | ||
| 241 | +} | ||
| 242 | + | ||
| 243 | +function stats(pid) { | ||
| 244 | + const all = list(pid); | ||
| 245 | + const out = { | ||
| 246 | + open: 0, fixing: 0, fixed: 0, verified: 0, wontfix: 0, duplicate: 0, | ||
| 247 | + by_severity: { critical: 0, high: 0, medium: 0, low: 0 }, | ||
| 248 | + total: all.length, | ||
| 249 | + }; | ||
| 250 | + for (const b of all) { | ||
| 251 | + if (out[b.status] !== undefined) out[b.status]++; | ||
| 252 | + if (out.by_severity[b.severity] !== undefined) out.by_severity[b.severity]++; | ||
| 253 | + } | ||
| 254 | + return out; | ||
| 255 | +} | ||
| 256 | + | ||
| 257 | +module.exports = { | ||
| 258 | + // 写 | ||
| 259 | + open, | ||
| 260 | + transition, | ||
| 261 | + // 读 | ||
| 262 | + list, | ||
| 263 | + get, | ||
| 264 | + history, | ||
| 265 | + stats, | ||
| 266 | + // 工具 | ||
| 267 | + dedupKey, | ||
| 268 | + canTransition, | ||
| 269 | + VALID_TRANSITIONS, | ||
| 270 | + InvalidBugTransitionError, | ||
| 271 | + // 依赖注入 | ||
| 272 | + attachTimelineLogger, | ||
| 273 | + attachSseBroker, | ||
| 274 | +}; |
server/services/devloop/changelog-writer.js
0 → 100644
| 1 | +/** | ||
| 2 | + * DevLoop · changelogWriter | ||
| 3 | + * | ||
| 4 | + * 跨 PRD/SDD/units 的版本变更流(M-15)。append-only NDJSON。 | ||
| 5 | + * - 由 DocStore 在 writePRD/writeSDD/writeUnits 成功后调用 | ||
| 6 | + * - 外部 Agent 不直接调 | ||
| 7 | + * | ||
| 8 | + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-17 | ||
| 9 | + * docs/prd/newfunction/_spec/sdd-model.md · M-15 | ||
| 10 | + */ | ||
| 11 | + | ||
| 12 | +const fs = require('fs'); | ||
| 13 | +const path = require('path'); | ||
| 14 | + | ||
| 15 | +const pathResolver = require('./path-resolver'); | ||
| 16 | + | ||
| 17 | +function _logPath(pid) { return pathResolver.resolveChangelog(pid); } | ||
| 18 | + | ||
| 19 | +const VALID_KINDS = ['prd_rev', 'sdd_rev', 'units_rev', 'integration_spec_rev']; | ||
| 20 | + | ||
| 21 | +function append(pid, entry) { | ||
| 22 | + if (!VALID_KINDS.includes(entry.kind)) { | ||
| 23 | + throw new Error(`changelogWriter.append: invalid kind "${entry.kind}"`); | ||
| 24 | + } | ||
| 25 | + if (typeof entry.rev !== 'number') { | ||
| 26 | + throw new Error('changelogWriter.append: rev (number) required'); | ||
| 27 | + } | ||
| 28 | + if (!entry.summary) { | ||
| 29 | + throw new Error('changelogWriter.append: summary required'); | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + const full = { | ||
| 33 | + ts: new Date().toISOString(), | ||
| 34 | + kind: entry.kind, | ||
| 35 | + rev: entry.rev, | ||
| 36 | + by: entry.by || 'system', | ||
| 37 | + trigger: entry.trigger || 'unknown', | ||
| 38 | + summary: entry.summary, | ||
| 39 | + diff: entry.diff || { | ||
| 40 | + sections_added: [], sections_changed: [], sections_removed: [], | ||
| 41 | + units_added: [], units_changed: [], units_removed: [], | ||
| 42 | + }, | ||
| 43 | + archive_ref: entry.archive_ref || null, | ||
| 44 | + }; | ||
| 45 | + | ||
| 46 | + const line = JSON.stringify(full) + '\n'; | ||
| 47 | + const p = _logPath(pid); | ||
| 48 | + fs.mkdirSync(path.dirname(p), { recursive: true }); | ||
| 49 | + fs.appendFileSync(p, line); | ||
| 50 | + return full; | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +function read(pid, opts = {}) { | ||
| 54 | + const p = _logPath(pid); | ||
| 55 | + if (!fs.existsSync(p)) return []; | ||
| 56 | + const limit = opts.limit || 100; | ||
| 57 | + const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean); | ||
| 58 | + | ||
| 59 | + const entries = []; | ||
| 60 | + for (let i = lines.length - 1; i >= 0; i--) { | ||
| 61 | + let e; | ||
| 62 | + try { e = JSON.parse(lines[i]); } catch { continue; } | ||
| 63 | + if (opts.kind && e.kind !== opts.kind) continue; | ||
| 64 | + if (opts.since && new Date(e.ts).getTime() < new Date(opts.since).getTime()) break; | ||
| 65 | + entries.push(e); | ||
| 66 | + if (entries.length >= limit) break; | ||
| 67 | + } | ||
| 68 | + return entries.reverse(); | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +/** 取某 kind 最近一条 */ | ||
| 72 | +function latest(pid, kind) { | ||
| 73 | + const recent = read(pid, { kind, limit: 1 }); | ||
| 74 | + return recent[0] || null; | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +module.exports = { append, read, latest, VALID_KINDS }; |
server/services/devloop/doc-store.js
0 → 100644
| 1 | +/** | ||
| 2 | + * DevLoop · docStore | ||
| 3 | + * | ||
| 4 | + * PRD / SDD / units.json / integration/spec.json 的读写、归档、校验。 | ||
| 5 | + * 所有 Agent 不直接 fs.writeFile 这些文件 — 必须走 docStore,确保: | ||
| 6 | + * 1. cheerio HTML 校验在写盘前跑(不合规拒收 → ValidationError) | ||
| 7 | + * 2. 旧版自动归档到 history/{kind}-rev-{N}/(或单文件 .json) | ||
| 8 | + * 3. 写完后自动调 changelogWriter.append(如 changelogWriter 已就位) | ||
| 9 | + * | ||
| 10 | + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-9 | ||
| 11 | + * docs/prd/newfunction/_spec/sdd-model.md · M-3 / M-4 / M-2 / M-6 | ||
| 12 | + * | ||
| 13 | + * 注意:prd / sdd 现在是<b>多页 HTML 站点目录</b>而非单文件。 | ||
| 14 | + * - writePRD(pid, files) 接受 [{relPath, content}],逐个校验逐个落盘 | ||
| 15 | + * - readPRD(pid, relPath) 单文件读 | ||
| 16 | + * - listPrdFiles(pid) 列文件 | ||
| 17 | + * 单文件写法(writePRDChapter)也保留为常用 helper。 | ||
| 18 | + */ | ||
| 19 | + | ||
| 20 | +const fs = require('fs'); | ||
| 21 | +const path = require('path'); | ||
| 22 | +const crypto = require('crypto'); | ||
| 23 | +const cheerio = require('cheerio'); | ||
| 24 | + | ||
| 25 | +const pathResolver = require('./path-resolver'); | ||
| 26 | + | ||
| 27 | +// ──────────────────────────────────────────────────────────────── | ||
| 28 | +// Validation | ||
| 29 | +// ──────────────────────────────────────────────────────────────── | ||
| 30 | + | ||
| 31 | +class ValidationError extends Error { | ||
| 32 | + constructor(message, details = []) { | ||
| 33 | + super(message); | ||
| 34 | + this.name = 'ValidationError'; | ||
| 35 | + this.details = details; | ||
| 36 | + } | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +/** | ||
| 40 | + * 校验单页 PRD/SDD HTML 是否符合规范。 | ||
| 41 | + * - 必含顶级 <article data-doc="prd"|"sdd"> 且 data-rev 数字 | ||
| 42 | + * - 每个 section 必含 <h3> + <p> | ||
| 43 | + * - PRD 的 data-rev 单调递增(仅检查升序,跳号警告但不报错) | ||
| 44 | + * | ||
| 45 | + * 不强求 8 类信息覆盖(那是 PM/Architect 自检层的事,不在 DocStore 阻塞) | ||
| 46 | + * | ||
| 47 | + * @param {string} html | ||
| 48 | + * @param {'prd'|'sdd'} kind | ||
| 49 | + * @returns {{ ok: boolean, errors: string[] }} | ||
| 50 | + */ | ||
| 51 | +function validateHTML(html, kind) { | ||
| 52 | + const errors = []; | ||
| 53 | + if (typeof html !== 'string' || html.length === 0) { | ||
| 54 | + return { ok: false, errors: ['empty html'] }; | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + let $; | ||
| 58 | + try { | ||
| 59 | + $ = cheerio.load(html, { decodeEntities: false }); | ||
| 60 | + } catch (e) { | ||
| 61 | + return { ok: false, errors: [`cheerio parse failed: ${e.message}`] }; | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + const article = $(`article[data-doc="${kind}"]`); | ||
| 65 | + if (article.length === 0) { | ||
| 66 | + errors.push(`missing <article data-doc="${kind}">`); | ||
| 67 | + return { ok: false, errors }; | ||
| 68 | + } | ||
| 69 | + if (article.length > 1) { | ||
| 70 | + errors.push(`multiple <article data-doc="${kind}"> elements`); | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + const articleRev = article.first().attr('data-rev'); | ||
| 74 | + if (!articleRev || !/^\d+$/.test(articleRev)) { | ||
| 75 | + errors.push(`article missing or invalid data-rev: "${articleRev}"`); | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + const sections = article.first().find('section'); | ||
| 79 | + if (sections.length === 0) { | ||
| 80 | + errors.push('article has no <section>'); | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + const revs = []; | ||
| 84 | + sections.each((i, el) => { | ||
| 85 | + const $sec = $(el); | ||
| 86 | + if ($sec.find('h3').length === 0) { | ||
| 87 | + errors.push(`section[${i}] missing <h3>`); | ||
| 88 | + } | ||
| 89 | + if ($sec.find('p').length === 0) { | ||
| 90 | + errors.push(`section[${i}] missing <p>`); | ||
| 91 | + } | ||
| 92 | + const rev = $sec.attr('data-rev'); | ||
| 93 | + if (rev && /^\d+$/.test(rev)) revs.push(parseInt(rev, 10)); | ||
| 94 | + }); | ||
| 95 | + | ||
| 96 | + // section data-rev 单调递增检查(允许相等,因为可能多 section 同 rev) | ||
| 97 | + for (let i = 1; i < revs.length; i++) { | ||
| 98 | + if (revs[i] < revs[i - 1]) { | ||
| 99 | + errors.push(`data-rev not monotonic at section[${i}]: ${revs[i - 1]} → ${revs[i]}`); | ||
| 100 | + } | ||
| 101 | + } | ||
| 102 | + | ||
| 103 | + return { ok: errors.length === 0, errors }; | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +/** | ||
| 107 | + * 校验 units.json schema(M-2)+ DAG 无环 | ||
| 108 | + * @returns {{ ok, errors }} | ||
| 109 | + */ | ||
| 110 | +function validateUnitsSchema(json) { | ||
| 111 | + const errors = []; | ||
| 112 | + if (!json || typeof json !== 'object') { | ||
| 113 | + return { ok: false, errors: ['units json must be object'] }; | ||
| 114 | + } | ||
| 115 | + for (const k of ['project_id', 'units']) { | ||
| 116 | + if (!json[k]) errors.push(`missing field: ${k}`); | ||
| 117 | + } | ||
| 118 | + if (!Array.isArray(json.units)) { | ||
| 119 | + errors.push('units must be array'); | ||
| 120 | + return { ok: false, errors }; | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + const idPattern = /^[a-z]+(-[a-z]+)*-(be|fe|mw|db)$/; | ||
| 124 | + const validStatuses = ['pending', 'coding', 'testing', 'failed', 'passed', 'blocked']; | ||
| 125 | + const validTypes = ['be', 'fe', 'mw', 'db']; | ||
| 126 | + const seen = new Set(); | ||
| 127 | + | ||
| 128 | + for (const u of json.units) { | ||
| 129 | + if (!u.id) { | ||
| 130 | + errors.push('unit missing id'); | ||
| 131 | + continue; | ||
| 132 | + } | ||
| 133 | + if (!idPattern.test(u.id)) { | ||
| 134 | + errors.push(`unit id "${u.id}" violates naming pattern`); | ||
| 135 | + } | ||
| 136 | + if (seen.has(u.id)) { | ||
| 137 | + errors.push(`duplicate unit id: ${u.id}`); | ||
| 138 | + } | ||
| 139 | + seen.add(u.id); | ||
| 140 | + if (!validTypes.includes(u.type)) { | ||
| 141 | + errors.push(`unit ${u.id} invalid type: ${u.type}`); | ||
| 142 | + } | ||
| 143 | + if (!validStatuses.includes(u.status)) { | ||
| 144 | + errors.push(`unit ${u.id} invalid status: ${u.status}`); | ||
| 145 | + } | ||
| 146 | + if (!Array.isArray(u.depends_on)) { | ||
| 147 | + errors.push(`unit ${u.id} depends_on must be array`); | ||
| 148 | + } | ||
| 149 | + } | ||
| 150 | + | ||
| 151 | + // 引用完整性 + DAG 无环 | ||
| 152 | + const ids = new Set(json.units.map(u => u.id)); | ||
| 153 | + for (const u of json.units) { | ||
| 154 | + for (const dep of u.depends_on || []) { | ||
| 155 | + if (!ids.has(dep)) { | ||
| 156 | + errors.push(`unit ${u.id} depends_on unknown unit: ${dep}`); | ||
| 157 | + } | ||
| 158 | + } | ||
| 159 | + } | ||
| 160 | + if (errors.length === 0 && hasCycle(json.units)) { | ||
| 161 | + errors.push('units DAG has cycle'); | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + return { ok: errors.length === 0, errors }; | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +function hasCycle(units) { | ||
| 168 | + const idx = new Map(units.map((u, i) => [u.id, i])); | ||
| 169 | + const color = new Array(units.length).fill(0); // 0=white, 1=gray, 2=black | ||
| 170 | + let cycle = false; | ||
| 171 | + | ||
| 172 | + function visit(i) { | ||
| 173 | + if (cycle) return; | ||
| 174 | + if (color[i] === 2) return; | ||
| 175 | + if (color[i] === 1) { | ||
| 176 | + cycle = true; | ||
| 177 | + return; | ||
| 178 | + } | ||
| 179 | + color[i] = 1; | ||
| 180 | + for (const dep of units[i].depends_on || []) { | ||
| 181 | + if (idx.has(dep)) visit(idx.get(dep)); | ||
| 182 | + } | ||
| 183 | + color[i] = 2; | ||
| 184 | + } | ||
| 185 | + for (let i = 0; i < units.length && !cycle; i++) visit(i); | ||
| 186 | + return cycle; | ||
| 187 | +} | ||
| 188 | + | ||
| 189 | +// ──────────────────────────────────────────────────────────────── | ||
| 190 | +// PRD / SDD multi-file site | ||
| 191 | +// ──────────────────────────────────────────────────────────────── | ||
| 192 | + | ||
| 193 | +/** 列项目下 prd/ 站点内所有 html 章节相对路径 */ | ||
| 194 | +function listPrdFiles(pid) { | ||
| 195 | + return _listSiteHtmls(pathResolver.resolvePrdSite(pid)); | ||
| 196 | +} | ||
| 197 | + | ||
| 198 | +function listSddFiles(pid) { | ||
| 199 | + return _listSiteHtmls(pathResolver.resolveSddSite(pid)); | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +function _listSiteHtmls(siteRoot) { | ||
| 203 | + const out = []; | ||
| 204 | + if (!fs.existsSync(siteRoot)) return out; | ||
| 205 | + function walk(dir, prefix) { | ||
| 206 | + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { | ||
| 207 | + const rel = prefix ? `${prefix}/${e.name}` : e.name; | ||
| 208 | + const full = path.join(dir, e.name); | ||
| 209 | + if (e.isDirectory()) { | ||
| 210 | + if (e.name === 'assets') continue; // assets 不计入 chapter 列表 | ||
| 211 | + walk(full, rel); | ||
| 212 | + } else if (e.name.endsWith('.html')) { | ||
| 213 | + out.push(rel); | ||
| 214 | + } | ||
| 215 | + } | ||
| 216 | + } | ||
| 217 | + walk(siteRoot, ''); | ||
| 218 | + return out.sort(); | ||
| 219 | +} | ||
| 220 | + | ||
| 221 | +/** 读单个 PRD/SDD 章节 */ | ||
| 222 | +function readPrdChapter(pid, relPath) { | ||
| 223 | + const file = path.join(pathResolver.resolvePrdSite(pid), relPath); | ||
| 224 | + if (!fs.existsSync(file)) throw new Error(`PRD chapter not found: ${relPath}`); | ||
| 225 | + return fs.readFileSync(file, 'utf8'); | ||
| 226 | +} | ||
| 227 | + | ||
| 228 | +function readSddChapter(pid, relPath) { | ||
| 229 | + const file = path.join(pathResolver.resolveSddSite(pid), relPath); | ||
| 230 | + if (!fs.existsSync(file)) throw new Error(`SDD chapter not found: ${relPath}`); | ||
| 231 | + return fs.readFileSync(file, 'utf8'); | ||
| 232 | +} | ||
| 233 | + | ||
| 234 | +/** | ||
| 235 | + * 写一个 PRD chapter(HTML)。校验通过 + 旧版(如有)归档 + 落盘。 | ||
| 236 | + * @returns { rev: number, path: string } | ||
| 237 | + */ | ||
| 238 | +function writePrdChapter(pid, relPath, html, opts = {}) { | ||
| 239 | + return _writeChapter(pid, 'prd', relPath, html, opts); | ||
| 240 | +} | ||
| 241 | + | ||
| 242 | +function writeSddChapter(pid, relPath, html, opts = {}) { | ||
| 243 | + return _writeChapter(pid, 'sdd', relPath, html, opts); | ||
| 244 | +} | ||
| 245 | + | ||
| 246 | +function _writeChapter(pid, kind, relPath, html, opts) { | ||
| 247 | + const validation = validateHTML(html, kind); | ||
| 248 | + if (!validation.ok && !opts.skipValidation) { | ||
| 249 | + throw new ValidationError( | ||
| 250 | + `${kind}/${relPath} validation failed`, validation.errors); | ||
| 251 | + } | ||
| 252 | + const siteRoot = kind === 'prd' | ||
| 253 | + ? pathResolver.resolvePrdSite(pid) | ||
| 254 | + : pathResolver.resolveSddSite(pid); | ||
| 255 | + const target = path.join(siteRoot, relPath); | ||
| 256 | + fs.mkdirSync(path.dirname(target), { recursive: true }); | ||
| 257 | + | ||
| 258 | + // 归档旧版(如有) | ||
| 259 | + if (fs.existsSync(target)) { | ||
| 260 | + archiveFile(pid, kind, relPath, fs.readFileSync(target, 'utf8')); | ||
| 261 | + } | ||
| 262 | + | ||
| 263 | + fs.writeFileSync(target, html); | ||
| 264 | + | ||
| 265 | + // 解析 article data-rev 作为新 rev | ||
| 266 | + let rev = 1; | ||
| 267 | + try { | ||
| 268 | + const $ = cheerio.load(html); | ||
| 269 | + const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev'); | ||
| 270 | + if (r && /^\d+$/.test(r)) rev = parseInt(r, 10); | ||
| 271 | + } catch { /* ignore */ } | ||
| 272 | + | ||
| 273 | + return { rev, path: target }; | ||
| 274 | +} | ||
| 275 | + | ||
| 276 | +/** 把旧文件归档到 history/{kind}-rev-{N}/{relPath} */ | ||
| 277 | +function archiveFile(pid, kind, relPath, oldContent) { | ||
| 278 | + // 取旧版的 rev(从 oldContent 解析 article data-rev) | ||
| 279 | + let oldRev = 0; | ||
| 280 | + try { | ||
| 281 | + const $ = cheerio.load(oldContent); | ||
| 282 | + const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev'); | ||
| 283 | + if (r && /^\d+$/.test(r)) oldRev = parseInt(r, 10); | ||
| 284 | + } catch { /* ignore */ } | ||
| 285 | + | ||
| 286 | + const archiveDir = path.join( | ||
| 287 | + pathResolver.resolveHistoryDir(pid), | ||
| 288 | + `${kind}-rev-${oldRev || 'unknown'}` | ||
| 289 | + ); | ||
| 290 | + const archiveTarget = path.join(archiveDir, relPath); | ||
| 291 | + fs.mkdirSync(path.dirname(archiveTarget), { recursive: true }); | ||
| 292 | + fs.writeFileSync(archiveTarget, oldContent); | ||
| 293 | +} | ||
| 294 | + | ||
| 295 | +// ──────────────────────────────────────────────────────────────── | ||
| 296 | +// units.json / integration/spec.json | ||
| 297 | +// ──────────────────────────────────────────────────────────────── | ||
| 298 | + | ||
| 299 | +function readUnits(pid) { | ||
| 300 | + const p = pathResolver.resolveUnitsJson(pid); | ||
| 301 | + if (!fs.existsSync(p)) return null; | ||
| 302 | + return JSON.parse(fs.readFileSync(p, 'utf8')); | ||
| 303 | +} | ||
| 304 | + | ||
| 305 | +function writeUnits(pid, json, opts = {}) { | ||
| 306 | + const validation = validateUnitsSchema(json); | ||
| 307 | + if (!validation.ok && !opts.skipValidation) { | ||
| 308 | + throw new ValidationError('units.json validation failed', validation.errors); | ||
| 309 | + } | ||
| 310 | + const p = pathResolver.resolveUnitsJson(pid); | ||
| 311 | + | ||
| 312 | + // 归档旧版 | ||
| 313 | + if (fs.existsSync(p)) { | ||
| 314 | + const old = JSON.parse(fs.readFileSync(p, 'utf8')); | ||
| 315 | + const oldRev = old.sdd_rev || 'unknown'; | ||
| 316 | + const archivePath = path.join( | ||
| 317 | + pathResolver.resolveHistoryDir(pid), | ||
| 318 | + `units-rev-${oldRev}.json` | ||
| 319 | + ); | ||
| 320 | + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); | ||
| 321 | + fs.writeFileSync(archivePath, JSON.stringify(old, null, 2)); | ||
| 322 | + } | ||
| 323 | + | ||
| 324 | + fs.mkdirSync(path.dirname(p), { recursive: true }); | ||
| 325 | + fs.writeFileSync(p, JSON.stringify(json, null, 2)); | ||
| 326 | + return { rev: json.sdd_rev || 1, path: p }; | ||
| 327 | +} | ||
| 328 | + | ||
| 329 | +function readIntegrationSpec(pid) { | ||
| 330 | + const p = path.join(pathResolver.resolveIntegrationDir(pid), 'spec.json'); | ||
| 331 | + if (!fs.existsSync(p)) return null; | ||
| 332 | + return JSON.parse(fs.readFileSync(p, 'utf8')); | ||
| 333 | +} | ||
| 334 | + | ||
| 335 | +function writeIntegrationSpec(pid, json) { | ||
| 336 | + const p = path.join(pathResolver.resolveIntegrationDir(pid), 'spec.json'); | ||
| 337 | + fs.mkdirSync(path.dirname(p), { recursive: true }); | ||
| 338 | + | ||
| 339 | + // 校验 involves ⊆ units.json | ||
| 340 | + const units = readUnits(pid); | ||
| 341 | + if (units && Array.isArray(json.cases)) { | ||
| 342 | + const unitIds = new Set(units.units.map(u => u.id)); | ||
| 343 | + const errors = []; | ||
| 344 | + for (const c of json.cases) { | ||
| 345 | + for (const u of c.involves || []) { | ||
| 346 | + if (!unitIds.has(u)) { | ||
| 347 | + errors.push(`case ${c.id} involves unknown unit: ${u}`); | ||
| 348 | + } | ||
| 349 | + } | ||
| 350 | + } | ||
| 351 | + if (errors.length > 0) { | ||
| 352 | + throw new ValidationError('integration spec references unknown units', errors); | ||
| 353 | + } | ||
| 354 | + } | ||
| 355 | + | ||
| 356 | + fs.writeFileSync(p, JSON.stringify(json, null, 2)); | ||
| 357 | + return { path: p }; | ||
| 358 | +} | ||
| 359 | + | ||
| 360 | +// ──────────────────────────────────────────────────────────────── | ||
| 361 | +// Project meta + utilities | ||
| 362 | +// ──────────────────────────────────────────────────────────────── | ||
| 363 | + | ||
| 364 | +function readProjectMeta(pid) { | ||
| 365 | + const p = pathResolver.resolveProjectJson(pid); | ||
| 366 | + if (!fs.existsSync(p)) return null; | ||
| 367 | + return JSON.parse(fs.readFileSync(p, 'utf8')); | ||
| 368 | +} | ||
| 369 | + | ||
| 370 | +function writeProjectMeta(pid, meta) { | ||
| 371 | + const p = pathResolver.resolveProjectJson(pid); | ||
| 372 | + fs.mkdirSync(path.dirname(p), { recursive: true }); | ||
| 373 | + fs.writeFileSync(p, JSON.stringify(meta, null, 2)); | ||
| 374 | + return { path: p }; | ||
| 375 | +} | ||
| 376 | + | ||
| 377 | +/** | ||
| 378 | + * canonical JSON + sha256 截 16 hex 字符 | ||
| 379 | + * @param {object} contract | ||
| 380 | + * @returns {string} "sha256:..." | ||
| 381 | + */ | ||
| 382 | +function hashContract(contract) { | ||
| 383 | + if (!contract || typeof contract !== 'object') { | ||
| 384 | + throw new Error('hashContract: contract must be object'); | ||
| 385 | + } | ||
| 386 | + const body = contract.exports || contract; | ||
| 387 | + const normalized = canonical(body); | ||
| 388 | + const full = crypto.createHash('sha256').update(normalized).digest('hex'); | ||
| 389 | + return 'sha256:' + full.slice(0, 16); | ||
| 390 | +} | ||
| 391 | + | ||
| 392 | +function canonical(v) { | ||
| 393 | + if (v === null || typeof v !== 'object') return JSON.stringify(v); | ||
| 394 | + if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']'; | ||
| 395 | + const keys = Object.keys(v).sort(); | ||
| 396 | + return '{' + keys.map(k => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}'; | ||
| 397 | +} | ||
| 398 | + | ||
| 399 | +/** 验证某 Unit 的 contract_hash 与重算一致 */ | ||
| 400 | +function verifyContractHash(unit, contract) { | ||
| 401 | + const recomputed = hashContract(contract); | ||
| 402 | + return { | ||
| 403 | + ok: recomputed === unit.contract_hash, | ||
| 404 | + expected: unit.contract_hash, | ||
| 405 | + actual: recomputed, | ||
| 406 | + }; | ||
| 407 | +} | ||
| 408 | + | ||
| 409 | +module.exports = { | ||
| 410 | + // 校验 | ||
| 411 | + validateHTML, | ||
| 412 | + validateUnitsSchema, | ||
| 413 | + ValidationError, | ||
| 414 | + | ||
| 415 | + // PRD/SDD 多页站点 | ||
| 416 | + listPrdFiles, | ||
| 417 | + listSddFiles, | ||
| 418 | + readPrdChapter, | ||
| 419 | + readSddChapter, | ||
| 420 | + writePrdChapter, | ||
| 421 | + writeSddChapter, | ||
| 422 | + | ||
| 423 | + // units / integration | ||
| 424 | + readUnits, | ||
| 425 | + writeUnits, | ||
| 426 | + readIntegrationSpec, | ||
| 427 | + writeIntegrationSpec, | ||
| 428 | + | ||
| 429 | + // project meta | ||
| 430 | + readProjectMeta, | ||
| 431 | + writeProjectMeta, | ||
| 432 | + | ||
| 433 | + // utils | ||
| 434 | + hashContract, | ||
| 435 | + verifyContractHash, | ||
| 436 | + canonical, | ||
| 437 | + hasCycle, | ||
| 438 | +}; |
server/services/devloop/git-vault.js
0 → 100644
| 1 | +/** | ||
| 2 | + * DevLoop · gitVault | ||
| 3 | + * | ||
| 4 | + * 每项目一个独立 git repo,LoopController 在每次 Agent 成功完成时调 | ||
| 5 | + * gitVault.commit(...) 落盘一个版本。Agent 自身不动 git。 | ||
| 6 | + * | ||
| 7 | + * - 实现用 child_process.spawn('git', ...);不依赖第三方 npm 包 | ||
| 8 | + * - 所有方法按 pid 串行化(per-pid mutex),避免同项目并发 git 操作打架 | ||
| 9 | + * - commit author 标 Agent 名("PM Agent <pm@devloop.local>") | ||
| 10 | + * - nothing-to-commit 不抛错,返 { ok: true, skipped: true } | ||
| 11 | + * | ||
| 12 | + * 规格:docs/prd/newfunction/_spec/arch-git-versioning.md | ||
| 13 | + * docs/prd/newfunction/_spec/sdd-control.md · C-18 | ||
| 14 | + * docs/prd/newfunction/_spec/sdd-process.md · P-10 | ||
| 15 | + */ | ||
| 16 | + | ||
| 17 | +const { spawn } = require('child_process'); | ||
| 18 | +const fs = require('fs'); | ||
| 19 | +const path = require('path'); | ||
| 20 | + | ||
| 21 | +const pathResolver = require('./path-resolver'); | ||
| 22 | + | ||
| 23 | +const DEFAULT_GITIGNORE = `# DevLoop project .gitignore | ||
| 24 | + | ||
| 25 | +# 高频写的运行态,不需要版本 | ||
| 26 | +agent-state/ | ||
| 27 | + | ||
| 28 | +# git 本身就是 history | ||
| 29 | +history/ | ||
| 30 | + | ||
| 31 | +# 大日志 | ||
| 32 | +stream.log | ||
| 33 | +units/*/runs/*/stdout.txt | ||
| 34 | +units/*/runs/*/stderr.txt | ||
| 35 | +integration/runs/*/process.log | ||
| 36 | +integration/runs/*/http-trace.ndjson.gz | ||
| 37 | + | ||
| 38 | +# Node / 依赖 | ||
| 39 | +node_modules/ | ||
| 40 | +*.log | ||
| 41 | +.DS_Store | ||
| 42 | +.cache/ | ||
| 43 | +.tmp/ | ||
| 44 | +.coverage/ | ||
| 45 | + | ||
| 46 | +# 测试中间文件 | ||
| 47 | +units/*/.vitest-cache/ | ||
| 48 | +`; | ||
| 49 | + | ||
| 50 | +// per-pid mutex(简单 Promise chain) | ||
| 51 | +const mutexes = new Map(); | ||
| 52 | +function _withLock(pid, fn) { | ||
| 53 | + const prev = mutexes.get(pid) || Promise.resolve(); | ||
| 54 | + const next = prev.then(fn, fn); // 不管前一个成功失败,继续跑下一个 | ||
| 55 | + mutexes.set(pid, next.catch(() => {})); // 防止 unhandled rejection | ||
| 56 | + return next; | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +// 运行单条 git 命令;返回 { ok, code, stdout, stderr } | ||
| 60 | +function _run(cwd, args, opts = {}) { | ||
| 61 | + return new Promise((resolve) => { | ||
| 62 | + const child = spawn('git', args, { | ||
| 63 | + cwd, | ||
| 64 | + env: { ...process.env, ...(opts.env || {}) }, | ||
| 65 | + stdio: ['ignore', 'pipe', 'pipe'], | ||
| 66 | + }); | ||
| 67 | + let stdout = '', stderr = ''; | ||
| 68 | + child.stdout.on('data', c => { stdout += c.toString(); }); | ||
| 69 | + child.stderr.on('data', c => { stderr += c.toString(); }); | ||
| 70 | + child.on('close', code => { | ||
| 71 | + resolve({ ok: code === 0, code, stdout, stderr }); | ||
| 72 | + }); | ||
| 73 | + child.on('error', err => { | ||
| 74 | + resolve({ ok: false, code: -1, stdout, stderr: err.message }); | ||
| 75 | + }); | ||
| 76 | + }); | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +// ──────────────────────────────────────────────────────────────── | ||
| 80 | +// API | ||
| 81 | +// ──────────────────────────────────────────────────────────────── | ||
| 82 | + | ||
| 83 | +/** 在项目根 git init + 写 .gitignore + 首个 commit */ | ||
| 84 | +async function init(pid, opts = {}) { | ||
| 85 | + return _withLock(pid, async () => { | ||
| 86 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 87 | + | ||
| 88 | + // 已是 repo 则跳 | ||
| 89 | + if (fs.existsSync(path.join(root, '.git'))) { | ||
| 90 | + return { ok: true, head: await _head(root), already_exists: true }; | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + // 1. git init | ||
| 94 | + const r1 = await _run(root, ['init', '-q', '-b', opts.initialBranch || 'main']); | ||
| 95 | + if (!r1.ok) throw new Error(`git init failed: ${r1.stderr}`); | ||
| 96 | + | ||
| 97 | + // 2. 配 author identity(commit 需要) | ||
| 98 | + const userName = opts.name || 'DevLoop'; | ||
| 99 | + const userEmail = opts.email || 'devloop@lemonboss.local'; | ||
| 100 | + await _run(root, ['config', 'user.name', userName]); | ||
| 101 | + await _run(root, ['config', 'user.email', userEmail]); | ||
| 102 | + // 不签名(v1 不强求 GPG) | ||
| 103 | + await _run(root, ['config', 'commit.gpgsign', 'false']); | ||
| 104 | + | ||
| 105 | + // 3. 写 .gitignore | ||
| 106 | + const gitignorePath = path.join(root, '.gitignore'); | ||
| 107 | + if (!fs.existsSync(gitignorePath)) { | ||
| 108 | + fs.writeFileSync(gitignorePath, DEFAULT_GITIGNORE); | ||
| 109 | + } | ||
| 110 | + | ||
| 111 | + // 4. 首个 commit | ||
| 112 | + const r2 = await _run(root, ['add', '.']); | ||
| 113 | + if (!r2.ok) throw new Error(`git add failed: ${r2.stderr}`); | ||
| 114 | + | ||
| 115 | + const initialMsg = opts.initialMessage || | ||
| 116 | + `init: project ${pid}` + (opts.summary ? ` · ${opts.summary}` : ''); | ||
| 117 | + const r3 = await _run(root, ['commit', '-q', '-m', initialMsg, '--allow-empty']); | ||
| 118 | + if (!r3.ok) throw new Error(`initial commit failed: ${r3.stderr}`); | ||
| 119 | + | ||
| 120 | + return { ok: true, head: await _head(root) }; | ||
| 121 | + }); | ||
| 122 | +} | ||
| 123 | + | ||
| 124 | +/** | ||
| 125 | + * Commit current changes. | ||
| 126 | + * @param {string} pid | ||
| 127 | + * @param {object} spec | ||
| 128 | + * - kind: 'init'|'prd'|'sdd'|'code'|'triage'|'integ'|'ship'|'chore' | ||
| 129 | + * - unit_id?: string (code 时填,作为 message 一部分) | ||
| 130 | + * - run_id?: string (integ/triage 时填) | ||
| 131 | + * - summary: string (必填) | ||
| 132 | + * - files?: string[] (限定 add 的路径,默认 git add -A) | ||
| 133 | + * - author?: { name, email } (commit author) | ||
| 134 | + * - skipEmpty?: boolean (默认 true,nothing-to-commit 返 skipped) | ||
| 135 | + * @returns { ok, sha?, skipped? } | ||
| 136 | + */ | ||
| 137 | +async function commit(pid, spec) { | ||
| 138 | + return _withLock(pid, async () => { | ||
| 139 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 140 | + if (!fs.existsSync(path.join(root, '.git'))) { | ||
| 141 | + throw new Error(`not a git repo: ${root} (call init first)`); | ||
| 142 | + } | ||
| 143 | + if (!spec.summary) throw new Error('commit: summary required'); | ||
| 144 | + | ||
| 145 | + // 1. 加 files(默认 -A,但受 .gitignore 控制) | ||
| 146 | + const addArgs = ['add', '--']; | ||
| 147 | + if (Array.isArray(spec.files) && spec.files.length) { | ||
| 148 | + addArgs.push(...spec.files); | ||
| 149 | + } else { | ||
| 150 | + // -A:把所有变更(包括删除)加进 index | ||
| 151 | + const rA = await _run(root, ['add', '-A']); | ||
| 152 | + if (!rA.ok) throw new Error(`git add -A failed: ${rA.stderr}`); | ||
| 153 | + } | ||
| 154 | + | ||
| 155 | + // 2. 检查是否有可 commit 变化 | ||
| 156 | + const status = await _run(root, ['status', '--porcelain']); | ||
| 157 | + const dirty = status.stdout.trim().length > 0; | ||
| 158 | + if (!dirty) { | ||
| 159 | + if (spec.skipEmpty === false) { | ||
| 160 | + throw new Error('nothing to commit and skipEmpty=false'); | ||
| 161 | + } | ||
| 162 | + return { ok: true, skipped: true }; | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + // 3. commit | ||
| 166 | + const env = {}; | ||
| 167 | + if (spec.author?.name) env.GIT_AUTHOR_NAME = spec.author.name; | ||
| 168 | + if (spec.author?.email) env.GIT_AUTHOR_EMAIL = spec.author.email; | ||
| 169 | + if (spec.author?.name) env.GIT_COMMITTER_NAME = spec.author.name; | ||
| 170 | + if (spec.author?.email) env.GIT_COMMITTER_EMAIL = spec.author.email; | ||
| 171 | + | ||
| 172 | + const r = await _run(root, ['commit', '-q', '-m', spec.summary], { env }); | ||
| 173 | + if (!r.ok) { | ||
| 174 | + // 兜底:检测 "nothing to commit" 文案 | ||
| 175 | + if (/nothing to commit/i.test(r.stdout + r.stderr)) { | ||
| 176 | + return { ok: true, skipped: true }; | ||
| 177 | + } | ||
| 178 | + throw new Error(`git commit failed: ${r.stderr || r.stdout}`); | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + const sha = await _head(root); | ||
| 182 | + return { ok: true, sha, kind: spec.kind, summary: spec.summary }; | ||
| 183 | + }); | ||
| 184 | +} | ||
| 185 | + | ||
| 186 | +async function tag(pid, tagName, opts = {}) { | ||
| 187 | + return _withLock(pid, async () => { | ||
| 188 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 189 | + const args = ['tag']; | ||
| 190 | + if (opts.message) { | ||
| 191 | + args.push('-a', tagName, '-m', opts.message); | ||
| 192 | + } else { | ||
| 193 | + args.push(tagName); | ||
| 194 | + } | ||
| 195 | + if (opts.sha) args.push(opts.sha); | ||
| 196 | + const r = await _run(root, args); | ||
| 197 | + if (!r.ok) throw new Error(`git tag failed: ${r.stderr}`); | ||
| 198 | + return { ok: true, tag: tagName }; | ||
| 199 | + }); | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +async function log(pid, opts = {}) { | ||
| 203 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 204 | + const args = ['log', '--format=%H%x09%an%x09%ae%x09%at%x09%s']; | ||
| 205 | + if (opts.limit) args.push(`-n${opts.limit}`); | ||
| 206 | + if (opts.since) args.push(`--since=${opts.since}`); | ||
| 207 | + if (opts.grep) args.push(`--grep=${opts.grep}`); | ||
| 208 | + const r = await _run(root, args); | ||
| 209 | + if (!r.ok) { | ||
| 210 | + // 空仓库 (no commits) → 返空 | ||
| 211 | + if (/does not have any commits/i.test(r.stderr)) return []; | ||
| 212 | + throw new Error(`git log failed: ${r.stderr}`); | ||
| 213 | + } | ||
| 214 | + return r.stdout.trim().split('\n').filter(Boolean).map(line => { | ||
| 215 | + const [sha, author_name, author_email, ts, ...rest] = line.split('\t'); | ||
| 216 | + return { | ||
| 217 | + sha, author_name, author_email, | ||
| 218 | + ts: parseInt(ts, 10), | ||
| 219 | + summary: rest.join('\t'), | ||
| 220 | + }; | ||
| 221 | + }); | ||
| 222 | +} | ||
| 223 | + | ||
| 224 | +async function diff(pid, refA, refB, opts = {}) { | ||
| 225 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 226 | + const args = ['diff', `${refA}..${refB}`]; | ||
| 227 | + if (opts.path) args.push('--', opts.path); | ||
| 228 | + const r = await _run(root, args); | ||
| 229 | + if (!r.ok) throw new Error(`git diff failed: ${r.stderr}`); | ||
| 230 | + return r.stdout; | ||
| 231 | +} | ||
| 232 | + | ||
| 233 | +async function show(pid, sha) { | ||
| 234 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 235 | + const r = await _run(root, ['show', '--format=%H%x09%an%x09%ae%x09%at%x09%s%n', sha]); | ||
| 236 | + if (!r.ok) throw new Error(`git show failed: ${r.stderr}`); | ||
| 237 | + return { sha, output: r.stdout }; | ||
| 238 | +} | ||
| 239 | + | ||
| 240 | +async function listTags(pid) { | ||
| 241 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 242 | + const r = await _run(root, ['tag', '--list', '--sort=-creatordate']); | ||
| 243 | + if (!r.ok) throw new Error(`git tag --list failed: ${r.stderr}`); | ||
| 244 | + return r.stdout.trim().split('\n').filter(Boolean); | ||
| 245 | +} | ||
| 246 | + | ||
| 247 | +async function status(pid) { | ||
| 248 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 249 | + if (!fs.existsSync(path.join(root, '.git'))) { | ||
| 250 | + return { dirty: false, files: [], no_repo: true }; | ||
| 251 | + } | ||
| 252 | + const r = await _run(root, ['status', '--porcelain']); | ||
| 253 | + if (!r.ok) throw new Error(`git status failed: ${r.stderr}`); | ||
| 254 | + const files = r.stdout.trim().split('\n').filter(Boolean).map(l => l.trim().split(/\s+/, 2)); | ||
| 255 | + return { dirty: files.length > 0, files }; | ||
| 256 | +} | ||
| 257 | + | ||
| 258 | +async function resetWorkingTree(pid, ref = 'HEAD') { | ||
| 259 | + return _withLock(pid, async () => { | ||
| 260 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 261 | + const r1 = await _run(root, ['reset', '--hard', ref]); | ||
| 262 | + if (!r1.ok) throw new Error(`git reset failed: ${r1.stderr}`); | ||
| 263 | + const r2 = await _run(root, ['clean', '-fd']); | ||
| 264 | + if (!r2.ok) throw new Error(`git clean failed: ${r2.stderr}`); | ||
| 265 | + return { ok: true }; | ||
| 266 | + }); | ||
| 267 | +} | ||
| 268 | + | ||
| 269 | +async function revert(pid, sha) { | ||
| 270 | + return _withLock(pid, async () => { | ||
| 271 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 272 | + const r = await _run(root, ['revert', '--no-edit', sha]); | ||
| 273 | + if (!r.ok) throw new Error(`git revert failed: ${r.stderr}`); | ||
| 274 | + return { ok: true, newSha: await _head(root) }; | ||
| 275 | + }); | ||
| 276 | +} | ||
| 277 | + | ||
| 278 | +async function archive(pid, ref = 'HEAD', format = 'zip') { | ||
| 279 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 280 | + const outPath = path.join('/tmp', `${pid}-${ref}.${format}`); | ||
| 281 | + // git archive --format=zip -o /tmp/x.zip HEAD | ||
| 282 | + const r = await _run(root, ['archive', `--format=${format}`, `-o`, outPath, ref]); | ||
| 283 | + if (!r.ok) throw new Error(`git archive failed: ${r.stderr}`); | ||
| 284 | + return { path: outPath }; | ||
| 285 | +} | ||
| 286 | + | ||
| 287 | +// 工具 | ||
| 288 | +async function _head(root) { | ||
| 289 | + const r = await _run(root, ['rev-parse', 'HEAD']); | ||
| 290 | + if (!r.ok) return null; | ||
| 291 | + return r.stdout.trim(); | ||
| 292 | +} | ||
| 293 | + | ||
| 294 | +function _resetMutexesForTesting() { | ||
| 295 | + mutexes.clear(); | ||
| 296 | +} | ||
| 297 | + | ||
| 298 | +module.exports = { | ||
| 299 | + init, | ||
| 300 | + commit, | ||
| 301 | + tag, | ||
| 302 | + log, | ||
| 303 | + diff, | ||
| 304 | + show, | ||
| 305 | + listTags, | ||
| 306 | + status, | ||
| 307 | + resetWorkingTree, | ||
| 308 | + revert, | ||
| 309 | + archive, | ||
| 310 | + DEFAULT_GITIGNORE, | ||
| 311 | + _resetMutexesForTesting, | ||
| 312 | +}; |
server/services/devloop/path-resolver.js
0 → 100644
| 1 | +/** | ||
| 2 | + * DevLoop · pathResolver | ||
| 3 | + * | ||
| 4 | + * 所有 DevLoop 产物路径的唯一真源。其他服务(docStore / unitRegistry / | ||
| 5 | + * bugTracker / sseBroker / gitVault)都通过这里查路径,绝不硬编码 | ||
| 6 | + * `data/companies/...`。 | ||
| 7 | + * | ||
| 8 | + * 支持三种 mode(见 arch-project-root.html §②): | ||
| 9 | + * - sandbox : 自动生成 root_path = `{dataDir}/companies/{cid}/projects/{pid}/` | ||
| 10 | + * - linked-new : 用户指定 root_path(绝对路径),注册到 ~/.devloop/linked-projects.json | ||
| 11 | + * - linked-existing : v2,本文件预留接口但 register 阶段拒收 | ||
| 12 | + * | ||
| 13 | + * 详尽规格:docs/prd/newfunction/_spec/arch-project-root.md | ||
| 14 | + */ | ||
| 15 | + | ||
| 16 | +const fs = require('fs'); | ||
| 17 | +const path = require('path'); | ||
| 18 | +const os = require('os'); | ||
| 19 | + | ||
| 20 | +const DATA_DIR = path.resolve(__dirname, '../../../data'); | ||
| 21 | +const LINKED_REGISTRY = path.join(os.homedir(), '.devloop', 'linked-projects.json'); | ||
| 22 | + | ||
| 23 | +// 系统敏感路径黑名单(不允许作为 linked-new 的 target_path) | ||
| 24 | +// 注意:os.tmpdir() (e.g. /var/folders/... on macOS, /tmp on Linux) 是允许的, | ||
| 25 | +// 即使前缀匹配 /var;isSensitivePath 内部会先检查 tmpdir 例外。 | ||
| 26 | +const SENSITIVE_PREFIXES = [ | ||
| 27 | + '/etc', '/usr', '/bin', '/sbin', '/boot', | ||
| 28 | + '/var/log', '/var/lib', '/var/db', '/var/spool', '/var/cache', | ||
| 29 | + '/System', '/Library/System', '/private/etc', '/private/var/log', | ||
| 30 | + path.join(os.homedir(), '.ssh'), | ||
| 31 | + path.join(os.homedir(), '.aws'), | ||
| 32 | + path.join(os.homedir(), '.gnupg'), | ||
| 33 | + path.join(os.homedir(), '.config'), | ||
| 34 | + path.join(os.homedir(), 'Downloads'), | ||
| 35 | +]; | ||
| 36 | + | ||
| 37 | +const ALLOWED_EXEMPTIONS = [ | ||
| 38 | + os.tmpdir(), // /var/folders/... (mac) 或 /tmp (linux) | ||
| 39 | + '/tmp', | ||
| 40 | +]; | ||
| 41 | + | ||
| 42 | +// 内存缓存:pid → { root_path, mode, company_id } | ||
| 43 | +let cache = null; | ||
| 44 | + | ||
| 45 | +function loadLinkedRegistry() { | ||
| 46 | + if (!fs.existsSync(LINKED_REGISTRY)) return {}; | ||
| 47 | + try { | ||
| 48 | + return JSON.parse(fs.readFileSync(LINKED_REGISTRY, 'utf8')); | ||
| 49 | + } catch { | ||
| 50 | + return {}; | ||
| 51 | + } | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +function saveLinkedRegistry(reg) { | ||
| 55 | + fs.mkdirSync(path.dirname(LINKED_REGISTRY), { recursive: true }); | ||
| 56 | + fs.writeFileSync(LINKED_REGISTRY, JSON.stringify(reg, null, 2)); | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +/** 全量扫描 + linked 注册表,构造内存索引。LoopController 启动时调一次。 */ | ||
| 60 | +function init() { | ||
| 61 | + cache = new Map(); | ||
| 62 | + | ||
| 63 | + // 1. 扫 sandbox 项目(data/companies/*/projects/*/project.json) | ||
| 64 | + if (fs.existsSync(DATA_DIR)) { | ||
| 65 | + const companiesDir = path.join(DATA_DIR, 'companies'); | ||
| 66 | + if (fs.existsSync(companiesDir)) { | ||
| 67 | + for (const cid of fs.readdirSync(companiesDir)) { | ||
| 68 | + const projectsDir = path.join(companiesDir, cid, 'projects'); | ||
| 69 | + if (!fs.existsSync(projectsDir)) continue; | ||
| 70 | + for (const pid of fs.readdirSync(projectsDir)) { | ||
| 71 | + const root = path.join(projectsDir, pid); | ||
| 72 | + const meta = path.join(root, 'project.json'); | ||
| 73 | + if (fs.existsSync(meta)) { | ||
| 74 | + cache.set(pid, { root_path: root, mode: 'sandbox', company_id: cid }); | ||
| 75 | + } | ||
| 76 | + } | ||
| 77 | + } | ||
| 78 | + } | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + // 2. 叠加 linked 项目 | ||
| 82 | + const linked = loadLinkedRegistry(); | ||
| 83 | + for (const [pid, entry] of Object.entries(linked)) { | ||
| 84 | + cache.set(pid, { | ||
| 85 | + root_path: entry.root_path, | ||
| 86 | + mode: entry.mode, | ||
| 87 | + company_id: entry.company_id, | ||
| 88 | + }); | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + return { count: cache.size }; | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +function ensureInit() { | ||
| 95 | + if (cache === null) init(); | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +function resolveProjectRoot(pid) { | ||
| 99 | + ensureInit(); | ||
| 100 | + const entry = cache.get(pid); | ||
| 101 | + if (!entry) throw new Error(`Unknown project: ${pid}`); | ||
| 102 | + return entry.root_path; | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +function resolveProjectMeta(pid) { | ||
| 106 | + ensureInit(); | ||
| 107 | + const entry = cache.get(pid); | ||
| 108 | + if (!entry) throw new Error(`Unknown project: ${pid}`); | ||
| 109 | + return { ...entry }; | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +function findProjectByPath(abs) { | ||
| 113 | + ensureInit(); | ||
| 114 | + for (const [pid, entry] of cache.entries()) { | ||
| 115 | + if (entry.root_path === abs) return { pid, ...entry }; | ||
| 116 | + } | ||
| 117 | + return null; | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +// ── 派生路径 ── | ||
| 121 | + | ||
| 122 | +function resolveUnitDir(pid, unitId) { | ||
| 123 | + return path.join(resolveProjectRoot(pid), 'units', unitId); | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +function resolvePrdSite(pid) { | ||
| 127 | + return path.join(resolveProjectRoot(pid), 'prd'); | ||
| 128 | +} | ||
| 129 | + | ||
| 130 | +function resolveSddSite(pid) { | ||
| 131 | + return path.join(resolveProjectRoot(pid), 'sdd'); | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +function resolveAgentState(pid, agent) { | ||
| 135 | + return path.join(resolveProjectRoot(pid), 'agent-state', `${agent}.json`); | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +function resolveAgentTodolist(pid, agent) { | ||
| 139 | + return path.join(resolveProjectRoot(pid), 'agent-state', `${agent}-todo.json`); | ||
| 140 | +} | ||
| 141 | + | ||
| 142 | +function resolveAgentSnapshotDir(pid) { | ||
| 143 | + return path.join(resolveProjectRoot(pid), 'agent-state', 'snapshots'); | ||
| 144 | +} | ||
| 145 | + | ||
| 146 | +function resolveTimelineLog(pid) { | ||
| 147 | + return path.join(resolveProjectRoot(pid), 'timeline.ndjson'); | ||
| 148 | +} | ||
| 149 | + | ||
| 150 | +function resolveBugsLog(pid) { | ||
| 151 | + return path.join(resolveProjectRoot(pid), 'bugs.ndjson'); | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +function resolveChangelog(pid) { | ||
| 155 | + return path.join(resolveProjectRoot(pid), 'changelog.ndjson'); | ||
| 156 | +} | ||
| 157 | + | ||
| 158 | +function resolveStreamLog(pid) { | ||
| 159 | + return path.join(resolveProjectRoot(pid), 'stream.log'); | ||
| 160 | +} | ||
| 161 | + | ||
| 162 | +function resolveHistoryDir(pid) { | ||
| 163 | + return path.join(resolveProjectRoot(pid), 'history'); | ||
| 164 | +} | ||
| 165 | + | ||
| 166 | +function resolveIntegrationDir(pid) { | ||
| 167 | + return path.join(resolveProjectRoot(pid), 'integration'); | ||
| 168 | +} | ||
| 169 | + | ||
| 170 | +function resolveUnitsJson(pid) { | ||
| 171 | + return path.join(resolveProjectRoot(pid), 'units.json'); | ||
| 172 | +} | ||
| 173 | + | ||
| 174 | +function resolveProjectJson(pid) { | ||
| 175 | + return path.join(resolveProjectRoot(pid), 'project.json'); | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +/** Memory 永远在 company 级(不跟项目根走,详见 arch-project-root §⑧) */ | ||
| 179 | +function resolveMemoryDir(companyId, agent) { | ||
| 180 | + return path.join(DATA_DIR, 'companies', companyId, 'memory', 'devloop', agent); | ||
| 181 | +} | ||
| 182 | + | ||
| 183 | +function resolveCompanyDir(companyId) { | ||
| 184 | + return path.join(DATA_DIR, 'companies', companyId); | ||
| 185 | +} | ||
| 186 | + | ||
| 187 | +// ── 创建项目 ── | ||
| 188 | + | ||
| 189 | +function generatePid() { | ||
| 190 | + // p- + 6 位 base36 | ||
| 191 | + const rand = Math.random().toString(36).slice(2, 8).padEnd(6, '0'); | ||
| 192 | + return `p-${rand}`; | ||
| 193 | +} | ||
| 194 | + | ||
| 195 | +function isSensitivePath(abs) { | ||
| 196 | + const normalized = path.resolve(abs); | ||
| 197 | + // 1. 明确允许的目录(tmpdir 等)→ 即使前缀匹配也放行 | ||
| 198 | + for (const ex of ALLOWED_EXEMPTIONS) { | ||
| 199 | + const e = path.resolve(ex); | ||
| 200 | + if (normalized === e || normalized.startsWith(e + path.sep)) return false; | ||
| 201 | + } | ||
| 202 | + // 2. 不允许直接放在根 / | ||
| 203 | + if (normalized === '/') return true; | ||
| 204 | + // 3. 检查黑名单前缀 | ||
| 205 | + return SENSITIVE_PREFIXES.some(prefix => { | ||
| 206 | + const p = path.resolve(prefix); | ||
| 207 | + return normalized === p || normalized.startsWith(p + path.sep); | ||
| 208 | + }); | ||
| 209 | +} | ||
| 210 | + | ||
| 211 | +/** | ||
| 212 | + * 在文件系统上分配一个 root_path,但不写任何业务文件。 | ||
| 213 | + * 仅 mkdir + 校验路径合法。 | ||
| 214 | + * 业务文件(project.json / .gitignore / git init)由 LoopController.createProject 完成。 | ||
| 215 | + * | ||
| 216 | + * @returns { pid, root_path, mode } | ||
| 217 | + */ | ||
| 218 | +function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) { | ||
| 219 | + if (!company_id) throw new Error('company_id required'); | ||
| 220 | + | ||
| 221 | + const pid = generatePid(); | ||
| 222 | + | ||
| 223 | + let root_path; | ||
| 224 | + if (mode === 'sandbox') { | ||
| 225 | + root_path = path.join(DATA_DIR, 'companies', company_id, 'projects', pid); | ||
| 226 | + fs.mkdirSync(root_path, { recursive: true }); | ||
| 227 | + } else if (mode === 'linked-new') { | ||
| 228 | + if (!target_path) throw new Error('target_path required for linked-new'); | ||
| 229 | + if (!path.isAbsolute(target_path)) { | ||
| 230 | + throw new Error('target_path must be absolute'); | ||
| 231 | + } | ||
| 232 | + if (isSensitivePath(target_path)) { | ||
| 233 | + const e = new Error(`sensitive_path: ${target_path} is in blacklist`); | ||
| 234 | + e.code = 'SENSITIVE_PATH'; | ||
| 235 | + throw e; | ||
| 236 | + } | ||
| 237 | + // 父目录必须存在 | ||
| 238 | + const parent = path.dirname(target_path); | ||
| 239 | + if (!fs.existsSync(parent)) { | ||
| 240 | + throw new Error(`parent dir does not exist: ${parent}`); | ||
| 241 | + } | ||
| 242 | + // 必须不存在或为空 | ||
| 243 | + if (fs.existsSync(target_path)) { | ||
| 244 | + const entries = fs.readdirSync(target_path); | ||
| 245 | + if (entries.length > 0) { | ||
| 246 | + throw new Error(`target_path is not empty: ${target_path}`); | ||
| 247 | + } | ||
| 248 | + } else { | ||
| 249 | + fs.mkdirSync(target_path, { recursive: false }); | ||
| 250 | + } | ||
| 251 | + root_path = path.resolve(target_path); | ||
| 252 | + // 注册到 linked-projects | ||
| 253 | + const reg = loadLinkedRegistry(); | ||
| 254 | + reg[pid] = { | ||
| 255 | + root_path, | ||
| 256 | + mode: 'linked-new', | ||
| 257 | + company_id, | ||
| 258 | + registered_at: new Date().toISOString(), | ||
| 259 | + }; | ||
| 260 | + saveLinkedRegistry(reg); | ||
| 261 | + } else if (mode === 'linked-existing') { | ||
| 262 | + const e = new Error('linked-existing mode is v2; not supported in v1'); | ||
| 263 | + e.code = 'NOT_IMPLEMENTED'; | ||
| 264 | + throw e; | ||
| 265 | + } else { | ||
| 266 | + throw new Error(`unknown mode: ${mode}`); | ||
| 267 | + } | ||
| 268 | + | ||
| 269 | + // 立即注入缓存 | ||
| 270 | + ensureInit(); | ||
| 271 | + cache.set(pid, { root_path, mode, company_id }); | ||
| 272 | + | ||
| 273 | + return { pid, root_path, mode }; | ||
| 274 | +} | ||
| 275 | + | ||
| 276 | +function unregisterLinkedProject(pid) { | ||
| 277 | + const reg = loadLinkedRegistry(); | ||
| 278 | + if (reg[pid]) { | ||
| 279 | + delete reg[pid]; | ||
| 280 | + saveLinkedRegistry(reg); | ||
| 281 | + } | ||
| 282 | + if (cache) cache.delete(pid); | ||
| 283 | +} | ||
| 284 | + | ||
| 285 | +function listProjects() { | ||
| 286 | + ensureInit(); | ||
| 287 | + return Array.from(cache.entries()).map(([pid, entry]) => ({ pid, ...entry })); | ||
| 288 | +} | ||
| 289 | + | ||
| 290 | +/** 测试用:重置缓存,强制下次重新扫描 */ | ||
| 291 | +function _resetForTesting() { | ||
| 292 | + cache = null; | ||
| 293 | +} | ||
| 294 | + | ||
| 295 | +module.exports = { | ||
| 296 | + init, | ||
| 297 | + resolveProjectRoot, | ||
| 298 | + resolveProjectMeta, | ||
| 299 | + findProjectByPath, | ||
| 300 | + resolveUnitDir, | ||
| 301 | + resolvePrdSite, | ||
| 302 | + resolveSddSite, | ||
| 303 | + resolveAgentState, | ||
| 304 | + resolveAgentTodolist, | ||
| 305 | + resolveAgentSnapshotDir, | ||
| 306 | + resolveTimelineLog, | ||
| 307 | + resolveBugsLog, | ||
| 308 | + resolveChangelog, | ||
| 309 | + resolveStreamLog, | ||
| 310 | + resolveHistoryDir, | ||
| 311 | + resolveIntegrationDir, | ||
| 312 | + resolveUnitsJson, | ||
| 313 | + resolveProjectJson, | ||
| 314 | + resolveMemoryDir, | ||
| 315 | + resolveCompanyDir, | ||
| 316 | + allocateProjectRoot, | ||
| 317 | + unregisterLinkedProject, | ||
| 318 | + listProjects, | ||
| 319 | + isSensitivePath, | ||
| 320 | + _resetForTesting, | ||
| 321 | + // 常量 | ||
| 322 | + DATA_DIR, | ||
| 323 | + LINKED_REGISTRY, | ||
| 324 | + SENSITIVE_PREFIXES, | ||
| 325 | +}; |
server/services/devloop/sse-broker.js
0 → 100644
| 1 | +/** | ||
| 2 | + * DevLoop · sseBroker | ||
| 3 | + * | ||
| 4 | + * 每项目一个广播通道,多订阅者。 | ||
| 5 | + * - subscribe(pid, res, opts) 把 HTTP res 注册为订阅者 | ||
| 6 | + * - emit(pid, event, data) 写一行到 stream.log + 广播给所有订阅者 | ||
| 7 | + * - 心跳:每 25s 一条 `:keepalive\n\n` 注释 | ||
| 8 | + * - 断线重连:客户端发 Last-Event-ID header,replay 从 stream.log 读 > 该 id 的行 | ||
| 9 | + * | ||
| 10 | + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-11 | ||
| 11 | + * docs/prd/newfunction/_spec/api-sse.md | ||
| 12 | + * docs/prd/newfunction/_spec/sdd-process.md · P-5 | ||
| 13 | + */ | ||
| 14 | + | ||
| 15 | +const fs = require('fs'); | ||
| 16 | +const path = require('path'); | ||
| 17 | +const pathResolver = require('./path-resolver'); | ||
| 18 | + | ||
| 19 | +const HEARTBEAT_MS = 25_000; | ||
| 20 | + | ||
| 21 | +// pid → Set<{ res, heartbeatTimer }> | ||
| 22 | +const subscriptions = new Map(); | ||
| 23 | + | ||
| 24 | +// 单调递增的事件 id(跨进程不严格保证;崩溃重启从 stream.log 读最大值续) | ||
| 25 | +let eventCounter = 0; | ||
| 26 | + | ||
| 27 | +function _streamLogPath(pid) { | ||
| 28 | + return pathResolver.resolveStreamLog(pid); | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +function _initCounterFromLog(pid) { | ||
| 32 | + // 启动时如果 stream.log 已存在,找最大 id 续号 | ||
| 33 | + const p = _streamLogPath(pid); | ||
| 34 | + if (!fs.existsSync(p)) return; | ||
| 35 | + const raw = fs.readFileSync(p, 'utf8'); | ||
| 36 | + const matches = raw.match(/^id: (\d+)$/gm) || []; | ||
| 37 | + for (const m of matches) { | ||
| 38 | + const n = parseInt(m.slice(4), 10); | ||
| 39 | + if (n > eventCounter) eventCounter = n; | ||
| 40 | + } | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +function _ensureCounterInit(pid) { | ||
| 44 | + // 全局单调即可;不严格 per-pid(避免每次启动都全扫) | ||
| 45 | + if (eventCounter === 0) _initCounterFromLog(pid); | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +/** | ||
| 49 | + * @param {string} pid | ||
| 50 | + * @param {http.ServerResponse} res - 调用方应已 writeHead(200, SSE headers) | ||
| 51 | + * @param {object} opts - { replayFromOffset?: string | number (Last-Event-ID) } | ||
| 52 | + */ | ||
| 53 | +function subscribe(pid, res, opts = {}) { | ||
| 54 | + _ensureCounterInit(pid); | ||
| 55 | + | ||
| 56 | + // Replay:从 stream.log 读 > replayFromOffset 的行 | ||
| 57 | + if (opts.replayFromOffset != null) { | ||
| 58 | + const fromId = parseInt(opts.replayFromOffset, 10) || 0; | ||
| 59 | + _replayTo(res, pid, fromId); | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + // 心跳 | ||
| 63 | + const heartbeat = setInterval(() => { | ||
| 64 | + try { res.write(':keepalive\n\n'); } catch { /* ignore broken pipe */ } | ||
| 65 | + }, HEARTBEAT_MS); | ||
| 66 | + | ||
| 67 | + // 加订阅 | ||
| 68 | + if (!subscriptions.has(pid)) subscriptions.set(pid, new Set()); | ||
| 69 | + const entry = { res, heartbeatTimer: heartbeat }; | ||
| 70 | + subscriptions.get(pid).add(entry); | ||
| 71 | + | ||
| 72 | + // 客户端断开 | ||
| 73 | + res.on('close', () => { | ||
| 74 | + clearInterval(heartbeat); | ||
| 75 | + subscriptions.get(pid)?.delete(entry); | ||
| 76 | + if (subscriptions.get(pid)?.size === 0) subscriptions.delete(pid); | ||
| 77 | + }); | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +function _replayTo(res, pid, fromId) { | ||
| 81 | + const p = _streamLogPath(pid); | ||
| 82 | + if (!fs.existsSync(p)) return; | ||
| 83 | + | ||
| 84 | + const raw = fs.readFileSync(p, 'utf8'); | ||
| 85 | + // stream.log 格式为 SSE 帧:`id: N\nevent: X\ndata: {...}\n\n` | ||
| 86 | + const blocks = raw.split('\n\n').filter(Boolean); | ||
| 87 | + for (const block of blocks) { | ||
| 88 | + const idMatch = block.match(/^id: (\d+)/m); | ||
| 89 | + if (!idMatch) continue; | ||
| 90 | + const id = parseInt(idMatch[1], 10); | ||
| 91 | + if (id <= fromId) continue; | ||
| 92 | + try { res.write(block + '\n\n'); } catch { return; } | ||
| 93 | + } | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +/** | ||
| 97 | + * 广播一条事件 + 落盘 stream.log。 | ||
| 98 | + * @param {string} pid | ||
| 99 | + * @param {string} event - 事件名,如 "unit.state" / "bug.opened" | ||
| 100 | + * @param {any} data | ||
| 101 | + */ | ||
| 102 | +function emit(pid, event, data) { | ||
| 103 | + _ensureCounterInit(pid); | ||
| 104 | + const id = ++eventCounter; | ||
| 105 | + const payload = JSON.stringify(data == null ? {} : data); | ||
| 106 | + const line = `id: ${id}\nevent: ${event}\ndata: ${payload}\n\n`; | ||
| 107 | + | ||
| 108 | + // 1. 落盘 | ||
| 109 | + const p = _streamLogPath(pid); | ||
| 110 | + fs.mkdirSync(path.dirname(p), { recursive: true }); | ||
| 111 | + fs.appendFileSync(p, line); | ||
| 112 | + | ||
| 113 | + // 2. 广播 | ||
| 114 | + const subs = subscriptions.get(pid); | ||
| 115 | + if (subs) { | ||
| 116 | + for (const entry of subs) { | ||
| 117 | + try { entry.res.write(line); } catch { /* ignore broken pipe */ } | ||
| 118 | + } | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + return { id, event }; | ||
| 122 | +} | ||
| 123 | + | ||
| 124 | +/** 项目结束:发 close + 关所有订阅。 */ | ||
| 125 | +function close(pid) { | ||
| 126 | + const subs = subscriptions.get(pid); | ||
| 127 | + if (!subs) return; | ||
| 128 | + for (const entry of subs) { | ||
| 129 | + try { entry.res.write('event: close\ndata: {}\n\n'); } catch { /* ignore */ } | ||
| 130 | + try { entry.res.end(); } catch { /* ignore */ } | ||
| 131 | + clearInterval(entry.heartbeatTimer); | ||
| 132 | + } | ||
| 133 | + subscriptions.delete(pid); | ||
| 134 | +} | ||
| 135 | + | ||
| 136 | +/** 测试用 */ | ||
| 137 | +function _resetForTesting() { | ||
| 138 | + for (const subs of subscriptions.values()) { | ||
| 139 | + for (const entry of subs) clearInterval(entry.heartbeatTimer); | ||
| 140 | + } | ||
| 141 | + subscriptions.clear(); | ||
| 142 | + eventCounter = 0; | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +function _activeSubscribers(pid) { | ||
| 146 | + return subscriptions.get(pid)?.size || 0; | ||
| 147 | +} | ||
| 148 | + | ||
| 149 | +module.exports = { | ||
| 150 | + subscribe, | ||
| 151 | + emit, | ||
| 152 | + close, | ||
| 153 | + HEARTBEAT_MS, | ||
| 154 | + _resetForTesting, | ||
| 155 | + _activeSubscribers, | ||
| 156 | +}; |
server/services/devloop/timeline-logger.js
0 → 100644
| 1 | +/** | ||
| 2 | + * DevLoop · timelineLogger | ||
| 3 | + * | ||
| 4 | + * 全流程统一事件日志(M-13 ProcessTimeline)。append-only NDJSON。 | ||
| 5 | + * - 每行一个事件 | ||
| 6 | + * - 每条 ≤ 1KB(summary 自动截 280 字符;payload > 4KB 强制 ref 形式) | ||
| 7 | + * - 同步原子写(appendFileSync) | ||
| 8 | + * | ||
| 9 | + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-15 | ||
| 10 | + * docs/prd/newfunction/_spec/sdd-model.md · M-13 | ||
| 11 | + */ | ||
| 12 | + | ||
| 13 | +const fs = require('fs'); | ||
| 14 | +const path = require('path'); | ||
| 15 | +const readline = require('readline'); | ||
| 16 | + | ||
| 17 | +const pathResolver = require('./path-resolver'); | ||
| 18 | + | ||
| 19 | +const SUMMARY_MAX = 280; | ||
| 20 | +const PAYLOAD_MAX_BYTES = 4096; | ||
| 21 | + | ||
| 22 | +function _logPath(pid) { return pathResolver.resolveTimelineLog(pid); } | ||
| 23 | + | ||
| 24 | +function _truncate(s, n) { | ||
| 25 | + if (typeof s !== 'string') s = String(s); | ||
| 26 | + if (s.length <= n) return s; | ||
| 27 | + return s.slice(0, n - 1) + '…'; | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +/** | ||
| 31 | + * Append 一条事件。同步写。 | ||
| 32 | + * @param {string} pid | ||
| 33 | + * @param {object} ev - { source, kind, ref?, summary, payload? } | ||
| 34 | + */ | ||
| 35 | +function append(pid, ev) { | ||
| 36 | + if (!ev || !ev.source || !ev.kind || !ev.summary) { | ||
| 37 | + throw new Error('timelineLogger.append: source/kind/summary required'); | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + const event = { | ||
| 41 | + ts: Math.floor(Date.now() / 1000), | ||
| 42 | + source: ev.source, | ||
| 43 | + kind: ev.kind, | ||
| 44 | + ref: ev.ref || null, | ||
| 45 | + summary: _truncate(ev.summary, SUMMARY_MAX), | ||
| 46 | + }; | ||
| 47 | + | ||
| 48 | + // payload 超大 → 强制改 ref 形式(不写盘外部文件,仅写入 ref 占位) | ||
| 49 | + if (ev.payload !== undefined) { | ||
| 50 | + const json = JSON.stringify(ev.payload); | ||
| 51 | + if (json.length > PAYLOAD_MAX_BYTES) { | ||
| 52 | + event.payload = { oversize: true, original_bytes: json.length }; | ||
| 53 | + } else { | ||
| 54 | + event.payload = ev.payload; | ||
| 55 | + } | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + const line = JSON.stringify(event) + '\n'; | ||
| 59 | + const p = _logPath(pid); | ||
| 60 | + fs.mkdirSync(path.dirname(p), { recursive: true }); | ||
| 61 | + fs.appendFileSync(p, line); | ||
| 62 | + return event; | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +/** | ||
| 66 | + * 读全部 / 过滤事件。 | ||
| 67 | + * @param {object} opts - { limit?, since?: unix ts, source?, kind? } | ||
| 68 | + */ | ||
| 69 | +function read(pid, opts = {}) { | ||
| 70 | + const p = _logPath(pid); | ||
| 71 | + if (!fs.existsSync(p)) return []; | ||
| 72 | + | ||
| 73 | + const limit = opts.limit || 100; | ||
| 74 | + const since = opts.since; | ||
| 75 | + const filterSource = opts.source; | ||
| 76 | + const filterKind = opts.kind; | ||
| 77 | + | ||
| 78 | + const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean); | ||
| 79 | + const events = []; | ||
| 80 | + | ||
| 81 | + // 倒序读,遇到 limit 停(避免 load 整个 file) | ||
| 82 | + for (let i = lines.length - 1; i >= 0; i--) { | ||
| 83 | + let ev; | ||
| 84 | + try { ev = JSON.parse(lines[i]); } catch { continue; } | ||
| 85 | + if (since && ev.ts < since) break; // 由于 ts 单调,可以提前 break | ||
| 86 | + if (filterSource && ev.source !== filterSource) continue; | ||
| 87 | + if (filterKind && ev.kind !== filterKind) continue; | ||
| 88 | + events.push(ev); | ||
| 89 | + if (events.length >= limit) break; | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + return events.reverse(); // 输出按时间正序 | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +/** 最近 N 条(已按时间正序) */ | ||
| 96 | +function tail(pid, n = 10) { | ||
| 97 | + return read(pid, { limit: n }); | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +module.exports = { append, read, tail }; |
server/services/devloop/unit-registry.js
0 → 100644
| 1 | +/** | ||
| 2 | + * DevLoop · unitRegistry | ||
| 3 | + * | ||
| 4 | + * Unit 状态机 (M-2 + P-1) 的唯一真源。所有 markCoding/markTesting/pass/fail/ | ||
| 5 | + * block/reset 都走 FSM 校验,非法转换抛 InvalidTransitionError。 | ||
| 6 | + * | ||
| 7 | + * 持久化:通过 docStore 读写 units.json(不直接 fs IO)。 | ||
| 8 | + * 事件:每次合法状态转换发 SSE `unit.state`(若 sseBroker 注入)。 | ||
| 9 | + * | ||
| 10 | + * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-10 | ||
| 11 | + * docs/prd/newfunction/_spec/sdd-process.md · P-1 | ||
| 12 | + * docs/prd/newfunction/_spec/sdd-model.md · M-2 | ||
| 13 | + */ | ||
| 14 | + | ||
| 15 | +const docStore = require('./doc-store'); | ||
| 16 | +const pathResolver = require('./path-resolver'); | ||
| 17 | + | ||
| 18 | +class InvalidTransitionError extends Error { | ||
| 19 | + constructor(from, to, unitId) { | ||
| 20 | + super(`Invalid state transition for ${unitId}: ${from} → ${to}`); | ||
| 21 | + this.name = 'InvalidTransitionError'; | ||
| 22 | + this.from = from; | ||
| 23 | + this.to = to; | ||
| 24 | + this.unitId = unitId; | ||
| 25 | + } | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +// FSM 合法转换表(来自 sdd-process P-1) | ||
| 29 | +// from → allowed [to] 集合 | ||
| 30 | +const TRANSITIONS = { | ||
| 31 | + pending: ['coding'], | ||
| 32 | + coding: ['testing', 'failed'], | ||
| 33 | + testing: ['passed', 'failed'], | ||
| 34 | + failed: ['coding', 'blocked'], | ||
| 35 | + passed: ['pending'], // L2 reset | ||
| 36 | + blocked: ['pending'], // 人类干预后 | ||
| 37 | +}; | ||
| 38 | + | ||
| 39 | +function canTransition(from, to) { | ||
| 40 | + if (!TRANSITIONS[from]) return false; | ||
| 41 | + return TRANSITIONS[from].includes(to); | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +// sseBroker 是可选依赖。LoopController 启动时 attach 进来。 | ||
| 45 | +let sseBroker = null; | ||
| 46 | +function attachSseBroker(broker) { | ||
| 47 | + sseBroker = broker; | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +// ──────────────────────────────────────────────────────────────── | ||
| 51 | +// 读 | ||
| 52 | +// ──────────────────────────────────────────────────────────────── | ||
| 53 | + | ||
| 54 | +function _loadUnits(pid) { | ||
| 55 | + const u = docStore.readUnits(pid); | ||
| 56 | + if (!u) throw new Error(`units.json not found for ${pid}`); | ||
| 57 | + return u; | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +function list(pid) { | ||
| 61 | + return _loadUnits(pid).units; | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +function get(pid, unitId) { | ||
| 65 | + const u = list(pid).find(x => x.id === unitId); | ||
| 66 | + if (!u) throw new Error(`unit ${unitId} not found in project ${pid}`); | ||
| 67 | + return u; | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +function pending(pid) { return list(pid).filter(u => u.status === 'pending'); } | ||
| 71 | +function passed(pid) { return list(pid).filter(u => u.status === 'passed'); } | ||
| 72 | +function blocked(pid) { return list(pid).filter(u => u.status === 'blocked'); } | ||
| 73 | +function failed(pid) { return list(pid).filter(u => u.status === 'failed'); } | ||
| 74 | +function anyBlocked(pid) { return blocked(pid).length > 0; } | ||
| 75 | + | ||
| 76 | +/** | ||
| 77 | + * L2 闸门:全部 unit status=passed 且 contract_hash 与当前 SDD 一致。 | ||
| 78 | + * 这里只检查 status;contract_hash 一致性由 LoopController 调用前先 verifyContractHash | ||
| 79 | + * (这是更深的检查,需要读每个 unit 的 contract.json,太重不放这里)。 | ||
| 80 | + */ | ||
| 81 | +function l2GateOpen(pid) { | ||
| 82 | + const units = list(pid); | ||
| 83 | + if (units.length === 0) return false; | ||
| 84 | + return units.every(u => u.status === 'passed'); | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +// ──────────────────────────────────────────────────────────────── | ||
| 88 | +// 写(FSM 转换) | ||
| 89 | +// ──────────────────────────────────────────────────────────────── | ||
| 90 | + | ||
| 91 | +function _writeBack(pid, unitsJson) { | ||
| 92 | + docStore.writeUnits(pid, unitsJson, { skipValidation: false }); | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +function _transition(pid, unitId, to, extra = {}) { | ||
| 96 | + const unitsJson = _loadUnits(pid); | ||
| 97 | + const unit = unitsJson.units.find(u => u.id === unitId); | ||
| 98 | + if (!unit) throw new Error(`unit ${unitId} not found in project ${pid}`); | ||
| 99 | + | ||
| 100 | + const from = unit.status; | ||
| 101 | + if (!canTransition(from, to)) { | ||
| 102 | + throw new InvalidTransitionError(from, to, unitId); | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + unit.status = to; | ||
| 106 | + Object.assign(unit, extra); | ||
| 107 | + _writeBack(pid, unitsJson); | ||
| 108 | + | ||
| 109 | + if (sseBroker) { | ||
| 110 | + sseBroker.emit(pid, 'unit.state', { | ||
| 111 | + unit_id: unitId, | ||
| 112 | + from, to, | ||
| 113 | + retries: unit.retries, | ||
| 114 | + reason: extra.reason || null, | ||
| 115 | + }); | ||
| 116 | + } | ||
| 117 | + | ||
| 118 | + return unit; | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +function markCoding(pid, unitId) { | ||
| 122 | + return _transition(pid, unitId, 'coding'); | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +function markTesting(pid, unitId) { | ||
| 126 | + return _transition(pid, unitId, 'testing'); | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +function pass(pid, unitId, contractHash) { | ||
| 130 | + return _transition(pid, unitId, 'passed', { contract_hash: contractHash }); | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +function fail(pid, unitId, reason) { | ||
| 134 | + // failed 时累加 retries | ||
| 135 | + const unitsJson = _loadUnits(pid); | ||
| 136 | + const unit = unitsJson.units.find(u => u.id === unitId); | ||
| 137 | + if (!unit) throw new Error(`unit ${unitId} not found`); | ||
| 138 | + | ||
| 139 | + if (!canTransition(unit.status, 'failed')) { | ||
| 140 | + throw new InvalidTransitionError(unit.status, 'failed', unitId); | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + const from = unit.status; | ||
| 144 | + unit.status = 'failed'; | ||
| 145 | + unit.retries = (unit.retries || 0) + 1; | ||
| 146 | + unit.last_failure_reason = reason; | ||
| 147 | + _writeBack(pid, unitsJson); | ||
| 148 | + | ||
| 149 | + if (sseBroker) { | ||
| 150 | + sseBroker.emit(pid, 'unit.state', { | ||
| 151 | + unit_id: unitId, from, to: 'failed', | ||
| 152 | + retries: unit.retries, reason, | ||
| 153 | + }); | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + return unit; | ||
| 157 | +} | ||
| 158 | + | ||
| 159 | +function block(pid, unitId, reason) { | ||
| 160 | + return _transition(pid, unitId, 'blocked', { block_reason: reason }); | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +/** | ||
| 164 | + * 重置一批 Unit 到 pending(L2 修复后或 L3 增量后)。 | ||
| 165 | + * 允许 passed → pending 和 blocked → pending;其他状态保留(warning log)。 | ||
| 166 | + */ | ||
| 167 | +function reset(pid, unitIds) { | ||
| 168 | + const unitsJson = _loadUnits(pid); | ||
| 169 | + const reset_units = []; | ||
| 170 | + const skipped = []; | ||
| 171 | + for (const id of unitIds) { | ||
| 172 | + const u = unitsJson.units.find(x => x.id === id); | ||
| 173 | + if (!u) { | ||
| 174 | + skipped.push({ id, reason: 'not_found' }); | ||
| 175 | + continue; | ||
| 176 | + } | ||
| 177 | + const from = u.status; | ||
| 178 | + if (!canTransition(from, 'pending')) { | ||
| 179 | + skipped.push({ id, reason: `cannot_reset_from_${from}` }); | ||
| 180 | + continue; | ||
| 181 | + } | ||
| 182 | + u.status = 'pending'; | ||
| 183 | + u.retries = 0; // 重置 retry counter | ||
| 184 | + reset_units.push({ id, from }); | ||
| 185 | + } | ||
| 186 | + _writeBack(pid, unitsJson); | ||
| 187 | + | ||
| 188 | + if (sseBroker && reset_units.length) { | ||
| 189 | + for (const r of reset_units) { | ||
| 190 | + sseBroker.emit(pid, 'unit.state', { | ||
| 191 | + unit_id: r.id, from: r.from, to: 'pending', retries: 0, reason: 'reset', | ||
| 192 | + }); | ||
| 193 | + } | ||
| 194 | + } | ||
| 195 | + return { reset: reset_units, skipped }; | ||
| 196 | +} | ||
| 197 | + | ||
| 198 | +// ──────────────────────────────────────────────────────────────── | ||
| 199 | +// L3 增量 diff | ||
| 200 | +// ──────────────────────────────────────────────────────────────── | ||
| 201 | + | ||
| 202 | +/** | ||
| 203 | + * 对比新旧 units.json,返回 added/changed/removed 三类 unit id。 | ||
| 204 | + * - added : 新 list 有,旧 list 无 | ||
| 205 | + * - changed: 双方都有但 contract_hash 不同 | ||
| 206 | + * - removed: 旧 list 有,新 list 无 | ||
| 207 | + */ | ||
| 208 | +function diffUnits(oldUnitsList, newUnitsList) { | ||
| 209 | + const oldMap = new Map((oldUnitsList || []).map(u => [u.id, u])); | ||
| 210 | + const newMap = new Map((newUnitsList || []).map(u => [u.id, u])); | ||
| 211 | + | ||
| 212 | + const added = []; | ||
| 213 | + const changed = []; | ||
| 214 | + const removed = []; | ||
| 215 | + | ||
| 216 | + for (const [id, n] of newMap) { | ||
| 217 | + if (!oldMap.has(id)) { | ||
| 218 | + added.push(id); | ||
| 219 | + } else if (oldMap.get(id).contract_hash !== n.contract_hash) { | ||
| 220 | + changed.push(id); | ||
| 221 | + } | ||
| 222 | + } | ||
| 223 | + for (const [id] of oldMap) { | ||
| 224 | + if (!newMap.has(id)) removed.push(id); | ||
| 225 | + } | ||
| 226 | + return { added, changed, removed }; | ||
| 227 | +} | ||
| 228 | + | ||
| 229 | +// ──────────────────────────────────────────────────────────────── | ||
| 230 | +// 初始化 units.json 骨架 | ||
| 231 | +// ──────────────────────────────────────────────────────────────── | ||
| 232 | + | ||
| 233 | +/** | ||
| 234 | + * Architect 第一次生成 SDD 时,构造 units.json 骨架。 | ||
| 235 | + * 各 unit 自带默认 retry_budget=5、status=pending、retries=0。 | ||
| 236 | + */ | ||
| 237 | +function buildUnitsJson({ project_id, prd_rev, sdd_rev, units, l2_budget = 3 }) { | ||
| 238 | + return { | ||
| 239 | + project_id, | ||
| 240 | + prd_rev: String(prd_rev), | ||
| 241 | + sdd_rev: String(sdd_rev), | ||
| 242 | + l2_rounds: 0, | ||
| 243 | + l2_budget, | ||
| 244 | + units: units.map(u => ({ | ||
| 245 | + id: u.id, | ||
| 246 | + type: u.type, | ||
| 247 | + status: 'pending', | ||
| 248 | + depends_on: u.depends_on || [], | ||
| 249 | + retries: 0, | ||
| 250 | + retry_budget: u.retry_budget || 5, | ||
| 251 | + contract_hash: u.contract_hash, | ||
| 252 | + ...(u.last_run ? { last_run: u.last_run } : {}), | ||
| 253 | + })), | ||
| 254 | + }; | ||
| 255 | +} | ||
| 256 | + | ||
| 257 | +module.exports = { | ||
| 258 | + // 读 | ||
| 259 | + list, get, | ||
| 260 | + pending, passed, blocked, failed, anyBlocked, | ||
| 261 | + l2GateOpen, | ||
| 262 | + | ||
| 263 | + // 写 | ||
| 264 | + markCoding, markTesting, pass, fail, block, reset, | ||
| 265 | + | ||
| 266 | + // L3 | ||
| 267 | + diffUnits, | ||
| 268 | + | ||
| 269 | + // 工具 | ||
| 270 | + buildUnitsJson, | ||
| 271 | + canTransition, | ||
| 272 | + TRANSITIONS, | ||
| 273 | + | ||
| 274 | + // 错误 | ||
| 275 | + InvalidTransitionError, | ||
| 276 | + | ||
| 277 | + // 依赖注入 | ||
| 278 | + attachSseBroker, | ||
| 279 | +}; |
server/tests/devloop/bug-tracker.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * bugTracker 单测 | ||
| 3 | + */ | ||
| 4 | +const fs = require('fs'); | ||
| 5 | + | ||
| 6 | +const pathResolver = require('../../services/devloop/path-resolver'); | ||
| 7 | +const bugTracker = require('../../services/devloop/bug-tracker'); | ||
| 8 | + | ||
| 9 | +describe('bugTracker', () => { | ||
| 10 | + let pid, root; | ||
| 11 | + | ||
| 12 | + beforeEach(() => { | ||
| 13 | + pathResolver._resetForTesting(); | ||
| 14 | + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); | ||
| 15 | + pid = r.pid; | ||
| 16 | + root = r.root_path; | ||
| 17 | + }); | ||
| 18 | + | ||
| 19 | + afterEach(() => { | ||
| 20 | + fs.rmSync(root, { recursive: true, force: true }); | ||
| 21 | + }); | ||
| 22 | + | ||
| 23 | + describe('open + dedup', () => { | ||
| 24 | + it('open new bug returns id', () => { | ||
| 25 | + const b = bugTracker.open(pid, { | ||
| 26 | + source: 'unit_test', unit_id: 'auth-be', | ||
| 27 | + test_name: 'login.invalid-pwd', severity: 'high', | ||
| 28 | + title: 'expected 401 got 500', | ||
| 29 | + }); | ||
| 30 | + expect(b.id).toMatch(/^bug-/); | ||
| 31 | + expect(b.status).toBe('open'); | ||
| 32 | + }); | ||
| 33 | + | ||
| 34 | + it('same unit + test → dedup returns existing id', () => { | ||
| 35 | + const b1 = bugTracker.open(pid, { | ||
| 36 | + unit_id: 'auth-be', test_name: 'login.invalid-pwd', title: 'T1', | ||
| 37 | + }); | ||
| 38 | + const b2 = bugTracker.open(pid, { | ||
| 39 | + unit_id: 'auth-be', test_name: 'login.invalid-pwd', title: 'T2', | ||
| 40 | + }); | ||
| 41 | + expect(b2.id).toBe(b1.id); | ||
| 42 | + }); | ||
| 43 | + }); | ||
| 44 | + | ||
| 45 | + describe('transition', () => { | ||
| 46 | + it('open → fixing → fixed → verified', () => { | ||
| 47 | + const b = bugTracker.open(pid, { | ||
| 48 | + unit_id: 'auth-be', test_name: 'x', title: 'T', | ||
| 49 | + }); | ||
| 50 | + bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' }); | ||
| 51 | + expect(bugTracker.get(pid, b.id).status).toBe('fixing'); | ||
| 52 | + bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' }); | ||
| 53 | + expect(bugTracker.get(pid, b.id).status).toBe('fixed'); | ||
| 54 | + bugTracker.transition(pid, b.id, { to: 'verified', by: 'integrator' }); | ||
| 55 | + expect(bugTracker.get(pid, b.id).status).toBe('verified'); | ||
| 56 | + }); | ||
| 57 | + | ||
| 58 | + it('open → verified (skip) throws', () => { | ||
| 59 | + const b = bugTracker.open(pid, { unit_id: 'x-be', test_name: 'y', title: 'T' }); | ||
| 60 | + expect(() => bugTracker.transition(pid, b.id, { to: 'verified', by: 'system' })) | ||
| 61 | + .toThrow(bugTracker.InvalidBugTransitionError); | ||
| 62 | + }); | ||
| 63 | + | ||
| 64 | + it('verified → open (reopen) ok', () => { | ||
| 65 | + const b = bugTracker.open(pid, { unit_id: 'x-be', test_name: 'y', title: 'T' }); | ||
| 66 | + bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' }); | ||
| 67 | + bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' }); | ||
| 68 | + bugTracker.transition(pid, b.id, { to: 'verified', by: 'integrator' }); | ||
| 69 | + bugTracker.transition(pid, b.id, { to: 'open', by: 'system' }); | ||
| 70 | + expect(bugTracker.get(pid, b.id).status).toBe('open'); | ||
| 71 | + }); | ||
| 72 | + }); | ||
| 73 | + | ||
| 74 | + describe('list + stats', () => { | ||
| 75 | + it('filter by status', () => { | ||
| 76 | + const b1 = bugTracker.open(pid, { unit_id: 'a-be', test_name: 't1', title: 'T1' }); | ||
| 77 | + const b2 = bugTracker.open(pid, { unit_id: 'b-be', test_name: 't2', title: 'T2' }); | ||
| 78 | + bugTracker.transition(pid, b2.id, { to: 'fixing', by: 'coder' }); | ||
| 79 | + const open = bugTracker.list(pid, { status: ['open'] }); | ||
| 80 | + const fixing = bugTracker.list(pid, { status: ['fixing'] }); | ||
| 81 | + expect(open.map(b => b.id)).toContain(b1.id); | ||
| 82 | + expect(fixing.map(b => b.id)).toContain(b2.id); | ||
| 83 | + }); | ||
| 84 | + | ||
| 85 | + it('stats counts by status', () => { | ||
| 86 | + bugTracker.open(pid, { unit_id: 'a-be', test_name: 't1', title: 'T', severity: 'high' }); | ||
| 87 | + bugTracker.open(pid, { unit_id: 'b-be', test_name: 't2', title: 'T', severity: 'low' }); | ||
| 88 | + const s = bugTracker.stats(pid); | ||
| 89 | + expect(s.open).toBe(2); | ||
| 90 | + expect(s.by_severity.high).toBe(1); | ||
| 91 | + expect(s.by_severity.low).toBe(1); | ||
| 92 | + }); | ||
| 93 | + }); | ||
| 94 | + | ||
| 95 | + describe('history', () => { | ||
| 96 | + it('tracks all transitions', () => { | ||
| 97 | + const b = bugTracker.open(pid, { unit_id: 'a-be', test_name: 't', title: 'T' }); | ||
| 98 | + bugTracker.transition(pid, b.id, { to: 'fixing', by: 'coder' }); | ||
| 99 | + bugTracker.transition(pid, b.id, { to: 'fixed', by: 'tester' }); | ||
| 100 | + const h = bugTracker.history(pid, b.id); | ||
| 101 | + // 含 open 时的 history (1) + 2 次 transition = 3 条 | ||
| 102 | + expect(h.length).toBeGreaterThanOrEqual(3); | ||
| 103 | + }); | ||
| 104 | + }); | ||
| 105 | +}); |
server/tests/devloop/doc-store.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * docStore 单测 | ||
| 3 | + */ | ||
| 4 | +const docStore = require('../../services/devloop/doc-store'); | ||
| 5 | + | ||
| 6 | +describe('docStore', () => { | ||
| 7 | + describe('validateHTML', () => { | ||
| 8 | + it('rejects empty', () => { | ||
| 9 | + const r = docStore.validateHTML('', 'prd'); | ||
| 10 | + expect(r.ok).toBe(false); | ||
| 11 | + }); | ||
| 12 | + | ||
| 13 | + it('rejects missing article', () => { | ||
| 14 | + const r = docStore.validateHTML('<p>hi</p>', 'prd'); | ||
| 15 | + expect(r.ok).toBe(false); | ||
| 16 | + expect(r.errors.join(' ')).toContain('article'); | ||
| 17 | + }); | ||
| 18 | + | ||
| 19 | + it('rejects wrong doc kind', () => { | ||
| 20 | + const r = docStore.validateHTML('<article data-doc="sdd" data-rev="1"><section><h3>x</h3><p>y</p></section></article>', 'prd'); | ||
| 21 | + expect(r.ok).toBe(false); | ||
| 22 | + }); | ||
| 23 | + | ||
| 24 | + it('accepts valid PRD chapter', () => { | ||
| 25 | + const html = `<article data-doc="prd" data-rev="2"> | ||
| 26 | + <section data-rev="1"><h3>Title</h3><p>body</p></section> | ||
| 27 | + <section data-rev="2"><h3>Other</h3><p>more</p></section> | ||
| 28 | + </article>`; | ||
| 29 | + const r = docStore.validateHTML(html, 'prd'); | ||
| 30 | + expect(r.ok).toBe(true); | ||
| 31 | + }); | ||
| 32 | + | ||
| 33 | + it('detects section without h3', () => { | ||
| 34 | + const html = `<article data-doc="prd" data-rev="1"> | ||
| 35 | + <section><p>only p</p></section> | ||
| 36 | + </article>`; | ||
| 37 | + const r = docStore.validateHTML(html, 'prd'); | ||
| 38 | + expect(r.ok).toBe(false); | ||
| 39 | + expect(r.errors.join(' ')).toContain('h3'); | ||
| 40 | + }); | ||
| 41 | + | ||
| 42 | + it('detects non-monotonic data-rev', () => { | ||
| 43 | + const html = `<article data-doc="prd" data-rev="3"> | ||
| 44 | + <section data-rev="2"><h3>x</h3><p>y</p></section> | ||
| 45 | + <section data-rev="1"><h3>x</h3><p>y</p></section> | ||
| 46 | + </article>`; | ||
| 47 | + const r = docStore.validateHTML(html, 'prd'); | ||
| 48 | + expect(r.ok).toBe(false); | ||
| 49 | + expect(r.errors.join(' ')).toContain('monotonic'); | ||
| 50 | + }); | ||
| 51 | + }); | ||
| 52 | + | ||
| 53 | + describe('validateUnitsSchema', () => { | ||
| 54 | + const validUnit = { | ||
| 55 | + id: 'auth-be', type: 'be', status: 'pending', | ||
| 56 | + depends_on: [], retries: 0, retry_budget: 5, | ||
| 57 | + contract_hash: 'sha256:abc', | ||
| 58 | + }; | ||
| 59 | + | ||
| 60 | + it('accepts valid units.json', () => { | ||
| 61 | + const json = { | ||
| 62 | + project_id: 'p-test', prd_rev: '1', sdd_rev: '1', | ||
| 63 | + units: [validUnit], l2_rounds: 0, l2_budget: 3, | ||
| 64 | + }; | ||
| 65 | + const r = docStore.validateUnitsSchema(json); | ||
| 66 | + expect(r.ok).toBe(true); | ||
| 67 | + }); | ||
| 68 | + | ||
| 69 | + it('rejects invalid unit id', () => { | ||
| 70 | + const json = { | ||
| 71 | + project_id: 'p', units: [{ ...validUnit, id: 'AuthBE' }], | ||
| 72 | + }; | ||
| 73 | + const r = docStore.validateUnitsSchema(json); | ||
| 74 | + expect(r.ok).toBe(false); | ||
| 75 | + expect(r.errors.join(' ')).toContain('naming'); | ||
| 76 | + }); | ||
| 77 | + | ||
| 78 | + it('rejects duplicate ids', () => { | ||
| 79 | + const json = { | ||
| 80 | + project_id: 'p', units: [validUnit, validUnit], | ||
| 81 | + }; | ||
| 82 | + const r = docStore.validateUnitsSchema(json); | ||
| 83 | + expect(r.ok).toBe(false); | ||
| 84 | + expect(r.errors.join(' ')).toContain('duplicate'); | ||
| 85 | + }); | ||
| 86 | + | ||
| 87 | + it('rejects unknown depends_on', () => { | ||
| 88 | + const json = { | ||
| 89 | + project_id: 'p', | ||
| 90 | + units: [{ ...validUnit, depends_on: ['ghost-be'] }], | ||
| 91 | + }; | ||
| 92 | + const r = docStore.validateUnitsSchema(json); | ||
| 93 | + expect(r.ok).toBe(false); | ||
| 94 | + expect(r.errors.join(' ')).toContain('unknown unit'); | ||
| 95 | + }); | ||
| 96 | + | ||
| 97 | + it('detects cycle', () => { | ||
| 98 | + const a = { id: 'a-be', type: 'be', status: 'pending', depends_on: ['b-be'], retries: 0, retry_budget: 5, contract_hash: 'sha256:1' }; | ||
| 99 | + const b = { id: 'b-be', type: 'be', status: 'pending', depends_on: ['a-be'], retries: 0, retry_budget: 5, contract_hash: 'sha256:2' }; | ||
| 100 | + const json = { project_id: 'p', units: [a, b] }; | ||
| 101 | + const r = docStore.validateUnitsSchema(json); | ||
| 102 | + expect(r.ok).toBe(false); | ||
| 103 | + expect(r.errors.join(' ')).toContain('cycle'); | ||
| 104 | + }); | ||
| 105 | + }); | ||
| 106 | + | ||
| 107 | + describe('hashContract', () => { | ||
| 108 | + it('deterministic for same content', () => { | ||
| 109 | + const c = { unit_id: 'x', exports: { http: [{ path: '/x' }] } }; | ||
| 110 | + const h1 = docStore.hashContract(c); | ||
| 111 | + const h2 = docStore.hashContract(c); | ||
| 112 | + expect(h1).toBe(h2); | ||
| 113 | + expect(h1).toMatch(/^sha256:[a-f0-9]{16}$/); | ||
| 114 | + }); | ||
| 115 | + | ||
| 116 | + it('different content → different hash', () => { | ||
| 117 | + const c1 = { unit_id: 'x', exports: { http: [{ path: '/x' }] } }; | ||
| 118 | + const c2 = { unit_id: 'x', exports: { http: [{ path: '/y' }] } }; | ||
| 119 | + expect(docStore.hashContract(c1)).not.toBe(docStore.hashContract(c2)); | ||
| 120 | + }); | ||
| 121 | + | ||
| 122 | + it('canonical: key ordering does not affect hash', () => { | ||
| 123 | + const c1 = { exports: { http: [{ a: 1, b: 2 }] } }; | ||
| 124 | + const c2 = { exports: { http: [{ b: 2, a: 1 }] } }; | ||
| 125 | + expect(docStore.hashContract(c1)).toBe(docStore.hashContract(c2)); | ||
| 126 | + }); | ||
| 127 | + }); | ||
| 128 | +}); |
server/tests/devloop/git-vault.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * gitVault 单测 | ||
| 3 | + * | ||
| 4 | + * 需要系统装了 git。如果没装则 skip 所有用例。 | ||
| 5 | + */ | ||
| 6 | +const { execSync } = require('child_process'); | ||
| 7 | +const fs = require('fs'); | ||
| 8 | +const path = require('path'); | ||
| 9 | + | ||
| 10 | +const pathResolver = require('../../services/devloop/path-resolver'); | ||
| 11 | +const gitVault = require('../../services/devloop/git-vault'); | ||
| 12 | +const docStore = require('../../services/devloop/doc-store'); | ||
| 13 | + | ||
| 14 | +let gitAvailable = false; | ||
| 15 | +try { execSync('git --version', { stdio: 'ignore' }); gitAvailable = true; } catch { /* skip */ } | ||
| 16 | + | ||
| 17 | +(gitAvailable ? describe : describe.skip)('gitVault', () => { | ||
| 18 | + let pid, root; | ||
| 19 | + | ||
| 20 | + beforeEach(() => { | ||
| 21 | + pathResolver._resetForTesting(); | ||
| 22 | + gitVault._resetMutexesForTesting(); | ||
| 23 | + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); | ||
| 24 | + pid = r.pid; | ||
| 25 | + root = r.root_path; | ||
| 26 | + }); | ||
| 27 | + | ||
| 28 | + afterEach(() => { | ||
| 29 | + fs.rmSync(root, { recursive: true, force: true }); | ||
| 30 | + }); | ||
| 31 | + | ||
| 32 | + it('init creates .git + initial commit', async () => { | ||
| 33 | + const r = await gitVault.init(pid); | ||
| 34 | + expect(r.ok).toBe(true); | ||
| 35 | + expect(fs.existsSync(path.join(root, '.git'))).toBe(true); | ||
| 36 | + expect(fs.existsSync(path.join(root, '.gitignore'))).toBe(true); | ||
| 37 | + | ||
| 38 | + const logs = await gitVault.log(pid); | ||
| 39 | + expect(logs.length).toBeGreaterThanOrEqual(1); | ||
| 40 | + expect(logs[0].summary).toMatch(/^init: project p-/); | ||
| 41 | + }); | ||
| 42 | + | ||
| 43 | + it('init is idempotent', async () => { | ||
| 44 | + await gitVault.init(pid); | ||
| 45 | + const r = await gitVault.init(pid); | ||
| 46 | + expect(r.already_exists).toBe(true); | ||
| 47 | + }); | ||
| 48 | + | ||
| 49 | + it('commit with content creates new sha', async () => { | ||
| 50 | + await gitVault.init(pid); | ||
| 51 | + // 写一个真实的文件再 commit (PRD/HTML 校验严格,这里直接 raw) | ||
| 52 | + fs.writeFileSync(path.join(root, 'README.md'), 'hello\n'); | ||
| 53 | + const r = await gitVault.commit(pid, { | ||
| 54 | + kind: 'chore', summary: 'add README', | ||
| 55 | + author: { name: 'PM Agent', email: 'pm@devloop.local' }, | ||
| 56 | + }); | ||
| 57 | + expect(r.ok).toBe(true); | ||
| 58 | + expect(r.skipped).toBeFalsy(); | ||
| 59 | + expect(r.sha).toMatch(/^[a-f0-9]{40}$/); | ||
| 60 | + | ||
| 61 | + // author 被记录 | ||
| 62 | + const logs = await gitVault.log(pid, { limit: 1 }); | ||
| 63 | + expect(logs[0].author_name).toBe('PM Agent'); | ||
| 64 | + expect(logs[0].author_email).toBe('pm@devloop.local'); | ||
| 65 | + }); | ||
| 66 | + | ||
| 67 | + it('commit with nothing-to-commit returns skipped', async () => { | ||
| 68 | + await gitVault.init(pid); | ||
| 69 | + const r = await gitVault.commit(pid, { | ||
| 70 | + kind: 'chore', summary: 'empty commit attempt', | ||
| 71 | + }); | ||
| 72 | + expect(r.ok).toBe(true); | ||
| 73 | + expect(r.skipped).toBe(true); | ||
| 74 | + }); | ||
| 75 | + | ||
| 76 | + it('tag + listTags', async () => { | ||
| 77 | + await gitVault.init(pid); | ||
| 78 | + await gitVault.tag(pid, 'shipped-v1'); | ||
| 79 | + const tags = await gitVault.listTags(pid); | ||
| 80 | + expect(tags).toContain('shipped-v1'); | ||
| 81 | + }); | ||
| 82 | + | ||
| 83 | + it('status detects dirty tree', async () => { | ||
| 84 | + await gitVault.init(pid); | ||
| 85 | + let s = await gitVault.status(pid); | ||
| 86 | + expect(s.dirty).toBe(false); | ||
| 87 | + | ||
| 88 | + fs.writeFileSync(path.join(root, 'untracked.txt'), 'x'); | ||
| 89 | + s = await gitVault.status(pid); | ||
| 90 | + expect(s.dirty).toBe(true); | ||
| 91 | + }); | ||
| 92 | + | ||
| 93 | + it('resetWorkingTree clears dirty', async () => { | ||
| 94 | + await gitVault.init(pid); | ||
| 95 | + fs.writeFileSync(path.join(root, 'dirt.txt'), 'x'); | ||
| 96 | + expect((await gitVault.status(pid)).dirty).toBe(true); | ||
| 97 | + | ||
| 98 | + await gitVault.resetWorkingTree(pid); | ||
| 99 | + expect((await gitVault.status(pid)).dirty).toBe(false); | ||
| 100 | + }); | ||
| 101 | + | ||
| 102 | + it('archive produces zip', async () => { | ||
| 103 | + await gitVault.init(pid); | ||
| 104 | + fs.writeFileSync(path.join(root, 'kept.txt'), 'x'); | ||
| 105 | + await gitVault.commit(pid, { kind: 'chore', summary: 'add file' }); | ||
| 106 | + const r = await gitVault.archive(pid); | ||
| 107 | + expect(fs.existsSync(r.path)).toBe(true); | ||
| 108 | + fs.rmSync(r.path); | ||
| 109 | + }); | ||
| 110 | +}); |
server/tests/devloop/path-resolver.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * pathResolver 单测 | ||
| 3 | + */ | ||
| 4 | +const fs = require('fs'); | ||
| 5 | +const path = require('path'); | ||
| 6 | +const os = require('os'); | ||
| 7 | + | ||
| 8 | +const pathResolver = require('../../services/devloop/path-resolver'); | ||
| 9 | + | ||
| 10 | +// 用 tmpdir 隔离测试 | ||
| 11 | +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-test-')); | ||
| 12 | +process.env.HOME = TEST_HOME; // 让 LINKED_REGISTRY 写到 tmp | ||
| 13 | + | ||
| 14 | +describe('pathResolver', () => { | ||
| 15 | + beforeEach(() => { | ||
| 16 | + pathResolver._resetForTesting(); | ||
| 17 | + }); | ||
| 18 | + | ||
| 19 | + describe('isSensitivePath', () => { | ||
| 20 | + it('rejects /etc', () => { | ||
| 21 | + expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true); | ||
| 22 | + }); | ||
| 23 | + it('rejects /usr/local/bin', () => { | ||
| 24 | + expect(pathResolver.isSensitivePath('/usr/local/bin')).toBe(true); | ||
| 25 | + }); | ||
| 26 | + it('rejects /etc/passwd absolute', () => { | ||
| 27 | + expect(pathResolver.isSensitivePath('/etc/passwd')).toBe(true); | ||
| 28 | + }); | ||
| 29 | + it('rejects /var/log/something', () => { | ||
| 30 | + expect(pathResolver.isSensitivePath('/var/log/anything')).toBe(true); | ||
| 31 | + }); | ||
| 32 | + it('accepts ~/code/project', () => { | ||
| 33 | + expect(pathResolver.isSensitivePath(path.join(os.homedir(), 'code', 'project'))).toBe(false); | ||
| 34 | + }); | ||
| 35 | + it('accepts /tmp/anything', () => { | ||
| 36 | + expect(pathResolver.isSensitivePath('/tmp/anything')).toBe(false); | ||
| 37 | + }); | ||
| 38 | + }); | ||
| 39 | + | ||
| 40 | + describe('allocateProjectRoot · sandbox', () => { | ||
| 41 | + it('creates root under data/companies/<cid>/projects/<pid>', () => { | ||
| 42 | + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); | ||
| 43 | + expect(r.pid).toMatch(/^p-[a-z0-9]{6}$/); | ||
| 44 | + expect(r.mode).toBe('sandbox'); | ||
| 45 | + expect(r.root_path).toContain('data/companies/c-test/projects/'); | ||
| 46 | + expect(fs.existsSync(r.root_path)).toBe(true); | ||
| 47 | + // cleanup | ||
| 48 | + fs.rmSync(r.root_path, { recursive: true, force: true }); | ||
| 49 | + }); | ||
| 50 | + }); | ||
| 51 | + | ||
| 52 | + describe('allocateProjectRoot · linked-new', () => { | ||
| 53 | + it('rejects when target_path missing', () => { | ||
| 54 | + expect(() => | ||
| 55 | + pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'linked-new' }) | ||
| 56 | + ).toThrow(/target_path required/); | ||
| 57 | + }); | ||
| 58 | + | ||
| 59 | + it('rejects relative target_path', () => { | ||
| 60 | + expect(() => | ||
| 61 | + pathResolver.allocateProjectRoot({ | ||
| 62 | + company_id: 'c-test', mode: 'linked-new', target_path: 'relative/path', | ||
| 63 | + }) | ||
| 64 | + ).toThrow(/must be absolute/); | ||
| 65 | + }); | ||
| 66 | + | ||
| 67 | + it('rejects sensitive path', () => { | ||
| 68 | + expect(() => | ||
| 69 | + pathResolver.allocateProjectRoot({ | ||
| 70 | + company_id: 'c-test', mode: 'linked-new', target_path: '/etc/foo', | ||
| 71 | + }) | ||
| 72 | + ).toThrow(/sensitive_path/); | ||
| 73 | + }); | ||
| 74 | + | ||
| 75 | + it('rejects when parent does not exist', () => { | ||
| 76 | + expect(() => | ||
| 77 | + pathResolver.allocateProjectRoot({ | ||
| 78 | + company_id: 'c-test', mode: 'linked-new', | ||
| 79 | + target_path: '/tmp/devloop-test-nonexistent-parent/proj', | ||
| 80 | + }) | ||
| 81 | + ).toThrow(/parent dir does not exist/); | ||
| 82 | + }); | ||
| 83 | + | ||
| 84 | + it('rejects when target_path is non-empty dir', () => { | ||
| 85 | + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-')); | ||
| 86 | + fs.writeFileSync(path.join(target, 'existing.txt'), 'x'); | ||
| 87 | + expect(() => | ||
| 88 | + pathResolver.allocateProjectRoot({ | ||
| 89 | + company_id: 'c-test', mode: 'linked-new', target_path: target, | ||
| 90 | + }) | ||
| 91 | + ).toThrow(/not empty/); | ||
| 92 | + fs.rmSync(target, { recursive: true, force: true }); | ||
| 93 | + }); | ||
| 94 | + | ||
| 95 | + it('accepts empty existing dir', () => { | ||
| 96 | + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-linked-')); | ||
| 97 | + const r = pathResolver.allocateProjectRoot({ | ||
| 98 | + company_id: 'c-test', mode: 'linked-new', target_path: target, | ||
| 99 | + }); | ||
| 100 | + expect(r.mode).toBe('linked-new'); | ||
| 101 | + expect(r.root_path).toBe(target); | ||
| 102 | + // 检查 linked-registry | ||
| 103 | + const reg = JSON.parse(fs.readFileSync(pathResolver.LINKED_REGISTRY, 'utf8')); | ||
| 104 | + expect(reg[r.pid]).toBeDefined(); | ||
| 105 | + expect(reg[r.pid].mode).toBe('linked-new'); | ||
| 106 | + | ||
| 107 | + fs.rmSync(target, { recursive: true, force: true }); | ||
| 108 | + pathResolver.unregisterLinkedProject(r.pid); | ||
| 109 | + }); | ||
| 110 | + }); | ||
| 111 | + | ||
| 112 | + describe('allocateProjectRoot · linked-existing rejected in v1', () => { | ||
| 113 | + it('throws NOT_IMPLEMENTED', () => { | ||
| 114 | + expect(() => | ||
| 115 | + pathResolver.allocateProjectRoot({ | ||
| 116 | + company_id: 'c-test', mode: 'linked-existing', target_path: '/tmp', | ||
| 117 | + }) | ||
| 118 | + ).toThrow(/v2/); | ||
| 119 | + }); | ||
| 120 | + }); | ||
| 121 | + | ||
| 122 | + describe('resolveProjectRoot + 派生路径', () => { | ||
| 123 | + it('throws on unknown pid', () => { | ||
| 124 | + expect(() => pathResolver.resolveProjectRoot('p-nonexistent')).toThrow(/Unknown project/); | ||
| 125 | + }); | ||
| 126 | + | ||
| 127 | + it('after allocate, all resolvers work', () => { | ||
| 128 | + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); | ||
| 129 | + expect(pathResolver.resolveProjectRoot(r.pid)).toBe(r.root_path); | ||
| 130 | + expect(pathResolver.resolveUnitDir(r.pid, 'auth-be')) | ||
| 131 | + .toBe(path.join(r.root_path, 'units', 'auth-be')); | ||
| 132 | + expect(pathResolver.resolvePrdSite(r.pid)).toBe(path.join(r.root_path, 'prd')); | ||
| 133 | + expect(pathResolver.resolveSddSite(r.pid)).toBe(path.join(r.root_path, 'sdd')); | ||
| 134 | + expect(pathResolver.resolveTimelineLog(r.pid)).toBe(path.join(r.root_path, 'timeline.ndjson')); | ||
| 135 | + expect(pathResolver.resolveBugsLog(r.pid)).toBe(path.join(r.root_path, 'bugs.ndjson')); | ||
| 136 | + expect(pathResolver.resolveAgentState(r.pid, 'pm')) | ||
| 137 | + .toBe(path.join(r.root_path, 'agent-state', 'pm.json')); | ||
| 138 | + fs.rmSync(r.root_path, { recursive: true, force: true }); | ||
| 139 | + }); | ||
| 140 | + }); | ||
| 141 | + | ||
| 142 | + describe('memory dir 在 company 级', () => { | ||
| 143 | + it('does not depend on pid', () => { | ||
| 144 | + const m1 = pathResolver.resolveMemoryDir('c-test', 'pm'); | ||
| 145 | + expect(m1).toContain('companies/c-test/memory/devloop/pm'); | ||
| 146 | + expect(m1).not.toContain('projects/'); | ||
| 147 | + }); | ||
| 148 | + }); | ||
| 149 | +}); |
server/tests/devloop/timeline-logger.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * timelineLogger + changelogWriter 单测 | ||
| 3 | + */ | ||
| 4 | +const fs = require('fs'); | ||
| 5 | + | ||
| 6 | +const pathResolver = require('../../services/devloop/path-resolver'); | ||
| 7 | +const timelineLogger = require('../../services/devloop/timeline-logger'); | ||
| 8 | +const changelogWriter = require('../../services/devloop/changelog-writer'); | ||
| 9 | + | ||
| 10 | +describe('timelineLogger', () => { | ||
| 11 | + let pid, root; | ||
| 12 | + | ||
| 13 | + beforeEach(() => { | ||
| 14 | + pathResolver._resetForTesting(); | ||
| 15 | + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); | ||
| 16 | + pid = r.pid; | ||
| 17 | + root = r.root_path; | ||
| 18 | + }); | ||
| 19 | + | ||
| 20 | + afterEach(() => { | ||
| 21 | + fs.rmSync(root, { recursive: true, force: true }); | ||
| 22 | + }); | ||
| 23 | + | ||
| 24 | + it('append writes NDJSON line', () => { | ||
| 25 | + timelineLogger.append(pid, { | ||
| 26 | + source: 'system', kind: 'project_created', ref: pid, | ||
| 27 | + summary: 'test project', | ||
| 28 | + }); | ||
| 29 | + const events = timelineLogger.read(pid); | ||
| 30 | + expect(events).toHaveLength(1); | ||
| 31 | + expect(events[0].kind).toBe('project_created'); | ||
| 32 | + }); | ||
| 33 | + | ||
| 34 | + it('append rejects missing fields', () => { | ||
| 35 | + expect(() => timelineLogger.append(pid, {})).toThrow(); | ||
| 36 | + expect(() => timelineLogger.append(pid, { source: 'x' })).toThrow(); | ||
| 37 | + }); | ||
| 38 | + | ||
| 39 | + it('summary truncated to 280 chars', () => { | ||
| 40 | + const longSummary = 'x'.repeat(500); | ||
| 41 | + timelineLogger.append(pid, { | ||
| 42 | + source: 'system', kind: 'test', summary: longSummary, | ||
| 43 | + }); | ||
| 44 | + const events = timelineLogger.read(pid); | ||
| 45 | + expect(events[0].summary.length).toBeLessThanOrEqual(280); | ||
| 46 | + expect(events[0].summary).toMatch(/…$/); | ||
| 47 | + }); | ||
| 48 | + | ||
| 49 | + it('oversized payload reduced to ref marker', () => { | ||
| 50 | + const big = { huge: 'x'.repeat(5000) }; | ||
| 51 | + timelineLogger.append(pid, { | ||
| 52 | + source: 'system', kind: 'test', summary: 's', payload: big, | ||
| 53 | + }); | ||
| 54 | + const events = timelineLogger.read(pid); | ||
| 55 | + expect(events[0].payload.oversize).toBe(true); | ||
| 56 | + }); | ||
| 57 | + | ||
| 58 | + it('tail returns last N', () => { | ||
| 59 | + for (let i = 0; i < 15; i++) { | ||
| 60 | + timelineLogger.append(pid, { source: 'system', kind: 'k', summary: `${i}` }); | ||
| 61 | + } | ||
| 62 | + const last5 = timelineLogger.tail(pid, 5); | ||
| 63 | + expect(last5).toHaveLength(5); | ||
| 64 | + // 时间正序,最后一条是最新 | ||
| 65 | + expect(last5[4].summary).toBe('14'); | ||
| 66 | + }); | ||
| 67 | + | ||
| 68 | + it('filter by kind', () => { | ||
| 69 | + timelineLogger.append(pid, { source: 'pm', kind: 'prd_drafted', summary: 'a' }); | ||
| 70 | + timelineLogger.append(pid, { source: 'arch', kind: 'sdd_generated', summary: 'b' }); | ||
| 71 | + timelineLogger.append(pid, { source: 'pm', kind: 'prd_drafted', summary: 'c' }); | ||
| 72 | + const r = timelineLogger.read(pid, { kind: 'prd_drafted' }); | ||
| 73 | + expect(r).toHaveLength(2); | ||
| 74 | + }); | ||
| 75 | +}); | ||
| 76 | + | ||
| 77 | +describe('changelogWriter', () => { | ||
| 78 | + let pid, root; | ||
| 79 | + | ||
| 80 | + beforeEach(() => { | ||
| 81 | + pathResolver._resetForTesting(); | ||
| 82 | + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); | ||
| 83 | + pid = r.pid; | ||
| 84 | + root = r.root_path; | ||
| 85 | + }); | ||
| 86 | + | ||
| 87 | + afterEach(() => { | ||
| 88 | + fs.rmSync(root, { recursive: true, force: true }); | ||
| 89 | + }); | ||
| 90 | + | ||
| 91 | + it('append writes entry', () => { | ||
| 92 | + changelogWriter.append(pid, { | ||
| 93 | + kind: 'prd_rev', rev: 1, by: 'pm-agent', | ||
| 94 | + trigger: 'init', summary: 'first PRD', | ||
| 95 | + }); | ||
| 96 | + const all = changelogWriter.read(pid); | ||
| 97 | + expect(all).toHaveLength(1); | ||
| 98 | + expect(all[0].kind).toBe('prd_rev'); | ||
| 99 | + expect(all[0].rev).toBe(1); | ||
| 100 | + }); | ||
| 101 | + | ||
| 102 | + it('rejects invalid kind', () => { | ||
| 103 | + expect(() => changelogWriter.append(pid, { | ||
| 104 | + kind: 'wrong-kind', rev: 1, summary: 's', | ||
| 105 | + })).toThrow(); | ||
| 106 | + }); | ||
| 107 | + | ||
| 108 | + it('latest returns most recent of kind', () => { | ||
| 109 | + changelogWriter.append(pid, { kind: 'prd_rev', rev: 1, summary: 's1' }); | ||
| 110 | + changelogWriter.append(pid, { kind: 'sdd_rev', rev: 1, summary: 's2' }); | ||
| 111 | + changelogWriter.append(pid, { kind: 'prd_rev', rev: 2, summary: 's3' }); | ||
| 112 | + const r = changelogWriter.latest(pid, 'prd_rev'); | ||
| 113 | + expect(r.rev).toBe(2); | ||
| 114 | + }); | ||
| 115 | +}); |
server/tests/devloop/unit-registry.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * unitRegistry 单测 — 跑 FSM 转换 | ||
| 3 | + */ | ||
| 4 | +const fs = require('fs'); | ||
| 5 | +const path = require('path'); | ||
| 6 | +const os = require('os'); | ||
| 7 | + | ||
| 8 | +const pathResolver = require('../../services/devloop/path-resolver'); | ||
| 9 | +const docStore = require('../../services/devloop/doc-store'); | ||
| 10 | +const unitRegistry = require('../../services/devloop/unit-registry'); | ||
| 11 | + | ||
| 12 | +describe('unitRegistry', () => { | ||
| 13 | + let pid; | ||
| 14 | + | ||
| 15 | + beforeEach(() => { | ||
| 16 | + pathResolver._resetForTesting(); | ||
| 17 | + const r = pathResolver.allocateProjectRoot({ company_id: 'c-test', mode: 'sandbox' }); | ||
| 18 | + pid = r.pid; | ||
| 19 | + // 准备 units.json | ||
| 20 | + const units = { | ||
| 21 | + project_id: pid, prd_rev: '1', sdd_rev: '1', l2_rounds: 0, l2_budget: 3, | ||
| 22 | + units: [ | ||
| 23 | + { | ||
| 24 | + id: 'auth-be', type: 'be', status: 'pending', depends_on: [], | ||
| 25 | + retries: 0, retry_budget: 5, contract_hash: 'sha256:aaaaaaaaaaaaaaaa', | ||
| 26 | + }, | ||
| 27 | + { | ||
| 28 | + id: 'auth-fe', type: 'fe', status: 'pending', depends_on: ['auth-be'], | ||
| 29 | + retries: 0, retry_budget: 5, contract_hash: 'sha256:bbbbbbbbbbbbbbbb', | ||
| 30 | + }, | ||
| 31 | + ], | ||
| 32 | + }; | ||
| 33 | + docStore.writeUnits(pid, units); | ||
| 34 | + }); | ||
| 35 | + | ||
| 36 | + afterEach(() => { | ||
| 37 | + const root = pathResolver.resolveProjectRoot(pid); | ||
| 38 | + fs.rmSync(root, { recursive: true, force: true }); | ||
| 39 | + }); | ||
| 40 | + | ||
| 41 | + describe('list / pending / passed', () => { | ||
| 42 | + it('lists all units', () => { | ||
| 43 | + expect(unitRegistry.list(pid)).toHaveLength(2); | ||
| 44 | + }); | ||
| 45 | + it('all start pending', () => { | ||
| 46 | + expect(unitRegistry.pending(pid)).toHaveLength(2); | ||
| 47 | + expect(unitRegistry.passed(pid)).toHaveLength(0); | ||
| 48 | + }); | ||
| 49 | + }); | ||
| 50 | + | ||
| 51 | + describe('canTransition', () => { | ||
| 52 | + it('pending → coding ok', () => { | ||
| 53 | + expect(unitRegistry.canTransition('pending', 'coding')).toBe(true); | ||
| 54 | + }); | ||
| 55 | + it('coding → testing ok', () => { | ||
| 56 | + expect(unitRegistry.canTransition('coding', 'testing')).toBe(true); | ||
| 57 | + }); | ||
| 58 | + it('pending → passed FORBIDDEN', () => { | ||
| 59 | + expect(unitRegistry.canTransition('pending', 'passed')).toBe(false); | ||
| 60 | + }); | ||
| 61 | + it('passed → pending ok (L2 reset)', () => { | ||
| 62 | + expect(unitRegistry.canTransition('passed', 'pending')).toBe(true); | ||
| 63 | + }); | ||
| 64 | + it('blocked → pending ok (人类干预)', () => { | ||
| 65 | + expect(unitRegistry.canTransition('blocked', 'pending')).toBe(true); | ||
| 66 | + }); | ||
| 67 | + }); | ||
| 68 | + | ||
| 69 | + describe('markCoding / markTesting / pass', () => { | ||
| 70 | + it('full L1 happy path', () => { | ||
| 71 | + unitRegistry.markCoding(pid, 'auth-be'); | ||
| 72 | + expect(unitRegistry.get(pid, 'auth-be').status).toBe('coding'); | ||
| 73 | + | ||
| 74 | + unitRegistry.markTesting(pid, 'auth-be'); | ||
| 75 | + expect(unitRegistry.get(pid, 'auth-be').status).toBe('testing'); | ||
| 76 | + | ||
| 77 | + unitRegistry.pass(pid, 'auth-be', 'sha256:newhash00000000'); | ||
| 78 | + const u = unitRegistry.get(pid, 'auth-be'); | ||
| 79 | + expect(u.status).toBe('passed'); | ||
| 80 | + expect(u.contract_hash).toBe('sha256:newhash00000000'); | ||
| 81 | + }); | ||
| 82 | + | ||
| 83 | + it('pending → testing throws', () => { | ||
| 84 | + expect(() => unitRegistry.markTesting(pid, 'auth-be')) | ||
| 85 | + .toThrow(unitRegistry.InvalidTransitionError); | ||
| 86 | + }); | ||
| 87 | + }); | ||
| 88 | + | ||
| 89 | + describe('fail + retries', () => { | ||
| 90 | + it('coding/testing → failed accumulates retries', () => { | ||
| 91 | + unitRegistry.markCoding(pid, 'auth-be'); | ||
| 92 | + unitRegistry.markTesting(pid, 'auth-be'); | ||
| 93 | + unitRegistry.fail(pid, 'auth-be', 'test red'); | ||
| 94 | + expect(unitRegistry.get(pid, 'auth-be').retries).toBe(1); | ||
| 95 | + | ||
| 96 | + unitRegistry.markCoding(pid, 'auth-be'); | ||
| 97 | + unitRegistry.markTesting(pid, 'auth-be'); | ||
| 98 | + unitRegistry.fail(pid, 'auth-be', 'test red 2'); | ||
| 99 | + expect(unitRegistry.get(pid, 'auth-be').retries).toBe(2); | ||
| 100 | + }); | ||
| 101 | + }); | ||
| 102 | + | ||
| 103 | + describe('block + reset', () => { | ||
| 104 | + it('failed → blocked then reset', () => { | ||
| 105 | + unitRegistry.markCoding(pid, 'auth-be'); | ||
| 106 | + unitRegistry.markTesting(pid, 'auth-be'); | ||
| 107 | + unitRegistry.fail(pid, 'auth-be', 'red'); | ||
| 108 | + unitRegistry.block(pid, 'auth-be', 'budget exceeded'); | ||
| 109 | + expect(unitRegistry.get(pid, 'auth-be').status).toBe('blocked'); | ||
| 110 | + | ||
| 111 | + const r = unitRegistry.reset(pid, ['auth-be']); | ||
| 112 | + expect(r.reset).toHaveLength(1); | ||
| 113 | + expect(unitRegistry.get(pid, 'auth-be').status).toBe('pending'); | ||
| 114 | + expect(unitRegistry.get(pid, 'auth-be').retries).toBe(0); | ||
| 115 | + }); | ||
| 116 | + | ||
| 117 | + it('reset skips coding/testing units with reason', () => { | ||
| 118 | + unitRegistry.markCoding(pid, 'auth-be'); | ||
| 119 | + const r = unitRegistry.reset(pid, ['auth-be', 'ghost']); | ||
| 120 | + expect(r.reset).toHaveLength(0); | ||
| 121 | + expect(r.skipped.find(s => s.id === 'auth-be').reason).toMatch(/coding/); | ||
| 122 | + expect(r.skipped.find(s => s.id === 'ghost').reason).toBe('not_found'); | ||
| 123 | + }); | ||
| 124 | + }); | ||
| 125 | + | ||
| 126 | + describe('l2GateOpen', () => { | ||
| 127 | + it('false when any not passed', () => { | ||
| 128 | + expect(unitRegistry.l2GateOpen(pid)).toBe(false); | ||
| 129 | + }); | ||
| 130 | + it('true when all passed', () => { | ||
| 131 | + for (const id of ['auth-be', 'auth-fe']) { | ||
| 132 | + unitRegistry.markCoding(pid, id); | ||
| 133 | + unitRegistry.markTesting(pid, id); | ||
| 134 | + unitRegistry.pass(pid, id, 'sha256:0000000000000000'); | ||
| 135 | + } | ||
| 136 | + expect(unitRegistry.l2GateOpen(pid)).toBe(true); | ||
| 137 | + }); | ||
| 138 | + }); | ||
| 139 | + | ||
| 140 | + describe('diffUnits', () => { | ||
| 141 | + it('detects added/changed/removed', () => { | ||
| 142 | + const oldList = [ | ||
| 143 | + { id: 'a-be', contract_hash: 'sha256:1' }, | ||
| 144 | + { id: 'b-be', contract_hash: 'sha256:2' }, | ||
| 145 | + ]; | ||
| 146 | + const newList = [ | ||
| 147 | + { id: 'a-be', contract_hash: 'sha256:1' }, // same | ||
| 148 | + { id: 'b-be', contract_hash: 'sha256:CHANGED' }, // changed | ||
| 149 | + { id: 'c-be', contract_hash: 'sha256:3' }, // added | ||
| 150 | + ]; | ||
| 151 | + const r = unitRegistry.diffUnits(oldList, newList); | ||
| 152 | + expect(r.added).toEqual(['c-be']); | ||
| 153 | + expect(r.changed).toEqual(['b-be']); | ||
| 154 | + expect(r.removed).toEqual([]); | ||
| 155 | + }); | ||
| 156 | + }); | ||
| 157 | +}); |