mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-21 07:14:19 +01:00
refactor: add effect-ts and refactor codes
This commit is contained in:
@@ -39,6 +39,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.98",
|
||||
"@effect/platform": "^0.92.1",
|
||||
"@effect/platform-node": "^0.98.3",
|
||||
"@hono/zod-validator": "^0.7.2",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -51,6 +53,8 @@
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"effect": "^3.18.4",
|
||||
"es-toolkit": "^1.40.0",
|
||||
"hono": "^4.9.5",
|
||||
"jotai": "^2.13.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
|
||||
463
pnpm-lock.yaml
generated
463
pnpm-lock.yaml
generated
@@ -11,6 +11,12 @@ importers:
|
||||
'@anthropic-ai/claude-code':
|
||||
specifier: ^1.0.98
|
||||
version: 1.0.128
|
||||
'@effect/platform':
|
||||
specifier: ^0.92.1
|
||||
version: 0.92.1(effect@3.18.4)
|
||||
'@effect/platform-node':
|
||||
specifier: ^0.98.3
|
||||
version: 0.98.3(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
|
||||
'@hono/zod-validator':
|
||||
specifier: ^0.7.2
|
||||
version: 0.7.2(hono@4.9.5)(zod@4.1.5)
|
||||
@@ -47,6 +53,12 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
effect:
|
||||
specifier: ^3.18.4
|
||||
version: 3.18.4
|
||||
es-toolkit:
|
||||
specifier: ^1.40.0
|
||||
version: 1.40.0
|
||||
hono:
|
||||
specifier: ^4.9.5
|
||||
version: 4.9.5
|
||||
@@ -225,6 +237,71 @@ packages:
|
||||
conventional-commits-parser:
|
||||
optional: true
|
||||
|
||||
'@effect/cluster@0.50.4':
|
||||
resolution: {integrity: sha512-9uS2pRN4BCguAGqFCLFlQkReXG993UFj/TLtiwaXsacytKhdlGBU5zDDI/TckbM0wUv4g2nZPRRywqU8qnrvjQ==}
|
||||
peerDependencies:
|
||||
'@effect/platform': ^0.92.1
|
||||
'@effect/rpc': ^0.71.0
|
||||
'@effect/sql': ^0.46.0
|
||||
'@effect/workflow': ^0.11.3
|
||||
effect: ^3.18.4
|
||||
|
||||
'@effect/experimental@0.56.0':
|
||||
resolution: {integrity: sha512-ZT9wTUVyDptzdkW4Tfvz5fNzygW9vt5jWcFmKI9SlhZMu9unVJgsBhxWCNYCyfPnxw3n/Z6SEKsqgt8iKQc4MA==}
|
||||
peerDependencies:
|
||||
'@effect/platform': ^0.92.0
|
||||
effect: ^3.18.0
|
||||
ioredis: ^5
|
||||
lmdb: ^3
|
||||
peerDependenciesMeta:
|
||||
ioredis:
|
||||
optional: true
|
||||
lmdb:
|
||||
optional: true
|
||||
|
||||
'@effect/platform-node-shared@0.51.4':
|
||||
resolution: {integrity: sha512-xElU9+cNPa1BnUHAZ3sVVanuuKof8oWQhK7rbyHNqgWM7CZTjv7x9VMDs0X05+1OcTQnnW3E+SrZKIPCfcYlDQ==}
|
||||
peerDependencies:
|
||||
'@effect/cluster': ^0.50.3
|
||||
'@effect/platform': ^0.92.1
|
||||
'@effect/rpc': ^0.71.0
|
||||
'@effect/sql': ^0.46.0
|
||||
effect: ^3.18.2
|
||||
|
||||
'@effect/platform-node@0.98.3':
|
||||
resolution: {integrity: sha512-90eMWmFSVHrUEreICCd2qLPiw7qcaAv9XTx9OJ+LLv7igQgt4qkisRSK0oxAr5hqU9TdUrsgFDohqe7q7h3ZRg==}
|
||||
peerDependencies:
|
||||
'@effect/cluster': ^0.50.3
|
||||
'@effect/platform': ^0.92.1
|
||||
'@effect/rpc': ^0.71.0
|
||||
'@effect/sql': ^0.46.0
|
||||
effect: ^3.18.1
|
||||
|
||||
'@effect/platform@0.92.1':
|
||||
resolution: {integrity: sha512-XXWCBVwyhaKZISN7aM1fv/3fWDGyxr84ObywnUrL8aHvJLoIeskWFAP/fqw3c5MFCrJ3ZV97RWLbv6JiBQugdg==}
|
||||
peerDependencies:
|
||||
effect: ^3.18.1
|
||||
|
||||
'@effect/rpc@0.71.0':
|
||||
resolution: {integrity: sha512-m6mFX0ShdA+fnYAyamz7SRKF4FepaDB/ZhBri6iue26tBF2LrOFJUWewbwv8/LdLSedkO4eukhsHXuEYortL/w==}
|
||||
peerDependencies:
|
||||
'@effect/platform': ^0.92.0
|
||||
effect: ^3.18.0
|
||||
|
||||
'@effect/sql@0.46.0':
|
||||
resolution: {integrity: sha512-nm9TuTTG7gLmJlIPkf71wA5lXArSvkpm1oYoIF+rhf01wef+1ujz9Mv1SfuzYbzsk7W9+OXUIRMxz/nSlKkiGQ==}
|
||||
peerDependencies:
|
||||
'@effect/experimental': ^0.56.0
|
||||
'@effect/platform': ^0.92.0
|
||||
effect: ^3.18.0
|
||||
|
||||
'@effect/workflow@0.11.3':
|
||||
resolution: {integrity: sha512-3uyj0yOc2QRtQVOw6NEJVEMOhN/F7khhnf3QSU+2T3wvuDag9iBUzJFvSls8PgNCO3j/GgeaWzbcXwxqpFQYOQ==}
|
||||
peerDependencies:
|
||||
'@effect/platform': ^0.92.1
|
||||
'@effect/rpc': ^0.71.0
|
||||
effect: ^3.18.1
|
||||
|
||||
'@emnapi/runtime@1.5.0':
|
||||
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
|
||||
|
||||
@@ -741,6 +818,36 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.30':
|
||||
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
||||
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
|
||||
resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
|
||||
resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
|
||||
resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
|
||||
resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
||||
resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@next/env@15.5.2':
|
||||
resolution: {integrity: sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==}
|
||||
|
||||
@@ -853,6 +960,88 @@ packages:
|
||||
'@octokit/types@14.1.0':
|
||||
resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@parcel/watcher-darwin-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@parcel/watcher-darwin-x64@2.5.1':
|
||||
resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@parcel/watcher-freebsd-x64@2.5.1':
|
||||
resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@parcel/watcher-linux-arm-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher-win32-ia32@2.5.1':
|
||||
resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher-win32-x64@2.5.1':
|
||||
resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher@2.5.1':
|
||||
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
'@phun-ky/typeof@1.2.8':
|
||||
resolution: {integrity: sha512-7J6ca1tK0duM2BgVB+CuFMh3idlIVASOP2QvOCbNWDc6JnvjtKa9nufPoJQQ4xrwBonwgT1TIhRRcEtzdVgWsA==}
|
||||
engines: {node: ^20.9.0 || >=22.0.0, npm: '>=10.8.2'}
|
||||
@@ -1342,6 +1531,9 @@ packages:
|
||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@standard-schema/spec@1.0.0':
|
||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
@@ -1862,6 +2054,11 @@ packages:
|
||||
destr@2.0.5:
|
||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||
|
||||
detect-libc@1.0.3:
|
||||
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
||||
engines: {node: '>=0.10'}
|
||||
hasBin: true
|
||||
|
||||
detect-libc@2.0.4:
|
||||
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1884,6 +2081,9 @@ packages:
|
||||
resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
effect@3.18.4:
|
||||
resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==}
|
||||
|
||||
emoji-regex@10.5.0:
|
||||
resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==}
|
||||
|
||||
@@ -1897,6 +2097,9 @@ packages:
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
es-toolkit@1.40.0:
|
||||
resolution: {integrity: sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==}
|
||||
|
||||
esbuild@0.25.9:
|
||||
resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1956,6 +2159,10 @@ packages:
|
||||
extend@3.0.2:
|
||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||
|
||||
fast-check@3.23.2:
|
||||
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
fast-content-type-parse@2.0.1:
|
||||
resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==}
|
||||
|
||||
@@ -1979,6 +2186,9 @@ packages:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-my-way-ts@0.1.6:
|
||||
resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==}
|
||||
|
||||
format@0.2.2:
|
||||
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
|
||||
engines: {node: '>=0.4.x'}
|
||||
@@ -2498,6 +2708,10 @@ packages:
|
||||
micromark@4.0.2:
|
||||
resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
|
||||
|
||||
micromatch@4.0.8:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
mime-db@1.54.0:
|
||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -2506,6 +2720,11 @@ packages:
|
||||
resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime@3.0.0:
|
||||
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
hasBin: true
|
||||
|
||||
mimic-fn@4.0.0:
|
||||
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2554,6 +2773,16 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
msgpackr-extract@3.0.3:
|
||||
resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==}
|
||||
hasBin: true
|
||||
|
||||
msgpackr@1.11.5:
|
||||
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
|
||||
|
||||
multipasta@0.2.7:
|
||||
resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==}
|
||||
|
||||
mute-stream@2.0.0:
|
||||
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
@@ -2598,9 +2827,16 @@ packages:
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-fetch-native@1.6.7:
|
||||
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
||||
|
||||
node-gyp-build-optional-packages@5.2.2:
|
||||
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
||||
hasBin: true
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2785,6 +3021,9 @@ packages:
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
|
||||
pure-rand@6.1.0:
|
||||
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||
|
||||
rc9@2.1.2:
|
||||
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
||||
|
||||
@@ -3132,6 +3371,10 @@ packages:
|
||||
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
|
||||
engines: {node: '>=18.17'}
|
||||
|
||||
undici@7.16.0:
|
||||
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unicorn-magic@0.3.0:
|
||||
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3186,6 +3429,10 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
|
||||
vfile-message@4.0.3:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
@@ -3291,6 +3538,18 @@ packages:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ws@8.18.3:
|
||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3393,6 +3652,75 @@ snapshots:
|
||||
conventional-commits-filter: 5.0.0
|
||||
conventional-commits-parser: 6.2.0
|
||||
|
||||
'@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
'@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/sql': 0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/workflow': 0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
|
||||
effect: 3.18.4
|
||||
|
||||
'@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
effect: 3.18.4
|
||||
uuid: 11.1.0
|
||||
|
||||
'@effect/platform-node-shared@0.51.4(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/cluster': 0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
'@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/sql': 0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
'@parcel/watcher': 2.5.1
|
||||
effect: 3.18.4
|
||||
multipasta: 0.2.7
|
||||
ws: 8.18.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@effect/platform-node@0.98.3(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/cluster': 0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
'@effect/platform-node-shared': 0.51.4(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/sql': 0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
effect: 3.18.4
|
||||
mime: 3.0.0
|
||||
undici: 7.16.0
|
||||
ws: 8.18.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@effect/platform@0.92.1(effect@3.18.4)':
|
||||
dependencies:
|
||||
effect: 3.18.4
|
||||
find-my-way-ts: 0.1.6
|
||||
msgpackr: 1.11.5
|
||||
multipasta: 0.2.7
|
||||
|
||||
'@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
effect: 3.18.4
|
||||
msgpackr: 1.11.5
|
||||
|
||||
'@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/experimental': 0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
effect: 3.18.4
|
||||
uuid: 11.1.0
|
||||
|
||||
'@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)':
|
||||
dependencies:
|
||||
'@effect/platform': 0.92.1(effect@3.18.4)
|
||||
'@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
|
||||
effect: 3.18.4
|
||||
|
||||
'@emnapi/runtime@1.5.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3779,6 +4107,24 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@next/env@15.5.2': {}
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.2':
|
||||
@@ -3877,6 +4223,66 @@ snapshots:
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 25.1.0
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-darwin-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-darwin-x64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-freebsd-x64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm-glibc@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-ia32@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-x64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher@2.5.1':
|
||||
dependencies:
|
||||
detect-libc: 1.0.3
|
||||
is-glob: 4.0.3
|
||||
micromatch: 4.0.8
|
||||
node-addon-api: 7.1.1
|
||||
optionalDependencies:
|
||||
'@parcel/watcher-android-arm64': 2.5.1
|
||||
'@parcel/watcher-darwin-arm64': 2.5.1
|
||||
'@parcel/watcher-darwin-x64': 2.5.1
|
||||
'@parcel/watcher-freebsd-x64': 2.5.1
|
||||
'@parcel/watcher-linux-arm-glibc': 2.5.1
|
||||
'@parcel/watcher-linux-arm-musl': 2.5.1
|
||||
'@parcel/watcher-linux-arm64-glibc': 2.5.1
|
||||
'@parcel/watcher-linux-arm64-musl': 2.5.1
|
||||
'@parcel/watcher-linux-x64-glibc': 2.5.1
|
||||
'@parcel/watcher-linux-x64-musl': 2.5.1
|
||||
'@parcel/watcher-win32-arm64': 2.5.1
|
||||
'@parcel/watcher-win32-ia32': 2.5.1
|
||||
'@parcel/watcher-win32-x64': 2.5.1
|
||||
|
||||
'@phun-ky/typeof@1.2.8': {}
|
||||
|
||||
'@playwright/test@1.55.0':
|
||||
@@ -4309,6 +4715,8 @@ snapshots:
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@standard-schema/spec@1.0.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -4831,6 +5239,8 @@ snapshots:
|
||||
|
||||
destr@2.0.5: {}
|
||||
|
||||
detect-libc@1.0.3: {}
|
||||
|
||||
detect-libc@2.0.4: {}
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
@@ -4847,6 +5257,11 @@ snapshots:
|
||||
|
||||
dotenv@17.2.1: {}
|
||||
|
||||
effect@3.18.4:
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
fast-check: 3.23.2
|
||||
|
||||
emoji-regex@10.5.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
@@ -4858,6 +5273,8 @@ snapshots:
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
es-toolkit@1.40.0: {}
|
||||
|
||||
esbuild@0.25.9:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.9
|
||||
@@ -4946,6 +5363,10 @@ snapshots:
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
fast-check@3.23.2:
|
||||
dependencies:
|
||||
pure-rand: 6.1.0
|
||||
|
||||
fast-content-type-parse@2.0.1: {}
|
||||
|
||||
fault@1.0.4:
|
||||
@@ -4964,6 +5385,8 @@ snapshots:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
find-my-way-ts@0.1.6: {}
|
||||
|
||||
format@0.2.2: {}
|
||||
|
||||
fs-minipass@2.1.0:
|
||||
@@ -5642,12 +6065,19 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
micromatch@4.0.8:
|
||||
dependencies:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.1
|
||||
|
||||
mime-db@1.54.0: {}
|
||||
|
||||
mime-types@3.0.1:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
mime@3.0.0: {}
|
||||
|
||||
mimic-fn@4.0.0: {}
|
||||
|
||||
mimic-function@5.0.1: {}
|
||||
@@ -5684,6 +6114,24 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
msgpackr-extract@3.0.3:
|
||||
dependencies:
|
||||
node-gyp-build-optional-packages: 5.2.2
|
||||
optionalDependencies:
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3
|
||||
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3
|
||||
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3
|
||||
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3
|
||||
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3
|
||||
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3
|
||||
optional: true
|
||||
|
||||
msgpackr@1.11.5:
|
||||
optionalDependencies:
|
||||
msgpackr-extract: 3.0.3
|
||||
|
||||
multipasta@0.2.7: {}
|
||||
|
||||
mute-stream@2.0.0: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
@@ -5723,8 +6171,15 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-fetch-native@1.6.7: {}
|
||||
|
||||
node-gyp-build-optional-packages@5.2.2:
|
||||
dependencies:
|
||||
detect-libc: 2.0.4
|
||||
optional: true
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
npm-normalize-package-bin@4.0.0: {}
|
||||
@@ -5945,6 +6400,8 @@ snapshots:
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
pure-rand@6.1.0: {}
|
||||
|
||||
rc9@2.1.2:
|
||||
dependencies:
|
||||
defu: 6.1.4
|
||||
@@ -6370,6 +6827,8 @@ snapshots:
|
||||
|
||||
undici@6.21.3: {}
|
||||
|
||||
undici@7.16.0: {}
|
||||
|
||||
unicorn-magic@0.3.0: {}
|
||||
|
||||
unified@11.0.5:
|
||||
@@ -6428,6 +6887,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
vfile-message@4.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -6542,6 +7003,8 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
dependencies:
|
||||
is-wsl: 3.1.0
|
||||
|
||||
@@ -1,8 +1,53 @@
|
||||
import { NodeContext } from "@effect/platform-node";
|
||||
import { Effect } from "effect";
|
||||
import { handle } from "hono/vercel";
|
||||
import { honoApp } from "../../../server/hono/app";
|
||||
import { InitializeService } from "../../../server/hono/initialize";
|
||||
import { routes } from "../../../server/hono/route";
|
||||
import { ClaudeCodeLifeCycleService } from "../../../server/service/claude-code/ClaudeCodeLifeCycleService";
|
||||
import { ClaudeCodePermissionService } from "../../../server/service/claude-code/ClaudeCodePermissionService";
|
||||
import { ClaudeCodeSessionProcessService } from "../../../server/service/claude-code/ClaudeCodeSessionProcessService";
|
||||
import { EventBus } from "../../../server/service/events/EventBus";
|
||||
import { FileWatcherService } from "../../../server/service/events/fileWatcher";
|
||||
import { ProjectMetaService } from "../../../server/service/project/ProjectMetaService";
|
||||
import { ProjectRepository } from "../../../server/service/project/ProjectRepository";
|
||||
import { VirtualConversationDatabase } from "../../../server/service/session/PredictSessionsDatabase";
|
||||
import { SessionMetaService } from "../../../server/service/session/SessionMetaService";
|
||||
import { SessionRepository } from "../../../server/service/session/SessionRepository";
|
||||
|
||||
await routes(honoApp);
|
||||
const program = routes(honoApp);
|
||||
|
||||
await Effect.runPromise(
|
||||
program.pipe(
|
||||
// 依存の浅い順にコンテナに pipe する必要がある
|
||||
|
||||
/** Application */
|
||||
Effect.provide(InitializeService.Live),
|
||||
|
||||
/** Domain */
|
||||
Effect.provide(ClaudeCodeLifeCycleService.Live),
|
||||
Effect.provide(ClaudeCodePermissionService.Live),
|
||||
Effect.provide(ClaudeCodeSessionProcessService.Live),
|
||||
|
||||
// Shared Services
|
||||
Effect.provide(FileWatcherService.Live),
|
||||
Effect.provide(EventBus.Live),
|
||||
|
||||
/** Infrastructure */
|
||||
|
||||
// Repository
|
||||
Effect.provide(ProjectRepository.Live),
|
||||
Effect.provide(SessionRepository.Live),
|
||||
|
||||
// StorageService
|
||||
Effect.provide(ProjectMetaService.Live),
|
||||
Effect.provide(SessionMetaService.Live),
|
||||
Effect.provide(VirtualConversationDatabase.Live),
|
||||
|
||||
/** Platform */
|
||||
Effect.provide(NodeContext.layer),
|
||||
),
|
||||
);
|
||||
|
||||
export const GET = handle(honoApp);
|
||||
export const POST = handle(honoApp);
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useSetAtom } from "jotai";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { projectDetailQuery, sessionDetailQuery } from "../../lib/api/queries";
|
||||
import { useServerEventListener } from "../../lib/sse/hook/useServerEventListener";
|
||||
import { aliveTasksAtom } from "../projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom";
|
||||
import { sessionProcessesAtom } from "../projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom";
|
||||
|
||||
export const SSEEventListeners: FC<PropsWithChildren> = ({ children }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const setAliveTasks = useSetAtom(aliveTasksAtom);
|
||||
const setSessionProcesses = useSetAtom(sessionProcessesAtom);
|
||||
|
||||
useServerEventListener("sessionListChanged", async (event) => {
|
||||
// invalidate session list
|
||||
@@ -25,8 +25,8 @@ export const SSEEventListeners: FC<PropsWithChildren> = ({ children }) => {
|
||||
});
|
||||
});
|
||||
|
||||
useServerEventListener("taskChanged", async ({ aliveTasks }) => {
|
||||
setAliveTasks(aliveTasks);
|
||||
useServerEventListener("sessionProcessChanged", async ({ processes }) => {
|
||||
setSessionProcesses(processes);
|
||||
});
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
18
src/app/components/SyncSessionProcess.tsx
Normal file
18
src/app/components/SyncSessionProcess.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import { type FC, type PropsWithChildren, useEffect } from "react";
|
||||
import type { PublicSessionProcess } from "../../types/session-process";
|
||||
import { sessionProcessesAtom } from "../projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom";
|
||||
|
||||
export const SyncSessionProcess: FC<
|
||||
PropsWithChildren<{ initProcesses: PublicSessionProcess[] }>
|
||||
> = ({ children, initProcesses }) => {
|
||||
const setSessionProcesses = useSetAtom(sessionProcessesAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setSessionProcesses(initProcesses);
|
||||
}, [initProcesses, setSessionProcesses]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -7,8 +7,10 @@ import { SSEProvider } from "../lib/sse/components/SSEProvider";
|
||||
import { RootErrorBoundary } from "./components/RootErrorBoundary";
|
||||
|
||||
import "./globals.css";
|
||||
import { honoClient } from "../lib/api/client";
|
||||
import { configQuery } from "../lib/api/queries";
|
||||
import { SSEEventListeners } from "./components/SSEEventListeners";
|
||||
import { SyncSessionProcess } from "./components/SyncSessionProcess";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const fetchCache = "force-no-store";
|
||||
@@ -40,6 +42,10 @@ export default async function RootLayout({
|
||||
queryFn: configQuery.queryFn,
|
||||
});
|
||||
|
||||
const initSessionProcesses = await honoClient.api.cc["session-processes"]
|
||||
.$get({})
|
||||
.then((response) => response.json());
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
@@ -48,7 +54,13 @@ export default async function RootLayout({
|
||||
<RootErrorBoundary>
|
||||
<QueryClientProviderWrapper>
|
||||
<SSEProvider>
|
||||
<SSEEventListeners>{children}</SSEEventListeners>
|
||||
<SSEEventListeners>
|
||||
<SyncSessionProcess
|
||||
initProcesses={initSessionProcesses.processes}
|
||||
>
|
||||
{children}
|
||||
</SyncSessionProcess>
|
||||
</SSEEventListeners>
|
||||
</SSEProvider>
|
||||
</QueryClientProviderWrapper>
|
||||
</RootErrorBoundary>
|
||||
|
||||
@@ -4,4 +4,7 @@ export type { CommandCompletionRef } from "./CommandCompletion";
|
||||
export { CommandCompletion } from "./CommandCompletion";
|
||||
export type { FileCompletionRef } from "./FileCompletion";
|
||||
export { FileCompletion } from "./FileCompletion";
|
||||
export { useNewChatMutation, useResumeChatMutation } from "./useChatMutations";
|
||||
export {
|
||||
useContinueSessionProcessMutation,
|
||||
useCreateSessionProcessMutation,
|
||||
} from "./useChatMutations";
|
||||
|
||||
@@ -2,20 +2,24 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { honoClient } from "../../../../../lib/api/client";
|
||||
|
||||
export const useNewChatMutation = (
|
||||
export const useCreateSessionProcessMutation = (
|
||||
projectId: string,
|
||||
onSuccess?: () => void,
|
||||
) => {
|
||||
const router = useRouter();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (options: { message: string }) => {
|
||||
const response = await honoClient.api.projects[":projectId"][
|
||||
"new-session"
|
||||
].$post(
|
||||
mutationFn: async (options: {
|
||||
message: string;
|
||||
baseSessionId?: string;
|
||||
}) => {
|
||||
const response = await honoClient.api.cc["session-processes"].$post(
|
||||
{
|
||||
param: { projectId },
|
||||
json: { message: options.message },
|
||||
json: {
|
||||
projectId,
|
||||
baseSessionId: options.baseSessionId,
|
||||
message: options.message,
|
||||
},
|
||||
},
|
||||
{
|
||||
init: {
|
||||
@@ -32,22 +36,32 @@ export const useNewChatMutation = (
|
||||
},
|
||||
onSuccess: async (response) => {
|
||||
onSuccess?.();
|
||||
router.push(`/projects/${projectId}/sessions/${response.sessionId}`);
|
||||
router.push(
|
||||
`/projects/${projectId}/sessions/${response.sessionProcess.sessionId}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useResumeChatMutation = (projectId: string, sessionId: string) => {
|
||||
const router = useRouter();
|
||||
|
||||
export const useContinueSessionProcessMutation = (
|
||||
projectId: string,
|
||||
baseSessionId: string,
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: async (options: { message: string }) => {
|
||||
const response = await honoClient.api.projects[":projectId"].sessions[
|
||||
":sessionId"
|
||||
].resume.$post(
|
||||
mutationFn: async (options: {
|
||||
message: string;
|
||||
sessionProcessId: string;
|
||||
}) => {
|
||||
const response = await honoClient.api.cc["session-processes"][
|
||||
":sessionProcessId"
|
||||
].continue.$post(
|
||||
{
|
||||
param: { projectId, sessionId },
|
||||
json: { resumeMessage: options.message },
|
||||
param: { sessionProcessId: options.sessionProcessId },
|
||||
json: {
|
||||
projectId: projectId,
|
||||
baseSessionId: baseSessionId,
|
||||
continueMessage: options.message,
|
||||
},
|
||||
},
|
||||
{
|
||||
init: {
|
||||
@@ -62,10 +76,5 @@ export const useResumeChatMutation = (projectId: string, sessionId: string) => {
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: async (response) => {
|
||||
if (sessionId !== response.sessionId) {
|
||||
router.push(`/projects/${projectId}/sessions/${response.sessionId}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import type { FC } from "react";
|
||||
import { useConfig } from "../../../../hooks/useConfig";
|
||||
import { ChatInput, useNewChatMutation } from "../chatForm";
|
||||
import { ChatInput, useCreateSessionProcessMutation } from "../chatForm";
|
||||
|
||||
export const NewChat: FC<{
|
||||
projectId: string;
|
||||
onSuccess?: () => void;
|
||||
}> = ({ projectId, onSuccess }) => {
|
||||
const startNewChat = useNewChatMutation(projectId, onSuccess);
|
||||
const createSessionProcess = useCreateSessionProcessMutation(
|
||||
projectId,
|
||||
onSuccess,
|
||||
);
|
||||
const { config } = useConfig();
|
||||
|
||||
const handleSubmit = async (message: string) => {
|
||||
await startNewChat.mutateAsync({ message });
|
||||
await createSessionProcess.mutateAsync({ message });
|
||||
};
|
||||
|
||||
const getPlaceholder = () => {
|
||||
@@ -25,8 +28,8 @@ export const NewChat: FC<{
|
||||
<ChatInput
|
||||
projectId={projectId}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={startNewChat.isPending}
|
||||
error={startNewChat.error}
|
||||
isPending={createSessionProcess.isPending}
|
||||
error={createSessionProcess.error}
|
||||
placeholder={getPlaceholder()}
|
||||
buttonText="Start Chat"
|
||||
minHeight="min-h-[200px]"
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PermissionDialog } from "@/components/PermissionDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePermissionRequests } from "@/hooks/usePermissionRequests";
|
||||
@@ -20,10 +20,11 @@ import { Badge } from "../../../../../../components/ui/badge";
|
||||
import { honoClient } from "../../../../../../lib/api/client";
|
||||
import { useProject } from "../../../hooks/useProject";
|
||||
import { firstCommandToTitle } from "../../../services/firstCommandToTitle";
|
||||
import { useAliveTask } from "../hooks/useAliveTask";
|
||||
import { useSession } from "../hooks/useSession";
|
||||
import { useSessionProcess } from "../hooks/useSessionProcess";
|
||||
import { ConversationList } from "./conversationList/ConversationList";
|
||||
import { DiffModal } from "./diffModal";
|
||||
import { ContinueChat } from "./resumeChat/ContinueChat";
|
||||
import { ResumeChat } from "./resumeChat/ResumeChat";
|
||||
import { SessionSidebar } from "./sessionSidebar/SessionSidebar";
|
||||
|
||||
@@ -40,9 +41,12 @@ export const SessionPageContent: FC<{
|
||||
const project = projectData.pages[0]!.project;
|
||||
|
||||
const abortTask = useMutation({
|
||||
mutationFn: async (sessionId: string) => {
|
||||
const response = await honoClient.api.tasks.abort.$post({
|
||||
json: { sessionId },
|
||||
mutationFn: async (sessionProcessId: string) => {
|
||||
const response = await honoClient.api.cc["session-processes"][
|
||||
":sessionProcessId"
|
||||
].abort.$post({
|
||||
param: { sessionProcessId },
|
||||
json: { projectId },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -52,13 +56,18 @@ export const SessionPageContent: FC<{
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
const sessionProcess = useSessionProcess();
|
||||
|
||||
const { isRunningTask, isPausedTask } = useAliveTask(sessionId);
|
||||
const { currentPermissionRequest, isDialogOpen, onPermissionResponse } =
|
||||
usePermissionRequests();
|
||||
|
||||
const relatedSessionProcess = useMemo(
|
||||
() => sessionProcess.getSessionProcess(sessionId),
|
||||
[sessionProcess, sessionId],
|
||||
);
|
||||
|
||||
// Set up task completion notifications
|
||||
useTaskNotifications(isRunningTask);
|
||||
useTaskNotifications(relatedSessionProcess?.status === "running");
|
||||
|
||||
const [previousConversationLength, setPreviousConversationLength] =
|
||||
useState(0);
|
||||
@@ -69,7 +78,7 @@ export const SessionPageContent: FC<{
|
||||
// 自動スクロール処理
|
||||
useEffect(() => {
|
||||
if (
|
||||
(isRunningTask || isPausedTask) &&
|
||||
relatedSessionProcess?.status === "running" &&
|
||||
conversations.length !== previousConversationLength
|
||||
) {
|
||||
setPreviousConversationLength(conversations.length);
|
||||
@@ -81,7 +90,11 @@ export const SessionPageContent: FC<{
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [conversations, isRunningTask, isPausedTask, previousConversationLength]);
|
||||
}, [
|
||||
conversations,
|
||||
relatedSessionProcess?.status,
|
||||
previousConversationLength,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen max-h-screen overflow-hidden">
|
||||
@@ -136,7 +149,7 @@ export const SessionPageContent: FC<{
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{isRunningTask && (
|
||||
{relatedSessionProcess?.status === "running" && (
|
||||
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5">
|
||||
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
|
||||
<div className="flex-1">
|
||||
@@ -148,7 +161,7 @@ export const SessionPageContent: FC<{
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
abortTask.mutate(sessionId);
|
||||
abortTask.mutate(relatedSessionProcess.id);
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
@@ -157,7 +170,7 @@ export const SessionPageContent: FC<{
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPausedTask && (
|
||||
{relatedSessionProcess?.status === "paused" && (
|
||||
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5">
|
||||
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<div className="flex-1">
|
||||
@@ -169,7 +182,7 @@ export const SessionPageContent: FC<{
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
abortTask.mutate(sessionId);
|
||||
abortTask.mutate(relatedSessionProcess.id);
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
@@ -190,7 +203,7 @@ export const SessionPageContent: FC<{
|
||||
getToolResult={getToolResult}
|
||||
/>
|
||||
|
||||
{isRunningTask && (
|
||||
{relatedSessionProcess?.status === "running" && (
|
||||
<div className="flex justify-start items-center py-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -207,12 +220,15 @@ export const SessionPageContent: FC<{
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResumeChat
|
||||
{relatedSessionProcess !== undefined ? (
|
||||
<ContinueChat
|
||||
projectId={projectId}
|
||||
sessionId={sessionId}
|
||||
isPausedTask={isPausedTask}
|
||||
isRunningTask={isRunningTask}
|
||||
sessionProcessId={relatedSessionProcess.id}
|
||||
/>
|
||||
) : (
|
||||
<ResumeChat projectId={projectId} sessionId={sessionId} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FC } from "react";
|
||||
import { useConfig } from "../../../../../../hooks/useConfig";
|
||||
import {
|
||||
ChatInput,
|
||||
useContinueSessionProcessMutation,
|
||||
} from "../../../../components/chatForm";
|
||||
|
||||
export const ContinueChat: FC<{
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
sessionProcessId: string;
|
||||
}> = ({ projectId, sessionId, sessionProcessId }) => {
|
||||
const continueSessionProcess = useContinueSessionProcessMutation(
|
||||
projectId,
|
||||
sessionId,
|
||||
);
|
||||
const { config } = useConfig();
|
||||
|
||||
const handleSubmit = async (message: string) => {
|
||||
await continueSessionProcess.mutateAsync({ message, sessionProcessId });
|
||||
};
|
||||
|
||||
const getPlaceholder = () => {
|
||||
const isEnterSend = config?.enterKeyBehavior === "enter-send";
|
||||
if (isEnterSend) {
|
||||
return "Type your message... (Start with / for commands, Enter to send)";
|
||||
}
|
||||
return "Type your message... (Start with / for commands, Shift+Enter to send)";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/50 bg-muted/20 p-4 mt-6">
|
||||
<ChatInput
|
||||
projectId={projectId}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={continueSessionProcess.isPending}
|
||||
error={continueSessionProcess.error}
|
||||
placeholder={getPlaceholder()}
|
||||
buttonText={"Send"}
|
||||
minHeight="min-h-[100px]"
|
||||
containerClassName="space-y-2"
|
||||
buttonSize="default"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,27 +2,21 @@ import type { FC } from "react";
|
||||
import { useConfig } from "../../../../../../hooks/useConfig";
|
||||
import {
|
||||
ChatInput,
|
||||
useResumeChatMutation,
|
||||
useCreateSessionProcessMutation,
|
||||
} from "../../../../components/chatForm";
|
||||
|
||||
export const ResumeChat: FC<{
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
isPausedTask: boolean;
|
||||
isRunningTask: boolean;
|
||||
}> = ({ projectId, sessionId, isPausedTask, isRunningTask }) => {
|
||||
const resumeChat = useResumeChatMutation(projectId, sessionId);
|
||||
}> = ({ projectId, sessionId }) => {
|
||||
const createSessionProcess = useCreateSessionProcessMutation(projectId);
|
||||
const { config } = useConfig();
|
||||
|
||||
const handleSubmit = async (message: string) => {
|
||||
await resumeChat.mutateAsync({ message });
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (isPausedTask || isRunningTask) {
|
||||
return "Send";
|
||||
}
|
||||
return "Resume";
|
||||
await createSessionProcess.mutateAsync({
|
||||
message,
|
||||
baseSessionId: sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
const getPlaceholder = () => {
|
||||
@@ -38,10 +32,10 @@ export const ResumeChat: FC<{
|
||||
<ChatInput
|
||||
projectId={projectId}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={resumeChat.isPending}
|
||||
error={resumeChat.error}
|
||||
isPending={createSessionProcess.isPending}
|
||||
error={createSessionProcess.error}
|
||||
placeholder={getPlaceholder()}
|
||||
buttonText={getButtonText()}
|
||||
buttonText={"Resume"}
|
||||
minHeight="min-h-[100px]"
|
||||
containerClassName="space-y-2"
|
||||
buttonSize="default"
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { mcpListQuery } from "../../../../../../../lib/api/queries";
|
||||
|
||||
export const McpTab: FC = () => {
|
||||
export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
@@ -14,12 +14,14 @@ export const McpTab: FC = () => {
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: mcpListQuery.queryKey,
|
||||
queryFn: mcpListQuery.queryFn,
|
||||
queryKey: mcpListQuery(projectId).queryKey,
|
||||
queryFn: mcpListQuery(projectId).queryFn,
|
||||
});
|
||||
|
||||
const handleReload = () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpListQuery.queryKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: mcpListQuery(projectId).queryKey,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -87,7 +87,7 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
return <McpTab />;
|
||||
return <McpTab projectId={projectId} />;
|
||||
case "settings":
|
||||
return <SettingsTab openingProjectId={projectId} />;
|
||||
default:
|
||||
|
||||
@@ -69,7 +69,7 @@ export const SessionSidebar: FC<{
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
return <McpTab />;
|
||||
return <McpTab projectId={projectId} />;
|
||||
case "settings":
|
||||
return <SettingsTab openingProjectId={projectId} />;
|
||||
default:
|
||||
|
||||
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
|
||||
import type { Session } from "../../../../../../../server/service/types";
|
||||
import { NewChatModal } from "../../../../components/newChat/NewChatModal";
|
||||
import { firstCommandToTitle } from "../../../../services/firstCommandToTitle";
|
||||
import { aliveTasksAtom } from "../../store/aliveTasksAtom";
|
||||
import { sessionProcessesAtom } from "../../store/sessionProcessesAtom";
|
||||
|
||||
export const SessionsTab: FC<{
|
||||
sessions: Session[];
|
||||
@@ -27,18 +27,22 @@ export const SessionsTab: FC<{
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
}) => {
|
||||
const aliveTasks = useAtomValue(aliveTasksAtom);
|
||||
const sessionProcesses = useAtomValue(sessionProcessesAtom);
|
||||
|
||||
// Sort sessions: Running > Paused > Others, then by lastModifiedAt (newest first)
|
||||
const sortedSessions = [...sessions].sort((a, b) => {
|
||||
const aTask = aliveTasks.find((task) => task.sessionId === a.id);
|
||||
const bTask = aliveTasks.find((task) => task.sessionId === b.id);
|
||||
const aProcess = sessionProcesses.find(
|
||||
(process) => process.sessionId === a.id,
|
||||
);
|
||||
const bProcess = sessionProcesses.find(
|
||||
(process) => process.sessionId === b.id,
|
||||
);
|
||||
|
||||
const aStatus = aTask?.status;
|
||||
const bStatus = bTask?.status;
|
||||
const aStatus = aProcess?.status;
|
||||
const bStatus = bProcess?.status;
|
||||
|
||||
// Define priority: running = 0, paused = 1, others = 2
|
||||
const getPriority = (status: string | undefined) => {
|
||||
const getPriority = (status: "paused" | "running" | undefined) => {
|
||||
if (status === "running") return 0;
|
||||
if (status === "paused") return 1;
|
||||
return 2;
|
||||
@@ -86,11 +90,11 @@ export const SessionsTab: FC<{
|
||||
? firstCommandToTitle(session.meta.firstCommand)
|
||||
: session.id;
|
||||
|
||||
const aliveTask = aliveTasks.find(
|
||||
const sessionProcess = sessionProcesses.find(
|
||||
(task) => task.sessionId === session.id,
|
||||
);
|
||||
const isRunning = aliveTask?.status === "running";
|
||||
const isPaused = aliveTask?.status === "paused";
|
||||
const isRunning = sessionProcess?.status === "running";
|
||||
const isPaused = sessionProcess?.status === "paused";
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { aliveTasksQuery } from "../../../../../../lib/api/queries";
|
||||
import { aliveTasksAtom } from "../store/aliveTasksAtom";
|
||||
|
||||
export const useAliveTask = (sessionId: string) => {
|
||||
const [aliveTasks, setAliveTasks] = useAtom(aliveTasksAtom);
|
||||
|
||||
useQuery({
|
||||
queryKey: aliveTasksQuery.queryKey,
|
||||
queryFn: async () => {
|
||||
const { aliveTasks } = await aliveTasksQuery.queryFn();
|
||||
setAliveTasks(aliveTasks);
|
||||
return aliveTasks;
|
||||
},
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const taskInfo = useMemo(() => {
|
||||
const aliveTask = aliveTasks.find((task) => task.sessionId === sessionId);
|
||||
|
||||
return {
|
||||
aliveTask: aliveTasks.find((task) => task.sessionId === sessionId),
|
||||
isRunningTask: aliveTask?.status === "running",
|
||||
isPausedTask: aliveTask?.status === "paused",
|
||||
} as const;
|
||||
}, [aliveTasks, sessionId]);
|
||||
|
||||
return taskInfo;
|
||||
};
|
||||
@@ -3,9 +3,13 @@ import { useSessionQuery } from "./useSessionQuery";
|
||||
|
||||
export const useSession = (projectId: string, sessionId: string) => {
|
||||
const query = useSessionQuery(projectId, sessionId);
|
||||
const session = query.data?.session;
|
||||
if (session === undefined || session === null) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
const toolResultMap = useMemo(() => {
|
||||
const entries = query.data.session.conversations.flatMap((conversation) => {
|
||||
const entries = session.conversations.flatMap((conversation) => {
|
||||
if (conversation.type !== "user") {
|
||||
return [];
|
||||
}
|
||||
@@ -28,7 +32,7 @@ export const useSession = (projectId: string, sessionId: string) => {
|
||||
});
|
||||
|
||||
return new Map(entries);
|
||||
}, [query.data.session.conversations]);
|
||||
}, [session.conversations]);
|
||||
|
||||
const getToolResult = useCallback(
|
||||
(toolUseId: string) => {
|
||||
@@ -38,8 +42,8 @@ export const useSession = (projectId: string, sessionId: string) => {
|
||||
);
|
||||
|
||||
return {
|
||||
session: query.data.session,
|
||||
conversations: query.data.session.conversations,
|
||||
session,
|
||||
conversations: session.conversations,
|
||||
getToolResult,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { sessionProcessesAtom } from "../store/sessionProcessesAtom";
|
||||
|
||||
export const useSessionProcess = () => {
|
||||
const sessionProcesses = useAtomValue(sessionProcessesAtom);
|
||||
|
||||
const getSessionProcess = useCallback(
|
||||
(sessionId: string) => {
|
||||
const targetProcess = sessionProcesses.find(
|
||||
(process) => process.sessionId === sessionId,
|
||||
);
|
||||
|
||||
return targetProcess;
|
||||
},
|
||||
[sessionProcesses],
|
||||
);
|
||||
|
||||
return {
|
||||
sessionProcesses,
|
||||
getSessionProcess,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
import { atom } from "jotai";
|
||||
import type { SerializableAliveTask } from "../../../../../../server/service/claude-code/types";
|
||||
|
||||
export const aliveTasksAtom = atom<SerializableAliveTask[]>([]);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
import type { PublicSessionProcess } from "../../../../../../types/session-process";
|
||||
|
||||
export const sessionProcessesAtom = atom<PublicSessionProcess[]>([]);
|
||||
@@ -25,7 +25,7 @@ export const usePermissionRequests = () => {
|
||||
const handlePermissionResponse = useCallback(
|
||||
async (response: PermissionResponse) => {
|
||||
try {
|
||||
const apiResponse = await honoClient.api.tasks[
|
||||
const apiResponse = await honoClient.api.cc[
|
||||
"permission-response"
|
||||
].$post({
|
||||
json: response,
|
||||
|
||||
@@ -74,10 +74,10 @@ export const claudeCommandsQuery = (projectId: string) =>
|
||||
},
|
||||
}) as const;
|
||||
|
||||
export const aliveTasksQuery = {
|
||||
queryKey: ["aliveTasks"],
|
||||
export const sessionProcessesQuery = {
|
||||
queryKey: ["sessionProcesses"],
|
||||
queryFn: async () => {
|
||||
const response = await honoClient.api.tasks.alive.$get({});
|
||||
const response = await honoClient.api.cc["session-processes"].$get({});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch alive tasks: ${response.statusText}`);
|
||||
@@ -123,10 +123,15 @@ export const gitCommitsQuery = (projectId: string) =>
|
||||
},
|
||||
}) as const;
|
||||
|
||||
export const mcpListQuery = {
|
||||
queryKey: ["mcp", "list"],
|
||||
export const mcpListQuery = (projectId: string) =>
|
||||
({
|
||||
queryKey: ["mcp", "list", projectId],
|
||||
queryFn: async () => {
|
||||
const response = await honoClient.api.mcp.list.$get();
|
||||
const response = await honoClient.api.projects[
|
||||
":projectId"
|
||||
].mcp.list.$get({
|
||||
param: { projectId },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch MCP list: ${response.statusText}`);
|
||||
@@ -134,7 +139,7 @@ export const mcpListQuery = {
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
} as const;
|
||||
}) as const;
|
||||
|
||||
export const fileCompletionQuery = (projectId: string, basePath: string) =>
|
||||
({
|
||||
@@ -151,7 +156,7 @@ export const fileCompletionQuery = (projectId: string, basePath: string) =>
|
||||
throw new Error("Failed to fetch file completion");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return await response.json();
|
||||
},
|
||||
}) as const;
|
||||
|
||||
|
||||
25
src/lib/controllablePromise.ts
Normal file
25
src/lib/controllablePromise.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type ControllablePromise<T> = {
|
||||
readonly promise: Promise<T>;
|
||||
readonly resolve: (value: T) => void;
|
||||
readonly reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
export const controllablePromise = <T>(): ControllablePromise<T> => {
|
||||
let promiseResolve: ((value: T) => void) | undefined;
|
||||
let promiseReject: ((reason?: unknown) => void) | undefined;
|
||||
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
promiseResolve = resolve;
|
||||
promiseReject = reject;
|
||||
});
|
||||
|
||||
if (!promiseResolve || !promiseReject) {
|
||||
throw new Error("Illegal state: Promise not created");
|
||||
}
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve: promiseResolve,
|
||||
reject: promiseReject,
|
||||
} as const;
|
||||
};
|
||||
@@ -9,3 +9,5 @@ export const UserEntrySchema = BaseEntrySchema.extend({
|
||||
// required
|
||||
message: UserMessageSchema,
|
||||
});
|
||||
|
||||
export type UserEntry = z.infer<typeof UserEntrySchema>;
|
||||
|
||||
362
src/server/hono/initialize.test.ts
Normal file
362
src/server/hono/initialize.test.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { Effect, Layer, Ref } from "effect";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EventBus } from "../service/events/EventBus";
|
||||
import { FileWatcherService } from "../service/events/fileWatcher";
|
||||
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
|
||||
import { ProjectMetaService } from "../service/project/ProjectMetaService";
|
||||
import { ProjectRepository } from "../service/project/ProjectRepository";
|
||||
import { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase";
|
||||
import { SessionMetaService } from "../service/session/SessionMetaService";
|
||||
import { SessionRepository } from "../service/session/SessionRepository";
|
||||
import { InitializeService } from "./initialize";
|
||||
|
||||
describe("InitializeService", () => {
|
||||
const createMockProjectRepository = (
|
||||
projects: Array<{
|
||||
id: string;
|
||||
claudeProjectPath: string;
|
||||
lastModifiedAt: Date;
|
||||
meta: {
|
||||
projectName: string | null;
|
||||
projectPath: string | null;
|
||||
sessionCount: number;
|
||||
};
|
||||
}> = [],
|
||||
) =>
|
||||
Layer.succeed(ProjectRepository, {
|
||||
getProjects: () => Effect.succeed({ projects }),
|
||||
getProject: () => Effect.fail(new Error("Not implemented in mock")),
|
||||
});
|
||||
|
||||
const createMockSessionRepository = (
|
||||
sessions: Array<{
|
||||
id: string;
|
||||
jsonlFilePath: string;
|
||||
lastModifiedAt: Date;
|
||||
meta: {
|
||||
messageCount: number;
|
||||
firstCommand: {
|
||||
kind: "command";
|
||||
commandName: string;
|
||||
commandArgs?: string;
|
||||
commandMessage?: string;
|
||||
} | null;
|
||||
};
|
||||
}> = [],
|
||||
getSessionsCb?: (projectId: string) => void,
|
||||
) =>
|
||||
Layer.succeed(SessionRepository, {
|
||||
getSessions: (projectId: string) => {
|
||||
if (getSessionsCb) getSessionsCb(projectId);
|
||||
return Effect.succeed({ sessions });
|
||||
},
|
||||
getSession: () => Effect.fail(new Error("Not implemented in mock")),
|
||||
});
|
||||
|
||||
const createMockProjectMetaService = () =>
|
||||
Layer.succeed(ProjectMetaService, {
|
||||
getProjectMeta: () =>
|
||||
Effect.succeed({
|
||||
projectName: "Test Project",
|
||||
projectPath: "/path/to/project",
|
||||
sessionCount: 0,
|
||||
}),
|
||||
invalidateProject: () => Effect.void,
|
||||
});
|
||||
|
||||
const createMockSessionMetaService = () =>
|
||||
Layer.succeed(SessionMetaService, {
|
||||
getSessionMeta: () =>
|
||||
Effect.succeed({
|
||||
messageCount: 0,
|
||||
firstCommand: null,
|
||||
}),
|
||||
invalidateSession: () => Effect.void,
|
||||
});
|
||||
|
||||
const createTestLayer = (
|
||||
mockProjectRepositoryLayer: Layer.Layer<
|
||||
ProjectRepository,
|
||||
never,
|
||||
never
|
||||
> = createMockProjectRepository(),
|
||||
mockSessionRepositoryLayer: Layer.Layer<
|
||||
SessionRepository,
|
||||
never,
|
||||
never
|
||||
> = createMockSessionRepository(),
|
||||
) => {
|
||||
// Provide EventBus first since FileWatcherService depends on it
|
||||
const fileWatcherWithEventBus = FileWatcherService.Live.pipe(
|
||||
Layer.provide(EventBus.Live),
|
||||
);
|
||||
|
||||
// Merge all dependencies
|
||||
const allDependencies = Layer.mergeAll(
|
||||
EventBus.Live,
|
||||
fileWatcherWithEventBus,
|
||||
mockProjectRepositoryLayer,
|
||||
mockSessionRepositoryLayer,
|
||||
createMockProjectMetaService(),
|
||||
createMockSessionMetaService(),
|
||||
VirtualConversationDatabase.Live,
|
||||
);
|
||||
|
||||
// Provide dependencies to InitializeService.Live and expose all services
|
||||
return Layer.provide(InitializeService.Live, allDependencies).pipe(
|
||||
Layer.merge(allDependencies),
|
||||
);
|
||||
};
|
||||
|
||||
describe("basic initialization process", () => {
|
||||
it("service initialization succeeds", async () => {
|
||||
const mockProjectRepositoryLayer = createMockProjectRepository([
|
||||
{
|
||||
id: "project-1",
|
||||
claudeProjectPath: "/path/to/project-1",
|
||||
lastModifiedAt: new Date(),
|
||||
meta: {
|
||||
projectName: "Project 1",
|
||||
projectPath: "/path/to/project-1",
|
||||
sessionCount: 2,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const mockSessionRepositoryLayer = createMockSessionRepository([
|
||||
{
|
||||
id: "session-1",
|
||||
jsonlFilePath: "/path/to/session-1.jsonl",
|
||||
lastModifiedAt: new Date(),
|
||||
meta: {
|
||||
messageCount: 5,
|
||||
firstCommand: {
|
||||
kind: "command",
|
||||
commandName: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "session-2",
|
||||
jsonlFilePath: "/path/to/session-2.jsonl",
|
||||
lastModifiedAt: new Date(),
|
||||
meta: {
|
||||
messageCount: 3,
|
||||
firstCommand: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const initialize = yield* InitializeService;
|
||||
return yield* initialize.startInitialization();
|
||||
});
|
||||
|
||||
const testLayer = createTestLayer(
|
||||
mockProjectRepositoryLayer,
|
||||
mockSessionRepositoryLayer,
|
||||
);
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(testLayer)),
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("file watcher is started", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const initialize = yield* InitializeService;
|
||||
|
||||
yield* initialize.startInitialization();
|
||||
|
||||
// Verify file watcher is started
|
||||
// (In actual implementation, verify that startWatching is called)
|
||||
return "file watcher started";
|
||||
});
|
||||
|
||||
const testLayer = createTestLayer();
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(testLayer)),
|
||||
);
|
||||
|
||||
expect(result).toBe("file watcher started");
|
||||
});
|
||||
});
|
||||
|
||||
describe("event processing", () => {
|
||||
it("receives sessionChanged event", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const initialize = yield* InitializeService;
|
||||
const eventBus = yield* EventBus;
|
||||
const eventsRef = yield* Ref.make<
|
||||
Array<InternalEventDeclaration["sessionChanged"]>
|
||||
>([]);
|
||||
|
||||
// Set up listener for sessionChanged event
|
||||
yield* eventBus.on("sessionChanged", (event) => {
|
||||
Effect.runSync(Ref.update(eventsRef, (events) => [...events, event]));
|
||||
});
|
||||
|
||||
yield* initialize.startInitialization();
|
||||
|
||||
// Emit event
|
||||
yield* eventBus.emit("sessionChanged", {
|
||||
projectId: "project-1",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
// Wait a bit for event to be processed
|
||||
yield* Effect.sleep("50 millis");
|
||||
|
||||
const events = yield* Ref.get(eventsRef);
|
||||
return events;
|
||||
});
|
||||
|
||||
const testLayer = createTestLayer();
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(testLayer)),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
projectId: "project-1",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("heartbeat event is emitted periodically", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const initialize = yield* InitializeService;
|
||||
const eventBus = yield* EventBus;
|
||||
const heartbeatCountRef = yield* Ref.make(0);
|
||||
|
||||
// Set up listener for heartbeat event
|
||||
yield* eventBus.on("heartbeat", () =>
|
||||
Effect.runSync(Ref.update(heartbeatCountRef, (count) => count + 1)),
|
||||
);
|
||||
|
||||
yield* initialize.startInitialization();
|
||||
|
||||
// Wait a bit to verify heartbeat is emitted
|
||||
// (In actual tests, should use mock to shorten time)
|
||||
yield* Effect.sleep("100 millis");
|
||||
|
||||
const count = yield* Ref.get(heartbeatCountRef);
|
||||
return count;
|
||||
});
|
||||
|
||||
const testLayer = createTestLayer();
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(testLayer)),
|
||||
);
|
||||
|
||||
// heartbeat is emitted immediately once first, then every 10 seconds
|
||||
// At 100ms, only the first one is emitted
|
||||
expect(result).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache initialization", () => {
|
||||
it("project and session caches are initialized", async () => {
|
||||
const getProjectsCalled = vi.fn();
|
||||
const getSessionsCalled = vi.fn();
|
||||
|
||||
const mockProjectRepositoryLayer = Layer.succeed(ProjectRepository, {
|
||||
getProjects: () => {
|
||||
getProjectsCalled();
|
||||
return Effect.succeed({
|
||||
projects: [
|
||||
{
|
||||
id: "project-1",
|
||||
claudeProjectPath: "/path/to/project-1",
|
||||
lastModifiedAt: new Date(),
|
||||
meta: {
|
||||
projectName: "Project 1",
|
||||
projectPath: "/path/to/project-1",
|
||||
sessionCount: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
getProject: () => Effect.fail(new Error("Not implemented in mock")),
|
||||
});
|
||||
|
||||
const mockSessionRepositoryLayer = createMockSessionRepository(
|
||||
[
|
||||
{
|
||||
id: "session-1",
|
||||
jsonlFilePath: "/path/to/session-1.jsonl",
|
||||
lastModifiedAt: new Date(),
|
||||
meta: {
|
||||
messageCount: 5,
|
||||
firstCommand: {
|
||||
kind: "command",
|
||||
commandName: "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
getSessionsCalled,
|
||||
);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const initialize = yield* InitializeService;
|
||||
yield* initialize.startInitialization();
|
||||
});
|
||||
|
||||
const testLayer = createTestLayer(
|
||||
mockProjectRepositoryLayer,
|
||||
mockSessionRepositoryLayer,
|
||||
);
|
||||
|
||||
await Effect.runPromise(program.pipe(Effect.provide(testLayer)));
|
||||
|
||||
expect(getProjectsCalled).toHaveBeenCalledTimes(1);
|
||||
expect(getSessionsCalled).toHaveBeenCalledTimes(1);
|
||||
expect(getSessionsCalled).toHaveBeenCalledWith("project-1");
|
||||
});
|
||||
|
||||
it("doesn't throw error even if cache initialization fails", async () => {
|
||||
const mockProjectRepositoryLayer = Layer.succeed(ProjectRepository, {
|
||||
getProjects: () => Effect.fail(new Error("Failed to get projects")),
|
||||
getProject: () => Effect.fail(new Error("Not implemented in mock")),
|
||||
});
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const initialize = yield* InitializeService;
|
||||
return yield* initialize.startInitialization();
|
||||
});
|
||||
|
||||
const testLayer = createTestLayer(mockProjectRepositoryLayer);
|
||||
|
||||
// Completes without throwing error
|
||||
await expect(
|
||||
Effect.runPromise(program.pipe(Effect.provide(testLayer))),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("resources are cleaned up with stopCleanup", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const initialize = yield* InitializeService;
|
||||
yield* initialize.startInitialization();
|
||||
yield* initialize.stopCleanup();
|
||||
return "cleaned up";
|
||||
});
|
||||
|
||||
const testLayer = createTestLayer();
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(testLayer)),
|
||||
);
|
||||
|
||||
expect(result).toBe("cleaned up");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,144 @@
|
||||
import prexit from "prexit";
|
||||
import { claudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
|
||||
import { eventBus } from "../service/events/EventBus";
|
||||
import { fileWatcher } from "../service/events/fileWatcher";
|
||||
import { Context, Effect, Layer, Ref, Schedule } from "effect";
|
||||
import { EventBus } from "../service/events/EventBus";
|
||||
import { FileWatcherService } from "../service/events/fileWatcher";
|
||||
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
|
||||
import type { ProjectRepository } from "../service/project/ProjectRepository";
|
||||
import { projectMetaStorage } from "../service/project/projectMetaStorage";
|
||||
import type { SessionRepository } from "../service/session/SessionRepository";
|
||||
import { sessionMetaStorage } from "../service/session/sessionMetaStorage";
|
||||
import { ProjectMetaService } from "../service/project/ProjectMetaService";
|
||||
import { ProjectRepository } from "../service/project/ProjectRepository";
|
||||
import { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase";
|
||||
import { SessionMetaService } from "../service/session/SessionMetaService";
|
||||
import { SessionRepository } from "../service/session/SessionRepository";
|
||||
|
||||
export const initialize = async (deps: {
|
||||
sessionRepository: SessionRepository;
|
||||
projectRepository: ProjectRepository;
|
||||
}): Promise<void> => {
|
||||
fileWatcher.startWatching();
|
||||
interface InitializeServiceInterface {
|
||||
readonly startInitialization: () => Effect.Effect<void>;
|
||||
readonly stopCleanup: () => Effect.Effect<void>;
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
eventBus.emit("heartbeat", {});
|
||||
}, 10 * 1000);
|
||||
export class InitializeService extends Context.Tag("InitializeService")<
|
||||
InitializeService,
|
||||
InitializeServiceInterface
|
||||
>() {
|
||||
static Live = Layer.effect(
|
||||
this,
|
||||
Effect.gen(function* () {
|
||||
const eventBus = yield* EventBus;
|
||||
const fileWatcher = yield* FileWatcherService;
|
||||
const projectRepository = yield* ProjectRepository;
|
||||
const sessionRepository = yield* SessionRepository;
|
||||
const projectMetaService = yield* ProjectMetaService;
|
||||
const sessionMetaService = yield* SessionMetaService;
|
||||
const virtualConversationDatabase = yield* VirtualConversationDatabase;
|
||||
|
||||
// 状態管理用の Ref
|
||||
const listenersRef = yield* Ref.make<{
|
||||
sessionProcessChanged?:
|
||||
| ((event: InternalEventDeclaration["sessionProcessChanged"]) => void)
|
||||
| null;
|
||||
sessionChanged?:
|
||||
| ((event: InternalEventDeclaration["sessionChanged"]) => void)
|
||||
| null;
|
||||
}>({});
|
||||
|
||||
const startInitialization = (): Effect.Effect<void> => {
|
||||
return Effect.gen(function* () {
|
||||
// ファイルウォッチャーを開始
|
||||
yield* fileWatcher.startWatching();
|
||||
|
||||
// ハートビートを定期的に送信
|
||||
const daemon = Effect.repeat(
|
||||
eventBus.emit("heartbeat", {}),
|
||||
Schedule.fixed("10 seconds"),
|
||||
);
|
||||
|
||||
console.log("start heartbeat");
|
||||
yield* Effect.forkDaemon(daemon);
|
||||
console.log("after starting heartbeat fork");
|
||||
|
||||
// sessionChanged イベントのリスナーを登録
|
||||
const onSessionChanged = (
|
||||
event: InternalEventDeclaration["sessionChanged"],
|
||||
) => {
|
||||
projectMetaStorage.invalidateProject(event.projectId);
|
||||
sessionMetaStorage.invalidateSession(event.projectId, event.sessionId);
|
||||
Effect.runFork(
|
||||
projectMetaService.invalidateProject(event.projectId),
|
||||
);
|
||||
|
||||
Effect.runFork(
|
||||
sessionMetaService.invalidateSession(
|
||||
event.projectId,
|
||||
event.sessionId,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
eventBus.on("sessionChanged", onSessionChanged);
|
||||
const onSessionProcessChanged = (
|
||||
event: InternalEventDeclaration["sessionProcessChanged"],
|
||||
) => {
|
||||
if (
|
||||
(event.changed.type === "completed" ||
|
||||
event.changed.type === "paused") &&
|
||||
event.changed.sessionId !== undefined
|
||||
) {
|
||||
Effect.runFork(
|
||||
virtualConversationDatabase.deleteVirtualConversations(
|
||||
event.changed.sessionId,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
yield* Ref.set(listenersRef, {
|
||||
sessionChanged: onSessionChanged,
|
||||
sessionProcessChanged: onSessionProcessChanged,
|
||||
});
|
||||
yield* eventBus.on("sessionChanged", onSessionChanged);
|
||||
yield* eventBus.on("sessionProcessChanged", onSessionProcessChanged);
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
console.log("Initializing projects cache");
|
||||
const { projects } = await deps.projectRepository.getProjects();
|
||||
const { projects } = yield* projectRepository.getProjects();
|
||||
console.log(`${projects.length} projects cache initialized`);
|
||||
|
||||
console.log("Initializing sessions cache");
|
||||
const results = await Promise.all(
|
||||
projects.map((project) => deps.sessionRepository.getSessions(project.id)),
|
||||
const results = yield* Effect.all(
|
||||
projects.map((project) =>
|
||||
sessionRepository.getSessions(project.id),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
);
|
||||
console.log(
|
||||
`${results.reduce(
|
||||
const totalSessions = results.reduce(
|
||||
(s, { sessions }) => s + sessions.length,
|
||||
0,
|
||||
)} sessions cache initialized`,
|
||||
);
|
||||
} catch {
|
||||
// do nothing
|
||||
console.log(`${totalSessions} sessions cache initialized`);
|
||||
}).pipe(
|
||||
Effect.catchAll(() => Effect.void),
|
||||
Effect.withSpan("initialize-cache"),
|
||||
);
|
||||
}).pipe(Effect.withSpan("start-initialization")) as Effect.Effect<void>;
|
||||
};
|
||||
|
||||
const stopCleanup = (): Effect.Effect<void> =>
|
||||
Effect.gen(function* () {
|
||||
const listeners = yield* Ref.get(listenersRef);
|
||||
if (listeners.sessionChanged) {
|
||||
yield* eventBus.off("sessionChanged", listeners.sessionChanged);
|
||||
}
|
||||
|
||||
prexit(() => {
|
||||
clearInterval(intervalId);
|
||||
eventBus.off("sessionChanged", onSessionChanged);
|
||||
fileWatcher.stop();
|
||||
claudeCodeTaskController.abortAllTasks();
|
||||
if (listeners.sessionProcessChanged) {
|
||||
yield* eventBus.off(
|
||||
"sessionProcessChanged",
|
||||
listeners.sessionProcessChanged,
|
||||
);
|
||||
}
|
||||
|
||||
yield* Ref.set(listenersRef, {});
|
||||
yield* fileWatcher.stop();
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
startInitialization,
|
||||
stopCleanup,
|
||||
} satisfies InitializeServiceInterface;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,50 +1,67 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import type { CommandExecutor, FileSystem, Path } from "@effect/platform";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Effect, Runtime } from "effect";
|
||||
import { setCookie } from "hono/cookie";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import prexit from "prexit";
|
||||
import { z } from "zod";
|
||||
import { type Config, configSchema } from "../config/config";
|
||||
import type { PublicSessionProcess } from "../../types/session-process";
|
||||
import { configSchema } from "../config/config";
|
||||
import { env } from "../lib/env";
|
||||
import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
|
||||
import type { SerializableAliveTask } from "../service/claude-code/types";
|
||||
import { ClaudeCodeLifeCycleService } from "../service/claude-code/ClaudeCodeLifeCycleService";
|
||||
import { ClaudeCodePermissionService } from "../service/claude-code/ClaudeCodePermissionService";
|
||||
import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE";
|
||||
import { eventBus } from "../service/events/EventBus";
|
||||
import { EventBus } from "../service/events/EventBus";
|
||||
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
|
||||
import { writeTypeSafeSSE } from "../service/events/typeSafeSSE";
|
||||
import { TypeSafeSSE } from "../service/events/typeSafeSSE";
|
||||
import { getFileCompletion } from "../service/file-completion/getFileCompletion";
|
||||
import { getBranches } from "../service/git/getBranches";
|
||||
import { getCommits } from "../service/git/getCommits";
|
||||
import { getDiff } from "../service/git/getDiff";
|
||||
import { getMcpList } from "../service/mcp/getMcpList";
|
||||
import { claudeCommandsDirPath } from "../service/paths";
|
||||
import type { ProjectMetaService } from "../service/project/ProjectMetaService";
|
||||
import { ProjectRepository } from "../service/project/ProjectRepository";
|
||||
import type { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase";
|
||||
import type { SessionMetaService } from "../service/session/SessionMetaService";
|
||||
import { SessionRepository } from "../service/session/SessionRepository";
|
||||
import type { HonoAppType } from "./app";
|
||||
import { initialize } from "./initialize";
|
||||
import { InitializeService } from "./initialize";
|
||||
import { configMiddleware } from "./middleware/config.middleware";
|
||||
|
||||
export const routes = async (app: HonoAppType) => {
|
||||
const sessionRepository = new SessionRepository();
|
||||
const projectRepository = new ProjectRepository();
|
||||
export const routes = (app: HonoAppType) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionRepository = yield* SessionRepository;
|
||||
const projectRepository = yield* ProjectRepository;
|
||||
const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService;
|
||||
const claudeCodePermissionService = yield* ClaudeCodePermissionService;
|
||||
const initializeService = yield* InitializeService;
|
||||
const eventBus = yield* EventBus;
|
||||
|
||||
const fileWatcher = getFileWatcher();
|
||||
const eventBus = getEventBus();
|
||||
const runtime = yield* Effect.runtime<
|
||||
| ProjectMetaService
|
||||
| SessionMetaService
|
||||
| VirtualConversationDatabase
|
||||
| FileSystem.FileSystem
|
||||
| Path.Path
|
||||
| CommandExecutor.CommandExecutor
|
||||
>();
|
||||
|
||||
if (env.get("NEXT_PHASE") !== "phase-production-build") {
|
||||
fileWatcher.startWatching();
|
||||
yield* initializeService.startInitialization();
|
||||
|
||||
setInterval(() => {
|
||||
eventBus.emit("heartbeat", {});
|
||||
}, 10 * 1000);
|
||||
prexit(async () => {
|
||||
await Runtime.runPromise(runtime)(initializeService.stopCleanup());
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
app
|
||||
// middleware
|
||||
.use(configMiddleware)
|
||||
.use(async (c, next) => {
|
||||
claudeCodeTaskController.updateConfig(c.get("config"));
|
||||
.use(async (_c, next) => {
|
||||
await next();
|
||||
})
|
||||
|
||||
@@ -66,7 +83,12 @@ export const routes = async (app: HonoAppType) => {
|
||||
})
|
||||
|
||||
.get("/projects", async (c) => {
|
||||
const { projects } = await projectRepository.getProjects();
|
||||
const program = Effect.gen(function* () {
|
||||
return yield* projectRepository.getProjects();
|
||||
});
|
||||
|
||||
const { projects } = await Runtime.runPromise(runtime)(program);
|
||||
|
||||
return c.json({ projects });
|
||||
})
|
||||
|
||||
@@ -76,23 +98,27 @@ export const routes = async (app: HonoAppType) => {
|
||||
async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { cursor } = c.req.valid("query");
|
||||
const config = c.get("config");
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const { project } =
|
||||
yield* projectRepository.getProject(projectId);
|
||||
const { sessions } = yield* sessionRepository.getSessions(
|
||||
projectId,
|
||||
{ cursor },
|
||||
);
|
||||
|
||||
const [{ project }, { sessions, nextCursor }] = await Promise.all([
|
||||
projectRepository.getProject(projectId),
|
||||
sessionRepository
|
||||
.getSessions(projectId, { cursor })
|
||||
.then(({ sessions }) => {
|
||||
let filteredSessions = sessions;
|
||||
|
||||
// Filter sessions based on hideNoUserMessageSession setting
|
||||
if (c.get("config").hideNoUserMessageSession) {
|
||||
if (config.hideNoUserMessageSession) {
|
||||
filteredSessions = filteredSessions.filter((session) => {
|
||||
return session.meta.firstCommand !== null;
|
||||
});
|
||||
}
|
||||
|
||||
// Unify sessions with same title if unifySameTitleSession is enabled
|
||||
if (c.get("config").unifySameTitleSession) {
|
||||
if (config.unifySameTitleSession) {
|
||||
const sessionMap = new Map<
|
||||
string,
|
||||
(typeof filteredSessions)[0]
|
||||
@@ -127,8 +153,7 @@ export const routes = async (app: HonoAppType) => {
|
||||
existingSession.lastModifiedAt
|
||||
) {
|
||||
if (
|
||||
session.lastModifiedAt >
|
||||
existingSession.lastModifiedAt
|
||||
session.lastModifiedAt > existingSession.lastModifiedAt
|
||||
) {
|
||||
sessionMap.set(title, session);
|
||||
}
|
||||
@@ -148,23 +173,30 @@ export const routes = async (app: HonoAppType) => {
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
sessions: filteredSessions,
|
||||
nextCursor: sessions.at(-1)?.id,
|
||||
};
|
||||
}),
|
||||
] as const);
|
||||
});
|
||||
|
||||
return c.json({ project, sessions, nextCursor });
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
return c.json(result);
|
||||
},
|
||||
)
|
||||
|
||||
.get("/projects/:projectId/sessions/:sessionId", async (c) => {
|
||||
const { projectId, sessionId } = c.req.param();
|
||||
const { session } = await sessionRepository.getSession(
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const { session } = yield* sessionRepository.getSession(
|
||||
projectId,
|
||||
sessionId,
|
||||
);
|
||||
return c.json({ session });
|
||||
return { session };
|
||||
});
|
||||
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
return c.json(result);
|
||||
})
|
||||
|
||||
.get(
|
||||
@@ -179,30 +211,51 @@ export const routes = async (app: HonoAppType) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { basePath } = c.req.valid("query");
|
||||
|
||||
const { project } = await projectRepository.getProject(projectId);
|
||||
const program = Effect.gen(function* () {
|
||||
const { project } =
|
||||
yield* projectRepository.getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
return {
|
||||
error: "Project path not found",
|
||||
status: 400 as const,
|
||||
};
|
||||
}
|
||||
|
||||
const projectPath = project.meta.projectPath;
|
||||
|
||||
try {
|
||||
const result = await getFileCompletion(
|
||||
project.meta.projectPath,
|
||||
basePath,
|
||||
const result = yield* Effect.promise(() =>
|
||||
getFileCompletion(projectPath, basePath),
|
||||
);
|
||||
return c.json(result);
|
||||
return { data: result, status: 200 as const };
|
||||
} catch (error) {
|
||||
console.error("File completion error:", error);
|
||||
return c.json({ error: "Failed to get file completion" }, 500);
|
||||
return {
|
||||
error: "Failed to get file completion",
|
||||
status: 500 as const,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
|
||||
if (result.status === 200) {
|
||||
return c.json(result.data);
|
||||
}
|
||||
return c.json({ error: result.error }, result.status);
|
||||
},
|
||||
)
|
||||
|
||||
.get("/projects/:projectId/claude-commands", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { project } = await projectRepository.getProject(projectId);
|
||||
|
||||
const [globalCommands, projectCommands] = await Promise.allSettled([
|
||||
const program = Effect.gen(function* () {
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
|
||||
const [globalCommands, projectCommands] = yield* Effect.promise(
|
||||
() =>
|
||||
Promise.allSettled([
|
||||
readdir(claudeCommandsDirPath, {
|
||||
withFileTypes: true,
|
||||
}).then((dirents) =>
|
||||
@@ -212,7 +265,11 @@ export const routes = async (app: HonoAppType) => {
|
||||
),
|
||||
project.meta.projectPath !== null
|
||||
? readdir(
|
||||
resolve(project.meta.projectPath, ".claude", "commands"),
|
||||
resolve(
|
||||
project.meta.projectPath,
|
||||
".claude",
|
||||
"commands",
|
||||
),
|
||||
{
|
||||
withFileTypes: true,
|
||||
},
|
||||
@@ -222,55 +279,91 @@ export const routes = async (app: HonoAppType) => {
|
||||
.map((d) => d.name.replace(/\.md$/, "")),
|
||||
)
|
||||
: [],
|
||||
]);
|
||||
]),
|
||||
);
|
||||
|
||||
return c.json({
|
||||
return {
|
||||
globalCommands:
|
||||
globalCommands.status === "fulfilled" ? globalCommands.value : [],
|
||||
globalCommands.status === "fulfilled"
|
||||
? globalCommands.value
|
||||
: [],
|
||||
projectCommands:
|
||||
projectCommands.status === "fulfilled" ? projectCommands.value : [],
|
||||
projectCommands.status === "fulfilled"
|
||||
? projectCommands.value
|
||||
: [],
|
||||
defaultCommands: ["init", "compact"],
|
||||
};
|
||||
});
|
||||
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
return c.json(result);
|
||||
})
|
||||
|
||||
.get("/projects/:projectId/git/branches", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { project } = await projectRepository.getProject(projectId);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
return { error: "Project path not found", status: 400 as const };
|
||||
}
|
||||
|
||||
const projectPath = project.meta.projectPath;
|
||||
|
||||
try {
|
||||
const result = await getBranches(project.meta.projectPath);
|
||||
return c.json(result);
|
||||
const result = yield* Effect.promise(() =>
|
||||
getBranches(projectPath),
|
||||
);
|
||||
return { data: result, status: 200 as const };
|
||||
} catch (error) {
|
||||
console.error("Get branches error:", error);
|
||||
if (error instanceof Error) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
return { error: error.message, status: 400 as const };
|
||||
}
|
||||
return c.json({ error: "Failed to get branches" }, 500);
|
||||
return { error: "Failed to get branches", status: 500 as const };
|
||||
}
|
||||
});
|
||||
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
if (result.status === 200) {
|
||||
return c.json(result.data);
|
||||
}
|
||||
|
||||
return c.json({ error: result.error }, result.status);
|
||||
})
|
||||
|
||||
.get("/projects/:projectId/git/commits", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { project } = await projectRepository.getProject(projectId);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
return { error: "Project path not found", status: 400 as const };
|
||||
}
|
||||
|
||||
const projectPath = project.meta.projectPath;
|
||||
|
||||
try {
|
||||
const result = await getCommits(project.meta.projectPath);
|
||||
return c.json(result);
|
||||
const result = yield* Effect.promise(() =>
|
||||
getCommits(projectPath),
|
||||
);
|
||||
return { data: result, status: 200 as const };
|
||||
} catch (error) {
|
||||
console.error("Get commits error:", error);
|
||||
if (error instanceof Error) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
return { error: error.message, status: 400 as const };
|
||||
}
|
||||
return c.json({ error: "Failed to get commits" }, 500);
|
||||
return { error: "Failed to get commits", status: 500 as const };
|
||||
}
|
||||
});
|
||||
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
if (result.status === 200) {
|
||||
return c.json(result.data);
|
||||
}
|
||||
return c.json({ error: result.error }, result.status);
|
||||
})
|
||||
|
||||
.post(
|
||||
@@ -285,123 +378,189 @@ export const routes = async (app: HonoAppType) => {
|
||||
async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { fromRef, toRef } = c.req.valid("json");
|
||||
const { project } = await projectRepository.getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
}
|
||||
const program = Effect.gen(function* () {
|
||||
const { project } =
|
||||
yield* projectRepository.getProject(projectId);
|
||||
|
||||
try {
|
||||
const result = await getDiff(
|
||||
project.meta.projectPath,
|
||||
fromRef,
|
||||
toRef,
|
||||
if (project.meta.projectPath === null) {
|
||||
return {
|
||||
error: "Project path not found",
|
||||
status: 400 as const,
|
||||
};
|
||||
}
|
||||
|
||||
const projectPath = project.meta.projectPath;
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
getDiff(projectPath, fromRef, toRef),
|
||||
);
|
||||
return c.json(result);
|
||||
return { data: result, status: 200 as const };
|
||||
} catch (error) {
|
||||
console.error("Get diff error:", error);
|
||||
if (error instanceof Error) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
return { error: error.message, status: 400 as const };
|
||||
}
|
||||
return c.json({ error: "Failed to get diff" }, 500);
|
||||
return { error: "Failed to get diff", status: 500 as const };
|
||||
}
|
||||
});
|
||||
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
if (result.status === 200) {
|
||||
return c.json(result.data);
|
||||
}
|
||||
return c.json({ error: result.error }, result.status);
|
||||
},
|
||||
)
|
||||
|
||||
.get("/mcp/list", async (c) => {
|
||||
const { servers } = await getMcpList();
|
||||
.get("/projects/:projectId/mcp/list", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { servers } = await getMcpList(projectId);
|
||||
return c.json({ servers });
|
||||
})
|
||||
|
||||
.post(
|
||||
"/projects/:projectId/new-session",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { message } = c.req.valid("json");
|
||||
const { project } = await projectRepository.getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
}
|
||||
|
||||
const task = await claudeCodeTaskController.startOrContinueTask(
|
||||
{
|
||||
projectId,
|
||||
cwd: project.meta.projectPath,
|
||||
},
|
||||
message,
|
||||
.get("/cc/session-processes", async (c) => {
|
||||
const publicProcesses = await Runtime.runPromise(runtime)(
|
||||
claudeCodeLifeCycleService.getPublicSessionProcesses(),
|
||||
);
|
||||
|
||||
return c.json({
|
||||
taskId: task.id,
|
||||
sessionId: task.sessionId,
|
||||
});
|
||||
},
|
||||
)
|
||||
|
||||
.post(
|
||||
"/projects/:projectId/sessions/:sessionId/resume",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
resumeMessage: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { projectId, sessionId } = c.req.param();
|
||||
const { resumeMessage } = c.req.valid("json");
|
||||
const { project } = await projectRepository.getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
}
|
||||
|
||||
const task = await claudeCodeTaskController.startOrContinueTask(
|
||||
{
|
||||
projectId,
|
||||
sessionId,
|
||||
cwd: project.meta.projectPath,
|
||||
},
|
||||
resumeMessage,
|
||||
);
|
||||
|
||||
return c.json({
|
||||
taskId: task.id,
|
||||
sessionId: task.sessionId,
|
||||
});
|
||||
},
|
||||
)
|
||||
|
||||
.get("/tasks/alive", async (c) => {
|
||||
return c.json({
|
||||
aliveTasks: claudeCodeTaskController.aliveTasks.map(
|
||||
(task): SerializableAliveTask => ({
|
||||
id: task.id,
|
||||
status: task.status,
|
||||
sessionId: task.sessionId,
|
||||
processes: publicProcesses.map(
|
||||
(process): PublicSessionProcess => ({
|
||||
id: process.def.sessionProcessId,
|
||||
projectId: process.def.projectId,
|
||||
sessionId: process.sessionId,
|
||||
status: process.type === "paused" ? "paused" : "running",
|
||||
}),
|
||||
),
|
||||
});
|
||||
})
|
||||
|
||||
// new or resume
|
||||
.post(
|
||||
"/tasks/abort",
|
||||
zValidator("json", z.object({ sessionId: z.string() })),
|
||||
"/cc/session-processes",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
message: z.string(),
|
||||
baseSessionId: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { sessionId } = c.req.valid("json");
|
||||
claudeCodeTaskController.abortTask(sessionId);
|
||||
const { projectId, message, baseSessionId } = c.req.valid("json");
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const { project } =
|
||||
yield* projectRepository.getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return {
|
||||
error: "Project path not found",
|
||||
status: 400 as const,
|
||||
};
|
||||
}
|
||||
|
||||
const result = yield* claudeCodeLifeCycleService.startTask({
|
||||
baseSession: {
|
||||
cwd: project.meta.projectPath,
|
||||
projectId,
|
||||
sessionId: baseSessionId,
|
||||
},
|
||||
config: c.get("config"),
|
||||
message,
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
status: 200 as const,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
|
||||
if (result.status === 200) {
|
||||
return c.json({
|
||||
sessionProcess: {
|
||||
id: result.result.sessionProcess.def.sessionProcessId,
|
||||
projectId: result.result.sessionProcess.def.projectId,
|
||||
sessionId: await result.result.awaitSessionInitialized(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ error: result.error }, result.status);
|
||||
},
|
||||
)
|
||||
|
||||
// continue
|
||||
.post(
|
||||
"/cc/session-processes/:sessionProcessId/continue",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
continueMessage: z.string(),
|
||||
baseSessionId: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { sessionProcessId } = c.req.param();
|
||||
const { projectId, continueMessage, baseSessionId } =
|
||||
c.req.valid("json");
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const { project } =
|
||||
yield* projectRepository.getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return {
|
||||
error: "Project path not found",
|
||||
status: 400 as const,
|
||||
};
|
||||
}
|
||||
|
||||
const result = yield* claudeCodeLifeCycleService.continueTask({
|
||||
sessionProcessId,
|
||||
message: continueMessage,
|
||||
baseSessionId,
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
sessionProcess: {
|
||||
id: result.sessionProcess.def.sessionProcessId,
|
||||
projectId: result.sessionProcess.def.projectId,
|
||||
sessionId: baseSessionId,
|
||||
},
|
||||
},
|
||||
status: 200 as const,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
if (result.status === 200) {
|
||||
return c.json(result.data);
|
||||
}
|
||||
|
||||
return c.json({ error: result.error }, result.status);
|
||||
},
|
||||
)
|
||||
|
||||
.post(
|
||||
"/cc/session-processes/:sessionProcessId/abort",
|
||||
zValidator("json", z.object({ projectId: z.string() })),
|
||||
async (c) => {
|
||||
const { sessionProcessId } = c.req.param();
|
||||
void Effect.runFork(
|
||||
claudeCodeLifeCycleService.abortTask(sessionProcessId),
|
||||
);
|
||||
return c.json({ message: "Task aborted" });
|
||||
},
|
||||
)
|
||||
|
||||
.post(
|
||||
"/tasks/permission-response",
|
||||
"/cc/permission-response",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
@@ -411,8 +570,10 @@ export const routes = async (app: HonoAppType) => {
|
||||
),
|
||||
async (c) => {
|
||||
const permissionResponse = c.req.valid("json");
|
||||
claudeCodeTaskController.respondToPermissionRequest(
|
||||
Effect.runFork(
|
||||
claudeCodePermissionService.respondToPermissionRequest(
|
||||
permissionResponse,
|
||||
),
|
||||
);
|
||||
return c.json({ message: "Permission response received" });
|
||||
},
|
||||
@@ -422,57 +583,96 @@ export const routes = async (app: HonoAppType) => {
|
||||
return streamSSE(
|
||||
c,
|
||||
async (rawStream) => {
|
||||
const stream = writeTypeSafeSSE(rawStream);
|
||||
const handleSSE = Effect.gen(function* () {
|
||||
const typeSafeSSE = yield* TypeSafeSSE;
|
||||
|
||||
// Send connect event
|
||||
yield* typeSafeSSE.writeSSE("connect", {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const onHeartbeat = () => {
|
||||
Effect.runFork(
|
||||
typeSafeSSE.writeSSE("heartbeat", {
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onSessionListChanged = (
|
||||
event: InternalEventDeclaration["sessionListChanged"],
|
||||
) => {
|
||||
stream.writeSSE("sessionListChanged", {
|
||||
Effect.runFork(
|
||||
typeSafeSSE.writeSSE("sessionListChanged", {
|
||||
projectId: event.projectId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onSessionChanged = (
|
||||
event: InternalEventDeclaration["sessionChanged"],
|
||||
) => {
|
||||
stream.writeSSE("sessionChanged", {
|
||||
Effect.runFork(
|
||||
typeSafeSSE.writeSSE("sessionChanged", {
|
||||
projectId: event.projectId,
|
||||
sessionId: event.sessionId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onTaskChanged = (
|
||||
event: InternalEventDeclaration["taskChanged"],
|
||||
const onSessionProcessChanged = (
|
||||
event: InternalEventDeclaration["sessionProcessChanged"],
|
||||
) => {
|
||||
stream.writeSSE("taskChanged", {
|
||||
aliveTasks: event.aliveTasks,
|
||||
changed: {
|
||||
status: event.changed.status,
|
||||
sessionId: event.changed.sessionId,
|
||||
projectId: event.changed.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (event.changed.sessionId !== undefined) {
|
||||
stream.writeSSE("sessionChanged", {
|
||||
projectId: event.changed.projectId,
|
||||
sessionId: event.changed.sessionId,
|
||||
});
|
||||
}
|
||||
Effect.runFork(
|
||||
typeSafeSSE.writeSSE("sessionProcessChanged", {
|
||||
processes: event.processes,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
eventBus.on("sessionListChanged", onSessionListChanged);
|
||||
eventBus.on("sessionChanged", onSessionChanged);
|
||||
eventBus.on("taskChanged", onTaskChanged);
|
||||
const { connectionPromise } = adaptInternalEventToSSE(rawStream, {
|
||||
yield* eventBus.on("sessionListChanged", onSessionListChanged);
|
||||
yield* eventBus.on("sessionChanged", onSessionChanged);
|
||||
yield* eventBus.on(
|
||||
"sessionProcessChanged",
|
||||
onSessionProcessChanged,
|
||||
);
|
||||
yield* eventBus.on("heartbeat", onHeartbeat);
|
||||
|
||||
const { connectionPromise } = adaptInternalEventToSSE(
|
||||
rawStream,
|
||||
{
|
||||
timeout: 5 /* min */ * 60 /* sec */ * 1000,
|
||||
cleanUp: () => {
|
||||
eventBus.off("sessionListChanged", onSessionListChanged);
|
||||
eventBus.off("sessionChanged", onSessionChanged);
|
||||
eventBus.off("taskChanged", onTaskChanged);
|
||||
cleanUp: async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* eventBus.off(
|
||||
"sessionListChanged",
|
||||
onSessionListChanged,
|
||||
);
|
||||
yield* eventBus.off(
|
||||
"sessionChanged",
|
||||
onSessionChanged,
|
||||
);
|
||||
yield* eventBus.off(
|
||||
"sessionProcessChanged",
|
||||
onSessionProcessChanged,
|
||||
);
|
||||
yield* eventBus.off("heartbeat", onHeartbeat);
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
connectionPromise,
|
||||
};
|
||||
});
|
||||
|
||||
const { connectionPromise } = await Runtime.runPromise(runtime)(
|
||||
handleSSE.pipe(Effect.provide(TypeSafeSSE.make(rawStream))),
|
||||
);
|
||||
|
||||
await connectionPromise;
|
||||
},
|
||||
async (err) => {
|
||||
@@ -481,6 +681,12 @@ export const routes = async (app: HonoAppType) => {
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export type RouteType = Awaited<ReturnType<typeof routes>>;
|
||||
export type RouteType = ReturnType<typeof routes> extends Effect.Effect<
|
||||
infer A,
|
||||
unknown,
|
||||
unknown
|
||||
>
|
||||
? A
|
||||
: never;
|
||||
|
||||
6
src/server/lib/effect/types.ts
Normal file
6
src/server/lib/effect/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Effect } from "effect";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: for type restriction
|
||||
export type InferEffect<T> = T extends Effect.Effect<infer U, any, any>
|
||||
? U
|
||||
: never;
|
||||
1
src/server/lib/env/schema.ts
vendored
1
src/server/lib/env/schema.ts
vendored
@@ -13,6 +13,7 @@ export const envSchema = z.object({
|
||||
.optional()
|
||||
.default("3000")
|
||||
.transform((val) => parseInt(val, 10)),
|
||||
PATH: z.string().optional(),
|
||||
});
|
||||
|
||||
export type EnvSchema = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { claudeCodeViewerCacheDirPath } from "../../service/paths";
|
||||
|
||||
const saveSchema = z.array(z.tuple([z.string(), z.unknown()]));
|
||||
|
||||
export class FileCacheStorage<const T> {
|
||||
private storage = new Map<string, T>();
|
||||
|
||||
private constructor(private readonly key: string) {}
|
||||
|
||||
public static load<const LoadSchema>(
|
||||
key: string,
|
||||
schema: z.ZodType<LoadSchema>,
|
||||
) {
|
||||
const instance = new FileCacheStorage<LoadSchema>(key);
|
||||
|
||||
if (!existsSync(claudeCodeViewerCacheDirPath)) {
|
||||
mkdirSync(claudeCodeViewerCacheDirPath, { recursive: true });
|
||||
}
|
||||
|
||||
if (!existsSync(instance.cacheFilePath)) {
|
||||
writeFileSync(instance.cacheFilePath, "[]");
|
||||
} else {
|
||||
const content = readFileSync(instance.cacheFilePath, "utf-8");
|
||||
const parsed = saveSchema.safeParse(JSON.parse(content));
|
||||
|
||||
if (!parsed.success) {
|
||||
writeFileSync(instance.cacheFilePath, "[]");
|
||||
} else {
|
||||
for (const [key, value] of parsed.data) {
|
||||
const parsedValue = schema.safeParse(value);
|
||||
if (!parsedValue.success) {
|
||||
continue;
|
||||
}
|
||||
|
||||
instance.storage.set(key, parsedValue.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private get cacheFilePath() {
|
||||
return resolve(claudeCodeViewerCacheDirPath, `${this.key}.json`);
|
||||
}
|
||||
|
||||
private asSaveFormat() {
|
||||
return JSON.stringify(Array.from(this.storage.entries()));
|
||||
}
|
||||
|
||||
private async syncToFile() {
|
||||
await writeFile(this.cacheFilePath, this.asSaveFormat());
|
||||
}
|
||||
|
||||
public get(key: string) {
|
||||
return this.storage.get(key);
|
||||
}
|
||||
|
||||
public save(key: string, value: T) {
|
||||
const previous = this.asSaveFormat();
|
||||
this.storage.set(key, value);
|
||||
|
||||
if (previous === this.asSaveFormat()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.syncToFile();
|
||||
}
|
||||
|
||||
public invalidate(key: string) {
|
||||
if (!this.storage.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.storage.delete(key);
|
||||
void this.syncToFile();
|
||||
}
|
||||
}
|
||||
64
src/server/lib/storage/FileCacheStorage/PersistantService.ts
Normal file
64
src/server/lib/storage/FileCacheStorage/PersistantService.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { resolve } from "node:path";
|
||||
import { FileSystem } from "@effect/platform";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import { z } from "zod";
|
||||
import { claudeCodeViewerCacheDirPath } from "../../../service/paths";
|
||||
|
||||
const saveSchema = z.array(z.tuple([z.string(), z.unknown()]));
|
||||
|
||||
const getCacheFilePath = (key: string) =>
|
||||
resolve(claudeCodeViewerCacheDirPath, `${key}.json`);
|
||||
|
||||
const load = (key: string) => {
|
||||
const cacheFilePath = getCacheFilePath(key);
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
|
||||
if (!(yield* fs.exists(claudeCodeViewerCacheDirPath))) {
|
||||
yield* fs.makeDirectory(claudeCodeViewerCacheDirPath, {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(yield* fs.exists(cacheFilePath))) {
|
||||
yield* fs.writeFileString(cacheFilePath, "[]");
|
||||
} else {
|
||||
const content = yield* fs.readFileString(cacheFilePath);
|
||||
const parsed = saveSchema.safeParse(JSON.parse(content));
|
||||
|
||||
if (!parsed.success) {
|
||||
yield* fs.writeFileString(cacheFilePath, "[]");
|
||||
} else {
|
||||
parsed.data;
|
||||
return parsed.data;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
const save = (key: string, entries: readonly [string, unknown][]) => {
|
||||
const cacheFilePath = getCacheFilePath(key);
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
yield* fs.writeFileString(cacheFilePath, JSON.stringify(entries));
|
||||
});
|
||||
};
|
||||
|
||||
export class PersistentService extends Context.Tag("PersistentService")<
|
||||
PersistentService,
|
||||
{
|
||||
readonly load: typeof load;
|
||||
readonly save: typeof save;
|
||||
}
|
||||
>() {
|
||||
static Live = Layer.succeed(this, {
|
||||
load,
|
||||
save,
|
||||
});
|
||||
}
|
||||
|
||||
export type IPersistentService = Context.Tag.Service<PersistentService>;
|
||||
516
src/server/lib/storage/FileCacheStorage/index.test.ts
Normal file
516
src/server/lib/storage/FileCacheStorage/index.test.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
import { FileSystem } from "@effect/platform";
|
||||
import { Effect, Layer, Ref } from "effect";
|
||||
import { z } from "zod";
|
||||
import { FileCacheStorage, makeFileCacheStorageLayer } from "./index";
|
||||
import { PersistentService } from "./PersistantService";
|
||||
|
||||
// Schema for testing
|
||||
const UserSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
type User = z.infer<typeof UserSchema>;
|
||||
|
||||
const FileSystemMock = FileSystem.layerNoop({});
|
||||
|
||||
describe("FileCacheStorage", () => {
|
||||
describe("basic operations", () => {
|
||||
it("can save and retrieve data with set and get", async () => {
|
||||
// PersistentService mock (empty data)
|
||||
const PersistentServiceMock = Layer.succeed(PersistentService, {
|
||||
load: () => Effect.succeed([]),
|
||||
save: () => Effect.void,
|
||||
});
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cache = yield* FileCacheStorage<User>();
|
||||
|
||||
// Save data
|
||||
yield* cache.set("user-1", {
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
});
|
||||
|
||||
// Retrieve data
|
||||
const user = yield* cache.get("user-1");
|
||||
return user;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(
|
||||
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
|
||||
Layer.provide(PersistentServiceMock),
|
||||
Layer.provide(FileSystemMock),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined when retrieving non-existent key", async () => {
|
||||
const PersistentServiceMock = Layer.succeed(PersistentService, {
|
||||
load: () => Effect.succeed([]),
|
||||
save: () => Effect.void,
|
||||
});
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cache = yield* FileCacheStorage<User>();
|
||||
return yield* cache.get("non-existent");
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(
|
||||
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
|
||||
Layer.provide(PersistentServiceMock),
|
||||
Layer.provide(FileSystemMock),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can delete data with invalidate", async () => {
|
||||
const PersistentServiceMock = Layer.succeed(PersistentService, {
|
||||
load: () => Effect.succeed([]),
|
||||
save: () => Effect.void,
|
||||
});
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cache = yield* FileCacheStorage<User>();
|
||||
|
||||
// Save data
|
||||
yield* cache.set("user-1", {
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
});
|
||||
|
||||
// Delete data
|
||||
yield* cache.invalidate("user-1");
|
||||
|
||||
// Returns undefined after deletion
|
||||
return yield* cache.get("user-1");
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(
|
||||
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
|
||||
Layer.provide(PersistentServiceMock),
|
||||
Layer.provide(FileSystemMock),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getAll ですべてのデータを取得できる", async () => {
|
||||
const PersistentServiceMock = Layer.succeed(PersistentService, {
|
||||
load: () => Effect.succeed([]),
|
||||
save: () => Effect.void,
|
||||
});
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cache = yield* FileCacheStorage<User>();
|
||||
|
||||
// 複数のデータを保存
|
||||
yield* cache.set("user-1", {
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
});
|
||||
yield* cache.set("user-2", {
|
||||
id: "user-2",
|
||||
name: "Bob",
|
||||
email: "bob@example.com",
|
||||
});
|
||||
|
||||
// すべてのデータを取得
|
||||
return yield* cache.getAll();
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(
|
||||
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
|
||||
Layer.provide(PersistentServiceMock),
|
||||
Layer.provide(FileSystemMock),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get("user-1")).toEqual({
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
});
|
||||
expect(result.get("user-2")).toEqual({
|
||||
id: "user-2",
|
||||
name: "Bob",
|
||||
email: "bob@example.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("永続化データの読み込み", () => {
|
||||
it("初期化時に永続化データを読み込む", async () => {
|
||||
// 永続化データを返すモック
|
||||
const PersistentServiceMock = Layer.succeed(PersistentService, {
|
||||
load: () =>
|
||||
Effect.succeed([
|
||||
[
|
||||
"user-1",
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
},
|
||||
],
|
||||
[
|
||||
"user-2",
|
||||
{
|
||||
id: "user-2",
|
||||
name: "Bob",
|
||||
email: "bob@example.com",
|
||||
},
|
||||
],
|
||||
] as const),
|
||||
save: () => Effect.void,
|
||||
});
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cache = yield* FileCacheStorage<User>();
|
||||
return yield* cache.getAll();
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(
|
||||
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
|
||||
Layer.provide(PersistentServiceMock),
|
||||
Layer.provide(FileSystemMock),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get("user-1")?.name).toBe("Alice");
|
||||
expect(result.get("user-2")?.name).toBe("Bob");
|
||||
});
|
||||
|
||||
it("スキーマバリデーションに失敗したデータは無視される", async () => {
|
||||
// 不正なデータを含む永続化データ
|
||||
const PersistentServiceMock = Layer.succeed(PersistentService, {
|
||||
load: () =>
|
||||
Effect.succeed([
|
||||
[
|
||||
"user-1",
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
},
|
||||
],
|
||||
[
|
||||
"user-invalid",
|
||||
{
|
||||
id: "invalid",
|
||||
name: "Invalid",
|
||||
// email が無い(バリデーションエラー)
|
||||
},
|
||||
],
|
||||
[
|
||||
"user-2",
|
||||
{
|
||||
id: "user-2",
|
||||
name: "Bob",
|
||||
email: "invalid-email", // 不正なメールアドレス
|
||||
},
|
||||
],
|
||||
] as const),
|
||||
save: () => Effect.void,
|
||||
});
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cache = yield* FileCacheStorage<User>();
|
||||
return yield* cache.getAll();
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(
|
||||
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
|
||||
Layer.provide(PersistentServiceMock),
|
||||
Layer.provide(FileSystemMock),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 有効なデータのみ読み込まれる
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get("user-1")?.name).toBe("Alice");
|
||||
expect(result.get("user-invalid")).toBeUndefined();
|
||||
expect(result.get("user-2")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("永続化への同期", () => {
|
||||
it("set でデータを保存すると save が呼ばれる", async () => {
|
||||
const saveCallsRef = await Effect.runPromise(Ref.make<number>(0));
|
||||
|
||||
const PersistentServiceMock = Layer.succeed(PersistentService, {
|
||||
load: () => Effect.succeed([]),
|
||||
save: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(saveCallsRef, (n) => n + 1);
|
||||
}),
|
||||
});
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cache = yield* FileCacheStorage<User>();
|
||||
|
||||
yield* cache.set("user-1", {
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
});
|
||||
|
||||
// バックグラウンド実行を待つために少し待機
|
||||
yield* Effect.sleep("10 millis");
|
||||
});
|
||||
|
||||
await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(
|
||||
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
|
||||
Layer.provide(PersistentServiceMock),
|
||||
Layer.provide(FileSystemMock),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef));
|
||||
expect(saveCalls).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("同じ値を set しても save は呼ばれない(差分検出)", async () => {
|
||||
const saveCallsRef = await Effect.runPromise(Ref.make<number>(0));
|
||||
|
||||
const PersistentServiceMock = Layer.succeed(PersistentService, {
|
||||
load: () =>
|
||||
Effect.succeed([
|
||||
[
|
||||
"user-1",
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
},
|
||||
],
|
||||
] as const),
|
||||
save: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(saveCallsRef, (n) => n + 1);
|
||||
}),
|
||||
});
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cache = yield* FileCacheStorage<User>();
|
||||
|
||||
// 既に存在する同じ値を set
|
||||
yield* cache.set("user-1", {
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
});
|
||||
|
||||
// バックグラウンド実行を待つために少し待機
|
||||
yield* Effect.sleep("10 millis");
|
||||
});
|
||||
|
||||
await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(
|
||||
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
|
||||
Layer.provide(PersistentServiceMock),
|
||||
Layer.provide(FileSystemMock),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef));
|
||||
// 差分がないので save は呼ばれない
|
||||
expect(saveCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("invalidate でデータを削除すると save が呼ばれる", async () => {
|
||||
const saveCallsRef = await Effect.runPromise(Ref.make<number>(0));
|
||||
|
||||
const PersistentServiceMock = Layer.succeed(PersistentService, {
|
||||
load: () =>
|
||||
Effect.succeed([
|
||||
[
|
||||
"user-1",
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
},
|
||||
],
|
||||
] as const),
|
||||
save: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(saveCallsRef, (n) => n + 1);
|
||||
}),
|
||||
});
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cache = yield* FileCacheStorage<User>();
|
||||
|
||||
yield* cache.invalidate("user-1");
|
||||
|
||||
// バックグラウンド実行を待つために少し待機
|
||||
yield* Effect.sleep("10 millis");
|
||||
});
|
||||
|
||||
await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(
|
||||
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
|
||||
Layer.provide(PersistentServiceMock),
|
||||
Layer.provide(FileSystemMock),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef));
|
||||
expect(saveCalls).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("存在しないキーを invalidate しても save は呼ばれない", async () => {
|
||||
const saveCallsRef = await Effect.runPromise(Ref.make<number>(0));
|
||||
|
||||
const PersistentServiceMock = Layer.succeed(PersistentService, {
|
||||
load: () => Effect.succeed([]),
|
||||
save: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(saveCallsRef, (n) => n + 1);
|
||||
}),
|
||||
});
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cache = yield* FileCacheStorage<User>();
|
||||
|
||||
// 存在しないキーを invalidate
|
||||
yield* cache.invalidate("non-existent");
|
||||
|
||||
// バックグラウンド実行を待つために少し待機
|
||||
yield* Effect.sleep("10 millis");
|
||||
});
|
||||
|
||||
await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(
|
||||
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
|
||||
Layer.provide(PersistentServiceMock),
|
||||
Layer.provide(FileSystemMock),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef));
|
||||
// 存在しないキーなので save は呼ばれない
|
||||
expect(saveCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("複雑なシナリオ", () => {
|
||||
it("複数の操作を順次実行できる", async () => {
|
||||
const PersistentServiceMock = Layer.succeed(PersistentService, {
|
||||
load: () =>
|
||||
Effect.succeed([
|
||||
[
|
||||
"user-1",
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
},
|
||||
],
|
||||
] as const),
|
||||
save: () => Effect.void,
|
||||
});
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const cache = yield* FileCacheStorage<User>();
|
||||
|
||||
// 初期データの確認
|
||||
const initial = yield* cache.getAll();
|
||||
expect(initial.size).toBe(1);
|
||||
|
||||
// 新しいユーザーを追加
|
||||
yield* cache.set("user-2", {
|
||||
id: "user-2",
|
||||
name: "Bob",
|
||||
email: "bob@example.com",
|
||||
});
|
||||
|
||||
// 既存のユーザーを更新
|
||||
yield* cache.set("user-1", {
|
||||
id: "user-1",
|
||||
name: "Alice Updated",
|
||||
email: "alice.updated@example.com",
|
||||
});
|
||||
|
||||
// すべてのデータを取得
|
||||
const afterUpdate = yield* cache.getAll();
|
||||
expect(afterUpdate.size).toBe(2);
|
||||
expect(afterUpdate.get("user-1")?.name).toBe("Alice Updated");
|
||||
expect(afterUpdate.get("user-2")?.name).toBe("Bob");
|
||||
|
||||
// ユーザーを削除
|
||||
yield* cache.invalidate("user-1");
|
||||
|
||||
// 削除後の確認
|
||||
const afterDelete = yield* cache.getAll();
|
||||
expect(afterDelete.size).toBe(1);
|
||||
expect(afterDelete.get("user-1")).toBeUndefined();
|
||||
expect(afterDelete.get("user-2")?.name).toBe("Bob");
|
||||
});
|
||||
|
||||
await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(
|
||||
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
|
||||
Layer.provide(PersistentServiceMock),
|
||||
Layer.provide(FileSystemMock),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
94
src/server/lib/storage/FileCacheStorage/index.ts
Normal file
94
src/server/lib/storage/FileCacheStorage/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { FileSystem } from "@effect/platform";
|
||||
import { Context, Effect, Layer, Ref, Runtime } from "effect";
|
||||
import type { z } from "zod";
|
||||
import { PersistentService } from "./PersistantService";
|
||||
|
||||
export interface FileCacheStorageService<T> {
|
||||
readonly get: (key: string) => Effect.Effect<T | undefined>;
|
||||
readonly set: (key: string, value: T) => Effect.Effect<void>;
|
||||
readonly invalidate: (key: string) => Effect.Effect<void>;
|
||||
readonly getAll: () => Effect.Effect<Map<string, T>>;
|
||||
}
|
||||
|
||||
export const FileCacheStorage = <T>() =>
|
||||
Context.GenericTag<FileCacheStorageService<T>>("FileCacheStorage");
|
||||
|
||||
export const makeFileCacheStorageLayer = <T>(
|
||||
storageKey: string,
|
||||
schema: z.ZodType<T>,
|
||||
) =>
|
||||
Layer.effect(
|
||||
FileCacheStorage<T>(),
|
||||
Effect.gen(function* () {
|
||||
const persistentService = yield* PersistentService;
|
||||
|
||||
const runtime = yield* Effect.runtime<FileSystem.FileSystem>();
|
||||
|
||||
const storageRef = yield* Effect.gen(function* () {
|
||||
const persistedData = yield* persistentService.load(storageKey);
|
||||
|
||||
const initialMap = new Map<string, T>();
|
||||
for (const [key, value] of persistedData) {
|
||||
const parsed = schema.safeParse(value);
|
||||
if (parsed.success) {
|
||||
initialMap.set(key, parsed.data);
|
||||
}
|
||||
}
|
||||
|
||||
return yield* Ref.make(initialMap);
|
||||
});
|
||||
|
||||
const syncToFile = (entries: readonly [string, T][]) => {
|
||||
Runtime.runFork(runtime)(persistentService.save(storageKey, entries));
|
||||
};
|
||||
|
||||
return {
|
||||
get: (key: string) =>
|
||||
Effect.gen(function* () {
|
||||
const storage = yield* Ref.get(storageRef);
|
||||
return storage.get(key);
|
||||
}),
|
||||
|
||||
set: (key: string, value: T) =>
|
||||
Effect.gen(function* () {
|
||||
const before = yield* Ref.get(storageRef);
|
||||
const beforeString = JSON.stringify(Array.from(before.entries()));
|
||||
|
||||
yield* Ref.update(storageRef, (map) => {
|
||||
map.set(key, value);
|
||||
return map;
|
||||
});
|
||||
|
||||
const after = yield* Ref.get(storageRef);
|
||||
const afterString = JSON.stringify(Array.from(after.entries()));
|
||||
|
||||
if (beforeString !== afterString) {
|
||||
syncToFile(Array.from(after.entries()));
|
||||
}
|
||||
}),
|
||||
|
||||
invalidate: (key: string) =>
|
||||
Effect.gen(function* () {
|
||||
const before = yield* Ref.get(storageRef);
|
||||
|
||||
if (!before.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield* Ref.update(storageRef, (map) => {
|
||||
map.delete(key);
|
||||
return map;
|
||||
});
|
||||
|
||||
const after = yield* Ref.get(storageRef);
|
||||
syncToFile(Array.from(after.entries()));
|
||||
}),
|
||||
|
||||
getAll: () =>
|
||||
Effect.gen(function* () {
|
||||
const storage = yield* Ref.get(storageRef);
|
||||
return new Map(storage);
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -1,19 +0,0 @@
|
||||
export class InMemoryCacheStorage<const T> {
|
||||
private storage = new Map<string, T>();
|
||||
|
||||
public get(key: string) {
|
||||
return this.storage.get(key);
|
||||
}
|
||||
|
||||
public save(key: string, value: T) {
|
||||
this.storage.set(key, value);
|
||||
}
|
||||
|
||||
public invalidate(key: string) {
|
||||
if (!this.storage.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.storage.delete(key);
|
||||
}
|
||||
}
|
||||
94
src/server/service/claude-code/ClaudeCode.test.ts
Normal file
94
src/server/service/claude-code/ClaudeCode.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { CommandExecutor, Path } from "@effect/platform";
|
||||
import { NodeContext } from "@effect/platform-node";
|
||||
import { Effect, Layer } from "effect";
|
||||
import * as ClaudeCode from "./ClaudeCode";
|
||||
|
||||
describe("ClaudeCode.Config", () => {
|
||||
describe("when environment variable CLAUDE_CODE_VIEWER_CC_EXECUTABLE_PATH is not set", () => {
|
||||
it("should correctly parse results of 'which claude' and 'claude --version'", async () => {
|
||||
const CommandExecutorTest = Layer.effect(
|
||||
CommandExecutor.CommandExecutor,
|
||||
Effect.map(CommandExecutor.CommandExecutor, (realExecutor) => ({
|
||||
...realExecutor,
|
||||
string: (() => {
|
||||
const responses = ["/path/to/claude", "1.0.53 (Claude Code)\n"];
|
||||
return () => Effect.succeed(responses.shift() ?? "");
|
||||
})(),
|
||||
})),
|
||||
).pipe(Layer.provide(NodeContext.layer));
|
||||
|
||||
const config = await Effect.runPromise(
|
||||
ClaudeCode.Config.pipe(
|
||||
Effect.provide(Path.layer),
|
||||
Effect.provide(CommandExecutorTest),
|
||||
),
|
||||
);
|
||||
|
||||
expect(config.claudeCodeExecutablePath).toBe("/path/to/claude");
|
||||
|
||||
expect(config.claudeCodeVersion).toStrictEqual({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 53,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeCode.AvailableFeatures", () => {
|
||||
describe("when claudeCodeVersion is null", () => {
|
||||
it("canUseTool and uuidOnSDKMessage should be false", () => {
|
||||
const features = ClaudeCode.getAvailableFeatures(null);
|
||||
expect(features.canUseTool).toBe(false);
|
||||
expect(features.uuidOnSDKMessage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when claudeCodeVersion is v1.0.81", () => {
|
||||
it("canUseTool should be false, uuidOnSDKMessage should be false", () => {
|
||||
const features = ClaudeCode.getAvailableFeatures({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 81,
|
||||
});
|
||||
expect(features.canUseTool).toBe(false);
|
||||
expect(features.uuidOnSDKMessage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when claudeCodeVersion is v1.0.82", () => {
|
||||
it("canUseTool should be true, uuidOnSDKMessage should be false", () => {
|
||||
const features = ClaudeCode.getAvailableFeatures({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 82,
|
||||
});
|
||||
expect(features.canUseTool).toBe(true);
|
||||
expect(features.uuidOnSDKMessage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when claudeCodeVersion is v1.0.85", () => {
|
||||
it("canUseTool should be true, uuidOnSDKMessage should be false", () => {
|
||||
const features = ClaudeCode.getAvailableFeatures({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 85,
|
||||
});
|
||||
expect(features.canUseTool).toBe(true);
|
||||
expect(features.uuidOnSDKMessage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when claudeCodeVersion is v1.0.86", () => {
|
||||
it("canUseTool should be true, uuidOnSDKMessage should be true", () => {
|
||||
const features = ClaudeCode.getAvailableFeatures({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 86,
|
||||
});
|
||||
expect(features.canUseTool).toBe(true);
|
||||
expect(features.uuidOnSDKMessage).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
81
src/server/service/claude-code/ClaudeCode.ts
Normal file
81
src/server/service/claude-code/ClaudeCode.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { query as originalQuery } from "@anthropic-ai/claude-code";
|
||||
import { Command, Path } from "@effect/platform";
|
||||
import { Effect } from "effect";
|
||||
import { env } from "../../lib/env";
|
||||
import * as ClaudeCodeVersion from "./models/ClaudeCodeVersion";
|
||||
|
||||
type CCQuery = typeof originalQuery;
|
||||
type CCQueryPrompt = Parameters<CCQuery>[0]["prompt"];
|
||||
type CCQueryOptions = NonNullable<Parameters<CCQuery>[0]["options"]>;
|
||||
|
||||
export const Config = Effect.gen(function* () {
|
||||
const path = yield* Path.Path;
|
||||
|
||||
const specifiedExecutablePath = env.get(
|
||||
"CLAUDE_CODE_VIEWER_CC_EXECUTABLE_PATH",
|
||||
);
|
||||
|
||||
const claudeCodeExecutablePath =
|
||||
specifiedExecutablePath !== undefined
|
||||
? path.resolve(specifiedExecutablePath)
|
||||
: (yield* Command.string(
|
||||
Command.make("which", "claude").pipe(
|
||||
Command.env({
|
||||
PATH: env.get("PATH"),
|
||||
}),
|
||||
Command.runInShell(true),
|
||||
),
|
||||
)).trim();
|
||||
|
||||
const claudeCodeVersion = ClaudeCodeVersion.fromCLIString(
|
||||
yield* Command.string(Command.make(claudeCodeExecutablePath, "--version")),
|
||||
);
|
||||
|
||||
return {
|
||||
claudeCodeExecutablePath,
|
||||
claudeCodeVersion,
|
||||
};
|
||||
});
|
||||
|
||||
export const getAvailableFeatures = (
|
||||
claudeCodeVersion: ClaudeCodeVersion.ClaudeCodeVersion | null,
|
||||
) => ({
|
||||
canUseTool:
|
||||
claudeCodeVersion !== null
|
||||
? ClaudeCodeVersion.greaterThanOrEqual(claudeCodeVersion, {
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 82,
|
||||
})
|
||||
: false,
|
||||
uuidOnSDKMessage:
|
||||
claudeCodeVersion !== null
|
||||
? ClaudeCodeVersion.greaterThanOrEqual(claudeCodeVersion, {
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 86,
|
||||
})
|
||||
: false,
|
||||
});
|
||||
|
||||
export const query = (prompt: CCQueryPrompt, options: CCQueryOptions) => {
|
||||
const { canUseTool, permissionMode, ...baseOptions } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const { claudeCodeExecutablePath, claudeCodeVersion } = yield* Config;
|
||||
const availableFeatures = getAvailableFeatures(claudeCodeVersion);
|
||||
|
||||
return originalQuery({
|
||||
prompt,
|
||||
options: {
|
||||
pathToClaudeCodeExecutable: claudeCodeExecutablePath,
|
||||
...baseOptions,
|
||||
...(availableFeatures.canUseTool
|
||||
? { canUseTool, permissionMode }
|
||||
: {
|
||||
permissionMode: "bypassPermissions",
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { query } from "@anthropic-ai/claude-code";
|
||||
import { env } from "../../lib/env";
|
||||
import { ClaudeCodeVersion } from "./ClaudeCodeVersion";
|
||||
|
||||
type CCQuery = typeof query;
|
||||
type CCQueryPrompt = Parameters<CCQuery>[0]["prompt"];
|
||||
type CCQueryOptions = NonNullable<Parameters<CCQuery>[0]["options"]>;
|
||||
|
||||
export class ClaudeCodeExecutor {
|
||||
private pathToClaudeCodeExecutable: string;
|
||||
private claudeCodeVersion: ClaudeCodeVersion | null;
|
||||
|
||||
constructor() {
|
||||
const executablePath = env.get("CLAUDE_CODE_VIEWER_CC_EXECUTABLE_PATH");
|
||||
this.pathToClaudeCodeExecutable =
|
||||
executablePath !== undefined
|
||||
? resolve(executablePath)
|
||||
: execSync("which claude", {}).toString().trim();
|
||||
this.claudeCodeVersion = ClaudeCodeVersion.fromCLIString(
|
||||
execSync(`${this.pathToClaudeCodeExecutable} --version`, {}).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
public get version() {
|
||||
return this.claudeCodeVersion?.version;
|
||||
}
|
||||
|
||||
public get availableFeatures() {
|
||||
return {
|
||||
canUseTool:
|
||||
this.claudeCodeVersion?.greaterThanOrEqual(
|
||||
new ClaudeCodeVersion({ major: 1, minor: 0, patch: 82 }),
|
||||
) ?? false,
|
||||
uuidOnSDKMessage:
|
||||
this.claudeCodeVersion?.greaterThanOrEqual(
|
||||
new ClaudeCodeVersion({ major: 1, minor: 0, patch: 86 }),
|
||||
) ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
public query(prompt: CCQueryPrompt, options: CCQueryOptions) {
|
||||
const { canUseTool, ...baseOptions } = options;
|
||||
|
||||
return query({
|
||||
prompt,
|
||||
options: {
|
||||
pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable,
|
||||
...baseOptions,
|
||||
...(this.availableFeatures.canUseTool ? { canUseTool } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
367
src/server/service/claude-code/ClaudeCodeLifeCycleService.ts
Normal file
367
src/server/service/claude-code/ClaudeCodeLifeCycleService.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-code";
|
||||
import type { FileSystem, Path } from "@effect/platform";
|
||||
import type { CommandExecutor } from "@effect/platform/CommandExecutor";
|
||||
import { Context, Effect, Layer, Runtime } from "effect";
|
||||
import { ulid } from "ulid";
|
||||
import { controllablePromise } from "../../../lib/controllablePromise";
|
||||
import type { Config } from "../../config/config";
|
||||
import type { InferEffect } from "../../lib/effect/types";
|
||||
import { EventBus } from "../events/EventBus";
|
||||
import { VirtualConversationDatabase } from "../session/PredictSessionsDatabase";
|
||||
import type { SessionMetaService } from "../session/SessionMetaService";
|
||||
import { SessionRepository } from "../session/SessionRepository";
|
||||
import * as ClaudeCode from "./ClaudeCode";
|
||||
import { ClaudeCodePermissionService } from "./ClaudeCodePermissionService";
|
||||
import { ClaudeCodeSessionProcessService } from "./ClaudeCodeSessionProcessService";
|
||||
import { createMessageGenerator } from "./MessageGenerator";
|
||||
import * as CCSessionProcess from "./models/CCSessionProcess";
|
||||
|
||||
export type MessageGenerator = () => AsyncGenerator<
|
||||
SDKUserMessage,
|
||||
void,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const eventBusService = yield* EventBus;
|
||||
const sessionRepository = yield* SessionRepository;
|
||||
const sessionProcessService = yield* ClaudeCodeSessionProcessService;
|
||||
const virtualConversationDatabase = yield* VirtualConversationDatabase;
|
||||
const permissionService = yield* ClaudeCodePermissionService;
|
||||
|
||||
const runtime = yield* Effect.runtime<
|
||||
| FileSystem.FileSystem
|
||||
| Path.Path
|
||||
| CommandExecutor
|
||||
| VirtualConversationDatabase
|
||||
| SessionMetaService
|
||||
| ClaudeCodePermissionService
|
||||
>();
|
||||
|
||||
const continueTask = (options: {
|
||||
sessionProcessId: string;
|
||||
baseSessionId: string;
|
||||
message: string;
|
||||
}) => {
|
||||
const { sessionProcessId, baseSessionId, message } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const { sessionProcess, task } =
|
||||
yield* sessionProcessService.continueSessionProcess({
|
||||
sessionProcessId,
|
||||
taskDef: {
|
||||
type: "continue",
|
||||
sessionId: baseSessionId,
|
||||
baseSessionId: baseSessionId,
|
||||
taskId: ulid(),
|
||||
},
|
||||
});
|
||||
|
||||
const virtualConversation =
|
||||
yield* CCSessionProcess.createVirtualConversation(sessionProcess, {
|
||||
sessionId: baseSessionId,
|
||||
userMessage: message,
|
||||
});
|
||||
|
||||
yield* virtualConversationDatabase.createVirtualConversation(
|
||||
sessionProcess.def.projectId,
|
||||
baseSessionId,
|
||||
[virtualConversation],
|
||||
);
|
||||
|
||||
sessionProcess.def.setNextMessage(message);
|
||||
return {
|
||||
sessionProcess,
|
||||
task,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const startTask = (options: {
|
||||
config: Config;
|
||||
baseSession: {
|
||||
cwd: string;
|
||||
projectId: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
message: string;
|
||||
}) => {
|
||||
const { baseSession, message, config } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const {
|
||||
generateMessages,
|
||||
setNextMessage,
|
||||
setHooks: setMessageGeneratorHooks,
|
||||
} = createMessageGenerator();
|
||||
|
||||
const { sessionProcess, task } =
|
||||
yield* sessionProcessService.startSessionProcess({
|
||||
sessionDef: {
|
||||
projectId: baseSession.projectId,
|
||||
cwd: baseSession.cwd,
|
||||
abortController: new AbortController(),
|
||||
setNextMessage,
|
||||
sessionProcessId: ulid(),
|
||||
},
|
||||
taskDef:
|
||||
baseSession.sessionId === undefined
|
||||
? {
|
||||
type: "new",
|
||||
taskId: ulid(),
|
||||
}
|
||||
: {
|
||||
type: "resume",
|
||||
taskId: ulid(),
|
||||
sessionId: undefined,
|
||||
baseSessionId: baseSession.sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
const sessionInitializedPromise = controllablePromise<string>();
|
||||
|
||||
setMessageGeneratorHooks({
|
||||
onNewUserMessageResolved: async (message) => {
|
||||
Effect.runFork(
|
||||
sessionProcessService.toNotInitializedState({
|
||||
sessionProcessId: sessionProcess.def.sessionProcessId,
|
||||
rawUserMessage: message,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleMessage = (message: SDKMessage) =>
|
||||
Effect.gen(function* () {
|
||||
const processState = yield* sessionProcessService.getSessionProcess(
|
||||
sessionProcess.def.sessionProcessId,
|
||||
);
|
||||
|
||||
if (processState.type === "completed") {
|
||||
return "break" as const;
|
||||
}
|
||||
|
||||
if (processState.type === "paused") {
|
||||
// rule: paused は not_initialized に更新されてからくる想定
|
||||
yield* Effect.die(
|
||||
new Error("Illegal state: paused is not expected"),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === "system" &&
|
||||
message.subtype === "init" &&
|
||||
processState.type === "not_initialized"
|
||||
) {
|
||||
yield* sessionProcessService.toInitializedState({
|
||||
sessionProcessId: processState.def.sessionProcessId,
|
||||
initContext: {
|
||||
initMessage: message,
|
||||
},
|
||||
});
|
||||
|
||||
// Virtual Conversation Creation
|
||||
const virtualConversation =
|
||||
yield* CCSessionProcess.createVirtualConversation(processState, {
|
||||
sessionId: message.session_id,
|
||||
userMessage: processState.rawUserMessage,
|
||||
});
|
||||
|
||||
if (processState.currentTask.def.type === "new") {
|
||||
// 末尾に追加するだけで OK
|
||||
yield* virtualConversationDatabase.createVirtualConversation(
|
||||
baseSession.projectId,
|
||||
message.session_id,
|
||||
[virtualConversation],
|
||||
);
|
||||
} else if (processState.currentTask.def.type === "resume") {
|
||||
const existingSession = yield* sessionRepository.getSession(
|
||||
processState.def.projectId,
|
||||
processState.currentTask.def.baseSessionId,
|
||||
);
|
||||
|
||||
const copiedConversations =
|
||||
existingSession.session === null
|
||||
? []
|
||||
: existingSession.session.conversations;
|
||||
|
||||
yield* virtualConversationDatabase.createVirtualConversation(
|
||||
processState.def.projectId,
|
||||
message.session_id,
|
||||
[...copiedConversations, virtualConversation],
|
||||
);
|
||||
} else {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
sessionInitializedPromise.resolve(message.session_id);
|
||||
|
||||
yield* eventBusService.emit("sessionListChanged", {
|
||||
projectId: processState.def.projectId,
|
||||
});
|
||||
|
||||
yield* eventBusService.emit("sessionChanged", {
|
||||
projectId: processState.def.projectId,
|
||||
sessionId: message.session_id,
|
||||
});
|
||||
|
||||
return "continue" as const;
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === "result" &&
|
||||
processState.type === "initialized"
|
||||
) {
|
||||
yield* sessionProcessService.toPausedState({
|
||||
sessionProcessId: processState.def.sessionProcessId,
|
||||
resultMessage: message,
|
||||
});
|
||||
|
||||
yield* eventBusService.emit("sessionChanged", {
|
||||
projectId: processState.def.projectId,
|
||||
sessionId: message.session_id,
|
||||
});
|
||||
|
||||
return "continue" as const;
|
||||
}
|
||||
|
||||
return "continue" as const;
|
||||
});
|
||||
|
||||
const handleSessionProcessDaemon = async () => {
|
||||
const messageIter = await Runtime.runPromise(runtime)(
|
||||
Effect.gen(function* () {
|
||||
const permissionOptions =
|
||||
yield* permissionService.createCanUseToolRelatedOptions({
|
||||
taskId: task.def.taskId,
|
||||
config,
|
||||
sessionId: task.def.baseSessionId,
|
||||
});
|
||||
|
||||
return yield* ClaudeCode.query(generateMessages(), {
|
||||
resume: task.def.baseSessionId,
|
||||
cwd: sessionProcess.def.cwd,
|
||||
abortController: sessionProcess.def.abortController,
|
||||
...permissionOptions,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
setNextMessage(message);
|
||||
|
||||
try {
|
||||
for await (const message of messageIter) {
|
||||
const result = await Runtime.runPromise(runtime)(
|
||||
handleMessage(message),
|
||||
).catch((error) => {
|
||||
// iter 自体が落ちてなければ継続したいので握りつぶす
|
||||
Effect.runFork(
|
||||
sessionProcessService.changeTaskState({
|
||||
sessionProcessId: sessionProcess.def.sessionProcessId,
|
||||
taskId: task.def.taskId,
|
||||
nextTask: {
|
||||
status: "failed",
|
||||
def: task.def,
|
||||
error: error,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return "continue" as const;
|
||||
});
|
||||
|
||||
if (result === "break") {
|
||||
break;
|
||||
} else {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await Effect.runPromise(
|
||||
sessionProcessService.changeTaskState({
|
||||
sessionProcessId: sessionProcess.def.sessionProcessId,
|
||||
taskId: task.def.taskId,
|
||||
nextTask: {
|
||||
status: "failed",
|
||||
def: task.def,
|
||||
error: error,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const daemonPromise = handleSessionProcessDaemon()
|
||||
.catch((error) => {
|
||||
console.error("Error occur in task daemon process", error);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
Effect.runFork(
|
||||
Effect.gen(function* () {
|
||||
const currentProcess =
|
||||
yield* sessionProcessService.getSessionProcess(
|
||||
sessionProcess.def.sessionProcessId,
|
||||
);
|
||||
|
||||
yield* sessionProcessService.toCompletedState({
|
||||
sessionProcessId: currentProcess.def.sessionProcessId,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
sessionProcess,
|
||||
task,
|
||||
daemonPromise,
|
||||
awaitSessionInitialized: async () =>
|
||||
await sessionInitializedPromise.promise,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getPublicSessionProcesses = () =>
|
||||
Effect.gen(function* () {
|
||||
const processes = yield* sessionProcessService.getSessionProcesses();
|
||||
return processes.filter((process) => CCSessionProcess.isPublic(process));
|
||||
});
|
||||
|
||||
const abortTask = (sessionProcessId: string): Effect.Effect<void, Error> =>
|
||||
Effect.gen(function* () {
|
||||
const currentProcess =
|
||||
yield* sessionProcessService.getSessionProcess(sessionProcessId);
|
||||
|
||||
yield* sessionProcessService.toCompletedState({
|
||||
sessionProcessId: currentProcess.def.sessionProcessId,
|
||||
error: new Error("Task aborted"),
|
||||
});
|
||||
});
|
||||
|
||||
const abortAllTasks = () =>
|
||||
Effect.gen(function* () {
|
||||
const processes = yield* sessionProcessService.getSessionProcesses();
|
||||
|
||||
for (const process of processes) {
|
||||
yield* sessionProcessService.toCompletedState({
|
||||
sessionProcessId: process.def.sessionProcessId,
|
||||
error: new Error("Task aborted"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
continueTask,
|
||||
startTask,
|
||||
abortTask,
|
||||
abortAllTasks,
|
||||
getPublicSessionProcesses,
|
||||
};
|
||||
});
|
||||
|
||||
export type IClaudeCodeLifeCycleService = InferEffect<typeof LayerImpl>;
|
||||
|
||||
export class ClaudeCodeLifeCycleService extends Context.Tag(
|
||||
"ClaudeCodeLifeCycleService",
|
||||
)<ClaudeCodeLifeCycleService, IClaudeCodeLifeCycleService>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
158
src/server/service/claude-code/ClaudeCodePermissionService.ts
Normal file
158
src/server/service/claude-code/ClaudeCodePermissionService.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { CanUseTool } from "@anthropic-ai/claude-code";
|
||||
import { Context, Effect, Layer, Ref } from "effect";
|
||||
import { ulid } from "ulid";
|
||||
import type {
|
||||
PermissionRequest,
|
||||
PermissionResponse,
|
||||
} from "../../../types/permissions";
|
||||
import type { Config } from "../../config/config";
|
||||
import type { InferEffect } from "../../lib/effect/types";
|
||||
import { EventBus } from "../events/EventBus";
|
||||
import * as ClaudeCode from "./ClaudeCode";
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const pendingPermissionRequestsRef = yield* Ref.make<
|
||||
Map<string, PermissionRequest>
|
||||
>(new Map());
|
||||
const permissionResponsesRef = yield* Ref.make<
|
||||
Map<string, PermissionResponse>
|
||||
>(new Map());
|
||||
const eventBus = yield* EventBus;
|
||||
|
||||
const waitPermissionResponse = (
|
||||
request: PermissionRequest,
|
||||
options: { timeoutMs: number },
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(pendingPermissionRequestsRef, (requests) => {
|
||||
requests.set(request.id, request);
|
||||
return requests;
|
||||
});
|
||||
|
||||
yield* eventBus.emit("permissionRequested", {
|
||||
permissionRequest: request,
|
||||
});
|
||||
|
||||
let passedMs = 0;
|
||||
let response: PermissionResponse | null = null;
|
||||
while (passedMs < options.timeoutMs) {
|
||||
const responses = yield* Ref.get(permissionResponsesRef);
|
||||
response = responses.get(request.id) ?? null;
|
||||
if (response !== null) {
|
||||
break;
|
||||
}
|
||||
|
||||
yield* Effect.sleep(1000);
|
||||
passedMs += 1000;
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
const createCanUseToolRelatedOptions = (options: {
|
||||
taskId: string;
|
||||
config: Config;
|
||||
sessionId?: string;
|
||||
}) => {
|
||||
const { taskId, config, sessionId } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const claudeCodeConfig = yield* ClaudeCode.Config;
|
||||
|
||||
if (
|
||||
!ClaudeCode.getAvailableFeatures(claudeCodeConfig.claudeCodeVersion)
|
||||
.canUseTool
|
||||
) {
|
||||
return {
|
||||
permissionMode: "bypassPermissions",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const canUseTool: CanUseTool = async (toolName, toolInput, _options) => {
|
||||
if (config.permissionMode !== "default") {
|
||||
// Convert Claude Code permission modes to canUseTool behaviors
|
||||
if (
|
||||
config.permissionMode === "bypassPermissions" ||
|
||||
config.permissionMode === "acceptEdits"
|
||||
) {
|
||||
return {
|
||||
behavior: "allow" as const,
|
||||
updatedInput: toolInput,
|
||||
};
|
||||
} else {
|
||||
// plan mode should deny actual tool execution
|
||||
return {
|
||||
behavior: "deny" as const,
|
||||
message: "Tool execution is disabled in plan mode",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const permissionRequest: PermissionRequest = {
|
||||
id: ulid(),
|
||||
taskId,
|
||||
sessionId,
|
||||
toolName,
|
||||
toolInput,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const response = await Effect.runPromise(
|
||||
waitPermissionResponse(permissionRequest, { timeoutMs: 60000 }),
|
||||
);
|
||||
|
||||
if (response === null) {
|
||||
return {
|
||||
behavior: "deny" as const,
|
||||
message: "Permission request timed out",
|
||||
};
|
||||
}
|
||||
|
||||
if (response.decision === "allow") {
|
||||
return {
|
||||
behavior: "allow" as const,
|
||||
updatedInput: toolInput,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
behavior: "deny" as const,
|
||||
message: "Permission denied by user",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
canUseTool,
|
||||
permissionMode: config.permissionMode,
|
||||
} as const;
|
||||
});
|
||||
};
|
||||
|
||||
const respondToPermissionRequest = (
|
||||
response: PermissionResponse,
|
||||
): Effect.Effect<void> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(permissionResponsesRef, (responses) => {
|
||||
responses.set(response.permissionRequestId, response);
|
||||
return responses;
|
||||
});
|
||||
|
||||
yield* Ref.update(pendingPermissionRequestsRef, (requests) => {
|
||||
requests.delete(response.permissionRequestId);
|
||||
return requests;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
createCanUseToolRelatedOptions,
|
||||
respondToPermissionRequest,
|
||||
};
|
||||
});
|
||||
|
||||
export type IClaudeCodePermissionService = InferEffect<typeof LayerImpl>;
|
||||
|
||||
export class ClaudeCodePermissionService extends Context.Tag(
|
||||
"ClaudeCodePermissionService",
|
||||
)<ClaudeCodePermissionService, IClaudeCodePermissionService>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import type { SDKResultMessage } from "@anthropic-ai/claude-code";
|
||||
import { Context, Data, Effect, Layer, Ref } from "effect";
|
||||
import type { InferEffect } from "../../lib/effect/types";
|
||||
import { EventBus } from "../events/EventBus";
|
||||
import type { InitMessageContext } from "./createMessageGenerator";
|
||||
import * as CCSessionProcess from "./models/CCSessionProcess";
|
||||
import type * as CCTask from "./models/ClaudeCodeTask";
|
||||
|
||||
class SessionProcessNotFoundError extends Data.TaggedError(
|
||||
"SessionProcessNotFoundError",
|
||||
)<{
|
||||
sessionProcessId: string;
|
||||
}> {}
|
||||
|
||||
class SessionProcessNotPausedError extends Data.TaggedError(
|
||||
"SessionProcessNotPausedError",
|
||||
)<{
|
||||
sessionProcessId: string;
|
||||
}> {}
|
||||
|
||||
class SessionProcessAlreadyAliveError extends Data.TaggedError(
|
||||
"SessionProcessAlreadyAliveError",
|
||||
)<{
|
||||
sessionProcessId: string;
|
||||
aliveTaskId: string;
|
||||
aliveTaskSessionId?: string;
|
||||
}> {}
|
||||
|
||||
class IllegalStateChangeError extends Data.TaggedError(
|
||||
"IllegalStateChangeError",
|
||||
)<{
|
||||
from: CCSessionProcess.CCSessionProcessState["type"];
|
||||
to: CCSessionProcess.CCSessionProcessState["type"];
|
||||
}> {}
|
||||
|
||||
class TaskNotFoundError extends Data.TaggedError("TaskNotFoundError")<{
|
||||
taskId: string;
|
||||
}> {}
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const processesRef = yield* Ref.make<
|
||||
CCSessionProcess.CCSessionProcessState[]
|
||||
>([]);
|
||||
const eventBus = yield* EventBus;
|
||||
|
||||
const startSessionProcess = (options: {
|
||||
sessionDef: CCSessionProcess.CCSessionProcessDef;
|
||||
taskDef: CCTask.NewClaudeCodeTaskDef | CCTask.ResumeClaudeCodeTaskDef;
|
||||
}) => {
|
||||
const { sessionDef, taskDef } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const task: CCTask.PendingClaudeCodeTaskState = {
|
||||
def: taskDef,
|
||||
status: "pending",
|
||||
};
|
||||
|
||||
const newProcess: CCSessionProcess.CCSessionProcessState = {
|
||||
def: sessionDef,
|
||||
type: "pending",
|
||||
tasks: [task],
|
||||
currentTask: task,
|
||||
};
|
||||
|
||||
yield* Ref.update(processesRef, (processes) => [
|
||||
...processes,
|
||||
newProcess,
|
||||
]);
|
||||
return {
|
||||
sessionProcess: newProcess,
|
||||
task,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const continueSessionProcess = (options: {
|
||||
sessionProcessId: string;
|
||||
taskDef: CCTask.ContinueClaudeCodeTaskDef;
|
||||
}) => {
|
||||
const { sessionProcessId } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const process = yield* getSessionProcess(sessionProcessId);
|
||||
|
||||
if (process.type !== "paused") {
|
||||
return yield* Effect.fail(
|
||||
new SessionProcessNotPausedError({
|
||||
sessionProcessId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const [firstAliveTask] = CCSessionProcess.getAliveTasks(process);
|
||||
if (firstAliveTask !== undefined) {
|
||||
return yield* Effect.fail(
|
||||
new SessionProcessAlreadyAliveError({
|
||||
sessionProcessId,
|
||||
aliveTaskId: firstAliveTask.def.taskId,
|
||||
aliveTaskSessionId:
|
||||
firstAliveTask.def.sessionId ?? firstAliveTask.sessionId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const newTask: CCTask.PendingClaudeCodeTaskState = {
|
||||
def: options.taskDef,
|
||||
status: "pending",
|
||||
};
|
||||
|
||||
const newProcess: CCSessionProcess.CCSessionProcessPendingState = {
|
||||
def: process.def,
|
||||
type: "pending",
|
||||
tasks: [...process.tasks, newTask],
|
||||
currentTask: newTask,
|
||||
};
|
||||
|
||||
yield* Ref.update(processesRef, (processes) => {
|
||||
return processes.map((p) =>
|
||||
p.def.sessionProcessId === sessionProcessId ? newProcess : p,
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
sessionProcess: newProcess,
|
||||
task: newTask,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getSessionProcess = (sessionProcessId: string) => {
|
||||
return Effect.gen(function* () {
|
||||
const processes = yield* Ref.get(processesRef);
|
||||
const result = processes.find(
|
||||
(p) => p.def.sessionProcessId === sessionProcessId,
|
||||
);
|
||||
if (result === undefined) {
|
||||
return yield* Effect.fail(
|
||||
new SessionProcessNotFoundError({ sessionProcessId }),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
const getSessionProcesses = () => {
|
||||
return Effect.gen(function* () {
|
||||
const processes = yield* Ref.get(processesRef);
|
||||
return processes;
|
||||
});
|
||||
};
|
||||
|
||||
const getTask = (taskId: string) => {
|
||||
return Effect.gen(function* () {
|
||||
const processes = yield* Ref.get(processesRef);
|
||||
const result = processes
|
||||
.flatMap((p) => {
|
||||
const found = p.tasks.find((t) => t.def.taskId === taskId);
|
||||
if (found === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
sessionProcess: p,
|
||||
task: found,
|
||||
},
|
||||
];
|
||||
})
|
||||
.at(0);
|
||||
|
||||
if (result === undefined) {
|
||||
return yield* Effect.fail(new TaskNotFoundError({ taskId }));
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
const dangerouslyChangeProcessState = <
|
||||
T extends CCSessionProcess.CCSessionProcessState,
|
||||
>(options: {
|
||||
sessionProcessId: string;
|
||||
nextState: T;
|
||||
}) => {
|
||||
const { sessionProcessId, nextState } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const processes = yield* Ref.get(processesRef);
|
||||
const targetProcess = processes.find(
|
||||
(p) => p.def.sessionProcessId === sessionProcessId,
|
||||
);
|
||||
|
||||
const updatedProcesses = processes.map((p) =>
|
||||
p.def.sessionProcessId === sessionProcessId ? nextState : p,
|
||||
);
|
||||
|
||||
yield* Ref.set(processesRef, updatedProcesses);
|
||||
|
||||
if (targetProcess?.type !== nextState.type) {
|
||||
yield* eventBus.emit("sessionProcessChanged", {
|
||||
processes: updatedProcesses
|
||||
.filter(CCSessionProcess.isPublic)
|
||||
.map((process) => ({
|
||||
id: process.def.sessionProcessId,
|
||||
projectId: process.def.projectId,
|
||||
sessionId: process.sessionId,
|
||||
status: process.type === "paused" ? "paused" : "running",
|
||||
})),
|
||||
changed: nextState,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`sessionProcessStateChanged(${sessionProcessId}): ${targetProcess?.type} -> ${nextState.type}`,
|
||||
);
|
||||
|
||||
return nextState;
|
||||
});
|
||||
};
|
||||
|
||||
const changeTaskState = <T extends CCTask.ClaudeCodeTaskState>(options: {
|
||||
sessionProcessId: string;
|
||||
taskId: string;
|
||||
nextTask: T;
|
||||
}) => {
|
||||
const { sessionProcessId, taskId, nextTask } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const { task } = yield* getTask(taskId);
|
||||
|
||||
yield* Ref.update(processesRef, (processes) => {
|
||||
return processes.map((p) =>
|
||||
p.def.sessionProcessId === sessionProcessId
|
||||
? {
|
||||
...p,
|
||||
tasks: p.tasks.map((t) =>
|
||||
t.def.taskId === task.def.taskId ? { ...nextTask } : t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
);
|
||||
});
|
||||
|
||||
const updated = yield* getTask(taskId);
|
||||
if (updated === undefined) {
|
||||
throw new Error("Unreachable: updatedProcess is undefined");
|
||||
}
|
||||
|
||||
return updated.task as T;
|
||||
});
|
||||
};
|
||||
|
||||
const toNotInitializedState = (options: {
|
||||
sessionProcessId: string;
|
||||
rawUserMessage: string;
|
||||
}) => {
|
||||
const { sessionProcessId, rawUserMessage } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const currentProcess = yield* getSessionProcess(sessionProcessId);
|
||||
|
||||
if (currentProcess.type !== "pending") {
|
||||
return yield* Effect.fail(
|
||||
new IllegalStateChangeError({
|
||||
from: currentProcess.type,
|
||||
to: "not_initialized",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const newTask = yield* changeTaskState({
|
||||
sessionProcessId,
|
||||
taskId: currentProcess.currentTask.def.taskId,
|
||||
nextTask: {
|
||||
status: "running",
|
||||
def: currentProcess.currentTask.def,
|
||||
},
|
||||
});
|
||||
|
||||
const newProcess = yield* dangerouslyChangeProcessState({
|
||||
sessionProcessId,
|
||||
nextState: {
|
||||
type: "not_initialized",
|
||||
def: currentProcess.def,
|
||||
tasks: currentProcess.tasks,
|
||||
currentTask: newTask,
|
||||
rawUserMessage,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sessionProcess: newProcess,
|
||||
task: newTask,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toInitializedState = (options: {
|
||||
sessionProcessId: string;
|
||||
initContext: InitMessageContext;
|
||||
}) => {
|
||||
const { sessionProcessId, initContext } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const currentProcess = yield* getSessionProcess(sessionProcessId);
|
||||
if (currentProcess.type !== "not_initialized") {
|
||||
return yield* Effect.fail(
|
||||
new IllegalStateChangeError({
|
||||
from: currentProcess.type,
|
||||
to: "initialized",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const newProcess = yield* dangerouslyChangeProcessState({
|
||||
sessionProcessId,
|
||||
nextState: {
|
||||
type: "initialized",
|
||||
def: currentProcess.def,
|
||||
tasks: currentProcess.tasks,
|
||||
currentTask: currentProcess.currentTask,
|
||||
sessionId: initContext.initMessage.session_id,
|
||||
rawUserMessage: currentProcess.rawUserMessage,
|
||||
initContext: initContext,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sessionProcess: newProcess,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toPausedState = (options: {
|
||||
sessionProcessId: string;
|
||||
resultMessage: SDKResultMessage;
|
||||
}) => {
|
||||
const { sessionProcessId, resultMessage } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const currentProcess = yield* getSessionProcess(sessionProcessId);
|
||||
if (currentProcess.type !== "initialized") {
|
||||
return yield* Effect.fail(
|
||||
new IllegalStateChangeError({
|
||||
from: currentProcess.type,
|
||||
to: "paused",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const newTask = yield* changeTaskState({
|
||||
sessionProcessId,
|
||||
taskId: currentProcess.currentTask.def.taskId,
|
||||
nextTask: {
|
||||
status: "completed",
|
||||
def: currentProcess.currentTask.def,
|
||||
sessionId: resultMessage.session_id,
|
||||
},
|
||||
});
|
||||
|
||||
const newProcess = yield* dangerouslyChangeProcessState({
|
||||
sessionProcessId,
|
||||
nextState: {
|
||||
type: "paused",
|
||||
def: currentProcess.def,
|
||||
tasks: currentProcess.tasks.map((t) =>
|
||||
t.def.taskId === newTask.def.taskId ? newTask : t,
|
||||
),
|
||||
sessionId: currentProcess.sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sessionProcess: newProcess,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toCompletedState = (options: {
|
||||
sessionProcessId: string;
|
||||
error?: unknown;
|
||||
}) => {
|
||||
const { sessionProcessId, error } = options;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const currentProcess = yield* getSessionProcess(sessionProcessId);
|
||||
|
||||
const currentTask =
|
||||
currentProcess.type === "not_initialized" ||
|
||||
currentProcess.type === "initialized"
|
||||
? currentProcess.currentTask
|
||||
: undefined;
|
||||
|
||||
const newTask =
|
||||
currentTask !== undefined
|
||||
? error !== undefined
|
||||
? ({
|
||||
status: "failed",
|
||||
def: currentTask.def,
|
||||
error,
|
||||
} as const)
|
||||
: ({
|
||||
status: "completed",
|
||||
def: currentTask.def,
|
||||
sessionId: currentProcess.sessionId,
|
||||
} as const)
|
||||
: undefined;
|
||||
|
||||
if (newTask !== undefined) {
|
||||
yield* changeTaskState({
|
||||
sessionProcessId,
|
||||
taskId: newTask.def.taskId,
|
||||
nextTask: newTask,
|
||||
});
|
||||
}
|
||||
|
||||
const newProcess = yield* dangerouslyChangeProcessState({
|
||||
sessionProcessId,
|
||||
nextState: {
|
||||
type: "completed",
|
||||
def: currentProcess.def,
|
||||
tasks:
|
||||
newTask !== undefined
|
||||
? currentProcess.tasks.map((t) =>
|
||||
t.def.taskId === newTask.def.taskId ? newTask : t,
|
||||
)
|
||||
: currentProcess.tasks,
|
||||
sessionId: currentProcess.sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sessionProcess: newProcess,
|
||||
task: newTask,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
// session
|
||||
startSessionProcess,
|
||||
continueSessionProcess,
|
||||
toNotInitializedState,
|
||||
toInitializedState,
|
||||
toPausedState,
|
||||
toCompletedState,
|
||||
dangerouslyChangeProcessState,
|
||||
getSessionProcesses,
|
||||
getSessionProcess,
|
||||
|
||||
// task
|
||||
getTask,
|
||||
changeTaskState,
|
||||
};
|
||||
});
|
||||
|
||||
export type IClaudeCodeSessionProcessService = InferEffect<typeof LayerImpl>;
|
||||
|
||||
export class ClaudeCodeSessionProcessService extends Context.Tag(
|
||||
"ClaudeCodeSessionProcessService",
|
||||
)<ClaudeCodeSessionProcessService, IClaudeCodeSessionProcessService>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
import prexit from "prexit";
|
||||
import { ulid } from "ulid";
|
||||
import type { Config } from "../../config/config";
|
||||
import { eventBus } from "../events/EventBus";
|
||||
import { predictSessionsDatabase } from "../session/PredictSessionsDatabase";
|
||||
import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor";
|
||||
import { createMessageGenerator } from "./createMessageGenerator";
|
||||
import type {
|
||||
AliveClaudeCodeTask,
|
||||
ClaudeCodeTask,
|
||||
PendingClaudeCodeTask,
|
||||
PermissionRequest,
|
||||
PermissionResponse,
|
||||
RunningClaudeCodeTask,
|
||||
} from "./types";
|
||||
|
||||
export class ClaudeCodeTaskController {
|
||||
private claudeCode: ClaudeCodeExecutor;
|
||||
private tasks: ClaudeCodeTask[] = [];
|
||||
private config: Config;
|
||||
private pendingPermissionRequests: Map<string, PermissionRequest> = new Map();
|
||||
private permissionResponses: Map<string, PermissionResponse> = new Map();
|
||||
|
||||
constructor(config: Config) {
|
||||
this.claudeCode = new ClaudeCodeExecutor();
|
||||
this.eventBus = getEventBus();
|
||||
this.config = config;
|
||||
|
||||
prexit(() => {
|
||||
this.aliveTasks.forEach((task) => {
|
||||
task.abortController.abort();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public updateConfig(config: Config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public respondToPermissionRequest(response: PermissionResponse) {
|
||||
this.permissionResponses.set(response.permissionRequestId, response);
|
||||
this.pendingPermissionRequests.delete(response.permissionRequestId);
|
||||
}
|
||||
|
||||
private createCanUseToolCallback(taskId: string, sessionId?: string) {
|
||||
return async (
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
_options: { signal: AbortSignal },
|
||||
) => {
|
||||
// If not in default mode, use the configured permission mode behavior
|
||||
if (this.config.permissionMode !== "default") {
|
||||
// Convert Claude Code permission modes to canUseTool behaviors
|
||||
if (
|
||||
this.config.permissionMode === "bypassPermissions" ||
|
||||
this.config.permissionMode === "acceptEdits"
|
||||
) {
|
||||
return {
|
||||
behavior: "allow" as const,
|
||||
updatedInput: toolInput,
|
||||
};
|
||||
} else {
|
||||
// plan mode should deny actual tool execution
|
||||
return {
|
||||
behavior: "deny" as const,
|
||||
message: "Tool execution is disabled in plan mode",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create permission request
|
||||
const permissionRequest: PermissionRequest = {
|
||||
id: ulid(),
|
||||
taskId,
|
||||
sessionId,
|
||||
toolName,
|
||||
toolInput,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Store the request
|
||||
this.pendingPermissionRequests.set(
|
||||
permissionRequest.id,
|
||||
permissionRequest,
|
||||
);
|
||||
|
||||
// Emit event to notify UI
|
||||
eventBus.emit("permissionRequested", {
|
||||
permissionRequest,
|
||||
});
|
||||
|
||||
// Wait for user response with timeout
|
||||
const response = await this.waitForPermissionResponse(
|
||||
permissionRequest.id,
|
||||
60000,
|
||||
); // 60 second timeout
|
||||
|
||||
if (response) {
|
||||
if (response.decision === "allow") {
|
||||
return {
|
||||
behavior: "allow" as const,
|
||||
updatedInput: toolInput,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
behavior: "deny" as const,
|
||||
message: "Permission denied by user",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Timeout - default to deny for security
|
||||
this.pendingPermissionRequests.delete(permissionRequest.id);
|
||||
return {
|
||||
behavior: "deny" as const,
|
||||
message: "Permission request timed out",
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async waitForPermissionResponse(
|
||||
permissionRequestId: string,
|
||||
timeoutMs: number,
|
||||
): Promise<PermissionResponse | null> {
|
||||
return new Promise((resolve) => {
|
||||
const checkResponse = () => {
|
||||
const response = this.permissionResponses.get(permissionRequestId);
|
||||
if (response) {
|
||||
this.permissionResponses.delete(permissionRequestId);
|
||||
resolve(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if request was cancelled/deleted
|
||||
if (!this.pendingPermissionRequests.has(permissionRequestId)) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue polling
|
||||
setTimeout(checkResponse, 100);
|
||||
};
|
||||
|
||||
// Set timeout
|
||||
setTimeout(() => {
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
|
||||
// Start polling
|
||||
checkResponse();
|
||||
});
|
||||
}
|
||||
|
||||
public get aliveTasks() {
|
||||
return this.tasks.filter(
|
||||
(task) => task.status === "running" || task.status === "paused",
|
||||
);
|
||||
}
|
||||
|
||||
public async startOrContinueTask(
|
||||
currentSession: {
|
||||
cwd: string;
|
||||
projectId: string;
|
||||
sessionId?: string;
|
||||
},
|
||||
message: string,
|
||||
): Promise<AliveClaudeCodeTask> {
|
||||
const existingTask = this.aliveTasks.find(
|
||||
(task) => task.sessionId === currentSession.sessionId,
|
||||
);
|
||||
|
||||
if (existingTask) {
|
||||
console.log(
|
||||
`Alive task for session(id=${currentSession.sessionId}) continued.`,
|
||||
);
|
||||
const result = await this.continueTask(existingTask, message);
|
||||
return result;
|
||||
} else {
|
||||
if (currentSession.sessionId === undefined) {
|
||||
console.log(`New task started.`);
|
||||
} else {
|
||||
console.log(
|
||||
`New task started for existing session(id=${currentSession.sessionId}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.startTask(currentSession, message);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private async continueTask(task: AliveClaudeCodeTask, message: string) {
|
||||
task.setNextMessage(message);
|
||||
await task.awaitFirstMessage();
|
||||
return task;
|
||||
}
|
||||
|
||||
private startTask(
|
||||
currentSession: {
|
||||
cwd: string;
|
||||
projectId: string;
|
||||
sessionId?: string;
|
||||
},
|
||||
userMessage: string,
|
||||
) {
|
||||
const {
|
||||
generateMessages,
|
||||
setNextMessage,
|
||||
setFirstMessagePromise,
|
||||
resolveFirstMessage,
|
||||
awaitFirstMessage,
|
||||
} = createMessageGenerator(userMessage);
|
||||
|
||||
const task: PendingClaudeCodeTask = {
|
||||
status: "pending",
|
||||
id: ulid(),
|
||||
projectId: currentSession.projectId,
|
||||
baseSessionId: currentSession.sessionId,
|
||||
cwd: currentSession.cwd,
|
||||
generateMessages,
|
||||
setNextMessage,
|
||||
setFirstMessagePromise,
|
||||
resolveFirstMessage,
|
||||
awaitFirstMessage,
|
||||
onMessageHandlers: [],
|
||||
};
|
||||
|
||||
let aliveTaskResolve: (task: AliveClaudeCodeTask) => void;
|
||||
let aliveTaskReject: (error: unknown) => void;
|
||||
|
||||
const aliveTaskPromise = new Promise<AliveClaudeCodeTask>(
|
||||
(resolve, reject) => {
|
||||
aliveTaskResolve = resolve;
|
||||
aliveTaskReject = reject;
|
||||
},
|
||||
);
|
||||
|
||||
let resolved = false;
|
||||
|
||||
const handleTask = async () => {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
let currentTask: AliveClaudeCodeTask | undefined;
|
||||
|
||||
for await (const message of this.claudeCode.query(
|
||||
task.generateMessages(),
|
||||
{
|
||||
resume: task.baseSessionId,
|
||||
cwd: task.cwd,
|
||||
permissionMode: this.config.permissionMode,
|
||||
canUseTool: this.createCanUseToolCallback(
|
||||
task.id,
|
||||
task.baseSessionId,
|
||||
),
|
||||
abortController: abortController,
|
||||
},
|
||||
)) {
|
||||
currentTask ??= this.aliveTasks.find((t) => t.id === task.id);
|
||||
|
||||
if (currentTask !== undefined && currentTask.status === "paused") {
|
||||
this.upsertExistingTask({
|
||||
...currentTask,
|
||||
status: "running",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === "system" &&
|
||||
message.subtype === "init" &&
|
||||
currentSession.sessionId === undefined
|
||||
) {
|
||||
// because it takes time for the Claude Code file to be updated, simulate the message
|
||||
predictSessionsDatabase.createPredictSession({
|
||||
id: message.session_id,
|
||||
jsonlFilePath: message.session_id,
|
||||
conversations: [
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: userMessage,
|
||||
},
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: message.cwd,
|
||||
sessionId: message.session_id,
|
||||
version: this.claudeCode.version?.toString() ?? "unknown",
|
||||
uuid: message.uuid,
|
||||
timestamp: new Date().toISOString(),
|
||||
parentUuid: null,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
firstCommand: null,
|
||||
lastModifiedAt: new Date().toISOString(),
|
||||
messageCount: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!resolved) {
|
||||
const runningTask: RunningClaudeCodeTask = {
|
||||
status: "running",
|
||||
id: task.id,
|
||||
projectId: task.projectId,
|
||||
cwd: task.cwd,
|
||||
generateMessages: task.generateMessages,
|
||||
setNextMessage: task.setNextMessage,
|
||||
resolveFirstMessage: task.resolveFirstMessage,
|
||||
setFirstMessagePromise: task.setFirstMessagePromise,
|
||||
awaitFirstMessage: task.awaitFirstMessage,
|
||||
onMessageHandlers: task.onMessageHandlers,
|
||||
sessionId: message.session_id,
|
||||
abortController: abortController,
|
||||
};
|
||||
this.tasks.push(runningTask);
|
||||
aliveTaskResolve(runningTask);
|
||||
resolved = true;
|
||||
}
|
||||
|
||||
resolveFirstMessage();
|
||||
|
||||
await Promise.all(
|
||||
task.onMessageHandlers.map(async (onMessageHandler) => {
|
||||
await onMessageHandler(message);
|
||||
}),
|
||||
);
|
||||
|
||||
if (currentTask !== undefined && message.type === "result") {
|
||||
this.upsertExistingTask({
|
||||
...currentTask,
|
||||
status: "paused",
|
||||
});
|
||||
resolved = true;
|
||||
setFirstMessagePromise();
|
||||
predictSessionsDatabase.deletePredictSession(currentTask.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedTask = this.aliveTasks.find((t) => t.id === task.id);
|
||||
|
||||
if (updatedTask === undefined) {
|
||||
console.log(
|
||||
"[DEBUG startTask] 17. ERROR: Task not found in aliveTasks",
|
||||
);
|
||||
const error = new Error(
|
||||
`illegal state: task is not running, task: ${JSON.stringify(
|
||||
updatedTask,
|
||||
)}`,
|
||||
);
|
||||
aliveTaskReject(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.upsertExistingTask({
|
||||
...updatedTask,
|
||||
status: "completed",
|
||||
});
|
||||
} catch (error) {
|
||||
if (!resolved) {
|
||||
console.log(
|
||||
"[DEBUG startTask] 20. Rejecting task (not yet resolved)",
|
||||
);
|
||||
aliveTaskReject(error);
|
||||
resolved = true;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message, error.stack);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
this.upsertExistingTask({
|
||||
...task,
|
||||
status: "failed",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// continue background
|
||||
void handleTask();
|
||||
|
||||
return aliveTaskPromise;
|
||||
}
|
||||
|
||||
public abortTask(sessionId: string) {
|
||||
const task = this.aliveTasks.find((task) => task.sessionId === sessionId);
|
||||
if (!task) {
|
||||
throw new Error("Alive Task not found");
|
||||
}
|
||||
|
||||
task.abortController.abort();
|
||||
this.upsertExistingTask({
|
||||
id: task.id,
|
||||
projectId: task.projectId,
|
||||
sessionId: task.sessionId,
|
||||
status: "failed",
|
||||
cwd: task.cwd,
|
||||
generateMessages: task.generateMessages,
|
||||
setNextMessage: task.setNextMessage,
|
||||
resolveFirstMessage: task.resolveFirstMessage,
|
||||
setFirstMessagePromise: task.setFirstMessagePromise,
|
||||
awaitFirstMessage: task.awaitFirstMessage,
|
||||
onMessageHandlers: task.onMessageHandlers,
|
||||
baseSessionId: task.baseSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
private upsertExistingTask(task: ClaudeCodeTask) {
|
||||
const target = this.tasks.find((t) => t.id === task.id);
|
||||
|
||||
if (!target) {
|
||||
console.error("Task not found", task);
|
||||
this.tasks.push(task);
|
||||
} else {
|
||||
Object.assign(target, task);
|
||||
}
|
||||
|
||||
if (task.status === "paused" || task.status === "running") {
|
||||
this.eventBus.emit("taskChanged", {
|
||||
aliveTasks: this.aliveTasks,
|
||||
changed: task,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const claudeCodeTaskController = new ClaudeCodeTaskController();
|
||||
@@ -1,71 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const versionRegex = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/;
|
||||
const versionSchema = z
|
||||
.object({
|
||||
major: z.string().transform((value) => Number.parseInt(value, 10)),
|
||||
minor: z.string().transform((value) => Number.parseInt(value, 10)),
|
||||
patch: z.string().transform((value) => Number.parseInt(value, 10)),
|
||||
})
|
||||
.refine((data) =>
|
||||
[data.major, data.minor, data.patch].every((value) => !Number.isNaN(value)),
|
||||
);
|
||||
|
||||
type ParsedVersion = z.infer<typeof versionSchema>;
|
||||
|
||||
export class ClaudeCodeVersion {
|
||||
public constructor(public readonly version: ParsedVersion) {}
|
||||
|
||||
public static fromCLIString(version: string) {
|
||||
const groups = version.trim().match(versionRegex)?.groups;
|
||||
|
||||
if (groups === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = versionSchema.safeParse(groups);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ClaudeCodeVersion(parsed.data);
|
||||
}
|
||||
|
||||
public get major() {
|
||||
return this.version.major;
|
||||
}
|
||||
|
||||
public get minor() {
|
||||
return this.version.minor;
|
||||
}
|
||||
|
||||
public get patch() {
|
||||
return this.version.patch;
|
||||
}
|
||||
|
||||
public toString() {
|
||||
return `${this.major}.${this.minor}.${this.patch}`;
|
||||
}
|
||||
|
||||
public equals(other: ClaudeCodeVersion) {
|
||||
return (
|
||||
this.version.major === other.version.major &&
|
||||
this.version.minor === other.version.minor &&
|
||||
this.version.patch === other.version.patch
|
||||
);
|
||||
}
|
||||
|
||||
public greaterThan(other: ClaudeCodeVersion) {
|
||||
return (
|
||||
this.version.major > other.version.major ||
|
||||
(this.version.major === other.version.major &&
|
||||
(this.version.minor > other.version.minor ||
|
||||
(this.version.minor === other.version.minor &&
|
||||
this.version.patch > other.version.patch)))
|
||||
);
|
||||
}
|
||||
|
||||
public greaterThanOrEqual(other: ClaudeCodeVersion) {
|
||||
return this.equals(other) || this.greaterThan(other);
|
||||
}
|
||||
}
|
||||
83
src/server/service/claude-code/MessageGenerator.ts
Normal file
83
src/server/service/claude-code/MessageGenerator.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-code";
|
||||
import { controllablePromise } from "../../../lib/controllablePromise";
|
||||
|
||||
export type OnMessage = (message: SDKMessage) => void | Promise<void>;
|
||||
|
||||
export type MessageGenerator = () => AsyncGenerator<
|
||||
SDKUserMessage,
|
||||
void,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export const createMessageGenerator = (): {
|
||||
generateMessages: MessageGenerator;
|
||||
setNextMessage: (message: string) => void;
|
||||
setHooks: (hooks: {
|
||||
onNextMessageSet?: (message: string) => void | Promise<void>;
|
||||
onNewUserMessageResolved?: (message: string) => void | Promise<void>;
|
||||
}) => void;
|
||||
} => {
|
||||
let sendMessagePromise = controllablePromise<string>();
|
||||
let registeredHooks: {
|
||||
onNextMessageSet: ((message: string) => void | Promise<void>)[];
|
||||
onNewUserMessageResolved: ((message: string) => void | Promise<void>)[];
|
||||
} = {
|
||||
onNextMessageSet: [],
|
||||
onNewUserMessageResolved: [],
|
||||
};
|
||||
|
||||
const createMessage = (message: string): SDKUserMessage => {
|
||||
return {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: message,
|
||||
},
|
||||
} as SDKUserMessage;
|
||||
};
|
||||
|
||||
async function* generateMessages(): ReturnType<MessageGenerator> {
|
||||
sendMessagePromise = controllablePromise<string>();
|
||||
|
||||
while (true) {
|
||||
const message = await sendMessagePromise.promise;
|
||||
sendMessagePromise = controllablePromise<string>();
|
||||
void Promise.allSettled(
|
||||
registeredHooks.onNewUserMessageResolved.map((hook) => hook(message)),
|
||||
);
|
||||
|
||||
yield createMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
const setNextMessage = (message: string) => {
|
||||
sendMessagePromise.resolve(message);
|
||||
void Promise.allSettled(
|
||||
registeredHooks.onNextMessageSet.map((hook) => hook(message)),
|
||||
);
|
||||
};
|
||||
|
||||
const setHooks = (hooks: {
|
||||
onNextMessageSet?: (message: string) => void | Promise<void>;
|
||||
onNewUserMessageResolved?: (message: string) => void | Promise<void>;
|
||||
}) => {
|
||||
registeredHooks = {
|
||||
onNextMessageSet: [
|
||||
...(hooks?.onNextMessageSet ? [hooks.onNextMessageSet] : []),
|
||||
...registeredHooks.onNextMessageSet,
|
||||
],
|
||||
onNewUserMessageResolved: [
|
||||
...(hooks?.onNewUserMessageResolved
|
||||
? [hooks.onNewUserMessageResolved]
|
||||
: []),
|
||||
...registeredHooks.onNewUserMessageResolved,
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
generateMessages,
|
||||
setNextMessage,
|
||||
setHooks,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-code";
|
||||
import type {
|
||||
SDKMessage,
|
||||
SDKSystemMessage,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-code";
|
||||
|
||||
export type OnMessage = (message: SDKMessage) => void | Promise<void>;
|
||||
|
||||
@@ -28,17 +32,21 @@ const createPromise = <T>() => {
|
||||
} as const;
|
||||
};
|
||||
|
||||
export type InitMessageContext = {
|
||||
initMessage: SDKSystemMessage;
|
||||
};
|
||||
|
||||
export const createMessageGenerator = (
|
||||
firstMessage: string,
|
||||
): {
|
||||
generateMessages: MessageGenerator;
|
||||
setNextMessage: (message: string) => void;
|
||||
setFirstMessagePromise: () => void;
|
||||
resolveFirstMessage: () => void;
|
||||
awaitFirstMessage: () => Promise<void>;
|
||||
setInitMessagePromise: () => void;
|
||||
resolveInitMessage: (context: InitMessageContext) => void;
|
||||
awaitInitMessage: (ctx: InitMessageContext) => Promise<void>;
|
||||
} => {
|
||||
let sendMessagePromise = createPromise<string>();
|
||||
let receivedFirstMessagePromise = createPromise<undefined>();
|
||||
let receivedInitMessagePromise = createPromise<InitMessageContext>();
|
||||
|
||||
const createMessage = (message: string): SDKUserMessage => {
|
||||
return {
|
||||
@@ -65,23 +73,23 @@ export const createMessageGenerator = (
|
||||
sendMessagePromise.resolve(message);
|
||||
};
|
||||
|
||||
const setFirstMessagePromise = () => {
|
||||
receivedFirstMessagePromise = createPromise<undefined>();
|
||||
const setInitMessagePromise = () => {
|
||||
receivedInitMessagePromise = createPromise<InitMessageContext>();
|
||||
};
|
||||
|
||||
const resolveFirstMessage = () => {
|
||||
receivedFirstMessagePromise.resolve(undefined);
|
||||
const resolveInitMessage = (context: InitMessageContext) => {
|
||||
receivedInitMessagePromise.resolve(context);
|
||||
};
|
||||
|
||||
const awaitFirstMessage = async () => {
|
||||
await receivedFirstMessagePromise.promise;
|
||||
const awaitInitMessage = async () => {
|
||||
await receivedInitMessagePromise.promise;
|
||||
};
|
||||
|
||||
return {
|
||||
generateMessages,
|
||||
setNextMessage,
|
||||
setFirstMessagePromise,
|
||||
resolveFirstMessage,
|
||||
awaitFirstMessage,
|
||||
setInitMessagePromise,
|
||||
resolveInitMessage,
|
||||
awaitInitMessage,
|
||||
};
|
||||
};
|
||||
|
||||
108
src/server/service/claude-code/models/CCSessionProcess.ts
Normal file
108
src/server/service/claude-code/models/CCSessionProcess.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Effect } from "effect";
|
||||
import type { UserEntry } from "../../../../lib/conversation-schema/entry/UserEntrySchema";
|
||||
import * as ClaudeCode from "../ClaudeCode";
|
||||
import type { InitMessageContext } from "../createMessageGenerator";
|
||||
import type * as CCTask from "./ClaudeCodeTask";
|
||||
import * as ClaudeCodeVersion from "./ClaudeCodeVersion";
|
||||
|
||||
export type CCSessionProcessDef = {
|
||||
sessionProcessId: string;
|
||||
projectId: string;
|
||||
cwd: string;
|
||||
abortController: AbortController;
|
||||
setNextMessage: (message: string) => void;
|
||||
};
|
||||
|
||||
type CCSessionProcessStateBase = {
|
||||
def: CCSessionProcessDef;
|
||||
tasks: CCTask.ClaudeCodeTaskState[];
|
||||
};
|
||||
|
||||
export type CCSessionProcessPendingState = CCSessionProcessStateBase & {
|
||||
type: "pending" /* メッセージがまだ解決されていない状態 */;
|
||||
sessionId?: undefined;
|
||||
currentTask: CCTask.PendingClaudeCodeTaskState;
|
||||
};
|
||||
|
||||
export type CCSessionProcessNotInitializedState = CCSessionProcessStateBase & {
|
||||
type: "not_initialized" /* メッセージは解決されているが、init メッセージを未受信 */;
|
||||
sessionId?: undefined;
|
||||
currentTask: CCTask.RunningClaudeCodeTaskState;
|
||||
rawUserMessage: string;
|
||||
};
|
||||
|
||||
export type CCSessionProcessInitializedState = CCSessionProcessStateBase & {
|
||||
type: "initialized" /* init メッセージを受信した状態 */;
|
||||
sessionId: string;
|
||||
currentTask: CCTask.RunningClaudeCodeTaskState;
|
||||
rawUserMessage: string;
|
||||
initContext: InitMessageContext;
|
||||
};
|
||||
|
||||
export type CCSessionProcessPausedState = CCSessionProcessStateBase & {
|
||||
type: "paused" /* タスクが完了し、次のタスクを受け付け可能 */;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type CCSessionProcessCompletedState = CCSessionProcessStateBase & {
|
||||
type: "completed" /* paused あるいは起動中のタスクが中断された状態。再開不可 */;
|
||||
sessionId?: string | undefined;
|
||||
};
|
||||
|
||||
export type CCSessionProcessStatePublic =
|
||||
| CCSessionProcessInitializedState
|
||||
| CCSessionProcessPausedState;
|
||||
|
||||
export type CCSessionProcessState =
|
||||
| CCSessionProcessPendingState
|
||||
| CCSessionProcessNotInitializedState
|
||||
| CCSessionProcessStatePublic
|
||||
| CCSessionProcessCompletedState;
|
||||
|
||||
export const isPublic = (
|
||||
process: CCSessionProcessState,
|
||||
): process is CCSessionProcessStatePublic => {
|
||||
return process.type === "initialized" || process.type === "paused";
|
||||
};
|
||||
|
||||
export const getAliveTasks = (
|
||||
process: CCSessionProcessState,
|
||||
): CCTask.AliveClaudeCodeTaskState[] => {
|
||||
return process.tasks.filter(
|
||||
(task) => task.status === "pending" || task.status === "running",
|
||||
);
|
||||
};
|
||||
|
||||
export const createVirtualConversation = (
|
||||
process: CCSessionProcessState,
|
||||
ctx: {
|
||||
sessionId: string;
|
||||
userMessage: string;
|
||||
},
|
||||
) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const config = yield* ClaudeCode.Config;
|
||||
|
||||
const virtualConversation: UserEntry = {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: ctx.userMessage,
|
||||
},
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: process.def.cwd,
|
||||
sessionId: ctx.sessionId,
|
||||
version: config.claudeCodeVersion
|
||||
? ClaudeCodeVersion.versionText(config.claudeCodeVersion)
|
||||
: "unknown",
|
||||
uuid: `vc__${ctx.sessionId}__${timestamp}`,
|
||||
timestamp,
|
||||
parentUuid: null,
|
||||
};
|
||||
|
||||
return virtualConversation;
|
||||
});
|
||||
};
|
||||
59
src/server/service/claude-code/models/ClaudeCodeTask.ts
Normal file
59
src/server/service/claude-code/models/ClaudeCodeTask.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
type BaseClaudeCodeTaskDef = {
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
export type NewClaudeCodeTaskDef = BaseClaudeCodeTaskDef & {
|
||||
type: "new";
|
||||
sessionId?: undefined;
|
||||
baseSessionId?: undefined;
|
||||
};
|
||||
|
||||
export type ContinueClaudeCodeTaskDef = BaseClaudeCodeTaskDef & {
|
||||
type: "continue";
|
||||
sessionId: string;
|
||||
baseSessionId: string;
|
||||
};
|
||||
|
||||
export type ResumeClaudeCodeTaskDef = BaseClaudeCodeTaskDef & {
|
||||
type: "resume";
|
||||
sessionId?: undefined;
|
||||
baseSessionId: string;
|
||||
};
|
||||
|
||||
export type ClaudeCodeTaskDef =
|
||||
| NewClaudeCodeTaskDef
|
||||
| ContinueClaudeCodeTaskDef
|
||||
| ResumeClaudeCodeTaskDef;
|
||||
|
||||
type ClaudeCodeTaskStateBase = {
|
||||
def: ClaudeCodeTaskDef;
|
||||
};
|
||||
|
||||
export type PendingClaudeCodeTaskState = ClaudeCodeTaskStateBase & {
|
||||
status: "pending";
|
||||
sessionId?: undefined;
|
||||
};
|
||||
|
||||
export type RunningClaudeCodeTaskState = ClaudeCodeTaskStateBase & {
|
||||
status: "running";
|
||||
sessionId?: undefined;
|
||||
};
|
||||
|
||||
export type CompletedClaudeCodeTaskState = ClaudeCodeTaskStateBase & {
|
||||
status: "completed";
|
||||
sessionId?: string | undefined;
|
||||
};
|
||||
|
||||
export type FailedClaudeCodeTaskState = ClaudeCodeTaskStateBase & {
|
||||
status: "failed";
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export type AliveClaudeCodeTaskState =
|
||||
| PendingClaudeCodeTaskState
|
||||
| RunningClaudeCodeTaskState;
|
||||
|
||||
export type ClaudeCodeTaskState =
|
||||
| AliveClaudeCodeTaskState
|
||||
| CompletedClaudeCodeTaskState
|
||||
| FailedClaudeCodeTaskState;
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as ClaudeCodeVersion from "./ClaudeCodeVersion";
|
||||
|
||||
describe("ClaudeCodeVersion.fromCLIString", () => {
|
||||
describe("with valid version string", () => {
|
||||
it("should correctly parse CLI output format: 'x.x.x (Claude Code)'", () => {
|
||||
const version = ClaudeCodeVersion.fromCLIString("1.0.53 (Claude Code)\n");
|
||||
expect(version).toStrictEqual({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 53,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("with invalid version string", () => {
|
||||
it("should return null for non-version format strings", () => {
|
||||
const version = ClaudeCodeVersion.fromCLIString("invalid version");
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeCodeVersion.versionText", () => {
|
||||
it("should convert version object to 'major.minor.patch' format string", () => {
|
||||
const text = ClaudeCodeVersion.versionText({
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 53,
|
||||
});
|
||||
expect(text).toBe("1.0.53");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeCodeVersion.equals", () => {
|
||||
describe("with same version", () => {
|
||||
it("should return true", () => {
|
||||
const a = { major: 1, minor: 0, patch: 53 };
|
||||
const b = { major: 1, minor: 0, patch: 53 };
|
||||
expect(ClaudeCodeVersion.equals(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeCodeVersion.greaterThan", () => {
|
||||
describe("when a is greater than b", () => {
|
||||
it("should return true when major is greater", () => {
|
||||
const a = { major: 2, minor: 0, patch: 0 };
|
||||
const b = { major: 1, minor: 9, patch: 99 };
|
||||
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when major is same and minor is greater", () => {
|
||||
const a = { major: 1, minor: 1, patch: 0 };
|
||||
const b = { major: 1, minor: 0, patch: 99 };
|
||||
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when major and minor are same and patch is greater", () => {
|
||||
const a = { major: 1, minor: 0, patch: 86 };
|
||||
const b = { major: 1, minor: 0, patch: 85 };
|
||||
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a is less than or equal to b", () => {
|
||||
it("should return false for same version", () => {
|
||||
const a = { major: 1, minor: 0, patch: 53 };
|
||||
const b = { major: 1, minor: 0, patch: 53 };
|
||||
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when a is less than b", () => {
|
||||
const a = { major: 1, minor: 0, patch: 81 };
|
||||
const b = { major: 1, minor: 0, patch: 82 };
|
||||
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when major is less", () => {
|
||||
const a = { major: 1, minor: 9, patch: 99 };
|
||||
const b = { major: 2, minor: 0, patch: 0 };
|
||||
expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
47
src/server/service/claude-code/models/ClaudeCodeVersion.ts
Normal file
47
src/server/service/claude-code/models/ClaudeCodeVersion.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const versionRegex = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/;
|
||||
const versionSchema = z
|
||||
.object({
|
||||
major: z.string().transform((value) => Number.parseInt(value, 10)),
|
||||
minor: z.string().transform((value) => Number.parseInt(value, 10)),
|
||||
patch: z.string().transform((value) => Number.parseInt(value, 10)),
|
||||
})
|
||||
.refine((data) =>
|
||||
[data.major, data.minor, data.patch].every((value) => !Number.isNaN(value)),
|
||||
);
|
||||
|
||||
export type ClaudeCodeVersion = z.infer<typeof versionSchema>;
|
||||
|
||||
export const fromCLIString = (
|
||||
versionOutput: string,
|
||||
): ClaudeCodeVersion | null => {
|
||||
const groups = versionOutput.trim().match(versionRegex)?.groups;
|
||||
|
||||
if (groups === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = versionSchema.safeParse(groups);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
export const versionText = (version: ClaudeCodeVersion) =>
|
||||
`${version.major}.${version.minor}.${version.patch}`;
|
||||
|
||||
export const equals = (a: ClaudeCodeVersion, b: ClaudeCodeVersion) =>
|
||||
a.major === b.major && a.minor === b.minor && a.patch === b.patch;
|
||||
|
||||
export const greaterThan = (a: ClaudeCodeVersion, b: ClaudeCodeVersion) =>
|
||||
a.major > b.major ||
|
||||
(a.major === b.major &&
|
||||
(a.minor > b.minor || (a.minor === b.minor && a.patch > b.patch)));
|
||||
|
||||
export const greaterThanOrEqual = (
|
||||
a: ClaudeCodeVersion,
|
||||
b: ClaudeCodeVersion,
|
||||
) => equals(a, b) || greaterThan(a, b);
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { MessageGenerator, OnMessage } from "./createMessageGenerator";
|
||||
|
||||
type BaseClaudeCodeTask = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
baseSessionId?: string | undefined; // undefined = new session
|
||||
cwd: string;
|
||||
generateMessages: MessageGenerator;
|
||||
setNextMessage: (message: string) => void;
|
||||
resolveFirstMessage: () => void;
|
||||
setFirstMessagePromise: () => void;
|
||||
awaitFirstMessage: () => Promise<void>;
|
||||
onMessageHandlers: OnMessage[];
|
||||
};
|
||||
|
||||
export type PendingClaudeCodeTask = BaseClaudeCodeTask & {
|
||||
status: "pending";
|
||||
};
|
||||
|
||||
export type RunningClaudeCodeTask = BaseClaudeCodeTask & {
|
||||
status: "running";
|
||||
sessionId: string;
|
||||
abortController: AbortController;
|
||||
};
|
||||
|
||||
export type PausedClaudeCodeTask = BaseClaudeCodeTask & {
|
||||
status: "paused";
|
||||
sessionId: string;
|
||||
abortController: AbortController;
|
||||
};
|
||||
|
||||
type CompletedClaudeCodeTask = BaseClaudeCodeTask & {
|
||||
status: "completed";
|
||||
sessionId: string;
|
||||
abortController: AbortController;
|
||||
resolveFirstMessage: () => void;
|
||||
};
|
||||
|
||||
type FailedClaudeCodeTask = BaseClaudeCodeTask & {
|
||||
status: "failed";
|
||||
sessionId?: string;
|
||||
userMessageId?: string;
|
||||
abortController?: AbortController;
|
||||
};
|
||||
|
||||
export type ClaudeCodeTask =
|
||||
| RunningClaudeCodeTask
|
||||
| PausedClaudeCodeTask
|
||||
| CompletedClaudeCodeTask
|
||||
| FailedClaudeCodeTask;
|
||||
|
||||
export type AliveClaudeCodeTask = RunningClaudeCodeTask | PausedClaudeCodeTask;
|
||||
|
||||
export type SerializableAliveTask = Pick<
|
||||
AliveClaudeCodeTask,
|
||||
"id" | "status" | "sessionId"
|
||||
>;
|
||||
|
||||
export type PermissionRequest = {
|
||||
id: string;
|
||||
taskId: string;
|
||||
sessionId?: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type PermissionResponse = {
|
||||
permissionRequestId: string;
|
||||
decision: "allow" | "deny";
|
||||
};
|
||||
282
src/server/service/events/EventBus.test.ts
Normal file
282
src/server/service/events/EventBus.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PermissionRequest } from "../../../types/permissions";
|
||||
import type { PublicSessionProcess } from "../../../types/session-process";
|
||||
import type { CCSessionProcessState } from "../claude-code/models/CCSessionProcess";
|
||||
import { EventBus } from "./EventBus";
|
||||
import type { InternalEventDeclaration } from "./InternalEventDeclaration";
|
||||
|
||||
describe("EventBus", () => {
|
||||
describe("basic event processing", () => {
|
||||
it("can send and receive events with emit and on", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const eventBus = yield* EventBus;
|
||||
const events: Array<InternalEventDeclaration["heartbeat"]> = [];
|
||||
|
||||
const listener = (event: InternalEventDeclaration["heartbeat"]) => {
|
||||
events.push(event);
|
||||
};
|
||||
|
||||
yield* eventBus.on("heartbeat", listener);
|
||||
yield* eventBus.emit("heartbeat", {});
|
||||
|
||||
// Wait a bit since events are processed asynchronously
|
||||
yield* Effect.sleep("10 millis");
|
||||
|
||||
return events;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(EventBus.Live)),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({});
|
||||
});
|
||||
|
||||
it("events are delivered to multiple listeners", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const eventBus = yield* EventBus;
|
||||
const events1: Array<InternalEventDeclaration["sessionChanged"]> = [];
|
||||
const events2: Array<InternalEventDeclaration["sessionChanged"]> = [];
|
||||
|
||||
const listener1 = (
|
||||
event: InternalEventDeclaration["sessionChanged"],
|
||||
) => {
|
||||
events1.push(event);
|
||||
};
|
||||
|
||||
const listener2 = (
|
||||
event: InternalEventDeclaration["sessionChanged"],
|
||||
) => {
|
||||
events2.push(event);
|
||||
};
|
||||
|
||||
yield* eventBus.on("sessionChanged", listener1);
|
||||
yield* eventBus.on("sessionChanged", listener2);
|
||||
|
||||
yield* eventBus.emit("sessionChanged", {
|
||||
projectId: "project-1",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
yield* Effect.sleep("10 millis");
|
||||
|
||||
return { events1, events2 };
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(EventBus.Live)),
|
||||
);
|
||||
|
||||
expect(result.events1).toHaveLength(1);
|
||||
expect(result.events2).toHaveLength(1);
|
||||
expect(result.events1[0]).toEqual({
|
||||
projectId: "project-1",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
expect(result.events2[0]).toEqual({
|
||||
projectId: "project-1",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("can remove listener with off", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const eventBus = yield* EventBus;
|
||||
const events: Array<InternalEventDeclaration["heartbeat"]> = [];
|
||||
|
||||
const listener = (event: InternalEventDeclaration["heartbeat"]) => {
|
||||
events.push(event);
|
||||
};
|
||||
|
||||
yield* eventBus.on("heartbeat", listener);
|
||||
yield* eventBus.emit("heartbeat", {});
|
||||
yield* Effect.sleep("10 millis");
|
||||
|
||||
// Remove listener
|
||||
yield* eventBus.off("heartbeat", listener);
|
||||
yield* eventBus.emit("heartbeat", {});
|
||||
yield* Effect.sleep("10 millis");
|
||||
|
||||
return events;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(EventBus.Live)),
|
||||
);
|
||||
|
||||
// Only receives first emit
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("different event types", () => {
|
||||
it("can process sessionListChanged event", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const eventBus = yield* EventBus;
|
||||
const events: Array<InternalEventDeclaration["sessionListChanged"]> =
|
||||
[];
|
||||
|
||||
const listener = (
|
||||
event: InternalEventDeclaration["sessionListChanged"],
|
||||
) => {
|
||||
events.push(event);
|
||||
};
|
||||
|
||||
yield* eventBus.on("sessionListChanged", listener);
|
||||
yield* eventBus.emit("sessionListChanged", {
|
||||
projectId: "project-1",
|
||||
});
|
||||
|
||||
yield* Effect.sleep("10 millis");
|
||||
|
||||
return events;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(EventBus.Live)),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({ projectId: "project-1" });
|
||||
});
|
||||
|
||||
it("can process sessionProcessChanged event", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const eventBus = yield* EventBus;
|
||||
const events: Array<InternalEventDeclaration["sessionProcessChanged"]> =
|
||||
[];
|
||||
|
||||
const listener = (
|
||||
event: InternalEventDeclaration["sessionProcessChanged"],
|
||||
) => {
|
||||
events.push(event);
|
||||
};
|
||||
|
||||
yield* eventBus.on("sessionProcessChanged", listener);
|
||||
|
||||
const mockProcess: CCSessionProcessState = {
|
||||
type: "initialized",
|
||||
sessionId: "session-1",
|
||||
currentTask: {
|
||||
status: "running",
|
||||
def: {
|
||||
type: "new",
|
||||
taskId: "task-1",
|
||||
},
|
||||
},
|
||||
rawUserMessage: "test message",
|
||||
initContext: {} as never,
|
||||
def: {
|
||||
sessionProcessId: "process-1",
|
||||
projectId: "project-1",
|
||||
cwd: "/test/path",
|
||||
abortController: new AbortController(),
|
||||
setNextMessage: () => {},
|
||||
},
|
||||
tasks: [],
|
||||
};
|
||||
|
||||
const publicProcess: PublicSessionProcess = {
|
||||
id: "process-1",
|
||||
projectId: "project-1",
|
||||
sessionId: "session-1",
|
||||
status: "running",
|
||||
};
|
||||
|
||||
yield* eventBus.emit("sessionProcessChanged", {
|
||||
processes: [publicProcess],
|
||||
changed: mockProcess,
|
||||
});
|
||||
|
||||
yield* Effect.sleep("10 millis");
|
||||
|
||||
return events;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(EventBus.Live)),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result.at(0)?.processes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("can process permissionRequested event", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const eventBus = yield* EventBus;
|
||||
const events: Array<InternalEventDeclaration["permissionRequested"]> =
|
||||
[];
|
||||
|
||||
const listener = (
|
||||
event: InternalEventDeclaration["permissionRequested"],
|
||||
) => {
|
||||
events.push(event);
|
||||
};
|
||||
|
||||
yield* eventBus.on("permissionRequested", listener);
|
||||
|
||||
const mockPermissionRequest: PermissionRequest = {
|
||||
id: "permission-1",
|
||||
taskId: "task-1",
|
||||
toolName: "read",
|
||||
toolInput: {},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
yield* eventBus.emit("permissionRequested", {
|
||||
permissionRequest: mockPermissionRequest,
|
||||
});
|
||||
|
||||
yield* Effect.sleep("10 millis");
|
||||
|
||||
return events;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(EventBus.Live)),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result.at(0)?.permissionRequest.id).toBe("permission-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("errors thrown by listeners don't affect other listeners", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const eventBus = yield* EventBus;
|
||||
const events1: Array<InternalEventDeclaration["heartbeat"]> = [];
|
||||
const events2: Array<InternalEventDeclaration["heartbeat"]> = [];
|
||||
|
||||
const failingListener = (
|
||||
_event: InternalEventDeclaration["heartbeat"],
|
||||
) => {
|
||||
throw new Error("Listener error");
|
||||
};
|
||||
|
||||
const successListener = (
|
||||
event: InternalEventDeclaration["heartbeat"],
|
||||
) => {
|
||||
events2.push(event);
|
||||
};
|
||||
|
||||
yield* eventBus.on("heartbeat", failingListener);
|
||||
yield* eventBus.on("heartbeat", successListener);
|
||||
|
||||
yield* eventBus.emit("heartbeat", {});
|
||||
yield* Effect.sleep("10 millis");
|
||||
|
||||
return { events1, events2 };
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(EventBus.Live)),
|
||||
);
|
||||
|
||||
// failingListener fails, but successListener works normally
|
||||
expect(result.events2).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +1,83 @@
|
||||
import { EventEmitter } from "node:stream";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import type { InternalEventDeclaration } from "./InternalEventDeclaration";
|
||||
|
||||
class EventBus {
|
||||
private emitter: EventEmitter;
|
||||
type Listener<T> = (data: T) => void | Promise<void>;
|
||||
|
||||
constructor() {
|
||||
this.emitter = new EventEmitter();
|
||||
}
|
||||
|
||||
public emit<EventName extends keyof InternalEventDeclaration>(
|
||||
interface EventBusService {
|
||||
readonly emit: <EventName extends keyof InternalEventDeclaration>(
|
||||
event: EventName,
|
||||
data: InternalEventDeclaration[EventName],
|
||||
): void {
|
||||
this.emitter.emit(event, {
|
||||
...data,
|
||||
) => Effect.Effect<void>;
|
||||
readonly on: <EventName extends keyof InternalEventDeclaration>(
|
||||
event: EventName,
|
||||
listener: Listener<InternalEventDeclaration[EventName]>,
|
||||
) => Effect.Effect<void>;
|
||||
readonly off: <EventName extends keyof InternalEventDeclaration>(
|
||||
event: EventName,
|
||||
listener: Listener<InternalEventDeclaration[EventName]>,
|
||||
) => Effect.Effect<void>;
|
||||
}
|
||||
|
||||
export class EventBus extends Context.Tag("EventBus")<
|
||||
EventBus,
|
||||
EventBusService
|
||||
>() {
|
||||
static Live = Layer.effect(
|
||||
this,
|
||||
Effect.gen(function* () {
|
||||
const listenersMap = new Map<
|
||||
keyof InternalEventDeclaration,
|
||||
Set<Listener<unknown>>
|
||||
>();
|
||||
|
||||
const getListeners = <EventName extends keyof InternalEventDeclaration>(
|
||||
event: EventName,
|
||||
): Set<Listener<InternalEventDeclaration[EventName]>> => {
|
||||
if (!listenersMap.has(event)) {
|
||||
listenersMap.set(event, new Set());
|
||||
}
|
||||
return listenersMap.get(event) as Set<
|
||||
Listener<InternalEventDeclaration[EventName]>
|
||||
>;
|
||||
};
|
||||
|
||||
const emit = <EventName extends keyof InternalEventDeclaration>(
|
||||
event: EventName,
|
||||
data: InternalEventDeclaration[EventName],
|
||||
): Effect.Effect<void> =>
|
||||
Effect.gen(function* () {
|
||||
const listeners = getListeners(event);
|
||||
|
||||
void Promise.allSettled(
|
||||
Array.from(listeners).map(async (listener) => {
|
||||
await listener(data);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public on<EventName extends keyof InternalEventDeclaration>(
|
||||
const on = <EventName extends keyof InternalEventDeclaration>(
|
||||
event: EventName,
|
||||
listener: (
|
||||
data: InternalEventDeclaration[EventName],
|
||||
) => void | Promise<void>,
|
||||
): void {
|
||||
this.emitter.on(event, listener);
|
||||
}
|
||||
listener: Listener<InternalEventDeclaration[EventName]>,
|
||||
): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
const listeners = getListeners(event);
|
||||
listeners.add(listener);
|
||||
});
|
||||
|
||||
public off<EventName extends keyof InternalEventDeclaration>(
|
||||
const off = <EventName extends keyof InternalEventDeclaration>(
|
||||
event: EventName,
|
||||
listener: (
|
||||
data: InternalEventDeclaration[EventName],
|
||||
) => void | Promise<void>,
|
||||
): void {
|
||||
this.emitter.off(event, listener);
|
||||
}
|
||||
}
|
||||
listener: Listener<InternalEventDeclaration[EventName]>,
|
||||
): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
const listeners = getListeners(event);
|
||||
listeners.delete(listener);
|
||||
});
|
||||
|
||||
export const eventBus = new EventBus();
|
||||
return {
|
||||
emit,
|
||||
on,
|
||||
off,
|
||||
} satisfies EventBusService;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type {
|
||||
AliveClaudeCodeTask,
|
||||
ClaudeCodeTask,
|
||||
PermissionRequest,
|
||||
} from "../claude-code/types";
|
||||
import type { PermissionRequest } from "../../../types/permissions";
|
||||
import type { PublicSessionProcess } from "../../../types/session-process";
|
||||
import type * as CCSessionProcess from "../claude-code/models/CCSessionProcess";
|
||||
|
||||
export type InternalEventDeclaration = {
|
||||
// biome-ignore lint/complexity/noBannedTypes: correct type
|
||||
@@ -17,9 +15,9 @@ export type InternalEventDeclaration = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
taskChanged: {
|
||||
aliveTasks: AliveClaudeCodeTask[];
|
||||
changed: ClaudeCodeTask;
|
||||
sessionProcessChanged: {
|
||||
processes: PublicSessionProcess[];
|
||||
changed: CCSessionProcess.CCSessionProcessState;
|
||||
};
|
||||
|
||||
permissionRequested: {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type { SSEStreamingApi } from "hono/streaming";
|
||||
import { eventBus } from "./EventBus";
|
||||
import type { InternalEventDeclaration } from "./InternalEventDeclaration";
|
||||
import { writeTypeSafeSSE } from "./typeSafeSSE";
|
||||
|
||||
export const adaptInternalEventToSSE = (
|
||||
rawStream: SSEStreamingApi,
|
||||
@@ -12,10 +9,6 @@ export const adaptInternalEventToSSE = (
|
||||
) => {
|
||||
const { timeout = 60 * 1000, cleanUp } = options ?? {};
|
||||
|
||||
console.log("SSE connection started");
|
||||
|
||||
const stream = writeTypeSafeSSE(rawStream);
|
||||
|
||||
const abortController = new AbortController();
|
||||
let connectionResolve: (() => void) | undefined;
|
||||
const connectionPromise = new Promise<void>((resolve) => {
|
||||
@@ -23,42 +16,15 @@ export const adaptInternalEventToSSE = (
|
||||
});
|
||||
|
||||
const closeConnection = () => {
|
||||
console.log("SSE connection closed");
|
||||
connectionResolve?.();
|
||||
abortController.abort();
|
||||
|
||||
eventBus.off("heartbeat", heartbeat);
|
||||
eventBus.off("permissionRequested", permissionRequested);
|
||||
cleanUp?.();
|
||||
};
|
||||
|
||||
rawStream.onAbort(() => {
|
||||
console.log("SSE connection aborted");
|
||||
closeConnection();
|
||||
});
|
||||
|
||||
// Event Listeners
|
||||
const heartbeat = (event: InternalEventDeclaration["heartbeat"]) => {
|
||||
stream.writeSSE("heartbeat", {
|
||||
...event,
|
||||
});
|
||||
};
|
||||
|
||||
const permissionRequested = (
|
||||
event: InternalEventDeclaration["permissionRequested"],
|
||||
) => {
|
||||
stream.writeSSE("permission_requested", {
|
||||
permissionRequest: event.permissionRequest,
|
||||
});
|
||||
};
|
||||
|
||||
eventBus.on("heartbeat", heartbeat);
|
||||
eventBus.on("permissionRequested", permissionRequested);
|
||||
|
||||
stream.writeSSE("connect", {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
closeConnection();
|
||||
}, timeout);
|
||||
|
||||
176
src/server/service/events/fileWatcher.test.ts
Normal file
176
src/server/service/events/fileWatcher.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EventBus } from "./EventBus";
|
||||
import { FileWatcherService } from "./fileWatcher";
|
||||
import type { InternalEventDeclaration } from "./InternalEventDeclaration";
|
||||
|
||||
describe("FileWatcherService", () => {
|
||||
describe("startWatching", () => {
|
||||
it("can start file watching", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const watcher = yield* FileWatcherService;
|
||||
|
||||
// Start watching
|
||||
yield* watcher.startWatching();
|
||||
|
||||
// Confirm successful start (no errors)
|
||||
return true;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(FileWatcherService.Live),
|
||||
Effect.provide(EventBus.Live),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can stop watching with stop", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const watcher = yield* FileWatcherService;
|
||||
|
||||
// Start watching
|
||||
yield* watcher.startWatching();
|
||||
|
||||
// Stop watching
|
||||
yield* watcher.stop();
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(FileWatcherService.Live),
|
||||
Effect.provide(EventBus.Live),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("only starts once even when startWatching is called multiple times", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const watcher = yield* FileWatcherService;
|
||||
|
||||
// Start watching multiple times
|
||||
yield* watcher.startWatching();
|
||||
yield* watcher.startWatching();
|
||||
yield* watcher.startWatching();
|
||||
|
||||
// Confirm no errors occur
|
||||
return true;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(FileWatcherService.Live),
|
||||
Effect.provide(EventBus.Live),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can call startWatching again after stop", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const watcher = yield* FileWatcherService;
|
||||
|
||||
// Start watching
|
||||
yield* watcher.startWatching();
|
||||
|
||||
// Stop watching
|
||||
yield* watcher.stop();
|
||||
|
||||
// Start watching again
|
||||
yield* watcher.startWatching();
|
||||
|
||||
// Stop watching
|
||||
yield* watcher.stop();
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(FileWatcherService.Live),
|
||||
Effect.provide(EventBus.Live),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verify event firing behavior", () => {
|
||||
it("file change events propagate to EventBus (integration test)", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const watcher = yield* FileWatcherService;
|
||||
const eventBus = yield* EventBus;
|
||||
|
||||
const sessionChangedEvents: Array<
|
||||
InternalEventDeclaration["sessionChanged"]
|
||||
> = [];
|
||||
|
||||
// Register event listener
|
||||
const listener = (
|
||||
event: InternalEventDeclaration["sessionChanged"],
|
||||
) => {
|
||||
sessionChangedEvents.push(event);
|
||||
};
|
||||
|
||||
yield* eventBus.on("sessionChanged", listener);
|
||||
|
||||
// Start watching
|
||||
yield* watcher.startWatching();
|
||||
|
||||
// Note: It's difficult to trigger actual file changes,
|
||||
// so here we only verify that watching starts successfully
|
||||
yield* Effect.sleep("50 millis");
|
||||
|
||||
// Stop watching
|
||||
yield* watcher.stop();
|
||||
|
||||
yield* eventBus.off("sessionChanged", listener);
|
||||
|
||||
// Confirm watching started
|
||||
return true;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(FileWatcherService.Live),
|
||||
Effect.provide(EventBus.Live),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("continues processing without throwing errors even with invalid directories", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const watcher = yield* FileWatcherService;
|
||||
|
||||
// Start watching (catches errors and continues even with invalid directories)
|
||||
yield* watcher.startWatching();
|
||||
|
||||
// Confirm no errors occur and processing continues normally
|
||||
yield* watcher.stop();
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(FileWatcherService.Live),
|
||||
Effect.provide(EventBus.Live),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type FSWatcher, watch } from "node:fs";
|
||||
import { Context, Effect, Layer, Ref } from "effect";
|
||||
import z from "zod";
|
||||
import { claudeProjectsDirPath } from "../paths";
|
||||
import { eventBus } from "./EventBus";
|
||||
import { EventBus } from "./EventBus";
|
||||
|
||||
const fileRegExp = /(?<projectId>.*?)\/(?<sessionId>.*?)\.jsonl/;
|
||||
const fileRegExpGroupSchema = z.object({
|
||||
@@ -9,22 +10,39 @@ const fileRegExpGroupSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export class FileWatcherService {
|
||||
private isWatching = false;
|
||||
private watcher: FSWatcher | null = null;
|
||||
private projectWatchers: Map<string, FSWatcher> = new Map();
|
||||
interface FileWatcherServiceInterface {
|
||||
readonly startWatching: () => Effect.Effect<void>;
|
||||
readonly stop: () => Effect.Effect<void>;
|
||||
}
|
||||
|
||||
public startWatching(): void {
|
||||
if (this.isWatching) return;
|
||||
this.isWatching = true;
|
||||
export class FileWatcherService extends Context.Tag("FileWatcherService")<
|
||||
FileWatcherService,
|
||||
FileWatcherServiceInterface
|
||||
>() {
|
||||
static Live = Layer.effect(
|
||||
this,
|
||||
Effect.gen(function* () {
|
||||
const eventBus = yield* EventBus;
|
||||
const isWatchingRef = yield* Ref.make(false);
|
||||
const watcherRef = yield* Ref.make<FSWatcher | null>(null);
|
||||
const projectWatchersRef = yield* Ref.make<Map<string, FSWatcher>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
try {
|
||||
const startWatching = (): Effect.Effect<void> =>
|
||||
Effect.gen(function* () {
|
||||
const isWatching = yield* Ref.get(isWatchingRef);
|
||||
if (isWatching) return;
|
||||
|
||||
yield* Ref.set(isWatchingRef, true);
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
console.log("Starting file watcher on:", claudeProjectsDirPath);
|
||||
// メインプロジェクトディレクトリを監視
|
||||
this.watcher = watch(
|
||||
const watcher = watch(
|
||||
claudeProjectsDirPath,
|
||||
{ persistent: false, recursive: true },
|
||||
(eventType, filename) => {
|
||||
(_eventType, filename) => {
|
||||
if (!filename) return;
|
||||
|
||||
const groups = fileRegExpGroupSchema.safeParse(
|
||||
@@ -35,39 +53,56 @@ export class FileWatcherService {
|
||||
|
||||
const { projectId, sessionId } = groups.data;
|
||||
|
||||
if (eventType === "change") {
|
||||
// セッションファイルの中身が変更されている
|
||||
Effect.runFork(
|
||||
eventBus.emit("sessionChanged", {
|
||||
projectId,
|
||||
sessionId,
|
||||
});
|
||||
} else if (eventType === "rename") {
|
||||
// セッションファイルの追加/削除
|
||||
}),
|
||||
);
|
||||
|
||||
Effect.runFork(
|
||||
eventBus.emit("sessionListChanged", {
|
||||
projectId,
|
||||
});
|
||||
} else {
|
||||
eventType satisfies never;
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await Effect.runPromise(Ref.set(watcherRef, watcher));
|
||||
console.log("File watcher initialization completed");
|
||||
} catch (error) {
|
||||
},
|
||||
catch: (error) => {
|
||||
console.error("Failed to start file watching:", error);
|
||||
}
|
||||
return new Error(
|
||||
`Failed to start file watching: ${String(error)}`,
|
||||
);
|
||||
},
|
||||
}).pipe(
|
||||
// エラーが発生しても続行する
|
||||
Effect.catchAll(() => Effect.void),
|
||||
);
|
||||
});
|
||||
|
||||
const stop = (): Effect.Effect<void> =>
|
||||
Effect.gen(function* () {
|
||||
const watcher = yield* Ref.get(watcherRef);
|
||||
if (watcher) {
|
||||
yield* Effect.sync(() => watcher.close());
|
||||
yield* Ref.set(watcherRef, null);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this.watcher) {
|
||||
this.watcher.close();
|
||||
this.watcher = null;
|
||||
const projectWatchers = yield* Ref.get(projectWatchersRef);
|
||||
for (const [, projectWatcher] of projectWatchers) {
|
||||
yield* Effect.sync(() => projectWatcher.close());
|
||||
}
|
||||
yield* Ref.set(projectWatchersRef, new Map());
|
||||
yield* Ref.set(isWatchingRef, false);
|
||||
});
|
||||
|
||||
for (const [, watcher] of this.projectWatchers) {
|
||||
watcher.close();
|
||||
return {
|
||||
startWatching,
|
||||
stop,
|
||||
} satisfies FileWatcherServiceInterface;
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.projectWatchers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const fileWatcher = new FileWatcherService();
|
||||
|
||||
248
src/server/service/events/typeSafeSSE.test.ts
Normal file
248
src/server/service/events/typeSafeSSE.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { Effect } from "effect";
|
||||
import type { SSEStreamingApi } from "hono/streaming";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PermissionRequest } from "../../../types/permissions";
|
||||
import { TypeSafeSSE } from "./typeSafeSSE";
|
||||
|
||||
describe("typeSafeSSE", () => {
|
||||
describe("writeTypeSafeSSE", () => {
|
||||
it("can correctly format and write SSE events", async () => {
|
||||
const writtenEvents: Array<{
|
||||
event: string;
|
||||
id: string;
|
||||
data: string;
|
||||
}> = [];
|
||||
|
||||
const mockStream: SSEStreamingApi = {
|
||||
writeSSE: vi.fn(async (event) => {
|
||||
writtenEvents.push(event);
|
||||
}),
|
||||
} as unknown as SSEStreamingApi;
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const typeSafeSSE = yield* TypeSafeSSE;
|
||||
|
||||
yield* typeSafeSSE.writeSSE("heartbeat", {});
|
||||
|
||||
return writtenEvents;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TypeSafeSSE.make(mockStream))),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
const item = result.at(0);
|
||||
expect(item).toBeDefined();
|
||||
if (!item) {
|
||||
throw new Error("item is undefined");
|
||||
}
|
||||
|
||||
expect(item.event).toBe("heartbeat");
|
||||
expect(item.id).toBeDefined();
|
||||
|
||||
const data = JSON.parse(item.data);
|
||||
expect(data.kind).toBe("heartbeat");
|
||||
expect(data.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("can correctly write connect event", async () => {
|
||||
const writtenEvents: Array<{
|
||||
event: string;
|
||||
id: string;
|
||||
data: string;
|
||||
}> = [];
|
||||
|
||||
const mockStream: SSEStreamingApi = {
|
||||
writeSSE: vi.fn(async (event) => {
|
||||
writtenEvents.push(event);
|
||||
}),
|
||||
} as unknown as SSEStreamingApi;
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const typeSafeSSE = yield* TypeSafeSSE;
|
||||
|
||||
yield* typeSafeSSE.writeSSE("connect", {});
|
||||
|
||||
return writtenEvents;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TypeSafeSSE.make(mockStream))),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const item = result.at(0);
|
||||
expect(item).toBeDefined();
|
||||
if (!item) {
|
||||
throw new Error("item is undefined");
|
||||
}
|
||||
expect(item.event).toBe("connect");
|
||||
|
||||
const data = JSON.parse(item.data);
|
||||
expect(data.kind).toBe("connect");
|
||||
expect(data.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("can correctly write sessionChanged event", async () => {
|
||||
const writtenEvents: Array<{
|
||||
event: string;
|
||||
id: string;
|
||||
data: string;
|
||||
}> = [];
|
||||
|
||||
const mockStream: SSEStreamingApi = {
|
||||
writeSSE: vi.fn(async (event) => {
|
||||
writtenEvents.push(event);
|
||||
}),
|
||||
} as unknown as SSEStreamingApi;
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const typeSafeSSE = yield* TypeSafeSSE;
|
||||
|
||||
yield* typeSafeSSE.writeSSE("sessionChanged", {
|
||||
projectId: "project-1",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
return writtenEvents;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TypeSafeSSE.make(mockStream))),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const item = result.at(0);
|
||||
expect(item).toBeDefined();
|
||||
if (!item) {
|
||||
throw new Error("item is undefined");
|
||||
}
|
||||
expect(item.event).toBe("sessionChanged");
|
||||
|
||||
const data = JSON.parse(item.data);
|
||||
expect(data.kind).toBe("sessionChanged");
|
||||
expect(data.projectId).toBe("project-1");
|
||||
expect(data.sessionId).toBe("session-1");
|
||||
expect(data.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("can correctly write permission_requested event", async () => {
|
||||
const writtenEvents: Array<{
|
||||
event: string;
|
||||
id: string;
|
||||
data: string;
|
||||
}> = [];
|
||||
|
||||
const mockStream: SSEStreamingApi = {
|
||||
writeSSE: vi.fn(async (event) => {
|
||||
writtenEvents.push(event);
|
||||
}),
|
||||
} as unknown as SSEStreamingApi;
|
||||
|
||||
const mockPermissionRequest: PermissionRequest = {
|
||||
id: "permission-1",
|
||||
sessionId: "session-1",
|
||||
taskId: "task-1",
|
||||
toolName: "read",
|
||||
toolInput: {},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const typeSafeSSE = yield* TypeSafeSSE;
|
||||
|
||||
yield* typeSafeSSE.writeSSE("permission_requested", {
|
||||
permissionRequest: mockPermissionRequest,
|
||||
});
|
||||
|
||||
return writtenEvents;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TypeSafeSSE.make(mockStream))),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const item = result.at(0);
|
||||
expect(item).toBeDefined();
|
||||
if (!item) {
|
||||
throw new Error("item is undefined");
|
||||
}
|
||||
expect(item.event).toBe("permission_requested");
|
||||
|
||||
const data = JSON.parse(item.data);
|
||||
expect(data.kind).toBe("permission_requested");
|
||||
expect(data.permissionRequest.id).toBe("permission-1");
|
||||
expect(data.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("can write multiple events consecutively", async () => {
|
||||
const writtenEvents: Array<{
|
||||
event: string;
|
||||
id: string;
|
||||
data: string;
|
||||
}> = [];
|
||||
|
||||
const mockStream: SSEStreamingApi = {
|
||||
writeSSE: vi.fn(async (event) => {
|
||||
writtenEvents.push(event);
|
||||
}),
|
||||
} as unknown as SSEStreamingApi;
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const typeSafeSSE = yield* TypeSafeSSE;
|
||||
|
||||
yield* typeSafeSSE.writeSSE("connect", {});
|
||||
yield* typeSafeSSE.writeSSE("heartbeat", {});
|
||||
yield* typeSafeSSE.writeSSE("sessionListChanged", {
|
||||
projectId: "project-1",
|
||||
});
|
||||
|
||||
return writtenEvents;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TypeSafeSSE.make(mockStream))),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.at(0)?.event).toBe("connect");
|
||||
expect(result.at(1)?.event).toBe("heartbeat");
|
||||
expect(result.at(2)?.event).toBe("sessionListChanged");
|
||||
});
|
||||
|
||||
it("assigns unique ID to each event", async () => {
|
||||
const writtenEvents: Array<{
|
||||
event: string;
|
||||
id: string;
|
||||
data: string;
|
||||
}> = [];
|
||||
|
||||
const mockStream: SSEStreamingApi = {
|
||||
writeSSE: vi.fn(async (event) => {
|
||||
writtenEvents.push(event);
|
||||
}),
|
||||
} as unknown as SSEStreamingApi;
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const typeSafeSSE = yield* TypeSafeSSE;
|
||||
|
||||
yield* typeSafeSSE.writeSSE("heartbeat", {});
|
||||
yield* typeSafeSSE.writeSSE("heartbeat", {});
|
||||
yield* typeSafeSSE.writeSSE("heartbeat", {});
|
||||
|
||||
return writtenEvents;
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(TypeSafeSSE.make(mockStream))),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
const ids = result.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(3); // All IDs are unique
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,27 @@
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import type { SSEStreamingApi } from "hono/streaming";
|
||||
import { ulid } from "ulid";
|
||||
import type { SSEEventDeclaration } from "../../../types/sse";
|
||||
|
||||
export const writeTypeSafeSSE = (stream: SSEStreamingApi) => ({
|
||||
writeSSE: async <EventName extends keyof SSEEventDeclaration>(
|
||||
interface TypeSafeSSEService {
|
||||
readonly writeSSE: <EventName extends keyof SSEEventDeclaration>(
|
||||
event: EventName,
|
||||
data: SSEEventDeclaration[EventName],
|
||||
): Promise<void> => {
|
||||
) => Effect.Effect<void, Error>;
|
||||
}
|
||||
|
||||
export class TypeSafeSSE extends Context.Tag("TypeSafeSSE")<
|
||||
TypeSafeSSE,
|
||||
TypeSafeSSEService
|
||||
>() {
|
||||
static make = (stream: SSEStreamingApi) =>
|
||||
Layer.succeed(this, {
|
||||
writeSSE: <EventName extends keyof SSEEventDeclaration>(
|
||||
event: EventName,
|
||||
data: SSEEventDeclaration[EventName],
|
||||
): Effect.Effect<void, Error> =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const id = ulid();
|
||||
await stream.writeSSE({
|
||||
event: event,
|
||||
@@ -18,4 +33,12 @@ export const writeTypeSafeSSE = (stream: SSEStreamingApi) => ({
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
catch: (error) => {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
return new Error(String(error));
|
||||
},
|
||||
}),
|
||||
} satisfies TypeSafeSSEService);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { decodeProjectId } from "../project/id";
|
||||
|
||||
export interface McpServer {
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export const getMcpList = async (): Promise<{ servers: McpServer[] }> => {
|
||||
export const getMcpList = async (
|
||||
projectId: string,
|
||||
): Promise<{ servers: McpServer[] }> => {
|
||||
try {
|
||||
const output = execSync("claude mcp list", {
|
||||
encoding: "utf8",
|
||||
timeout: 10000,
|
||||
cwd: decodeProjectId(projectId),
|
||||
});
|
||||
|
||||
const servers: McpServer[] = [];
|
||||
|
||||
@@ -7,13 +7,13 @@ export const parseJsonl = (content: string) => {
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
|
||||
return lines.map((line) => {
|
||||
return lines.map((line, index) => {
|
||||
const parsed = ConversationSchema.safeParse(JSON.parse(line));
|
||||
if (!parsed.success) {
|
||||
console.warn("Failed to parse jsonl, skipping", parsed.error);
|
||||
const errorData: ErrorJsonl = {
|
||||
type: "x-error",
|
||||
line,
|
||||
lineNumber: index + 1,
|
||||
};
|
||||
return errorData;
|
||||
}
|
||||
|
||||
221
src/server/service/project/ProjectMetaService.test.ts
Normal file
221
src/server/service/project/ProjectMetaService.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { FileSystem, Path } from "@effect/platform";
|
||||
import { Effect, Layer, Option } from "effect";
|
||||
import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService";
|
||||
import { ProjectMetaService } from "./ProjectMetaService";
|
||||
|
||||
/**
|
||||
* Helper function to create a FileSystem mock layer
|
||||
* @see FileSystem.layerNoop - Can override only necessary methods
|
||||
*/
|
||||
const makeFileSystemMock = (
|
||||
overrides: Partial<FileSystem.FileSystem>,
|
||||
): Layer.Layer<FileSystem.FileSystem> => {
|
||||
return FileSystem.layerNoop(overrides);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create a Path mock layer
|
||||
* @see Path.layer - Uses default POSIX Path implementation
|
||||
*/
|
||||
const makePathMock = (): Layer.Layer<Path.Path> => {
|
||||
return Path.layer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create a PersistentService mock layer
|
||||
*/
|
||||
const makePersistentServiceMock = (): Layer.Layer<PersistentService> => {
|
||||
return Layer.succeed(PersistentService, {
|
||||
load: () => Effect.succeed([]),
|
||||
save: () => Effect.void,
|
||||
});
|
||||
};
|
||||
|
||||
describe("ProjectMetaService", () => {
|
||||
describe("getProjectMeta", () => {
|
||||
it("returns cached metadata", async () => {
|
||||
let readDirectoryCalls = 0;
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
readDirectory: () => {
|
||||
readDirectoryCalls++;
|
||||
return Effect.succeed(["session1.jsonl"]);
|
||||
},
|
||||
readFileString: () =>
|
||||
Effect.succeed(
|
||||
'{"type":"user","cwd":"/workspace/app","text":"test"}',
|
||||
),
|
||||
stat: () =>
|
||||
Effect.succeed({
|
||||
type: "File",
|
||||
mtime: Option.some(new Date("2024-01-01")),
|
||||
atime: Option.none(),
|
||||
birthtime: Option.none(),
|
||||
dev: 0,
|
||||
ino: Option.none(),
|
||||
mode: 0,
|
||||
nlink: Option.none(),
|
||||
uid: Option.none(),
|
||||
gid: Option.none(),
|
||||
rdev: Option.none(),
|
||||
size: FileSystem.Size(0n),
|
||||
blksize: Option.none(),
|
||||
blocks: Option.none(),
|
||||
}),
|
||||
exists: () => Effect.succeed(true),
|
||||
makeDirectory: () => Effect.void,
|
||||
writeFileString: () => Effect.void,
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const storage = yield* ProjectMetaService;
|
||||
const projectId = Buffer.from("/test/project").toString("base64url");
|
||||
|
||||
// First call
|
||||
const result1 = yield* storage.getProjectMeta(projectId);
|
||||
|
||||
// Second call (retrieved from cache)
|
||||
const result2 = yield* storage.getProjectMeta(projectId);
|
||||
|
||||
return { result1, result2, readDirectoryCalls };
|
||||
});
|
||||
|
||||
const { result1, result2 } = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(ProjectMetaService.Live),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
// Both results are the same
|
||||
expect(result1).toEqual(result2);
|
||||
|
||||
// readDirectory is called only once (cache is working)
|
||||
expect(readDirectoryCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null if project path is not found", async () => {
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
readDirectory: () => Effect.succeed(["session1.jsonl"]),
|
||||
readFileString: () =>
|
||||
Effect.succeed('{"type":"summary","text":"summary"}'),
|
||||
stat: () =>
|
||||
Effect.succeed({
|
||||
type: "File",
|
||||
mtime: Option.some(new Date("2024-01-01")),
|
||||
atime: Option.none(),
|
||||
birthtime: Option.none(),
|
||||
dev: 0,
|
||||
ino: Option.none(),
|
||||
mode: 0,
|
||||
nlink: Option.none(),
|
||||
uid: Option.none(),
|
||||
gid: Option.none(),
|
||||
rdev: Option.none(),
|
||||
size: FileSystem.Size(0n),
|
||||
blksize: Option.none(),
|
||||
blocks: Option.none(),
|
||||
}),
|
||||
exists: () => Effect.succeed(true),
|
||||
makeDirectory: () => Effect.void,
|
||||
writeFileString: () => Effect.void,
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const storage = yield* ProjectMetaService;
|
||||
const projectId = Buffer.from("/test/project").toString("base64url");
|
||||
return yield* storage.getProjectMeta(projectId);
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(ProjectMetaService.Live),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.projectName).toBeNull();
|
||||
expect(result.projectPath).toBeNull();
|
||||
expect(result.sessionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateProject", () => {
|
||||
it("can invalidate project cache", async () => {
|
||||
let readDirectoryCalls = 0;
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
readDirectory: () => {
|
||||
readDirectoryCalls++;
|
||||
return Effect.succeed(["session1.jsonl"]);
|
||||
},
|
||||
readFileString: () =>
|
||||
Effect.succeed(
|
||||
'{"type":"user","cwd":"/workspace/app","text":"test"}',
|
||||
),
|
||||
stat: () =>
|
||||
Effect.succeed({
|
||||
type: "File",
|
||||
mtime: Option.some(new Date("2024-01-01")),
|
||||
atime: Option.none(),
|
||||
birthtime: Option.none(),
|
||||
dev: 0,
|
||||
ino: Option.none(),
|
||||
mode: 0,
|
||||
nlink: Option.none(),
|
||||
uid: Option.none(),
|
||||
gid: Option.none(),
|
||||
rdev: Option.none(),
|
||||
size: FileSystem.Size(0n),
|
||||
blksize: Option.none(),
|
||||
blocks: Option.none(),
|
||||
}),
|
||||
exists: () => Effect.succeed(true),
|
||||
makeDirectory: () => Effect.void,
|
||||
writeFileString: () => Effect.void,
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const storage = yield* ProjectMetaService;
|
||||
const projectId = Buffer.from("/test/project").toString("base64url");
|
||||
|
||||
// First call
|
||||
yield* storage.getProjectMeta(projectId);
|
||||
|
||||
// Invalidate cache
|
||||
yield* storage.invalidateProject(projectId);
|
||||
|
||||
// Second call (re-read from file)
|
||||
yield* storage.getProjectMeta(projectId);
|
||||
});
|
||||
|
||||
await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(ProjectMetaService.Live),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
// readDirectory is called twice (cache was invalidated)
|
||||
expect(readDirectoryCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
154
src/server/service/project/ProjectMetaService.ts
Normal file
154
src/server/service/project/ProjectMetaService.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { basename } from "node:path";
|
||||
import { FileSystem, Path } from "@effect/platform";
|
||||
import { Context, Effect, Layer, Option, Ref } from "effect";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
FileCacheStorage,
|
||||
makeFileCacheStorageLayer,
|
||||
} from "../../lib/storage/FileCacheStorage";
|
||||
import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService";
|
||||
import { parseJsonl } from "../parseJsonl";
|
||||
import type { ProjectMeta } from "../types";
|
||||
import { decodeProjectId } from "./id";
|
||||
|
||||
const ProjectPathSchema = z.string().nullable();
|
||||
|
||||
export class ProjectMetaService extends Context.Tag("ProjectMetaService")<
|
||||
ProjectMetaService,
|
||||
{
|
||||
readonly getProjectMeta: (
|
||||
projectId: string,
|
||||
) => Effect.Effect<ProjectMeta, Error>;
|
||||
readonly invalidateProject: (projectId: string) => Effect.Effect<void>;
|
||||
}
|
||||
>() {
|
||||
static Live = Layer.effect(
|
||||
this,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const path = yield* Path.Path;
|
||||
const projectPathCache = yield* FileCacheStorage<string | null>();
|
||||
const projectMetaCacheRef = yield* Ref.make(
|
||||
new Map<string, ProjectMeta>(),
|
||||
);
|
||||
|
||||
const extractProjectPathFromJsonl = (
|
||||
filePath: string,
|
||||
): Effect.Effect<string | null, Error> =>
|
||||
Effect.gen(function* () {
|
||||
const cached = yield* projectPathCache.get(filePath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const content = yield* fs.readFileString(filePath);
|
||||
const lines = content.split("\n");
|
||||
|
||||
let cwd: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const conversation = parseJsonl(line).at(0);
|
||||
|
||||
if (
|
||||
conversation === undefined ||
|
||||
conversation.type === "summary" ||
|
||||
conversation.type === "x-error"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cwd = conversation.cwd;
|
||||
break;
|
||||
}
|
||||
|
||||
if (cwd !== null) {
|
||||
yield* projectPathCache.set(filePath, cwd);
|
||||
}
|
||||
|
||||
return cwd;
|
||||
});
|
||||
|
||||
const getProjectMeta = (
|
||||
projectId: string,
|
||||
): Effect.Effect<ProjectMeta, Error> =>
|
||||
Effect.gen(function* () {
|
||||
const metaCache = yield* Ref.get(projectMetaCacheRef);
|
||||
const cached = metaCache.get(projectId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const claudeProjectPath = decodeProjectId(projectId);
|
||||
|
||||
const dirents = yield* fs.readDirectory(claudeProjectPath);
|
||||
const fileEntries = yield* Effect.all(
|
||||
dirents
|
||||
.filter((name) => name.endsWith(".jsonl"))
|
||||
.map((name) =>
|
||||
Effect.gen(function* () {
|
||||
const fullPath = path.resolve(claudeProjectPath, name);
|
||||
const stat = yield* fs.stat(fullPath);
|
||||
const mtime = Option.getOrElse(stat.mtime, () => new Date(0));
|
||||
return {
|
||||
fullPath,
|
||||
mtime,
|
||||
} as const;
|
||||
}),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
);
|
||||
|
||||
const files = fileEntries.sort((a, b) => {
|
||||
return a.mtime.getTime() - b.mtime.getTime();
|
||||
});
|
||||
|
||||
let projectPath: string | null = null;
|
||||
|
||||
for (const file of files) {
|
||||
projectPath = yield* extractProjectPathFromJsonl(file.fullPath);
|
||||
|
||||
if (projectPath === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const projectMeta: ProjectMeta = {
|
||||
projectName: projectPath ? basename(projectPath) : null,
|
||||
projectPath,
|
||||
sessionCount: files.length,
|
||||
};
|
||||
|
||||
yield* Ref.update(projectMetaCacheRef, (cache) => {
|
||||
cache.set(projectId, projectMeta);
|
||||
return cache;
|
||||
});
|
||||
|
||||
return projectMeta;
|
||||
});
|
||||
|
||||
const invalidateProject = (projectId: string): Effect.Effect<void> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(projectMetaCacheRef, (cache) => {
|
||||
cache.delete(projectId);
|
||||
return cache;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
getProjectMeta,
|
||||
invalidateProject,
|
||||
};
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(
|
||||
makeFileCacheStorageLayer("project-path-cache", ProjectPathSchema),
|
||||
),
|
||||
Layer.provide(PersistentService.Live),
|
||||
);
|
||||
}
|
||||
|
||||
export type IProjectMetaService = Context.Tag.Service<
|
||||
typeof ProjectMetaService
|
||||
>;
|
||||
329
src/server/service/project/ProjectRepository.test.ts
Normal file
329
src/server/service/project/ProjectRepository.test.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { FileSystem, Path } from "@effect/platform";
|
||||
import { SystemError } from "@effect/platform/Error";
|
||||
import { Effect, Layer, Option } from "effect";
|
||||
import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService";
|
||||
import type { ProjectMeta } from "../types";
|
||||
import { ProjectMetaService } from "./ProjectMetaService";
|
||||
import { ProjectRepository } from "./ProjectRepository";
|
||||
|
||||
/**
|
||||
* Helper function to create FileSystem mock layer
|
||||
*/
|
||||
const makeFileSystemMock = (
|
||||
overrides: Partial<FileSystem.FileSystem>,
|
||||
): Layer.Layer<FileSystem.FileSystem> => {
|
||||
return FileSystem.layerNoop(overrides);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create Path mock layer
|
||||
*/
|
||||
const makePathMock = (): Layer.Layer<Path.Path> => {
|
||||
return Path.layer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create PersistentService mock layer
|
||||
*/
|
||||
const makePersistentServiceMock = (): Layer.Layer<PersistentService> => {
|
||||
return Layer.succeed(PersistentService, {
|
||||
load: () => Effect.succeed([]),
|
||||
save: () => Effect.void,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create ProjectMetaService mock layer
|
||||
*/
|
||||
const makeProjectMetaServiceMock = (
|
||||
meta: ProjectMeta,
|
||||
): Layer.Layer<ProjectMetaService> => {
|
||||
return Layer.succeed(ProjectMetaService, {
|
||||
getProjectMeta: () => Effect.succeed(meta),
|
||||
invalidateProject: () => Effect.void,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create File.Info mock
|
||||
*/
|
||||
const makeFileInfoMock = (
|
||||
type: "File" | "Directory",
|
||||
mtime: Date,
|
||||
): FileSystem.File.Info => ({
|
||||
type,
|
||||
mtime: Option.some(mtime),
|
||||
atime: Option.none(),
|
||||
birthtime: Option.none(),
|
||||
dev: 0,
|
||||
ino: Option.none(),
|
||||
mode: 0o755,
|
||||
nlink: Option.none(),
|
||||
uid: Option.none(),
|
||||
gid: Option.none(),
|
||||
rdev: Option.none(),
|
||||
size: FileSystem.Size(0n),
|
||||
blksize: Option.none(),
|
||||
blocks: Option.none(),
|
||||
});
|
||||
|
||||
describe("ProjectRepository", () => {
|
||||
describe("getProject", () => {
|
||||
it("returns project information when project exists", async () => {
|
||||
const projectPath = "/test/project";
|
||||
const projectId = Buffer.from(projectPath).toString("base64url");
|
||||
const mockDate = new Date("2024-01-01T00:00:00.000Z");
|
||||
const mockMeta: ProjectMeta = {
|
||||
projectName: "Test Project",
|
||||
projectPath: "/workspace",
|
||||
sessionCount: 5,
|
||||
};
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: (path: string) => Effect.succeed(path === projectPath),
|
||||
stat: () => Effect.succeed(makeFileInfoMock("Directory", mockDate)),
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const ProjectMetaServiceMock = makeProjectMetaServiceMock(mockMeta);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* ProjectRepository;
|
||||
return yield* repo.getProject(projectId);
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(ProjectRepository.Live),
|
||||
Effect.provide(ProjectMetaServiceMock),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.project).toEqual({
|
||||
id: projectId,
|
||||
claudeProjectPath: projectPath,
|
||||
lastModifiedAt: mockDate,
|
||||
meta: mockMeta,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns error when project does not exist", async () => {
|
||||
const projectPath = "/test/nonexistent";
|
||||
const projectId = Buffer.from(projectPath).toString("base64url");
|
||||
const mockMeta: ProjectMeta = {
|
||||
projectName: null,
|
||||
projectPath: null,
|
||||
sessionCount: 0,
|
||||
};
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: () => Effect.succeed(false),
|
||||
stat: () => Effect.succeed(makeFileInfoMock("Directory", new Date())),
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const ProjectMetaServiceMock = makeProjectMetaServiceMock(mockMeta);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* ProjectRepository;
|
||||
return yield* repo.getProject(projectId);
|
||||
});
|
||||
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(ProjectRepository.Live),
|
||||
Effect.provide(ProjectMetaServiceMock),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("Project not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjects", () => {
|
||||
it("returns empty array when project directory does not exist", async () => {
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: () => Effect.succeed(false),
|
||||
readDirectory: () => Effect.succeed([]),
|
||||
stat: () => Effect.succeed(makeFileInfoMock("Directory", new Date())),
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const mockMeta: ProjectMeta = {
|
||||
projectName: null,
|
||||
projectPath: null,
|
||||
sessionCount: 0,
|
||||
};
|
||||
const ProjectMetaServiceMock = makeProjectMetaServiceMock(mockMeta);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* ProjectRepository;
|
||||
return yield* repo.getProjects();
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(ProjectRepository.Live),
|
||||
Effect.provide(ProjectMetaServiceMock),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.projects).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns multiple projects correctly sorted", async () => {
|
||||
const date1 = new Date("2024-01-01T00:00:00.000Z");
|
||||
const date2 = new Date("2024-01-02T00:00:00.000Z");
|
||||
const date3 = new Date("2024-01-03T00:00:00.000Z");
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: () => Effect.succeed(true),
|
||||
readDirectory: () =>
|
||||
Effect.succeed(["project1", "project2", "project3"]),
|
||||
readFileString: () =>
|
||||
Effect.succeed('{"type":"user","cwd":"/workspace","text":"test"}'),
|
||||
stat: (path: string) => {
|
||||
if (path.includes("project1")) {
|
||||
return Effect.succeed(makeFileInfoMock("Directory", date2));
|
||||
}
|
||||
if (path.includes("project2")) {
|
||||
return Effect.succeed(makeFileInfoMock("Directory", date3));
|
||||
}
|
||||
if (path.includes("project3")) {
|
||||
return Effect.succeed(makeFileInfoMock("Directory", date1));
|
||||
}
|
||||
return Effect.succeed(makeFileInfoMock("Directory", new Date()));
|
||||
},
|
||||
makeDirectory: () => Effect.void,
|
||||
writeFileString: () => Effect.void,
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* ProjectRepository;
|
||||
return yield* repo.getProjects();
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(ProjectRepository.Live),
|
||||
Effect.provide(ProjectMetaService.Live),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.projects.length).toBe(3);
|
||||
expect(result.projects.at(0)?.lastModifiedAt).toEqual(date3); // project2
|
||||
expect(result.projects.at(1)?.lastModifiedAt).toEqual(date2); // project1
|
||||
expect(result.projects.at(2)?.lastModifiedAt).toEqual(date1); // project3
|
||||
});
|
||||
|
||||
it("filters only directories", async () => {
|
||||
const date = new Date("2024-01-01T00:00:00.000Z");
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: () => Effect.succeed(true),
|
||||
readDirectory: () =>
|
||||
Effect.succeed(["project1", "file.txt", "project2"]),
|
||||
readFileString: () =>
|
||||
Effect.succeed('{"type":"user","cwd":"/workspace","text":"test"}'),
|
||||
stat: (path: string) => {
|
||||
if (path.includes("file.txt")) {
|
||||
return Effect.succeed(makeFileInfoMock("File", date));
|
||||
}
|
||||
return Effect.succeed(makeFileInfoMock("Directory", date));
|
||||
},
|
||||
makeDirectory: () => Effect.void,
|
||||
writeFileString: () => Effect.void,
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* ProjectRepository;
|
||||
return yield* repo.getProjects();
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(ProjectRepository.Live),
|
||||
Effect.provide(ProjectMetaService.Live),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.projects.length).toBe(2);
|
||||
expect(
|
||||
result.projects.every((p) => p.claudeProjectPath.match(/project[12]$/)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("skips entries where stat retrieval fails", async () => {
|
||||
const date = new Date("2024-01-01T00:00:00.000Z");
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: () => Effect.succeed(true),
|
||||
readDirectory: () => Effect.succeed(["project1", "broken", "project2"]),
|
||||
readFileString: () =>
|
||||
Effect.succeed('{"type":"user","cwd":"/workspace","text":"test"}'),
|
||||
stat: (path: string) => {
|
||||
if (path.includes("broken")) {
|
||||
return Effect.fail(
|
||||
new SystemError({
|
||||
method: "stat",
|
||||
reason: "PermissionDenied",
|
||||
module: "FileSystem",
|
||||
cause: undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Effect.succeed(makeFileInfoMock("Directory", date));
|
||||
},
|
||||
makeDirectory: () => Effect.void,
|
||||
writeFileString: () => Effect.void,
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* ProjectRepository;
|
||||
return yield* repo.getProjects();
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(ProjectRepository.Live),
|
||||
Effect.provide(ProjectMetaService.Live),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.projects.length).toBe(2);
|
||||
expect(
|
||||
result.projects.every((p) => p.claudeProjectPath.match(/project[12]$/)),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,73 +1,109 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { access, constants, readdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { FileSystem } from "@effect/platform";
|
||||
import { Context, Effect, Layer, Option } from "effect";
|
||||
import { claudeProjectsDirPath } from "../paths";
|
||||
import type { Project } from "../types";
|
||||
import { decodeProjectId, encodeProjectId } from "./id";
|
||||
import { projectMetaStorage } from "./projectMetaStorage";
|
||||
import { ProjectMetaService } from "./ProjectMetaService";
|
||||
|
||||
const getProject = (projectId: string) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const projectMetaService = yield* ProjectMetaService;
|
||||
|
||||
export class ProjectRepository {
|
||||
public async getProject(projectId: string): Promise<{ project: Project }> {
|
||||
const fullPath = decodeProjectId(projectId);
|
||||
if (!existsSync(fullPath)) {
|
||||
throw new Error("Project not found");
|
||||
|
||||
// Check if project directory exists
|
||||
const exists = yield* fs.exists(fullPath);
|
||||
if (!exists) {
|
||||
return yield* Effect.fail(new Error("Project not found"));
|
||||
}
|
||||
|
||||
const meta = await projectMetaStorage.getProjectMeta(projectId);
|
||||
// Get file stats
|
||||
const stat = yield* fs.stat(fullPath);
|
||||
|
||||
// Get project metadata
|
||||
const meta = yield* projectMetaService.getProjectMeta(projectId);
|
||||
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
claudeProjectPath: fullPath,
|
||||
lastModifiedAt: statSync(fullPath).mtime,
|
||||
lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()),
|
||||
meta,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const getProjects = () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const projectMetaService = yield* ProjectMetaService;
|
||||
|
||||
public async getProjects(): Promise<{ projects: Project[] }> {
|
||||
try {
|
||||
// Check if the claude projects directory exists
|
||||
await access(claudeProjectsDirPath, constants.F_OK);
|
||||
} catch (_error) {
|
||||
// Directory doesn't exist, return empty array
|
||||
const dirExists = yield* fs.exists(claudeProjectsDirPath);
|
||||
if (!dirExists) {
|
||||
console.warn(
|
||||
`Claude projects directory not found at ${claudeProjectsDirPath}`,
|
||||
);
|
||||
return { projects: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const dirents = await readdir(claudeProjectsDirPath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
const projects = await Promise.all(
|
||||
dirents
|
||||
.filter((d) => d.isDirectory())
|
||||
.map(async (d) => {
|
||||
const fullPath = resolve(claudeProjectsDirPath, d.name);
|
||||
// Read directory entries
|
||||
const entries = yield* fs.readDirectory(claudeProjectsDirPath);
|
||||
|
||||
// Filter directories and map to Project objects
|
||||
const projectEffects = entries.map((entry) =>
|
||||
Effect.gen(function* () {
|
||||
const fullPath = resolve(claudeProjectsDirPath, entry);
|
||||
|
||||
// Check if it's a directory
|
||||
const stat = yield* Effect.tryPromise(() =>
|
||||
fs.stat(fullPath).pipe(Effect.runPromise),
|
||||
).pipe(Effect.catchAll(() => Effect.succeed(null)));
|
||||
|
||||
if (!stat || stat.type !== "Directory") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = encodeProjectId(fullPath);
|
||||
const meta = yield* projectMetaService.getProjectMeta(id);
|
||||
|
||||
return {
|
||||
id,
|
||||
claudeProjectPath: fullPath,
|
||||
lastModifiedAt: statSync(fullPath).mtime,
|
||||
meta: await projectMetaStorage.getProjectMeta(id),
|
||||
};
|
||||
lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()),
|
||||
meta,
|
||||
} satisfies Project;
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
projects: projects.sort((a, b) => {
|
||||
// Execute all effects in parallel and filter out nulls
|
||||
const projectsWithNulls = yield* Effect.all(projectEffects, {
|
||||
concurrency: "unbounded",
|
||||
});
|
||||
const projects = projectsWithNulls.filter((p): p is Project => p !== null);
|
||||
|
||||
// Sort by last modified date (newest first)
|
||||
const sortedProjects = projects.sort((a, b) => {
|
||||
return (
|
||||
(b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0) -
|
||||
(a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0)
|
||||
);
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error reading projects:", error);
|
||||
return { projects: [] };
|
||||
}
|
||||
});
|
||||
|
||||
return { projects: sortedProjects };
|
||||
});
|
||||
|
||||
export class ProjectRepository extends Context.Tag("ProjectRepository")<
|
||||
ProjectRepository,
|
||||
{
|
||||
readonly getProject: typeof getProject;
|
||||
readonly getProjects: typeof getProjects;
|
||||
}
|
||||
>() {
|
||||
static Live = Layer.succeed(this, {
|
||||
getProject,
|
||||
getProjects,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { FileCacheStorage } from "../../lib/storage/FileCacheStorage";
|
||||
import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage";
|
||||
import { parseJsonl } from "../parseJsonl";
|
||||
import type { ProjectMeta } from "../types";
|
||||
import { decodeProjectId } from "./id";
|
||||
|
||||
class ProjectMetaStorage {
|
||||
private projectPathCache = FileCacheStorage.load(
|
||||
"project-path-cache",
|
||||
z.string().nullable(),
|
||||
);
|
||||
private projectMetaCache = new InMemoryCacheStorage<ProjectMeta>();
|
||||
|
||||
public async getProjectMeta(projectId: string): Promise<ProjectMeta> {
|
||||
const cached = this.projectMetaCache.get(projectId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const claudeProjectPath = decodeProjectId(projectId);
|
||||
|
||||
const dirents = await readdir(claudeProjectPath, { withFileTypes: true });
|
||||
const files = dirents
|
||||
.filter((d) => d.isFile() && d.name.endsWith(".jsonl"))
|
||||
.map(
|
||||
(d) =>
|
||||
({
|
||||
fullPath: resolve(claudeProjectPath, d.name),
|
||||
stats: statSync(resolve(claudeProjectPath, d.name)),
|
||||
}) as const,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
return a.stats.mtime.getTime() - b.stats.mtime.getTime();
|
||||
});
|
||||
|
||||
let projectPath: string | null = null;
|
||||
|
||||
for (const file of files) {
|
||||
projectPath = await this.extractProjectPathFromJsonl(file.fullPath);
|
||||
|
||||
if (projectPath === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const projectMeta: ProjectMeta = {
|
||||
projectName: projectPath ? basename(projectPath) : null,
|
||||
projectPath,
|
||||
sessionCount: files.length,
|
||||
};
|
||||
|
||||
this.projectMetaCache.save(projectId, projectMeta);
|
||||
|
||||
return projectMeta;
|
||||
}
|
||||
|
||||
public invalidateProject(projectId: string) {
|
||||
this.projectMetaCache.invalidate(projectId);
|
||||
}
|
||||
|
||||
private async extractProjectPathFromJsonl(
|
||||
filePath: string,
|
||||
): Promise<string | null> {
|
||||
const cached = this.projectPathCache.get(filePath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
let cwd: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const conversation = parseJsonl(line).at(0);
|
||||
|
||||
if (
|
||||
conversation === undefined ||
|
||||
conversation.type === "summary" ||
|
||||
conversation.type === "x-error"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cwd = conversation.cwd;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (cwd !== null) {
|
||||
this.projectPathCache.save(filePath, cwd);
|
||||
}
|
||||
|
||||
return cwd;
|
||||
}
|
||||
}
|
||||
|
||||
export const projectMetaStorage = new ProjectMetaStorage();
|
||||
@@ -1,34 +0,0 @@
|
||||
import { encodeProjectIdFromSessionFilePath } from "../project/id";
|
||||
import type { Session, SessionDetail } from "../types";
|
||||
|
||||
/**
|
||||
* For interactively experience, handle sessions not already persisted to the filesystem.
|
||||
*/
|
||||
class PredictSessionsDatabase {
|
||||
private storage = new Map<string, SessionDetail>();
|
||||
|
||||
public getPredictSessions(projectId: string): Session[] {
|
||||
return Array.from(this.storage.values()).filter(
|
||||
({ jsonlFilePath }) =>
|
||||
encodeProjectIdFromSessionFilePath(jsonlFilePath) === projectId,
|
||||
);
|
||||
}
|
||||
|
||||
public getPredictSession(sessionId: string): SessionDetail {
|
||||
const session = this.storage.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
public createPredictSession(session: SessionDetail) {
|
||||
this.storage.set(session.id, session);
|
||||
}
|
||||
|
||||
public deletePredictSession(sessionId: string) {
|
||||
this.storage.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export const predictSessionsDatabase = new PredictSessionsDatabase();
|
||||
247
src/server/service/session/SessionMetaService.test.ts
Normal file
247
src/server/service/session/SessionMetaService.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { FileSystem, Path } from "@effect/platform";
|
||||
import { Effect, Layer } from "effect";
|
||||
import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService";
|
||||
import { SessionMetaService } from "./SessionMetaService";
|
||||
|
||||
/**
|
||||
* Helper function to create a FileSystem mock layer
|
||||
*/
|
||||
const makeFileSystemMock = (
|
||||
overrides: Partial<FileSystem.FileSystem>,
|
||||
): Layer.Layer<FileSystem.FileSystem> => {
|
||||
return FileSystem.layerNoop(overrides);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create a Path mock layer
|
||||
*/
|
||||
const makePathMock = (): Layer.Layer<Path.Path> => {
|
||||
return Path.layer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create a PersistentService mock layer
|
||||
* load returns an empty array to avoid file system access
|
||||
*/
|
||||
const makePersistentServiceMock = (): Layer.Layer<PersistentService> => {
|
||||
return Layer.succeed(PersistentService, {
|
||||
load: (_key: string) => Effect.succeed([]),
|
||||
save: (_key: string, _entries: readonly [string, unknown][]) => Effect.void,
|
||||
});
|
||||
};
|
||||
|
||||
describe("SessionMetaService", () => {
|
||||
describe("getSessionMeta", () => {
|
||||
it("can retrieve session metadata", async () => {
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
readFileString: () =>
|
||||
Effect.succeed(
|
||||
'{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace/app","sessionId":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","version":"1.0.0","gitBranch":"","type":"user","message":{"role":"user","content":"test message"},"uuid":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","timestamp":"2024-01-01T00:00:00.000Z"}',
|
||||
),
|
||||
exists: () => Effect.succeed(false),
|
||||
makeDirectory: () => Effect.void,
|
||||
writeFileString: () => Effect.void,
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const storage = yield* SessionMetaService;
|
||||
const projectId = Buffer.from("/test/project").toString("base64url");
|
||||
const sessionId = Buffer.from("/test/project/session.jsonl").toString(
|
||||
"base64url",
|
||||
);
|
||||
|
||||
return yield* storage.getSessionMeta(projectId, sessionId);
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionMetaService.Live),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.messageCount).toBe(1);
|
||||
expect(result.firstCommand).toEqual({
|
||||
kind: "text",
|
||||
content: "test message",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cached metadata", async () => {
|
||||
let readFileStringCalls = 0;
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
readFileString: () => {
|
||||
readFileStringCalls++;
|
||||
return Effect.succeed(
|
||||
'{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace/app","sessionId":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","version":"1.0.0","gitBranch":"","type":"user","message":{"role":"user","content":"test message"},"uuid":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","timestamp":"2024-01-01T00:00:00.000Z"}',
|
||||
);
|
||||
},
|
||||
exists: () => Effect.succeed(true),
|
||||
makeDirectory: () => Effect.void,
|
||||
writeFileString: () => Effect.void,
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const storage = yield* SessionMetaService;
|
||||
const projectId = Buffer.from("/test/project").toString("base64url");
|
||||
const sessionId = Buffer.from("/test/project/session.jsonl").toString(
|
||||
"base64url",
|
||||
);
|
||||
|
||||
// 1回目の呼び出し
|
||||
const result1 = yield* storage.getSessionMeta(projectId, sessionId);
|
||||
|
||||
// 2回目の呼び出し(キャッシュから取得)
|
||||
const result2 = yield* storage.getSessionMeta(projectId, sessionId);
|
||||
|
||||
return { result1, result2, readFileStringCalls };
|
||||
});
|
||||
|
||||
const { result1, result2 } = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionMetaService.Live),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result1).toEqual(result2);
|
||||
|
||||
expect(readFileStringCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("correctly parses commands", async () => {
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
readFileString: () =>
|
||||
Effect.succeed(
|
||||
'{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace/app","sessionId":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","version":"1.0.0","gitBranch":"","type":"user","message":{"role":"user","content":"<command-name>/test</command-name>"},"uuid":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","timestamp":"2024-01-01T00:00:00.000Z"}',
|
||||
),
|
||||
exists: () => Effect.succeed(false),
|
||||
makeDirectory: () => Effect.void,
|
||||
writeFileString: () => Effect.void,
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const storage = yield* SessionMetaService;
|
||||
const projectId = Buffer.from("/test/project").toString("base64url");
|
||||
const sessionId = Buffer.from("/test/project/session.jsonl").toString(
|
||||
"base64url",
|
||||
);
|
||||
|
||||
return yield* storage.getSessionMeta(projectId, sessionId);
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionMetaService.Live),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.firstCommand).toEqual({
|
||||
kind: "command",
|
||||
commandName: "/test",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips commands that should be ignored", async () => {
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
readFileString: () =>
|
||||
Effect.succeed(
|
||||
'{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace/app","sessionId":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","version":"1.0.0","gitBranch":"","type":"user","message":{"role":"user","content":"<command-name>/clear</command-name>"},"uuid":"d78d1de2-52bd-4e64-ad0f-affcbcc1dabf","timestamp":"2024-01-01T00:00:00.000Z"}\n{"parentUuid":"d78d1de2-52bd-4e64-ad0f-affcbcc1dabf","isSidechain":false,"userType":"external","cwd":"/workspace/app","sessionId":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","version":"1.0.0","gitBranch":"","type":"user","message":{"role":"user","content":"actual message"},"uuid":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","timestamp":"2024-01-01T00:00:01.000Z"}',
|
||||
),
|
||||
exists: () => Effect.succeed(false),
|
||||
makeDirectory: () => Effect.void,
|
||||
writeFileString: () => Effect.void,
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const storage = yield* SessionMetaService;
|
||||
const projectId = Buffer.from("/test/project").toString("base64url");
|
||||
const sessionId = Buffer.from("/test/project/session.jsonl").toString(
|
||||
"base64url",
|
||||
);
|
||||
|
||||
return yield* storage.getSessionMeta(projectId, sessionId);
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionMetaService.Live),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.firstCommand).toEqual({
|
||||
kind: "text",
|
||||
content: "actual message",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateSession", () => {
|
||||
it("can invalidate session cache", async () => {
|
||||
let readFileStringCalls = 0;
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
readFileString: () => {
|
||||
readFileStringCalls++;
|
||||
return Effect.succeed(
|
||||
'{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace/app","sessionId":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","version":"1.0.0","gitBranch":"","type":"user","message":{"role":"user","content":"test message"},"uuid":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","timestamp":"2024-01-01T00:00:00.000Z"}',
|
||||
);
|
||||
},
|
||||
exists: () => Effect.succeed(true),
|
||||
makeDirectory: () => Effect.void,
|
||||
writeFileString: () => Effect.void,
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const storage = yield* SessionMetaService;
|
||||
const projectId = Buffer.from("/test/project").toString("base64url");
|
||||
const sessionId = Buffer.from("/test/project/session.jsonl").toString(
|
||||
"base64url",
|
||||
);
|
||||
|
||||
yield* storage.getSessionMeta(projectId, sessionId);
|
||||
|
||||
yield* storage.invalidateSession(projectId, sessionId);
|
||||
|
||||
yield* storage.getSessionMeta(projectId, sessionId);
|
||||
});
|
||||
|
||||
await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionMetaService.Live),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(readFileStringCalls).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
185
src/server/service/session/SessionMetaService.ts
Normal file
185
src/server/service/session/SessionMetaService.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { FileSystem } from "@effect/platform";
|
||||
import { Context, Effect, Layer, Ref } from "effect";
|
||||
import {
|
||||
FileCacheStorage,
|
||||
makeFileCacheStorageLayer,
|
||||
} from "../../lib/storage/FileCacheStorage";
|
||||
import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService";
|
||||
import {
|
||||
type ParsedCommand,
|
||||
parseCommandXml,
|
||||
parsedCommandSchema,
|
||||
} from "../parseCommandXml";
|
||||
import { parseJsonl } from "../parseJsonl";
|
||||
import type { SessionMeta } from "../types";
|
||||
import { decodeSessionId } from "./id";
|
||||
|
||||
const ignoreCommands = [
|
||||
"/clear",
|
||||
"/login",
|
||||
"/logout",
|
||||
"/exit",
|
||||
"/mcp",
|
||||
"/memory",
|
||||
];
|
||||
|
||||
const parsedCommandOrNullSchema = parsedCommandSchema.nullable();
|
||||
|
||||
export class SessionMetaService extends Context.Tag("SessionMetaService")<
|
||||
SessionMetaService,
|
||||
{
|
||||
readonly getSessionMeta: (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
) => Effect.Effect<SessionMeta, Error>;
|
||||
readonly invalidateSession: (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
) => Effect.Effect<void>;
|
||||
}
|
||||
>() {
|
||||
static Live = Layer.effect(
|
||||
this,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const firstCommandCache = yield* FileCacheStorage<ParsedCommand | null>();
|
||||
const sessionMetaCacheRef = yield* Ref.make(
|
||||
new Map<string, SessionMeta>(),
|
||||
);
|
||||
|
||||
const extractFirstUserText = (
|
||||
conversation: Exclude<ReturnType<typeof parseJsonl>[0], undefined>,
|
||||
): string | null => {
|
||||
if (conversation.type !== "user") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstUserText =
|
||||
typeof conversation.message.content === "string"
|
||||
? conversation.message.content
|
||||
: (() => {
|
||||
const firstContent = conversation.message.content.at(0);
|
||||
if (firstContent === undefined) return null;
|
||||
if (typeof firstContent === "string") return firstContent;
|
||||
if (firstContent.type === "text") return firstContent.text;
|
||||
return null;
|
||||
})();
|
||||
|
||||
return firstUserText;
|
||||
};
|
||||
|
||||
const getFirstCommand = (
|
||||
jsonlFilePath: string,
|
||||
lines: string[],
|
||||
): Effect.Effect<ParsedCommand | null, Error> =>
|
||||
Effect.gen(function* () {
|
||||
const cached = yield* firstCommandCache.get(jsonlFilePath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let firstCommand: ParsedCommand | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const conversation = parseJsonl(line).at(0);
|
||||
|
||||
if (conversation === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstUserText = extractFirstUserText(conversation);
|
||||
|
||||
if (firstUserText === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
firstUserText ===
|
||||
"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const command = parseCommandXml(firstUserText);
|
||||
if (command.kind === "local-command") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
command.kind === "command" &&
|
||||
ignoreCommands.includes(command.commandName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
firstCommand = command;
|
||||
break;
|
||||
}
|
||||
|
||||
if (firstCommand !== null) {
|
||||
yield* firstCommandCache.set(jsonlFilePath, firstCommand);
|
||||
}
|
||||
|
||||
return firstCommand;
|
||||
});
|
||||
|
||||
const getSessionMeta = (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
): Effect.Effect<SessionMeta, Error> =>
|
||||
Effect.gen(function* () {
|
||||
const metaCache = yield* Ref.get(sessionMetaCacheRef);
|
||||
const cached = metaCache.get(sessionId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const sessionPath = decodeSessionId(projectId, sessionId);
|
||||
const content = yield* fs.readFileString(sessionPath);
|
||||
const lines = content.split("\n");
|
||||
|
||||
const firstCommand = yield* getFirstCommand(sessionPath, lines);
|
||||
|
||||
const sessionMeta: SessionMeta = {
|
||||
messageCount: lines.length,
|
||||
firstCommand,
|
||||
};
|
||||
|
||||
yield* Ref.update(sessionMetaCacheRef, (cache) => {
|
||||
cache.set(sessionId, sessionMeta);
|
||||
return cache;
|
||||
});
|
||||
|
||||
return sessionMeta;
|
||||
});
|
||||
|
||||
const invalidateSession = (
|
||||
_projectId: string,
|
||||
sessionId: string,
|
||||
): Effect.Effect<void> =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(sessionMetaCacheRef, (cache) => {
|
||||
cache.delete(sessionId);
|
||||
return cache;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
getSessionMeta,
|
||||
invalidateSession,
|
||||
};
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(
|
||||
makeFileCacheStorageLayer(
|
||||
"first-command-cache",
|
||||
parsedCommandOrNullSchema,
|
||||
),
|
||||
),
|
||||
Layer.provide(PersistentService.Live),
|
||||
);
|
||||
}
|
||||
|
||||
export type ISessionMetaService = Context.Tag.Service<
|
||||
typeof SessionMetaService
|
||||
>;
|
||||
604
src/server/service/session/SessionRepository.test.ts
Normal file
604
src/server/service/session/SessionRepository.test.ts
Normal file
@@ -0,0 +1,604 @@
|
||||
import { FileSystem, Path } from "@effect/platform";
|
||||
import { SystemError } from "@effect/platform/Error";
|
||||
import { Effect, Layer, Option } from "effect";
|
||||
import type { Conversation } from "../../../lib/conversation-schema";
|
||||
import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService";
|
||||
import { decodeProjectId } from "../project/id";
|
||||
import type { ErrorJsonl, SessionDetail, SessionMeta } from "../types";
|
||||
import { VirtualConversationDatabase } from "./PredictSessionsDatabase";
|
||||
import { SessionMetaService } from "./SessionMetaService";
|
||||
import { SessionRepository } from "./SessionRepository";
|
||||
|
||||
/**
|
||||
* Helper function to create a FileSystem mock layer
|
||||
*/
|
||||
const makeFileSystemMock = (
|
||||
overrides: Partial<FileSystem.FileSystem>,
|
||||
): Layer.Layer<FileSystem.FileSystem> => {
|
||||
return FileSystem.layerNoop(overrides);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create a Path mock layer
|
||||
*/
|
||||
const makePathMock = (): Layer.Layer<Path.Path> => {
|
||||
return Path.layer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create a PersistentService mock layer
|
||||
*/
|
||||
const makePersistentServiceMock = (): Layer.Layer<PersistentService> => {
|
||||
return Layer.succeed(PersistentService, {
|
||||
load: () => Effect.succeed([]),
|
||||
save: () => Effect.void,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create a SessionMetaService mock layer
|
||||
*/
|
||||
const makeSessionMetaServiceMock = (
|
||||
meta: SessionMeta,
|
||||
): Layer.Layer<SessionMetaService> => {
|
||||
return Layer.succeed(SessionMetaService, {
|
||||
getSessionMeta: () => Effect.succeed(meta),
|
||||
invalidateSession: () => Effect.void,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create a PredictSessionsDatabase mock layer
|
||||
*/
|
||||
const makePredictSessionsDatabaseMock = (
|
||||
sessions: Map<string, SessionDetail>,
|
||||
): Layer.Layer<VirtualConversationDatabase> => {
|
||||
return Layer.succeed(VirtualConversationDatabase, {
|
||||
getProjectVirtualConversations: (projectId: string) =>
|
||||
Effect.succeed(
|
||||
Array.from(sessions.values())
|
||||
.filter((s) => {
|
||||
const projectPath = decodeProjectId(projectId);
|
||||
return s.jsonlFilePath.startsWith(projectPath);
|
||||
})
|
||||
.map((s) => ({
|
||||
projectId,
|
||||
sessionId: s.id,
|
||||
conversations: s.conversations,
|
||||
})),
|
||||
),
|
||||
getSessionVirtualConversation: (sessionId: string) => {
|
||||
const session = sessions.get(sessionId);
|
||||
return Effect.succeed(
|
||||
session
|
||||
? {
|
||||
projectId: "",
|
||||
sessionId: session.id,
|
||||
conversations: session.conversations,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
createVirtualConversation: () => Effect.void,
|
||||
deleteVirtualConversations: () => Effect.void,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create a File.Info mock
|
||||
*/
|
||||
const makeFileInfoMock = (
|
||||
type: "File" | "Directory",
|
||||
mtime: Date,
|
||||
): FileSystem.File.Info => ({
|
||||
type,
|
||||
mtime: Option.some(mtime),
|
||||
atime: Option.none(),
|
||||
birthtime: Option.none(),
|
||||
dev: 0,
|
||||
ino: Option.none(),
|
||||
mode: 0o755,
|
||||
nlink: Option.none(),
|
||||
uid: Option.none(),
|
||||
gid: Option.none(),
|
||||
rdev: Option.none(),
|
||||
size: FileSystem.Size(0n),
|
||||
blksize: Option.none(),
|
||||
blocks: Option.none(),
|
||||
});
|
||||
|
||||
describe("SessionRepository", () => {
|
||||
describe("getSession", () => {
|
||||
it("returns session details when session file exists", async () => {
|
||||
const projectId = Buffer.from("/test/project").toString("base64url");
|
||||
const sessionId = "test-session";
|
||||
const sessionPath = `/test/project/${sessionId}.jsonl`;
|
||||
const mockDate = new Date("2024-01-01T00:00:00.000Z");
|
||||
const mockMeta: SessionMeta = {
|
||||
messageCount: 3,
|
||||
firstCommand: null,
|
||||
};
|
||||
|
||||
const mockContent = `{"type":"user","message":{"role":"user","content":"Hello"}}\n{"type":"assistant","message":{"role":"assistant","content":"Hi"}}\n{"type":"user","message":{"role":"user","content":"Test"}}`;
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: (path: string) => Effect.succeed(path === sessionPath),
|
||||
readFileString: (path: string) =>
|
||||
path === sessionPath
|
||||
? Effect.succeed(mockContent)
|
||||
: Effect.fail(
|
||||
new SystemError({
|
||||
method: "readFileString",
|
||||
reason: "NotFound",
|
||||
module: "FileSystem",
|
||||
cause: undefined,
|
||||
}),
|
||||
),
|
||||
stat: () => Effect.succeed(makeFileInfoMock("File", mockDate)),
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const SessionMetaServiceMock = makeSessionMetaServiceMock(mockMeta);
|
||||
const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* SessionRepository;
|
||||
return yield* repo.getSession(projectId, sessionId);
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionRepository.Live),
|
||||
Effect.provide(SessionMetaServiceMock),
|
||||
Effect.provide(PredictSessionsDatabaseMock),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.session).not.toBeNull();
|
||||
if (result.session) {
|
||||
expect(result.session.id).toBe(sessionId);
|
||||
expect(result.session.jsonlFilePath).toBe(sessionPath);
|
||||
expect(result.session.meta).toEqual(mockMeta);
|
||||
expect(result.session.conversations).toHaveLength(3);
|
||||
expect(result.session.lastModifiedAt).toEqual(mockDate);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns predicted session when session file does not exist but predicted session exists", async () => {
|
||||
const projectId = Buffer.from("/test/project").toString("base64url");
|
||||
const sessionId = "predict-session";
|
||||
const mockDate = new Date("2024-01-01T00:00:00.000Z");
|
||||
|
||||
const mockConversations: (Conversation | ErrorJsonl)[] = [
|
||||
{
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: mockDate.toISOString(),
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId,
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
},
|
||||
];
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: () => Effect.succeed(false),
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const SessionMetaServiceMock = makeSessionMetaServiceMock({
|
||||
messageCount: 0,
|
||||
firstCommand: null,
|
||||
});
|
||||
const PredictSessionsDatabaseMock = Layer.succeed(
|
||||
VirtualConversationDatabase,
|
||||
{
|
||||
getProjectVirtualConversations: () => Effect.succeed([]),
|
||||
getSessionVirtualConversation: (sid: string) =>
|
||||
Effect.succeed(
|
||||
sid === sessionId
|
||||
? {
|
||||
projectId,
|
||||
sessionId,
|
||||
conversations: mockConversations,
|
||||
}
|
||||
: null,
|
||||
),
|
||||
createVirtualConversation: () => Effect.void,
|
||||
deleteVirtualConversations: () => Effect.void,
|
||||
},
|
||||
);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* SessionRepository;
|
||||
return yield* repo.getSession(projectId, sessionId);
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionRepository.Live),
|
||||
Effect.provide(SessionMetaServiceMock),
|
||||
Effect.provide(PredictSessionsDatabaseMock),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.session).not.toBeNull();
|
||||
if (result.session) {
|
||||
expect(result.session.id).toBe(sessionId);
|
||||
expect(result.session.conversations).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when session does not exist", async () => {
|
||||
const projectId = Buffer.from("/test/project").toString("base64url");
|
||||
const sessionId = "nonexistent-session";
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: () => Effect.succeed(false),
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const SessionMetaServiceMock = makeSessionMetaServiceMock({
|
||||
messageCount: 0,
|
||||
firstCommand: null,
|
||||
});
|
||||
const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* SessionRepository;
|
||||
return yield* repo.getSession(projectId, sessionId);
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionRepository.Live),
|
||||
Effect.provide(SessionMetaServiceMock),
|
||||
Effect.provide(PredictSessionsDatabaseMock),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.session).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when resuming session without predict session (reproduces bug)", async () => {
|
||||
const projectId = Buffer.from("/test/project").toString("base64url");
|
||||
const sessionId = "resume-session-id";
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: () => Effect.succeed(false),
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const SessionMetaServiceMock = makeSessionMetaServiceMock({
|
||||
messageCount: 0,
|
||||
firstCommand: null,
|
||||
});
|
||||
const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* SessionRepository;
|
||||
return yield* repo.getSession(projectId, sessionId);
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionRepository.Live),
|
||||
Effect.provide(SessionMetaServiceMock),
|
||||
Effect.provide(PredictSessionsDatabaseMock),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.session).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSessions", () => {
|
||||
it("returns list of sessions within project", async () => {
|
||||
const projectPath = "/test/project";
|
||||
const projectId = Buffer.from(projectPath).toString("base64url");
|
||||
const date1 = new Date("2024-01-01T00:00:00.000Z");
|
||||
const date2 = new Date("2024-01-02T00:00:00.000Z");
|
||||
|
||||
const mockMeta: SessionMeta = {
|
||||
messageCount: 1,
|
||||
firstCommand: null,
|
||||
};
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: (path: string) => Effect.succeed(path === projectPath),
|
||||
readDirectory: (path: string) =>
|
||||
path === projectPath
|
||||
? Effect.succeed(["session1.jsonl", "session2.jsonl"])
|
||||
: Effect.succeed([]),
|
||||
stat: (path: string) => {
|
||||
if (path.includes("session1.jsonl")) {
|
||||
return Effect.succeed(makeFileInfoMock("File", date2));
|
||||
}
|
||||
if (path.includes("session2.jsonl")) {
|
||||
return Effect.succeed(makeFileInfoMock("File", date1));
|
||||
}
|
||||
return Effect.succeed(makeFileInfoMock("File", new Date()));
|
||||
},
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const SessionMetaServiceMock = makeSessionMetaServiceMock(mockMeta);
|
||||
const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* SessionRepository;
|
||||
return yield* repo.getSessions(projectId);
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionRepository.Live),
|
||||
Effect.provide(SessionMetaServiceMock),
|
||||
Effect.provide(PredictSessionsDatabaseMock),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.sessions).toHaveLength(2);
|
||||
expect(result.sessions.at(0)?.lastModifiedAt).toEqual(date2);
|
||||
expect(result.sessions.at(1)?.lastModifiedAt).toEqual(date1);
|
||||
});
|
||||
|
||||
it("can limit number of results with maxCount option", async () => {
|
||||
const projectPath = "/test/project";
|
||||
const projectId = Buffer.from(projectPath).toString("base64url");
|
||||
const mockDate = new Date("2024-01-01T00:00:00.000Z");
|
||||
|
||||
const mockMeta: SessionMeta = {
|
||||
messageCount: 1,
|
||||
firstCommand: null,
|
||||
};
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: (path: string) => Effect.succeed(path === projectPath),
|
||||
readDirectory: (path: string) =>
|
||||
path === projectPath
|
||||
? Effect.succeed([
|
||||
"session1.jsonl",
|
||||
"session2.jsonl",
|
||||
"session3.jsonl",
|
||||
])
|
||||
: Effect.succeed([]),
|
||||
stat: () => Effect.succeed(makeFileInfoMock("File", mockDate)),
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const SessionMetaServiceMock = makeSessionMetaServiceMock(mockMeta);
|
||||
const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* SessionRepository;
|
||||
return yield* repo.getSessions(projectId, { maxCount: 2 });
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionRepository.Live),
|
||||
Effect.provide(SessionMetaServiceMock),
|
||||
Effect.provide(PredictSessionsDatabaseMock),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.sessions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("can paginate with cursor option", async () => {
|
||||
const projectPath = "/test/project";
|
||||
const projectId = Buffer.from(projectPath).toString("base64url");
|
||||
const mockDate = new Date("2024-01-01T00:00:00.000Z");
|
||||
|
||||
const mockMeta: SessionMeta = {
|
||||
messageCount: 1,
|
||||
firstCommand: null,
|
||||
};
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: (path: string) => Effect.succeed(path === projectPath),
|
||||
readDirectory: (path: string) =>
|
||||
path === projectPath
|
||||
? Effect.succeed([
|
||||
"session1.jsonl",
|
||||
"session2.jsonl",
|
||||
"session3.jsonl",
|
||||
])
|
||||
: Effect.succeed([]),
|
||||
stat: () => Effect.succeed(makeFileInfoMock("File", mockDate)),
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const SessionMetaServiceMock = makeSessionMetaServiceMock(mockMeta);
|
||||
const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* SessionRepository;
|
||||
return yield* repo.getSessions(projectId, {
|
||||
cursor: "session1",
|
||||
});
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionRepository.Live),
|
||||
Effect.provide(SessionMetaServiceMock),
|
||||
Effect.provide(PredictSessionsDatabaseMock),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.sessions.length).toBeGreaterThan(0);
|
||||
expect(result.sessions.every((s) => s.id !== "session1")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty array when project does not exist", async () => {
|
||||
const projectId = Buffer.from("/nonexistent").toString("base64url");
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: () => Effect.succeed(false),
|
||||
readDirectory: () =>
|
||||
Effect.fail(
|
||||
new SystemError({
|
||||
method: "readDirectory",
|
||||
reason: "NotFound",
|
||||
module: "FileSystem",
|
||||
cause: undefined,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const SessionMetaServiceMock = makeSessionMetaServiceMock({
|
||||
messageCount: 0,
|
||||
firstCommand: null,
|
||||
});
|
||||
const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* SessionRepository;
|
||||
return yield* repo.getSessions(projectId);
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionRepository.Live),
|
||||
Effect.provide(SessionMetaServiceMock),
|
||||
Effect.provide(PredictSessionsDatabaseMock),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns including predicted sessions", async () => {
|
||||
const projectPath = "/test/project";
|
||||
const projectId = Buffer.from(projectPath).toString("base64url");
|
||||
const mockDate = new Date("2024-01-01T00:00:00.000Z");
|
||||
const virtualDate = new Date("2024-01-03T00:00:00.000Z");
|
||||
|
||||
const mockMeta: SessionMeta = {
|
||||
messageCount: 1,
|
||||
firstCommand: null,
|
||||
};
|
||||
|
||||
const mockConversations: (Conversation | ErrorJsonl)[] = [
|
||||
{
|
||||
type: "user",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
timestamp: virtualDate.toISOString(),
|
||||
message: { role: "user", content: "Hello" },
|
||||
isSidechain: false,
|
||||
userType: "external",
|
||||
cwd: "/test",
|
||||
sessionId: "predict-session",
|
||||
version: "1.0.0",
|
||||
parentUuid: null,
|
||||
},
|
||||
];
|
||||
|
||||
const FileSystemMock = makeFileSystemMock({
|
||||
exists: (path: string) => Effect.succeed(path === projectPath),
|
||||
readDirectory: (path: string) =>
|
||||
path === projectPath
|
||||
? Effect.succeed(["session1.jsonl"])
|
||||
: Effect.succeed([]),
|
||||
stat: () => Effect.succeed(makeFileInfoMock("File", mockDate)),
|
||||
});
|
||||
|
||||
const PathMock = makePathMock();
|
||||
const PersistentServiceMock = makePersistentServiceMock();
|
||||
const SessionMetaServiceMock = makeSessionMetaServiceMock(mockMeta);
|
||||
const PredictSessionsDatabaseMock = Layer.succeed(
|
||||
VirtualConversationDatabase,
|
||||
{
|
||||
getProjectVirtualConversations: (pid: string) =>
|
||||
Effect.succeed(
|
||||
pid === projectId
|
||||
? [
|
||||
{
|
||||
projectId,
|
||||
sessionId: "predict-session",
|
||||
conversations: mockConversations,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
getSessionVirtualConversation: () => Effect.succeed(null),
|
||||
createVirtualConversation: () => Effect.void,
|
||||
deleteVirtualConversations: () => Effect.void,
|
||||
},
|
||||
);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const repo = yield* SessionRepository;
|
||||
return yield* repo.getSessions(projectId);
|
||||
});
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
program.pipe(
|
||||
Effect.provide(SessionRepository.Live),
|
||||
Effect.provide(SessionMetaServiceMock),
|
||||
Effect.provide(PredictSessionsDatabaseMock),
|
||||
Effect.provide(FileSystemMock),
|
||||
Effect.provide(PathMock),
|
||||
Effect.provide(PersistentServiceMock),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.sessions.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.sessions.some((s) => s.id === "predict-session")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,99 +1,207 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { FileSystem } from "@effect/platform";
|
||||
import { Context, Effect, Layer, Option } from "effect";
|
||||
import { uniqBy } from "es-toolkit";
|
||||
import { parseCommandXml } from "../parseCommandXml";
|
||||
import { parseJsonl } from "../parseJsonl";
|
||||
import { decodeProjectId } from "../project/id";
|
||||
import type { Session, SessionDetail } from "../types";
|
||||
import { decodeSessionId, encodeSessionId } from "./id";
|
||||
import { predictSessionsDatabase } from "./PredictSessionsDatabase";
|
||||
import { sessionMetaStorage } from "./sessionMetaStorage";
|
||||
import { VirtualConversationDatabase } from "./PredictSessionsDatabase";
|
||||
import { SessionMetaService } from "./SessionMetaService";
|
||||
|
||||
const getSession = (projectId: string, sessionId: string) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const sessionMetaService = yield* SessionMetaService;
|
||||
const virtualConversationDatabase = yield* VirtualConversationDatabase;
|
||||
|
||||
export class SessionRepository {
|
||||
public async getSession(
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
): Promise<{
|
||||
session: SessionDetail;
|
||||
}> {
|
||||
const sessionPath = decodeSessionId(projectId, sessionId);
|
||||
if (!existsSync(sessionPath)) {
|
||||
const predictSession =
|
||||
predictSessionsDatabase.getPredictSession(sessionId);
|
||||
if (predictSession) {
|
||||
return {
|
||||
session: predictSession,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
const content = await readFile(sessionPath, "utf-8");
|
||||
const virtualConversation =
|
||||
yield* virtualConversationDatabase.getSessionVirtualConversation(
|
||||
sessionId,
|
||||
);
|
||||
|
||||
if (predictSession !== null) {
|
||||
return {
|
||||
session: predictSession,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
const content = await readFile(sessionPath, "utf-8");
|
||||
// Check if session file exists
|
||||
const exists = yield* fs.exists(sessionPath);
|
||||
const sessionDetail = yield* exists
|
||||
? Effect.gen(function* () {
|
||||
// Read session file
|
||||
const content = yield* fs.readFileString(sessionPath);
|
||||
const allLines = content.split("\n").filter((line) => line.trim());
|
||||
|
||||
const conversations = parseJsonl(allLines.join("\n"));
|
||||
|
||||
// Get file stats
|
||||
const stat = yield* fs.stat(sessionPath);
|
||||
|
||||
// Get session metadata
|
||||
const meta = yield* sessionMetaService.getSessionMeta(
|
||||
projectId,
|
||||
sessionId,
|
||||
);
|
||||
|
||||
const mergedConversations = [
|
||||
...conversations,
|
||||
...(virtualConversation !== null
|
||||
? virtualConversation.conversations
|
||||
: []),
|
||||
];
|
||||
|
||||
const conversationMap = new Map(
|
||||
mergedConversations.flatMap((c, index) => {
|
||||
if (
|
||||
c.type === "user" ||
|
||||
c.type === "assistant" ||
|
||||
c.type === "system"
|
||||
) {
|
||||
return [[c.uuid, { conversation: c, index }] as const];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const isBroken = mergedConversations.some((item, index) => {
|
||||
if (item.type !== "summary") return false;
|
||||
const leftMessage = conversationMap.get(item.leafUuid);
|
||||
if (leftMessage === undefined) return false;
|
||||
|
||||
return index < leftMessage.index;
|
||||
});
|
||||
|
||||
const sessionDetail: SessionDetail = {
|
||||
id: sessionId,
|
||||
jsonlFilePath: sessionPath,
|
||||
meta: await sessionMetaStorage.getSessionMeta(projectId, sessionId),
|
||||
conversations,
|
||||
lastModifiedAt: statSync(sessionPath).mtime,
|
||||
meta,
|
||||
conversations: isBroken
|
||||
? conversations
|
||||
: uniqBy(mergedConversations, (item) => {
|
||||
switch (item.type) {
|
||||
case "system":
|
||||
return `${item.type}-${item.uuid}`;
|
||||
case "assistant":
|
||||
return `${item.type}-${item.message.id}`;
|
||||
case "user":
|
||||
return `${item.type}-${item.message.content}`;
|
||||
case "summary":
|
||||
return `${item.type}-${item.leafUuid}`;
|
||||
case "x-error":
|
||||
return `${item.type}-${item.lineNumber}-${item.line}`;
|
||||
default:
|
||||
item satisfies never;
|
||||
throw new Error(`Unknown conversation type: ${item}`);
|
||||
}
|
||||
}),
|
||||
lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()),
|
||||
};
|
||||
|
||||
return sessionDetail;
|
||||
})
|
||||
: (() => {
|
||||
if (virtualConversation === null) {
|
||||
return Effect.succeed(null);
|
||||
}
|
||||
|
||||
const lastConversation = virtualConversation.conversations
|
||||
.filter(
|
||||
(conversation) =>
|
||||
conversation.type === "user" ||
|
||||
conversation.type === "assistant" ||
|
||||
conversation.type === "system",
|
||||
)
|
||||
.at(-1);
|
||||
|
||||
const virtualSession: SessionDetail = {
|
||||
id: sessionId,
|
||||
jsonlFilePath: `${decodeProjectId(projectId)}/${sessionId}.jsonl`,
|
||||
meta: {
|
||||
messageCount: 0,
|
||||
firstCommand: null,
|
||||
},
|
||||
conversations: virtualConversation.conversations,
|
||||
lastModifiedAt:
|
||||
lastConversation !== undefined
|
||||
? new Date(lastConversation.timestamp)
|
||||
: new Date(),
|
||||
};
|
||||
|
||||
return Effect.succeed(virtualSession);
|
||||
})();
|
||||
|
||||
return {
|
||||
session: sessionDetail,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
public async getSessions(
|
||||
const getSessions = (
|
||||
projectId: string,
|
||||
options?: {
|
||||
maxCount?: number;
|
||||
cursor?: string;
|
||||
},
|
||||
): Promise<{ sessions: Session[] }> {
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const sessionMetaService = yield* SessionMetaService;
|
||||
const virtualConversationDatabase = yield* VirtualConversationDatabase;
|
||||
|
||||
const { maxCount = 20, cursor } = options ?? {};
|
||||
|
||||
try {
|
||||
const claudeProjectPath = decodeProjectId(projectId);
|
||||
const dirents = await readdir(claudeProjectPath, { withFileTypes: true });
|
||||
const sessions = await Promise.all(
|
||||
dirents
|
||||
.filter((d) => d.isFile() && d.name.endsWith(".jsonl"))
|
||||
.map(async (d) => {
|
||||
const sessionId = encodeSessionId(
|
||||
resolve(claudeProjectPath, d.name),
|
||||
|
||||
// Check if project directory exists
|
||||
const dirExists = yield* fs.exists(claudeProjectPath);
|
||||
if (!dirExists) {
|
||||
console.warn(`Project directory not found at ${claudeProjectPath}`);
|
||||
return { sessions: [] };
|
||||
}
|
||||
|
||||
// Read directory entries with error handling
|
||||
const dirents = yield* Effect.tryPromise({
|
||||
try: () => fs.readDirectory(claudeProjectPath).pipe(Effect.runPromise),
|
||||
catch: (error) => {
|
||||
console.warn(
|
||||
`Failed to read sessions for project ${projectId}:`,
|
||||
error,
|
||||
);
|
||||
const stats = statSync(resolve(claudeProjectPath, d.name));
|
||||
return new Error("Failed to read directory");
|
||||
},
|
||||
}).pipe(Effect.catchAll(() => Effect.succeed([])));
|
||||
|
||||
// Process session files
|
||||
const sessionEffects = dirents
|
||||
.filter((entry) => entry.endsWith(".jsonl"))
|
||||
.map((entry) =>
|
||||
Effect.gen(function* () {
|
||||
const fullPath = resolve(claudeProjectPath, entry);
|
||||
const sessionId = encodeSessionId(fullPath);
|
||||
|
||||
// Get file stats with error handling
|
||||
const stat = yield* Effect.tryPromise(() =>
|
||||
fs.stat(fullPath).pipe(Effect.runPromise),
|
||||
).pipe(Effect.catchAll(() => Effect.succeed(null)));
|
||||
|
||||
if (!stat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
jsonlFilePath: resolve(claudeProjectPath, d.name),
|
||||
lastModifiedAt: stats.mtime,
|
||||
jsonlFilePath: fullPath,
|
||||
lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()),
|
||||
};
|
||||
}),
|
||||
).then((fetched) =>
|
||||
fetched.sort(
|
||||
(a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(),
|
||||
),
|
||||
);
|
||||
const sessionMap = new Map<string, Session>(
|
||||
sessions.map((session) => [session.id, session]),
|
||||
);
|
||||
|
||||
const predictSessions = predictSessionsDatabase
|
||||
.getPredictSessions(projectId)
|
||||
.filter((session) => !sessionMap.has(session.id));
|
||||
// Execute all effects in parallel and filter out nulls
|
||||
const sessionsWithNulls = yield* Effect.all(sessionEffects, {
|
||||
concurrency: "unbounded",
|
||||
});
|
||||
const sessions = sessionsWithNulls
|
||||
.filter((s): s is NonNullable<typeof s> => s !== null)
|
||||
.sort((a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime());
|
||||
|
||||
const sessionMap = new Map(
|
||||
sessions.map((session) => [session.id, session] as const),
|
||||
@@ -105,40 +213,116 @@ export class SessionRepository {
|
||||
: -1;
|
||||
|
||||
if (index !== -1) {
|
||||
return {
|
||||
sessions: await Promise.all(
|
||||
sessions
|
||||
.slice(index + 1, Math.min(index + 1 + maxCount, sessions.length))
|
||||
.map(async (item) => {
|
||||
return {
|
||||
...item,
|
||||
meta: await sessionMetaStorage.getSessionMeta(
|
||||
const sessionsToReturn = sessions.slice(
|
||||
index + 1,
|
||||
Math.min(index + 1 + maxCount, sessions.length),
|
||||
);
|
||||
|
||||
const sessionsWithMeta = yield* Effect.all(
|
||||
sessionsToReturn.map((item) =>
|
||||
Effect.gen(function* () {
|
||||
const meta = yield* sessionMetaService.getSessionMeta(
|
||||
projectId,
|
||||
item.id,
|
||||
),
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
meta,
|
||||
};
|
||||
}),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
);
|
||||
|
||||
return {
|
||||
sessions: sessionsWithMeta,
|
||||
};
|
||||
}
|
||||
|
||||
const predictSessions = predictSessionsDatabase
|
||||
.getPredictSessions(projectId)
|
||||
.filter((session) => !sessionMap.has(session.id))
|
||||
// Get predict sessions
|
||||
const virtualConversations =
|
||||
yield* virtualConversationDatabase.getProjectVirtualConversations(
|
||||
projectId,
|
||||
);
|
||||
|
||||
const virtualSessions = virtualConversations
|
||||
.filter(({ sessionId }) => !sessionMap.has(sessionId))
|
||||
.map(({ sessionId, conversations }): Session => {
|
||||
const first = conversations
|
||||
.filter((conversation) => conversation.type === "user")
|
||||
.at(0);
|
||||
const last = conversations
|
||||
.filter(
|
||||
(conversation) =>
|
||||
conversation.type === "user" ||
|
||||
conversation.type === "assistant" ||
|
||||
conversation.type === "system",
|
||||
)
|
||||
.at(-1);
|
||||
|
||||
const firstUserText =
|
||||
first !== undefined
|
||||
? typeof first.message.content === "string"
|
||||
? first.message.content
|
||||
: (() => {
|
||||
const firstContent = first.message.content.at(0);
|
||||
if (firstContent === undefined) return null;
|
||||
if (typeof firstContent === "string") return firstContent;
|
||||
if (firstContent.type === "text") return firstContent.text;
|
||||
return null;
|
||||
})()
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
jsonlFilePath: `${decodeProjectId(projectId)}/${sessionId}.jsonl`,
|
||||
lastModifiedAt:
|
||||
last !== undefined ? new Date(last.timestamp) : new Date(),
|
||||
meta: {
|
||||
messageCount: conversations.length,
|
||||
firstCommand: firstUserText ? parseCommandXml(firstUserText) : null,
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime();
|
||||
});
|
||||
|
||||
return {
|
||||
sessions: [...predictSessions, ...sessions].sort((a, b) => {
|
||||
return (
|
||||
getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt)
|
||||
// Get sessions with metadata
|
||||
const sessionsToReturn = sessions.slice(
|
||||
0,
|
||||
Math.min(maxCount, sessions.length),
|
||||
);
|
||||
}),
|
||||
const sessionsWithMeta: Session[] = yield* Effect.all(
|
||||
sessionsToReturn.map((item) =>
|
||||
Effect.gen(function* () {
|
||||
const meta = yield* sessionMetaService.getSessionMeta(
|
||||
projectId,
|
||||
item.id,
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
meta,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read sessions for project ${projectId}:`, error);
|
||||
return { sessions: [] };
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
);
|
||||
|
||||
return {
|
||||
sessions: [...virtualSessions, ...sessionsWithMeta],
|
||||
};
|
||||
});
|
||||
|
||||
export class SessionRepository extends Context.Tag("SessionRepository")<
|
||||
SessionRepository,
|
||||
{
|
||||
readonly getSession: typeof getSession;
|
||||
readonly getSessions: typeof getSessions;
|
||||
}
|
||||
>() {
|
||||
static Live = Layer.succeed(this, {
|
||||
getSession,
|
||||
getSessions,
|
||||
});
|
||||
}
|
||||
|
||||
245
src/server/service/session/VirtualConversationDatabase.test.ts
Normal file
245
src/server/service/session/VirtualConversationDatabase.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { Effect } from "effect";
|
||||
import type { Conversation } from "../../../lib/conversation-schema";
|
||||
import type { ErrorJsonl } from "../types";
|
||||
import { VirtualConversationDatabase } from "./PredictSessionsDatabase";
|
||||
|
||||
describe("VirtualConversationDatabase", () => {
|
||||
describe("getProjectVirtualConversations", () => {
|
||||
it("can retrieve session list for specified project ID", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const db = yield* VirtualConversationDatabase;
|
||||
|
||||
const projectPath = "/projects/test-project";
|
||||
const projectId = Buffer.from(projectPath).toString("base64url");
|
||||
const conversations1: (Conversation | ErrorJsonl)[] = [];
|
||||
const conversations2: (Conversation | ErrorJsonl)[] = [];
|
||||
const conversations3: (Conversation | ErrorJsonl)[] = [];
|
||||
|
||||
yield* db.createVirtualConversation(
|
||||
projectId,
|
||||
"session-1",
|
||||
conversations1,
|
||||
);
|
||||
yield* db.createVirtualConversation(
|
||||
projectId,
|
||||
"session-2",
|
||||
conversations2,
|
||||
);
|
||||
yield* db.createVirtualConversation(
|
||||
"other-project-id",
|
||||
"session-3",
|
||||
conversations3,
|
||||
);
|
||||
|
||||
const sessions = yield* db.getProjectVirtualConversations(projectId);
|
||||
|
||||
return { sessions };
|
||||
});
|
||||
|
||||
const { sessions } = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(VirtualConversationDatabase.Live)),
|
||||
);
|
||||
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions.map((s) => s.sessionId)).toEqual(
|
||||
expect.arrayContaining(["session-1", "session-2"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty array when no matching sessions exist", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const db = yield* VirtualConversationDatabase;
|
||||
const sessions = yield* db.getProjectVirtualConversations(
|
||||
"non-existent-project",
|
||||
);
|
||||
return { sessions };
|
||||
});
|
||||
|
||||
const { sessions } = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(VirtualConversationDatabase.Live)),
|
||||
);
|
||||
|
||||
expect(sessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSessionVirtualConversation", () => {
|
||||
it("can retrieve session by specified ID", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const db = yield* VirtualConversationDatabase;
|
||||
|
||||
const conversations: (Conversation | ErrorJsonl)[] = [];
|
||||
|
||||
yield* db.createVirtualConversation(
|
||||
"project-1",
|
||||
"session-1",
|
||||
conversations,
|
||||
);
|
||||
const result = yield* db.getSessionVirtualConversation("session-1");
|
||||
|
||||
return { result };
|
||||
});
|
||||
|
||||
const { result } = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(VirtualConversationDatabase.Live)),
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.sessionId).toBe("session-1");
|
||||
});
|
||||
|
||||
it("returns null for non-existent session ID", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const db = yield* VirtualConversationDatabase;
|
||||
const result = yield* db.getSessionVirtualConversation(
|
||||
"non-existent-session",
|
||||
);
|
||||
return { result };
|
||||
});
|
||||
|
||||
const { result } = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(VirtualConversationDatabase.Live)),
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVirtualConversation", () => {
|
||||
it("can add new session", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const db = yield* VirtualConversationDatabase;
|
||||
|
||||
const conversations: (Conversation | ErrorJsonl)[] = [];
|
||||
|
||||
yield* db.createVirtualConversation(
|
||||
"project-1",
|
||||
"session-1",
|
||||
conversations,
|
||||
);
|
||||
const result = yield* db.getSessionVirtualConversation("session-1");
|
||||
|
||||
return { result };
|
||||
});
|
||||
|
||||
const { result } = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(VirtualConversationDatabase.Live)),
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.sessionId).toBe("session-1");
|
||||
});
|
||||
|
||||
it("can append conversations to existing session", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const db = yield* VirtualConversationDatabase;
|
||||
|
||||
const conversations1: (Conversation | ErrorJsonl)[] = [];
|
||||
const conversations2: (Conversation | ErrorJsonl)[] = [];
|
||||
|
||||
yield* db.createVirtualConversation(
|
||||
"project-1",
|
||||
"session-1",
|
||||
conversations1,
|
||||
);
|
||||
yield* db.createVirtualConversation(
|
||||
"project-1",
|
||||
"session-1",
|
||||
conversations2,
|
||||
);
|
||||
const result = yield* db.getSessionVirtualConversation("session-1");
|
||||
|
||||
return { result };
|
||||
});
|
||||
|
||||
const { result } = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(VirtualConversationDatabase.Live)),
|
||||
);
|
||||
|
||||
expect(result?.conversations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteVirtualConversations", () => {
|
||||
it("can delete specified session", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const db = yield* VirtualConversationDatabase;
|
||||
|
||||
const conversations: (Conversation | ErrorJsonl)[] = [];
|
||||
|
||||
yield* db.createVirtualConversation(
|
||||
"project-1",
|
||||
"session-1",
|
||||
conversations,
|
||||
);
|
||||
yield* db.deleteVirtualConversations("session-1");
|
||||
const result = yield* db.getSessionVirtualConversation("session-1");
|
||||
|
||||
return { result };
|
||||
});
|
||||
|
||||
const { result } = await Effect.runPromise(
|
||||
program.pipe(Effect.provide(VirtualConversationDatabase.Live)),
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("deleting non-existent session does not cause error", async () => {
|
||||
const program = Effect.gen(function* () {
|
||||
const db = yield* VirtualConversationDatabase;
|
||||
yield* db.deleteVirtualConversations("non-existent-session");
|
||||
});
|
||||
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
program.pipe(Effect.provide(VirtualConversationDatabase.Live)),
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("state is isolated between multiple instances", () => {
|
||||
it("different layers have different states", async () => {
|
||||
const projectId = "test-project-id";
|
||||
const conversations1: (Conversation | ErrorJsonl)[] = [];
|
||||
const conversations2: (Conversation | ErrorJsonl)[] = [];
|
||||
|
||||
const program1 = Effect.gen(function* () {
|
||||
const db = yield* VirtualConversationDatabase;
|
||||
yield* db.createVirtualConversation(
|
||||
projectId,
|
||||
"session-1",
|
||||
conversations1,
|
||||
);
|
||||
const sessions = yield* db.getProjectVirtualConversations(projectId);
|
||||
return { sessions };
|
||||
});
|
||||
|
||||
const program2 = Effect.gen(function* () {
|
||||
const db = yield* VirtualConversationDatabase;
|
||||
yield* db.createVirtualConversation(
|
||||
projectId,
|
||||
"session-2",
|
||||
conversations2,
|
||||
);
|
||||
const sessions = yield* db.getProjectVirtualConversations(projectId);
|
||||
return { sessions };
|
||||
});
|
||||
|
||||
const result1 = await Effect.runPromise(
|
||||
program1.pipe(Effect.provide(VirtualConversationDatabase.Live)),
|
||||
);
|
||||
|
||||
const result2 = await Effect.runPromise(
|
||||
program2.pipe(Effect.provide(VirtualConversationDatabase.Live)),
|
||||
);
|
||||
|
||||
expect(result1.sessions).toHaveLength(1);
|
||||
expect(result1.sessions.at(0)?.sessionId).toBe("session-1");
|
||||
|
||||
expect(result2.sessions).toHaveLength(1);
|
||||
expect(result2.sessions.at(0)?.sessionId).toBe("session-2");
|
||||
});
|
||||
});
|
||||
});
|
||||
116
src/server/service/session/VirtualConversationDatabase.ts
Normal file
116
src/server/service/session/VirtualConversationDatabase.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Context, Effect, Layer, Ref } from "effect";
|
||||
import type { Conversation } from "../../../lib/conversation-schema";
|
||||
import type { ErrorJsonl } from "../types";
|
||||
|
||||
/**
|
||||
* For interactively experience, handle sessions not already persisted to the filesystem.
|
||||
*/
|
||||
export class VirtualConversationDatabase extends Context.Tag(
|
||||
"VirtualConversationDatabase",
|
||||
)<
|
||||
VirtualConversationDatabase,
|
||||
{
|
||||
readonly getProjectVirtualConversations: (
|
||||
projectId: string,
|
||||
) => Effect.Effect<
|
||||
{
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
conversations: (Conversation | ErrorJsonl)[];
|
||||
}[]
|
||||
>;
|
||||
readonly getSessionVirtualConversation: (
|
||||
sessionId: string,
|
||||
) => Effect.Effect<{
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
conversations: (Conversation | ErrorJsonl)[];
|
||||
} | null>;
|
||||
readonly createVirtualConversation: (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
conversations: readonly (Conversation | ErrorJsonl)[],
|
||||
) => Effect.Effect<void>;
|
||||
readonly deleteVirtualConversations: (
|
||||
sessionId: string,
|
||||
) => Effect.Effect<void>;
|
||||
}
|
||||
>() {
|
||||
static Live = Layer.effect(
|
||||
this,
|
||||
Effect.gen(function* () {
|
||||
const storageRef = yield* Ref.make<
|
||||
{
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
conversations: (Conversation | ErrorJsonl)[];
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const getProjectVirtualConversations = (projectId: string) =>
|
||||
Effect.gen(function* () {
|
||||
const conversations = yield* Ref.get(storageRef);
|
||||
return conversations.filter(
|
||||
(conversation) => conversation.projectId === projectId,
|
||||
);
|
||||
});
|
||||
|
||||
const getSessionVirtualConversation = (sessionId: string) =>
|
||||
Effect.gen(function* () {
|
||||
const conversations = yield* Ref.get(storageRef);
|
||||
return (
|
||||
conversations.find(
|
||||
(conversation) => conversation.sessionId === sessionId,
|
||||
) ?? null
|
||||
);
|
||||
});
|
||||
|
||||
const createVirtualConversation = (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
createConversations: readonly (Conversation | ErrorJsonl)[],
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(storageRef, (conversations) => {
|
||||
const existingRecord = conversations.find(
|
||||
(record) =>
|
||||
record.projectId === projectId &&
|
||||
record.sessionId === sessionId,
|
||||
);
|
||||
|
||||
if (existingRecord === undefined) {
|
||||
return [
|
||||
...conversations,
|
||||
{
|
||||
projectId,
|
||||
sessionId,
|
||||
conversations: [...createConversations],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
existingRecord.conversations.push(...createConversations);
|
||||
return conversations;
|
||||
});
|
||||
});
|
||||
|
||||
const deleteVirtualConversations = (sessionId: string) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(storageRef, (conversations) => {
|
||||
return conversations.filter((c) => c.sessionId !== sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
getProjectVirtualConversations,
|
||||
getSessionVirtualConversation,
|
||||
createVirtualConversation,
|
||||
deleteVirtualConversations,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export type IVirtualConversationDatabase = Context.Tag.Service<
|
||||
typeof VirtualConversationDatabase
|
||||
>;
|
||||
@@ -1,123 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { FileCacheStorage } from "../../lib/storage/FileCacheStorage";
|
||||
import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage";
|
||||
import {
|
||||
type ParsedCommand,
|
||||
parseCommandXml,
|
||||
parsedCommandSchema,
|
||||
} from "../parseCommandXml";
|
||||
import { parseJsonl } from "../parseJsonl";
|
||||
import type { SessionMeta } from "../types";
|
||||
import { decodeSessionId } from "./id";
|
||||
|
||||
const ignoreCommands = [
|
||||
"/clear",
|
||||
"/login",
|
||||
"/logout",
|
||||
"/exit",
|
||||
"/mcp",
|
||||
"/memory",
|
||||
];
|
||||
|
||||
class SessionMetaStorage {
|
||||
private firstCommandCache = FileCacheStorage.load(
|
||||
"first-command-cache",
|
||||
parsedCommandSchema,
|
||||
);
|
||||
private sessionMetaCache = new InMemoryCacheStorage<SessionMeta>();
|
||||
|
||||
public async getSessionMeta(
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
): Promise<SessionMeta> {
|
||||
const cached = this.sessionMetaCache.get(sessionId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const sessionPath = decodeSessionId(projectId, sessionId);
|
||||
|
||||
const content = await readFile(sessionPath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
const sessionMeta: SessionMeta = {
|
||||
messageCount: lines.length,
|
||||
firstCommand: this.getFirstCommand(sessionPath, lines),
|
||||
};
|
||||
|
||||
this.sessionMetaCache.save(sessionId, sessionMeta);
|
||||
|
||||
return sessionMeta;
|
||||
}
|
||||
|
||||
private getFirstCommand = (
|
||||
jsonlFilePath: string,
|
||||
lines: string[],
|
||||
): ParsedCommand | null => {
|
||||
const cached = this.firstCommandCache.get(jsonlFilePath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let firstCommand: ParsedCommand | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const conversation = parseJsonl(line).at(0);
|
||||
|
||||
if (conversation === undefined || conversation.type !== "user") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstUserText =
|
||||
conversation === null
|
||||
? null
|
||||
: typeof conversation.message.content === "string"
|
||||
? conversation.message.content
|
||||
: (() => {
|
||||
const firstContent = conversation.message.content.at(0);
|
||||
if (firstContent === undefined) return null;
|
||||
if (typeof firstContent === "string") return firstContent;
|
||||
if (firstContent.type === "text") return firstContent.text;
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (firstUserText === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
firstUserText ===
|
||||
"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const command = parseCommandXml(firstUserText);
|
||||
if (command.kind === "local-command") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
command.kind === "command" &&
|
||||
ignoreCommands.includes(command.commandName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
firstCommand = command;
|
||||
break;
|
||||
}
|
||||
|
||||
if (firstCommand !== null) {
|
||||
this.firstCommandCache.save(jsonlFilePath, firstCommand);
|
||||
}
|
||||
|
||||
return firstCommand;
|
||||
};
|
||||
|
||||
public invalidateSession(_projectId: string, sessionId: string) {
|
||||
this.sessionMetaCache.invalidate(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export const sessionMetaStorage = new SessionMetaStorage();
|
||||
@@ -23,6 +23,7 @@ export type SessionMeta = z.infer<typeof sessionMetaSchema>;
|
||||
export type ErrorJsonl = {
|
||||
type: "x-error";
|
||||
line: string;
|
||||
lineNumber: number;
|
||||
};
|
||||
|
||||
export type SessionDetail = Session & {
|
||||
|
||||
6
src/types/session-process.ts
Normal file
6
src/types/session-process.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type PublicSessionProcess = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
status: "paused" | "running";
|
||||
};
|
||||
@@ -1,8 +1,5 @@
|
||||
import type {
|
||||
AliveClaudeCodeTask,
|
||||
ClaudeCodeTask,
|
||||
PermissionRequest,
|
||||
} from "../server/service/claude-code/types";
|
||||
import type { PermissionRequest } from "./permissions";
|
||||
import type { PublicSessionProcess } from "./session-process";
|
||||
|
||||
export type SSEEventDeclaration = {
|
||||
// biome-ignore lint/complexity/noBannedTypes: correct type
|
||||
@@ -20,9 +17,8 @@ export type SSEEventDeclaration = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
taskChanged: {
|
||||
aliveTasks: AliveClaudeCodeTask[];
|
||||
changed: Pick<ClaudeCodeTask, "status" | "sessionId" | "projectId">;
|
||||
sessionProcessChanged: {
|
||||
processes: PublicSessionProcess[];
|
||||
};
|
||||
|
||||
permission_requested: {
|
||||
|
||||
Reference in New Issue
Block a user