diff --git a/package.json b/package.json index bd2edac..efab3cb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6faec8f..4b275ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/api/[[...route]]/route.ts b/src/app/api/[[...route]]/route.ts index 76f4e5e..12ae66e 100644 --- a/src/app/api/[[...route]]/route.ts +++ b/src/app/api/[[...route]]/route.ts @@ -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); diff --git a/src/app/components/SSEEventListeners.tsx b/src/app/components/SSEEventListeners.tsx index 77d6fbc..50bf595 100644 --- a/src/app/components/SSEEventListeners.tsx +++ b/src/app/components/SSEEventListeners.tsx @@ -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 = ({ 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 = ({ children }) => { }); }); - useServerEventListener("taskChanged", async ({ aliveTasks }) => { - setAliveTasks(aliveTasks); + useServerEventListener("sessionProcessChanged", async ({ processes }) => { + setSessionProcesses(processes); }); return <>{children}; diff --git a/src/app/components/SyncSessionProcess.tsx b/src/app/components/SyncSessionProcess.tsx new file mode 100644 index 0000000..e0ff79a --- /dev/null +++ b/src/app/components/SyncSessionProcess.tsx @@ -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}; +}; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 030d292..f882369 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - {children} + + + {children} + + diff --git a/src/app/projects/[projectId]/components/chatForm/index.ts b/src/app/projects/[projectId]/components/chatForm/index.ts index 1b68d06..ca75a3b 100644 --- a/src/app/projects/[projectId]/components/chatForm/index.ts +++ b/src/app/projects/[projectId]/components/chatForm/index.ts @@ -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"; diff --git a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts index 72d49ab..e124182 100644 --- a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts +++ b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts @@ -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}`); - } - }, }); }; diff --git a/src/app/projects/[projectId]/components/newChat/NewChat.tsx b/src/app/projects/[projectId]/components/newChat/NewChat.tsx index 62f4e30..6f73a1e 100644 --- a/src/app/projects/[projectId]/components/newChat/NewChat.tsx +++ b/src/app/projects/[projectId]/components/newChat/NewChat.tsx @@ -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<{ { - 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 (
@@ -136,7 +149,7 @@ export const SessionPageContent: FC<{
- {isRunningTask && ( + {relatedSessionProcess?.status === "running" && (
@@ -148,7 +161,7 @@ export const SessionPageContent: FC<{ variant="ghost" size="sm" onClick={() => { - abortTask.mutate(sessionId); + abortTask.mutate(relatedSessionProcess.id); }} > @@ -157,7 +170,7 @@ export const SessionPageContent: FC<{
)} - {isPausedTask && ( + {relatedSessionProcess?.status === "paused" && (
@@ -169,7 +182,7 @@ export const SessionPageContent: FC<{ variant="ghost" size="sm" onClick={() => { - abortTask.mutate(sessionId); + abortTask.mutate(relatedSessionProcess.id); }} > @@ -190,7 +203,7 @@ export const SessionPageContent: FC<{ getToolResult={getToolResult} /> - {isRunningTask && ( + {relatedSessionProcess?.status === "running" && (
@@ -207,12 +220,15 @@ export const SessionPageContent: FC<{
)} - + {relatedSessionProcess !== undefined ? ( + + ) : ( + + )}
diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ContinueChat.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ContinueChat.tsx new file mode 100644 index 0000000..bdbf87f --- /dev/null +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ContinueChat.tsx @@ -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 ( +
+ +
+ ); +}; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx index 0f4397e..b2a788e 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx @@ -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<{ { +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 ( diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx index d49066f..dca0e3c 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx @@ -87,7 +87,7 @@ export const MobileSidebar: FC = ({ /> ); case "mcp": - return ; + return ; case "settings": return ; default: diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx index 5b4a9f0..4e08949 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx @@ -69,7 +69,7 @@ export const SessionSidebar: FC<{ /> ); case "mcp": - return ; + return ; case "settings": return ; default: diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx index 221090b..fd99fbd 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx @@ -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 ( { - 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; -}; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSession.ts b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSession.ts index 961de3a..88295a2 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSession.ts +++ b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSession.ts @@ -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, }; }; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionProcess.ts b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionProcess.ts new file mode 100644 index 0000000..52203bd --- /dev/null +++ b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionProcess.ts @@ -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, + }; +}; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom.ts b/src/app/projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom.ts deleted file mode 100644 index 90dd9de..0000000 --- a/src/app/projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { atom } from "jotai"; -import type { SerializableAliveTask } from "../../../../../../server/service/claude-code/types"; - -export const aliveTasksAtom = atom([]); diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom.ts b/src/app/projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom.ts new file mode 100644 index 0000000..7cefa5c --- /dev/null +++ b/src/app/projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom.ts @@ -0,0 +1,4 @@ +import { atom } from "jotai"; +import type { PublicSessionProcess } from "../../../../../../types/session-process"; + +export const sessionProcessesAtom = atom([]); diff --git a/src/hooks/usePermissionRequests.ts b/src/hooks/usePermissionRequests.ts index 17a4cb9..d28f485 100644 --- a/src/hooks/usePermissionRequests.ts +++ b/src/hooks/usePermissionRequests.ts @@ -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, diff --git a/src/lib/api/queries.ts b/src/lib/api/queries.ts index 5d754d5..2a75bdc 100644 --- a/src/lib/api/queries.ts +++ b/src/lib/api/queries.ts @@ -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,18 +123,23 @@ export const gitCommitsQuery = (projectId: string) => }, }) as const; -export const mcpListQuery = { - queryKey: ["mcp", "list"], - queryFn: async () => { - const response = await honoClient.api.mcp.list.$get(); +export const mcpListQuery = (projectId: string) => + ({ + queryKey: ["mcp", "list", projectId], + queryFn: async () => { + 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}`); - } + if (!response.ok) { + throw new Error(`Failed to fetch MCP list: ${response.statusText}`); + } - return await response.json(); - }, -} as const; + return await response.json(); + }, + }) 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; diff --git a/src/lib/controllablePromise.ts b/src/lib/controllablePromise.ts new file mode 100644 index 0000000..98d44b0 --- /dev/null +++ b/src/lib/controllablePromise.ts @@ -0,0 +1,25 @@ +export type ControllablePromise = { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (reason?: unknown) => void; +}; + +export const controllablePromise = (): ControllablePromise => { + let promiseResolve: ((value: T) => void) | undefined; + let promiseReject: ((reason?: unknown) => void) | undefined; + + const promise = new Promise((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; +}; diff --git a/src/lib/conversation-schema/entry/UserEntrySchema.ts b/src/lib/conversation-schema/entry/UserEntrySchema.ts index 6cc7b77..99c86c5 100644 --- a/src/lib/conversation-schema/entry/UserEntrySchema.ts +++ b/src/lib/conversation-schema/entry/UserEntrySchema.ts @@ -9,3 +9,5 @@ export const UserEntrySchema = BaseEntrySchema.extend({ // required message: UserMessageSchema, }); + +export type UserEntry = z.infer; diff --git a/src/server/hono/initialize.test.ts b/src/server/hono/initialize.test.ts new file mode 100644 index 0000000..e3515a7 --- /dev/null +++ b/src/server/hono/initialize.test.ts @@ -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 + >([]); + + // 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"); + }); + }); +}); diff --git a/src/server/hono/initialize.ts b/src/server/hono/initialize.ts index 5e992b2..ab11261 100644 --- a/src/server/hono/initialize.ts +++ b/src/server/hono/initialize.ts @@ -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 => { - fileWatcher.startWatching(); +interface InitializeServiceInterface { + readonly startInitialization: () => Effect.Effect; + readonly stopCleanup: () => Effect.Effect; +} - 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; - const onSessionChanged = ( - event: InternalEventDeclaration["sessionChanged"], - ) => { - projectMetaStorage.invalidateProject(event.projectId); - sessionMetaStorage.invalidateSession(event.projectId, event.sessionId); - }; + // 状態管理用の Ref + const listenersRef = yield* Ref.make<{ + sessionProcessChanged?: + | ((event: InternalEventDeclaration["sessionProcessChanged"]) => void) + | null; + sessionChanged?: + | ((event: InternalEventDeclaration["sessionChanged"]) => void) + | null; + }>({}); - eventBus.on("sessionChanged", onSessionChanged); + const startInitialization = (): Effect.Effect => { + return Effect.gen(function* () { + // ファイルウォッチャーを開始 + yield* fileWatcher.startWatching(); - try { - console.log("Initializing projects cache"); - const { projects } = await deps.projectRepository.getProjects(); - console.log(`${projects.length} projects cache initialized`); + // ハートビートを定期的に送信 + const daemon = Effect.repeat( + eventBus.emit("heartbeat", {}), + Schedule.fixed("10 seconds"), + ); - console.log("Initializing sessions cache"); - const results = await Promise.all( - projects.map((project) => deps.sessionRepository.getSessions(project.id)), - ); - console.log( - `${results.reduce( - (s, { sessions }) => s + sessions.length, - 0, - )} sessions cache initialized`, - ); - } catch { - // do nothing - } + console.log("start heartbeat"); + yield* Effect.forkDaemon(daemon); + console.log("after starting heartbeat fork"); - prexit(() => { - clearInterval(intervalId); - eventBus.off("sessionChanged", onSessionChanged); - fileWatcher.stop(); - claudeCodeTaskController.abortAllTasks(); - }); -}; + // sessionChanged イベントのリスナーを登録 + const onSessionChanged = ( + event: InternalEventDeclaration["sessionChanged"], + ) => { + Effect.runFork( + projectMetaService.invalidateProject(event.projectId), + ); + + Effect.runFork( + sessionMetaService.invalidateSession( + event.projectId, + event.sessionId, + ), + ); + }; + + 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; + } + }; + + 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 } = yield* projectRepository.getProjects(); + console.log(`${projects.length} projects cache initialized`); + + console.log("Initializing sessions cache"); + const results = yield* Effect.all( + projects.map((project) => + sessionRepository.getSessions(project.id), + ), + { concurrency: "unbounded" }, + ); + const totalSessions = results.reduce( + (s, { sessions }) => s + sessions.length, + 0, + ); + console.log(`${totalSessions} sessions cache initialized`); + }).pipe( + Effect.catchAll(() => Effect.void), + Effect.withSpan("initialize-cache"), + ); + }).pipe(Effect.withSpan("start-initialization")) as Effect.Effect; + }; + + const stopCleanup = (): Effect.Effect => + Effect.gen(function* () { + const listeners = yield* Ref.get(listenersRef); + if (listeners.sessionChanged) { + yield* eventBus.off("sessionChanged", listeners.sessionChanged); + } + + if (listeners.sessionProcessChanged) { + yield* eventBus.off( + "sessionProcessChanged", + listeners.sessionProcessChanged, + ); + } + + yield* Ref.set(listenersRef, {}); + yield* fileWatcher.stop(); + }); + + return { + startInitialization, + stopCleanup, + } satisfies InitializeServiceInterface; + }), + ); +} diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index 9521f74..3a809ee 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -1,486 +1,692 @@ 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(); + if (env.get("NEXT_PHASE") !== "phase-production-build") { + 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")); - await next(); - }) + return ( + app + // middleware + .use(configMiddleware) + .use(async (_c, next) => { + await next(); + }) - // routes - .get("/config", async (c) => { - return c.json({ - config: c.get("config"), - }); - }) + // routes + .get("/config", async (c) => { + return c.json({ + config: c.get("config"), + }); + }) - .put("/config", zValidator("json", configSchema), async (c) => { - const { ...config } = c.req.valid("json"); + .put("/config", zValidator("json", configSchema), async (c) => { + const { ...config } = c.req.valid("json"); - setCookie(c, "ccv-config", JSON.stringify(config)); + setCookie(c, "ccv-config", JSON.stringify(config)); - return c.json({ - config, - }); - }) + return c.json({ + config, + }); + }) - .get("/projects", async (c) => { - const { projects } = await projectRepository.getProjects(); - return c.json({ projects }); - }) + .get("/projects", async (c) => { + const program = Effect.gen(function* () { + return yield* projectRepository.getProjects(); + }); - .get( - "/projects/:projectId", - zValidator("query", z.object({ cursor: z.string().optional() })), - async (c) => { - const { projectId } = c.req.param(); - const { cursor } = c.req.valid("query"); + const { projects } = await Runtime.runPromise(runtime)(program); - const [{ project }, { sessions, nextCursor }] = await Promise.all([ - projectRepository.getProject(projectId), - sessionRepository - .getSessions(projectId, { cursor }) - .then(({ sessions }) => { - let filteredSessions = sessions; + return c.json({ projects }); + }) - // Filter sessions based on hideNoUserMessageSession setting - if (c.get("config").hideNoUserMessageSession) { - filteredSessions = filteredSessions.filter((session) => { - return session.meta.firstCommand !== null; - }); - } + .get( + "/projects/:projectId", + zValidator("query", z.object({ cursor: z.string().optional() })), + async (c) => { + const { projectId } = c.req.param(); + const { cursor } = c.req.valid("query"); + const config = c.get("config"); - // Unify sessions with same title if unifySameTitleSession is enabled - if (c.get("config").unifySameTitleSession) { - const sessionMap = new Map< - string, - (typeof filteredSessions)[0] - >(); + const program = Effect.gen(function* () { + const { project } = + yield* projectRepository.getProject(projectId); + const { sessions } = yield* sessionRepository.getSessions( + projectId, + { cursor }, + ); - for (const session of filteredSessions) { - // Generate title for comparison - const title = - session.meta.firstCommand !== null - ? (() => { - const cmd = session.meta.firstCommand; - switch (cmd.kind) { - case "command": - return cmd.commandArgs === undefined - ? cmd.commandName - : `${cmd.commandName} ${cmd.commandArgs}`; - case "local-command": - return cmd.stdout; - case "text": - return cmd.content; - default: - return session.id; - } - })() - : session.id; + let filteredSessions = sessions; - const existingSession = sessionMap.get(title); - if (existingSession) { - // Keep the session with the latest modification date + // Filter sessions based on hideNoUserMessageSession setting + if (config.hideNoUserMessageSession) { + filteredSessions = filteredSessions.filter((session) => { + return session.meta.firstCommand !== null; + }); + } + + // Unify sessions with same title if unifySameTitleSession is enabled + if (config.unifySameTitleSession) { + const sessionMap = new Map< + string, + (typeof filteredSessions)[0] + >(); + + for (const session of filteredSessions) { + // Generate title for comparison + const title = + session.meta.firstCommand !== null + ? (() => { + const cmd = session.meta.firstCommand; + switch (cmd.kind) { + case "command": + return cmd.commandArgs === undefined + ? cmd.commandName + : `${cmd.commandName} ${cmd.commandArgs}`; + case "local-command": + return cmd.stdout; + case "text": + return cmd.content; + default: + return session.id; + } + })() + : session.id; + + const existingSession = sessionMap.get(title); + if (existingSession) { + // Keep the session with the latest modification date + if ( + session.lastModifiedAt && + existingSession.lastModifiedAt + ) { if ( - session.lastModifiedAt && - existingSession.lastModifiedAt - ) { - if ( - session.lastModifiedAt > - existingSession.lastModifiedAt - ) { - sessionMap.set(title, session); - } - } else if ( - session.lastModifiedAt && - !existingSession.lastModifiedAt + session.lastModifiedAt > existingSession.lastModifiedAt ) { sessionMap.set(title, session); } - // If no modification dates, keep the existing one - } else { + } else if ( + session.lastModifiedAt && + !existingSession.lastModifiedAt + ) { sessionMap.set(title, session); } + // If no modification dates, keep the existing one + } else { + sessionMap.set(title, session); } - - filteredSessions = Array.from(sessionMap.values()); } - return { - sessions: filteredSessions, - nextCursor: sessions.at(-1)?.id, - }; - }), - ] as const); - - return c.json({ project, sessions, nextCursor }); - }, - ) - - .get("/projects/:projectId/sessions/:sessionId", async (c) => { - const { projectId, sessionId } = c.req.param(); - const { session } = await sessionRepository.getSession( - projectId, - sessionId, - ); - return c.json({ session }); - }) - - .get( - "/projects/:projectId/file-completion", - zValidator( - "query", - z.object({ - basePath: z.string().optional().default("/"), - }), - ), - async (c) => { - const { projectId } = c.req.param(); - const { basePath } = c.req.valid("query"); - - const { project } = await projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return c.json({ error: "Project path not found" }, 400); - } - - try { - const result = await getFileCompletion( - project.meta.projectPath, - basePath, - ); - return c.json(result); - } catch (error) { - console.error("File completion error:", error); - return c.json({ error: "Failed to get file completion" }, 500); - } - }, - ) - - .get("/projects/:projectId/claude-commands", async (c) => { - const { projectId } = c.req.param(); - const { project } = await projectRepository.getProject(projectId); - - const [globalCommands, projectCommands] = await Promise.allSettled([ - readdir(claudeCommandsDirPath, { - withFileTypes: true, - }).then((dirents) => - dirents - .filter((d) => d.isFile() && d.name.endsWith(".md")) - .map((d) => d.name.replace(/\.md$/, "")), - ), - project.meta.projectPath !== null - ? readdir( - resolve(project.meta.projectPath, ".claude", "commands"), - { - withFileTypes: true, - }, - ).then((dirents) => - dirents - .filter((d) => d.isFile() && d.name.endsWith(".md")) - .map((d) => d.name.replace(/\.md$/, "")), - ) - : [], - ]); - - return c.json({ - globalCommands: - globalCommands.status === "fulfilled" ? globalCommands.value : [], - projectCommands: - projectCommands.status === "fulfilled" ? projectCommands.value : [], - defaultCommands: ["init", "compact"], - }); - }) - - .get("/projects/:projectId/git/branches", async (c) => { - const { projectId } = c.req.param(); - const { project } = await projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return c.json({ error: "Project path not found" }, 400); - } - - try { - const result = await getBranches(project.meta.projectPath); - return c.json(result); - } catch (error) { - console.error("Get branches error:", error); - if (error instanceof Error) { - return c.json({ error: error.message }, 400); - } - return c.json({ error: "Failed to get branches" }, 500); - } - }) - - .get("/projects/:projectId/git/commits", async (c) => { - const { projectId } = c.req.param(); - const { project } = await projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return c.json({ error: "Project path not found" }, 400); - } - - try { - const result = await getCommits(project.meta.projectPath); - return c.json(result); - } catch (error) { - console.error("Get commits error:", error); - if (error instanceof Error) { - return c.json({ error: error.message }, 400); - } - return c.json({ error: "Failed to get commits" }, 500); - } - }) - - .post( - "/projects/:projectId/git/diff", - zValidator( - "json", - z.object({ - fromRef: z.string().min(1, "fromRef is required"), - toRef: z.string().min(1, "toRef is required"), - }), - ), - 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); - } - - try { - const result = await getDiff( - project.meta.projectPath, - fromRef, - toRef, - ); - return c.json(result); - } catch (error) { - console.error("Get diff error:", error); - if (error instanceof Error) { - return c.json({ error: error.message }, 400); - } - return c.json({ error: "Failed to get diff" }, 500); - } - }, - ) - - .get("/mcp/list", async (c) => { - const { servers } = await getMcpList(); - 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, - ); - - 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, - }), - ), - }); - }) - - .post( - "/tasks/abort", - zValidator("json", z.object({ sessionId: z.string() })), - async (c) => { - const { sessionId } = c.req.valid("json"); - claudeCodeTaskController.abortTask(sessionId); - return c.json({ message: "Task aborted" }); - }, - ) - - .post( - "/tasks/permission-response", - zValidator( - "json", - z.object({ - permissionRequestId: z.string(), - decision: z.enum(["allow", "deny"]), - }), - ), - async (c) => { - const permissionResponse = c.req.valid("json"); - claudeCodeTaskController.respondToPermissionRequest( - permissionResponse, - ); - return c.json({ message: "Permission response received" }); - }, - ) - - .get("/sse", async (c) => { - return streamSSE( - c, - async (rawStream) => { - const stream = writeTypeSafeSSE(rawStream); - - const onSessionListChanged = ( - event: InternalEventDeclaration["sessionListChanged"], - ) => { - stream.writeSSE("sessionListChanged", { - projectId: event.projectId, - }); - }; - - const onSessionChanged = ( - event: InternalEventDeclaration["sessionChanged"], - ) => { - stream.writeSSE("sessionChanged", { - projectId: event.projectId, - sessionId: event.sessionId, - }); - }; - - const onTaskChanged = ( - event: InternalEventDeclaration["taskChanged"], - ) => { - 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, - }); + filteredSessions = Array.from(sessionMap.values()); } - }; - eventBus.on("sessionListChanged", onSessionListChanged); - eventBus.on("sessionChanged", onSessionChanged); - eventBus.on("taskChanged", onTaskChanged); - const { connectionPromise } = adaptInternalEventToSSE(rawStream, { - timeout: 5 /* min */ * 60 /* sec */ * 1000, - cleanUp: () => { - eventBus.off("sessionListChanged", onSessionListChanged); - eventBus.off("sessionChanged", onSessionChanged); - eventBus.off("taskChanged", onTaskChanged); - }, + return { + project, + sessions: filteredSessions, + nextCursor: sessions.at(-1)?.id, + }; }); - await connectionPromise; + const result = await Runtime.runPromise(runtime)(program); + return c.json(result); }, - async (err) => { - console.error("Streaming error:", err); - }, - ); - }) - ); -}; + ) -export type RouteType = Awaited>; + .get("/projects/:projectId/sessions/:sessionId", async (c) => { + const { projectId, sessionId } = c.req.param(); + + const program = Effect.gen(function* () { + const { session } = yield* sessionRepository.getSession( + projectId, + sessionId, + ); + return { session }; + }); + + const result = await Runtime.runPromise(runtime)(program); + return c.json(result); + }) + + .get( + "/projects/:projectId/file-completion", + zValidator( + "query", + z.object({ + basePath: z.string().optional().default("/"), + }), + ), + async (c) => { + const { projectId } = c.req.param(); + const { basePath } = c.req.valid("query"); + + 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 projectPath = project.meta.projectPath; + + try { + const result = yield* Effect.promise(() => + getFileCompletion(projectPath, basePath), + ); + return { data: result, status: 200 as const }; + } catch (error) { + console.error("File completion error:", error); + 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 program = Effect.gen(function* () { + const { project } = yield* projectRepository.getProject(projectId); + + const [globalCommands, projectCommands] = yield* Effect.promise( + () => + Promise.allSettled([ + readdir(claudeCommandsDirPath, { + withFileTypes: true, + }).then((dirents) => + dirents + .filter((d) => d.isFile() && d.name.endsWith(".md")) + .map((d) => d.name.replace(/\.md$/, "")), + ), + project.meta.projectPath !== null + ? readdir( + resolve( + project.meta.projectPath, + ".claude", + "commands", + ), + { + withFileTypes: true, + }, + ).then((dirents) => + dirents + .filter((d) => d.isFile() && d.name.endsWith(".md")) + .map((d) => d.name.replace(/\.md$/, "")), + ) + : [], + ]), + ); + + return { + globalCommands: + globalCommands.status === "fulfilled" + ? globalCommands.value + : [], + projectCommands: + 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 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 projectPath = project.meta.projectPath; + + try { + 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 { error: error.message, status: 400 as const }; + } + 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 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 projectPath = project.meta.projectPath; + + try { + 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 { error: error.message, status: 400 as const }; + } + 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( + "/projects/:projectId/git/diff", + zValidator( + "json", + z.object({ + fromRef: z.string().min(1, "fromRef is required"), + toRef: z.string().min(1, "toRef is required"), + }), + ), + async (c) => { + const { projectId } = c.req.param(); + const { fromRef, toRef } = c.req.valid("json"); + + const program = Effect.gen(function* () { + const { project } = + yield* projectRepository.getProject(projectId); + + try { + 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 { data: result, status: 200 as const }; + } catch (error) { + console.error("Get diff error:", error); + if (error instanceof Error) { + return { error: error.message, status: 400 as const }; + } + 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("/projects/:projectId/mcp/list", async (c) => { + const { projectId } = c.req.param(); + const { servers } = await getMcpList(projectId); + return c.json({ servers }); + }) + + .get("/cc/session-processes", async (c) => { + const publicProcesses = await Runtime.runPromise(runtime)( + claudeCodeLifeCycleService.getPublicSessionProcesses(), + ); + return c.json({ + 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( + "/cc/session-processes", + zValidator( + "json", + z.object({ + projectId: z.string(), + message: z.string(), + baseSessionId: z.string().optional(), + }), + ), + async (c) => { + 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( + "/cc/permission-response", + zValidator( + "json", + z.object({ + permissionRequestId: z.string(), + decision: z.enum(["allow", "deny"]), + }), + ), + async (c) => { + const permissionResponse = c.req.valid("json"); + Effect.runFork( + claudeCodePermissionService.respondToPermissionRequest( + permissionResponse, + ), + ); + return c.json({ message: "Permission response received" }); + }, + ) + + .get("/sse", async (c) => { + return streamSSE( + c, + async (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"], + ) => { + Effect.runFork( + typeSafeSSE.writeSSE("sessionListChanged", { + projectId: event.projectId, + }), + ); + }; + + const onSessionChanged = ( + event: InternalEventDeclaration["sessionChanged"], + ) => { + Effect.runFork( + typeSafeSSE.writeSSE("sessionChanged", { + projectId: event.projectId, + sessionId: event.sessionId, + }), + ); + }; + + const onSessionProcessChanged = ( + event: InternalEventDeclaration["sessionProcessChanged"], + ) => { + Effect.runFork( + typeSafeSSE.writeSSE("sessionProcessChanged", { + processes: event.processes, + }), + ); + }; + + 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: 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) => { + console.error("Streaming error:", err); + }, + ); + }) + ); + }); + +export type RouteType = ReturnType extends Effect.Effect< + infer A, + unknown, + unknown +> + ? A + : never; diff --git a/src/server/lib/effect/types.ts b/src/server/lib/effect/types.ts new file mode 100644 index 0000000..1da6522 --- /dev/null +++ b/src/server/lib/effect/types.ts @@ -0,0 +1,6 @@ +import type { Effect } from "effect"; + +// biome-ignore lint/suspicious/noExplicitAny: for type restriction +export type InferEffect = T extends Effect.Effect + ? U + : never; diff --git a/src/server/lib/env/schema.ts b/src/server/lib/env/schema.ts index 13ac4e2..b60a9ff 100644 --- a/src/server/lib/env/schema.ts +++ b/src/server/lib/env/schema.ts @@ -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; diff --git a/src/server/lib/storage/FileCacheStorage.ts b/src/server/lib/storage/FileCacheStorage.ts deleted file mode 100644 index ed333e9..0000000 --- a/src/server/lib/storage/FileCacheStorage.ts +++ /dev/null @@ -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 { - private storage = new Map(); - - private constructor(private readonly key: string) {} - - public static load( - key: string, - schema: z.ZodType, - ) { - const instance = new FileCacheStorage(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(); - } -} diff --git a/src/server/lib/storage/FileCacheStorage/PersistantService.ts b/src/server/lib/storage/FileCacheStorage/PersistantService.ts new file mode 100644 index 0000000..f59445c --- /dev/null +++ b/src/server/lib/storage/FileCacheStorage/PersistantService.ts @@ -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; diff --git a/src/server/lib/storage/FileCacheStorage/index.test.ts b/src/server/lib/storage/FileCacheStorage/index.test.ts new file mode 100644 index 0000000..9f289f2 --- /dev/null +++ b/src/server/lib/storage/FileCacheStorage/index.test.ts @@ -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; + +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(); + + // 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(); + 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(); + + // 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(); + + // 複数のデータを保存 + 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(); + 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(); + 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(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(); + + 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(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(); + + // 既に存在する同じ値を 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(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(); + + 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(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(); + + // 存在しないキーを 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(); + + // 初期データの確認 + 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), + ), + ), + ), + ); + }); + }); +}); diff --git a/src/server/lib/storage/FileCacheStorage/index.ts b/src/server/lib/storage/FileCacheStorage/index.ts new file mode 100644 index 0000000..71656ae --- /dev/null +++ b/src/server/lib/storage/FileCacheStorage/index.ts @@ -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 { + readonly get: (key: string) => Effect.Effect; + readonly set: (key: string, value: T) => Effect.Effect; + readonly invalidate: (key: string) => Effect.Effect; + readonly getAll: () => Effect.Effect>; +} + +export const FileCacheStorage = () => + Context.GenericTag>("FileCacheStorage"); + +export const makeFileCacheStorageLayer = ( + storageKey: string, + schema: z.ZodType, +) => + Layer.effect( + FileCacheStorage(), + Effect.gen(function* () { + const persistentService = yield* PersistentService; + + const runtime = yield* Effect.runtime(); + + const storageRef = yield* Effect.gen(function* () { + const persistedData = yield* persistentService.load(storageKey); + + const initialMap = new Map(); + 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); + }), + }; + }), + ); diff --git a/src/server/lib/storage/InMemoryCacheStorage.ts b/src/server/lib/storage/InMemoryCacheStorage.ts deleted file mode 100644 index 8f19e5e..0000000 --- a/src/server/lib/storage/InMemoryCacheStorage.ts +++ /dev/null @@ -1,19 +0,0 @@ -export class InMemoryCacheStorage { - private storage = new Map(); - - 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); - } -} diff --git a/src/server/service/claude-code/ClaudeCode.test.ts b/src/server/service/claude-code/ClaudeCode.test.ts new file mode 100644 index 0000000..16ba1de --- /dev/null +++ b/src/server/service/claude-code/ClaudeCode.test.ts @@ -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); + }); + }); +}); diff --git a/src/server/service/claude-code/ClaudeCode.ts b/src/server/service/claude-code/ClaudeCode.ts new file mode 100644 index 0000000..cd6fecd --- /dev/null +++ b/src/server/service/claude-code/ClaudeCode.ts @@ -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[0]["prompt"]; +type CCQueryOptions = NonNullable[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", + }), + }, + }); + }); +}; diff --git a/src/server/service/claude-code/ClaudeCodeExecutor.ts b/src/server/service/claude-code/ClaudeCodeExecutor.ts deleted file mode 100644 index b9de1b2..0000000 --- a/src/server/service/claude-code/ClaudeCodeExecutor.ts +++ /dev/null @@ -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[0]["prompt"]; -type CCQueryOptions = NonNullable[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 } : {}), - }, - }); - } -} diff --git a/src/server/service/claude-code/ClaudeCodeLifeCycleService.ts b/src/server/service/claude-code/ClaudeCodeLifeCycleService.ts new file mode 100644 index 0000000..5e6660a --- /dev/null +++ b/src/server/service/claude-code/ClaudeCodeLifeCycleService.ts @@ -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(); + + 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 => + 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; + +export class ClaudeCodeLifeCycleService extends Context.Tag( + "ClaudeCodeLifeCycleService", +)() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/service/claude-code/ClaudeCodePermissionService.ts b/src/server/service/claude-code/ClaudeCodePermissionService.ts new file mode 100644 index 0000000..7cee416 --- /dev/null +++ b/src/server/service/claude-code/ClaudeCodePermissionService.ts @@ -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 + >(new Map()); + const permissionResponsesRef = yield* Ref.make< + Map + >(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 => + 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; + +export class ClaudeCodePermissionService extends Context.Tag( + "ClaudeCodePermissionService", +)() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/service/claude-code/ClaudeCodeSessionProcessService.ts b/src/server/service/claude-code/ClaudeCodeSessionProcessService.ts new file mode 100644 index 0000000..1b14435 --- /dev/null +++ b/src/server/service/claude-code/ClaudeCodeSessionProcessService.ts @@ -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 = (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; + +export class ClaudeCodeSessionProcessService extends Context.Tag( + "ClaudeCodeSessionProcessService", +)() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts deleted file mode 100644 index 50cbaa0..0000000 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ /dev/null @@ -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 = new Map(); - private permissionResponses: Map = 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, - _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 { - 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 { - 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( - (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(); diff --git a/src/server/service/claude-code/ClaudeCodeVersion.ts b/src/server/service/claude-code/ClaudeCodeVersion.ts deleted file mode 100644 index 9193a7e..0000000 --- a/src/server/service/claude-code/ClaudeCodeVersion.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { z } from "zod"; - -const versionRegex = /^(?\d+)\.(?\d+)\.(?\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; - -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); - } -} diff --git a/src/server/service/claude-code/MessageGenerator.ts b/src/server/service/claude-code/MessageGenerator.ts new file mode 100644 index 0000000..80562ce --- /dev/null +++ b/src/server/service/claude-code/MessageGenerator.ts @@ -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; + +export type MessageGenerator = () => AsyncGenerator< + SDKUserMessage, + void, + unknown +>; + +export const createMessageGenerator = (): { + generateMessages: MessageGenerator; + setNextMessage: (message: string) => void; + setHooks: (hooks: { + onNextMessageSet?: (message: string) => void | Promise; + onNewUserMessageResolved?: (message: string) => void | Promise; + }) => void; +} => { + let sendMessagePromise = controllablePromise(); + let registeredHooks: { + onNextMessageSet: ((message: string) => void | Promise)[]; + onNewUserMessageResolved: ((message: string) => void | Promise)[]; + } = { + onNextMessageSet: [], + onNewUserMessageResolved: [], + }; + + const createMessage = (message: string): SDKUserMessage => { + return { + type: "user", + message: { + role: "user", + content: message, + }, + } as SDKUserMessage; + }; + + async function* generateMessages(): ReturnType { + sendMessagePromise = controllablePromise(); + + while (true) { + const message = await sendMessagePromise.promise; + sendMessagePromise = controllablePromise(); + 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; + onNewUserMessageResolved?: (message: string) => void | Promise; + }) => { + registeredHooks = { + onNextMessageSet: [ + ...(hooks?.onNextMessageSet ? [hooks.onNextMessageSet] : []), + ...registeredHooks.onNextMessageSet, + ], + onNewUserMessageResolved: [ + ...(hooks?.onNewUserMessageResolved + ? [hooks.onNewUserMessageResolved] + : []), + ...registeredHooks.onNewUserMessageResolved, + ], + }; + }; + + return { + generateMessages, + setNextMessage, + setHooks, + }; +}; diff --git a/src/server/service/claude-code/createMessageGenerator.ts b/src/server/service/claude-code/createMessageGenerator.ts index 2534178..541b123 100644 --- a/src/server/service/claude-code/createMessageGenerator.ts +++ b/src/server/service/claude-code/createMessageGenerator.ts @@ -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; @@ -28,17 +32,21 @@ const createPromise = () => { } as const; }; +export type InitMessageContext = { + initMessage: SDKSystemMessage; +}; + export const createMessageGenerator = ( firstMessage: string, ): { generateMessages: MessageGenerator; setNextMessage: (message: string) => void; - setFirstMessagePromise: () => void; - resolveFirstMessage: () => void; - awaitFirstMessage: () => Promise; + setInitMessagePromise: () => void; + resolveInitMessage: (context: InitMessageContext) => void; + awaitInitMessage: (ctx: InitMessageContext) => Promise; } => { let sendMessagePromise = createPromise(); - let receivedFirstMessagePromise = createPromise(); + let receivedInitMessagePromise = createPromise(); const createMessage = (message: string): SDKUserMessage => { return { @@ -65,23 +73,23 @@ export const createMessageGenerator = ( sendMessagePromise.resolve(message); }; - const setFirstMessagePromise = () => { - receivedFirstMessagePromise = createPromise(); + const setInitMessagePromise = () => { + receivedInitMessagePromise = createPromise(); }; - 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, }; }; diff --git a/src/server/service/claude-code/models/CCSessionProcess.ts b/src/server/service/claude-code/models/CCSessionProcess.ts new file mode 100644 index 0000000..59e7488 --- /dev/null +++ b/src/server/service/claude-code/models/CCSessionProcess.ts @@ -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; + }); +}; diff --git a/src/server/service/claude-code/models/ClaudeCodeTask.ts b/src/server/service/claude-code/models/ClaudeCodeTask.ts new file mode 100644 index 0000000..d4b3788 --- /dev/null +++ b/src/server/service/claude-code/models/ClaudeCodeTask.ts @@ -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; diff --git a/src/server/service/claude-code/models/ClaudeCodeVersion.test.ts b/src/server/service/claude-code/models/ClaudeCodeVersion.test.ts new file mode 100644 index 0000000..aac77e5 --- /dev/null +++ b/src/server/service/claude-code/models/ClaudeCodeVersion.test.ts @@ -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); + }); + }); +}); diff --git a/src/server/service/claude-code/models/ClaudeCodeVersion.ts b/src/server/service/claude-code/models/ClaudeCodeVersion.ts new file mode 100644 index 0000000..707f0ea --- /dev/null +++ b/src/server/service/claude-code/models/ClaudeCodeVersion.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +const versionRegex = /^(?\d+)\.(?\d+)\.(?\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; + +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); diff --git a/src/server/service/claude-code/types.ts b/src/server/service/claude-code/types.ts deleted file mode 100644 index c920d14..0000000 --- a/src/server/service/claude-code/types.ts +++ /dev/null @@ -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; - 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; - timestamp: number; -}; - -export type PermissionResponse = { - permissionRequestId: string; - decision: "allow" | "deny"; -}; diff --git a/src/server/service/events/EventBus.test.ts b/src/server/service/events/EventBus.test.ts new file mode 100644 index 0000000..21e5793 --- /dev/null +++ b/src/server/service/events/EventBus.test.ts @@ -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 = []; + + 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 = []; + const events2: Array = []; + + 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 = []; + + 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 = + []; + + 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 = + []; + + 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 = + []; + + 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 = []; + const events2: Array = []; + + 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); + }); + }); +}); diff --git a/src/server/service/events/EventBus.ts b/src/server/service/events/EventBus.ts index 9bc0a2b..902b7b2 100644 --- a/src/server/service/events/EventBus.ts +++ b/src/server/service/events/EventBus.ts @@ -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 = (data: T) => void | Promise; - constructor() { - this.emitter = new EventEmitter(); - } - - public emit( +interface EventBusService { + readonly emit: ( event: EventName, data: InternalEventDeclaration[EventName], - ): void { - this.emitter.emit(event, { - ...data, - }); - } - - public on( + ) => Effect.Effect; + readonly on: ( event: EventName, - listener: ( - data: InternalEventDeclaration[EventName], - ) => void | Promise, - ): void { - this.emitter.on(event, listener); - } - - public off( + listener: Listener, + ) => Effect.Effect; + readonly off: ( event: EventName, - listener: ( - data: InternalEventDeclaration[EventName], - ) => void | Promise, - ): void { - this.emitter.off(event, listener); - } + listener: Listener, + ) => Effect.Effect; } -export const eventBus = new EventBus(); +export class EventBus extends Context.Tag("EventBus")< + EventBus, + EventBusService +>() { + static Live = Layer.effect( + this, + Effect.gen(function* () { + const listenersMap = new Map< + keyof InternalEventDeclaration, + Set> + >(); + + const getListeners = ( + event: EventName, + ): Set> => { + if (!listenersMap.has(event)) { + listenersMap.set(event, new Set()); + } + return listenersMap.get(event) as Set< + Listener + >; + }; + + const emit = ( + event: EventName, + data: InternalEventDeclaration[EventName], + ): Effect.Effect => + Effect.gen(function* () { + const listeners = getListeners(event); + + void Promise.allSettled( + Array.from(listeners).map(async (listener) => { + await listener(data); + }), + ); + }); + + const on = ( + event: EventName, + listener: Listener, + ): Effect.Effect => + Effect.sync(() => { + const listeners = getListeners(event); + listeners.add(listener); + }); + + const off = ( + event: EventName, + listener: Listener, + ): Effect.Effect => + Effect.sync(() => { + const listeners = getListeners(event); + listeners.delete(listener); + }); + + return { + emit, + on, + off, + } satisfies EventBusService; + }), + ); +} diff --git a/src/server/service/events/InternalEventDeclaration.ts b/src/server/service/events/InternalEventDeclaration.ts index d221353..b120ef6 100644 --- a/src/server/service/events/InternalEventDeclaration.ts +++ b/src/server/service/events/InternalEventDeclaration.ts @@ -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: { diff --git a/src/server/service/events/adaptInternalEventToSSE.ts b/src/server/service/events/adaptInternalEventToSSE.ts index 9e53c41..5c281ce 100644 --- a/src/server/service/events/adaptInternalEventToSSE.ts +++ b/src/server/service/events/adaptInternalEventToSSE.ts @@ -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((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); diff --git a/src/server/service/events/fileWatcher.test.ts b/src/server/service/events/fileWatcher.test.ts new file mode 100644 index 0000000..7069085 --- /dev/null +++ b/src/server/service/events/fileWatcher.test.ts @@ -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); + }); + }); +}); diff --git a/src/server/service/events/fileWatcher.ts b/src/server/service/events/fileWatcher.ts index 0871678..91f60da 100644 --- a/src/server/service/events/fileWatcher.ts +++ b/src/server/service/events/fileWatcher.ts @@ -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 = /(?.*?)\/(?.*?)\.jsonl/; const fileRegExpGroupSchema = z.object({ @@ -9,65 +10,99 @@ const fileRegExpGroupSchema = z.object({ sessionId: z.string(), }); -export class FileWatcherService { - private isWatching = false; - private watcher: FSWatcher | null = null; - private projectWatchers: Map = new Map(); - - public startWatching(): void { - if (this.isWatching) return; - this.isWatching = true; - - try { - console.log("Starting file watcher on:", claudeProjectsDirPath); - // メインプロジェクトディレクトリを監視 - this.watcher = watch( - claudeProjectsDirPath, - { persistent: false, recursive: true }, - (eventType, filename) => { - if (!filename) return; - - const groups = fileRegExpGroupSchema.safeParse( - filename.match(fileRegExp)?.groups, - ); - - if (!groups.success) return; - - const { projectId, sessionId } = groups.data; - - if (eventType === "change") { - // セッションファイルの中身が変更されている - eventBus.emit("sessionChanged", { - projectId, - sessionId, - }); - } else if (eventType === "rename") { - // セッションファイルの追加/削除 - eventBus.emit("sessionListChanged", { - projectId, - }); - } else { - eventType satisfies never; - } - }, - ); - console.log("File watcher initialization completed"); - } catch (error) { - console.error("Failed to start file watching:", error); - } - } - - public stop(): void { - if (this.watcher) { - this.watcher.close(); - this.watcher = null; - } - - for (const [, watcher] of this.projectWatchers) { - watcher.close(); - } - this.projectWatchers.clear(); - } +interface FileWatcherServiceInterface { + readonly startWatching: () => Effect.Effect; + readonly stop: () => Effect.Effect; } -export const fileWatcher = new FileWatcherService(); +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(null); + const projectWatchersRef = yield* Ref.make>( + new Map(), + ); + + const startWatching = (): Effect.Effect => + 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); + const watcher = watch( + claudeProjectsDirPath, + { persistent: false, recursive: true }, + (_eventType, filename) => { + if (!filename) return; + + const groups = fileRegExpGroupSchema.safeParse( + filename.match(fileRegExp)?.groups, + ); + + if (!groups.success) return; + + const { projectId, sessionId } = groups.data; + + Effect.runFork( + eventBus.emit("sessionChanged", { + projectId, + sessionId, + }), + ); + + Effect.runFork( + eventBus.emit("sessionListChanged", { + projectId, + }), + ); + }, + ); + + await Effect.runPromise(Ref.set(watcherRef, watcher)); + console.log("File watcher initialization completed"); + }, + 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 => + Effect.gen(function* () { + const watcher = yield* Ref.get(watcherRef); + if (watcher) { + yield* Effect.sync(() => watcher.close()); + yield* Ref.set(watcherRef, 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); + }); + + return { + startWatching, + stop, + } satisfies FileWatcherServiceInterface; + }), + ); +} diff --git a/src/server/service/events/typeSafeSSE.test.ts b/src/server/service/events/typeSafeSSE.test.ts new file mode 100644 index 0000000..ddd4a00 --- /dev/null +++ b/src/server/service/events/typeSafeSSE.test.ts @@ -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 + }); + }); +}); diff --git a/src/server/service/events/typeSafeSSE.ts b/src/server/service/events/typeSafeSSE.ts index 4e0e610..75d40fb 100644 --- a/src/server/service/events/typeSafeSSE.ts +++ b/src/server/service/events/typeSafeSSE.ts @@ -1,21 +1,44 @@ +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 ( +interface TypeSafeSSEService { + readonly writeSSE: ( event: EventName, data: SSEEventDeclaration[EventName], - ): Promise => { - const id = ulid(); - await stream.writeSSE({ - event: event, - id: id, - data: JSON.stringify({ - kind: event, - timestamp: new Date().toISOString(), - ...data, - }), - }); - }, -}); + ) => Effect.Effect; +} + +export class TypeSafeSSE extends Context.Tag("TypeSafeSSE")< + TypeSafeSSE, + TypeSafeSSEService +>() { + static make = (stream: SSEStreamingApi) => + Layer.succeed(this, { + writeSSE: ( + event: EventName, + data: SSEEventDeclaration[EventName], + ): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const id = ulid(); + await stream.writeSSE({ + event: event, + id: id, + data: JSON.stringify({ + kind: event, + timestamp: new Date().toISOString(), + ...data, + }), + }); + }, + catch: (error) => { + if (error instanceof Error) { + return error; + } + return new Error(String(error)); + }, + }), + } satisfies TypeSafeSSEService); +} diff --git a/src/server/service/mcp/getMcpList.ts b/src/server/service/mcp/getMcpList.ts index 540b228..ea45520 100644 --- a/src/server/service/mcp/getMcpList.ts +++ b/src/server/service/mcp/getMcpList.ts @@ -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[] = []; diff --git a/src/server/service/parseJsonl.ts b/src/server/service/parseJsonl.ts index e3902df..0808e7e 100644 --- a/src/server/service/parseJsonl.ts +++ b/src/server/service/parseJsonl.ts @@ -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; } diff --git a/src/server/service/project/ProjectMetaService.test.ts b/src/server/service/project/ProjectMetaService.test.ts new file mode 100644 index 0000000..cf3b247 --- /dev/null +++ b/src/server/service/project/ProjectMetaService.test.ts @@ -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, +): Layer.Layer => { + return FileSystem.layerNoop(overrides); +}; + +/** + * Helper function to create a Path mock layer + * @see Path.layer - Uses default POSIX Path implementation + */ +const makePathMock = (): Layer.Layer => { + return Path.layer; +}; + +/** + * Helper function to create a PersistentService mock layer + */ +const makePersistentServiceMock = (): Layer.Layer => { + 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); + }); + }); +}); diff --git a/src/server/service/project/ProjectMetaService.ts b/src/server/service/project/ProjectMetaService.ts new file mode 100644 index 0000000..2f08697 --- /dev/null +++ b/src/server/service/project/ProjectMetaService.ts @@ -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; + readonly invalidateProject: (projectId: string) => Effect.Effect; + } +>() { + static Live = Layer.effect( + this, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectPathCache = yield* FileCacheStorage(); + const projectMetaCacheRef = yield* Ref.make( + new Map(), + ); + + const extractProjectPathFromJsonl = ( + filePath: string, + ): Effect.Effect => + 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 => + 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 => + 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 +>; diff --git a/src/server/service/project/ProjectRepository.test.ts b/src/server/service/project/ProjectRepository.test.ts new file mode 100644 index 0000000..be87f2b --- /dev/null +++ b/src/server/service/project/ProjectRepository.test.ts @@ -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, +): Layer.Layer => { + return FileSystem.layerNoop(overrides); +}; + +/** + * Helper function to create Path mock layer + */ +const makePathMock = (): Layer.Layer => { + return Path.layer; +}; + +/** + * Helper function to create PersistentService mock layer + */ +const makePersistentServiceMock = (): Layer.Layer => { + return Layer.succeed(PersistentService, { + load: () => Effect.succeed([]), + save: () => Effect.void, + }); +}; + +/** + * Helper function to create ProjectMetaService mock layer + */ +const makeProjectMetaServiceMock = ( + meta: ProjectMeta, +): Layer.Layer => { + 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); + }); + }); +}); diff --git a/src/server/service/project/ProjectRepository.ts b/src/server/service/project/ProjectRepository.ts index df87935..2222d5f 100644 --- a/src/server/service/project/ProjectRepository.ts +++ b/src/server/service/project/ProjectRepository.ts @@ -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, }, }; - } + }); - 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 getProjects = () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const projectMetaService = yield* ProjectMetaService; + + // Check if the claude projects directory exists + 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); - const id = encodeProjectId(fullPath); + // Read directory entries + const entries = yield* fs.readDirectory(claudeProjectsDirPath); - return { - id, - claudeProjectPath: fullPath, - lastModifiedAt: statSync(fullPath).mtime, - meta: await projectMetaStorage.getProjectMeta(id), - }; - }), + // 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: Option.getOrElse(stat.mtime, () => new Date()), + meta, + } satisfies Project; + }), + ); + + // 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) ); + }); - return { - projects: 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, + }); } diff --git a/src/server/service/project/projectMetaStorage.ts b/src/server/service/project/projectMetaStorage.ts deleted file mode 100644 index 85bd698..0000000 --- a/src/server/service/project/projectMetaStorage.ts +++ /dev/null @@ -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(); - - public async getProjectMeta(projectId: string): Promise { - 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 { - 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(); diff --git a/src/server/service/session/PredictSessionsDatabase.ts b/src/server/service/session/PredictSessionsDatabase.ts deleted file mode 100644 index 161b273..0000000 --- a/src/server/service/session/PredictSessionsDatabase.ts +++ /dev/null @@ -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(); - - 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(); diff --git a/src/server/service/session/SessionMetaService.test.ts b/src/server/service/session/SessionMetaService.test.ts new file mode 100644 index 0000000..a8be0f2 --- /dev/null +++ b/src/server/service/session/SessionMetaService.test.ts @@ -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, +): Layer.Layer => { + return FileSystem.layerNoop(overrides); +}; + +/** + * Helper function to create a Path mock layer + */ +const makePathMock = (): Layer.Layer => { + 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 => { + 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":"/test"},"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":"/clear"},"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); + }); + }); +}); diff --git a/src/server/service/session/SessionMetaService.ts b/src/server/service/session/SessionMetaService.ts new file mode 100644 index 0000000..bf669d7 --- /dev/null +++ b/src/server/service/session/SessionMetaService.ts @@ -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; + readonly invalidateSession: ( + projectId: string, + sessionId: string, + ) => Effect.Effect; + } +>() { + static Live = Layer.effect( + this, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const firstCommandCache = yield* FileCacheStorage(); + const sessionMetaCacheRef = yield* Ref.make( + new Map(), + ); + + const extractFirstUserText = ( + conversation: Exclude[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 => + 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 => + 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 => + 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 +>; diff --git a/src/server/service/session/SessionRepository.test.ts b/src/server/service/session/SessionRepository.test.ts new file mode 100644 index 0000000..187a765 --- /dev/null +++ b/src/server/service/session/SessionRepository.test.ts @@ -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, +): Layer.Layer => { + return FileSystem.layerNoop(overrides); +}; + +/** + * Helper function to create a Path mock layer + */ +const makePathMock = (): Layer.Layer => { + return Path.layer; +}; + +/** + * Helper function to create a PersistentService mock layer + */ +const makePersistentServiceMock = (): Layer.Layer => { + return Layer.succeed(PersistentService, { + load: () => Effect.succeed([]), + save: () => Effect.void, + }); +}; + +/** + * Helper function to create a SessionMetaService mock layer + */ +const makeSessionMetaServiceMock = ( + meta: SessionMeta, +): Layer.Layer => { + return Layer.succeed(SessionMetaService, { + getSessionMeta: () => Effect.succeed(meta), + invalidateSession: () => Effect.void, + }); +}; + +/** + * Helper function to create a PredictSessionsDatabase mock layer + */ +const makePredictSessionsDatabaseMock = ( + sessions: Map, +): Layer.Layer => { + 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, + ); + }); + }); +}); diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts index df9be24..8e3561f 100644 --- a/src/server/service/session/SessionRepository.ts +++ b/src/server/service/session/SessionRepository.ts @@ -1,144 +1,328 @@ -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, - }; - } + // 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()); - throw new Error("Session not found"); - } - const content = await readFile(sessionPath, "utf-8"); - const allLines = content.split("\n").filter((line) => line.trim()); + const conversations = parseJsonl(allLines.join("\n")); - const conversations = parseJsonl(allLines.join("\n")); + // Get file stats + const stat = yield* fs.stat(sessionPath); - const sessionDetail: SessionDetail = { - id: sessionId, - jsonlFilePath: sessionPath, - meta: await sessionMetaStorage.getSessionMeta(projectId, sessionId), - conversations, - lastModifiedAt: statSync(sessionPath).mtime, - }; + // 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, + 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, }; - } + }); + +const getSessions = ( + projectId: string, + options?: { + maxCount?: number; + cursor?: string; + }, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sessionMetaService = yield* SessionMetaService; + const virtualConversationDatabase = yield* VirtualConversationDatabase; - public async getSessions( - projectId: string, - options?: { - maxCount?: number; - cursor?: string; - }, - ): Promise<{ sessions: Session[] }> { 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), - ); - const stats = statSync(resolve(claudeProjectPath, d.name)); + const claudeProjectPath = decodeProjectId(projectId); - return { - id: sessionId, - jsonlFilePath: resolve(claudeProjectPath, d.name), - lastModifiedAt: stats.mtime, - }; - }), - ).then((fetched) => - fetched.sort( - (a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(), - ), - ); - const sessionMap = new Map( - sessions.map((session) => [session.id, session]), - ); - - const predictSessions = predictSessionsDatabase - .getPredictSessions(projectId) - .filter((session) => !sessionMap.has(session.id)); - - const sessionMap = new Map( - sessions.map((session) => [session.id, session] as const), - ); - - const index = - cursor !== undefined - ? sessions.findIndex((session) => session.id === cursor) - : -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( - projectId, - item.id, - ), - }; - }), - ), - }; - } - - const predictSessions = predictSessionsDatabase - .getPredictSessions(projectId) - .filter((session) => !sessionMap.has(session.id)) - .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) - ); - }), - }; - } catch (error) { - console.warn(`Failed to read sessions for project ${projectId}:`, error); + // 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, + ); + 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: fullPath, + lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), + }; + }), + ); + + // 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 => s !== null) + .sort((a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime()); + + const sessionMap = new Map( + sessions.map((session) => [session.id, session] as const), + ); + + const index = + cursor !== undefined + ? sessions.findIndex((session) => session.id === cursor) + : -1; + + if (index !== -1) { + 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, + }; + } + + // 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(); + }); + + // 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, + }; + }), + ), + { 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, + }); } diff --git a/src/server/service/session/VirtualConversationDatabase.test.ts b/src/server/service/session/VirtualConversationDatabase.test.ts new file mode 100644 index 0000000..4771f0b --- /dev/null +++ b/src/server/service/session/VirtualConversationDatabase.test.ts @@ -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"); + }); + }); +}); diff --git a/src/server/service/session/VirtualConversationDatabase.ts b/src/server/service/session/VirtualConversationDatabase.ts new file mode 100644 index 0000000..3b7a8eb --- /dev/null +++ b/src/server/service/session/VirtualConversationDatabase.ts @@ -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; + readonly deleteVirtualConversations: ( + sessionId: string, + ) => Effect.Effect; + } +>() { + 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 +>; diff --git a/src/server/service/session/sessionMetaStorage.ts b/src/server/service/session/sessionMetaStorage.ts deleted file mode 100644 index 00bbb47..0000000 --- a/src/server/service/session/sessionMetaStorage.ts +++ /dev/null @@ -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(); - - public async getSessionMeta( - projectId: string, - sessionId: string, - ): Promise { - 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(); diff --git a/src/server/service/types.ts b/src/server/service/types.ts index 2668e30..e95c463 100644 --- a/src/server/service/types.ts +++ b/src/server/service/types.ts @@ -23,6 +23,7 @@ export type SessionMeta = z.infer; export type ErrorJsonl = { type: "x-error"; line: string; + lineNumber: number; }; export type SessionDetail = Session & { diff --git a/src/types/session-process.ts b/src/types/session-process.ts new file mode 100644 index 0000000..09068d0 --- /dev/null +++ b/src/types/session-process.ts @@ -0,0 +1,6 @@ +export type PublicSessionProcess = { + id: string; + projectId: string; + sessionId: string; + status: "paused" | "running"; +}; diff --git a/src/types/sse.ts b/src/types/sse.ts index d4def0e..2ab4afc 100644 --- a/src/types/sse.ts +++ b/src/types/sse.ts @@ -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; + sessionProcessChanged: { + processes: PublicSessionProcess[]; }; permission_requested: {