diff --git a/package.json b/package.json index e4fad585..f0eacee6 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "dev": "PAPERCLIP_UI_DEV_MIDDLEWARE=true pnpm --filter @paperclip/server dev", + "dev:watch": "PAPERCLIP_UI_DEV_MIDDLEWARE=true PAPERCLIP_MIGRATION_PROMPT=never pnpm --filter @paperclip/server dev:watch", "dev:server": "pnpm --filter @paperclip/server dev", "dev:ui": "pnpm --filter @paperclip/ui dev", "build": "pnpm -r build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0793b9d9..3a3b530e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) cli: dependencies: @@ -110,7 +110,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) packages/shared: dependencies: @@ -194,7 +194,7 @@ importers: version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) ui: dependencies: @@ -213,6 +213,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.4) + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@4.1.18) '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.4) @@ -237,6 +240,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.4(react@19.2.4) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.14)(react@19.2.4) react-router-dom: specifier: ^7.1.5 version: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -270,7 +276,7 @@ importers: version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) packages: @@ -1822,6 +1828,11 @@ packages: resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} engines: {node: '>= 10'} + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tailwindcss/vite@4.1.18': resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} peerDependencies: @@ -1859,9 +1870,15 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1871,12 +1888,21 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@22.19.11': resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} @@ -1909,6 +1935,15 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1974,6 +2009,9 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true @@ -2009,10 +2047,25 @@ packages: caniuse-lite@1.0.30001770: resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -2037,6 +2090,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -2070,6 +2126,11 @@ packages: cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2085,6 +2146,9 @@ packages: supports-color: optional: true + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -2097,6 +2161,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2109,6 +2177,9 @@ packages: engines: {node: '>= 16.0.0'} hasBin: true + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} @@ -2283,6 +2354,9 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2298,6 +2372,9 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-copy@4.0.2: resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} @@ -2383,9 +2460,18 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -2397,10 +2483,29 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -2498,6 +2603,9 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -2516,6 +2624,30 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-from-markdown@2.0.2: + resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -2528,6 +2660,69 @@ packages: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -2582,6 +2777,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -2657,6 +2855,10 @@ packages: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2684,6 +2886,9 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2724,6 +2929,12 @@ packages: peerDependencies: react: ^19.2.4 + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -2783,6 +2994,12 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2862,6 +3079,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -2876,6 +3096,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -2883,6 +3106,12 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + superagent@10.3.0: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} @@ -2930,6 +3159,12 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2953,6 +3188,24 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -2988,10 +3241,19 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -3135,6 +3397,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@babel/code-frame@7.29.0': @@ -4457,6 +4722,11 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + '@tailwindcss/typography@0.5.19(tailwindcss@4.1.18)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 4.1.18 + '@tailwindcss/vite@4.1.18(vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@tailwindcss/node': 4.1.18 @@ -4508,8 +4778,16 @@ snapshots: '@types/cookiejar@2.1.5': {} + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + '@types/estree@1.0.8': {} '@types/express-serve-static-core@5.1.1': @@ -4525,10 +4803,20 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/http-errors@2.0.5': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/methods@1.1.4': {} + '@types/ms@2.1.0': {} + '@types/node@22.19.11': dependencies: undici-types: 6.21.0 @@ -4570,6 +4858,12 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.0': {} + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 @@ -4646,6 +4940,8 @@ snapshots: atomic-sleep@1.0.0: {} + bail@2.0.2: {} + baseline-browser-mapping@2.9.19: {} body-parser@2.2.2: @@ -4688,6 +4984,8 @@ snapshots: caniuse-lite@1.0.30001770: {} + ccount@2.0.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -4696,6 +4994,14 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} class-variance-authority@0.7.1: @@ -4722,6 +5028,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + commander@13.1.0: {} component-emitter@1.3.1: {} @@ -4740,6 +5048,8 @@ snapshots: cookiejar@2.1.4: {} + cssesc@3.0.0: {} + csstype@3.2.3: {} dateformat@4.6.3: {} @@ -4748,12 +5058,18 @@ snapshots: dependencies: ms: 2.1.3 + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-eql@5.0.2: {} delayed-stream@1.0.0: {} depd@2.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -4762,6 +5078,10 @@ snapshots: dependencies: address: 2.0.3 + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dezalgo@1.0.4: dependencies: asap: 2.0.6 @@ -4935,6 +5255,8 @@ snapshots: escape-html@1.0.3: {} + estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -4976,6 +5298,8 @@ snapshots: transitivePeerDependencies: - supports-color + extend@3.0.2: {} + fast-copy@4.0.2: {} fast-safe-stringify@2.1.1: {} @@ -5060,8 +5384,34 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + help-me@5.0.0: {} + html-url-attributes@3.0.1: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -5076,8 +5426,23 @@ snapshots: inherits@2.0.4: {} + inline-style-parser@0.2.7: {} + ipaddr.js@1.9.1: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} jiti@2.6.1: {} @@ -5141,6 +5506,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + longest-streak@3.1.0: {} + loupe@3.2.1: {} lru-cache@5.1.1: @@ -5157,12 +5524,234 @@ snapshots: math-intrinsics@1.1.0: {} + mdast-util-from-markdown@2.0.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} methods@1.1.2: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -5199,6 +5788,16 @@ snapshots: dependencies: wrappy: 1.0.2 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parseurl@1.3.3: {} path-to-regexp@8.3.0: {} @@ -5300,6 +5899,11 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 3.1.0 + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -5324,6 +5928,8 @@ snapshots: process-warning@5.0.0: {} + property-information@7.1.0: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -5417,6 +6023,24 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.14 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.4 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -5464,6 +6088,23 @@ snapshots: real-require@0.2.0: {} + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + resolve-pkg-maps@1.0.0: {} rollup@4.57.1: @@ -5591,6 +6232,8 @@ snapshots: source-map@0.6.1: {} + space-separated-tokens@2.0.2: {} + split2@4.2.0: {} stackback@0.0.2: {} @@ -5599,12 +6242,25 @@ snapshots: std-env@3.10.0: {} + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-json-comments@5.0.3: {} strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + superagent@10.3.0: dependencies: component-emitter: 1.3.1 @@ -5654,6 +6310,10 @@ snapshots: toidentifier@1.0.1: {} + trim-lines@3.0.1: {} + + trough@2.2.0: {} + tslib@2.8.1: {} tsx@4.21.0: @@ -5675,6 +6335,39 @@ snapshots: undici-types@7.16.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + unpipe@1.0.0: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -5702,8 +6395,20 @@ snapshots: dependencies: react: 19.2.4 + util-deprecate@1.0.2: {} + vary@1.1.2: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: cac: 6.7.14 @@ -5755,7 +6460,7 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 - vitest@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -5781,6 +6486,7 @@ snapshots: vite-node: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/debug': 4.1.12 '@types/node': 25.2.3 transitivePeerDependencies: - jiti @@ -5811,3 +6517,5 @@ snapshots: yallist@3.1.1: {} zod@3.25.76: {} + + zwitch@2.0.4: {} diff --git a/server/package.json b/server/package.json index e352e6fd..4874f7c4 100644 --- a/server/package.json +++ b/server/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "scripts": { - "dev": "tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", + "dev": "tsx src/index.ts", + "dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", "build": "tsc", "start": "node dist/index.js", "typecheck": "tsc --noEmit" diff --git a/server/src/adapters/http/index.ts b/server/src/adapters/http/index.ts index 24731af1..fe1f969f 100644 --- a/server/src/adapters/http/index.ts +++ b/server/src/adapters/http/index.ts @@ -5,4 +5,15 @@ export const httpAdapter: ServerAdapterModule = { type: "http", execute, models: [], + agentConfigurationDoc: `# http agent configuration + +Adapter: http + +Core fields: +- url (string, required): endpoint to invoke +- method (string, optional): HTTP method, default POST +- headers (object, optional): request headers +- payloadTemplate (object, optional): JSON payload template +- timeoutSec (number, optional): request timeout in seconds +`, }; diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 6b973404..11637410 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -1,4 +1,4 @@ -export { getServerAdapter, listAdapterModels } from "./registry.js"; +export { getServerAdapter, listAdapterModels, listServerAdapters } from "./registry.js"; export type { ServerAdapterModule, AdapterExecutionContext, diff --git a/server/src/adapters/process/index.ts b/server/src/adapters/process/index.ts index 630675e4..905f3a11 100644 --- a/server/src/adapters/process/index.ts +++ b/server/src/adapters/process/index.ts @@ -5,4 +5,18 @@ export const processAdapter: ServerAdapterModule = { type: "process", execute, models: [], + agentConfigurationDoc: `# process agent configuration + +Adapter: process + +Core fields: +- command (string, required): command to execute +- args (string[] | string, optional): command arguments +- cwd (string, optional): absolute working directory +- env (object, optional): KEY=VALUE environment variables + +Operational fields: +- timeoutSec (number, optional): run timeout in seconds +- graceSec (number, optional): SIGTERM grace period in seconds +`, }; diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index c5b11e34..452bc572 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,8 +1,8 @@ import type { ServerAdapterModule } from "./types.js"; import { execute as claudeExecute } from "@paperclip/adapter-claude-local/server"; -import { models as claudeModels } from "@paperclip/adapter-claude-local"; +import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclip/adapter-claude-local"; import { execute as codexExecute } from "@paperclip/adapter-codex-local/server"; -import { models as codexModels } from "@paperclip/adapter-codex-local"; +import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclip/adapter-codex-local"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; @@ -11,6 +11,7 @@ const claudeLocalAdapter: ServerAdapterModule = { execute: claudeExecute, models: claudeModels, supportsLocalAgentJwt: true, + agentConfigurationDoc: claudeAgentConfigurationDoc, }; const codexLocalAdapter: ServerAdapterModule = { @@ -18,6 +19,7 @@ const codexLocalAdapter: ServerAdapterModule = { execute: codexExecute, models: codexModels, supportsLocalAgentJwt: true, + agentConfigurationDoc: codexAgentConfigurationDoc, }; const adaptersByType = new Map( @@ -36,3 +38,7 @@ export function getServerAdapter(type: string): ServerAdapterModule { export function listAdapterModels(type: string): { id: string; label: string }[] { return adaptersByType.get(type)?.models ?? []; } + +export function listServerAdapters(): ServerAdapterModule[] { + return Array.from(adaptersByType.values()); +} diff --git a/server/src/app.ts b/server/src/app.ts index 1deed1bf..eefeae32 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -15,6 +15,8 @@ import { approvalRoutes } from "./routes/approvals.js"; import { costRoutes } from "./routes/costs.js"; import { activityRoutes } from "./routes/activity.js"; import { dashboardRoutes } from "./routes/dashboard.js"; +import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; +import { llmRoutes } from "./routes/llms.js"; type UiMode = "none" | "static" | "vite-dev"; @@ -24,6 +26,7 @@ export async function createApp(db: Db, opts: { uiMode: UiMode }) { app.use(express.json()); app.use(httpLogger); app.use(actorMiddleware(db)); + app.use(llmRoutes(db)); // Mount API routes const api = Router(); @@ -37,6 +40,7 @@ export async function createApp(db: Db, opts: { uiMode: UiMode }) { api.use(costRoutes(db)); api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); + api.use(sidebarBadgeRoutes(db)); app.use("/api", api); const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/server/src/index.ts b/server/src/index.ts index fa9c7d10..cad3640c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,6 +8,7 @@ import { ensurePostgresDatabase, inspectMigrations, applyPendingMigrations, + reconcilePendingMigrationHistory, } from "@paperclip/db"; import detectPort from "detect-port"; import { createApp } from "./app.js"; @@ -48,6 +49,7 @@ function formatPendingMigrationSummary(migrations: string[]): string { } async function promptApplyMigrations(migrations: string[]): Promise { + if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return false; if (!stdin.isTTY || !stdout.isTTY) return true; if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true; @@ -63,7 +65,18 @@ async function promptApplyMigrations(migrations: string[]): Promise { } async function ensureMigrations(connectionString: string, label: string): Promise { - const state = await inspectMigrations(connectionString); + let state = await inspectMigrations(connectionString); + if (state.status === "needsMigrations" && state.reason === "pending-migrations") { + const repair = await reconcilePendingMigrationHistory(connectionString); + if (repair.repairedMigrations.length > 0) { + logger.warn( + { repairedMigrations: repair.repairedMigrations }, + `${label} had drifted migration history; repaired migration journal entries from existing schema state.`, + ); + state = await inspectMigrations(connectionString); + if (state.status === "upToDate") return "already applied"; + } + } if (state.status === "upToDate") return "already applied"; if (state.status === "needsMigrations" && state.reason === "no-migration-journal-non-empty-db") { logger.warn( diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index a3d3d16e..7747a5a2 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -53,7 +53,7 @@ export function actorMiddleware(db: Db): RequestHandler { return; } - if (agentRecord.status === "terminated") { + if (agentRecord.status === "terminated" || agentRecord.status === "pending_approval") { next(); return; } @@ -74,6 +74,17 @@ export function actorMiddleware(db: Db): RequestHandler { .set({ lastUsedAt: new Date() }) .where(eq(agentApiKeys.id, key.id)); + const agentRecord = await db + .select() + .from(agents) + .where(eq(agents.id, key.agentId)) + .then((rows) => rows[0] ?? null); + + if (!agentRecord || agentRecord.status === "terminated" || agentRecord.status === "pending_approval") { + next(); + return; + } + req.actor = { type: "agent", agentId: key.agentId, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 508a6ed8..246da678 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,15 +1,25 @@ -import { Router } from "express"; +import { Router, type Request } from "express"; import type { Db } from "@paperclip/db"; -import { agents as agentsTable, heartbeatRuns } from "@paperclip/db"; +import { agents as agentsTable, companies, heartbeatRuns } from "@paperclip/db"; import { and, desc, eq, inArray, sql } from "drizzle-orm"; import { createAgentKeySchema, + createAgentHireSchema, createAgentSchema, + updateAgentPermissionsSchema, wakeAgentSchema, updateAgentSchema, } from "@paperclip/shared"; import { validate } from "../middleware/validate.js"; -import { agentService, heartbeatService, issueService, logActivity } from "../services/index.js"; +import { + agentService, + approvalService, + heartbeatService, + issueApprovalService, + issueService, + logActivity, +} from "../services/index.js"; +import { forbidden } from "../errors.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { listAdapterModels } from "../adapters/index.js"; @@ -55,7 +65,141 @@ function redactEventPayload(payload: Record | null): Record | null | undefined }) { + if (!agent.permissions || typeof agent.permissions !== "object") return false; + return Boolean((agent.permissions as Record).canCreateAgents); + } + + async function assertCanCreateAgentsForCompany(req: Request, companyId: string) { + assertCompanyAccess(req, companyId); + if (req.actor.type === "board") return null; + if (!req.actor.agentId) throw forbidden("Agent authentication required"); + const actorAgent = await svc.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + if (!canCreateAgents(actorAgent)) { + throw forbidden("Missing permission: can create agents"); + } + return actorAgent; + } + + async function assertCanReadConfigurations(req: Request, companyId: string) { + return assertCanCreateAgentsForCompany(req, companyId); + } + + async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { + assertCompanyAccess(req, companyId); + if (req.actor.type === "board") return true; + if (!req.actor.agentId) return false; + const actorAgent = await svc.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) return false; + return canCreateAgents(actorAgent); + } + + async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { + assertCompanyAccess(req, targetAgent.companyId); + if (req.actor.type === "board") return; + if (!req.actor.agentId) throw forbidden("Agent authentication required"); + + const actorAgent = await svc.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) { + throw forbidden("Agent key cannot access another company"); + } + + if (actorAgent.id === targetAgent.id) return; + if (actorAgent.role === "ceo") return; + if (canCreateAgents(actorAgent)) return; + throw forbidden("Only CEO or agent creators can modify other agents"); + } + + function parseSourceIssueIds(input: { + sourceIssueId?: string | null; + sourceIssueIds?: string[]; + }): string[] { + const values: string[] = []; + if (Array.isArray(input.sourceIssueIds)) values.push(...input.sourceIssueIds); + if (typeof input.sourceIssueId === "string" && input.sourceIssueId.length > 0) { + values.push(input.sourceIssueId); + } + return Array.from(new Set(values)); + } + + function redactForRestrictedAgentView(agent: Awaited>) { + if (!agent) return null; + return { + ...agent, + adapterConfig: {}, + runtimeConfig: {}, + }; + } + + function redactAgentConfiguration(agent: Awaited>) { + if (!agent) return null; + return { + id: agent.id, + companyId: agent.companyId, + name: agent.name, + role: agent.role, + title: agent.title, + status: agent.status, + reportsTo: agent.reportsTo, + adapterType: agent.adapterType, + adapterConfig: redactEventPayload(agent.adapterConfig), + runtimeConfig: redactEventPayload(agent.runtimeConfig), + permissions: agent.permissions, + updatedAt: agent.updatedAt, + }; + } + + function redactRevisionSnapshot(snapshot: unknown): Record { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return {}; + const record = snapshot as Record; + return { + ...record, + adapterConfig: redactEventPayload( + typeof record.adapterConfig === "object" && record.adapterConfig !== null + ? (record.adapterConfig as Record) + : {}, + ), + runtimeConfig: redactEventPayload( + typeof record.runtimeConfig === "object" && record.runtimeConfig !== null + ? (record.runtimeConfig as Record) + : {}, + ), + metadata: + typeof record.metadata === "object" && record.metadata !== null + ? redactEventPayload(record.metadata as Record) + : record.metadata ?? null, + }; + } + + function redactConfigRevision( + revision: Record & { beforeConfig: unknown; afterConfig: unknown }, + ) { + return { + ...revision, + beforeConfig: redactRevisionSnapshot(revision.beforeConfig), + afterConfig: redactRevisionSnapshot(revision.afterConfig), + }; + } + + function toLeanOrgNode(node: Record): Record { + const reports = Array.isArray(node.reports) + ? (node.reports as Array>).map((report) => toLeanOrgNode(report)) + : []; + return { + id: String(node.id), + name: String(node.name), + role: String(node.role), + status: String(node.status), + reports, + }; + } router.get("/adapters/:type/models", (req, res) => { const type = req.params.type as string; @@ -67,14 +211,27 @@ export function agentRoutes(db: Db) { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const result = await svc.list(companyId); - res.json(result); + const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId); + if (canReadConfigs || req.actor.type === "board") { + res.json(result); + return; + } + res.json(result.map((agent) => redactForRestrictedAgentView(agent))); }); router.get("/companies/:companyId/org", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const tree = await svc.orgForCompany(companyId); - res.json(tree); + const leanTree = tree.map((node) => toLeanOrgNode(node as Record)); + res.json(leanTree); + }); + + router.get("/companies/:companyId/agent-configurations", async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanReadConfigurations(req, companyId); + const rows = await svc.list(companyId); + res.json(rows.map((row) => redactAgentConfiguration(row))); }); router.get("/agents/me", async (req, res) => { @@ -99,10 +256,93 @@ export function agentRoutes(db: Db) { return; } assertCompanyAccess(req, agent.companyId); + if (req.actor.type === "agent" && req.actor.agentId !== id) { + const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId); + if (!canRead) { + const chainOfCommand = await svc.getChainOfCommand(agent.id); + res.json({ ...redactForRestrictedAgentView(agent), chainOfCommand }); + return; + } + } const chainOfCommand = await svc.getChainOfCommand(agent.id); res.json({ ...agent, chainOfCommand }); }); + router.get("/agents/:id/configuration", async (req, res) => { + const id = req.params.id as string; + const agent = await svc.getById(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + await assertCanReadConfigurations(req, agent.companyId); + res.json(redactAgentConfiguration(agent)); + }); + + router.get("/agents/:id/config-revisions", async (req, res) => { + const id = req.params.id as string; + const agent = await svc.getById(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + await assertCanReadConfigurations(req, agent.companyId); + const revisions = await svc.listConfigRevisions(id); + res.json(revisions.map((revision) => redactConfigRevision(revision))); + }); + + router.get("/agents/:id/config-revisions/:revisionId", async (req, res) => { + const id = req.params.id as string; + const revisionId = req.params.revisionId as string; + const agent = await svc.getById(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + await assertCanReadConfigurations(req, agent.companyId); + const revision = await svc.getConfigRevision(id, revisionId); + if (!revision) { + res.status(404).json({ error: "Revision not found" }); + return; + } + res.json(redactConfigRevision(revision)); + }); + + router.post("/agents/:id/config-revisions/:revisionId/rollback", async (req, res) => { + const id = req.params.id as string; + const revisionId = req.params.revisionId as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Agent not found" }); + return; + } + await assertCanUpdateAgent(req, existing); + + const actor = getActorInfo(req); + const updated = await svc.rollbackConfigRevision(id, revisionId, { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + if (!updated) { + res.status(404).json({ error: "Revision not found" }); + return; + } + + await logActivity(db, { + companyId: updated.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "agent.config_rolled_back", + entityType: "agent", + entityId: updated.id, + details: { revisionId }, + }); + + res.json(updated); + }); + router.get("/agents/:id/runtime-state", async (req, res) => { assertBoard(req); const id = req.params.id as string; @@ -141,6 +381,99 @@ export function agentRoutes(db: Db) { res.json(state); }); + router.post("/companies/:companyId/agent-hires", validate(createAgentHireSchema), async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanCreateAgentsForCompany(req, companyId); + const sourceIssueIds = parseSourceIssueIds(req.body); + const { sourceIssueId: _sourceIssueId, sourceIssueIds: _sourceIssueIds, ...hireInput } = req.body; + + const company = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + if (!company) { + res.status(404).json({ error: "Company not found" }); + return; + } + + const requiresApproval = company.requireBoardApprovalForNewAgents; + const status = requiresApproval ? "pending_approval" : "idle"; + const agent = await svc.create(companyId, { + ...hireInput, + status, + spentMonthlyCents: 0, + lastHeartbeatAt: null, + }); + + let approval: Awaited> | null = null; + const actor = getActorInfo(req); + + if (requiresApproval) { + approval = await approvalsSvc.create(companyId, { + type: "hire_agent", + requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, + requestedByUserId: actor.actorType === "user" ? actor.actorId : null, + status: "pending", + payload: { + ...hireInput, + agentId: agent.id, + requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, + requestedConfigurationSnapshot: { + adapterType: hireInput.adapterType ?? agent.adapterType, + adapterConfig: hireInput.adapterConfig ?? agent.adapterConfig, + runtimeConfig: hireInput.runtimeConfig ?? agent.runtimeConfig, + }, + }, + decisionNote: null, + decidedByUserId: null, + decidedAt: null, + updatedAt: new Date(), + }); + + if (sourceIssueIds.length > 0) { + await issueApprovalsSvc.linkManyForApproval(approval.id, sourceIssueIds, { + agentId: actor.actorType === "agent" ? actor.actorId : null, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + } + } + + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "agent.hire_created", + entityType: "agent", + entityId: agent.id, + details: { + name: agent.name, + role: agent.role, + requiresApproval, + approvalId: approval?.id ?? null, + issueIds: sourceIssueIds, + }, + }); + + if (approval) { + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "approval.created", + entityType: "approval", + entityId: approval.id, + details: { type: approval.type, linkedAgentId: agent.id }, + }); + } + + res.status(201).json({ agent, approval }); + }); + router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -172,7 +505,7 @@ export function agentRoutes(db: Db) { res.status(201).json(agent); }); - router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => { + router.patch("/agents/:id/permissions", validate(updateAgentPermissionsSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { @@ -181,18 +514,67 @@ export function agentRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); - if (req.actor.type === "agent" && req.actor.agentId !== id) { - res.status(403).json({ error: "Agent can only modify itself" }); - return; + if (req.actor.type === "agent") { + const actorAgent = req.actor.agentId ? await svc.getById(req.actor.agentId) : null; + if (!actorAgent || actorAgent.companyId !== existing.companyId) { + res.status(403).json({ error: "Forbidden" }); + return; + } + if (actorAgent.role !== "ceo") { + res.status(403).json({ error: "Only CEO can manage permissions" }); + return; + } } - const agent = await svc.update(id, req.body); + const agent = await svc.updatePermissions(id, req.body); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } const actor = getActorInfo(req); + await logActivity(db, { + companyId: agent.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "agent.permissions_updated", + entityType: "agent", + entityId: agent.id, + details: req.body, + }); + + res.json(agent); + }); + + router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => { + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Agent not found" }); + return; + } + await assertCanUpdateAgent(req, existing); + + if (Object.prototype.hasOwnProperty.call(req.body, "permissions")) { + res.status(422).json({ error: "Use /api/agents/:id/permissions for permission changes" }); + return; + } + + const actor = getActorInfo(req); + const agent = await svc.update(id, req.body, { + recordRevision: { + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + source: "patch", + }, + }); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + await logActivity(db, { companyId: agent.companyId, actorType: actor.actorType, @@ -275,6 +657,27 @@ export function agentRoutes(db: Db) { res.json(agent); }); + router.delete("/agents/:id", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const agent = await svc.remove(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + + await logActivity(db, { + companyId: agent.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "agent.deleted", + entityType: "agent", + entityId: agent.id, + }); + + res.json({ ok: true }); + }); + router.get("/agents/:id/keys", async (req, res) => { assertBoard(req); const id = req.params.id as string; diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index 3e4c998a..ace1c1e9 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -1,13 +1,27 @@ import { Router } from "express"; import type { Db } from "@paperclip/db"; -import { createApprovalSchema, resolveApprovalSchema } from "@paperclip/shared"; +import { + addApprovalCommentSchema, + createApprovalSchema, + requestApprovalRevisionSchema, + resolveApprovalSchema, + resubmitApprovalSchema, +} from "@paperclip/shared"; import { validate } from "../middleware/validate.js"; -import { approvalService, logActivity } from "../services/index.js"; +import { logger } from "../middleware/logger.js"; +import { + approvalService, + heartbeatService, + issueApprovalService, + logActivity, +} from "../services/index.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; export function approvalRoutes(db: Db) { const router = Router(); const svc = approvalService(db); + const heartbeat = heartbeatService(db); + const issueApprovalsSvc = issueApprovalService(db); router.get("/companies/:companyId/approvals", async (req, res) => { const companyId = req.params.companyId as string; @@ -17,16 +31,33 @@ export function approvalRoutes(db: Db) { res.json(result); }); + router.get("/approvals/:id", async (req, res) => { + const id = req.params.id as string; + const approval = await svc.getById(id); + if (!approval) { + res.status(404).json({ error: "Approval not found" }); + return; + } + assertCompanyAccess(req, approval.companyId); + res.json(approval); + }); + router.post("/companies/:companyId/approvals", validate(createApprovalSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + const rawIssueIds = req.body.issueIds; + const issueIds = Array.isArray(rawIssueIds) + ? rawIssueIds.filter((value: unknown): value is string => typeof value === "string") + : []; + const uniqueIssueIds = Array.from(new Set(issueIds)); + const { issueIds: _issueIds, ...approvalInput } = req.body; const actor = getActorInfo(req); const approval = await svc.create(companyId, { - ...req.body, + ...approvalInput, requestedByUserId: actor.actorType === "user" ? actor.actorId : null, requestedByAgentId: - req.body.requestedByAgentId ?? (actor.actorType === "agent" ? actor.actorId : null), + approvalInput.requestedByAgentId ?? (actor.actorType === "agent" ? actor.actorId : null), status: "pending", decisionNote: null, decidedByUserId: null, @@ -34,6 +65,13 @@ export function approvalRoutes(db: Db) { updatedAt: new Date(), }); + if (uniqueIssueIds.length > 0) { + await issueApprovalsSvc.linkManyForApproval(approval.id, uniqueIssueIds, { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + } + await logActivity(db, { companyId, actorType: actor.actorType, @@ -42,16 +80,31 @@ export function approvalRoutes(db: Db) { action: "approval.created", entityType: "approval", entityId: approval.id, - details: { type: approval.type }, + details: { type: approval.type, issueIds: uniqueIssueIds }, }); res.status(201).json(approval); }); + router.get("/approvals/:id/issues", async (req, res) => { + const id = req.params.id as string; + const approval = await svc.getById(id); + if (!approval) { + res.status(404).json({ error: "Approval not found" }); + return; + } + assertCompanyAccess(req, approval.companyId); + const issues = await issueApprovalsSvc.listIssuesForApproval(id); + res.json(issues); + }); + router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; const approval = await svc.approve(id, req.body.decidedByUserId ?? "board", req.body.decisionNote); + const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id); + const linkedIssueIds = linkedIssues.map((issue) => issue.id); + const primaryIssueId = linkedIssueIds[0] ?? null; await logActivity(db, { companyId: approval.companyId, @@ -60,9 +113,76 @@ export function approvalRoutes(db: Db) { action: "approval.approved", entityType: "approval", entityId: approval.id, - details: { type: approval.type }, + details: { + type: approval.type, + requestedByAgentId: approval.requestedByAgentId, + linkedIssueIds, + }, }); + if (approval.requestedByAgentId) { + try { + const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, { + source: "automation", + triggerDetail: "system", + reason: "approval_approved", + payload: { + approvalId: approval.id, + approvalStatus: approval.status, + issueId: primaryIssueId, + issueIds: linkedIssueIds, + }, + requestedByActorType: "user", + requestedByActorId: req.actor.userId ?? "board", + contextSnapshot: { + source: "approval.approved", + approvalId: approval.id, + approvalStatus: approval.status, + issueId: primaryIssueId, + issueIds: linkedIssueIds, + taskId: primaryIssueId, + wakeReason: "approval_approved", + }, + }); + + await logActivity(db, { + companyId: approval.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "approval.requester_wakeup_queued", + entityType: "approval", + entityId: approval.id, + details: { + requesterAgentId: approval.requestedByAgentId, + wakeRunId: wakeRun?.id ?? null, + linkedIssueIds, + }, + }); + } catch (err) { + logger.warn( + { + err, + approvalId: approval.id, + requestedByAgentId: approval.requestedByAgentId, + }, + "failed to queue requester wakeup after approval", + ); + await logActivity(db, { + companyId: approval.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "approval.requester_wakeup_failed", + entityType: "approval", + entityId: approval.id, + details: { + requesterAgentId: approval.requestedByAgentId, + linkedIssueIds, + error: err instanceof Error ? err.message : String(err), + }, + }); + } + } + res.json(approval); }); @@ -84,5 +204,100 @@ export function approvalRoutes(db: Db) { res.json(approval); }); + router.post( + "/approvals/:id/request-revision", + validate(requestApprovalRevisionSchema), + async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const approval = await svc.requestRevision( + id, + req.body.decidedByUserId ?? "board", + req.body.decisionNote, + ); + + await logActivity(db, { + companyId: approval.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "approval.revision_requested", + entityType: "approval", + entityId: approval.id, + details: { type: approval.type }, + }); + + res.json(approval); + }, + ); + + router.post("/approvals/:id/resubmit", validate(resubmitApprovalSchema), async (req, res) => { + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Approval not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + + if (req.actor.type === "agent" && req.actor.agentId !== existing.requestedByAgentId) { + res.status(403).json({ error: "Only requesting agent can resubmit this approval" }); + return; + } + + const approval = await svc.resubmit(id, req.body.payload); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: approval.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + action: "approval.resubmitted", + entityType: "approval", + entityId: approval.id, + details: { type: approval.type }, + }); + res.json(approval); + }); + + router.get("/approvals/:id/comments", async (req, res) => { + const id = req.params.id as string; + const approval = await svc.getById(id); + if (!approval) { + res.status(404).json({ error: "Approval not found" }); + return; + } + assertCompanyAccess(req, approval.companyId); + const comments = await svc.listComments(id); + res.json(comments); + }); + + router.post("/approvals/:id/comments", validate(addApprovalCommentSchema), async (req, res) => { + const id = req.params.id as string; + const approval = await svc.getById(id); + if (!approval) { + res.status(404).json({ error: "Approval not found" }); + return; + } + assertCompanyAccess(req, approval.companyId); + const actor = getActorInfo(req); + const comment = await svc.addComment(id, req.body.body, { + agentId: actor.agentId ?? undefined, + userId: actor.actorType === "user" ? actor.actorId : undefined, + }); + + await logActivity(db, { + companyId: approval.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + action: "approval.comment_added", + entityType: "approval", + entityId: approval.id, + details: { commentId: comment.id }, + }); + + res.status(201).json(comment); + }); + return router; } diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts index 00b14213..47f732ff 100644 --- a/server/src/routes/costs.ts +++ b/server/src/routes/costs.ts @@ -40,24 +40,33 @@ export function costRoutes(db: Db) { res.status(201).json(event); }); + function parseDateRange(query: Record) { + const from = query.from ? new Date(query.from as string) : undefined; + const to = query.to ? new Date(query.to as string) : undefined; + return (from || to) ? { from, to } : undefined; + } + router.get("/companies/:companyId/costs/summary", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const summary = await costs.summary(companyId); + const range = parseDateRange(req.query); + const summary = await costs.summary(companyId, range); res.json(summary); }); router.get("/companies/:companyId/costs/by-agent", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const rows = await costs.byAgent(companyId); + const range = parseDateRange(req.query); + const rows = await costs.byAgent(companyId, range); res.json(rows); }); router.get("/companies/:companyId/costs/by-project", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const rows = await costs.byProject(companyId); + const range = parseDateRange(req.query); + const rows = await costs.byProject(companyId, range); res.json(rows); }); diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 70300770..f1f57e7e 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -8,3 +8,5 @@ export { approvalRoutes } from "./approvals.js"; export { costRoutes } from "./costs.js"; export { activityRoutes } from "./activity.js"; export { dashboardRoutes } from "./dashboard.js"; +export { sidebarBadgeRoutes } from "./sidebar-badges.js"; +export { llmRoutes } from "./llms.js"; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f7fd3af7..48eadc13 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,13 +1,20 @@ -import { Router } from "express"; +import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclip/db"; import { addIssueCommentSchema, checkoutIssueSchema, createIssueSchema, + linkIssueApprovalSchema, updateIssueSchema, } from "@paperclip/shared"; import { validate } from "../middleware/validate.js"; -import { heartbeatService, issueService, logActivity } from "../services/index.js"; +import { + agentService, + heartbeatService, + issueApprovalService, + issueService, + logActivity, +} from "../services/index.js"; import { logger } from "../middleware/logger.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; @@ -15,6 +22,25 @@ export function issueRoutes(db: Db) { const router = Router(); const svc = issueService(db); const heartbeat = heartbeatService(db); + const agentsSvc = agentService(db); + const issueApprovalsSvc = issueApprovalService(db); + + async function assertCanManageIssueApprovalLinks(req: Request, res: Response, companyId: string) { + assertCompanyAccess(req, companyId); + if (req.actor.type === "board") return true; + if (!req.actor.agentId) { + res.status(403).json({ error: "Agent authentication required" }); + return false; + } + const actorAgent = await agentsSvc.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + res.status(403).json({ error: "Forbidden" }); + return false; + } + if (actorAgent.role === "ceo" || Boolean(actorAgent.permissions?.canCreateAgents)) return true; + res.status(403).json({ error: "Missing permission to link approvals" }); + return false; + } router.get("/companies/:companyId/issues", async (req, res) => { const companyId = req.params.companyId as string; @@ -40,6 +66,77 @@ export function issueRoutes(db: Db) { res.json({ ...issue, ancestors }); }); + router.get("/issues/:id/approvals", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + const approvals = await issueApprovalsSvc.listApprovalsForIssue(id); + res.json(approvals); + }); + + router.post("/issues/:id/approvals", validate(linkIssueApprovalSchema), async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return; + + const actor = getActorInfo(req); + await issueApprovalsSvc.link(id, req.body.approvalId, { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.approval_linked", + entityType: "issue", + entityId: issue.id, + details: { approvalId: req.body.approvalId }, + }); + + const approvals = await issueApprovalsSvc.listApprovalsForIssue(id); + res.status(201).json(approvals); + }); + + router.delete("/issues/:id/approvals/:approvalId", async (req, res) => { + const id = req.params.id as string; + const approvalId = req.params.approvalId as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return; + + await issueApprovalsSvc.unlink(id, approvalId); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.approval_unlinked", + entityType: "issue", + entityId: issue.id, + details: { approvalId }, + }); + + res.json({ ok: true }); + }); + router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -96,6 +193,14 @@ export function issueRoutes(db: Db) { return; } + // Build activity details with previous values for changed fields + const previous: Record = {}; + for (const key of Object.keys(updateFields)) { + if (key in existing && (existing as Record)[key] !== (updateFields as Record)[key]) { + previous[key] = (existing as Record)[key]; + } + } + const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, @@ -106,7 +211,7 @@ export function issueRoutes(db: Db) { action: "issue.updated", entityType: "issue", entityId: issue.id, - details: updateFields, + details: { ...updateFields, _previous: Object.keys(previous).length > 0 ? previous : undefined }, }); let comment = null; @@ -383,6 +488,28 @@ export function issueRoutes(db: Db) { }, }) .catch((err) => logger.warn({ err, issueId: currentIssue.id }, "failed to wake assignee on issue reopen comment")); + } else if (currentIssue.assigneeAgentId) { + void heartbeat + .wakeup(currentIssue.assigneeAgentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { + issueId: currentIssue.id, + commentId: comment.id, + mutation: "comment", + }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: currentIssue.id, + taskId: currentIssue.id, + commentId: comment.id, + source: "issue.comment", + wakeReason: "issue_commented", + }, + }) + .catch((err) => logger.warn({ err, issueId: currentIssue.id }, "failed to wake assignee on issue comment")); } res.status(201).json(comment); diff --git a/server/src/routes/llms.ts b/server/src/routes/llms.ts new file mode 100644 index 00000000..06b398ef --- /dev/null +++ b/server/src/routes/llms.ts @@ -0,0 +1,66 @@ +import { Router, type Request } from "express"; +import type { Db } from "@paperclip/db"; +import { forbidden } from "../errors.js"; +import { listServerAdapters } from "../adapters/index.js"; +import { agentService } from "../services/agents.js"; + +function hasCreatePermission(agent: { role: string; permissions: Record | null | undefined }) { + if (!agent.permissions || typeof agent.permissions !== "object") return false; + return Boolean((agent.permissions as Record).canCreateAgents); +} + +export function llmRoutes(db: Db) { + const router = Router(); + const agentsSvc = agentService(db); + + async function assertCanRead(req: Request) { + if (req.actor.type === "board") return; + if (req.actor.type !== "agent" || !req.actor.agentId) { + throw forbidden("Board or permitted agent authentication required"); + } + const actorAgent = await agentsSvc.getById(req.actor.agentId); + if (!actorAgent || !hasCreatePermission(actorAgent)) { + throw forbidden("Missing permission to read agent configuration reflection"); + } + } + + router.get("/llms/agent-configuration.txt", async (req, res) => { + await assertCanRead(req); + const adapters = listServerAdapters().sort((a, b) => a.type.localeCompare(b.type)); + const lines = [ + "# Paperclip Agent Configuration Index", + "", + "Installed adapters:", + ...adapters.map((adapter) => `- ${adapter.type}: /llms/agent-configuration/${adapter.type}.txt`), + "", + "Related API endpoints:", + "- GET /api/companies/:companyId/agent-configurations", + "- GET /api/agents/:id/configuration", + "- POST /api/companies/:companyId/agent-hires", + "", + "Notes:", + "- Sensitive values are redacted in configuration read APIs.", + "- New hires may be created in pending_approval state depending on company settings.", + "", + ]; + res.type("text/plain").send(lines.join("\n")); + }); + + router.get("/llms/agent-configuration/:adapterType.txt", async (req, res) => { + await assertCanRead(req); + const adapterType = req.params.adapterType as string; + const adapter = listServerAdapters().find((entry) => entry.type === adapterType); + if (!adapter) { + res.status(404).type("text/plain").send(`Unknown adapter type: ${adapterType}`); + return; + } + res + .type("text/plain") + .send( + adapter.agentConfigurationDoc ?? + `# ${adapterType} agent configuration\n\nNo adapter-specific documentation registered.`, + ); + }); + + return router; +} diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts new file mode 100644 index 00000000..9eeb782e --- /dev/null +++ b/server/src/routes/sidebar-badges.ts @@ -0,0 +1,18 @@ +import { Router } from "express"; +import type { Db } from "@paperclip/db"; +import { sidebarBadgeService } from "../services/sidebar-badges.js"; +import { assertCompanyAccess } from "./authz.js"; + +export function sidebarBadgeRoutes(db: Db) { + const router = Router(); + const svc = sidebarBadgeService(db); + + router.get("/companies/:companyId/sidebar-badges", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const badges = await svc.get(companyId); + res.json(badges); + }); + + return router; +} diff --git a/server/src/services/agent-permissions.ts b/server/src/services/agent-permissions.ts index 15500de6..a0379c92 100644 --- a/server/src/services/agent-permissions.ts +++ b/server/src/services/agent-permissions.ts @@ -1,6 +1,6 @@ -export interface NormalizedAgentPermissions { +export type NormalizedAgentPermissions = Record & { canCreateAgents: boolean; -} +}; export function defaultPermissionsForRole(role: string): NormalizedAgentPermissions { return { diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 196b3b96..c4565768 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -1,8 +1,17 @@ import { createHash, randomBytes } from "node:crypto"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclip/db"; -import { agents, agentApiKeys, heartbeatRuns } from "@paperclip/db"; +import { + agents, + agentConfigRevisions, + agentApiKeys, + agentRuntimeState, + agentWakeupRequests, + heartbeatRunEvents, + heartbeatRuns, +} from "@paperclip/db"; import { conflict, notFound, unprocessable } from "../errors.js"; +import { normalizeAgentPermissions } from "./agent-permissions.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -12,13 +21,118 @@ function createToken() { return `pcp_${randomBytes(24).toString("hex")}`; } +const CONFIG_REVISION_FIELDS = [ + "name", + "role", + "title", + "reportsTo", + "capabilities", + "adapterType", + "adapterConfig", + "runtimeConfig", + "budgetMonthlyCents", + "metadata", +] as const; + +type ConfigRevisionField = (typeof CONFIG_REVISION_FIELDS)[number]; +type AgentConfigSnapshot = Pick; + +interface RevisionMetadata { + createdByAgentId?: string | null; + createdByUserId?: string | null; + source?: string; + rolledBackFromRevisionId?: string | null; +} + +interface UpdateAgentOptions { + recordRevision?: RevisionMetadata; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function jsonEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function buildConfigSnapshot( + row: Pick, +): AgentConfigSnapshot { + return { + name: row.name, + role: row.role, + title: row.title, + reportsTo: row.reportsTo, + capabilities: row.capabilities, + adapterType: row.adapterType, + adapterConfig: row.adapterConfig ?? {}, + runtimeConfig: row.runtimeConfig ?? {}, + budgetMonthlyCents: row.budgetMonthlyCents, + metadata: row.metadata ?? null, + }; +} + +function hasConfigPatchFields(data: Partial) { + return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field)); +} + +function diffConfigSnapshot( + before: AgentConfigSnapshot, + after: AgentConfigSnapshot, +): string[] { + return CONFIG_REVISION_FIELDS.filter((field) => !jsonEqual(before[field], after[field])); +} + +function configPatchFromSnapshot(snapshot: unknown): Partial { + if (!isPlainRecord(snapshot)) throw unprocessable("Invalid revision snapshot"); + + if (typeof snapshot.name !== "string" || snapshot.name.length === 0) { + throw unprocessable("Invalid revision snapshot: name"); + } + if (typeof snapshot.role !== "string" || snapshot.role.length === 0) { + throw unprocessable("Invalid revision snapshot: role"); + } + if (typeof snapshot.adapterType !== "string" || snapshot.adapterType.length === 0) { + throw unprocessable("Invalid revision snapshot: adapterType"); + } + if (typeof snapshot.budgetMonthlyCents !== "number" || !Number.isFinite(snapshot.budgetMonthlyCents)) { + throw unprocessable("Invalid revision snapshot: budgetMonthlyCents"); + } + + return { + name: snapshot.name, + role: snapshot.role, + title: typeof snapshot.title === "string" || snapshot.title === null ? snapshot.title : null, + reportsTo: + typeof snapshot.reportsTo === "string" || snapshot.reportsTo === null ? snapshot.reportsTo : null, + capabilities: + typeof snapshot.capabilities === "string" || snapshot.capabilities === null + ? snapshot.capabilities + : null, + adapterType: snapshot.adapterType, + adapterConfig: isPlainRecord(snapshot.adapterConfig) ? snapshot.adapterConfig : {}, + runtimeConfig: isPlainRecord(snapshot.runtimeConfig) ? snapshot.runtimeConfig : {}, + budgetMonthlyCents: Math.max(0, Math.floor(snapshot.budgetMonthlyCents)), + metadata: isPlainRecord(snapshot.metadata) || snapshot.metadata === null ? snapshot.metadata : null, + }; +} + export function agentService(db: Db) { + function normalizeAgentRow(row: typeof agents.$inferSelect) { + return { + ...row, + permissions: normalizeAgentPermissions(row.permissions, row.role), + }; + } + async function getById(id: string) { - return db + const row = await db .select() .from(agents) .where(eq(agents.id, id)) .then((rows) => rows[0] ?? null); + return row ? normalizeAgentRow(row) : null; } async function ensureManager(companyId: string, managerId: string) { @@ -42,9 +156,76 @@ export function agentService(db: Db) { } } + async function updateAgent( + id: string, + data: Partial, + options?: UpdateAgentOptions, + ) { + const existing = await getById(id); + if (!existing) return null; + + if (existing.status === "terminated" && data.status && data.status !== "terminated") { + throw conflict("Terminated agents cannot be resumed"); + } + if ( + existing.status === "pending_approval" && + data.status && + data.status !== "pending_approval" && + data.status !== "terminated" + ) { + throw conflict("Pending approval agents cannot be activated directly"); + } + + if (data.reportsTo !== undefined) { + if (data.reportsTo) { + await ensureManager(existing.companyId, data.reportsTo); + } + await assertNoCycle(id, data.reportsTo); + } + + const normalizedPatch = { ...data } as Partial; + if (data.permissions !== undefined) { + const role = (data.role ?? existing.role) as string; + normalizedPatch.permissions = normalizeAgentPermissions(data.permissions, role); + } + + const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch); + const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null; + + const updated = await db + .update(agents) + .set({ ...normalizedPatch, updatedAt: new Date() }) + .where(eq(agents.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + const normalizedUpdated = updated ? normalizeAgentRow(updated) : null; + + if (normalizedUpdated && shouldRecordRevision && beforeConfig) { + const afterConfig = buildConfigSnapshot(normalizedUpdated); + const changedKeys = diffConfigSnapshot(beforeConfig, afterConfig); + if (changedKeys.length > 0) { + await db.insert(agentConfigRevisions).values({ + companyId: normalizedUpdated.companyId, + agentId: normalizedUpdated.id, + createdByAgentId: options?.recordRevision?.createdByAgentId ?? null, + createdByUserId: options?.recordRevision?.createdByUserId ?? null, + source: options?.recordRevision?.source ?? "patch", + rolledBackFromRevisionId: options?.recordRevision?.rolledBackFromRevisionId ?? null, + changedKeys, + beforeConfig: beforeConfig as unknown as Record, + afterConfig: afterConfig as unknown as Record, + }); + } + } + + return normalizedUpdated; + } + return { - list: (companyId: string) => - db.select().from(agents).where(eq(agents.companyId, companyId)), + list: async (companyId: string) => { + const rows = await db.select().from(agents).where(eq(agents.companyId, companyId)); + return rows.map(normalizeAgentRow); + }, getById, @@ -53,62 +234,48 @@ export function agentService(db: Db) { await ensureManager(companyId, data.reportsTo); } + const role = data.role ?? "general"; + const normalizedPermissions = normalizeAgentPermissions(data.permissions, role); const created = await db .insert(agents) - .values({ ...data, companyId }) + .values({ ...data, companyId, role, permissions: normalizedPermissions }) .returning() .then((rows) => rows[0]); - return created; + return normalizeAgentRow(created); }, - update: async (id: string, data: Partial) => { - const existing = await getById(id); - if (!existing) return null; - - if (existing.status === "terminated" && data.status && data.status !== "terminated") { - throw conflict("Terminated agents cannot be resumed"); - } - - if (data.reportsTo !== undefined) { - if (data.reportsTo) { - await ensureManager(existing.companyId, data.reportsTo); - } - await assertNoCycle(id, data.reportsTo); - } - - return db - .update(agents) - .set({ ...data, updatedAt: new Date() }) - .where(eq(agents.id, id)) - .returning() - .then((rows) => rows[0] ?? null); - }, + update: updateAgent, pause: async (id: string) => { const existing = await getById(id); if (!existing) return null; if (existing.status === "terminated") throw conflict("Cannot pause terminated agent"); - return db + const updated = await db .update(agents) .set({ status: "paused", updatedAt: new Date() }) .where(eq(agents.id, id)) .returning() .then((rows) => rows[0] ?? null); + return updated ? normalizeAgentRow(updated) : null; }, resume: async (id: string) => { const existing = await getById(id); if (!existing) return null; if (existing.status === "terminated") throw conflict("Cannot resume terminated agent"); + if (existing.status === "pending_approval") { + throw conflict("Pending approval agents cannot be resumed"); + } - return db + const updated = await db .update(agents) .set({ status: "idle", updatedAt: new Date() }) .where(eq(agents.id, id)) .returning() .then((rows) => rows[0] ?? null); + return updated ? normalizeAgentRow(updated) : null; }, terminate: async (id: string) => { @@ -128,9 +295,104 @@ export function agentService(db: Db) { return getById(id); }, + remove: async (id: string) => { + const existing = await getById(id); + if (!existing) return null; + + return db.transaction(async (tx) => { + await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id)); + await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.agentId, id)); + await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id)); + await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id)); + await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id)); + await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.agentId, id)); + const deleted = await tx + .delete(agents) + .where(eq(agents.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + return deleted ? normalizeAgentRow(deleted) : null; + }); + }, + + activatePendingApproval: async (id: string) => { + const existing = await getById(id); + if (!existing) return null; + if (existing.status !== "pending_approval") return existing; + + const updated = await db + .update(agents) + .set({ status: "idle", updatedAt: new Date() }) + .where(eq(agents.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + + return updated ? normalizeAgentRow(updated) : null; + }, + + updatePermissions: async (id: string, permissions: { canCreateAgents: boolean }) => { + const existing = await getById(id); + if (!existing) return null; + + const updated = await db + .update(agents) + .set({ + permissions: normalizeAgentPermissions(permissions, existing.role), + updatedAt: new Date(), + }) + .where(eq(agents.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + + return updated ? normalizeAgentRow(updated) : null; + }, + + listConfigRevisions: async (id: string) => + db + .select() + .from(agentConfigRevisions) + .where(eq(agentConfigRevisions.agentId, id)) + .orderBy(desc(agentConfigRevisions.createdAt)), + + getConfigRevision: async (id: string, revisionId: string) => + db + .select() + .from(agentConfigRevisions) + .where(and(eq(agentConfigRevisions.agentId, id), eq(agentConfigRevisions.id, revisionId))) + .then((rows) => rows[0] ?? null), + + rollbackConfigRevision: async ( + id: string, + revisionId: string, + actor: { agentId?: string | null; userId?: string | null }, + ) => { + const revision = await db + .select() + .from(agentConfigRevisions) + .where(and(eq(agentConfigRevisions.agentId, id), eq(agentConfigRevisions.id, revisionId))) + .then((rows) => rows[0] ?? null); + if (!revision) return null; + + const patch = configPatchFromSnapshot(revision.afterConfig); + return updateAgent(id, patch, { + recordRevision: { + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + source: "rollback", + rolledBackFromRevisionId: revision.id, + }, + }); + }, + createApiKey: async (id: string, name: string) => { const existing = await getById(id); if (!existing) throw notFound("Agent not found"); + if (existing.status === "pending_approval") { + throw conflict("Cannot create keys for pending approval agents"); + } + if (existing.status === "terminated") { + throw conflict("Cannot create keys for terminated agents"); + } const token = createToken(); const keyHash = hashToken(token); @@ -175,8 +437,9 @@ export function agentService(db: Db) { orgForCompany: async (companyId: string) => { const rows = await db.select().from(agents).where(eq(agents.companyId, companyId)); - const byManager = new Map(); - for (const row of rows) { + const normalizedRows = rows.map(normalizeAgentRow); + const byManager = new Map(); + for (const row of normalizedRows) { const key = row.reportsTo ?? null; const group = byManager.get(key) ?? []; group.push(row); diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts index a6642d86..ba9e4500 100644 --- a/server/src/services/approvals.ts +++ b/server/src/services/approvals.ts @@ -1,11 +1,22 @@ -import { and, eq } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import type { Db } from "@paperclip/db"; -import { approvals } from "@paperclip/db"; +import { approvalComments, approvals } from "@paperclip/db"; import { notFound, unprocessable } from "../errors.js"; import { agentService } from "./agents.js"; export function approvalService(db: Db) { const agentsSvc = agentService(db); + const canResolveStatuses = new Set(["pending", "revision_requested"]); + + async function getExistingApproval(id: string) { + const existing = await db + .select() + .from(approvals) + .where(eq(approvals.id, id)) + .then((rows) => rows[0] ?? null); + if (!existing) throw notFound("Approval not found"); + return existing; + } return { list: (companyId: string, status?: string) => { @@ -29,15 +40,9 @@ export function approvalService(db: Db) { .then((rows) => rows[0]), approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { - const existing = await db - .select() - .from(approvals) - .where(eq(approvals.id, id)) - .then((rows) => rows[0] ?? null); - - if (!existing) throw notFound("Approval not found"); - if (existing.status !== "pending") { - throw unprocessable("Only pending approvals can be approved"); + const existing = await getExistingApproval(id); + if (!canResolveStatuses.has(existing.status)) { + throw unprocessable("Only pending or revision requested approvals can be approved"); } const now = new Date(); @@ -56,46 +61,46 @@ export function approvalService(db: Db) { if (updated.type === "hire_agent") { const payload = updated.payload as Record; - await agentsSvc.create(updated.companyId, { - name: String(payload.name ?? "New Agent"), - role: String(payload.role ?? "general"), - title: typeof payload.title === "string" ? payload.title : null, - reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null, - capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null, - adapterType: String(payload.adapterType ?? "process"), - adapterConfig: - typeof payload.adapterConfig === "object" && payload.adapterConfig !== null - ? (payload.adapterConfig as Record) - : {}, - budgetMonthlyCents: - typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0, - metadata: - typeof payload.metadata === "object" && payload.metadata !== null - ? (payload.metadata as Record) - : null, - status: "idle", - spentMonthlyCents: 0, - lastHeartbeatAt: null, - }); + const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; + if (payloadAgentId) { + await agentsSvc.activatePendingApproval(payloadAgentId); + } else { + await agentsSvc.create(updated.companyId, { + name: String(payload.name ?? "New Agent"), + role: String(payload.role ?? "general"), + title: typeof payload.title === "string" ? payload.title : null, + reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null, + capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null, + adapterType: String(payload.adapterType ?? "process"), + adapterConfig: + typeof payload.adapterConfig === "object" && payload.adapterConfig !== null + ? (payload.adapterConfig as Record) + : {}, + budgetMonthlyCents: + typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0, + metadata: + typeof payload.metadata === "object" && payload.metadata !== null + ? (payload.metadata as Record) + : null, + status: "idle", + spentMonthlyCents: 0, + permissions: undefined, + lastHeartbeatAt: null, + }); + } } return updated; }, reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { - const existing = await db - .select() - .from(approvals) - .where(eq(approvals.id, id)) - .then((rows) => rows[0] ?? null); - - if (!existing) throw notFound("Approval not found"); - if (existing.status !== "pending") { - throw unprocessable("Only pending approvals can be rejected"); + const existing = await getExistingApproval(id); + if (!canResolveStatuses.has(existing.status)) { + throw unprocessable("Only pending or revision requested approvals can be rejected"); } const now = new Date(); - return db + const updated = await db .update(approvals) .set({ status: "rejected", @@ -107,6 +112,92 @@ export function approvalService(db: Db) { .where(eq(approvals.id, id)) .returning() .then((rows) => rows[0]); + + if (updated.type === "hire_agent") { + const payload = updated.payload as Record; + const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null; + if (payloadAgentId) { + await agentsSvc.terminate(payloadAgentId); + } + } + + return updated; + }, + + requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => { + const existing = await getExistingApproval(id); + if (existing.status !== "pending") { + throw unprocessable("Only pending approvals can request revision"); + } + + const now = new Date(); + return db + .update(approvals) + .set({ + status: "revision_requested", + decidedByUserId, + decisionNote: decisionNote ?? null, + decidedAt: now, + updatedAt: now, + }) + .where(eq(approvals.id, id)) + .returning() + .then((rows) => rows[0]); + }, + + resubmit: async (id: string, payload?: Record) => { + const existing = await getExistingApproval(id); + if (existing.status !== "revision_requested") { + throw unprocessable("Only revision requested approvals can be resubmitted"); + } + + const now = new Date(); + return db + .update(approvals) + .set({ + status: "pending", + payload: payload ?? existing.payload, + decisionNote: null, + decidedByUserId: null, + decidedAt: null, + updatedAt: now, + }) + .where(eq(approvals.id, id)) + .returning() + .then((rows) => rows[0]); + }, + + listComments: async (approvalId: string) => { + const existing = await getExistingApproval(approvalId); + return db + .select() + .from(approvalComments) + .where( + and( + eq(approvalComments.approvalId, approvalId), + eq(approvalComments.companyId, existing.companyId), + ), + ) + .orderBy(asc(approvalComments.createdAt)); + }, + + addComment: async ( + approvalId: string, + body: string, + actor: { agentId?: string; userId?: string }, + ) => { + const existing = await getExistingApproval(approvalId); + return db + .insert(approvalComments) + .values({ + companyId: existing.companyId, + approvalId, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + body, + }) + .returning() + .then((rows) => rows[0]); }, }; } diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 240ae1db..364e8171 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -13,6 +13,7 @@ import { heartbeatRuns, heartbeatRunEvents, costEvents, + approvalComments, approvals, activityLog, } from "@paperclip/db"; @@ -61,6 +62,7 @@ export function companyService(db: Db) { await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id)); await tx.delete(issueComments).where(eq(issueComments.companyId, id)); await tx.delete(costEvents).where(eq(costEvents.companyId, id)); + await tx.delete(approvalComments).where(eq(approvalComments.companyId, id)); await tx.delete(approvals).where(eq(approvals.companyId, id)); await tx.delete(issues).where(eq(issues.companyId, id)); await tx.delete(goals).where(eq(goals.companyId, id)); diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index 64ce31af..fdb31045 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -1,8 +1,13 @@ -import { and, desc, eq, isNotNull, sql } from "drizzle-orm"; +import { and, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm"; import type { Db } from "@paperclip/db"; import { agents, companies, costEvents } from "@paperclip/db"; import { notFound, unprocessable } from "../errors.js"; +export interface CostDateRange { + from?: Date; + to?: Date; +} + export function costService(db: Db) { return { createEvent: async (companyId: string, data: Omit) => { @@ -61,7 +66,7 @@ export function costService(db: Db) { return event; }, - summary: async (companyId: string) => { + summary: async (companyId: string, range?: CostDateRange) => { const company = await db .select() .from(companies) @@ -70,43 +75,71 @@ export function costService(db: Db) { if (!company) throw notFound("Company not found"); + const conditions: ReturnType[] = [eq(costEvents.companyId, companyId)]; + if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); + if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to)); + + const [{ total }] = await db + .select({ + total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + }) + .from(costEvents) + .where(and(...conditions)); + + const spendCents = Number(total); const utilization = company.budgetMonthlyCents > 0 - ? (company.spentMonthlyCents / company.budgetMonthlyCents) * 100 + ? (spendCents / company.budgetMonthlyCents) * 100 : 0; return { companyId, - monthSpendCents: company.spentMonthlyCents, - monthBudgetCents: company.budgetMonthlyCents, - monthUtilizationPercent: Number(utilization.toFixed(2)), + spendCents, + budgetCents: company.budgetMonthlyCents, + utilizationPercent: Number(utilization.toFixed(2)), }; }, - byAgent: async (companyId: string) => - db + byAgent: async (companyId: string, range?: CostDateRange) => { + const conditions: ReturnType[] = [eq(costEvents.companyId, companyId)]; + if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); + if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to)); + + return db .select({ agentId: costEvents.agentId, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)`, + agentName: agents.name, + agentStatus: agents.status, + costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, + outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, }) .from(costEvents) - .where(eq(costEvents.companyId, companyId)) - .groupBy(costEvents.agentId) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)`)), + .leftJoin(agents, eq(costEvents.agentId, agents.id)) + .where(and(...conditions)) + .groupBy(costEvents.agentId, agents.name, agents.status) + .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + }, - byProject: async (companyId: string) => - db + byProject: async (companyId: string, range?: CostDateRange) => { + const conditions: ReturnType[] = [ + eq(costEvents.companyId, companyId), + isNotNull(costEvents.projectId), + ]; + if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); + if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to)); + + return db .select({ projectId: costEvents.projectId, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)`, + costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, + outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, }) .from(costEvents) - .where(and(eq(costEvents.companyId, companyId), isNotNull(costEvents.projectId))) + .where(and(...conditions)) .groupBy(costEvents.projectId) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)`)), + .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + }, }; } diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts index 30133518..8f975af8 100644 --- a/server/src/services/dashboard.ts +++ b/server/src/services/dashboard.ts @@ -1,6 +1,6 @@ -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, gte, sql } from "drizzle-orm"; import type { Db } from "@paperclip/db"; -import { agents, approvals, companies, issues } from "@paperclip/db"; +import { agents, approvals, companies, costEvents, issues } from "@paperclip/db"; import { notFound } from "../errors.js"; export function dashboardService(db: Db) { @@ -69,9 +69,24 @@ export function dashboardService(db: Db) { if (row.status !== "done" && row.status !== "cancelled") taskCounts.open += count; } + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const [{ monthSpend }] = await db + .select({ + monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + }) + .from(costEvents) + .where( + and( + eq(costEvents.companyId, companyId), + gte(costEvents.occurredAt, monthStart), + ), + ); + + const monthSpendCents = Number(monthSpend); const utilization = company.budgetMonthlyCents > 0 - ? (company.spentMonthlyCents / company.budgetMonthlyCents) * 100 + ? (monthSpendCents / company.budgetMonthlyCents) * 100 : 0; return { @@ -84,7 +99,7 @@ export function dashboardService(db: Db) { }, tasks: taskCounts, costs: { - monthSpendCents: company.spentMonthlyCents, + monthSpendCents, monthBudgetCents: company.budgetMonthlyCents, monthUtilizationPercent: Number(utilization.toFixed(2)), }, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0402d9ce..45001c2c 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -621,7 +621,11 @@ export function heartbeatService(db: Db) { const agent = await getAgent(agentId); if (!agent) throw notFound("Agent not found"); - if (agent.status === "paused" || agent.status === "terminated") { + if ( + agent.status === "paused" || + agent.status === "terminated" || + agent.status === "pending_approval" + ) { throw conflict("Agent is not invokable in its current state", { status: agent.status }); } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 6675880d..2384ae35 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -2,11 +2,13 @@ export { companyService } from "./companies.js"; export { agentService } from "./agents.js"; export { projectService } from "./projects.js"; export { issueService, type IssueFilters } from "./issues.js"; +export { issueApprovalService } from "./issue-approvals.js"; export { goalService } from "./goals.js"; export { activityService, type ActivityFilters } from "./activity.js"; export { approvalService } from "./approvals.js"; export { costService } from "./costs.js"; export { heartbeatService } from "./heartbeat.js"; export { dashboardService } from "./dashboard.js"; +export { sidebarBadgeService } from "./sidebar-badges.js"; export { logActivity, type LogActivityInput } from "./activity-log.js"; export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js"; diff --git a/server/src/services/issue-approvals.ts b/server/src/services/issue-approvals.ts new file mode 100644 index 00000000..335a7ea4 --- /dev/null +++ b/server/src/services/issue-approvals.ts @@ -0,0 +1,169 @@ +import { and, desc, eq, inArray } from "drizzle-orm"; +import type { Db } from "@paperclip/db"; +import { approvals, issueApprovals, issues } from "@paperclip/db"; +import { notFound, unprocessable } from "../errors.js"; + +interface LinkActor { + agentId?: string | null; + userId?: string | null; +} + +export function issueApprovalService(db: Db) { + async function getIssue(issueId: string) { + return db + .select() + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + } + + async function getApproval(approvalId: string) { + return db + .select() + .from(approvals) + .where(eq(approvals.id, approvalId)) + .then((rows) => rows[0] ?? null); + } + + async function assertIssueAndApprovalSameCompany(issueId: string, approvalId: string) { + const issue = await getIssue(issueId); + if (!issue) throw notFound("Issue not found"); + + const approval = await getApproval(approvalId); + if (!approval) throw notFound("Approval not found"); + + if (issue.companyId !== approval.companyId) { + throw unprocessable("Issue and approval must belong to the same company"); + } + + return { issue, approval }; + } + + return { + listApprovalsForIssue: async (issueId: string) => { + const issue = await getIssue(issueId); + if (!issue) throw notFound("Issue not found"); + + return db + .select({ + id: approvals.id, + companyId: approvals.companyId, + type: approvals.type, + requestedByAgentId: approvals.requestedByAgentId, + requestedByUserId: approvals.requestedByUserId, + status: approvals.status, + payload: approvals.payload, + decisionNote: approvals.decisionNote, + decidedByUserId: approvals.decidedByUserId, + decidedAt: approvals.decidedAt, + createdAt: approvals.createdAt, + updatedAt: approvals.updatedAt, + }) + .from(issueApprovals) + .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) + .where(eq(issueApprovals.issueId, issueId)) + .orderBy(desc(issueApprovals.createdAt)); + }, + + listIssuesForApproval: async (approvalId: string) => { + const approval = await getApproval(approvalId); + if (!approval) throw notFound("Approval not found"); + + return db + .select({ + id: issues.id, + companyId: issues.companyId, + projectId: issues.projectId, + goalId: issues.goalId, + parentId: issues.parentId, + title: issues.title, + description: issues.description, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + createdByAgentId: issues.createdByAgentId, + createdByUserId: issues.createdByUserId, + issueNumber: issues.issueNumber, + identifier: issues.identifier, + requestDepth: issues.requestDepth, + billingCode: issues.billingCode, + startedAt: issues.startedAt, + completedAt: issues.completedAt, + cancelledAt: issues.cancelledAt, + createdAt: issues.createdAt, + updatedAt: issues.updatedAt, + }) + .from(issueApprovals) + .innerJoin(issues, eq(issueApprovals.issueId, issues.id)) + .where(eq(issueApprovals.approvalId, approvalId)) + .orderBy(desc(issueApprovals.createdAt)); + }, + + link: async (issueId: string, approvalId: string, actor?: LinkActor) => { + const { issue } = await assertIssueAndApprovalSameCompany(issueId, approvalId); + + await db + .insert(issueApprovals) + .values({ + companyId: issue.companyId, + issueId, + approvalId, + linkedByAgentId: actor?.agentId ?? null, + linkedByUserId: actor?.userId ?? null, + }) + .onConflictDoNothing(); + + return db + .select() + .from(issueApprovals) + .where(and(eq(issueApprovals.issueId, issueId), eq(issueApprovals.approvalId, approvalId))) + .then((rows) => rows[0] ?? null); + }, + + unlink: async (issueId: string, approvalId: string) => { + await assertIssueAndApprovalSameCompany(issueId, approvalId); + await db + .delete(issueApprovals) + .where(and(eq(issueApprovals.issueId, issueId), eq(issueApprovals.approvalId, approvalId))); + }, + + linkManyForApproval: async (approvalId: string, issueIds: string[], actor?: LinkActor) => { + if (issueIds.length === 0) return; + + const approval = await getApproval(approvalId); + if (!approval) throw notFound("Approval not found"); + + const uniqueIssueIds = Array.from(new Set(issueIds)); + const rows = await db + .select({ + id: issues.id, + companyId: issues.companyId, + }) + .from(issues) + .where(inArray(issues.id, uniqueIssueIds)); + + if (rows.length !== uniqueIssueIds.length) { + throw notFound("One or more issues not found"); + } + + for (const row of rows) { + if (row.companyId !== approval.companyId) { + throw unprocessable("Issue and approval must belong to the same company"); + } + } + + await db + .insert(issueApprovals) + .values( + uniqueIssueIds.map((issueId) => ({ + companyId: approval.companyId, + issueId, + approvalId, + linkedByAgentId: actor?.agentId ?? null, + linkedByUserId: actor?.userId ?? null, + })), + ) + .onConflictDoNothing(); + }, + }; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 160c3ddd..ef902494 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -3,21 +3,12 @@ import type { Db } from "@paperclip/db"; import { agents, companies, issues, issueComments } from "@paperclip/db"; import { conflict, notFound, unprocessable } from "../errors.js"; -const ISSUE_TRANSITIONS: Record = { - backlog: ["todo", "cancelled"], - todo: ["in_progress", "blocked", "cancelled"], - in_progress: ["in_review", "blocked", "done", "cancelled"], - in_review: ["in_progress", "done", "cancelled"], - blocked: ["todo", "in_progress", "cancelled"], - done: ["todo"], - cancelled: ["todo"], -}; +const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; function assertTransition(from: string, to: string) { if (from === to) return; - const allowed = ISSUE_TRANSITIONS[from] ?? []; - if (!allowed.includes(to)) { - throw conflict(`Invalid issue status transition: ${from} -> ${to}`); + if (!ALL_ISSUE_STATUSES.includes(to)) { + throw conflict(`Unknown issue status: ${to}`); } } @@ -46,6 +37,29 @@ export interface IssueFilters { } export function issueService(db: Db) { + async function assertAssignableAgent(companyId: string, agentId: string) { + const assignee = await db + .select({ + id: agents.id, + companyId: agents.companyId, + status: agents.status, + }) + .from(agents) + .where(eq(agents.id, agentId)) + .then((rows) => rows[0] ?? null); + + if (!assignee) throw notFound("Assignee agent not found"); + if (assignee.companyId !== companyId) { + throw unprocessable("Assignee must belong to same company"); + } + if (assignee.status === "pending_approval") { + throw conflict("Cannot assign work to pending approval agents"); + } + if (assignee.status === "terminated") { + throw conflict("Cannot assign work to terminated agents"); + } + } + return { list: async (companyId: string, filters?: IssueFilters) => { const conditions = [eq(issues.companyId, companyId)]; @@ -77,6 +91,9 @@ export function issueService(db: Db) { .then((rows) => rows[0] ?? null), create: async (companyId: string, data: Omit) => { + if (data.assigneeAgentId) { + await assertAssignableAgent(companyId, data.assigneeAgentId); + } return db.transaction(async (tx) => { const [company] = await tx .update(companies) @@ -123,6 +140,9 @@ export function issueService(db: Db) { if (patch.status === "in_progress" && !patch.assigneeAgentId && !existing.assigneeAgentId) { throw unprocessable("in_progress issues require an assignee"); } + if (data.assigneeAgentId) { + await assertAssignableAgent(existing.companyId, data.assigneeAgentId); + } applyStatusSideEffects(data.status, patch); if (data.status && data.status !== "done") { @@ -148,6 +168,14 @@ export function issueService(db: Db) { .then((rows) => rows[0] ?? null), checkout: async (id: string, agentId: string, expectedStatuses: string[]) => { + const issueCompany = await db + .select({ companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, id)) + .then((rows) => rows[0] ?? null); + if (!issueCompany) throw notFound("Issue not found"); + await assertAssignableAgent(issueCompany.companyId, agentId); + const now = new Date(); const updated = await db .update(issues) diff --git a/server/src/services/sidebar-badges.ts b/server/src/services/sidebar-badges.ts new file mode 100644 index 00000000..dc0c9391 --- /dev/null +++ b/server/src/services/sidebar-badges.ts @@ -0,0 +1,29 @@ +import { and, eq, inArray, sql } from "drizzle-orm"; +import type { Db } from "@paperclip/db"; +import { approvals } from "@paperclip/db"; +import type { SidebarBadges } from "@paperclip/shared"; + +const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"]; + +export function sidebarBadgeService(db: Db) { + return { + get: async (companyId: string): Promise => { + const actionableApprovals = await db + .select({ count: sql`count(*)` }) + .from(approvals) + .where( + and( + eq(approvals.companyId, companyId), + inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES), + ), + ) + .then((rows) => Number(rows[0]?.count ?? 0)); + + return { + // Inbox currently mirrors actionable approvals; expand as inbox categories grow. + inbox: actionableApprovals, + approvals: actionableApprovals, + }; + }, + }; +}