refactor: add effect-ts and refactor codes

This commit is contained in:
d-kimsuon
2025-10-15 23:22:27 +09:00
parent 94cc1c0630
commit 21070d09ff
76 changed files with 7598 additions and 1950 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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);

View File

@@ -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}</>;

View 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}</>;
};

View File

@@ -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>

View File

@@ -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";

View File

@@ -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}`);
}
},
});
};

View File

@@ -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]"

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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"

View File

@@ -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 (

View File

@@ -87,7 +87,7 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
/>
);
case "mcp":
return <McpTab />;
return <McpTab projectId={projectId} />;
case "settings":
return <SettingsTab openingProjectId={projectId} />;
default:

View File

@@ -69,7 +69,7 @@ export const SessionSidebar: FC<{
/>
);
case "mcp":
return <McpTab />;
return <McpTab projectId={projectId} />;
case "settings":
return <SettingsTab openingProjectId={projectId} />;
default:

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -1,4 +0,0 @@
import { atom } from "jotai";
import type { SerializableAliveTask } from "../../../../../../server/service/claude-code/types";
export const aliveTasksAtom = atom<SerializableAliveTask[]>([]);

View File

@@ -0,0 +1,4 @@
import { atom } from "jotai";
import type { PublicSessionProcess } from "../../../../../../types/session-process";
export const sessionProcessesAtom = atom<PublicSessionProcess[]>([]);

View File

@@ -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,

View File

@@ -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;

View 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;
};

View File

@@ -9,3 +9,5 @@ export const UserEntrySchema = BaseEntrySchema.extend({
// required
message: UserMessageSchema,
});
export type UserEntry = z.infer<typeof UserEntrySchema>;

View 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");
});
});
});

View File

@@ -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;
}),
);
}

View File

@@ -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;

View 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;

View File

@@ -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>;

View File

@@ -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();
}
}

View 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>;

View 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),
),
),
),
);
});
});
});

View 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);
}),
};
}),
);

View File

@@ -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);
}
}

View 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);
});
});
});

View 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",
}),
},
});
});
};

View File

@@ -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 } : {}),
},
});
}
}

View 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);
}

View 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);
}

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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);
}
}

View 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,
};
};

View File

@@ -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,
};
};

View 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;
});
};

View 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;

View File

@@ -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);
});
});
});

View 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);

View File

@@ -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";
};

View 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);
});
});
});

View File

@@ -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;
}),
);
}

View File

@@ -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: {

View File

@@ -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);

View 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);
});
});
});

View File

@@ -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();

View 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
});
});
});

View File

@@ -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);
}

View File

@@ -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[] = [];

View File

@@ -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;
}

View 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);
});
});
});

View 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
>;

View 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);
});
});
});

View File

@@ -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,
});
}

View File

@@ -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();

View File

@@ -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();

View 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);
});
});
});

View 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
>;

View 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,
);
});
});
});

View File

@@ -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,
});
}

View 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");
});
});
});

View 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
>;

View File

@@ -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();

View File

@@ -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 & {

View File

@@ -0,0 +1,6 @@
export type PublicSessionProcess = {
id: string;
projectId: string;
sessionId: string;
status: "paused" | "running";
};

View File

@@ -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: {