From 498cb2f8d53aed8097dafc9384f54c9866674a05 Mon Sep 17 00:00:00 2001 From: nyk <93952610+0xNyk@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:27:47 +0700 Subject: [PATCH] feat: phase 1 workspace isolation across auth and core APIs (#112) * feat: add workspace-scoped auth sessions and core API filtering * feat: extend workspace scoping to search status standup and messaging * feat: scope agent connect github and alert workflows by workspace * fix: scope status sync and session alerts by workspace * feat: add phase2 workspace migration and scope chat pipeline alerts * feat: add model selection for agents and cron jobs * feat: add deterministic agent avatars to task and squad views * feat: add read-only cron calendar and agenda views * feat: render task descriptions with markdown --- package.json | 2 + pnpm-lock.yaml | 183 ++++++++++ src/app/api/activities/route.ts | 43 +-- src/app/api/agents/[id]/heartbeat/route.ts | 33 +- src/app/api/agents/[id]/memory/route.ts | 37 +- src/app/api/agents/[id]/route.ts | 29 +- src/app/api/agents/[id]/soul/route.ts | 21 +- src/app/api/agents/[id]/wake/route.ts | 7 +- src/app/api/agents/comms/route.ts | 27 +- src/app/api/agents/message/route.ts | 11 +- src/app/api/agents/route.ts | 51 ++- src/app/api/alerts/route.ts | 81 +++-- src/app/api/auth/google/route.ts | 5 +- src/app/api/auth/login/route.ts | 3 +- src/app/api/auth/me/route.ts | 4 +- src/app/api/auth/users/route.ts | 35 +- src/app/api/chat/conversations/route.ts | 18 +- src/app/api/chat/messages/[id]/route.ts | 16 +- src/app/api/chat/messages/route.ts | 49 ++- src/app/api/connect/route.ts | 43 ++- src/app/api/cron/route.ts | 3 +- src/app/api/export/route.ts | 11 +- src/app/api/github/route.ts | 86 +++-- src/app/api/notifications/deliver/route.ts | 38 +- src/app/api/notifications/route.ts | 54 +-- src/app/api/pipelines/route.ts | 46 ++- src/app/api/pipelines/run/route.ts | 73 ++-- src/app/api/quality-review/route.ts | 27 +- src/app/api/search/route.ts | 13 +- src/app/api/standup/route.ts | 51 ++- src/app/api/status/route.ts | 36 +- src/app/api/tasks/[id]/broadcast/route.ts | 17 +- src/app/api/tasks/[id]/comments/route.ts | 43 ++- src/app/api/tasks/[id]/route.ts | 59 +-- src/app/api/tasks/route.ts | 49 +-- src/components/markdown-renderer.tsx | 64 ++++ src/components/panels/agent-detail-tabs.tsx | 64 +++- .../panels/agent-squad-panel-phase3.tsx | 10 +- .../panels/cron-management-panel.tsx | 344 +++++++++++++++++- src/components/panels/task-board-panel.tsx | 38 +- src/components/ui/agent-avatar.tsx | 58 +++ src/lib/__tests__/db-helpers.test.ts | 8 +- src/lib/auth.ts | 50 ++- src/lib/db.ts | 73 ++-- src/lib/migrations.ts | 96 +++++ src/store/index.ts | 4 + 46 files changed, 1623 insertions(+), 490 deletions(-) create mode 100644 src/components/markdown-renderer.tsx create mode 100644 src/components/ui/agent-avatar.tsx diff --git a/package.json b/package.json index b07b9dd..7cb7289 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,10 @@ "postcss": "^8.5.2", "react": "^19.0.1", "react-dom": "^19.0.1", + "react-markdown": "^10.1.0", "reactflow": "^11.11.4", "recharts": "^3.7.0", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78e24af..e230b81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,12 +47,18 @@ importers: react-dom: specifier: ^19.0.1 version: 19.2.4(react@19.2.4) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.13)(react@19.2.4) reactflow: specifier: ^11.11.4 version: 11.11.4(@types/react@19.2.13)(immer@11.1.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) recharts: specifier: ^3.7.0 version: 3.7.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -1286,6 +1292,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1321,6 +1330,9 @@ packages: '@types/react@19.2.13': resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==} + '@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==} @@ -1890,6 +1902,9 @@ packages: 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'} @@ -2331,6 +2346,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -2590,6 +2608,9 @@ packages: hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.1: resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} @@ -2625,6 +2646,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -2682,6 +2706,9 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2694,6 +2721,12 @@ packages: resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + 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-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2733,6 +2766,9 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2749,6 +2785,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-identifier@1.0.1: resolution: {integrity: sha512-HQ5v4rEJ7REUV54bCd2l5FaD299SGDEn2UPoVXaTHAyGviLq2menVUD2udi3trQ32uvB6LdAh/0ck2EuizrtpA==} engines: {node: '>=18'} @@ -2990,6 +3029,15 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + 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==} @@ -3262,6 +3310,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -3451,6 +3502,12 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} peerDependencies: @@ -3777,6 +3834,12 @@ packages: style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -5702,6 +5765,10 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + '@types/estree@1.0.8': {} '@types/geojson@7946.0.16': {} @@ -5734,6 +5801,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/unist@2.0.11': {} + '@types/unist@3.0.3': {} '@types/use-sync-external-store@0.0.6': {} @@ -6336,6 +6405,8 @@ snapshots: character-entities@2.0.2: {} + character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} chokidar@3.6.0: @@ -6914,6 +6985,8 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -7211,6 +7284,26 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 + 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-to-parse5@8.0.1: dependencies: '@types/hast': 3.0.4 @@ -7258,6 +7351,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} html-whitespace-sensitive-tag-names@3.0.1: {} @@ -7307,6 +7402,8 @@ snapshots: ini@1.3.8: {} + inline-style-parser@0.2.7: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -7317,6 +7414,13 @@ snapshots: is-absolute-url@4.0.1: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -7365,6 +7469,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -7383,6 +7489,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-identifier@1.0.1: dependencies: identifier-regex: 1.0.1 @@ -7671,6 +7779,45 @@ snapshots: 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.3 + 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.3 + 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.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -8066,6 +8213,16 @@ snapshots: dependencies: callsites: 3.1.0 + 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 + parse-ms@4.0.0: {} parse5@7.3.0: @@ -8267,6 +8424,24 @@ snapshots: react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@19.2.13)(react@19.2.4): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.13 + 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-redux@9.2.0(@types/react@19.2.13)(react@19.2.4)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 @@ -8735,6 +8910,14 @@ snapshots: style-mod@4.1.3: {} + 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 + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): dependencies: client-only: 0.0.1 diff --git a/src/app/api/activities/route.ts b/src/app/api/activities/route.ts index 1905aa8..92baff4 100644 --- a/src/app/api/activities/route.ts +++ b/src/app/api/activities/route.ts @@ -13,14 +13,15 @@ export async function GET(request: NextRequest) { try { const { searchParams, pathname } = new URL(request.url); + const workspaceId = auth.user.workspace_id ?? 1; // Route to stats endpoint if requested if (pathname.endsWith('/stats') || searchParams.has('stats')) { - return handleStatsRequest(request); + return handleStatsRequest(request, workspaceId); } // Default activities endpoint - return handleActivitiesRequest(request); + return handleActivitiesRequest(request, workspaceId); } catch (error) { logger.error({ err: error }, 'GET /api/activities error'); return NextResponse.json({ error: 'Failed to process request' }, { status: 500 }); @@ -30,7 +31,7 @@ export async function GET(request: NextRequest) { /** * Handle regular activities request */ -async function handleActivitiesRequest(request: NextRequest) { +async function handleActivitiesRequest(request: NextRequest, workspaceId: number) { try { const db = getDatabase(); const { searchParams } = new URL(request.url); @@ -44,8 +45,8 @@ async function handleActivitiesRequest(request: NextRequest) { const since = searchParams.get('since'); // Unix timestamp for real-time updates // Build dynamic query - let query = 'SELECT * FROM activities WHERE 1=1'; - const params: any[] = []; + let query = 'SELECT * FROM activities WHERE workspace_id = ?'; + const params: any[] = [workspaceId]; if (type) { query += ' AND type = ?'; @@ -74,13 +75,13 @@ async function handleActivitiesRequest(request: NextRequest) { const activities = stmt.all(...params) as Activity[]; // Prepare entity detail statements once (avoids N+1) - const taskDetailStmt = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?'); - const agentDetailStmt = db.prepare('SELECT id, name, role, status FROM agents WHERE id = ?'); + const taskDetailStmt = db.prepare('SELECT id, title, status FROM tasks WHERE id = ? AND workspace_id = ?'); + const agentDetailStmt = db.prepare('SELECT id, name, role, status FROM agents WHERE id = ? AND workspace_id = ?'); const commentDetailStmt = db.prepare(` SELECT c.id, c.content, c.task_id, t.title as task_title FROM comments c LEFT JOIN tasks t ON c.task_id = t.id - WHERE c.id = ? + WHERE c.id = ? AND c.workspace_id = ? AND t.workspace_id = ? `); // Parse JSON data field and enhance with related entity data @@ -90,21 +91,21 @@ async function handleActivitiesRequest(request: NextRequest) { try { switch (activity.entity_type) { case 'task': { - const task = taskDetailStmt.get(activity.entity_id) as any; + const task = taskDetailStmt.get(activity.entity_id, workspaceId) as any; if (task) { entityDetails = { type: 'task', ...task }; } break; } case 'agent': { - const agent = agentDetailStmt.get(activity.entity_id) as any; + const agent = agentDetailStmt.get(activity.entity_id, workspaceId) as any; if (agent) { entityDetails = { type: 'agent', ...agent }; } break; } case 'comment': { - const comment = commentDetailStmt.get(activity.entity_id) as any; + const comment = commentDetailStmt.get(activity.entity_id, workspaceId, workspaceId) as any; if (comment) { entityDetails = { type: 'comment', @@ -127,8 +128,8 @@ async function handleActivitiesRequest(request: NextRequest) { }); // Get total count for pagination - let countQuery = 'SELECT COUNT(*) as total FROM activities WHERE 1=1'; - const countParams: any[] = []; + let countQuery = 'SELECT COUNT(*) as total FROM activities WHERE workspace_id = ?'; + const countParams: any[] = [workspaceId]; if (type) { countQuery += ' AND type = ?'; @@ -166,7 +167,7 @@ async function handleActivitiesRequest(request: NextRequest) { /** * Handle stats request */ -async function handleStatsRequest(request: NextRequest) { +async function handleStatsRequest(request: NextRequest, workspaceId: number) { try { const db = getDatabase(); const { searchParams } = new URL(request.url); @@ -181,10 +182,10 @@ async function handleStatsRequest(request: NextRequest) { type, COUNT(*) as count FROM activities - WHERE created_at > ? + WHERE created_at > ? AND workspace_id = ? GROUP BY type ORDER BY count DESC - `).all(since) as { type: string; count: number }[]; + `).all(since, workspaceId) as { type: string; count: number }[]; // Get most active actors const activeActors = db.prepare(` @@ -192,11 +193,11 @@ async function handleStatsRequest(request: NextRequest) { actor, COUNT(*) as activity_count FROM activities - WHERE created_at > ? + WHERE created_at > ? AND workspace_id = ? GROUP BY actor ORDER BY activity_count DESC LIMIT 10 - `).all(since) as { actor: string; activity_count: number }[]; + `).all(since, workspaceId) as { actor: string; activity_count: number }[]; // Get activity timeline (hourly buckets) const timeline = db.prepare(` @@ -204,10 +205,10 @@ async function handleStatsRequest(request: NextRequest) { (created_at / 3600) * 3600 as hour_bucket, COUNT(*) as count FROM activities - WHERE created_at > ? + WHERE created_at > ? AND workspace_id = ? GROUP BY hour_bucket ORDER BY hour_bucket ASC - `).all(since) as { hour_bucket: number; count: number }[]; + `).all(since, workspaceId) as { hour_bucket: number; count: number }[]; return NextResponse.json({ timeframe: `${hours} hours`, @@ -223,4 +224,4 @@ async function handleStatsRequest(request: NextRequest) { logger.error({ err: error }, 'GET /api/activities (stats) error'); return NextResponse.json({ error: 'Failed to fetch activity stats' }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/agents/[id]/heartbeat/route.ts b/src/app/api/agents/[id]/heartbeat/route.ts index f7ba0f5..7b85049 100644 --- a/src/app/api/agents/[id]/heartbeat/route.ts +++ b/src/app/api/agents/[id]/heartbeat/route.ts @@ -24,15 +24,16 @@ export async function GET( const db = getDatabase(); const resolvedParams = await params; const agentId = resolvedParams.id; + const workspaceId = auth.user.workspace_id ?? 1; // Get agent by ID or name let agent: any; if (isNaN(Number(agentId))) { // Lookup by name - agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId); + agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId); } else { // Lookup by ID - agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId)); + agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId); } if (!agent) { @@ -49,10 +50,12 @@ export async function GET( FROM comments c JOIN tasks t ON c.task_id = t.id WHERE c.mentions LIKE ? + AND c.workspace_id = ? + AND t.workspace_id = ? AND c.created_at > ? ORDER BY c.created_at DESC LIMIT 10 - `).all(`%"${agent.name}"%`, fourHoursAgo); + `).all(`%"${agent.name}"%`, workspaceId, workspaceId, fourHoursAgo); if (mentions.length > 0) { workItems.push({ @@ -72,10 +75,11 @@ export async function GET( const assignedTasks = db.prepare(` SELECT * FROM tasks WHERE assigned_to = ? + AND workspace_id = ? AND status IN ('assigned', 'in_progress') ORDER BY priority DESC, created_at ASC LIMIT 10 - `).all(agent.name); + `).all(agent.name, workspaceId); if (assignedTasks.length > 0) { workItems.push({ @@ -92,7 +96,7 @@ export async function GET( } // 3. Check for unread notifications - const notifications = db_helpers.getUnreadNotifications(agent.name); + const notifications = db_helpers.getUnreadNotifications(agent.name, workspaceId); if (notifications.length > 0) { workItems.push({ @@ -112,11 +116,12 @@ export async function GET( const urgentActivities = db.prepare(` SELECT * FROM activities WHERE type IN ('task_created', 'task_assigned', 'high_priority_alert') + AND workspace_id = ? AND created_at > ? AND description LIKE ? ORDER BY created_at DESC LIMIT 5 - `).all(fourHoursAgo, `%${agent.name}%`); + `).all(workspaceId, fourHoursAgo, `%${agent.name}%`); if (urgentActivities.length > 0) { workItems.push({ @@ -132,7 +137,7 @@ export async function GET( } // Update agent last_seen and status to show heartbeat activity - db_helpers.updateAgentStatus(agent.name, 'idle', 'Heartbeat check'); + db_helpers.updateAgentStatus(agent.name, 'idle', 'Heartbeat check', workspaceId); // Log heartbeat activity db_helpers.logActivity( @@ -141,7 +146,8 @@ export async function GET( agent.id, agent.name, `Heartbeat check completed - ${workItems.length > 0 ? `${workItems.length} work items found` : 'no work items'}`, - { workItemsCount: workItems.length, workItemTypes: workItems.map(w => w.type) } + { workItemsCount: workItems.length, workItemTypes: workItems.map(w => w.type) }, + workspaceId ); if (workItems.length === 0) { @@ -193,11 +199,12 @@ export async function POST( const { connection_id, token_usage } = body; const db = getDatabase(); const now = Math.floor(Date.now() / 1000); + const workspaceId = auth.user.workspace_id ?? 1; // Update direct connection heartbeat if connection_id provided if (connection_id) { - db.prepare('UPDATE direct_connections SET last_heartbeat = ?, updated_at = ? WHERE connection_id = ? AND status = ?') - .run(now, now, connection_id, 'connected'); + db.prepare('UPDATE direct_connections SET last_heartbeat = ?, updated_at = ? WHERE connection_id = ? AND status = ? AND workspace_id = ?') + .run(now, now, connection_id, 'connected', workspaceId); } // Inline token reporting @@ -207,9 +214,9 @@ export async function POST( const agentId = resolvedParams.id; let agent: any; if (isNaN(Number(agentId))) { - agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId); + agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId); } else { - agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId)); + agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId); } if (agent) { @@ -230,4 +237,4 @@ export async function POST( ...getBody, token_recorded: tokenRecorded, }); -} \ No newline at end of file +} diff --git a/src/app/api/agents/[id]/memory/route.ts b/src/app/api/agents/[id]/memory/route.ts index 0e69558..db5ef30 100644 --- a/src/app/api/agents/[id]/memory/route.ts +++ b/src/app/api/agents/[id]/memory/route.ts @@ -20,13 +20,14 @@ export async function GET( const db = getDatabase(); const resolvedParams = await params; const agentId = resolvedParams.id; + const workspaceId = auth.user.workspace_id ?? 1; // Get agent by ID or name let agent: any; if (isNaN(Number(agentId))) { - agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId); + agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId); } else { - agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId)); + agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId); } if (!agent) { @@ -43,8 +44,8 @@ export async function GET( } // Get working memory content - const memoryStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?`); - const result = memoryStmt.get(agentId) as any; + const memoryStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ?`); + const result = memoryStmt.get(agentId, workspaceId) as any; const workingMemory = result?.working_memory || ''; @@ -78,15 +79,16 @@ export async function PUT( const db = getDatabase(); const resolvedParams = await params; const agentId = resolvedParams.id; + const workspaceId = auth.user.workspace_id ?? 1; const body = await request.json(); const { working_memory, append } = body; // Get agent by ID or name let agent: any; if (isNaN(Number(agentId))) { - agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId); + agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId); } else { - agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId)); + agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId); } if (!agent) { @@ -105,8 +107,8 @@ export async function PUT( // Handle append mode if (append) { - const currentStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?`); - const current = currentStmt.get(agentId) as any; + const currentStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ?`); + const current = currentStmt.get(agentId, workspaceId) as any; const currentContent = current?.working_memory || ''; // Add timestamp and append @@ -121,10 +123,10 @@ export async function PUT( const updateStmt = db.prepare(` UPDATE agents SET working_memory = ?, updated_at = ? - WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? + WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ? `); - updateStmt.run(newContent, now, agentId); + updateStmt.run(newContent, now, agentId, workspaceId); // Log activity db_helpers.logActivity( @@ -137,7 +139,8 @@ export async function PUT( content_length: newContent.length, append_mode: append || false, timestamp: now - } + }, + workspaceId ); return NextResponse.json({ @@ -167,13 +170,14 @@ export async function DELETE( const db = getDatabase(); const resolvedParams = await params; const agentId = resolvedParams.id; + const workspaceId = auth.user.workspace_id ?? 1; // Get agent by ID or name let agent: any; if (isNaN(Number(agentId))) { - agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId); + agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId); } else { - agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId)); + agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId); } if (!agent) { @@ -186,10 +190,10 @@ export async function DELETE( const updateStmt = db.prepare(` UPDATE agents SET working_memory = '', updated_at = ? - WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? + WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ? `); - updateStmt.run(now, agentId); + updateStmt.run(now, agentId, workspaceId); // Log activity db_helpers.logActivity( @@ -198,7 +202,8 @@ export async function DELETE( agent.id, agent.name, `Working memory cleared for agent ${agent.name}`, - { timestamp: now } + { timestamp: now }, + workspaceId ); return NextResponse.json({ diff --git a/src/app/api/agents/[id]/route.ts b/src/app/api/agents/[id]/route.ts index 00dfd46..55c23ef 100644 --- a/src/app/api/agents/[id]/route.ts +++ b/src/app/api/agents/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getDatabase, db_helpers, logAuditEvent } from '@/lib/db' -import { getUserFromRequest, requireRole } from '@/lib/auth' +import { requireRole } from '@/lib/auth' import { writeAgentToConfig, enrichAgentConfigFromWorkspace } from '@/lib/agent-sync' import { eventBus } from '@/lib/event-bus' import { logger } from '@/lib/logger' @@ -18,12 +18,13 @@ export async function GET( try { const db = getDatabase() const { id } = await params + const workspaceId = auth.user.workspace_id ?? 1; let agent if (isNaN(Number(id))) { - agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(id) + agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(id, workspaceId) } else { - agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(id)) + agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(id), workspaceId) } if (!agent) { @@ -61,14 +62,15 @@ export async function PUT( try { const db = getDatabase() const { id } = await params + const workspaceId = auth.user.workspace_id ?? 1; const body = await request.json() const { role, gateway_config, write_to_gateway } = body let agent if (isNaN(Number(id))) { - agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(id) as any + agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(id, workspaceId) as any } else { - agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(id)) as any + agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(id), workspaceId) as any } if (!agent) { @@ -98,8 +100,8 @@ export async function PUT( values.push(JSON.stringify(newConfig)) } - values.push(agent.id) - db.prepare(`UPDATE agents SET ${fields.join(', ')} WHERE id = ?`).run(...values) + values.push(agent.id, workspaceId) + db.prepare(`UPDATE agents SET ${fields.join(', ')} WHERE id = ? AND workspace_id = ?`).run(...values) // Write back to openclaw.json if requested if (write_to_gateway && gateway_config) { @@ -143,7 +145,8 @@ export async function PUT( agent.id, auth.user.username, `Config updated for agent ${agent.name}${write_to_gateway ? ' (+ gateway)' : ''}`, - { fields: Object.keys(gateway_config || {}), write_to_gateway } + { fields: Object.keys(gateway_config || {}), write_to_gateway }, + workspaceId ) // Broadcast update @@ -179,19 +182,20 @@ export async function DELETE( try { const db = getDatabase() const { id } = await params + const workspaceId = auth.user.workspace_id ?? 1; let agent if (isNaN(Number(id))) { - agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(id) as any + agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(id, workspaceId) as any } else { - agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(id)) as any + agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(id), workspaceId) as any } if (!agent) { return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) } - db.prepare('DELETE FROM agents WHERE id = ?').run(agent.id) + db.prepare('DELETE FROM agents WHERE id = ? AND workspace_id = ?').run(agent.id, workspaceId) db_helpers.logActivity( 'agent_deleted', @@ -199,7 +203,8 @@ export async function DELETE( agent.id, auth.user.username, `Deleted agent: ${agent.name}`, - { name: agent.name, role: agent.role } + { name: agent.name, role: agent.role }, + workspaceId ) eventBus.broadcast('agent.deleted', { id: agent.id, name: agent.name }) diff --git a/src/app/api/agents/[id]/soul/route.ts b/src/app/api/agents/[id]/soul/route.ts index f576750..061472a 100644 --- a/src/app/api/agents/[id]/soul/route.ts +++ b/src/app/api/agents/[id]/soul/route.ts @@ -4,7 +4,7 @@ import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from import { join, dirname } from 'path'; import { config } from '@/lib/config'; import { resolveWithin } from '@/lib/paths'; -import { getUserFromRequest, requireRole } from '@/lib/auth'; +import { requireRole } from '@/lib/auth'; import { logger } from '@/lib/logger'; /** @@ -21,13 +21,14 @@ export async function GET( const db = getDatabase(); const resolvedParams = await params; const agentId = resolvedParams.id; + const workspaceId = auth.user.workspace_id ?? 1; // Get agent by ID or name let agent: any; if (isNaN(Number(agentId))) { - agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId); + agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId); } else { - agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId)); + agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId); } if (!agent) { @@ -103,15 +104,16 @@ export async function PUT( const db = getDatabase(); const resolvedParams = await params; const agentId = resolvedParams.id; + const workspaceId = auth.user.workspace_id ?? 1; const body = await request.json(); const { soul_content, template_name } = body; // Get agent by ID or name let agent: any; if (isNaN(Number(agentId))) { - agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId); + agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId); } else { - agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId)); + agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId); } if (!agent) { @@ -170,24 +172,25 @@ export async function PUT( const updateStmt = db.prepare(` UPDATE agents SET soul_content = ?, updated_at = ? - WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? + WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ? `); - updateStmt.run(newSoulContent, now, agentId); + updateStmt.run(newSoulContent, now, agentId, workspaceId); // Log activity db_helpers.logActivity( 'agent_soul_updated', 'agent', agent.id, - getUserFromRequest(request)?.username || 'system', + auth.user.username, `SOUL content updated for agent ${agent.name}${template_name ? ` using template: ${template_name}` : ''}${savedToWorkspace ? ' (synced to workspace)' : ''}`, { template_used: template_name || null, content_length: newSoulContent ? newSoulContent.length : 0, previous_content_length: agent.soul_content ? agent.soul_content.length : 0, saved_to_workspace: savedToWorkspace - } + }, + workspaceId ); return NextResponse.json({ diff --git a/src/app/api/agents/[id]/wake/route.ts b/src/app/api/agents/[id]/wake/route.ts index 6f84be6..8c076df 100644 --- a/src/app/api/agents/[id]/wake/route.ts +++ b/src/app/api/agents/[id]/wake/route.ts @@ -14,14 +14,15 @@ export async function POST( try { const resolvedParams = await params const agentId = resolvedParams.id + const workspaceId = auth.user.workspace_id ?? 1; const body = await request.json().catch(() => ({})) const customMessage = typeof body?.message === 'string' ? body.message.trim() : '' const db = getDatabase() const agent: any = isNaN(Number(agentId)) - ? db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId) - : db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId)) + ? db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId) + : db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId) if (!agent) { return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) @@ -50,7 +51,7 @@ export async function POST( ) } - db_helpers.updateAgentStatus(agent.name, 'idle', 'Manual wake') + db_helpers.updateAgentStatus(agent.name, 'idle', 'Manual wake', workspaceId) return NextResponse.json({ success: true, diff --git a/src/app/api/agents/comms/route.ts b/src/app/api/agents/comms/route.ts index 88ee990..36a2ef4 100644 --- a/src/app/api/agents/comms/route.ts +++ b/src/app/api/agents/comms/route.ts @@ -14,6 +14,7 @@ export async function GET(request: NextRequest) { try { const db = getDatabase() const { searchParams } = new URL(request.url) + const workspaceId = auth.user.workspace_id ?? 1 const limit = parseInt(searchParams.get("limit") || "100") const offset = parseInt(searchParams.get("offset") || "0") @@ -27,11 +28,12 @@ export async function GET(request: NextRequest) { // 1. Get inter-agent messages let messagesQuery = ` SELECT * FROM messages - WHERE to_agent IS NOT NULL + WHERE workspace_id = ? + AND to_agent IS NOT NULL AND from_agent NOT IN (${humanPlaceholders}) AND to_agent NOT IN (${humanPlaceholders}) ` - const messagesParams: any[] = [...humanNames, ...humanNames] + const messagesParams: any[] = [workspaceId, ...humanNames, ...humanNames] if (since) { messagesQuery += " AND created_at > ?" @@ -55,11 +57,12 @@ export async function GET(request: NextRequest) { COUNT(*) as message_count, MAX(created_at) as last_message_at FROM messages - WHERE to_agent IS NOT NULL + WHERE workspace_id = ? + AND to_agent IS NOT NULL AND from_agent NOT IN (${humanPlaceholders}) AND to_agent NOT IN (${humanPlaceholders}) ` - const graphParams: any[] = [...humanNames, ...humanNames] + const graphParams: any[] = [workspaceId, ...humanNames, ...humanNames] if (since) { graphQuery += " AND created_at > ?" graphParams.push(parseInt(since)) @@ -72,29 +75,30 @@ export async function GET(request: NextRequest) { const statsQuery = ` SELECT agent, SUM(sent) as sent, SUM(received) as received FROM ( SELECT from_agent as agent, COUNT(*) as sent, 0 as received - FROM messages WHERE to_agent IS NOT NULL + FROM messages WHERE workspace_id = ? AND to_agent IS NOT NULL AND from_agent NOT IN (${humanPlaceholders}) AND to_agent NOT IN (${humanPlaceholders}) GROUP BY from_agent UNION ALL SELECT to_agent as agent, 0 as sent, COUNT(*) as received - FROM messages WHERE to_agent IS NOT NULL + FROM messages WHERE workspace_id = ? AND to_agent IS NOT NULL AND from_agent NOT IN (${humanPlaceholders}) AND to_agent NOT IN (${humanPlaceholders}) GROUP BY to_agent ) GROUP BY agent ORDER BY (sent + received) DESC ` - const statsParams = [...humanNames, ...humanNames, ...humanNames, ...humanNames] + const statsParams = [workspaceId, ...humanNames, ...humanNames, workspaceId, ...humanNames, ...humanNames] const agentStats = db.prepare(statsQuery).all(...statsParams) // 4. Total count let countQuery = ` SELECT COUNT(*) as total FROM messages - WHERE to_agent IS NOT NULL + WHERE workspace_id = ? + AND to_agent IS NOT NULL AND from_agent NOT IN (${humanPlaceholders}) AND to_agent NOT IN (${humanPlaceholders}) ` - const countParams: any[] = [...humanNames, ...humanNames] + const countParams: any[] = [workspaceId, ...humanNames, ...humanNames] if (since) { countQuery += " AND created_at > ?" countParams.push(parseInt(since)) @@ -107,12 +111,13 @@ export async function GET(request: NextRequest) { let seededCountQuery = ` SELECT COUNT(*) as seeded FROM messages - WHERE to_agent IS NOT NULL + WHERE workspace_id = ? + AND to_agent IS NOT NULL AND from_agent NOT IN (${humanPlaceholders}) AND to_agent NOT IN (${humanPlaceholders}) AND conversation_id LIKE ? ` - const seededParams: any[] = [...humanNames, ...humanNames, "conv-multi-%"] + const seededParams: any[] = [workspaceId, ...humanNames, ...humanNames, "conv-multi-%"] if (since) { seededCountQuery += " AND created_at > ?" seededParams.push(parseInt(since)) diff --git a/src/app/api/agents/message/route.ts b/src/app/api/agents/message/route.ts index 248e9df..09a0179 100644 --- a/src/app/api/agents/message/route.ts +++ b/src/app/api/agents/message/route.ts @@ -19,7 +19,10 @@ export async function POST(request: NextRequest) { const { from, to, message } = result.data const db = getDatabase() - const agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(to) as any + const workspaceId = auth.user.workspace_id ?? 1; + const agent = db + .prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?') + .get(to, workspaceId) as any if (!agent) { return NextResponse.json({ error: 'Recipient agent not found' }, { status: 404 }) } @@ -48,7 +51,8 @@ export async function POST(request: NextRequest) { 'Direct Message', `${from}: ${message.substring(0, 200)}${message.length > 200 ? '...' : ''}`, 'agent', - agent.id + agent.id, + workspaceId ) db_helpers.logActivity( @@ -57,7 +61,8 @@ export async function POST(request: NextRequest) { agent.id, from, `Sent message to ${to}`, - { to } + { to }, + workspaceId ) return NextResponse.json({ success: true }) diff --git a/src/app/api/agents/route.ts b/src/app/api/agents/route.ts index a42d780..3f835f0 100644 --- a/src/app/api/agents/route.ts +++ b/src/app/api/agents/route.ts @@ -4,7 +4,7 @@ import { eventBus } from '@/lib/event-bus'; import { getTemplate, buildAgentConfig } from '@/lib/agent-templates'; import { writeAgentToConfig, enrichAgentConfigFromWorkspace } from '@/lib/agent-sync'; import { logAuditEvent } from '@/lib/db'; -import { getUserFromRequest, requireRole } from '@/lib/auth'; +import { requireRole } from '@/lib/auth'; import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, createAgentSchema } from '@/lib/validation'; @@ -20,6 +20,7 @@ export async function GET(request: NextRequest) { try { const db = getDatabase(); const { searchParams } = new URL(request.url); + const workspaceId = auth.user.workspace_id ?? 1; // Parse query parameters const status = searchParams.get('status'); @@ -28,8 +29,8 @@ export async function GET(request: NextRequest) { const offset = parseInt(searchParams.get('offset') || '0'); // Build dynamic query - let query = 'SELECT * FROM agents WHERE 1=1'; - const params: any[] = []; + let query = 'SELECT * FROM agents WHERE workspace_id = ?'; + const params: any[] = [workspaceId]; if (status) { query += ' AND status = ?'; @@ -61,11 +62,11 @@ export async function GET(request: NextRequest) { SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress, SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as completed FROM tasks - WHERE assigned_to = ? + WHERE assigned_to = ? AND workspace_id = ? `); const agentsWithStats = agentsWithParsedData.map(agent => { - const taskStats = taskCountStmt.get(agent.name) as any; + const taskStats = taskCountStmt.get(agent.name, workspaceId) as any; return { ...agent, @@ -79,8 +80,8 @@ export async function GET(request: NextRequest) { }); // Get total count for pagination - let countQuery = 'SELECT COUNT(*) as total FROM agents WHERE 1=1'; - const countParams: any[] = []; + let countQuery = 'SELECT COUNT(*) as total FROM agents WHERE workspace_id = ?'; + const countParams: any[] = [workspaceId]; if (status) { countQuery += ' AND status = ?'; countParams.push(status); @@ -115,6 +116,7 @@ export async function POST(request: NextRequest) { try { const db = getDatabase(); + const workspaceId = auth.user.workspace_id ?? 1; const validated = await validateBody(request, createAgentSchema); if ('error' in validated) return validated.error; const body = validated.data; @@ -150,7 +152,9 @@ export async function POST(request: NextRequest) { } // Check if agent name already exists - const existingAgent = db.prepare('SELECT id FROM agents WHERE name = ?').get(name); + const existingAgent = db + .prepare('SELECT id FROM agents WHERE name = ? AND workspace_id = ?') + .get(name, workspaceId); if (existingAgent) { return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 }); } @@ -160,8 +164,8 @@ export async function POST(request: NextRequest) { const stmt = db.prepare(` INSERT INTO agents ( name, role, session_key, soul_content, status, - created_at, updated_at, config - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + created_at, updated_at, config, workspace_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); const dbResult = stmt.run( @@ -172,7 +176,8 @@ export async function POST(request: NextRequest) { status, now, now, - JSON.stringify(finalConfig) + JSON.stringify(finalConfig), + workspaceId ); const agentId = dbResult.lastInsertRowid as number; @@ -182,7 +187,7 @@ export async function POST(request: NextRequest) { 'agent_created', 'agent', agentId, - getUserFromRequest(request)?.username || 'system', + auth.user.username, `Created agent: ${name} (${finalRole})${template ? ` from template: ${template}` : ''}`, { name, @@ -190,11 +195,14 @@ export async function POST(request: NextRequest) { status, session_key, template: template || null - } + }, + workspaceId ); // Fetch the created agent - const createdAgent = db.prepare('SELECT * FROM agents WHERE id = ?').get(agentId) as Agent; + const createdAgent = db + .prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?') + .get(agentId, workspaceId) as Agent; const parsedAgent = { ...createdAgent, config: JSON.parse(createdAgent.config || '{}'), @@ -222,7 +230,8 @@ export async function POST(request: NextRequest) { const ipAddress = request.headers.get('x-forwarded-for') || 'unknown'; logAuditEvent({ action: 'agent_gateway_create', - actor: getUserFromRequest(request)?.username || 'system', + actor: auth.user.username, + actor_id: auth.user.id, target_type: 'agent', target_id: agentId as number, detail: { name, openclaw_id: openclawId, template: template || null }, @@ -256,6 +265,7 @@ export async function PUT(request: NextRequest) { try { const db = getDatabase(); + const workspaceId = auth.user.workspace_id ?? 1; const body = await request.json(); // Handle single agent update or bulk updates @@ -263,7 +273,9 @@ export async function PUT(request: NextRequest) { // Single agent update const { name, status, last_activity, config, session_key, soul_content, role } = body; - const agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(name) as Agent; + const agent = db + .prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?') + .get(name, workspaceId) as Agent; if (!agent) { return NextResponse.json({ error: 'Agent not found' }, { status: 404 }); } @@ -309,7 +321,7 @@ export async function PUT(request: NextRequest) { fieldsToUpdate.push('updated_at = ?'); params.push(now); - params.push(name); + params.push(name, workspaceId); if (fieldsToUpdate.length === 1) { // Only updated_at return NextResponse.json({ error: 'No fields to update' }, { status: 400 }); @@ -318,7 +330,7 @@ export async function PUT(request: NextRequest) { const stmt = db.prepare(` UPDATE agents SET ${fieldsToUpdate.join(', ')} - WHERE name = ? + WHERE name = ? AND workspace_id = ? `); stmt.run(...params); @@ -335,7 +347,8 @@ export async function PUT(request: NextRequest) { oldStatus: agent.status, newStatus: status, last_activity - } + }, + workspaceId ); } diff --git a/src/app/api/alerts/route.ts b/src/app/api/alerts/route.ts index c6a3ab9..5ceb09b 100644 --- a/src/app/api/alerts/route.ts +++ b/src/app/api/alerts/route.ts @@ -31,8 +31,11 @@ export async function GET(request: NextRequest) { if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 try { - const rules = db.prepare('SELECT * FROM alert_rules ORDER BY created_at DESC').all() as AlertRule[] + const rules = db + .prepare('SELECT * FROM alert_rules WHERE workspace_id = ? ORDER BY created_at DESC') + .all(workspaceId) as AlertRule[] return NextResponse.json({ rules }) } catch { return NextResponse.json({ rules: [] }) @@ -50,6 +53,7 @@ export async function POST(request: NextRequest) { if (rateCheck) return rateCheck const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 // Check for evaluate action first (peek at body without consuming) let rawBody: any @@ -58,7 +62,7 @@ export async function POST(request: NextRequest) { } if (rawBody.action === 'evaluate') { - return evaluateRules(db) + return evaluateRules(db, workspaceId) } // Validate for create using schema @@ -73,8 +77,8 @@ export async function POST(request: NextRequest) { try { const result = db.prepare(` - INSERT INTO alert_rules (name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO alert_rules (name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes, created_by, workspace_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( name, description || null, @@ -85,7 +89,8 @@ export async function POST(request: NextRequest) { action_type || 'notification', JSON.stringify(action_config || {}), cooldown_minutes || 60, - auth.user?.username || 'system' + auth.user?.username || 'system', + workspaceId ) // Audit log @@ -97,7 +102,9 @@ export async function POST(request: NextRequest) { ) } catch { /* audit table might not exist */ } - const rule = db.prepare('SELECT * FROM alert_rules WHERE id = ?').get(result.lastInsertRowid) as AlertRule + const rule = db + .prepare('SELECT * FROM alert_rules WHERE id = ? AND workspace_id = ?') + .get(result.lastInsertRowid, workspaceId) as AlertRule return NextResponse.json({ rule }, { status: 201 }) } catch (err: any) { return NextResponse.json({ error: err.message || 'Failed to create rule' }, { status: 500 }) @@ -115,12 +122,15 @@ export async function PUT(request: NextRequest) { if (rateCheck) return rateCheck const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 const body = await request.json() const { id, ...updates } = body if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 }) - const existing = db.prepare('SELECT * FROM alert_rules WHERE id = ?').get(id) as AlertRule | undefined + const existing = db + .prepare('SELECT * FROM alert_rules WHERE id = ? AND workspace_id = ?') + .get(id, workspaceId) as AlertRule | undefined if (!existing) return NextResponse.json({ error: 'Rule not found' }, { status: 404 }) const allowed = ['name', 'description', 'enabled', 'entity_type', 'condition_field', 'condition_operator', 'condition_value', 'action_type', 'action_config', 'cooldown_minutes'] @@ -137,11 +147,13 @@ export async function PUT(request: NextRequest) { if (sets.length === 0) return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 }) sets.push('updated_at = (unixepoch())') - values.push(id) + values.push(id, workspaceId) - db.prepare(`UPDATE alert_rules SET ${sets.join(', ')} WHERE id = ?`).run(...values) + db.prepare(`UPDATE alert_rules SET ${sets.join(', ')} WHERE id = ? AND workspace_id = ?`).run(...values) - const updated = db.prepare('SELECT * FROM alert_rules WHERE id = ?').get(id) as AlertRule + const updated = db + .prepare('SELECT * FROM alert_rules WHERE id = ? AND workspace_id = ?') + .get(id, workspaceId) as AlertRule return NextResponse.json({ rule: updated }) } @@ -156,12 +168,13 @@ export async function DELETE(request: NextRequest) { if (rateCheck) return rateCheck const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 const body = await request.json() const { id } = body if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 }) - const result = db.prepare('DELETE FROM alert_rules WHERE id = ?').run(id) + const result = db.prepare('DELETE FROM alert_rules WHERE id = ? AND workspace_id = ?').run(id, workspaceId) try { db.prepare('INSERT INTO audit_log (action, actor, detail) VALUES (?, ?, ?)').run( @@ -177,10 +190,10 @@ export async function DELETE(request: NextRequest) { /** * Evaluate all enabled alert rules against current data */ -function evaluateRules(db: ReturnType) { +function evaluateRules(db: ReturnType, workspaceId: number) { let rules: AlertRule[] try { - rules = db.prepare('SELECT * FROM alert_rules WHERE enabled = 1').all() as AlertRule[] + rules = db.prepare('SELECT * FROM alert_rules WHERE enabled = 1 AND workspace_id = ?').all(workspaceId) as AlertRule[] } catch { return NextResponse.json({ evaluated: 0, triggered: 0, results: [] }) } @@ -195,7 +208,7 @@ function evaluateRules(db: ReturnType) { continue } - const triggered = evaluateRule(db, rule, now) + const triggered = evaluateRule(db, rule, now, workspaceId) results.push({ rule_id: rule.id, rule_name: rule.name, triggered, reason: triggered ? 'Condition met' : 'Condition not met' }) if (triggered) { @@ -207,9 +220,9 @@ function evaluateRules(db: ReturnType) { const config = JSON.parse(rule.action_config || '{}') const recipient = config.recipient || 'system' db.prepare(` - INSERT INTO notifications (recipient, type, title, message, source_type, source_id) - VALUES (?, 'alert', ?, ?, 'alert_rule', ?) - `).run(recipient, `Alert: ${rule.name}`, rule.description || `Rule "${rule.name}" triggered`, rule.id) + INSERT INTO notifications (recipient, type, title, message, source_type, source_id, workspace_id) + VALUES (?, 'alert', ?, ?, 'alert_rule', ?, ?) + `).run(recipient, `Alert: ${rule.name}`, rule.description || `Rule "${rule.name}" triggered`, rule.id, workspaceId) } catch { /* notification creation failed */ } } } @@ -218,13 +231,13 @@ function evaluateRules(db: ReturnType) { return NextResponse.json({ evaluated: rules.length, triggered, results }) } -function evaluateRule(db: ReturnType, rule: AlertRule, now: number): boolean { +function evaluateRule(db: ReturnType, rule: AlertRule, now: number, workspaceId: number): boolean { try { switch (rule.entity_type) { - case 'agent': return evaluateAgentRule(db, rule, now) - case 'task': return evaluateTaskRule(db, rule, now) - case 'session': return evaluateSessionRule(db, rule, now) - case 'activity': return evaluateActivityRule(db, rule, now) + case 'agent': return evaluateAgentRule(db, rule, now, workspaceId) + case 'task': return evaluateTaskRule(db, rule, now, workspaceId) + case 'session': return evaluateSessionRule(db, rule, now, workspaceId) + case 'activity': return evaluateActivityRule(db, rule, now, workspaceId) default: return false } } catch { @@ -232,61 +245,61 @@ function evaluateRule(db: ReturnType, rule: AlertRule, now: } } -function evaluateAgentRule(db: ReturnType, rule: AlertRule, now: number): boolean { +function evaluateAgentRule(db: ReturnType, rule: AlertRule, now: number, workspaceId: number): boolean { const { condition_field, condition_operator, condition_value } = rule if (condition_operator === 'count_above' || condition_operator === 'count_below') { - const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE ${safeColumn('agents', condition_field)} = ?`).get(condition_value) as any)?.c || 0 + const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE workspace_id = ? AND ${safeColumn('agents', condition_field)} = ?`).get(workspaceId, condition_value) as any)?.c || 0 return condition_operator === 'count_above' ? count > parseInt(condition_value) : count < parseInt(condition_value) } if (condition_operator === 'age_minutes_above') { // Check agents where field value is older than N minutes (e.g., last_seen) const threshold = now - parseInt(condition_value) * 60 - const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND ${safeColumn('agents', condition_field)} < ?`).get(threshold) as any)?.c || 0 + const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE workspace_id = ? AND status != 'offline' AND ${safeColumn('agents', condition_field)} < ?`).get(workspaceId, threshold) as any)?.c || 0 return count > 0 } - const agents = db.prepare(`SELECT ${safeColumn('agents', condition_field)} as val FROM agents WHERE status != 'offline'`).all() as any[] + const agents = db.prepare(`SELECT ${safeColumn('agents', condition_field)} as val FROM agents WHERE workspace_id = ? AND status != 'offline'`).all(workspaceId) as any[] return agents.some(a => compareValue(a.val, condition_operator, condition_value)) } -function evaluateTaskRule(db: ReturnType, rule: AlertRule, _now: number): boolean { +function evaluateTaskRule(db: ReturnType, rule: AlertRule, _now: number, workspaceId: number): boolean { const { condition_field, condition_operator, condition_value } = rule if (condition_operator === 'count_above') { - const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks WHERE ${safeColumn('tasks', condition_field)} = ?`).get(condition_value) as any)?.c || 0 + const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks WHERE workspace_id = ? AND ${safeColumn('tasks', condition_field)} = ?`).get(workspaceId, condition_value) as any)?.c || 0 return count > parseInt(condition_value) } if (condition_operator === 'count_below') { - const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks`).get() as any)?.c || 0 + const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks WHERE workspace_id = ?`).get(workspaceId) as any)?.c || 0 return count < parseInt(condition_value) } - const tasks = db.prepare(`SELECT ${safeColumn('tasks', condition_field)} as val FROM tasks`).all() as any[] + const tasks = db.prepare(`SELECT ${safeColumn('tasks', condition_field)} as val FROM tasks WHERE workspace_id = ?`).all(workspaceId) as any[] return tasks.some(t => compareValue(t.val, condition_operator, condition_value)) } -function evaluateSessionRule(db: ReturnType, rule: AlertRule, _now: number): boolean { +function evaluateSessionRule(db: ReturnType, rule: AlertRule, _now: number, workspaceId: number): boolean { // Session data comes from the gateway, not the DB, so we check the agents table for session info const { condition_operator, condition_value } = rule if (condition_operator === 'count_above') { - const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE status = 'busy'`).get() as any)?.c || 0 + const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE workspace_id = ? AND status = 'busy'`).get(workspaceId) as any)?.c || 0 return count > parseInt(condition_value) } return false } -function evaluateActivityRule(db: ReturnType, rule: AlertRule, now: number): boolean { +function evaluateActivityRule(db: ReturnType, rule: AlertRule, now: number, workspaceId: number): boolean { const { condition_field, condition_operator, condition_value } = rule if (condition_operator === 'count_above') { // Count activities in the last hour const hourAgo = now - 3600 - const count = (db.prepare(`SELECT COUNT(*) as c FROM activities WHERE created_at > ? AND ${safeColumn('activities', condition_field)} = ?`).get(hourAgo, condition_value) as any)?.c || 0 + const count = (db.prepare(`SELECT COUNT(*) as c FROM activities WHERE workspace_id = ? AND created_at > ? AND ${safeColumn('activities', condition_field)} = ?`).get(workspaceId, hourAgo, condition_value) as any)?.c || 0 return count > parseInt(condition_value) } diff --git a/src/app/api/auth/google/route.ts b/src/app/api/auth/google/route.ts index a683074..6b6fd44 100644 --- a/src/app/api/auth/google/route.ts +++ b/src/app/api/auth/google/route.ts @@ -38,7 +38,7 @@ export async function POST(request: Request) { const avatar = profile.picture ? String(profile.picture) : null const row = db.prepare(` - SELECT id, username, display_name, role, provider, email, avatar_url, is_approved, created_at, updated_at, last_login_at + SELECT id, username, display_name, role, provider, email, avatar_url, is_approved, created_at, updated_at, last_login_at, workspace_id FROM users WHERE (provider = 'google' AND provider_user_id = ?) OR lower(email) = ? ORDER BY id ASC @@ -76,7 +76,7 @@ export async function POST(request: Request) { WHERE id = ? `).run(sub, email, avatar, row.id) - const { token, expiresAt } = createSession(row.id, ipAddress, userAgent) + const { token, expiresAt } = createSession(row.id, ipAddress, userAgent, row.workspace_id ?? 1) logAuditEvent({ action: 'login_google', actor: row.username, actor_id: row.id, ip_address: ipAddress, user_agent: userAgent }) @@ -89,6 +89,7 @@ export async function POST(request: Request) { provider: 'google', email, avatar_url: avatar, + workspace_id: row.workspace_id ?? 1, }, }) diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 57a5274..a3979d4 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -25,7 +25,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) } - const { token, expiresAt } = createSession(user.id, ipAddress, userAgent) + const { token, expiresAt } = createSession(user.id, ipAddress, userAgent, user.workspace_id) logAuditEvent({ action: 'login', actor: user.username, actor_id: user.id, ip_address: ipAddress, user_agent: userAgent }) @@ -38,6 +38,7 @@ export async function POST(request: Request) { provider: user.provider || 'local', email: user.email || null, avatar_url: user.avatar_url || null, + workspace_id: user.workspace_id ?? 1, }, }) diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 2017836..d650fdd 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { getUserFromRequest, updateUser , requireRole } from '@/lib/auth' +import { getUserFromRequest, updateUser, requireRole } from '@/lib/auth' import { logAuditEvent } from '@/lib/db' import { verifyPassword } from '@/lib/password' import { logger } from '@/lib/logger' @@ -23,6 +23,7 @@ export async function GET(request: Request) { provider: user.provider || 'local', email: user.email || null, avatar_url: user.avatar_url || null, + workspace_id: user.workspace_id ?? 1, }, }) } @@ -103,6 +104,7 @@ export async function PATCH(request: NextRequest) { provider: updated.provider || 'local', email: updated.email || null, avatar_url: updated.avatar_url || null, + workspace_id: updated.workspace_id ?? 1, }, }) } catch (error) { diff --git a/src/app/api/auth/users/route.ts b/src/app/api/auth/users/route.ts index d66f7ca..c2e7467 100644 --- a/src/app/api/auth/users/route.ts +++ b/src/app/api/auth/users/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { getUserFromRequest, getAllUsers, createUser, updateUser, deleteUser , requireRole } from '@/lib/auth' +import { getUserFromRequest, getAllUsers, createUser, updateUser, deleteUser, getUserById, requireRole } from '@/lib/auth' import { logAuditEvent } from '@/lib/db' import { validateBody, createUserSchema } from '@/lib/validation' import { mutationLimiter } from '@/lib/rate-limit' @@ -18,7 +18,8 @@ export async function GET(request: NextRequest) { } const users = getAllUsers() - return NextResponse.json({ users }) + const workspaceId = user.workspace_id ?? 1 + return NextResponse.json({ users: users.filter((u) => (u.workspace_id ?? 1) === workspaceId) }) } /** @@ -38,7 +39,12 @@ export async function POST(request: NextRequest) { if ('error' in result) return result.error const { username, password, display_name, role, provider, email } = result.data - const newUser = createUser(username, password, display_name || username, role, { provider, email: email || null }) + const workspaceId = currentUser.workspace_id ?? 1 + const newUser = createUser(username, password, display_name || username, role, { + provider, + email: email || null, + workspace_id: workspaceId, + }) const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown' logAuditEvent({ @@ -57,6 +63,7 @@ export async function POST(request: NextRequest) { email: newUser.email || null, avatar_url: newUser.avatar_url || null, is_approved: newUser.is_approved ?? 1, + workspace_id: newUser.workspace_id ?? 1, } }, { status: 201 }) } catch (error: any) { @@ -79,8 +86,9 @@ export async function PUT(request: NextRequest) { try { const { id, display_name, role, password, is_approved, email, avatar_url } = await request.json() + const userId = parseInt(String(id)) - if (!id) { + if (!id || Number.isNaN(userId)) { return NextResponse.json({ error: 'User ID is required' }, { status: 400 }) } @@ -89,11 +97,17 @@ export async function PUT(request: NextRequest) { } // Prevent demoting yourself - if (id === currentUser.id && role && role !== currentUser.role) { + if (userId === currentUser.id && role && role !== currentUser.role) { return NextResponse.json({ error: 'Cannot change your own role' }, { status: 400 }) } - const updated = updateUser(id, { display_name, role, password: password || undefined, is_approved, email, avatar_url }) + const workspaceId = currentUser.workspace_id ?? 1 + const existing = getUserById(userId) + if (!existing || (existing.workspace_id ?? 1) !== workspaceId) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + const updated = updateUser(userId, { display_name, role, password: password || undefined, is_approved, email, avatar_url }) if (!updated) { return NextResponse.json({ error: 'User not found' }, { status: 404 }) } @@ -101,7 +115,7 @@ export async function PUT(request: NextRequest) { const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown' logAuditEvent({ action: 'user_update', actor: currentUser.username, actor_id: currentUser.id, - target_type: 'user', target_id: id, + target_type: 'user', target_id: userId, detail: { display_name, role, password_changed: !!password, is_approved }, ip_address: ipAddress, }) @@ -115,6 +129,7 @@ export async function PUT(request: NextRequest) { email: updated.email || null, avatar_url: updated.avatar_url || null, is_approved: updated.is_approved ?? 1, + workspace_id: updated.workspace_id ?? 1, } }) } catch (error) { @@ -147,6 +162,12 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 400 }) } + const workspaceId = currentUser.workspace_id ?? 1 + const existing = getUserById(userId) + if (!existing || (existing.workspace_id ?? 1) !== workspaceId) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + const deleted = deleteUser(userId) if (!deleted) { return NextResponse.json({ error: 'User not found' }, { status: 404 }) diff --git a/src/app/api/chat/conversations/route.ts b/src/app/api/chat/conversations/route.ts index 8986171..21ab44a 100644 --- a/src/app/api/chat/conversations/route.ts +++ b/src/app/api/chat/conversations/route.ts @@ -14,6 +14,7 @@ export async function GET(request: NextRequest) { try { const db = getDatabase() const { searchParams } = new URL(request.url) + const workspaceId = auth.user.workspace_id ?? 1 const agent = searchParams.get('agent') const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200) @@ -32,12 +33,12 @@ export async function GET(request: NextRequest) { COUNT(DISTINCT m.from_agent) + COUNT(DISTINCT CASE WHEN m.to_agent IS NOT NULL THEN m.to_agent END) as participant_count, SUM(CASE WHEN m.to_agent = ? AND m.read_at IS NULL THEN 1 ELSE 0 END) as unread_count FROM messages m - WHERE m.from_agent = ? OR m.to_agent = ? OR m.to_agent IS NULL + WHERE m.workspace_id = ? AND (m.from_agent = ? OR m.to_agent = ? OR m.to_agent IS NULL) GROUP BY m.conversation_id ORDER BY last_message_at DESC LIMIT ? OFFSET ? ` - params.push(agent, agent, agent, limit, offset) + params.push(agent, workspaceId, agent, agent, limit, offset) } else { query = ` SELECT @@ -47,11 +48,12 @@ export async function GET(request: NextRequest) { COUNT(DISTINCT m.from_agent) + COUNT(DISTINCT CASE WHEN m.to_agent IS NOT NULL THEN m.to_agent END) as participant_count, 0 as unread_count FROM messages m + WHERE m.workspace_id = ? GROUP BY m.conversation_id ORDER BY last_message_at DESC LIMIT ? OFFSET ? ` - params.push(limit, offset) + params.push(workspaceId, limit, offset) } const conversations = db.prepare(query).all(...params) as any[] @@ -59,13 +61,13 @@ export async function GET(request: NextRequest) { // Prepare last message statement once (avoids N+1) const lastMsgStmt = db.prepare(` SELECT * FROM messages - WHERE conversation_id = ? + WHERE conversation_id = ? AND workspace_id = ? ORDER BY created_at DESC LIMIT 1 `); const withLastMessage = conversations.map((conv) => { - const lastMsg = lastMsgStmt.get(conv.conversation_id) as any; + const lastMsg = lastMsgStmt.get(conv.conversation_id, workspaceId) as any; return { ...conv, @@ -80,16 +82,16 @@ export async function GET(request: NextRequest) { // Get total count for pagination let countQuery: string - const countParams: any[] = [] + const countParams: any[] = [workspaceId] if (agent) { countQuery = ` SELECT COUNT(DISTINCT m.conversation_id) as total FROM messages m - WHERE m.from_agent = ? OR m.to_agent = ? OR m.to_agent IS NULL + WHERE m.workspace_id = ? AND (m.from_agent = ? OR m.to_agent = ? OR m.to_agent IS NULL) ` countParams.push(agent, agent) } else { - countQuery = 'SELECT COUNT(DISTINCT conversation_id) as total FROM messages' + countQuery = 'SELECT COUNT(DISTINCT conversation_id) as total FROM messages WHERE workspace_id = ?' } const countRow = db.prepare(countQuery).get(...countParams) as { total: number } diff --git a/src/app/api/chat/messages/[id]/route.ts b/src/app/api/chat/messages/[id]/route.ts index f8a0795..f8e290c 100644 --- a/src/app/api/chat/messages/[id]/route.ts +++ b/src/app/api/chat/messages/[id]/route.ts @@ -16,8 +16,11 @@ export async function GET( try { const db = getDatabase() const { id } = await params + const workspaceId = auth.user.workspace_id ?? 1 - const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(parseInt(id)) as Message | undefined + const message = db + .prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?') + .get(parseInt(id), workspaceId) as Message | undefined if (!message) { return NextResponse.json({ error: 'Message not found' }, { status: 404 }) @@ -48,9 +51,12 @@ export async function PATCH( try { const db = getDatabase() const { id } = await params + const workspaceId = auth.user.workspace_id ?? 1 const body = await request.json() - const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(parseInt(id)) as Message | undefined + const message = db + .prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?') + .get(parseInt(id), workspaceId) as Message | undefined if (!message) { return NextResponse.json({ error: 'Message not found' }, { status: 404 }) @@ -58,10 +64,12 @@ export async function PATCH( if (body.read) { const now = Math.floor(Date.now() / 1000) - db.prepare('UPDATE messages SET read_at = ? WHERE id = ?').run(now, parseInt(id)) + db.prepare('UPDATE messages SET read_at = ? WHERE id = ? AND workspace_id = ?').run(now, parseInt(id), workspaceId) } - const updated = db.prepare('SELECT * FROM messages WHERE id = ?').get(parseInt(id)) as Message + const updated = db + .prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?') + .get(parseInt(id), workspaceId) as Message return NextResponse.json({ message: { diff --git a/src/app/api/chat/messages/route.ts b/src/app/api/chat/messages/route.ts index b4b0fed..545e918 100644 --- a/src/app/api/chat/messages/route.ts +++ b/src/app/api/chat/messages/route.ts @@ -33,6 +33,7 @@ function parseGatewayJson(raw: string): any | null { function createChatReply( db: ReturnType, + workspaceId: number, conversationId: string, fromAgent: string, toAgent: string, @@ -42,8 +43,8 @@ function createChatReply( ) { const replyInsert = db .prepare(` - INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata, workspace_id) + VALUES (?, ?, ?, ?, ?, ?, ?) `) .run( conversationId, @@ -51,12 +52,13 @@ function createChatReply( toAgent, content, messageType, - metadata ? JSON.stringify(metadata) : null + metadata ? JSON.stringify(metadata) : null, + workspaceId ) const row = db - .prepare('SELECT * FROM messages WHERE id = ?') - .get(replyInsert.lastInsertRowid) as Message + .prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?') + .get(replyInsert.lastInsertRowid, workspaceId) as Message eventBus.broadcast('chat.message', { ...row, @@ -102,6 +104,7 @@ export async function GET(request: NextRequest) { try { const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 const { searchParams } = new URL(request.url) const conversation_id = searchParams.get('conversation_id') @@ -111,8 +114,8 @@ export async function GET(request: NextRequest) { const offset = parseInt(searchParams.get('offset') || '0') const since = searchParams.get('since') - let query = 'SELECT * FROM messages WHERE 1=1' - const params: any[] = [] + let query = 'SELECT * FROM messages WHERE workspace_id = ?' + const params: any[] = [workspaceId] if (conversation_id) { query += ' AND conversation_id = ?' @@ -145,8 +148,8 @@ export async function GET(request: NextRequest) { })) // Get total count for pagination - let countQuery = 'SELECT COUNT(*) as total FROM messages WHERE 1=1' - const countParams: any[] = [] + let countQuery = 'SELECT COUNT(*) as total FROM messages WHERE workspace_id = ?' + const countParams: any[] = [workspaceId] if (conversation_id) { countQuery += ' AND conversation_id = ?' countParams.push(conversation_id) @@ -182,6 +185,7 @@ export async function POST(request: NextRequest) { try { const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 const body = await request.json() const from = (body.from || '').trim() @@ -199,8 +203,8 @@ export async function POST(request: NextRequest) { } const stmt = db.prepare(` - INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata, workspace_id) + VALUES (?, ?, ?, ?, ?, ?, ?) `) const result = stmt.run( @@ -209,7 +213,8 @@ export async function POST(request: NextRequest) { to, content, message_type, - metadata ? JSON.stringify(metadata) : null + metadata ? JSON.stringify(metadata) : null, + workspaceId ) const messageId = result.lastInsertRowid as number @@ -223,7 +228,8 @@ export async function POST(request: NextRequest) { messageId, from, `Sent ${message_type} message${to ? ` to ${to}` : ' (broadcast)'}`, - { conversation_id, to, message_type } + { conversation_id, to, message_type }, + workspaceId ) // Create notification for recipient if specified @@ -234,7 +240,8 @@ export async function POST(request: NextRequest) { `Message from ${from}`, content.substring(0, 200) + (content.length > 200 ? '...' : ''), 'message', - messageId + messageId, + workspaceId ) // Optionally forward to agent via gateway @@ -242,8 +249,8 @@ export async function POST(request: NextRequest) { forwardInfo = { attempted: true, delivered: false } const agent = db - .prepare('SELECT * FROM agents WHERE lower(name) = lower(?)') - .get(to) as any + .prepare('SELECT * FROM agents WHERE lower(name) = lower(?) AND workspace_id = ?') + .get(to, workspaceId) as any let sessionKey: string | null = agent?.session_key || null @@ -280,6 +287,7 @@ export async function POST(request: NextRequest) { try { createChatReply( db, + workspaceId, conversation_id, COORDINATOR_AGENT, from, @@ -340,6 +348,7 @@ export async function POST(request: NextRequest) { try { createChatReply( db, + workspaceId, conversation_id, COORDINATOR_AGENT, from, @@ -363,6 +372,7 @@ export async function POST(request: NextRequest) { try { createChatReply( db, + workspaceId, conversation_id, COORDINATOR_AGENT, from, @@ -401,6 +411,7 @@ export async function POST(request: NextRequest) { : 'Unknown runtime error' createChatReply( db, + workspaceId, conversation_id, COORDINATOR_AGENT, from, @@ -411,6 +422,7 @@ export async function POST(request: NextRequest) { } else if (waitStatus === 'timeout') { createChatReply( db, + workspaceId, conversation_id, COORDINATOR_AGENT, from, @@ -423,6 +435,7 @@ export async function POST(request: NextRequest) { if (replyText) { createChatReply( db, + workspaceId, conversation_id, COORDINATOR_AGENT, from, @@ -433,6 +446,7 @@ export async function POST(request: NextRequest) { } else { createChatReply( db, + workspaceId, conversation_id, COORDINATOR_AGENT, from, @@ -453,6 +467,7 @@ export async function POST(request: NextRequest) { createChatReply( db, + workspaceId, conversation_id, COORDINATOR_AGENT, from, @@ -467,7 +482,7 @@ export async function POST(request: NextRequest) { } } - const created = db.prepare('SELECT * FROM messages WHERE id = ?').get(messageId) as Message + const created = db.prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?').get(messageId, workspaceId) as Message const parsedMessage = { ...created, metadata: created.metadata ? JSON.parse(created.metadata) : null diff --git a/src/app/api/connect/route.ts b/src/app/api/connect/route.ts index 95ab99a..c5d690e 100644 --- a/src/app/api/connect/route.ts +++ b/src/app/api/connect/route.ts @@ -21,22 +21,23 @@ export async function POST(request: NextRequest) { const { tool_name, tool_version, agent_name, agent_role, metadata } = validation.data const db = getDatabase() const now = Math.floor(Date.now() / 1000) + const workspaceId = auth.user.workspace_id ?? 1; // Find or create agent - let agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agent_name) as any + let agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agent_name, workspaceId) as any if (!agent) { const result = db.prepare( - `INSERT INTO agents (name, role, status, created_at, updated_at) - VALUES (?, ?, 'online', ?, ?)` - ).run(agent_name, agent_role || 'cli', now, now) + `INSERT INTO agents (name, role, status, created_at, updated_at, workspace_id) + VALUES (?, ?, 'online', ?, ?, ?)` + ).run(agent_name, agent_role || 'cli', now, now, workspaceId) agent = { id: result.lastInsertRowid, name: agent_name } db_helpers.logActivity('agent_created', 'agent', agent.id as number, 'system', - `Auto-created agent "${agent_name}" via direct CLI connection`) + `Auto-created agent "${agent_name}" via direct CLI connection`, undefined, workspaceId) eventBus.broadcast('agent.created', { id: agent.id, name: agent_name }) } else { // Set agent online - db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ?') - .run('online', now, agent.id) + db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ? AND workspace_id = ?') + .run('online', now, agent.id, workspaceId) eventBus.broadcast('agent.status_changed', { id: agent.id, name: agent.name, status: 'online' }) } @@ -48,12 +49,12 @@ export async function POST(request: NextRequest) { // Create new connection const connectionId = randomUUID() db.prepare( - `INSERT INTO direct_connections (agent_id, tool_name, tool_version, connection_id, status, last_heartbeat, metadata, created_at, updated_at) - VALUES (?, ?, ?, ?, 'connected', ?, ?, ?, ?)` - ).run(agent.id, tool_name, tool_version || null, connectionId, now, metadata ? JSON.stringify(metadata) : null, now, now) + `INSERT INTO direct_connections (agent_id, tool_name, tool_version, connection_id, status, last_heartbeat, metadata, created_at, updated_at, workspace_id) + VALUES (?, ?, ?, ?, 'connected', ?, ?, ?, ?, ?)` + ).run(agent.id, tool_name, tool_version || null, connectionId, now, metadata ? JSON.stringify(metadata) : null, now, now, workspaceId) db_helpers.logActivity('connection_created', 'agent', agent.id as number, agent_name, - `CLI connection established via ${tool_name}${tool_version ? ` v${tool_version}` : ''}`) + `CLI connection established via ${tool_name}${tool_version ? ` v${tool_version}` : ''}`, undefined, workspaceId) eventBus.broadcast('connection.created', { connection_id: connectionId, @@ -81,12 +82,14 @@ export async function GET(request: NextRequest) { if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1; const connections = db.prepare(` SELECT dc.*, a.name as agent_name, a.status as agent_status, a.role as agent_role FROM direct_connections dc JOIN agents a ON dc.agent_id = a.id + WHERE a.workspace_id = ? ORDER BY dc.created_at DESC - `).all() + `).all(workspaceId) return NextResponse.json({ connections }) } @@ -112,8 +115,14 @@ export async function DELETE(request: NextRequest) { const db = getDatabase() const now = Math.floor(Date.now() / 1000) + const workspaceId = auth.user.workspace_id ?? 1; - const conn = db.prepare('SELECT * FROM direct_connections WHERE connection_id = ?').get(connection_id) as any + const conn = db.prepare(` + SELECT dc.* + FROM direct_connections dc + JOIN agents a ON a.id = dc.agent_id + WHERE dc.connection_id = ? AND a.workspace_id = ? + `).get(connection_id, workspaceId) as any if (!conn) { return NextResponse.json({ error: 'Connection not found' }, { status: 404 }) } @@ -126,13 +135,13 @@ export async function DELETE(request: NextRequest) { 'SELECT COUNT(*) as count FROM direct_connections WHERE agent_id = ? AND status = ? AND connection_id != ?' ).get(conn.agent_id, 'connected', connection_id) as any if (!otherActive?.count) { - db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ?') - .run('offline', now, conn.agent_id) + db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ? AND workspace_id = ?') + .run('offline', now, conn.agent_id, workspaceId) } - const agent = db.prepare('SELECT name FROM agents WHERE id = ?').get(conn.agent_id) as any + const agent = db.prepare('SELECT name FROM agents WHERE id = ? AND workspace_id = ?').get(conn.agent_id, workspaceId) as any db_helpers.logActivity('connection_disconnected', 'agent', conn.agent_id, agent?.name || 'unknown', - `CLI connection disconnected (${conn.tool_name})`) + `CLI connection disconnected (${conn.tool_name})`, undefined, workspaceId) eventBus.broadcast('connection.disconnected', { connection_id, diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts index 1c39493..e25ccd4 100644 --- a/src/app/api/cron/route.ts +++ b/src/app/api/cron/route.ts @@ -308,7 +308,7 @@ export async function POST(request: NextRequest) { } if (action === 'add') { - const { schedule, command, description } = body + const { schedule, command, model, description } = body const name = jobName || body.name if (!schedule || !command || !name) { return NextResponse.json( @@ -336,6 +336,7 @@ export async function POST(request: NextRequest) { payload: { kind: 'agentTurn', message: command, + ...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}), }, delivery: { mode: 'none', diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index a495ee7..ff137df 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -28,6 +28,7 @@ export async function GET(request: NextRequest) { } const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 const conditions: string[] = [] const params: any[] = [] @@ -58,13 +59,19 @@ export async function GET(request: NextRequest) { break } case 'tasks': { - rows = db.prepare(`SELECT * FROM tasks ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit) + conditions.unshift('workspace_id = ?') + params.unshift(workspaceId) + const scopedWhere = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + rows = db.prepare(`SELECT * FROM tasks ${scopedWhere} ORDER BY created_at DESC LIMIT ?`).all(...params, limit) headers = ['id', 'title', 'description', 'status', 'priority', 'assigned_to', 'created_by', 'created_at', 'updated_at', 'due_date', 'estimated_hours', 'actual_hours', 'tags'] filename = 'tasks' break } case 'activities': { - rows = db.prepare(`SELECT * FROM activities ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit) + conditions.unshift('workspace_id = ?') + params.unshift(workspaceId) + const scopedWhere = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + rows = db.prepare(`SELECT * FROM activities ${scopedWhere} ORDER BY created_at DESC LIMIT ?`).all(...params, limit) headers = ['id', 'type', 'entity_type', 'entity_id', 'actor', 'description', 'data', 'created_at'] filename = 'activities' break diff --git a/src/app/api/github/route.ts b/src/app/api/github/route.ts index 33d5ec0..8f6bbea 100644 --- a/src/app/api/github/route.ts +++ b/src/app/api/github/route.ts @@ -76,13 +76,13 @@ export async function POST(request: NextRequest) { try { switch (action) { case 'sync': - return await handleSync(body, auth.user.username) + return await handleSync(body, auth.user.username, auth.user.workspace_id ?? 1) case 'comment': - return await handleComment(body, auth.user.username) + return await handleComment(body, auth.user.username, auth.user.workspace_id ?? 1) case 'close': - return await handleClose(body, auth.user.username) + return await handleClose(body, auth.user.username, auth.user.workspace_id ?? 1) case 'status': - return handleStatus() + return handleStatus(auth.user.workspace_id ?? 1) default: return NextResponse.json({ error: 'Unknown action' }, { status: 400 }) } @@ -96,7 +96,8 @@ export async function POST(request: NextRequest) { async function handleSync( body: { repo?: string; labels?: string; state?: 'open' | 'closed' | 'all'; assignAgent?: string }, - actor: string + actor: string, + workspaceId: number ) { const repo = body.repo || process.env.GITHUB_DEFAULT_REPO if (!repo) { @@ -128,7 +129,8 @@ async function handleSync( SELECT id FROM tasks WHERE json_extract(metadata, '$.github_repo') = ? AND json_extract(metadata, '$.github_issue_number') = ? - `).get(repo, issue.number) as { id: number } | undefined + AND workspace_id = ? + `).get(repo, issue.number, workspaceId) as { id: number } | undefined if (existing) { skipped++ @@ -151,8 +153,8 @@ async function handleSync( const stmt = db.prepare(` INSERT INTO tasks ( title, description, status, priority, assigned_to, created_by, - created_at, updated_at, tags, metadata - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + created_at, updated_at, tags, metadata, workspace_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) const dbResult = stmt.run( @@ -165,7 +167,8 @@ async function handleSync( now, now, JSON.stringify(tags), - JSON.stringify(metadata) + JSON.stringify(metadata), + workspaceId ) const taskId = dbResult.lastInsertRowid as number @@ -176,10 +179,11 @@ async function handleSync( taskId, actor, `Imported from GitHub: ${repo}#${issue.number}`, - { github_issue: issue.number, github_repo: repo } + { github_issue: issue.number, github_repo: repo }, + workspaceId ) - const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task + const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId) as Task const parsedTask = { ...createdTask, tags: JSON.parse(createdTask.tags || '[]'), @@ -196,16 +200,33 @@ async function handleSync( } // Log sync to github_syncs table - db.prepare(` - INSERT INTO github_syncs (repo, last_synced_at, issue_count, sync_direction, status, error) - VALUES (?, ?, ?, 'inbound', ?, ?) - `).run( - repo, - now, - imported, - errors > 0 ? 'partial' : 'success', - errors > 0 ? `${errors} issues failed to import` : null - ) + const syncTableHasWorkspace = db + .prepare("SELECT 1 as ok FROM pragma_table_info('github_syncs') WHERE name = 'workspace_id'") + .get() as { ok?: number } | undefined + if (syncTableHasWorkspace?.ok) { + db.prepare(` + INSERT INTO github_syncs (repo, last_synced_at, issue_count, sync_direction, status, error, workspace_id) + VALUES (?, ?, ?, 'inbound', ?, ?, ?) + `).run( + repo, + now, + imported, + errors > 0 ? 'partial' : 'success', + errors > 0 ? `${errors} issues failed to import` : null, + workspaceId + ) + } else { + db.prepare(` + INSERT INTO github_syncs (repo, last_synced_at, issue_count, sync_direction, status, error) + VALUES (?, ?, ?, 'inbound', ?, ?) + `).run( + repo, + now, + imported, + errors > 0 ? 'partial' : 'success', + errors > 0 ? `${errors} issues failed to import` : null + ) + } eventBus.broadcast('github.synced', { repo, @@ -227,7 +248,8 @@ async function handleSync( async function handleComment( body: { repo?: string; issueNumber?: number; body?: string }, - actor: string + actor: string, + workspaceId: number ) { if (!body.repo || !body.issueNumber || !body.body) { return NextResponse.json( @@ -244,7 +266,8 @@ async function handleComment( 0, actor, `Commented on ${body.repo}#${body.issueNumber}`, - { github_repo: body.repo, github_issue: body.issueNumber } + { github_repo: body.repo, github_issue: body.issueNumber }, + workspaceId ) return NextResponse.json({ ok: true }) @@ -254,7 +277,8 @@ async function handleComment( async function handleClose( body: { repo?: string; issueNumber?: number; comment?: string }, - actor: string + actor: string, + workspaceId: number ) { if (!body.repo || !body.issueNumber) { return NextResponse.json( @@ -279,7 +303,8 @@ async function handleClose( updated_at = ? WHERE json_extract(metadata, '$.github_repo') = ? AND json_extract(metadata, '$.github_issue_number') = ? - `).run(now, body.repo, body.issueNumber) + AND workspace_id = ? + `).run(now, body.repo, body.issueNumber, workspaceId) db_helpers.logActivity( 'github_close', @@ -287,7 +312,8 @@ async function handleClose( 0, actor, `Closed GitHub issue ${body.repo}#${body.issueNumber}`, - { github_repo: body.repo, github_issue: body.issueNumber } + { github_repo: body.repo, github_issue: body.issueNumber }, + workspaceId ) return NextResponse.json({ ok: true }) @@ -295,13 +321,17 @@ async function handleClose( // ── Status: return recent sync history ────────────────────────── -function handleStatus() { +function handleStatus(workspaceId: number) { const db = getDatabase() + const tableHasWorkspace = db + .prepare("SELECT 1 as ok FROM pragma_table_info('github_syncs') WHERE name = 'workspace_id'") + .get() as { ok?: number } | undefined const syncs = db.prepare(` SELECT * FROM github_syncs + ${tableHasWorkspace?.ok ? 'WHERE workspace_id = ?' : ''} ORDER BY created_at DESC LIMIT 20 - `).all() + `).all(...(tableHasWorkspace?.ok ? [workspaceId] : [])) return NextResponse.json({ syncs }) } diff --git a/src/app/api/notifications/deliver/route.ts b/src/app/api/notifications/deliver/route.ts index 9b3a31b..28b6b1a 100644 --- a/src/app/api/notifications/deliver/route.ts +++ b/src/app/api/notifications/deliver/route.ts @@ -17,6 +17,7 @@ export async function POST(request: NextRequest) { try { const db = getDatabase(); const body = await request.json(); + const workspaceId = auth.user.workspace_id ?? 1; const { agent_filter, // Optional: only deliver to specific agent limit = 50, // Max notifications to process per call @@ -27,11 +28,11 @@ export async function POST(request: NextRequest) { let query = ` SELECT n.*, a.session_key FROM notifications n - LEFT JOIN agents a ON n.recipient = a.name - WHERE n.delivered_at IS NULL + LEFT JOIN agents a ON n.recipient = a.name AND a.workspace_id = n.workspace_id + WHERE n.delivered_at IS NULL AND n.workspace_id = ? `; - const params: any[] = []; + const params: any[] = [workspaceId]; if (agent_filter) { query += ' AND n.recipient = ?'; @@ -59,7 +60,7 @@ export async function POST(request: NextRequest) { const deliveryResults: any[] = []; // Prepare update statement once (avoids N+1) - const markDeliveredStmt = db.prepare('UPDATE notifications SET delivered_at = ? WHERE id = ?'); + const markDeliveredStmt = db.prepare('UPDATE notifications SET delivered_at = ? WHERE id = ? AND workspace_id = ?'); for (const notification of undeliveredNotifications) { try { @@ -98,7 +99,7 @@ export async function POST(request: NextRequest) { // Mark as delivered const now = Math.floor(Date.now() / 1000); - markDeliveredStmt.run(now, notification.id); + markDeliveredStmt.run(now, notification.id, workspaceId); deliveredCount++; deliveryResults.push({ @@ -121,7 +122,8 @@ export async function POST(request: NextRequest) { notification_type: notification.type, session_key: notification.session_key, title: notification.title - } + }, + workspaceId ); } catch (cmdError: any) { throw new Error(`Command failed: ${cmdError.message}`); @@ -162,7 +164,8 @@ export async function POST(request: NextRequest) { errors: errorCount, dry_run, agent_filter: agent_filter || null - } + }, + workspaceId ); return NextResponse.json({ @@ -191,25 +194,26 @@ export async function GET(request: NextRequest) { try { const db = getDatabase(); const { searchParams } = new URL(request.url); + const workspaceId = auth.user.workspace_id ?? 1; const agent = searchParams.get('agent'); // Get delivery statistics - let baseQuery = 'SELECT COUNT(*) as count FROM notifications'; - let params: any[] = []; + let baseQuery = 'SELECT COUNT(*) as count FROM notifications WHERE workspace_id = ?'; + let params: any[] = [workspaceId]; if (agent) { - baseQuery += ' WHERE recipient = ?'; + baseQuery += ' AND recipient = ?'; params.push(agent); } const totalNotifications = db.prepare(baseQuery).get(...params) as { count: number }; const undeliveredCount = db.prepare( - baseQuery + (agent ? ' AND' : ' WHERE') + ' delivered_at IS NULL' + baseQuery + ' AND delivered_at IS NULL' ).get(...params) as { count: number }; const deliveredCount = db.prepare( - baseQuery + (agent ? ' AND' : ' WHERE') + ' delivered_at IS NOT NULL' + baseQuery + ' AND delivered_at IS NOT NULL' ).get(...params) as { count: number }; // Get recent delivery activity @@ -221,11 +225,11 @@ export async function GET(request: NextRequest) { delivered_at, created_at FROM notifications - WHERE delivered_at IS NOT NULL + WHERE delivered_at IS NOT NULL AND workspace_id = ? ${agent ? 'AND recipient = ?' : ''} ORDER BY delivered_at DESC LIMIT 10 - `).all(...(agent ? [agent] : [])); + `).all(...(agent ? [workspaceId, agent] : [workspaceId])); // Get agents with pending notifications const agentsPending = db.prepare(` @@ -234,11 +238,11 @@ export async function GET(request: NextRequest) { a.session_key, COUNT(*) as pending_count FROM notifications n - LEFT JOIN agents a ON n.recipient = a.name - WHERE n.delivered_at IS NULL + LEFT JOIN agents a ON n.recipient = a.name AND a.workspace_id = n.workspace_id + WHERE n.delivered_at IS NULL AND n.workspace_id = ? GROUP BY n.recipient, a.session_key ORDER BY pending_count DESC - `).all() as any[]; + `).all(workspaceId) as any[]; return NextResponse.json({ statistics: { diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 2a3c51f..2d8a089 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -16,6 +16,7 @@ export async function GET(request: NextRequest) { try { const db = getDatabase(); const { searchParams } = new URL(request.url); + const workspaceId = auth.user.workspace_id ?? 1; // Parse query parameters const recipient = searchParams.get('recipient'); @@ -29,8 +30,8 @@ export async function GET(request: NextRequest) { } // Build dynamic query - let query = 'SELECT * FROM notifications WHERE recipient = ?'; - const params: any[] = [recipient]; + let query = 'SELECT * FROM notifications WHERE recipient = ? AND workspace_id = ?'; + const params: any[] = [recipient, workspaceId]; if (unread_only) { query += ' AND read_at IS NULL'; @@ -48,14 +49,14 @@ export async function GET(request: NextRequest) { const notifications = stmt.all(...params) as Notification[]; // Prepare source detail statements once (avoids N+1) - const taskDetailStmt = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?'); + const taskDetailStmt = db.prepare('SELECT id, title, status FROM tasks WHERE id = ? AND workspace_id = ?'); const commentDetailStmt = db.prepare(` SELECT c.id, c.content, c.task_id, t.title as task_title FROM comments c LEFT JOIN tasks t ON c.task_id = t.id - WHERE c.id = ? + WHERE c.id = ? AND c.workspace_id = ? AND t.workspace_id = ? `); - const agentDetailStmt = db.prepare('SELECT id, name, role, status FROM agents WHERE id = ?'); + const agentDetailStmt = db.prepare('SELECT id, name, role, status FROM agents WHERE id = ? AND workspace_id = ?'); // Enhance notifications with related entity data const enhancedNotifications = notifications.map(notification => { @@ -65,14 +66,14 @@ export async function GET(request: NextRequest) { if (notification.source_type && notification.source_id) { switch (notification.source_type) { case 'task': { - const task = taskDetailStmt.get(notification.source_id) as any; + const task = taskDetailStmt.get(notification.source_id, workspaceId) as any; if (task) { sourceDetails = { type: 'task', ...task }; } break; } case 'comment': { - const comment = commentDetailStmt.get(notification.source_id) as any; + const comment = commentDetailStmt.get(notification.source_id, workspaceId, workspaceId) as any; if (comment) { sourceDetails = { type: 'comment', @@ -83,7 +84,7 @@ export async function GET(request: NextRequest) { break; } case 'agent': { - const agent = agentDetailStmt.get(notification.source_id) as any; + const agent = agentDetailStmt.get(notification.source_id, workspaceId) as any; if (agent) { sourceDetails = { type: 'agent', ...agent }; } @@ -105,12 +106,12 @@ export async function GET(request: NextRequest) { const unreadCount = db.prepare(` SELECT COUNT(*) as count FROM notifications - WHERE recipient = ? AND read_at IS NULL - `).get(recipient) as { count: number }; + WHERE recipient = ? AND read_at IS NULL AND workspace_id = ? + `).get(recipient, workspaceId) as { count: number }; // Get total count for pagination - let countQuery = 'SELECT COUNT(*) as total FROM notifications WHERE recipient = ?'; - const countParams: any[] = [recipient]; + let countQuery = 'SELECT COUNT(*) as total FROM notifications WHERE recipient = ? AND workspace_id = ?'; + const countParams: any[] = [recipient, workspaceId]; if (unread_only) { countQuery += ' AND read_at IS NULL'; } @@ -146,6 +147,7 @@ export async function PUT(request: NextRequest) { try { const db = getDatabase(); + const workspaceId = auth.user.workspace_id ?? 1; const body = await request.json(); const { ids, recipient, markAllRead } = body; @@ -156,10 +158,10 @@ export async function PUT(request: NextRequest) { const stmt = db.prepare(` UPDATE notifications SET read_at = ? - WHERE recipient = ? AND read_at IS NULL + WHERE recipient = ? AND read_at IS NULL AND workspace_id = ? `); - const result = stmt.run(now, recipient); + const result = stmt.run(now, recipient, workspaceId); return NextResponse.json({ success: true, @@ -171,10 +173,10 @@ export async function PUT(request: NextRequest) { const stmt = db.prepare(` UPDATE notifications SET read_at = ? - WHERE id IN (${placeholders}) AND read_at IS NULL + WHERE id IN (${placeholders}) AND read_at IS NULL AND workspace_id = ? `); - const result = stmt.run(now, ...ids); + const result = stmt.run(now, ...ids, workspaceId); return NextResponse.json({ success: true, @@ -204,6 +206,7 @@ export async function DELETE(request: NextRequest) { try { const db = getDatabase(); + const workspaceId = auth.user.workspace_id ?? 1; const body = await request.json(); const { ids, recipient, olderThan } = body; @@ -212,10 +215,10 @@ export async function DELETE(request: NextRequest) { const placeholders = ids.map(() => '?').join(','); const stmt = db.prepare(` DELETE FROM notifications - WHERE id IN (${placeholders}) + WHERE id IN (${placeholders}) AND workspace_id = ? `); - const result = stmt.run(...ids); + const result = stmt.run(...ids, workspaceId); return NextResponse.json({ success: true, @@ -225,10 +228,10 @@ export async function DELETE(request: NextRequest) { // Delete old notifications for recipient const stmt = db.prepare(` DELETE FROM notifications - WHERE recipient = ? AND created_at < ? + WHERE recipient = ? AND created_at < ? AND workspace_id = ? `); - const result = stmt.run(recipient, olderThan); + const result = stmt.run(recipient, olderThan, workspaceId); return NextResponse.json({ success: true, @@ -258,6 +261,7 @@ export async function POST(request: NextRequest) { try { const db = getDatabase(); + const workspaceId = auth.user.workspace_id ?? 1; const result = await validateBody(request, notificationActionSchema); if ('error' in result) return result.error; @@ -271,17 +275,17 @@ export async function POST(request: NextRequest) { const stmt = db.prepare(` UPDATE notifications SET delivered_at = ? - WHERE recipient = ? AND delivered_at IS NULL + WHERE recipient = ? AND delivered_at IS NULL AND workspace_id = ? `); - const result = stmt.run(now, agent); + const result = stmt.run(now, agent, workspaceId); // Get the notifications that were just marked as delivered const deliveredNotifications = db.prepare(` SELECT * FROM notifications - WHERE recipient = ? AND delivered_at = ? + WHERE recipient = ? AND delivered_at = ? AND workspace_id = ? ORDER BY created_at DESC - `).all(agent, now) as Notification[]; + `).all(agent, now, workspaceId) as Notification[]; return NextResponse.json({ success: true, @@ -295,4 +299,4 @@ export async function POST(request: NextRequest) { logger.error({ err: error }, 'POST /api/notifications error'); return NextResponse.json({ error: 'Failed to process notification action' }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/pipelines/route.ts b/src/app/api/pipelines/route.ts index 932ee21..320d42f 100644 --- a/src/app/api/pipelines/route.ts +++ b/src/app/api/pipelines/route.ts @@ -32,9 +32,10 @@ export async function GET(request: NextRequest) { try { const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 const pipelines = db.prepare( - 'SELECT * FROM workflow_pipelines ORDER BY use_count DESC, updated_at DESC' - ).all() as Pipeline[] + 'SELECT * FROM workflow_pipelines WHERE workspace_id = ? ORDER BY use_count DESC, updated_at DESC' + ).all(workspaceId) as Pipeline[] // Enrich steps with template names const templates = db.prepare('SELECT id, name FROM workflow_templates').all() as Array<{ id: number; name: string }> @@ -46,8 +47,8 @@ export async function GET(request: NextRequest) { SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running - FROM pipeline_runs GROUP BY pipeline_id - `).all() as Array<{ pipeline_id: number; total: number; completed: number; failed: number; running: number }> + FROM pipeline_runs WHERE workspace_id = ? GROUP BY pipeline_id + `).all(workspaceId) as Array<{ pipeline_id: number; total: number; completed: number; failed: number; running: number }> const runMap = new Map(runCounts.map(r => [r.pipeline_id, r])) const parsed = pipelines.map(p => { @@ -82,6 +83,7 @@ export async function POST(request: NextRequest) { const { name, description, steps } = result.data const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 // Validate template IDs exist const templateIds = steps.map((s: PipelineStep) => s.template_id) @@ -98,13 +100,23 @@ export async function POST(request: NextRequest) { })) const insertResult = db.prepare(` - INSERT INTO workflow_pipelines (name, description, steps, created_by) - VALUES (?, ?, ?, ?) - `).run(name, description || null, JSON.stringify(cleanSteps), auth.user?.username || 'system') + INSERT INTO workflow_pipelines (name, description, steps, created_by, workspace_id) + VALUES (?, ?, ?, ?, ?) + `).run(name, description || null, JSON.stringify(cleanSteps), auth.user?.username || 'system', workspaceId) - db_helpers.logActivity('pipeline_created', 'pipeline', Number(insertResult.lastInsertRowid), auth.user?.username || 'system', `Created pipeline: ${name}`) + db_helpers.logActivity( + 'pipeline_created', + 'pipeline', + Number(insertResult.lastInsertRowid), + auth.user?.username || 'system', + `Created pipeline: ${name}`, + undefined, + workspaceId + ) - const pipeline = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(insertResult.lastInsertRowid) as Pipeline + const pipeline = db + .prepare('SELECT * FROM workflow_pipelines WHERE id = ? AND workspace_id = ?') + .get(insertResult.lastInsertRowid, workspaceId) as Pipeline return NextResponse.json({ pipeline: { ...pipeline, steps: JSON.parse(pipeline.steps) } }, { status: 201 }) } catch (error) { logger.error({ err: error }, 'POST /api/pipelines error') @@ -121,12 +133,15 @@ export async function PUT(request: NextRequest) { try { const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 const body = await request.json() const { id, ...updates } = body if (!id) return NextResponse.json({ error: 'Pipeline ID required' }, { status: 400 }) - const existing = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(id) as Pipeline + const existing = db + .prepare('SELECT * FROM workflow_pipelines WHERE id = ? AND workspace_id = ?') + .get(id, workspaceId) as Pipeline if (!existing) return NextResponse.json({ error: 'Pipeline not found' }, { status: 404 }) const fields: string[] = [] @@ -147,11 +162,13 @@ export async function PUT(request: NextRequest) { fields.push('updated_at = ?') params.push(Math.floor(Date.now() / 1000)) - params.push(id) + params.push(id, workspaceId) - db.prepare(`UPDATE workflow_pipelines SET ${fields.join(', ')} WHERE id = ?`).run(...params) + db.prepare(`UPDATE workflow_pipelines SET ${fields.join(', ')} WHERE id = ? AND workspace_id = ?`).run(...params) - const updated = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(id) as Pipeline + const updated = db + .prepare('SELECT * FROM workflow_pipelines WHERE id = ? AND workspace_id = ?') + .get(id, workspaceId) as Pipeline return NextResponse.json({ pipeline: { ...updated, steps: JSON.parse(updated.steps) } }) } catch (error) { logger.error({ err: error }, 'PUT /api/pipelines error') @@ -168,12 +185,13 @@ export async function DELETE(request: NextRequest) { try { const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 let body: any try { body = await request.json() } catch { return NextResponse.json({ error: 'Request body required' }, { status: 400 }) } const id = body.id if (!id) return NextResponse.json({ error: 'Pipeline ID required' }, { status: 400 }) - db.prepare('DELETE FROM workflow_pipelines WHERE id = ?').run(parseInt(id)) + db.prepare('DELETE FROM workflow_pipelines WHERE id = ? AND workspace_id = ?').run(parseInt(id), workspaceId) return NextResponse.json({ success: true }) } catch (error) { logger.error({ err: error }, 'DELETE /api/pipelines error') diff --git a/src/app/api/pipelines/run/route.ts b/src/app/api/pipelines/run/route.ts index 3a5acf1..9e89cc4 100644 --- a/src/app/api/pipelines/run/route.ts +++ b/src/app/api/pipelines/run/route.ts @@ -42,21 +42,24 @@ export async function GET(request: NextRequest) { try { const db = getDatabase() const { searchParams } = new URL(request.url) + const workspaceId = auth.user.workspace_id ?? 1 const pipelineId = searchParams.get('pipeline_id') const runId = searchParams.get('id') const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 200) if (runId) { - const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ?').get(parseInt(runId)) as PipelineRun | undefined + const run = db + .prepare('SELECT * FROM pipeline_runs WHERE id = ? AND workspace_id = ?') + .get(parseInt(runId), workspaceId) as PipelineRun | undefined if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 }) return NextResponse.json({ run: { ...run, steps_snapshot: JSON.parse(run.steps_snapshot) } }) } - let query = 'SELECT * FROM pipeline_runs' - const params: any[] = [] + let query = 'SELECT * FROM pipeline_runs WHERE workspace_id = ?' + const params: any[] = [workspaceId] if (pipelineId) { - query += ' WHERE pipeline_id = ?' + query += ' AND pipeline_id = ?' params.push(parseInt(pipelineId)) } @@ -68,7 +71,7 @@ export async function GET(request: NextRequest) { // Enrich with pipeline names const pipelineIds = [...new Set(runs.map(r => r.pipeline_id))] const pipelines = pipelineIds.length > 0 - ? db.prepare(`SELECT id, name FROM workflow_pipelines WHERE id IN (${pipelineIds.map(() => '?').join(',')})`).all(...pipelineIds) as Array<{ id: number; name: string }> + ? db.prepare(`SELECT id, name FROM workflow_pipelines WHERE workspace_id = ? AND id IN (${pipelineIds.map(() => '?').join(',')})`).all(workspaceId, ...pipelineIds) as Array<{ id: number; name: string }> : [] const nameMap = new Map(pipelines.map(p => [p.id, p.name])) @@ -94,15 +97,16 @@ export async function POST(request: NextRequest) { try { const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 const body = await request.json() const { action, pipeline_id, run_id } = body if (action === 'start') { - return startPipeline(db, pipeline_id, auth.user?.username || 'system') + return startPipeline(db, pipeline_id, auth.user?.username || 'system', workspaceId) } else if (action === 'advance') { - return advanceRun(db, run_id, body.success ?? true, body.error) + return advanceRun(db, run_id, body.success ?? true, body.error, workspaceId) } else if (action === 'cancel') { - return cancelRun(db, run_id) + return cancelRun(db, run_id, workspaceId) } return NextResponse.json({ error: 'Invalid action. Use: start, advance, cancel' }, { status: 400 }) @@ -119,7 +123,8 @@ async function spawnStep( template: { name: string; model: string; task_prompt: string; timeout_seconds: number }, steps: RunStepState[], stepIdx: number, - runId: number + runId: number, + workspaceId: number ): Promise<{ success: boolean; stdout?: string; error?: string }> { try { const { runOpenClaw } = await import('@/lib/command') @@ -133,20 +138,20 @@ async function spawnStep( const spawnId = `pipeline-${runId}-step-${stepIdx}-${Date.now()}` steps[stepIdx].spawn_id = spawnId - db.prepare('UPDATE pipeline_runs SET steps_snapshot = ? WHERE id = ?').run(JSON.stringify(steps), runId) + db.prepare('UPDATE pipeline_runs SET steps_snapshot = ? WHERE id = ? AND workspace_id = ?').run(JSON.stringify(steps), runId, workspaceId) return { success: true, stdout: stdout.trim() } } catch (err: any) { // Spawn failed - record error but keep pipeline running for manual advance steps[stepIdx].error = err.message - db.prepare('UPDATE pipeline_runs SET steps_snapshot = ? WHERE id = ?').run(JSON.stringify(steps), runId) + db.prepare('UPDATE pipeline_runs SET steps_snapshot = ? WHERE id = ? AND workspace_id = ?').run(JSON.stringify(steps), runId, workspaceId) return { success: false, error: err.message } } } -async function startPipeline(db: ReturnType, pipelineId: number, triggeredBy: string) { - const pipeline = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(pipelineId) as any +async function startPipeline(db: ReturnType, pipelineId: number, triggeredBy: string, workspaceId: number) { + const pipeline = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ? AND workspace_id = ?').get(pipelineId, workspaceId) as any if (!pipeline) return NextResponse.json({ error: 'Pipeline not found' }, { status: 404 }) const steps: PipelineStep[] = JSON.parse(pipeline.steps || '[]') @@ -174,25 +179,25 @@ async function startPipeline(db: ReturnType, pipelineId: num const now = Math.floor(Date.now() / 1000) const result = db.prepare(` - INSERT INTO pipeline_runs (pipeline_id, status, current_step, steps_snapshot, started_at, triggered_by) - VALUES (?, 'running', 0, ?, ?, ?) - `).run(pipelineId, JSON.stringify(stepsSnapshot), now, triggeredBy) + INSERT INTO pipeline_runs (pipeline_id, status, current_step, steps_snapshot, started_at, triggered_by, workspace_id) + VALUES (?, 'running', 0, ?, ?, ?, ?) + `).run(pipelineId, JSON.stringify(stepsSnapshot), now, triggeredBy, workspaceId) const runId = Number(result.lastInsertRowid) // Update pipeline usage db.prepare(` - UPDATE workflow_pipelines SET use_count = use_count + 1, last_used_at = ?, updated_at = ? WHERE id = ? - `).run(now, now, pipelineId) + UPDATE workflow_pipelines SET use_count = use_count + 1, last_used_at = ?, updated_at = ? WHERE id = ? AND workspace_id = ? + `).run(now, now, pipelineId, workspaceId) // Spawn first step const firstTemplate = templateMap.get(steps[0].template_id) let spawnResult: any = null if (firstTemplate) { - spawnResult = await spawnStep(db, pipeline.name, firstTemplate, stepsSnapshot, 0, runId) + spawnResult = await spawnStep(db, pipeline.name, firstTemplate, stepsSnapshot, 0, runId, workspaceId) } - db_helpers.logActivity('pipeline_started', 'pipeline', pipelineId, triggeredBy, `Started pipeline: ${pipeline.name}`, { run_id: runId }) + db_helpers.logActivity('pipeline_started', 'pipeline', pipelineId, triggeredBy, `Started pipeline: ${pipeline.name}`, { run_id: runId }, workspaceId) eventBus.broadcast('activity.created', { type: 'pipeline_started', @@ -214,10 +219,10 @@ async function startPipeline(db: ReturnType, pipelineId: num }, { status: 201 }) } -async function advanceRun(db: ReturnType, runId: number, success: boolean, errorMsg?: string) { +async function advanceRun(db: ReturnType, runId: number, success: boolean, errorMsg: string | undefined, workspaceId: number) { if (!runId) return NextResponse.json({ error: 'run_id required' }, { status: 400 }) - const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ?').get(runId) as PipelineRun | undefined + const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ? AND workspace_id = ?').get(runId, workspaceId) as PipelineRun | undefined if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 }) if (run.status !== 'running') return NextResponse.json({ error: `Run is ${run.status}, not running` }, { status: 400 }) @@ -237,16 +242,16 @@ async function advanceRun(db: ReturnType, runId: number, suc if (!success && onFailure === 'stop') { // Mark remaining steps as skipped for (let i = nextIdx; i < steps.length; i++) steps[i].status = 'skipped' - db.prepare('UPDATE pipeline_runs SET status = ?, current_step = ?, steps_snapshot = ?, completed_at = ? WHERE id = ?') - .run('failed', currentIdx, JSON.stringify(steps), now, runId) + db.prepare('UPDATE pipeline_runs SET status = ?, current_step = ?, steps_snapshot = ?, completed_at = ? WHERE id = ? AND workspace_id = ?') + .run('failed', currentIdx, JSON.stringify(steps), now, runId, workspaceId) return NextResponse.json({ run: { id: runId, status: 'failed', steps_snapshot: steps } }) } if (nextIdx >= steps.length) { // Pipeline complete const finalStatus = steps.some(s => s.status === 'failed') ? 'completed' : 'completed' - db.prepare('UPDATE pipeline_runs SET status = ?, current_step = ?, steps_snapshot = ?, completed_at = ? WHERE id = ?') - .run(finalStatus, currentIdx, JSON.stringify(steps), now, runId) + db.prepare('UPDATE pipeline_runs SET status = ?, current_step = ?, steps_snapshot = ?, completed_at = ? WHERE id = ? AND workspace_id = ?') + .run(finalStatus, currentIdx, JSON.stringify(steps), now, runId, workspaceId) eventBus.broadcast('activity.created', { type: 'pipeline_completed', @@ -267,22 +272,22 @@ async function advanceRun(db: ReturnType, runId: number, suc let spawnResult: any = null if (template) { - const pipeline = db.prepare('SELECT name FROM workflow_pipelines WHERE id = ?').get(run.pipeline_id) as any - spawnResult = await spawnStep(db, pipeline?.name || '?', template, steps, nextIdx, runId) + const pipeline = db.prepare('SELECT name FROM workflow_pipelines WHERE id = ? AND workspace_id = ?').get(run.pipeline_id, workspaceId) as any + spawnResult = await spawnStep(db, pipeline?.name || '?', template, steps, nextIdx, runId, workspaceId) } - db.prepare('UPDATE pipeline_runs SET current_step = ?, steps_snapshot = ? WHERE id = ?') - .run(nextIdx, JSON.stringify(steps), runId) + db.prepare('UPDATE pipeline_runs SET current_step = ?, steps_snapshot = ? WHERE id = ? AND workspace_id = ?') + .run(nextIdx, JSON.stringify(steps), runId, workspaceId) return NextResponse.json({ run: { id: runId, status: 'running', current_step: nextIdx, steps_snapshot: steps, spawn: spawnResult } }) } -function cancelRun(db: ReturnType, runId: number) { +function cancelRun(db: ReturnType, runId: number, workspaceId: number) { if (!runId) return NextResponse.json({ error: 'run_id required' }, { status: 400 }) - const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ?').get(runId) as PipelineRun | undefined + const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ? AND workspace_id = ?').get(runId, workspaceId) as PipelineRun | undefined if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 }) if (run.status !== 'running' && run.status !== 'pending') { return NextResponse.json({ error: `Run is ${run.status}, cannot cancel` }, { status: 400 }) @@ -298,8 +303,8 @@ function cancelRun(db: ReturnType, runId: number) { } } - db.prepare('UPDATE pipeline_runs SET status = ?, steps_snapshot = ?, completed_at = ? WHERE id = ?') - .run('cancelled', JSON.stringify(steps), now, runId) + db.prepare('UPDATE pipeline_runs SET status = ?, steps_snapshot = ?, completed_at = ? WHERE id = ? AND workspace_id = ?') + .run('cancelled', JSON.stringify(steps), now, runId, workspaceId) return NextResponse.json({ run: { id: runId, status: 'cancelled', steps_snapshot: steps } }) } diff --git a/src/app/api/quality-review/route.ts b/src/app/api/quality-review/route.ts index d22559a..d07bead 100644 --- a/src/app/api/quality-review/route.ts +++ b/src/app/api/quality-review/route.ts @@ -13,6 +13,7 @@ export async function GET(request: NextRequest) { try { const db = getDatabase() const { searchParams } = new URL(request.url) + const workspaceId = auth.user.workspace_id ?? 1; const taskIdsParam = searchParams.get('taskIds') const taskId = parseInt(searchParams.get('taskId') || '') @@ -29,9 +30,9 @@ export async function GET(request: NextRequest) { const placeholders = ids.map(() => '?').join(',') const rows = db.prepare(` SELECT * FROM quality_reviews - WHERE task_id IN (${placeholders}) + WHERE task_id IN (${placeholders}) AND workspace_id = ? ORDER BY task_id ASC, created_at DESC - `).all(...ids) as Array<{ task_id: number; reviewer?: string; status?: string; created_at?: number }> + `).all(...ids, workspaceId) as Array<{ task_id: number; reviewer?: string; status?: string; created_at?: number }> const byTask: Record = {} for (const id of ids) { @@ -54,10 +55,10 @@ export async function GET(request: NextRequest) { const reviews = db.prepare(` SELECT * FROM quality_reviews - WHERE task_id = ? + WHERE task_id = ? AND workspace_id = ? ORDER BY created_at DESC LIMIT 10 - `).all(taskId) + `).all(taskId, workspaceId) return NextResponse.json({ reviews }) } catch (error) { @@ -79,16 +80,19 @@ export async function POST(request: NextRequest) { const { taskId, reviewer, status, notes } = validated.data const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1; - const task = db.prepare('SELECT id, title FROM tasks WHERE id = ?').get(taskId) as any + const task = db + .prepare('SELECT id, title FROM tasks WHERE id = ? AND workspace_id = ?') + .get(taskId, workspaceId) as any if (!task) { return NextResponse.json({ error: 'Task not found' }, { status: 404 }) } const result = db.prepare(` - INSERT INTO quality_reviews (task_id, reviewer, status, notes) - VALUES (?, ?, ?, ?) - `).run(taskId, reviewer, status, notes) + INSERT INTO quality_reviews (task_id, reviewer, status, notes, workspace_id) + VALUES (?, ?, ?, ?, ?) + `).run(taskId, reviewer, status, notes, workspaceId) db_helpers.logActivity( 'quality_review', @@ -96,13 +100,14 @@ export async function POST(request: NextRequest) { taskId, reviewer, `Quality review ${status} for task: ${task.title}`, - { status, notes } + { status, notes }, + workspaceId ) // Auto-advance task to 'done' when aegis approves if (status === 'approved' && reviewer === 'aegis') { - db.prepare('UPDATE tasks SET status = ?, updated_at = unixepoch() WHERE id = ?') - .run('done', taskId) + db.prepare('UPDATE tasks SET status = ?, updated_at = unixepoch() WHERE id = ? AND workspace_id = ?') + .run('done', taskId, workspaceId) eventBus.broadcast('task.status_changed', { id: taskId, status: 'done', diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 1b372d8..f97ef63 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -34,6 +34,7 @@ export async function GET(request: NextRequest) { } const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 const likeQ = `%${query}%` const results: SearchResult[] = [] @@ -42,9 +43,9 @@ export async function GET(request: NextRequest) { try { const tasks = db.prepare(` SELECT id, title, description, status, assigned_to, created_at - FROM tasks WHERE title LIKE ? OR description LIKE ? OR assigned_to LIKE ? + FROM tasks WHERE workspace_id = ? AND (title LIKE ? OR description LIKE ? OR assigned_to LIKE ?) ORDER BY created_at DESC LIMIT ? - `).all(likeQ, likeQ, likeQ, limit) as any[] + `).all(workspaceId, likeQ, likeQ, likeQ, limit) as any[] for (const t of tasks) { results.push({ type: 'task', @@ -64,9 +65,9 @@ export async function GET(request: NextRequest) { try { const agents = db.prepare(` SELECT id, name, role, status, last_activity, created_at - FROM agents WHERE name LIKE ? OR role LIKE ? OR last_activity LIKE ? + FROM agents WHERE workspace_id = ? AND (name LIKE ? OR role LIKE ? OR last_activity LIKE ?) ORDER BY created_at DESC LIMIT ? - `).all(likeQ, likeQ, likeQ, limit) as any[] + `).all(workspaceId, likeQ, likeQ, likeQ, limit) as any[] for (const a of agents) { results.push({ type: 'agent', @@ -86,9 +87,9 @@ export async function GET(request: NextRequest) { try { const activities = db.prepare(` SELECT id, type, actor, description, created_at - FROM activities WHERE description LIKE ? OR actor LIKE ? + FROM activities WHERE workspace_id = ? AND (description LIKE ? OR actor LIKE ?) ORDER BY created_at DESC LIMIT ? - `).all(likeQ, likeQ, limit) as any[] + `).all(workspaceId, likeQ, likeQ, limit) as any[] for (const a of activities) { results.push({ type: 'activity', diff --git a/src/app/api/standup/route.ts b/src/app/api/standup/route.ts index 85f7aca..866464b 100644 --- a/src/app/api/standup/route.ts +++ b/src/app/api/standup/route.ts @@ -14,6 +14,7 @@ export async function POST(request: NextRequest) { try { const db = getDatabase(); const body = await request.json(); + const workspaceId = auth.user.workspace_id ?? 1; // Parse parameters const targetDate = body.date || new Date().toISOString().split('T')[0]; // YYYY-MM-DD format @@ -24,12 +25,12 @@ export async function POST(request: NextRequest) { const endOfDay = Math.floor(new Date(`${targetDate}T23:59:59Z`).getTime() / 1000); // Get all active agents or filter by specific agents - let agentQuery = 'SELECT * FROM agents'; - const agentParams: any[] = []; + let agentQuery = 'SELECT * FROM agents WHERE workspace_id = ?'; + const agentParams: any[] = [workspaceId]; if (specificAgents && Array.isArray(specificAgents) && specificAgents.length > 0) { const placeholders = specificAgents.map(() => '?').join(','); - agentQuery += ` WHERE name IN (${placeholders})`; + agentQuery += ` AND name IN (${placeholders})`; agentParams.push(...specificAgents); } @@ -42,6 +43,7 @@ export async function POST(request: NextRequest) { SELECT id, title, status, updated_at FROM tasks WHERE assigned_to = ? + AND workspace_id = ? AND status = 'done' AND updated_at BETWEEN ? AND ? ORDER BY updated_at DESC @@ -50,6 +52,7 @@ export async function POST(request: NextRequest) { SELECT id, title, status, created_at, due_date FROM tasks WHERE assigned_to = ? + AND workspace_id = ? AND status = 'in_progress' ORDER BY created_at ASC `); @@ -57,6 +60,7 @@ export async function POST(request: NextRequest) { SELECT id, title, status, created_at, due_date, priority FROM tasks WHERE assigned_to = ? + AND workspace_id = ? AND status = 'assigned' ORDER BY priority DESC, created_at ASC `); @@ -64,6 +68,7 @@ export async function POST(request: NextRequest) { SELECT id, title, status, updated_at FROM tasks WHERE assigned_to = ? + AND workspace_id = ? AND status IN ('review', 'quality_review') ORDER BY updated_at ASC `); @@ -71,6 +76,7 @@ export async function POST(request: NextRequest) { SELECT id, title, status, priority, created_at, metadata FROM tasks WHERE assigned_to = ? + AND workspace_id = ? AND (priority = 'urgent' OR metadata LIKE '%blocked%') AND status NOT IN ('done') ORDER BY priority DESC, created_at ASC @@ -79,24 +85,26 @@ export async function POST(request: NextRequest) { SELECT COUNT(*) as count FROM activities WHERE actor = ? + AND workspace_id = ? AND created_at BETWEEN ? AND ? `); const commentCountStmt = db.prepare(` SELECT COUNT(*) as count FROM comments WHERE author = ? + AND workspace_id = ? AND created_at BETWEEN ? AND ? `); // Generate standup data for each agent const standupData = agents.map(agent => { - const completedTasks = completedTasksStmt.all(agent.name, startOfDay, endOfDay); - const inProgressTasks = inProgressTasksStmt.all(agent.name); - const assignedTasks = assignedTasksStmt.all(agent.name); - const reviewTasks = reviewTasksStmt.all(agent.name); - const blockedTasks = blockedTasksStmt.all(agent.name); - const activityCount = activityCountStmt.get(agent.name, startOfDay, endOfDay) as { count: number }; - const commentsToday = commentCountStmt.get(agent.name, startOfDay, endOfDay) as { count: number }; + const completedTasks = completedTasksStmt.all(agent.name, workspaceId, startOfDay, endOfDay); + const inProgressTasks = inProgressTasksStmt.all(agent.name, workspaceId); + const assignedTasks = assignedTasksStmt.all(agent.name, workspaceId); + const reviewTasks = reviewTasksStmt.all(agent.name, workspaceId); + const blockedTasks = blockedTasksStmt.all(agent.name, workspaceId); + const activityCount = activityCountStmt.get(agent.name, workspaceId, startOfDay, endOfDay) as { count: number }; + const commentsToday = commentCountStmt.get(agent.name, workspaceId, startOfDay, endOfDay) as { count: number }; return { agent: { @@ -145,10 +153,12 @@ export async function POST(request: NextRequest) { SELECT t.*, a.name as agent_name FROM tasks t LEFT JOIN agents a ON t.assigned_to = a.name + AND a.workspace_id = t.workspace_id WHERE t.due_date < ? + AND t.workspace_id = ? AND t.status NOT IN ('done') ORDER BY t.due_date ASC - `).all(now); + `).all(now, workspaceId); const standupReport = { date: targetDate, @@ -172,16 +182,16 @@ export async function POST(request: NextRequest) { // Persist standup report const createdAt = Math.floor(Date.now() / 1000); db.prepare(` - INSERT OR REPLACE INTO standup_reports (date, report, created_at) - VALUES (?, ?, ?) - `).run(targetDate, JSON.stringify(standupReport), createdAt); + INSERT OR REPLACE INTO standup_reports (date, report, created_at, workspace_id) + VALUES (?, ?, ?, ?) + `).run(targetDate, JSON.stringify(standupReport), createdAt, workspaceId); // Log the standup generation db_helpers.logActivity( 'standup_generated', 'standup', 0, // No specific entity - 'system', + auth.user.username, `Generated daily standup for ${targetDate}`, { date: targetDate, @@ -193,7 +203,8 @@ export async function POST(request: NextRequest) { review: totalReview, blocked: totalBlocked } - } + }, + workspaceId ); return NextResponse.json({ standup: standupReport }); @@ -214,6 +225,7 @@ export async function GET(request: NextRequest) { try { const db = getDatabase(); const { searchParams } = new URL(request.url); + const workspaceId = auth.user.workspace_id ?? 1; const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 200); const offset = parseInt(searchParams.get('offset') || '0'); @@ -221,9 +233,10 @@ export async function GET(request: NextRequest) { const standupRows = db.prepare(` SELECT date, report, created_at FROM standup_reports + WHERE workspace_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ? - `).all(limit, offset) as Array<{ date: string; report: string; created_at: number }>; + `).all(workspaceId, limit, offset) as Array<{ date: string; report: string; created_at: number }>; const standupHistory = standupRows.map((row, index) => { const report = row.report ? JSON.parse(row.report) : {}; @@ -236,7 +249,9 @@ export async function GET(request: NextRequest) { }; }); - const countRow = db.prepare('SELECT COUNT(*) as total FROM standup_reports').get() as { total: number }; + const countRow = db + .prepare('SELECT COUNT(*) as total FROM standup_reports WHERE workspace_id = ?') + .get(workspaceId) as { total: number }; return NextResponse.json({ history: standupHistory, diff --git a/src/app/api/status/route.ts b/src/app/api/status/route.ts index 5ac97bf..3c286d5 100644 --- a/src/app/api/status/route.ts +++ b/src/app/api/status/route.ts @@ -19,12 +19,12 @@ export async function GET(request: NextRequest) { const action = searchParams.get('action') || 'overview' if (action === 'overview') { - const status = await getSystemStatus() + const status = await getSystemStatus(auth.user.workspace_id ?? 1) return NextResponse.json(status) } if (action === 'dashboard') { - const data = await getDashboardData() + const data = await getDashboardData(auth.user.workspace_id ?? 1) return NextResponse.json(data) } @@ -59,16 +59,16 @@ export async function GET(request: NextRequest) { * Aggregate all dashboard data in a single request. * Combines system health, DB stats, audit summary, and recent activity. */ -async function getDashboardData() { +async function getDashboardData(workspaceId: number) { const [system, dbStats] = await Promise.all([ - getSystemStatus(), - getDbStats(), + getSystemStatus(workspaceId), + getDbStats(workspaceId), ]) return { ...system, db: dbStats } } -function getDbStats() { +function getDbStats(workspaceId: number) { try { const db = getDatabase() const now = Math.floor(Date.now() / 1000) @@ -77,8 +77,8 @@ function getDbStats() { // Task breakdown const taskStats = db.prepare(` - SELECT status, COUNT(*) as count FROM tasks GROUP BY status - `).all() as Array<{ status: string; count: number }> + SELECT status, COUNT(*) as count FROM tasks WHERE workspace_id = ? GROUP BY status + `).all(workspaceId) as Array<{ status: string; count: number }> const tasksByStatus: Record = {} let totalTasks = 0 for (const row of taskStats) { @@ -88,8 +88,8 @@ function getDbStats() { // Agent breakdown const agentStats = db.prepare(` - SELECT status, COUNT(*) as count FROM agents GROUP BY status - `).all() as Array<{ status: string; count: number }> + SELECT status, COUNT(*) as count FROM agents WHERE workspace_id = ? GROUP BY status + `).all(workspaceId) as Array<{ status: string; count: number }> const agentsByStatus: Record = {} let totalAgents = 0 for (const row of agentStats) { @@ -107,10 +107,14 @@ function getDbStats() { ).get(day) as any).c // Activities (24h) - const activityDay = (db.prepare('SELECT COUNT(*) as c FROM activities WHERE created_at > ?').get(day) as any).c + const activityDay = ( + db.prepare('SELECT COUNT(*) as c FROM activities WHERE created_at > ? AND workspace_id = ?').get(day, workspaceId) as any + ).c // Notifications (unread) - const unreadNotifs = (db.prepare('SELECT COUNT(*) as c FROM notifications WHERE read_at IS NULL').get() as any).c + const unreadNotifs = ( + db.prepare('SELECT COUNT(*) as c FROM notifications WHERE read_at IS NULL AND workspace_id = ?').get(workspaceId) as any + ).c // Pipeline runs (active + recent) let pipelineActive = 0 @@ -179,7 +183,7 @@ function getDbStats() { } } -async function getSystemStatus() { +async function getSystemStatus(workspaceId: number) { const status: any = { timestamp: Date.now(), uptime: 0, @@ -277,14 +281,16 @@ async function getSystemStatus() { // Match by: exact name, lowercase, or normalized (spaces→hyphens) const updateStmt = db.prepare( `UPDATE agents SET status = ?, last_seen = ?, updated_at = ? - WHERE LOWER(name) = LOWER(?) - OR LOWER(REPLACE(name, ' ', '-')) = LOWER(?)` + WHERE workspace_id = ? + AND (LOWER(name) = LOWER(?) + OR LOWER(REPLACE(name, ' ', '-')) = LOWER(?))` ) for (const [agentName, info] of liveStatuses) { updateStmt.run( info.status, Math.floor(info.lastActivity / 1000), now, + workspaceId, agentName, agentName ) diff --git a/src/app/api/tasks/[id]/broadcast/route.ts b/src/app/api/tasks/[id]/broadcast/route.ts index 01e25f2..042f80a 100644 --- a/src/app/api/tasks/[id]/broadcast/route.ts +++ b/src/app/api/tasks/[id]/broadcast/route.ts @@ -15,6 +15,7 @@ export async function POST( const resolvedParams = await params const taskId = parseInt(resolvedParams.id) const body = await request.json() + const workspaceId = auth.user.workspace_id ?? 1; const author = (body.author || 'system') as string const message = (body.message || '').trim() @@ -26,12 +27,14 @@ export async function POST( } const db = getDatabase() - const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as any + const task = db + .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?') + .get(taskId, workspaceId) as any if (!task) { return NextResponse.json({ error: 'Task not found' }, { status: 404 }) } - const subscribers = new Set(db_helpers.getTaskSubscribers(taskId)) + const subscribers = new Set(db_helpers.getTaskSubscribers(taskId, workspaceId)) subscribers.delete(author) if (subscribers.size === 0) { @@ -39,8 +42,8 @@ export async function POST( } const agents = db - .prepare('SELECT name, session_key FROM agents WHERE name IN (' + Array.from(subscribers).map(() => '?').join(',') + ')') - .all(...Array.from(subscribers)) as Array<{ name: string; session_key?: string }> + .prepare('SELECT name, session_key FROM agents WHERE workspace_id = ? AND name IN (' + Array.from(subscribers).map(() => '?').join(',') + ')') + .all(workspaceId, ...Array.from(subscribers)) as Array<{ name: string; session_key?: string }> const results = await Promise.allSettled( agents.map(async (agent) => { @@ -62,7 +65,8 @@ export async function POST( 'Task Broadcast', `${author} broadcasted a message on "${task.title}": ${message.substring(0, 100)}${message.length > 100 ? '...' : ''}`, 'task', - taskId + taskId, + workspaceId ) return 'sent' }) @@ -81,7 +85,8 @@ export async function POST( taskId, author, `Broadcasted message to ${sent} subscribers`, - { sent, skipped } + { sent, skipped }, + workspaceId ) return NextResponse.json({ sent, skipped }) diff --git a/src/app/api/tasks/[id]/comments/route.ts b/src/app/api/tasks/[id]/comments/route.ts index 8166ad8..ff8a275 100644 --- a/src/app/api/tasks/[id]/comments/route.ts +++ b/src/app/api/tasks/[id]/comments/route.ts @@ -19,13 +19,16 @@ export async function GET( const db = getDatabase(); const resolvedParams = await params; const taskId = parseInt(resolvedParams.id); + const workspaceId = auth.user.workspace_id ?? 1; if (isNaN(taskId)) { return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 }); } // Verify task exists - const task = db.prepare('SELECT id FROM tasks WHERE id = ?').get(taskId); + const task = db + .prepare('SELECT id FROM tasks WHERE id = ? AND workspace_id = ?') + .get(taskId, workspaceId); if (!task) { return NextResponse.json({ error: 'Task not found' }, { status: 404 }); } @@ -33,11 +36,11 @@ export async function GET( // Get comments ordered by creation time const stmt = db.prepare(` SELECT * FROM comments - WHERE task_id = ? + WHERE task_id = ? AND workspace_id = ? ORDER BY created_at ASC `); - const comments = stmt.all(taskId) as Comment[]; + const comments = stmt.all(taskId, workspaceId) as Comment[]; // Parse JSON fields and build thread structure const commentsWithParsedData = comments.map(comment => ({ @@ -97,6 +100,7 @@ export async function POST( const db = getDatabase(); const resolvedParams = await params; const taskId = parseInt(resolvedParams.id); + const workspaceId = auth.user.workspace_id ?? 1; if (isNaN(taskId)) { return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 }); @@ -107,14 +111,18 @@ export async function POST( const { content, author = 'system', parent_id } = result.data; // Verify task exists - const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as any; + const task = db + .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?') + .get(taskId, workspaceId) as any; if (!task) { return NextResponse.json({ error: 'Task not found' }, { status: 404 }); } // Verify parent comment exists if specified if (parent_id) { - const parentComment = db.prepare('SELECT id FROM comments WHERE id = ? AND task_id = ?').get(parent_id, taskId); + const parentComment = db + .prepare('SELECT id FROM comments WHERE id = ? AND task_id = ? AND workspace_id = ?') + .get(parent_id, taskId, workspaceId); if (!parentComment) { return NextResponse.json({ error: 'Parent comment not found' }, { status: 404 }); } @@ -127,8 +135,8 @@ export async function POST( // Insert comment const stmt = db.prepare(` - INSERT INTO comments (task_id, author, content, created_at, parent_id, mentions) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO comments (task_id, author, content, created_at, parent_id, mentions, workspace_id) + VALUES (?, ?, ?, ?, ?, ?, ?) `); const insertResult = stmt.run( @@ -137,7 +145,8 @@ export async function POST( content, now, parent_id || null, - mentions.length > 0 ? JSON.stringify(mentions) : null + mentions.length > 0 ? JSON.stringify(mentions) : null, + workspaceId ); const commentId = insertResult.lastInsertRowid as number; @@ -159,21 +168,22 @@ export async function POST( parent_id, mentions, content_preview: content.substring(0, 100) - } + }, + workspaceId ); // Ensure subscriptions for author, mentions, and assignee - db_helpers.ensureTaskSubscription(taskId, author); + db_helpers.ensureTaskSubscription(taskId, author, workspaceId); const uniqueMentions = Array.from(new Set(mentions)); uniqueMentions.forEach((mentionedAgent) => { - db_helpers.ensureTaskSubscription(taskId, mentionedAgent); + db_helpers.ensureTaskSubscription(taskId, mentionedAgent, workspaceId); }); if (task.assigned_to) { - db_helpers.ensureTaskSubscription(taskId, task.assigned_to); + db_helpers.ensureTaskSubscription(taskId, task.assigned_to, workspaceId); } // Notify subscribers - const subscribers = new Set(db_helpers.getTaskSubscribers(taskId)); + const subscribers = new Set(db_helpers.getTaskSubscribers(taskId, workspaceId)); subscribers.delete(author); const mentionSet = new Set(uniqueMentions); @@ -187,12 +197,15 @@ export async function POST( ? `${author} mentioned you in a comment on "${task.title}": ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}` : `${author} commented on "${task.title}": ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`, 'comment', - commentId + commentId, + workspaceId ); } // Fetch the created comment - const createdComment = db.prepare('SELECT * FROM comments WHERE id = ?').get(commentId) as Comment; + const createdComment = db + .prepare('SELECT * FROM comments WHERE id = ? AND workspace_id = ?') + .get(commentId, workspaceId) as Comment; return NextResponse.json({ comment: { diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index c38b859..1e5fd47 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -1,18 +1,22 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDatabase, Task, db_helpers } from '@/lib/db'; import { eventBus } from '@/lib/event-bus'; -import { getUserFromRequest, requireRole } from '@/lib/auth'; +import { requireRole } from '@/lib/auth'; import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, updateTaskSchema } from '@/lib/validation'; -function hasAegisApproval(db: ReturnType, taskId: number): boolean { +function hasAegisApproval( + db: ReturnType, + taskId: number, + workspaceId: number +): boolean { const review = db.prepare(` SELECT status FROM quality_reviews - WHERE task_id = ? AND reviewer = 'aegis' + WHERE task_id = ? AND reviewer = 'aegis' AND workspace_id = ? ORDER BY created_at DESC LIMIT 1 - `).get(taskId) as { status?: string } | undefined + `).get(taskId, workspaceId) as { status?: string } | undefined return review?.status === 'approved' } @@ -30,13 +34,14 @@ export async function GET( const db = getDatabase(); const resolvedParams = await params; const taskId = parseInt(resolvedParams.id); + const workspaceId = auth.user.workspace_id ?? 1; if (isNaN(taskId)) { return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 }); } - const stmt = db.prepare('SELECT * FROM tasks WHERE id = ?'); - const task = stmt.get(taskId) as Task; + const stmt = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?'); + const task = stmt.get(taskId, workspaceId) as Task; if (!task) { return NextResponse.json({ error: 'Task not found' }, { status: 404 }); @@ -73,6 +78,7 @@ export async function PUT( const db = getDatabase(); const resolvedParams = await params; const taskId = parseInt(resolvedParams.id); + const workspaceId = auth.user.workspace_id ?? 1; const validated = await validateBody(request, updateTaskSchema); if ('error' in validated) return validated.error; const body = validated.data; @@ -82,7 +88,9 @@ export async function PUT( } // Get current task for comparison - const currentTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task; + const currentTask = db + .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?') + .get(taskId, workspaceId) as Task; if (!currentTask) { return NextResponse.json({ error: 'Task not found' }, { status: 404 }); @@ -116,7 +124,7 @@ export async function PUT( updateParams.push(description); } if (status !== undefined) { - if (status === 'done' && !hasAegisApproval(db, taskId)) { + if (status === 'done' && !hasAegisApproval(db, taskId, workspaceId)) { return NextResponse.json( { error: 'Aegis approval is required to move task to done.' }, { status: 403 } @@ -156,7 +164,7 @@ export async function PUT( fieldsToUpdate.push('updated_at = ?'); updateParams.push(now); - updateParams.push(taskId); + updateParams.push(taskId, workspaceId); if (fieldsToUpdate.length === 1) { // Only updated_at return NextResponse.json({ error: 'No fields to update' }, { status: 400 }); @@ -165,7 +173,7 @@ export async function PUT( const stmt = db.prepare(` UPDATE tasks SET ${fieldsToUpdate.join(', ')} - WHERE id = ? + WHERE id = ? AND workspace_id = ? `); stmt.run(...updateParams); @@ -184,7 +192,8 @@ export async function PUT( 'Task Status Updated', `Task "${currentTask.title}" status changed to ${status}`, 'task', - taskId + taskId, + workspaceId ); } } @@ -194,14 +203,15 @@ export async function PUT( // Create notification for new assignee if (assigned_to) { - db_helpers.ensureTaskSubscription(taskId, assigned_to); + db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId); db_helpers.createNotification( assigned_to, 'assignment', 'Task Assigned', `You have been assigned to task: ${currentTask.title}`, 'task', - taskId + taskId, + workspaceId ); } } @@ -220,7 +230,7 @@ export async function PUT( 'task_updated', 'task', taskId, - getUserFromRequest(request)?.username || 'system', + auth.user.username, `Task updated: ${changes.join(', ')}`, { changes: changes, @@ -231,12 +241,15 @@ export async function PUT( assigned_to: currentTask.assigned_to }, newValues: { title, status, priority, assigned_to } - } + }, + workspaceId ); } // Fetch updated task - const updatedTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task; + const updatedTask = db + .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?') + .get(taskId, workspaceId) as Task; const parsedTask = { ...updatedTask, tags: updatedTask.tags ? JSON.parse(updatedTask.tags) : [], @@ -270,34 +283,38 @@ export async function DELETE( const db = getDatabase(); const resolvedParams = await params; const taskId = parseInt(resolvedParams.id); + const workspaceId = auth.user.workspace_id ?? 1; if (isNaN(taskId)) { return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 }); } // Get task before deletion for logging - const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task; + const task = db + .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?') + .get(taskId, workspaceId) as Task; if (!task) { return NextResponse.json({ error: 'Task not found' }, { status: 404 }); } // Delete task (cascades will handle comments) - const stmt = db.prepare('DELETE FROM tasks WHERE id = ?'); - stmt.run(taskId); + const stmt = db.prepare('DELETE FROM tasks WHERE id = ? AND workspace_id = ?'); + stmt.run(taskId, workspaceId); // Log deletion db_helpers.logActivity( 'task_deleted', 'task', taskId, - getUserFromRequest(request)?.username || 'system', + auth.user.username, `Deleted task: ${task.title}`, { title: task.title, status: task.status, assigned_to: task.assigned_to - } + }, + workspaceId ); // Broadcast to SSE clients diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 09ae207..ec04cc9 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -6,13 +6,13 @@ import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation'; -function hasAegisApproval(db: ReturnType, taskId: number): boolean { +function hasAegisApproval(db: ReturnType, taskId: number, workspaceId: number): boolean { const review = db.prepare(` SELECT status FROM quality_reviews - WHERE task_id = ? AND reviewer = 'aegis' + WHERE task_id = ? AND reviewer = 'aegis' AND workspace_id = ? ORDER BY created_at DESC LIMIT 1 - `).get(taskId) as { status?: string } | undefined + `).get(taskId, workspaceId) as { status?: string } | undefined return review?.status === 'approved' } @@ -26,6 +26,7 @@ export async function GET(request: NextRequest) { try { const db = getDatabase(); + const workspaceId = auth.user.workspace_id; const { searchParams } = new URL(request.url); // Parse query parameters @@ -36,8 +37,8 @@ export async function GET(request: NextRequest) { const offset = parseInt(searchParams.get('offset') || '0'); // Build dynamic query - let query = 'SELECT * FROM tasks WHERE 1=1'; - const params: any[] = []; + let query = 'SELECT * FROM tasks WHERE workspace_id = ?'; + const params: any[] = [workspaceId]; if (status) { query += ' AND status = ?'; @@ -68,8 +69,8 @@ export async function GET(request: NextRequest) { })); // Get total count for pagination - let countQuery = 'SELECT COUNT(*) as total FROM tasks WHERE 1=1'; - const countParams: any[] = []; + let countQuery = 'SELECT COUNT(*) as total FROM tasks WHERE workspace_id = ?'; + const countParams: any[] = [workspaceId]; if (status) { countQuery += ' AND status = ?'; countParams.push(status); @@ -103,6 +104,7 @@ export async function POST(request: NextRequest) { try { const db = getDatabase(); + const workspaceId = auth.user.workspace_id; const validated = await validateBody(request, createTaskSchema); if ('error' in validated) return validated.error; const body = validated.data; @@ -122,7 +124,7 @@ export async function POST(request: NextRequest) { } = body; // Check for duplicate title - const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ?').get(title); + const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ? AND workspace_id = ?').get(title, workspaceId); if (existingTask) { return NextResponse.json({ error: 'Task with this title already exists' }, { status: 409 }); } @@ -132,8 +134,8 @@ export async function POST(request: NextRequest) { const stmt = db.prepare(` INSERT INTO tasks ( title, description, status, priority, assigned_to, created_by, - created_at, updated_at, due_date, estimated_hours, tags, metadata - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const dbResult = stmt.run( @@ -148,7 +150,8 @@ export async function POST(request: NextRequest) { due_date, estimated_hours, JSON.stringify(tags), - JSON.stringify(metadata) + JSON.stringify(metadata), + workspaceId ); const taskId = dbResult.lastInsertRowid as number; @@ -159,27 +162,28 @@ export async function POST(request: NextRequest) { status, priority, assigned_to - }); + }, workspaceId); if (created_by) { - db_helpers.ensureTaskSubscription(taskId, created_by) + db_helpers.ensureTaskSubscription(taskId, created_by, workspaceId) } // Create notification if assigned if (assigned_to) { - db_helpers.ensureTaskSubscription(taskId, assigned_to) + db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId) db_helpers.createNotification( assigned_to, 'assignment', 'Task Assigned', `You have been assigned to task: ${title}`, 'task', - taskId + taskId, + workspaceId ); } // Fetch the created task - const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task; + const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId) as Task; const parsedTask = { ...createdTask, tags: JSON.parse(createdTask.tags || '[]'), @@ -208,6 +212,7 @@ export async function PUT(request: NextRequest) { try { const db = getDatabase(); + const workspaceId = auth.user.workspace_id; const validated = await validateBody(request, bulkUpdateTaskStatusSchema); if ('error' in validated) return validated.error; const { tasks } = validated.data; @@ -217,20 +222,21 @@ export async function PUT(request: NextRequest) { const updateStmt = db.prepare(` UPDATE tasks SET status = ?, updated_at = ? - WHERE id = ? + WHERE id = ? AND workspace_id = ? `); const actor = auth.user.username const transaction = db.transaction((tasksToUpdate: any[]) => { for (const task of tasksToUpdate) { - const oldTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(task.id) as Task; + const oldTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(task.id, workspaceId) as Task; + if (!oldTask) continue; - if (task.status === 'done' && !hasAegisApproval(db, task.id)) { + if (task.status === 'done' && !hasAegisApproval(db, task.id, workspaceId)) { throw new Error(`Aegis approval required for task ${task.id}`) } - updateStmt.run(task.status, now, task.id); + updateStmt.run(task.status, now, task.id, workspaceId); // Log status change if different if (oldTask && oldTask.status !== task.status) { @@ -240,7 +246,8 @@ export async function PUT(request: NextRequest) { task.id, actor, `Task moved from ${oldTask.status} to ${task.status}`, - { oldStatus: oldTask.status, newStatus: task.status } + { oldStatus: oldTask.status, newStatus: task.status }, + workspaceId ); } } diff --git a/src/components/markdown-renderer.tsx b/src/components/markdown-renderer.tsx new file mode 100644 index 0000000..e9bbbea --- /dev/null +++ b/src/components/markdown-renderer.tsx @@ -0,0 +1,64 @@ +'use client' + +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' + +interface MarkdownRendererProps { + content: string + preview?: boolean +} + +function getPreviewContent(content: string): string { + const firstParagraph = content.trim().split(/\n\s*\n/)[0] || '' + if (firstParagraph.length <= 240) return firstParagraph + return `${firstParagraph.slice(0, 240)}...` +} + +export function MarkdownRenderer({ content, preview = false }: MarkdownRendererProps) { + if (!content?.trim()) return null + + const markdownContent = preview ? getPreviewContent(content) : content + + return ( +
+

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + code: ({ children, className }) => { + const isInline = !className + if (isInline) { + return {children} + } + return ( + + {children} + + ) + }, + blockquote: ({ children }) => ( +
    + {children} +
    + ), + a: ({ href, children }) => ( + + {children} + + ), + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + }} + > + {markdownContent} +
    +
    + ) +} + diff --git a/src/components/panels/agent-detail-tabs.tsx b/src/components/panels/agent-detail-tabs.tsx index 10cfe61..7f95ed2 100644 --- a/src/components/panels/agent-detail-tabs.tsx +++ b/src/components/panels/agent-detail-tabs.tsx @@ -808,6 +808,12 @@ const MODEL_TIER_LABELS: Record = { haiku: 'Haiku $', } +const DEFAULT_MODEL_BY_TIER: Record<'opus' | 'sonnet' | 'haiku', string> = { + opus: 'anthropic/claude-opus-4-5', + sonnet: 'anthropic/claude-sonnet-4-20250514', + haiku: 'anthropic/claude-haiku-4-5', +} + // Enhanced Create Agent Modal with Template Wizard export function CreateAgentModal({ onClose, @@ -818,12 +824,14 @@ export function CreateAgentModal({ }) { const [step, setStep] = useState<1 | 2 | 3>(1) const [selectedTemplate, setSelectedTemplate] = useState(null) + const [availableModels, setAvailableModels] = useState([]) const [formData, setFormData] = useState({ name: '', id: '', role: '', emoji: '', - model: 'sonnet', + modelTier: 'sonnet' as 'opus' | 'sonnet' | 'haiku', + modelPrimary: DEFAULT_MODEL_BY_TIER.sonnet, workspaceAccess: 'rw' as 'rw' | 'ro' | 'none', sandboxMode: 'all' as 'all' | 'non-main', dockerNetwork: 'none' as 'none' | 'bridge', @@ -841,6 +849,24 @@ export function CreateAgentModal({ setFormData(prev => ({ ...prev, name, id })) } + useEffect(() => { + const loadAvailableModels = async () => { + try { + const response = await fetch('/api/status?action=models') + if (!response.ok) return + const data = await response.json() + const models = Array.isArray(data.models) ? data.models : [] + const names = models + .map((model: any) => String(model.name || model.alias || '').trim()) + .filter(Boolean) + setAvailableModels(Array.from(new Set(names))) + } catch { + // Keep modal usable without model suggestions. + } + } + loadAvailableModels() + }, []) + // When template is selected, pre-fill form const selectTemplate = (type: string | null) => { setSelectedTemplate(type) @@ -851,7 +877,8 @@ export function CreateAgentModal({ ...prev, role: tmpl.theme, emoji: tmpl.emoji, - model: tmpl.modelTier === 'opus' ? 'opus' : tmpl.modelTier === 'haiku' ? 'haiku' : 'sonnet', + modelTier: tmpl.modelTier, + modelPrimary: DEFAULT_MODEL_BY_TIER[tmpl.modelTier], workspaceAccess: type === 'researcher' || type === 'content-creator' ? 'none' : type === 'reviewer' || type === 'security-auditor' ? 'ro' : 'rw', sandboxMode: type === 'orchestrator' ? 'non-main' : 'all', dockerNetwork: type === 'developer' || type === 'specialist-dev' ? 'bridge' : 'none', @@ -868,6 +895,7 @@ export function CreateAgentModal({ setIsCreating(true) setError(null) try { + const primaryModel = formData.modelPrimary.trim() || DEFAULT_MODEL_BY_TIER[formData.modelTier] const response = await fetch('/api/agents', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -878,7 +906,7 @@ export function CreateAgentModal({ template: selectedTemplate || undefined, write_to_gateway: formData.write_to_gateway, gateway_config: { - model: { primary: `anthropic/claude-${formData.model === 'opus' ? 'opus-4-5' : formData.model === 'haiku' ? 'haiku-4-5' : 'sonnet-4-20250514'}` }, + model: { primary: primaryModel }, identity: { name: formData.name, theme: formData.role, emoji: formData.emoji }, sandbox: { mode: formData.sandboxMode, @@ -1033,14 +1061,18 @@ export function CreateAgentModal({
    - +
    {(['opus', 'sonnet', 'haiku'] as const).map(tier => (
    +
    + + setFormData(prev => ({ ...prev, modelPrimary: e.target.value }))} + list="create-agent-model-suggestions" + className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50 font-mono text-sm" + placeholder={DEFAULT_MODEL_BY_TIER[formData.modelTier]} + /> + + {availableModels.map((name) => ( + +
    +
    @@ -1114,8 +1163,9 @@ export function CreateAgentModal({
    ID: {formData.id}
    Template: {selectedTemplateData?.label || 'Custom'}
    -
    Model: {MODEL_TIER_LABELS[formData.model]}
    +
    Model: {MODEL_TIER_LABELS[formData.modelTier]}
    Tools: {selectedTemplateData?.toolCount || 'Custom'}
    +
    Primary Model: {formData.modelPrimary || DEFAULT_MODEL_BY_TIER[formData.modelTier]}
    Workspace: {formData.workspaceAccess}
    Sandbox: {formData.sandboxMode}
    Network: {formData.dockerNetwork}
    diff --git a/src/components/panels/agent-squad-panel-phase3.tsx b/src/components/panels/agent-squad-panel-phase3.tsx index 626b93d..f3f03a9 100644 --- a/src/components/panels/agent-squad-panel-phase3.tsx +++ b/src/components/panels/agent-squad-panel-phase3.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { useSmartPoll } from '@/lib/use-smart-poll' +import { AgentAvatar } from '@/components/ui/agent-avatar' import { OverviewTab, SoulTab, @@ -319,9 +320,12 @@ export function AgentSquadPanelPhase3() { > {/* Agent Header */}
    -
    -

    {agent.name}

    -

    {agent.role}

    +
    + +
    +

    {agent.name}

    +

    {agent.role}

    +
    diff --git a/src/components/panels/cron-management-panel.tsx b/src/components/panels/cron-management-panel.tsx index 79cf3f8..ec2bac1 100644 --- a/src/components/panels/cron-management-panel.tsx +++ b/src/components/panels/cron-management-panel.tsx @@ -8,6 +8,43 @@ interface NewJobForm { schedule: string command: string description: string + model: string +} + +type CalendarViewMode = 'agenda' | 'day' | 'week' | 'month' + +function startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()) +} + +function addDays(date: Date, days: number): Date { + const next = new Date(date) + next.setDate(next.getDate() + days) + return next +} + +function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) +} + +function getWeekStart(date: Date): Date { + const day = date.getDay() + const diffToMonday = (day + 6) % 7 + return addDays(startOfDay(date), -diffToMonday) +} + +function getMonthStartGrid(date: Date): Date { + const firstOfMonth = new Date(date.getFullYear(), date.getMonth(), 1) + const day = firstOfMonth.getDay() + return addDays(firstOfMonth, -day) +} + +function formatDateLabel(date: Date): string { + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) } export function CronManagementPanel() { @@ -16,11 +53,18 @@ export function CronManagementPanel() { const [showAddForm, setShowAddForm] = useState(false) const [selectedJob, setSelectedJob] = useState(null) const [jobLogs, setJobLogs] = useState([]) + const [availableModels, setAvailableModels] = useState([]) + const [calendarView, setCalendarView] = useState('week') + const [calendarDate, setCalendarDate] = useState(startOfDay(new Date())) + const [searchQuery, setSearchQuery] = useState('') + const [agentFilter, setAgentFilter] = useState('all') + const [stateFilter, setStateFilter] = useState<'all' | 'enabled' | 'disabled'>('all') const [newJob, setNewJob] = useState({ name: '', schedule: '0 * * * *', // Every hour command: '', - description: '' + description: '', + model: '' }) const formatRelativeTime = (timestamp: string | number, future = false) => { @@ -56,6 +100,24 @@ export function CronManagementPanel() { loadCronJobs() }, [loadCronJobs]) + useEffect(() => { + const loadAvailableModels = async () => { + try { + const response = await fetch('/api/status?action=models') + if (!response.ok) return + const data = await response.json() + const models = Array.isArray(data.models) ? data.models : [] + const names = models + .map((model: any) => String(model.name || model.alias || '').trim()) + .filter(Boolean) + setAvailableModels(Array.from(new Set(names))) + } catch { + // Keep cron form usable even when model discovery is unavailable. + } + } + loadAvailableModels() + }, []) + const loadJobLogs = async (jobName: string) => { try { const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(jobName)}`) @@ -130,7 +192,8 @@ export function CronManagementPanel() { action: 'add', jobName: newJob.name, schedule: newJob.schedule, - command: newJob.command + command: newJob.command, + ...(newJob.model.trim() ? { model: newJob.model.trim() } : {}) }) }) @@ -139,7 +202,8 @@ export function CronManagementPanel() { name: '', schedule: '0 * * * *', command: '', - description: '' + description: '', + model: '' }) setShowAddForm(false) await loadCronJobs() @@ -217,6 +281,82 @@ export function CronManagementPanel() { { label: 'Monthly (1st)', value: '0 0 1 * *' }, ] + const uniqueAgents = Array.from( + new Set( + cronJobs + .map((job) => (job.agentId || '').trim()) + .filter(Boolean) + ) + ) + + const filteredJobs = cronJobs.filter((job) => { + const query = searchQuery.trim().toLowerCase() + const matchesQuery = + !query || + job.name.toLowerCase().includes(query) || + job.command.toLowerCase().includes(query) || + (job.agentId || '').toLowerCase().includes(query) || + (job.model || '').toLowerCase().includes(query) + + const matchesAgent = agentFilter === 'all' || (job.agentId || '') === agentFilter + const matchesState = + stateFilter === 'all' || + (stateFilter === 'enabled' && job.enabled) || + (stateFilter === 'disabled' && !job.enabled) + + return matchesQuery && matchesAgent && matchesState + }) + + const agendaJobs = [...filteredJobs].sort((a, b) => { + const aRun = typeof a.nextRun === 'number' ? a.nextRun : Number.POSITIVE_INFINITY + const bRun = typeof b.nextRun === 'number' ? b.nextRun : Number.POSITIVE_INFINITY + return aRun - bRun + }) + + const dayStart = startOfDay(calendarDate) + const dayEnd = addDays(dayStart, 1) + const dayJobs = filteredJobs + .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= dayStart.getTime() && job.nextRun < dayEnd.getTime()) + .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0)) + + const weekStart = getWeekStart(calendarDate) + const weekDays = Array.from({ length: 7 }, (_, idx) => addDays(weekStart, idx)) + const jobsByWeekDay = weekDays.map((date) => { + const start = startOfDay(date).getTime() + const end = addDays(date, 1).getTime() + const jobs = filteredJobs + .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= start && job.nextRun < end) + .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0)) + return { date, jobs } + }) + + const monthGridStart = getMonthStartGrid(calendarDate) + const monthDays = Array.from({ length: 42 }, (_, idx) => addDays(monthGridStart, idx)) + const jobsByMonthDay = monthDays.map((date) => { + const start = startOfDay(date).getTime() + const end = addDays(date, 1).getTime() + const jobs = filteredJobs + .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= start && job.nextRun < end) + .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0)) + return { date, jobs } + }) + + const moveCalendar = (direction: -1 | 1) => { + setCalendarDate((prev) => { + if (calendarView === 'day') return addDays(prev, direction) + if (calendarView === 'week') return addDays(prev, direction * 7) + if (calendarView === 'month') return new Date(prev.getFullYear(), prev.getMonth() + direction, 1) + return addDays(prev, direction * 7) + }) + } + + const calendarRangeLabel = + calendarView === 'day' + ? calendarDate.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric', year: 'numeric' }) + : calendarView === 'week' + ? `${formatDateLabel(weekDays[0])} - ${formatDateLabel(weekDays[6])}` + : calendarDate.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) + return (
    @@ -246,6 +386,176 @@ export function CronManagementPanel() {
    + {/* Calendar View - Phase A (read-only) */} +
    +
    +
    +
    +

    Calendar View

    +

    Read-only schedule visibility across all cron jobs

    +
    +
    + + + +
    {calendarRangeLabel}
    +
    +
    + +
    + {(['agenda', 'day', 'week', 'month'] as CalendarViewMode[]).map((mode) => ( + + ))} +
    + +
    + setSearchQuery(e.target.value)} + placeholder="Search jobs, agents, models..." + className="px-3 py-2 border border-border rounded-md bg-background text-foreground text-sm" + /> + + +
    + + {calendarView === 'agenda' && ( +
    +
    + {agendaJobs.length === 0 ? ( +
    No jobs match the current filters.
    + ) : ( + agendaJobs.map((job) => ( +
    +
    +
    {job.name}
    +
    + {job.agentId || 'system'} · {job.enabled ? 'enabled' : 'disabled'} · {job.schedule} +
    +
    +
    + {job.nextRun ? new Date(job.nextRun).toLocaleString() : 'No upcoming run'} +
    +
    + )) + )} +
    +
    + )} + + {calendarView === 'day' && ( +
    + {dayJobs.length === 0 ? ( +
    No scheduled jobs for this day.
    + ) : ( +
    + {dayJobs.map((job) => ( +
    +
    {job.name}
    +
    + {job.nextRun ? new Date(job.nextRun).toLocaleTimeString() : 'Unknown time'} · {job.agentId || 'system'} · {job.enabled ? 'enabled' : 'disabled'} +
    +
    + ))} +
    + )} +
    + )} + + {calendarView === 'week' && ( +
    + {jobsByWeekDay.map(({ date, jobs }) => ( +
    +
    + {date.toLocaleDateString(undefined, { weekday: 'short', month: 'numeric', day: 'numeric' })} +
    +
    + {jobs.slice(0, 4).map((job) => ( +
    + {job.nextRun ? new Date(job.nextRun).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '--:--'} {job.name} +
    + ))} + {jobs.length > 4 && ( +
    +{jobs.length - 4} more
    + )} +
    +
    + ))} +
    + )} + + {calendarView === 'month' && ( +
    + {jobsByMonthDay.map(({ date, jobs }) => { + const inCurrentMonth = date.getMonth() === calendarDate.getMonth() + return ( +
    +
    + {date.getDate()} +
    +
    + {jobs.slice(0, 2).map((job) => ( +
    + {job.name} +
    + ))} + {jobs.length > 2 &&
    +{jobs.length - 2}
    } +
    +
    + ) + })} +
    + )} +
    +
    + {/* Job List */}

    Scheduled Jobs

    @@ -304,6 +614,11 @@ export function CronManagementPanel() {
    {job.command}
    + {job.model && ( +
    + Model: {job.model} +
    + )} {job.lastRun && (
    Last run: {formatRelativeTime(job.lastRun)} @@ -368,6 +683,9 @@ export function CronManagementPanel() {
    Schedule: {selectedJob.schedule}
    Command: {selectedJob.command}
    + {selectedJob.model && ( +
    Model: {selectedJob.model}
    + )}
    Status: {selectedJob.enabled ? '🟢 Enabled' : '🔴 Disabled'}
    {selectedJob.nextRun && (
    Next run: {new Date(selectedJob.nextRun).toLocaleString()}
    @@ -454,6 +772,26 @@ export function CronManagementPanel() { />
    +
    + + setNewJob(prev => ({ ...prev, model: e.target.value }))} + list="cron-model-suggestions" + placeholder="anthropic/claude-sonnet-4-20250514" + className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground font-mono text-sm" + /> + + {availableModels.map((modelName) => ( + +
    + Leave empty to use the agent or gateway default model. +
    +
    +
    {task.description && ( -

    - {task.description} -

    +
    + +
    )}
    - {getAgentName(task.assigned_to)} + + {task.assigned_to ? ( + <> + + {getAgentName(task.assigned_to)} + + ) : ( + Unassigned + )} + {formatTaskTimestamp(task.created_at)}
    @@ -620,7 +631,13 @@ function TaskDetailModal({
    -

    {task.description || 'No description'}

    + {task.description ? ( +
    + +
    + ) : ( +

    No description

    + )}
    {(['details', 'comments', 'quality'] as const).map(tab => (