nyk commited on
Commit
4e0867d
·
unverified ·
1 Parent(s): 2cf3427

feat: phase 1 workspace isolation across auth and core APIs (#112)

Browse files

* 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

Files changed (46) hide show
  1. package.json +2 -0
  2. pnpm-lock.yaml +183 -0
  3. src/app/api/activities/route.ts +22 -21
  4. src/app/api/agents/[id]/heartbeat/route.ts +20 -13
  5. src/app/api/agents/[id]/memory/route.ts +21 -16
  6. src/app/api/agents/[id]/route.ts +17 -12
  7. src/app/api/agents/[id]/soul/route.ts +12 -9
  8. src/app/api/agents/[id]/wake/route.ts +4 -3
  9. src/app/api/agents/comms/route.ts +16 -11
  10. src/app/api/agents/message/route.ts +8 -3
  11. src/app/api/agents/route.ts +32 -19
  12. src/app/api/alerts/route.ts +47 -34
  13. src/app/api/auth/google/route.ts +3 -2
  14. src/app/api/auth/login/route.ts +2 -1
  15. src/app/api/auth/me/route.ts +3 -1
  16. src/app/api/auth/users/route.ts +28 -7
  17. src/app/api/chat/conversations/route.ts +10 -8
  18. src/app/api/chat/messages/[id]/route.ts +12 -4
  19. src/app/api/chat/messages/route.ts +32 -17
  20. src/app/api/connect/route.ts +26 -17
  21. src/app/api/cron/route.ts +2 -1
  22. src/app/api/export/route.ts +9 -2
  23. src/app/api/github/route.ts +58 -28
  24. src/app/api/notifications/deliver/route.ts +21 -17
  25. src/app/api/notifications/route.ts +29 -25
  26. src/app/api/pipelines/route.ts +34 -16
  27. src/app/api/pipelines/run/route.ts +39 -34
  28. src/app/api/quality-review/route.ts +16 -11
  29. src/app/api/search/route.ts +7 -6
  30. src/app/api/standup/route.ts +33 -18
  31. src/app/api/status/route.ts +21 -15
  32. src/app/api/tasks/[id]/broadcast/route.ts +11 -6
  33. src/app/api/tasks/[id]/comments/route.ts +28 -15
  34. src/app/api/tasks/[id]/route.ts +38 -21
  35. src/app/api/tasks/route.ts +28 -21
  36. src/components/markdown-renderer.tsx +64 -0
  37. src/components/panels/agent-detail-tabs.tsx +57 -7
  38. src/components/panels/agent-squad-panel-phase3.tsx +7 -3
  39. src/components/panels/cron-management-panel.tsx +341 -3
  40. src/components/panels/task-board-panel.tsx +32 -6
  41. src/components/ui/agent-avatar.tsx +58 -0
  42. src/lib/__tests__/db-helpers.test.ts +4 -4
  43. src/lib/auth.ts +40 -10
  44. src/lib/db.ts +48 -25
  45. src/lib/migrations.ts +96 -0
  46. src/store/index.ts +4 -0
package.json CHANGED
@@ -29,8 +29,10 @@
29
  "postcss": "^8.5.2",
30
  "react": "^19.0.1",
31
  "react-dom": "^19.0.1",
 
32
  "reactflow": "^11.11.4",
33
  "recharts": "^3.7.0",
 
34
  "tailwind-merge": "^3.4.0",
35
  "tailwindcss": "^3.4.17",
36
  "typescript": "^5.7.2",
 
29
  "postcss": "^8.5.2",
30
  "react": "^19.0.1",
31
  "react-dom": "^19.0.1",
32
+ "react-markdown": "^10.1.0",
33
  "reactflow": "^11.11.4",
34
  "recharts": "^3.7.0",
35
+ "remark-gfm": "^4.0.1",
36
  "tailwind-merge": "^3.4.0",
37
  "tailwindcss": "^3.4.17",
38
  "typescript": "^5.7.2",
pnpm-lock.yaml CHANGED
@@ -47,12 +47,18 @@ importers:
47
  react-dom:
48
  specifier: ^19.0.1
49
  version: 19.2.4(react@19.2.4)
 
 
 
50
  reactflow:
51
  specifier: ^11.11.4
52
  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)
53
  recharts:
54
  specifier: ^3.7.0
55
  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)
 
 
 
56
  tailwind-merge:
57
  specifier: ^3.4.0
58
  version: 3.4.0
@@ -1286,6 +1292,9 @@ packages:
1286
  '@types/debug@4.1.12':
1287
  resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
1288
 
 
 
 
1289
  '@types/estree@1.0.8':
1290
  resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
1291
 
@@ -1321,6 +1330,9 @@ packages:
1321
  '@types/react@19.2.13':
1322
  resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==}
1323
 
 
 
 
1324
  '@types/unist@3.0.3':
1325
  resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
1326
 
@@ -1890,6 +1902,9 @@ packages:
1890
  character-entities@2.0.2:
1891
  resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
1892
 
 
 
 
1893
  check-error@2.1.3:
1894
  resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
1895
  engines: {node: '>= 16'}
@@ -2331,6 +2346,9 @@ packages:
2331
  resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
2332
  engines: {node: '>=4.0'}
2333
 
 
 
 
2334
  estree-walker@2.0.2:
2335
  resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
2336
 
@@ -2590,6 +2608,9 @@ packages:
2590
  hast-util-to-html@9.0.5:
2591
  resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
2592
 
 
 
 
2593
  hast-util-to-parse5@8.0.1:
2594
  resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
2595
 
@@ -2625,6 +2646,9 @@ packages:
2625
  resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
2626
  engines: {node: '>=18'}
2627
 
 
 
 
2628
  html-void-elements@3.0.0:
2629
  resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
2630
 
@@ -2682,6 +2706,9 @@ packages:
2682
  ini@1.3.8:
2683
  resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
2684
 
 
 
 
2685
  internal-slot@1.1.0:
2686
  resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
2687
  engines: {node: '>= 0.4'}
@@ -2694,6 +2721,12 @@ packages:
2694
  resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==}
2695
  engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
2696
 
 
 
 
 
 
 
2697
  is-array-buffer@3.0.5:
2698
  resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
2699
  engines: {node: '>= 0.4'}
@@ -2733,6 +2766,9 @@ packages:
2733
  resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
2734
  engines: {node: '>= 0.4'}
2735
 
 
 
 
2736
  is-extglob@2.1.1:
2737
  resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
2738
  engines: {node: '>=0.10.0'}
@@ -2749,6 +2785,9 @@ packages:
2749
  resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
2750
  engines: {node: '>=0.10.0'}
2751
 
 
 
 
2752
  is-identifier@1.0.1:
2753
  resolution: {integrity: sha512-HQ5v4rEJ7REUV54bCd2l5FaD299SGDEn2UPoVXaTHAyGviLq2menVUD2udi3trQ32uvB6LdAh/0ck2EuizrtpA==}
2754
  engines: {node: '>=18'}
@@ -2990,6 +3029,15 @@ packages:
2990
  mdast-util-gfm@3.1.0:
2991
  resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
2992
 
 
 
 
 
 
 
 
 
 
2993
  mdast-util-phrasing@4.1.0:
2994
  resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
2995
 
@@ -3262,6 +3310,9 @@ packages:
3262
  resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
3263
  engines: {node: '>=6'}
3264
 
 
 
 
3265
  parse-ms@4.0.0:
3266
  resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
3267
  engines: {node: '>=18'}
@@ -3451,6 +3502,12 @@ packages:
3451
  react-is@17.0.2:
3452
  resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
3453
 
 
 
 
 
 
 
3454
  react-redux@9.2.0:
3455
  resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
3456
  peerDependencies:
@@ -3777,6 +3834,12 @@ packages:
3777
  style-mod@4.1.3:
3778
  resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
3779
 
 
 
 
 
 
 
3780
  styled-jsx@5.1.6:
3781
  resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
3782
  engines: {node: '>= 12.0.0'}
@@ -5702,6 +5765,10 @@ snapshots:
5702
  dependencies:
5703
  '@types/ms': 2.1.0
5704
 
 
 
 
 
5705
  '@types/estree@1.0.8': {}
5706
 
5707
  '@types/geojson@7946.0.16': {}
@@ -5734,6 +5801,8 @@ snapshots:
5734
  dependencies:
5735
  csstype: 3.2.3
5736
 
 
 
5737
  '@types/unist@3.0.3': {}
5738
 
5739
  '@types/use-sync-external-store@0.0.6': {}
@@ -6336,6 +6405,8 @@ snapshots:
6336
 
6337
  character-entities@2.0.2: {}
6338
 
 
 
6339
  check-error@2.1.3: {}
6340
 
6341
  chokidar@3.6.0:
@@ -6914,6 +6985,8 @@ snapshots:
6914
 
6915
  estraverse@5.3.0: {}
6916
 
 
 
6917
  estree-walker@2.0.2: {}
6918
 
6919
  estree-walker@3.0.3:
@@ -7211,6 +7284,26 @@ snapshots:
7211
  stringify-entities: 4.0.4
7212
  zwitch: 2.0.4
7213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7214
  hast-util-to-parse5@8.0.1:
7215
  dependencies:
7216
  '@types/hast': 3.0.4
@@ -7258,6 +7351,8 @@ snapshots:
7258
  dependencies:
7259
  whatwg-encoding: 3.1.1
7260
 
 
 
7261
  html-void-elements@3.0.0: {}
7262
 
7263
  html-whitespace-sensitive-tag-names@3.0.1: {}
@@ -7307,6 +7402,8 @@ snapshots:
7307
 
7308
  ini@1.3.8: {}
7309
 
 
 
7310
  internal-slot@1.1.0:
7311
  dependencies:
7312
  es-errors: 1.3.0
@@ -7317,6 +7414,13 @@ snapshots:
7317
 
7318
  is-absolute-url@4.0.1: {}
7319
 
 
 
 
 
 
 
 
7320
  is-array-buffer@3.0.5:
7321
  dependencies:
7322
  call-bind: 1.0.8
@@ -7365,6 +7469,8 @@ snapshots:
7365
  call-bound: 1.0.4
7366
  has-tostringtag: 1.0.2
7367
 
 
 
7368
  is-extglob@2.1.1: {}
7369
 
7370
  is-finalizationregistry@1.1.1:
@@ -7383,6 +7489,8 @@ snapshots:
7383
  dependencies:
7384
  is-extglob: 2.1.1
7385
 
 
 
7386
  is-identifier@1.0.1:
7387
  dependencies:
7388
  identifier-regex: 1.0.1
@@ -7671,6 +7779,45 @@ snapshots:
7671
  transitivePeerDependencies:
7672
  - supports-color
7673
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7674
  mdast-util-phrasing@4.1.0:
7675
  dependencies:
7676
  '@types/mdast': 4.0.4
@@ -8066,6 +8213,16 @@ snapshots:
8066
  dependencies:
8067
  callsites: 3.1.0
8068
 
 
 
 
 
 
 
 
 
 
 
8069
  parse-ms@4.0.0: {}
8070
 
8071
  parse5@7.3.0:
@@ -8267,6 +8424,24 @@ snapshots:
8267
 
8268
  react-is@17.0.2: {}
8269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8270
  react-redux@9.2.0(@types/react@19.2.13)(react@19.2.4)(redux@5.0.1):
8271
  dependencies:
8272
  '@types/use-sync-external-store': 0.0.6
@@ -8735,6 +8910,14 @@ snapshots:
8735
 
8736
  style-mod@4.1.3: {}
8737
 
 
 
 
 
 
 
 
 
8738
  styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4):
8739
  dependencies:
8740
  client-only: 0.0.1
 
47
  react-dom:
48
  specifier: ^19.0.1
49
  version: 19.2.4(react@19.2.4)
50
+ react-markdown:
51
+ specifier: ^10.1.0
52
+ version: 10.1.0(@types/react@19.2.13)(react@19.2.4)
53
  reactflow:
54
  specifier: ^11.11.4
55
  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)
56
  recharts:
57
  specifier: ^3.7.0
58
  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)
59
+ remark-gfm:
60
+ specifier: ^4.0.1
61
+ version: 4.0.1
62
  tailwind-merge:
63
  specifier: ^3.4.0
64
  version: 3.4.0
 
1292
  '@types/debug@4.1.12':
1293
  resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
1294
 
1295
+ '@types/estree-jsx@1.0.5':
1296
+ resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
1297
+
1298
  '@types/estree@1.0.8':
1299
  resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
1300
 
 
1330
  '@types/react@19.2.13':
1331
  resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==}
1332
 
1333
+ '@types/unist@2.0.11':
1334
+ resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
1335
+
1336
  '@types/unist@3.0.3':
1337
  resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
1338
 
 
1902
  character-entities@2.0.2:
1903
  resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
1904
 
1905
+ character-reference-invalid@2.0.1:
1906
+ resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
1907
+
1908
  check-error@2.1.3:
1909
  resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
1910
  engines: {node: '>= 16'}
 
2346
  resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
2347
  engines: {node: '>=4.0'}
2348
 
2349
+ estree-util-is-identifier-name@3.0.0:
2350
+ resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
2351
+
2352
  estree-walker@2.0.2:
2353
  resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
2354
 
 
2608
  hast-util-to-html@9.0.5:
2609
  resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
2610
 
2611
+ hast-util-to-jsx-runtime@2.3.6:
2612
+ resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
2613
+
2614
  hast-util-to-parse5@8.0.1:
2615
  resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
2616
 
 
2646
  resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
2647
  engines: {node: '>=18'}
2648
 
2649
+ html-url-attributes@3.0.1:
2650
+ resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
2651
+
2652
  html-void-elements@3.0.0:
2653
  resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
2654
 
 
2706
  ini@1.3.8:
2707
  resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
2708
 
2709
+ inline-style-parser@0.2.7:
2710
+ resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
2711
+
2712
  internal-slot@1.1.0:
2713
  resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
2714
  engines: {node: '>= 0.4'}
 
2721
  resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==}
2722
  engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
2723
 
2724
+ is-alphabetical@2.0.1:
2725
+ resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
2726
+
2727
+ is-alphanumerical@2.0.1:
2728
+ resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
2729
+
2730
  is-array-buffer@3.0.5:
2731
  resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
2732
  engines: {node: '>= 0.4'}
 
2766
  resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
2767
  engines: {node: '>= 0.4'}
2768
 
2769
+ is-decimal@2.0.1:
2770
+ resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
2771
+
2772
  is-extglob@2.1.1:
2773
  resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
2774
  engines: {node: '>=0.10.0'}
 
2785
  resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
2786
  engines: {node: '>=0.10.0'}
2787
 
2788
+ is-hexadecimal@2.0.1:
2789
+ resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
2790
+
2791
  is-identifier@1.0.1:
2792
  resolution: {integrity: sha512-HQ5v4rEJ7REUV54bCd2l5FaD299SGDEn2UPoVXaTHAyGviLq2menVUD2udi3trQ32uvB6LdAh/0ck2EuizrtpA==}
2793
  engines: {node: '>=18'}
 
3029
  mdast-util-gfm@3.1.0:
3030
  resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
3031
 
3032
+ mdast-util-mdx-expression@2.0.1:
3033
+ resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
3034
+
3035
+ mdast-util-mdx-jsx@3.2.0:
3036
+ resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
3037
+
3038
+ mdast-util-mdxjs-esm@2.0.1:
3039
+ resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
3040
+
3041
  mdast-util-phrasing@4.1.0:
3042
  resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
3043
 
 
3310
  resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
3311
  engines: {node: '>=6'}
3312
 
3313
+ parse-entities@4.0.2:
3314
+ resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
3315
+
3316
  parse-ms@4.0.0:
3317
  resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
3318
  engines: {node: '>=18'}
 
3502
  react-is@17.0.2:
3503
  resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
3504
 
3505
+ react-markdown@10.1.0:
3506
+ resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
3507
+ peerDependencies:
3508
+ '@types/react': '>=18'
3509
+ react: '>=18'
3510
+
3511
  react-redux@9.2.0:
3512
  resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
3513
  peerDependencies:
 
3834
  style-mod@4.1.3:
3835
  resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
3836
 
3837
+ style-to-js@1.1.21:
3838
+ resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
3839
+
3840
+ style-to-object@1.0.14:
3841
+ resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
3842
+
3843
  styled-jsx@5.1.6:
3844
  resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
3845
  engines: {node: '>= 12.0.0'}
 
5765
  dependencies:
5766
  '@types/ms': 2.1.0
5767
 
5768
+ '@types/estree-jsx@1.0.5':
5769
+ dependencies:
5770
+ '@types/estree': 1.0.8
5771
+
5772
  '@types/estree@1.0.8': {}
5773
 
5774
  '@types/geojson@7946.0.16': {}
 
5801
  dependencies:
5802
  csstype: 3.2.3
5803
 
5804
+ '@types/unist@2.0.11': {}
5805
+
5806
  '@types/unist@3.0.3': {}
5807
 
5808
  '@types/use-sync-external-store@0.0.6': {}
 
6405
 
6406
  character-entities@2.0.2: {}
6407
 
6408
+ character-reference-invalid@2.0.1: {}
6409
+
6410
  check-error@2.1.3: {}
6411
 
6412
  chokidar@3.6.0:
 
6985
 
6986
  estraverse@5.3.0: {}
6987
 
6988
+ estree-util-is-identifier-name@3.0.0: {}
6989
+
6990
  estree-walker@2.0.2: {}
6991
 
6992
  estree-walker@3.0.3:
 
7284
  stringify-entities: 4.0.4
7285
  zwitch: 2.0.4
7286
 
7287
+ hast-util-to-jsx-runtime@2.3.6:
7288
+ dependencies:
7289
+ '@types/estree': 1.0.8
7290
+ '@types/hast': 3.0.4
7291
+ '@types/unist': 3.0.3
7292
+ comma-separated-tokens: 2.0.3
7293
+ devlop: 1.1.0
7294
+ estree-util-is-identifier-name: 3.0.0
7295
+ hast-util-whitespace: 3.0.0
7296
+ mdast-util-mdx-expression: 2.0.1
7297
+ mdast-util-mdx-jsx: 3.2.0
7298
+ mdast-util-mdxjs-esm: 2.0.1
7299
+ property-information: 7.1.0
7300
+ space-separated-tokens: 2.0.2
7301
+ style-to-js: 1.1.21
7302
+ unist-util-position: 5.0.0
7303
+ vfile-message: 4.0.3
7304
+ transitivePeerDependencies:
7305
+ - supports-color
7306
+
7307
  hast-util-to-parse5@8.0.1:
7308
  dependencies:
7309
  '@types/hast': 3.0.4
 
7351
  dependencies:
7352
  whatwg-encoding: 3.1.1
7353
 
7354
+ html-url-attributes@3.0.1: {}
7355
+
7356
  html-void-elements@3.0.0: {}
7357
 
7358
  html-whitespace-sensitive-tag-names@3.0.1: {}
 
7402
 
7403
  ini@1.3.8: {}
7404
 
7405
+ inline-style-parser@0.2.7: {}
7406
+
7407
  internal-slot@1.1.0:
7408
  dependencies:
7409
  es-errors: 1.3.0
 
7414
 
7415
  is-absolute-url@4.0.1: {}
7416
 
7417
+ is-alphabetical@2.0.1: {}
7418
+
7419
+ is-alphanumerical@2.0.1:
7420
+ dependencies:
7421
+ is-alphabetical: 2.0.1
7422
+ is-decimal: 2.0.1
7423
+
7424
  is-array-buffer@3.0.5:
7425
  dependencies:
7426
  call-bind: 1.0.8
 
7469
  call-bound: 1.0.4
7470
  has-tostringtag: 1.0.2
7471
 
7472
+ is-decimal@2.0.1: {}
7473
+
7474
  is-extglob@2.1.1: {}
7475
 
7476
  is-finalizationregistry@1.1.1:
 
7489
  dependencies:
7490
  is-extglob: 2.1.1
7491
 
7492
+ is-hexadecimal@2.0.1: {}
7493
+
7494
  is-identifier@1.0.1:
7495
  dependencies:
7496
  identifier-regex: 1.0.1
 
7779
  transitivePeerDependencies:
7780
  - supports-color
7781
 
7782
+ mdast-util-mdx-expression@2.0.1:
7783
+ dependencies:
7784
+ '@types/estree-jsx': 1.0.5
7785
+ '@types/hast': 3.0.4
7786
+ '@types/mdast': 4.0.4
7787
+ devlop: 1.1.0
7788
+ mdast-util-from-markdown: 2.0.3
7789
+ mdast-util-to-markdown: 2.1.2
7790
+ transitivePeerDependencies:
7791
+ - supports-color
7792
+
7793
+ mdast-util-mdx-jsx@3.2.0:
7794
+ dependencies:
7795
+ '@types/estree-jsx': 1.0.5
7796
+ '@types/hast': 3.0.4
7797
+ '@types/mdast': 4.0.4
7798
+ '@types/unist': 3.0.3
7799
+ ccount: 2.0.1
7800
+ devlop: 1.1.0
7801
+ mdast-util-from-markdown: 2.0.3
7802
+ mdast-util-to-markdown: 2.1.2
7803
+ parse-entities: 4.0.2
7804
+ stringify-entities: 4.0.4
7805
+ unist-util-stringify-position: 4.0.0
7806
+ vfile-message: 4.0.3
7807
+ transitivePeerDependencies:
7808
+ - supports-color
7809
+
7810
+ mdast-util-mdxjs-esm@2.0.1:
7811
+ dependencies:
7812
+ '@types/estree-jsx': 1.0.5
7813
+ '@types/hast': 3.0.4
7814
+ '@types/mdast': 4.0.4
7815
+ devlop: 1.1.0
7816
+ mdast-util-from-markdown: 2.0.3
7817
+ mdast-util-to-markdown: 2.1.2
7818
+ transitivePeerDependencies:
7819
+ - supports-color
7820
+
7821
  mdast-util-phrasing@4.1.0:
7822
  dependencies:
7823
  '@types/mdast': 4.0.4
 
8213
  dependencies:
8214
  callsites: 3.1.0
8215
 
8216
+ parse-entities@4.0.2:
8217
+ dependencies:
8218
+ '@types/unist': 2.0.11
8219
+ character-entities-legacy: 3.0.0
8220
+ character-reference-invalid: 2.0.1
8221
+ decode-named-character-reference: 1.3.0
8222
+ is-alphanumerical: 2.0.1
8223
+ is-decimal: 2.0.1
8224
+ is-hexadecimal: 2.0.1
8225
+
8226
  parse-ms@4.0.0: {}
8227
 
8228
  parse5@7.3.0:
 
8424
 
8425
  react-is@17.0.2: {}
8426
 
8427
+ react-markdown@10.1.0(@types/react@19.2.13)(react@19.2.4):
8428
+ dependencies:
8429
+ '@types/hast': 3.0.4
8430
+ '@types/mdast': 4.0.4
8431
+ '@types/react': 19.2.13
8432
+ devlop: 1.1.0
8433
+ hast-util-to-jsx-runtime: 2.3.6
8434
+ html-url-attributes: 3.0.1
8435
+ mdast-util-to-hast: 13.2.1
8436
+ react: 19.2.4
8437
+ remark-parse: 11.0.0
8438
+ remark-rehype: 11.1.2
8439
+ unified: 11.0.5
8440
+ unist-util-visit: 5.1.0
8441
+ vfile: 6.0.3
8442
+ transitivePeerDependencies:
8443
+ - supports-color
8444
+
8445
  react-redux@9.2.0(@types/react@19.2.13)(react@19.2.4)(redux@5.0.1):
8446
  dependencies:
8447
  '@types/use-sync-external-store': 0.0.6
 
8910
 
8911
  style-mod@4.1.3: {}
8912
 
8913
+ style-to-js@1.1.21:
8914
+ dependencies:
8915
+ style-to-object: 1.0.14
8916
+
8917
+ style-to-object@1.0.14:
8918
+ dependencies:
8919
+ inline-style-parser: 0.2.7
8920
+
8921
  styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4):
8922
  dependencies:
8923
  client-only: 0.0.1
src/app/api/activities/route.ts CHANGED
@@ -13,14 +13,15 @@ export async function GET(request: NextRequest) {
13
 
14
  try {
15
  const { searchParams, pathname } = new URL(request.url);
 
16
 
17
  // Route to stats endpoint if requested
18
  if (pathname.endsWith('/stats') || searchParams.has('stats')) {
19
- return handleStatsRequest(request);
20
  }
21
 
22
  // Default activities endpoint
23
- return handleActivitiesRequest(request);
24
  } catch (error) {
25
  logger.error({ err: error }, 'GET /api/activities error');
26
  return NextResponse.json({ error: 'Failed to process request' }, { status: 500 });
@@ -30,7 +31,7 @@ export async function GET(request: NextRequest) {
30
  /**
31
  * Handle regular activities request
32
  */
33
- async function handleActivitiesRequest(request: NextRequest) {
34
  try {
35
  const db = getDatabase();
36
  const { searchParams } = new URL(request.url);
@@ -44,8 +45,8 @@ async function handleActivitiesRequest(request: NextRequest) {
44
  const since = searchParams.get('since'); // Unix timestamp for real-time updates
45
 
46
  // Build dynamic query
47
- let query = 'SELECT * FROM activities WHERE 1=1';
48
- const params: any[] = [];
49
 
50
  if (type) {
51
  query += ' AND type = ?';
@@ -74,13 +75,13 @@ async function handleActivitiesRequest(request: NextRequest) {
74
  const activities = stmt.all(...params) as Activity[];
75
 
76
  // Prepare entity detail statements once (avoids N+1)
77
- const taskDetailStmt = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?');
78
- const agentDetailStmt = db.prepare('SELECT id, name, role, status FROM agents WHERE id = ?');
79
  const commentDetailStmt = db.prepare(`
80
  SELECT c.id, c.content, c.task_id, t.title as task_title
81
  FROM comments c
82
  LEFT JOIN tasks t ON c.task_id = t.id
83
- WHERE c.id = ?
84
  `);
85
 
86
  // Parse JSON data field and enhance with related entity data
@@ -90,21 +91,21 @@ async function handleActivitiesRequest(request: NextRequest) {
90
  try {
91
  switch (activity.entity_type) {
92
  case 'task': {
93
- const task = taskDetailStmt.get(activity.entity_id) as any;
94
  if (task) {
95
  entityDetails = { type: 'task', ...task };
96
  }
97
  break;
98
  }
99
  case 'agent': {
100
- const agent = agentDetailStmt.get(activity.entity_id) as any;
101
  if (agent) {
102
  entityDetails = { type: 'agent', ...agent };
103
  }
104
  break;
105
  }
106
  case 'comment': {
107
- const comment = commentDetailStmt.get(activity.entity_id) as any;
108
  if (comment) {
109
  entityDetails = {
110
  type: 'comment',
@@ -127,8 +128,8 @@ async function handleActivitiesRequest(request: NextRequest) {
127
  });
128
 
129
  // Get total count for pagination
130
- let countQuery = 'SELECT COUNT(*) as total FROM activities WHERE 1=1';
131
- const countParams: any[] = [];
132
 
133
  if (type) {
134
  countQuery += ' AND type = ?';
@@ -166,7 +167,7 @@ async function handleActivitiesRequest(request: NextRequest) {
166
  /**
167
  * Handle stats request
168
  */
169
- async function handleStatsRequest(request: NextRequest) {
170
  try {
171
  const db = getDatabase();
172
  const { searchParams } = new URL(request.url);
@@ -181,10 +182,10 @@ async function handleStatsRequest(request: NextRequest) {
181
  type,
182
  COUNT(*) as count
183
  FROM activities
184
- WHERE created_at > ?
185
  GROUP BY type
186
  ORDER BY count DESC
187
- `).all(since) as { type: string; count: number }[];
188
 
189
  // Get most active actors
190
  const activeActors = db.prepare(`
@@ -192,11 +193,11 @@ async function handleStatsRequest(request: NextRequest) {
192
  actor,
193
  COUNT(*) as activity_count
194
  FROM activities
195
- WHERE created_at > ?
196
  GROUP BY actor
197
  ORDER BY activity_count DESC
198
  LIMIT 10
199
- `).all(since) as { actor: string; activity_count: number }[];
200
 
201
  // Get activity timeline (hourly buckets)
202
  const timeline = db.prepare(`
@@ -204,10 +205,10 @@ async function handleStatsRequest(request: NextRequest) {
204
  (created_at / 3600) * 3600 as hour_bucket,
205
  COUNT(*) as count
206
  FROM activities
207
- WHERE created_at > ?
208
  GROUP BY hour_bucket
209
  ORDER BY hour_bucket ASC
210
- `).all(since) as { hour_bucket: number; count: number }[];
211
 
212
  return NextResponse.json({
213
  timeframe: `${hours} hours`,
@@ -223,4 +224,4 @@ async function handleStatsRequest(request: NextRequest) {
223
  logger.error({ err: error }, 'GET /api/activities (stats) error');
224
  return NextResponse.json({ error: 'Failed to fetch activity stats' }, { status: 500 });
225
  }
226
- }
 
13
 
14
  try {
15
  const { searchParams, pathname } = new URL(request.url);
16
+ const workspaceId = auth.user.workspace_id ?? 1;
17
 
18
  // Route to stats endpoint if requested
19
  if (pathname.endsWith('/stats') || searchParams.has('stats')) {
20
+ return handleStatsRequest(request, workspaceId);
21
  }
22
 
23
  // Default activities endpoint
24
+ return handleActivitiesRequest(request, workspaceId);
25
  } catch (error) {
26
  logger.error({ err: error }, 'GET /api/activities error');
27
  return NextResponse.json({ error: 'Failed to process request' }, { status: 500 });
 
31
  /**
32
  * Handle regular activities request
33
  */
34
+ async function handleActivitiesRequest(request: NextRequest, workspaceId: number) {
35
  try {
36
  const db = getDatabase();
37
  const { searchParams } = new URL(request.url);
 
45
  const since = searchParams.get('since'); // Unix timestamp for real-time updates
46
 
47
  // Build dynamic query
48
+ let query = 'SELECT * FROM activities WHERE workspace_id = ?';
49
+ const params: any[] = [workspaceId];
50
 
51
  if (type) {
52
  query += ' AND type = ?';
 
75
  const activities = stmt.all(...params) as Activity[];
76
 
77
  // Prepare entity detail statements once (avoids N+1)
78
+ const taskDetailStmt = db.prepare('SELECT id, title, status FROM tasks WHERE id = ? AND workspace_id = ?');
79
+ const agentDetailStmt = db.prepare('SELECT id, name, role, status FROM agents WHERE id = ? AND workspace_id = ?');
80
  const commentDetailStmt = db.prepare(`
81
  SELECT c.id, c.content, c.task_id, t.title as task_title
82
  FROM comments c
83
  LEFT JOIN tasks t ON c.task_id = t.id
84
+ WHERE c.id = ? AND c.workspace_id = ? AND t.workspace_id = ?
85
  `);
86
 
87
  // Parse JSON data field and enhance with related entity data
 
91
  try {
92
  switch (activity.entity_type) {
93
  case 'task': {
94
+ const task = taskDetailStmt.get(activity.entity_id, workspaceId) as any;
95
  if (task) {
96
  entityDetails = { type: 'task', ...task };
97
  }
98
  break;
99
  }
100
  case 'agent': {
101
+ const agent = agentDetailStmt.get(activity.entity_id, workspaceId) as any;
102
  if (agent) {
103
  entityDetails = { type: 'agent', ...agent };
104
  }
105
  break;
106
  }
107
  case 'comment': {
108
+ const comment = commentDetailStmt.get(activity.entity_id, workspaceId, workspaceId) as any;
109
  if (comment) {
110
  entityDetails = {
111
  type: 'comment',
 
128
  });
129
 
130
  // Get total count for pagination
131
+ let countQuery = 'SELECT COUNT(*) as total FROM activities WHERE workspace_id = ?';
132
+ const countParams: any[] = [workspaceId];
133
 
134
  if (type) {
135
  countQuery += ' AND type = ?';
 
167
  /**
168
  * Handle stats request
169
  */
170
+ async function handleStatsRequest(request: NextRequest, workspaceId: number) {
171
  try {
172
  const db = getDatabase();
173
  const { searchParams } = new URL(request.url);
 
182
  type,
183
  COUNT(*) as count
184
  FROM activities
185
+ WHERE created_at > ? AND workspace_id = ?
186
  GROUP BY type
187
  ORDER BY count DESC
188
+ `).all(since, workspaceId) as { type: string; count: number }[];
189
 
190
  // Get most active actors
191
  const activeActors = db.prepare(`
 
193
  actor,
194
  COUNT(*) as activity_count
195
  FROM activities
196
+ WHERE created_at > ? AND workspace_id = ?
197
  GROUP BY actor
198
  ORDER BY activity_count DESC
199
  LIMIT 10
200
+ `).all(since, workspaceId) as { actor: string; activity_count: number }[];
201
 
202
  // Get activity timeline (hourly buckets)
203
  const timeline = db.prepare(`
 
205
  (created_at / 3600) * 3600 as hour_bucket,
206
  COUNT(*) as count
207
  FROM activities
208
+ WHERE created_at > ? AND workspace_id = ?
209
  GROUP BY hour_bucket
210
  ORDER BY hour_bucket ASC
211
+ `).all(since, workspaceId) as { hour_bucket: number; count: number }[];
212
 
213
  return NextResponse.json({
214
  timeframe: `${hours} hours`,
 
224
  logger.error({ err: error }, 'GET /api/activities (stats) error');
225
  return NextResponse.json({ error: 'Failed to fetch activity stats' }, { status: 500 });
226
  }
227
+ }
src/app/api/agents/[id]/heartbeat/route.ts CHANGED
@@ -24,15 +24,16 @@ export async function GET(
24
  const db = getDatabase();
25
  const resolvedParams = await params;
26
  const agentId = resolvedParams.id;
 
27
 
28
  // Get agent by ID or name
29
  let agent: any;
30
  if (isNaN(Number(agentId))) {
31
  // Lookup by name
32
- agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
33
  } else {
34
  // Lookup by ID
35
- agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
36
  }
37
 
38
  if (!agent) {
@@ -49,10 +50,12 @@ export async function GET(
49
  FROM comments c
50
  JOIN tasks t ON c.task_id = t.id
51
  WHERE c.mentions LIKE ?
 
 
52
  AND c.created_at > ?
53
  ORDER BY c.created_at DESC
54
  LIMIT 10
55
- `).all(`%"${agent.name}"%`, fourHoursAgo);
56
 
57
  if (mentions.length > 0) {
58
  workItems.push({
@@ -72,10 +75,11 @@ export async function GET(
72
  const assignedTasks = db.prepare(`
73
  SELECT * FROM tasks
74
  WHERE assigned_to = ?
 
75
  AND status IN ('assigned', 'in_progress')
76
  ORDER BY priority DESC, created_at ASC
77
  LIMIT 10
78
- `).all(agent.name);
79
 
80
  if (assignedTasks.length > 0) {
81
  workItems.push({
@@ -92,7 +96,7 @@ export async function GET(
92
  }
93
 
94
  // 3. Check for unread notifications
95
- const notifications = db_helpers.getUnreadNotifications(agent.name);
96
 
97
  if (notifications.length > 0) {
98
  workItems.push({
@@ -112,11 +116,12 @@ export async function GET(
112
  const urgentActivities = db.prepare(`
113
  SELECT * FROM activities
114
  WHERE type IN ('task_created', 'task_assigned', 'high_priority_alert')
 
115
  AND created_at > ?
116
  AND description LIKE ?
117
  ORDER BY created_at DESC
118
  LIMIT 5
119
- `).all(fourHoursAgo, `%${agent.name}%`);
120
 
121
  if (urgentActivities.length > 0) {
122
  workItems.push({
@@ -132,7 +137,7 @@ export async function GET(
132
  }
133
 
134
  // Update agent last_seen and status to show heartbeat activity
135
- db_helpers.updateAgentStatus(agent.name, 'idle', 'Heartbeat check');
136
 
137
  // Log heartbeat activity
138
  db_helpers.logActivity(
@@ -141,7 +146,8 @@ export async function GET(
141
  agent.id,
142
  agent.name,
143
  `Heartbeat check completed - ${workItems.length > 0 ? `${workItems.length} work items found` : 'no work items'}`,
144
- { workItemsCount: workItems.length, workItemTypes: workItems.map(w => w.type) }
 
145
  );
146
 
147
  if (workItems.length === 0) {
@@ -193,11 +199,12 @@ export async function POST(
193
  const { connection_id, token_usage } = body;
194
  const db = getDatabase();
195
  const now = Math.floor(Date.now() / 1000);
 
196
 
197
  // Update direct connection heartbeat if connection_id provided
198
  if (connection_id) {
199
- db.prepare('UPDATE direct_connections SET last_heartbeat = ?, updated_at = ? WHERE connection_id = ? AND status = ?')
200
- .run(now, now, connection_id, 'connected');
201
  }
202
 
203
  // Inline token reporting
@@ -207,9 +214,9 @@ export async function POST(
207
  const agentId = resolvedParams.id;
208
  let agent: any;
209
  if (isNaN(Number(agentId))) {
210
- agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
211
  } else {
212
- agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
213
  }
214
 
215
  if (agent) {
@@ -230,4 +237,4 @@ export async function POST(
230
  ...getBody,
231
  token_recorded: tokenRecorded,
232
  });
233
- }
 
24
  const db = getDatabase();
25
  const resolvedParams = await params;
26
  const agentId = resolvedParams.id;
27
+ const workspaceId = auth.user.workspace_id ?? 1;
28
 
29
  // Get agent by ID or name
30
  let agent: any;
31
  if (isNaN(Number(agentId))) {
32
  // Lookup by name
33
+ agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
34
  } else {
35
  // Lookup by ID
36
+ agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
37
  }
38
 
39
  if (!agent) {
 
50
  FROM comments c
51
  JOIN tasks t ON c.task_id = t.id
52
  WHERE c.mentions LIKE ?
53
+ AND c.workspace_id = ?
54
+ AND t.workspace_id = ?
55
  AND c.created_at > ?
56
  ORDER BY c.created_at DESC
57
  LIMIT 10
58
+ `).all(`%"${agent.name}"%`, workspaceId, workspaceId, fourHoursAgo);
59
 
60
  if (mentions.length > 0) {
61
  workItems.push({
 
75
  const assignedTasks = db.prepare(`
76
  SELECT * FROM tasks
77
  WHERE assigned_to = ?
78
+ AND workspace_id = ?
79
  AND status IN ('assigned', 'in_progress')
80
  ORDER BY priority DESC, created_at ASC
81
  LIMIT 10
82
+ `).all(agent.name, workspaceId);
83
 
84
  if (assignedTasks.length > 0) {
85
  workItems.push({
 
96
  }
97
 
98
  // 3. Check for unread notifications
99
+ const notifications = db_helpers.getUnreadNotifications(agent.name, workspaceId);
100
 
101
  if (notifications.length > 0) {
102
  workItems.push({
 
116
  const urgentActivities = db.prepare(`
117
  SELECT * FROM activities
118
  WHERE type IN ('task_created', 'task_assigned', 'high_priority_alert')
119
+ AND workspace_id = ?
120
  AND created_at > ?
121
  AND description LIKE ?
122
  ORDER BY created_at DESC
123
  LIMIT 5
124
+ `).all(workspaceId, fourHoursAgo, `%${agent.name}%`);
125
 
126
  if (urgentActivities.length > 0) {
127
  workItems.push({
 
137
  }
138
 
139
  // Update agent last_seen and status to show heartbeat activity
140
+ db_helpers.updateAgentStatus(agent.name, 'idle', 'Heartbeat check', workspaceId);
141
 
142
  // Log heartbeat activity
143
  db_helpers.logActivity(
 
146
  agent.id,
147
  agent.name,
148
  `Heartbeat check completed - ${workItems.length > 0 ? `${workItems.length} work items found` : 'no work items'}`,
149
+ { workItemsCount: workItems.length, workItemTypes: workItems.map(w => w.type) },
150
+ workspaceId
151
  );
152
 
153
  if (workItems.length === 0) {
 
199
  const { connection_id, token_usage } = body;
200
  const db = getDatabase();
201
  const now = Math.floor(Date.now() / 1000);
202
+ const workspaceId = auth.user.workspace_id ?? 1;
203
 
204
  // Update direct connection heartbeat if connection_id provided
205
  if (connection_id) {
206
+ db.prepare('UPDATE direct_connections SET last_heartbeat = ?, updated_at = ? WHERE connection_id = ? AND status = ? AND workspace_id = ?')
207
+ .run(now, now, connection_id, 'connected', workspaceId);
208
  }
209
 
210
  // Inline token reporting
 
214
  const agentId = resolvedParams.id;
215
  let agent: any;
216
  if (isNaN(Number(agentId))) {
217
+ agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
218
  } else {
219
+ agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
220
  }
221
 
222
  if (agent) {
 
237
  ...getBody,
238
  token_recorded: tokenRecorded,
239
  });
240
+ }
src/app/api/agents/[id]/memory/route.ts CHANGED
@@ -20,13 +20,14 @@ export async function GET(
20
  const db = getDatabase();
21
  const resolvedParams = await params;
22
  const agentId = resolvedParams.id;
 
23
 
24
  // Get agent by ID or name
25
  let agent: any;
26
  if (isNaN(Number(agentId))) {
27
- agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
28
  } else {
29
- agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
30
  }
31
 
32
  if (!agent) {
@@ -43,8 +44,8 @@ export async function GET(
43
  }
44
 
45
  // Get working memory content
46
- const memoryStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?`);
47
- const result = memoryStmt.get(agentId) as any;
48
 
49
  const workingMemory = result?.working_memory || '';
50
 
@@ -78,15 +79,16 @@ export async function PUT(
78
  const db = getDatabase();
79
  const resolvedParams = await params;
80
  const agentId = resolvedParams.id;
 
81
  const body = await request.json();
82
  const { working_memory, append } = body;
83
 
84
  // Get agent by ID or name
85
  let agent: any;
86
  if (isNaN(Number(agentId))) {
87
- agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
88
  } else {
89
- agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
90
  }
91
 
92
  if (!agent) {
@@ -105,8 +107,8 @@ export async function PUT(
105
 
106
  // Handle append mode
107
  if (append) {
108
- const currentStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?`);
109
- const current = currentStmt.get(agentId) as any;
110
  const currentContent = current?.working_memory || '';
111
 
112
  // Add timestamp and append
@@ -121,10 +123,10 @@ export async function PUT(
121
  const updateStmt = db.prepare(`
122
  UPDATE agents
123
  SET working_memory = ?, updated_at = ?
124
- WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?
125
  `);
126
 
127
- updateStmt.run(newContent, now, agentId);
128
 
129
  // Log activity
130
  db_helpers.logActivity(
@@ -137,7 +139,8 @@ export async function PUT(
137
  content_length: newContent.length,
138
  append_mode: append || false,
139
  timestamp: now
140
- }
 
141
  );
142
 
143
  return NextResponse.json({
@@ -167,13 +170,14 @@ export async function DELETE(
167
  const db = getDatabase();
168
  const resolvedParams = await params;
169
  const agentId = resolvedParams.id;
 
170
 
171
  // Get agent by ID or name
172
  let agent: any;
173
  if (isNaN(Number(agentId))) {
174
- agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
175
  } else {
176
- agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
177
  }
178
 
179
  if (!agent) {
@@ -186,10 +190,10 @@ export async function DELETE(
186
  const updateStmt = db.prepare(`
187
  UPDATE agents
188
  SET working_memory = '', updated_at = ?
189
- WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?
190
  `);
191
 
192
- updateStmt.run(now, agentId);
193
 
194
  // Log activity
195
  db_helpers.logActivity(
@@ -198,7 +202,8 @@ export async function DELETE(
198
  agent.id,
199
  agent.name,
200
  `Working memory cleared for agent ${agent.name}`,
201
- { timestamp: now }
 
202
  );
203
 
204
  return NextResponse.json({
 
20
  const db = getDatabase();
21
  const resolvedParams = await params;
22
  const agentId = resolvedParams.id;
23
+ const workspaceId = auth.user.workspace_id ?? 1;
24
 
25
  // Get agent by ID or name
26
  let agent: any;
27
  if (isNaN(Number(agentId))) {
28
+ agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
29
  } else {
30
+ agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
31
  }
32
 
33
  if (!agent) {
 
44
  }
45
 
46
  // Get working memory content
47
+ const memoryStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ?`);
48
+ const result = memoryStmt.get(agentId, workspaceId) as any;
49
 
50
  const workingMemory = result?.working_memory || '';
51
 
 
79
  const db = getDatabase();
80
  const resolvedParams = await params;
81
  const agentId = resolvedParams.id;
82
+ const workspaceId = auth.user.workspace_id ?? 1;
83
  const body = await request.json();
84
  const { working_memory, append } = body;
85
 
86
  // Get agent by ID or name
87
  let agent: any;
88
  if (isNaN(Number(agentId))) {
89
+ agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
90
  } else {
91
+ agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
92
  }
93
 
94
  if (!agent) {
 
107
 
108
  // Handle append mode
109
  if (append) {
110
+ const currentStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ?`);
111
+ const current = currentStmt.get(agentId, workspaceId) as any;
112
  const currentContent = current?.working_memory || '';
113
 
114
  // Add timestamp and append
 
123
  const updateStmt = db.prepare(`
124
  UPDATE agents
125
  SET working_memory = ?, updated_at = ?
126
+ WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ?
127
  `);
128
 
129
+ updateStmt.run(newContent, now, agentId, workspaceId);
130
 
131
  // Log activity
132
  db_helpers.logActivity(
 
139
  content_length: newContent.length,
140
  append_mode: append || false,
141
  timestamp: now
142
+ },
143
+ workspaceId
144
  );
145
 
146
  return NextResponse.json({
 
170
  const db = getDatabase();
171
  const resolvedParams = await params;
172
  const agentId = resolvedParams.id;
173
+ const workspaceId = auth.user.workspace_id ?? 1;
174
 
175
  // Get agent by ID or name
176
  let agent: any;
177
  if (isNaN(Number(agentId))) {
178
+ agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
179
  } else {
180
+ agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
181
  }
182
 
183
  if (!agent) {
 
190
  const updateStmt = db.prepare(`
191
  UPDATE agents
192
  SET working_memory = '', updated_at = ?
193
+ WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ?
194
  `);
195
 
196
+ updateStmt.run(now, agentId, workspaceId);
197
 
198
  // Log activity
199
  db_helpers.logActivity(
 
202
  agent.id,
203
  agent.name,
204
  `Working memory cleared for agent ${agent.name}`,
205
+ { timestamp: now },
206
+ workspaceId
207
  );
208
 
209
  return NextResponse.json({
src/app/api/agents/[id]/route.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase, db_helpers, logAuditEvent } from '@/lib/db'
3
- import { getUserFromRequest, requireRole } from '@/lib/auth'
4
  import { writeAgentToConfig, enrichAgentConfigFromWorkspace } from '@/lib/agent-sync'
5
  import { eventBus } from '@/lib/event-bus'
6
  import { logger } from '@/lib/logger'
@@ -18,12 +18,13 @@ export async function GET(
18
  try {
19
  const db = getDatabase()
20
  const { id } = await params
 
21
 
22
  let agent
23
  if (isNaN(Number(id))) {
24
- agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(id)
25
  } else {
26
- agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(id))
27
  }
28
 
29
  if (!agent) {
@@ -61,14 +62,15 @@ export async function PUT(
61
  try {
62
  const db = getDatabase()
63
  const { id } = await params
 
64
  const body = await request.json()
65
  const { role, gateway_config, write_to_gateway } = body
66
 
67
  let agent
68
  if (isNaN(Number(id))) {
69
- agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(id) as any
70
  } else {
71
- agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(id)) as any
72
  }
73
 
74
  if (!agent) {
@@ -98,8 +100,8 @@ export async function PUT(
98
  values.push(JSON.stringify(newConfig))
99
  }
100
 
101
- values.push(agent.id)
102
- db.prepare(`UPDATE agents SET ${fields.join(', ')} WHERE id = ?`).run(...values)
103
 
104
  // Write back to openclaw.json if requested
105
  if (write_to_gateway && gateway_config) {
@@ -143,7 +145,8 @@ export async function PUT(
143
  agent.id,
144
  auth.user.username,
145
  `Config updated for agent ${agent.name}${write_to_gateway ? ' (+ gateway)' : ''}`,
146
- { fields: Object.keys(gateway_config || {}), write_to_gateway }
 
147
  )
148
 
149
  // Broadcast update
@@ -179,19 +182,20 @@ export async function DELETE(
179
  try {
180
  const db = getDatabase()
181
  const { id } = await params
 
182
 
183
  let agent
184
  if (isNaN(Number(id))) {
185
- agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(id) as any
186
  } else {
187
- agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(id)) as any
188
  }
189
 
190
  if (!agent) {
191
  return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
192
  }
193
 
194
- db.prepare('DELETE FROM agents WHERE id = ?').run(agent.id)
195
 
196
  db_helpers.logActivity(
197
  'agent_deleted',
@@ -199,7 +203,8 @@ export async function DELETE(
199
  agent.id,
200
  auth.user.username,
201
  `Deleted agent: ${agent.name}`,
202
- { name: agent.name, role: agent.role }
 
203
  )
204
 
205
  eventBus.broadcast('agent.deleted', { id: agent.id, name: agent.name })
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { getDatabase, db_helpers, logAuditEvent } from '@/lib/db'
3
+ import { requireRole } from '@/lib/auth'
4
  import { writeAgentToConfig, enrichAgentConfigFromWorkspace } from '@/lib/agent-sync'
5
  import { eventBus } from '@/lib/event-bus'
6
  import { logger } from '@/lib/logger'
 
18
  try {
19
  const db = getDatabase()
20
  const { id } = await params
21
+ const workspaceId = auth.user.workspace_id ?? 1;
22
 
23
  let agent
24
  if (isNaN(Number(id))) {
25
+ agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(id, workspaceId)
26
  } else {
27
+ agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(id), workspaceId)
28
  }
29
 
30
  if (!agent) {
 
62
  try {
63
  const db = getDatabase()
64
  const { id } = await params
65
+ const workspaceId = auth.user.workspace_id ?? 1;
66
  const body = await request.json()
67
  const { role, gateway_config, write_to_gateway } = body
68
 
69
  let agent
70
  if (isNaN(Number(id))) {
71
+ agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(id, workspaceId) as any
72
  } else {
73
+ agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(id), workspaceId) as any
74
  }
75
 
76
  if (!agent) {
 
100
  values.push(JSON.stringify(newConfig))
101
  }
102
 
103
+ values.push(agent.id, workspaceId)
104
+ db.prepare(`UPDATE agents SET ${fields.join(', ')} WHERE id = ? AND workspace_id = ?`).run(...values)
105
 
106
  // Write back to openclaw.json if requested
107
  if (write_to_gateway && gateway_config) {
 
145
  agent.id,
146
  auth.user.username,
147
  `Config updated for agent ${agent.name}${write_to_gateway ? ' (+ gateway)' : ''}`,
148
+ { fields: Object.keys(gateway_config || {}), write_to_gateway },
149
+ workspaceId
150
  )
151
 
152
  // Broadcast update
 
182
  try {
183
  const db = getDatabase()
184
  const { id } = await params
185
+ const workspaceId = auth.user.workspace_id ?? 1;
186
 
187
  let agent
188
  if (isNaN(Number(id))) {
189
+ agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(id, workspaceId) as any
190
  } else {
191
+ agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(id), workspaceId) as any
192
  }
193
 
194
  if (!agent) {
195
  return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
196
  }
197
 
198
+ db.prepare('DELETE FROM agents WHERE id = ? AND workspace_id = ?').run(agent.id, workspaceId)
199
 
200
  db_helpers.logActivity(
201
  'agent_deleted',
 
203
  agent.id,
204
  auth.user.username,
205
  `Deleted agent: ${agent.name}`,
206
+ { name: agent.name, role: agent.role },
207
+ workspaceId
208
  )
209
 
210
  eventBus.broadcast('agent.deleted', { id: agent.id, name: agent.name })
src/app/api/agents/[id]/soul/route.ts CHANGED
@@ -4,7 +4,7 @@ import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from
4
  import { join, dirname } from 'path';
5
  import { config } from '@/lib/config';
6
  import { resolveWithin } from '@/lib/paths';
7
- import { getUserFromRequest, requireRole } from '@/lib/auth';
8
  import { logger } from '@/lib/logger';
9
 
10
  /**
@@ -21,13 +21,14 @@ export async function GET(
21
  const db = getDatabase();
22
  const resolvedParams = await params;
23
  const agentId = resolvedParams.id;
 
24
 
25
  // Get agent by ID or name
26
  let agent: any;
27
  if (isNaN(Number(agentId))) {
28
- agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
29
  } else {
30
- agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
31
  }
32
 
33
  if (!agent) {
@@ -103,15 +104,16 @@ export async function PUT(
103
  const db = getDatabase();
104
  const resolvedParams = await params;
105
  const agentId = resolvedParams.id;
 
106
  const body = await request.json();
107
  const { soul_content, template_name } = body;
108
 
109
  // Get agent by ID or name
110
  let agent: any;
111
  if (isNaN(Number(agentId))) {
112
- agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
113
  } else {
114
- agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
115
  }
116
 
117
  if (!agent) {
@@ -170,24 +172,25 @@ export async function PUT(
170
  const updateStmt = db.prepare(`
171
  UPDATE agents
172
  SET soul_content = ?, updated_at = ?
173
- WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?
174
  `);
175
 
176
- updateStmt.run(newSoulContent, now, agentId);
177
 
178
  // Log activity
179
  db_helpers.logActivity(
180
  'agent_soul_updated',
181
  'agent',
182
  agent.id,
183
- getUserFromRequest(request)?.username || 'system',
184
  `SOUL content updated for agent ${agent.name}${template_name ? ` using template: ${template_name}` : ''}${savedToWorkspace ? ' (synced to workspace)' : ''}`,
185
  {
186
  template_used: template_name || null,
187
  content_length: newSoulContent ? newSoulContent.length : 0,
188
  previous_content_length: agent.soul_content ? agent.soul_content.length : 0,
189
  saved_to_workspace: savedToWorkspace
190
- }
 
191
  );
192
 
193
  return NextResponse.json({
 
4
  import { join, dirname } from 'path';
5
  import { config } from '@/lib/config';
6
  import { resolveWithin } from '@/lib/paths';
7
+ import { requireRole } from '@/lib/auth';
8
  import { logger } from '@/lib/logger';
9
 
10
  /**
 
21
  const db = getDatabase();
22
  const resolvedParams = await params;
23
  const agentId = resolvedParams.id;
24
+ const workspaceId = auth.user.workspace_id ?? 1;
25
 
26
  // Get agent by ID or name
27
  let agent: any;
28
  if (isNaN(Number(agentId))) {
29
+ agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
30
  } else {
31
+ agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
32
  }
33
 
34
  if (!agent) {
 
104
  const db = getDatabase();
105
  const resolvedParams = await params;
106
  const agentId = resolvedParams.id;
107
+ const workspaceId = auth.user.workspace_id ?? 1;
108
  const body = await request.json();
109
  const { soul_content, template_name } = body;
110
 
111
  // Get agent by ID or name
112
  let agent: any;
113
  if (isNaN(Number(agentId))) {
114
+ agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
115
  } else {
116
+ agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
117
  }
118
 
119
  if (!agent) {
 
172
  const updateStmt = db.prepare(`
173
  UPDATE agents
174
  SET soul_content = ?, updated_at = ?
175
+ WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ?
176
  `);
177
 
178
+ updateStmt.run(newSoulContent, now, agentId, workspaceId);
179
 
180
  // Log activity
181
  db_helpers.logActivity(
182
  'agent_soul_updated',
183
  'agent',
184
  agent.id,
185
+ auth.user.username,
186
  `SOUL content updated for agent ${agent.name}${template_name ? ` using template: ${template_name}` : ''}${savedToWorkspace ? ' (synced to workspace)' : ''}`,
187
  {
188
  template_used: template_name || null,
189
  content_length: newSoulContent ? newSoulContent.length : 0,
190
  previous_content_length: agent.soul_content ? agent.soul_content.length : 0,
191
  saved_to_workspace: savedToWorkspace
192
+ },
193
+ workspaceId
194
  );
195
 
196
  return NextResponse.json({
src/app/api/agents/[id]/wake/route.ts CHANGED
@@ -14,14 +14,15 @@ export async function POST(
14
  try {
15
  const resolvedParams = await params
16
  const agentId = resolvedParams.id
 
17
  const body = await request.json().catch(() => ({}))
18
  const customMessage =
19
  typeof body?.message === 'string' ? body.message.trim() : ''
20
 
21
  const db = getDatabase()
22
  const agent: any = isNaN(Number(agentId))
23
- ? db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId)
24
- : db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId))
25
 
26
  if (!agent) {
27
  return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
@@ -50,7 +51,7 @@ export async function POST(
50
  )
51
  }
52
 
53
- db_helpers.updateAgentStatus(agent.name, 'idle', 'Manual wake')
54
 
55
  return NextResponse.json({
56
  success: true,
 
14
  try {
15
  const resolvedParams = await params
16
  const agentId = resolvedParams.id
17
+ const workspaceId = auth.user.workspace_id ?? 1;
18
  const body = await request.json().catch(() => ({}))
19
  const customMessage =
20
  typeof body?.message === 'string' ? body.message.trim() : ''
21
 
22
  const db = getDatabase()
23
  const agent: any = isNaN(Number(agentId))
24
+ ? db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId)
25
+ : db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId)
26
 
27
  if (!agent) {
28
  return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
 
51
  )
52
  }
53
 
54
+ db_helpers.updateAgentStatus(agent.name, 'idle', 'Manual wake', workspaceId)
55
 
56
  return NextResponse.json({
57
  success: true,
src/app/api/agents/comms/route.ts CHANGED
@@ -14,6 +14,7 @@ export async function GET(request: NextRequest) {
14
  try {
15
  const db = getDatabase()
16
  const { searchParams } = new URL(request.url)
 
17
 
18
  const limit = parseInt(searchParams.get("limit") || "100")
19
  const offset = parseInt(searchParams.get("offset") || "0")
@@ -27,11 +28,12 @@ export async function GET(request: NextRequest) {
27
  // 1. Get inter-agent messages
28
  let messagesQuery = `
29
  SELECT * FROM messages
30
- WHERE to_agent IS NOT NULL
 
31
  AND from_agent NOT IN (${humanPlaceholders})
32
  AND to_agent NOT IN (${humanPlaceholders})
33
  `
34
- const messagesParams: any[] = [...humanNames, ...humanNames]
35
 
36
  if (since) {
37
  messagesQuery += " AND created_at > ?"
@@ -55,11 +57,12 @@ export async function GET(request: NextRequest) {
55
  COUNT(*) as message_count,
56
  MAX(created_at) as last_message_at
57
  FROM messages
58
- WHERE to_agent IS NOT NULL
 
59
  AND from_agent NOT IN (${humanPlaceholders})
60
  AND to_agent NOT IN (${humanPlaceholders})
61
  `
62
- const graphParams: any[] = [...humanNames, ...humanNames]
63
  if (since) {
64
  graphQuery += " AND created_at > ?"
65
  graphParams.push(parseInt(since))
@@ -72,29 +75,30 @@ export async function GET(request: NextRequest) {
72
  const statsQuery = `
73
  SELECT agent, SUM(sent) as sent, SUM(received) as received FROM (
74
  SELECT from_agent as agent, COUNT(*) as sent, 0 as received
75
- FROM messages WHERE to_agent IS NOT NULL
76
  AND from_agent NOT IN (${humanPlaceholders})
77
  AND to_agent NOT IN (${humanPlaceholders})
78
  GROUP BY from_agent
79
  UNION ALL
80
  SELECT to_agent as agent, 0 as sent, COUNT(*) as received
81
- FROM messages WHERE to_agent IS NOT NULL
82
  AND from_agent NOT IN (${humanPlaceholders})
83
  AND to_agent NOT IN (${humanPlaceholders})
84
  GROUP BY to_agent
85
  ) GROUP BY agent ORDER BY (sent + received) DESC
86
  `
87
- const statsParams = [...humanNames, ...humanNames, ...humanNames, ...humanNames]
88
  const agentStats = db.prepare(statsQuery).all(...statsParams)
89
 
90
  // 4. Total count
91
  let countQuery = `
92
  SELECT COUNT(*) as total FROM messages
93
- WHERE to_agent IS NOT NULL
 
94
  AND from_agent NOT IN (${humanPlaceholders})
95
  AND to_agent NOT IN (${humanPlaceholders})
96
  `
97
- const countParams: any[] = [...humanNames, ...humanNames]
98
  if (since) {
99
  countQuery += " AND created_at > ?"
100
  countParams.push(parseInt(since))
@@ -107,12 +111,13 @@ export async function GET(request: NextRequest) {
107
 
108
  let seededCountQuery = `
109
  SELECT COUNT(*) as seeded FROM messages
110
- WHERE to_agent IS NOT NULL
 
111
  AND from_agent NOT IN (${humanPlaceholders})
112
  AND to_agent NOT IN (${humanPlaceholders})
113
  AND conversation_id LIKE ?
114
  `
115
- const seededParams: any[] = [...humanNames, ...humanNames, "conv-multi-%"]
116
  if (since) {
117
  seededCountQuery += " AND created_at > ?"
118
  seededParams.push(parseInt(since))
 
14
  try {
15
  const db = getDatabase()
16
  const { searchParams } = new URL(request.url)
17
+ const workspaceId = auth.user.workspace_id ?? 1
18
 
19
  const limit = parseInt(searchParams.get("limit") || "100")
20
  const offset = parseInt(searchParams.get("offset") || "0")
 
28
  // 1. Get inter-agent messages
29
  let messagesQuery = `
30
  SELECT * FROM messages
31
+ WHERE workspace_id = ?
32
+ AND to_agent IS NOT NULL
33
  AND from_agent NOT IN (${humanPlaceholders})
34
  AND to_agent NOT IN (${humanPlaceholders})
35
  `
36
+ const messagesParams: any[] = [workspaceId, ...humanNames, ...humanNames]
37
 
38
  if (since) {
39
  messagesQuery += " AND created_at > ?"
 
57
  COUNT(*) as message_count,
58
  MAX(created_at) as last_message_at
59
  FROM messages
60
+ WHERE workspace_id = ?
61
+ AND to_agent IS NOT NULL
62
  AND from_agent NOT IN (${humanPlaceholders})
63
  AND to_agent NOT IN (${humanPlaceholders})
64
  `
65
+ const graphParams: any[] = [workspaceId, ...humanNames, ...humanNames]
66
  if (since) {
67
  graphQuery += " AND created_at > ?"
68
  graphParams.push(parseInt(since))
 
75
  const statsQuery = `
76
  SELECT agent, SUM(sent) as sent, SUM(received) as received FROM (
77
  SELECT from_agent as agent, COUNT(*) as sent, 0 as received
78
+ FROM messages WHERE workspace_id = ? AND to_agent IS NOT NULL
79
  AND from_agent NOT IN (${humanPlaceholders})
80
  AND to_agent NOT IN (${humanPlaceholders})
81
  GROUP BY from_agent
82
  UNION ALL
83
  SELECT to_agent as agent, 0 as sent, COUNT(*) as received
84
+ FROM messages WHERE workspace_id = ? AND to_agent IS NOT NULL
85
  AND from_agent NOT IN (${humanPlaceholders})
86
  AND to_agent NOT IN (${humanPlaceholders})
87
  GROUP BY to_agent
88
  ) GROUP BY agent ORDER BY (sent + received) DESC
89
  `
90
+ const statsParams = [workspaceId, ...humanNames, ...humanNames, workspaceId, ...humanNames, ...humanNames]
91
  const agentStats = db.prepare(statsQuery).all(...statsParams)
92
 
93
  // 4. Total count
94
  let countQuery = `
95
  SELECT COUNT(*) as total FROM messages
96
+ WHERE workspace_id = ?
97
+ AND to_agent IS NOT NULL
98
  AND from_agent NOT IN (${humanPlaceholders})
99
  AND to_agent NOT IN (${humanPlaceholders})
100
  `
101
+ const countParams: any[] = [workspaceId, ...humanNames, ...humanNames]
102
  if (since) {
103
  countQuery += " AND created_at > ?"
104
  countParams.push(parseInt(since))
 
111
 
112
  let seededCountQuery = `
113
  SELECT COUNT(*) as seeded FROM messages
114
+ WHERE workspace_id = ?
115
+ AND to_agent IS NOT NULL
116
  AND from_agent NOT IN (${humanPlaceholders})
117
  AND to_agent NOT IN (${humanPlaceholders})
118
  AND conversation_id LIKE ?
119
  `
120
+ const seededParams: any[] = [workspaceId, ...humanNames, ...humanNames, "conv-multi-%"]
121
  if (since) {
122
  seededCountQuery += " AND created_at > ?"
123
  seededParams.push(parseInt(since))
src/app/api/agents/message/route.ts CHANGED
@@ -19,7 +19,10 @@ export async function POST(request: NextRequest) {
19
  const { from, to, message } = result.data
20
 
21
  const db = getDatabase()
22
- const agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(to) as any
 
 
 
23
  if (!agent) {
24
  return NextResponse.json({ error: 'Recipient agent not found' }, { status: 404 })
25
  }
@@ -48,7 +51,8 @@ export async function POST(request: NextRequest) {
48
  'Direct Message',
49
  `${from}: ${message.substring(0, 200)}${message.length > 200 ? '...' : ''}`,
50
  'agent',
51
- agent.id
 
52
  )
53
 
54
  db_helpers.logActivity(
@@ -57,7 +61,8 @@ export async function POST(request: NextRequest) {
57
  agent.id,
58
  from,
59
  `Sent message to ${to}`,
60
- { to }
 
61
  )
62
 
63
  return NextResponse.json({ success: true })
 
19
  const { from, to, message } = result.data
20
 
21
  const db = getDatabase()
22
+ const workspaceId = auth.user.workspace_id ?? 1;
23
+ const agent = db
24
+ .prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?')
25
+ .get(to, workspaceId) as any
26
  if (!agent) {
27
  return NextResponse.json({ error: 'Recipient agent not found' }, { status: 404 })
28
  }
 
51
  'Direct Message',
52
  `${from}: ${message.substring(0, 200)}${message.length > 200 ? '...' : ''}`,
53
  'agent',
54
+ agent.id,
55
+ workspaceId
56
  )
57
 
58
  db_helpers.logActivity(
 
61
  agent.id,
62
  from,
63
  `Sent message to ${to}`,
64
+ { to },
65
+ workspaceId
66
  )
67
 
68
  return NextResponse.json({ success: true })
src/app/api/agents/route.ts CHANGED
@@ -4,7 +4,7 @@ import { eventBus } from '@/lib/event-bus';
4
  import { getTemplate, buildAgentConfig } from '@/lib/agent-templates';
5
  import { writeAgentToConfig, enrichAgentConfigFromWorkspace } from '@/lib/agent-sync';
6
  import { logAuditEvent } from '@/lib/db';
7
- import { getUserFromRequest, requireRole } from '@/lib/auth';
8
  import { mutationLimiter } from '@/lib/rate-limit';
9
  import { logger } from '@/lib/logger';
10
  import { validateBody, createAgentSchema } from '@/lib/validation';
@@ -20,6 +20,7 @@ export async function GET(request: NextRequest) {
20
  try {
21
  const db = getDatabase();
22
  const { searchParams } = new URL(request.url);
 
23
 
24
  // Parse query parameters
25
  const status = searchParams.get('status');
@@ -28,8 +29,8 @@ export async function GET(request: NextRequest) {
28
  const offset = parseInt(searchParams.get('offset') || '0');
29
 
30
  // Build dynamic query
31
- let query = 'SELECT * FROM agents WHERE 1=1';
32
- const params: any[] = [];
33
 
34
  if (status) {
35
  query += ' AND status = ?';
@@ -61,11 +62,11 @@ export async function GET(request: NextRequest) {
61
  SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
62
  SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as completed
63
  FROM tasks
64
- WHERE assigned_to = ?
65
  `);
66
 
67
  const agentsWithStats = agentsWithParsedData.map(agent => {
68
- const taskStats = taskCountStmt.get(agent.name) as any;
69
 
70
  return {
71
  ...agent,
@@ -79,8 +80,8 @@ export async function GET(request: NextRequest) {
79
  });
80
 
81
  // Get total count for pagination
82
- let countQuery = 'SELECT COUNT(*) as total FROM agents WHERE 1=1';
83
- const countParams: any[] = [];
84
  if (status) {
85
  countQuery += ' AND status = ?';
86
  countParams.push(status);
@@ -115,6 +116,7 @@ export async function POST(request: NextRequest) {
115
 
116
  try {
117
  const db = getDatabase();
 
118
  const validated = await validateBody(request, createAgentSchema);
119
  if ('error' in validated) return validated.error;
120
  const body = validated.data;
@@ -150,7 +152,9 @@ export async function POST(request: NextRequest) {
150
  }
151
 
152
  // Check if agent name already exists
153
- const existingAgent = db.prepare('SELECT id FROM agents WHERE name = ?').get(name);
 
 
154
  if (existingAgent) {
155
  return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 });
156
  }
@@ -160,8 +164,8 @@ export async function POST(request: NextRequest) {
160
  const stmt = db.prepare(`
161
  INSERT INTO agents (
162
  name, role, session_key, soul_content, status,
163
- created_at, updated_at, config
164
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
165
  `);
166
 
167
  const dbResult = stmt.run(
@@ -172,7 +176,8 @@ export async function POST(request: NextRequest) {
172
  status,
173
  now,
174
  now,
175
- JSON.stringify(finalConfig)
 
176
  );
177
 
178
  const agentId = dbResult.lastInsertRowid as number;
@@ -182,7 +187,7 @@ export async function POST(request: NextRequest) {
182
  'agent_created',
183
  'agent',
184
  agentId,
185
- getUserFromRequest(request)?.username || 'system',
186
  `Created agent: ${name} (${finalRole})${template ? ` from template: ${template}` : ''}`,
187
  {
188
  name,
@@ -190,11 +195,14 @@ export async function POST(request: NextRequest) {
190
  status,
191
  session_key,
192
  template: template || null
193
- }
 
194
  );
195
 
196
  // Fetch the created agent
197
- const createdAgent = db.prepare('SELECT * FROM agents WHERE id = ?').get(agentId) as Agent;
 
 
198
  const parsedAgent = {
199
  ...createdAgent,
200
  config: JSON.parse(createdAgent.config || '{}'),
@@ -222,7 +230,8 @@ export async function POST(request: NextRequest) {
222
  const ipAddress = request.headers.get('x-forwarded-for') || 'unknown';
223
  logAuditEvent({
224
  action: 'agent_gateway_create',
225
- actor: getUserFromRequest(request)?.username || 'system',
 
226
  target_type: 'agent',
227
  target_id: agentId as number,
228
  detail: { name, openclaw_id: openclawId, template: template || null },
@@ -256,6 +265,7 @@ export async function PUT(request: NextRequest) {
256
 
257
  try {
258
  const db = getDatabase();
 
259
  const body = await request.json();
260
 
261
  // Handle single agent update or bulk updates
@@ -263,7 +273,9 @@ export async function PUT(request: NextRequest) {
263
  // Single agent update
264
  const { name, status, last_activity, config, session_key, soul_content, role } = body;
265
 
266
- const agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(name) as Agent;
 
 
267
  if (!agent) {
268
  return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
269
  }
@@ -309,7 +321,7 @@ export async function PUT(request: NextRequest) {
309
 
310
  fieldsToUpdate.push('updated_at = ?');
311
  params.push(now);
312
- params.push(name);
313
 
314
  if (fieldsToUpdate.length === 1) { // Only updated_at
315
  return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
@@ -318,7 +330,7 @@ export async function PUT(request: NextRequest) {
318
  const stmt = db.prepare(`
319
  UPDATE agents
320
  SET ${fieldsToUpdate.join(', ')}
321
- WHERE name = ?
322
  `);
323
 
324
  stmt.run(...params);
@@ -335,7 +347,8 @@ export async function PUT(request: NextRequest) {
335
  oldStatus: agent.status,
336
  newStatus: status,
337
  last_activity
338
- }
 
339
  );
340
  }
341
 
 
4
  import { getTemplate, buildAgentConfig } from '@/lib/agent-templates';
5
  import { writeAgentToConfig, enrichAgentConfigFromWorkspace } from '@/lib/agent-sync';
6
  import { logAuditEvent } from '@/lib/db';
7
+ import { requireRole } from '@/lib/auth';
8
  import { mutationLimiter } from '@/lib/rate-limit';
9
  import { logger } from '@/lib/logger';
10
  import { validateBody, createAgentSchema } from '@/lib/validation';
 
20
  try {
21
  const db = getDatabase();
22
  const { searchParams } = new URL(request.url);
23
+ const workspaceId = auth.user.workspace_id ?? 1;
24
 
25
  // Parse query parameters
26
  const status = searchParams.get('status');
 
29
  const offset = parseInt(searchParams.get('offset') || '0');
30
 
31
  // Build dynamic query
32
+ let query = 'SELECT * FROM agents WHERE workspace_id = ?';
33
+ const params: any[] = [workspaceId];
34
 
35
  if (status) {
36
  query += ' AND status = ?';
 
62
  SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
63
  SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as completed
64
  FROM tasks
65
+ WHERE assigned_to = ? AND workspace_id = ?
66
  `);
67
 
68
  const agentsWithStats = agentsWithParsedData.map(agent => {
69
+ const taskStats = taskCountStmt.get(agent.name, workspaceId) as any;
70
 
71
  return {
72
  ...agent,
 
80
  });
81
 
82
  // Get total count for pagination
83
+ let countQuery = 'SELECT COUNT(*) as total FROM agents WHERE workspace_id = ?';
84
+ const countParams: any[] = [workspaceId];
85
  if (status) {
86
  countQuery += ' AND status = ?';
87
  countParams.push(status);
 
116
 
117
  try {
118
  const db = getDatabase();
119
+ const workspaceId = auth.user.workspace_id ?? 1;
120
  const validated = await validateBody(request, createAgentSchema);
121
  if ('error' in validated) return validated.error;
122
  const body = validated.data;
 
152
  }
153
 
154
  // Check if agent name already exists
155
+ const existingAgent = db
156
+ .prepare('SELECT id FROM agents WHERE name = ? AND workspace_id = ?')
157
+ .get(name, workspaceId);
158
  if (existingAgent) {
159
  return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 });
160
  }
 
164
  const stmt = db.prepare(`
165
  INSERT INTO agents (
166
  name, role, session_key, soul_content, status,
167
+ created_at, updated_at, config, workspace_id
168
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
169
  `);
170
 
171
  const dbResult = stmt.run(
 
176
  status,
177
  now,
178
  now,
179
+ JSON.stringify(finalConfig),
180
+ workspaceId
181
  );
182
 
183
  const agentId = dbResult.lastInsertRowid as number;
 
187
  'agent_created',
188
  'agent',
189
  agentId,
190
+ auth.user.username,
191
  `Created agent: ${name} (${finalRole})${template ? ` from template: ${template}` : ''}`,
192
  {
193
  name,
 
195
  status,
196
  session_key,
197
  template: template || null
198
+ },
199
+ workspaceId
200
  );
201
 
202
  // Fetch the created agent
203
+ const createdAgent = db
204
+ .prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?')
205
+ .get(agentId, workspaceId) as Agent;
206
  const parsedAgent = {
207
  ...createdAgent,
208
  config: JSON.parse(createdAgent.config || '{}'),
 
230
  const ipAddress = request.headers.get('x-forwarded-for') || 'unknown';
231
  logAuditEvent({
232
  action: 'agent_gateway_create',
233
+ actor: auth.user.username,
234
+ actor_id: auth.user.id,
235
  target_type: 'agent',
236
  target_id: agentId as number,
237
  detail: { name, openclaw_id: openclawId, template: template || null },
 
265
 
266
  try {
267
  const db = getDatabase();
268
+ const workspaceId = auth.user.workspace_id ?? 1;
269
  const body = await request.json();
270
 
271
  // Handle single agent update or bulk updates
 
273
  // Single agent update
274
  const { name, status, last_activity, config, session_key, soul_content, role } = body;
275
 
276
+ const agent = db
277
+ .prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?')
278
+ .get(name, workspaceId) as Agent;
279
  if (!agent) {
280
  return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
281
  }
 
321
 
322
  fieldsToUpdate.push('updated_at = ?');
323
  params.push(now);
324
+ params.push(name, workspaceId);
325
 
326
  if (fieldsToUpdate.length === 1) { // Only updated_at
327
  return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
 
330
  const stmt = db.prepare(`
331
  UPDATE agents
332
  SET ${fieldsToUpdate.join(', ')}
333
+ WHERE name = ? AND workspace_id = ?
334
  `);
335
 
336
  stmt.run(...params);
 
347
  oldStatus: agent.status,
348
  newStatus: status,
349
  last_activity
350
+ },
351
+ workspaceId
352
  );
353
  }
354
 
src/app/api/alerts/route.ts CHANGED
@@ -31,8 +31,11 @@ export async function GET(request: NextRequest) {
31
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
32
 
33
  const db = getDatabase()
 
34
  try {
35
- const rules = db.prepare('SELECT * FROM alert_rules ORDER BY created_at DESC').all() as AlertRule[]
 
 
36
  return NextResponse.json({ rules })
37
  } catch {
38
  return NextResponse.json({ rules: [] })
@@ -50,6 +53,7 @@ export async function POST(request: NextRequest) {
50
  if (rateCheck) return rateCheck
51
 
52
  const db = getDatabase()
 
53
 
54
  // Check for evaluate action first (peek at body without consuming)
55
  let rawBody: any
@@ -58,7 +62,7 @@ export async function POST(request: NextRequest) {
58
  }
59
 
60
  if (rawBody.action === 'evaluate') {
61
- return evaluateRules(db)
62
  }
63
 
64
  // Validate for create using schema
@@ -73,8 +77,8 @@ export async function POST(request: NextRequest) {
73
 
74
  try {
75
  const result = db.prepare(`
76
- INSERT INTO alert_rules (name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes, created_by)
77
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
78
  `).run(
79
  name,
80
  description || null,
@@ -85,7 +89,8 @@ export async function POST(request: NextRequest) {
85
  action_type || 'notification',
86
  JSON.stringify(action_config || {}),
87
  cooldown_minutes || 60,
88
- auth.user?.username || 'system'
 
89
  )
90
 
91
  // Audit log
@@ -97,7 +102,9 @@ export async function POST(request: NextRequest) {
97
  )
98
  } catch { /* audit table might not exist */ }
99
 
100
- const rule = db.prepare('SELECT * FROM alert_rules WHERE id = ?').get(result.lastInsertRowid) as AlertRule
 
 
101
  return NextResponse.json({ rule }, { status: 201 })
102
  } catch (err: any) {
103
  return NextResponse.json({ error: err.message || 'Failed to create rule' }, { status: 500 })
@@ -115,12 +122,15 @@ export async function PUT(request: NextRequest) {
115
  if (rateCheck) return rateCheck
116
 
117
  const db = getDatabase()
 
118
  const body = await request.json()
119
  const { id, ...updates } = body
120
 
121
  if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 })
122
 
123
- const existing = db.prepare('SELECT * FROM alert_rules WHERE id = ?').get(id) as AlertRule | undefined
 
 
124
  if (!existing) return NextResponse.json({ error: 'Rule not found' }, { status: 404 })
125
 
126
  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) {
137
  if (sets.length === 0) return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
138
 
139
  sets.push('updated_at = (unixepoch())')
140
- values.push(id)
141
 
142
- db.prepare(`UPDATE alert_rules SET ${sets.join(', ')} WHERE id = ?`).run(...values)
143
 
144
- const updated = db.prepare('SELECT * FROM alert_rules WHERE id = ?').get(id) as AlertRule
 
 
145
  return NextResponse.json({ rule: updated })
146
  }
147
 
@@ -156,12 +168,13 @@ export async function DELETE(request: NextRequest) {
156
  if (rateCheck) return rateCheck
157
 
158
  const db = getDatabase()
 
159
  const body = await request.json()
160
  const { id } = body
161
 
162
  if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 })
163
 
164
- const result = db.prepare('DELETE FROM alert_rules WHERE id = ?').run(id)
165
 
166
  try {
167
  db.prepare('INSERT INTO audit_log (action, actor, detail) VALUES (?, ?, ?)').run(
@@ -177,10 +190,10 @@ export async function DELETE(request: NextRequest) {
177
  /**
178
  * Evaluate all enabled alert rules against current data
179
  */
180
- function evaluateRules(db: ReturnType<typeof getDatabase>) {
181
  let rules: AlertRule[]
182
  try {
183
- rules = db.prepare('SELECT * FROM alert_rules WHERE enabled = 1').all() as AlertRule[]
184
  } catch {
185
  return NextResponse.json({ evaluated: 0, triggered: 0, results: [] })
186
  }
@@ -195,7 +208,7 @@ function evaluateRules(db: ReturnType<typeof getDatabase>) {
195
  continue
196
  }
197
 
198
- const triggered = evaluateRule(db, rule, now)
199
  results.push({ rule_id: rule.id, rule_name: rule.name, triggered, reason: triggered ? 'Condition met' : 'Condition not met' })
200
 
201
  if (triggered) {
@@ -207,9 +220,9 @@ function evaluateRules(db: ReturnType<typeof getDatabase>) {
207
  const config = JSON.parse(rule.action_config || '{}')
208
  const recipient = config.recipient || 'system'
209
  db.prepare(`
210
- INSERT INTO notifications (recipient, type, title, message, source_type, source_id)
211
- VALUES (?, 'alert', ?, ?, 'alert_rule', ?)
212
- `).run(recipient, `Alert: ${rule.name}`, rule.description || `Rule "${rule.name}" triggered`, rule.id)
213
  } catch { /* notification creation failed */ }
214
  }
215
  }
@@ -218,13 +231,13 @@ function evaluateRules(db: ReturnType<typeof getDatabase>) {
218
  return NextResponse.json({ evaluated: rules.length, triggered, results })
219
  }
220
 
221
- function evaluateRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number): boolean {
222
  try {
223
  switch (rule.entity_type) {
224
- case 'agent': return evaluateAgentRule(db, rule, now)
225
- case 'task': return evaluateTaskRule(db, rule, now)
226
- case 'session': return evaluateSessionRule(db, rule, now)
227
- case 'activity': return evaluateActivityRule(db, rule, now)
228
  default: return false
229
  }
230
  } catch {
@@ -232,61 +245,61 @@ function evaluateRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now:
232
  }
233
  }
234
 
235
- function evaluateAgentRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number): boolean {
236
  const { condition_field, condition_operator, condition_value } = rule
237
 
238
  if (condition_operator === 'count_above' || condition_operator === 'count_below') {
239
- const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE ${safeColumn('agents', condition_field)} = ?`).get(condition_value) as any)?.c || 0
240
  return condition_operator === 'count_above' ? count > parseInt(condition_value) : count < parseInt(condition_value)
241
  }
242
 
243
  if (condition_operator === 'age_minutes_above') {
244
  // Check agents where field value is older than N minutes (e.g., last_seen)
245
  const threshold = now - parseInt(condition_value) * 60
246
- const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND ${safeColumn('agents', condition_field)} < ?`).get(threshold) as any)?.c || 0
247
  return count > 0
248
  }
249
 
250
- const agents = db.prepare(`SELECT ${safeColumn('agents', condition_field)} as val FROM agents WHERE status != 'offline'`).all() as any[]
251
  return agents.some(a => compareValue(a.val, condition_operator, condition_value))
252
  }
253
 
254
- function evaluateTaskRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, _now: number): boolean {
255
  const { condition_field, condition_operator, condition_value } = rule
256
 
257
  if (condition_operator === 'count_above') {
258
- const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks WHERE ${safeColumn('tasks', condition_field)} = ?`).get(condition_value) as any)?.c || 0
259
  return count > parseInt(condition_value)
260
  }
261
 
262
  if (condition_operator === 'count_below') {
263
- const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks`).get() as any)?.c || 0
264
  return count < parseInt(condition_value)
265
  }
266
 
267
- const tasks = db.prepare(`SELECT ${safeColumn('tasks', condition_field)} as val FROM tasks`).all() as any[]
268
  return tasks.some(t => compareValue(t.val, condition_operator, condition_value))
269
  }
270
 
271
- function evaluateSessionRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, _now: number): boolean {
272
  // Session data comes from the gateway, not the DB, so we check the agents table for session info
273
  const { condition_operator, condition_value } = rule
274
 
275
  if (condition_operator === 'count_above') {
276
- const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE status = 'busy'`).get() as any)?.c || 0
277
  return count > parseInt(condition_value)
278
  }
279
 
280
  return false
281
  }
282
 
283
- function evaluateActivityRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number): boolean {
284
  const { condition_field, condition_operator, condition_value } = rule
285
 
286
  if (condition_operator === 'count_above') {
287
  // Count activities in the last hour
288
  const hourAgo = now - 3600
289
- 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
290
  return count > parseInt(condition_value)
291
  }
292
 
 
31
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
32
 
33
  const db = getDatabase()
34
+ const workspaceId = auth.user.workspace_id ?? 1
35
  try {
36
+ const rules = db
37
+ .prepare('SELECT * FROM alert_rules WHERE workspace_id = ? ORDER BY created_at DESC')
38
+ .all(workspaceId) as AlertRule[]
39
  return NextResponse.json({ rules })
40
  } catch {
41
  return NextResponse.json({ rules: [] })
 
53
  if (rateCheck) return rateCheck
54
 
55
  const db = getDatabase()
56
+ const workspaceId = auth.user.workspace_id ?? 1
57
 
58
  // Check for evaluate action first (peek at body without consuming)
59
  let rawBody: any
 
62
  }
63
 
64
  if (rawBody.action === 'evaluate') {
65
+ return evaluateRules(db, workspaceId)
66
  }
67
 
68
  // Validate for create using schema
 
77
 
78
  try {
79
  const result = db.prepare(`
80
+ INSERT INTO alert_rules (name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes, created_by, workspace_id)
81
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
82
  `).run(
83
  name,
84
  description || null,
 
89
  action_type || 'notification',
90
  JSON.stringify(action_config || {}),
91
  cooldown_minutes || 60,
92
+ auth.user?.username || 'system',
93
+ workspaceId
94
  )
95
 
96
  // Audit log
 
102
  )
103
  } catch { /* audit table might not exist */ }
104
 
105
+ const rule = db
106
+ .prepare('SELECT * FROM alert_rules WHERE id = ? AND workspace_id = ?')
107
+ .get(result.lastInsertRowid, workspaceId) as AlertRule
108
  return NextResponse.json({ rule }, { status: 201 })
109
  } catch (err: any) {
110
  return NextResponse.json({ error: err.message || 'Failed to create rule' }, { status: 500 })
 
122
  if (rateCheck) return rateCheck
123
 
124
  const db = getDatabase()
125
+ const workspaceId = auth.user.workspace_id ?? 1
126
  const body = await request.json()
127
  const { id, ...updates } = body
128
 
129
  if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 })
130
 
131
+ const existing = db
132
+ .prepare('SELECT * FROM alert_rules WHERE id = ? AND workspace_id = ?')
133
+ .get(id, workspaceId) as AlertRule | undefined
134
  if (!existing) return NextResponse.json({ error: 'Rule not found' }, { status: 404 })
135
 
136
  const allowed = ['name', 'description', 'enabled', 'entity_type', 'condition_field', 'condition_operator', 'condition_value', 'action_type', 'action_config', 'cooldown_minutes']
 
147
  if (sets.length === 0) return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
148
 
149
  sets.push('updated_at = (unixepoch())')
150
+ values.push(id, workspaceId)
151
 
152
+ db.prepare(`UPDATE alert_rules SET ${sets.join(', ')} WHERE id = ? AND workspace_id = ?`).run(...values)
153
 
154
+ const updated = db
155
+ .prepare('SELECT * FROM alert_rules WHERE id = ? AND workspace_id = ?')
156
+ .get(id, workspaceId) as AlertRule
157
  return NextResponse.json({ rule: updated })
158
  }
159
 
 
168
  if (rateCheck) return rateCheck
169
 
170
  const db = getDatabase()
171
+ const workspaceId = auth.user.workspace_id ?? 1
172
  const body = await request.json()
173
  const { id } = body
174
 
175
  if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 })
176
 
177
+ const result = db.prepare('DELETE FROM alert_rules WHERE id = ? AND workspace_id = ?').run(id, workspaceId)
178
 
179
  try {
180
  db.prepare('INSERT INTO audit_log (action, actor, detail) VALUES (?, ?, ?)').run(
 
190
  /**
191
  * Evaluate all enabled alert rules against current data
192
  */
193
+ function evaluateRules(db: ReturnType<typeof getDatabase>, workspaceId: number) {
194
  let rules: AlertRule[]
195
  try {
196
+ rules = db.prepare('SELECT * FROM alert_rules WHERE enabled = 1 AND workspace_id = ?').all(workspaceId) as AlertRule[]
197
  } catch {
198
  return NextResponse.json({ evaluated: 0, triggered: 0, results: [] })
199
  }
 
208
  continue
209
  }
210
 
211
+ const triggered = evaluateRule(db, rule, now, workspaceId)
212
  results.push({ rule_id: rule.id, rule_name: rule.name, triggered, reason: triggered ? 'Condition met' : 'Condition not met' })
213
 
214
  if (triggered) {
 
220
  const config = JSON.parse(rule.action_config || '{}')
221
  const recipient = config.recipient || 'system'
222
  db.prepare(`
223
+ INSERT INTO notifications (recipient, type, title, message, source_type, source_id, workspace_id)
224
+ VALUES (?, 'alert', ?, ?, 'alert_rule', ?, ?)
225
+ `).run(recipient, `Alert: ${rule.name}`, rule.description || `Rule "${rule.name}" triggered`, rule.id, workspaceId)
226
  } catch { /* notification creation failed */ }
227
  }
228
  }
 
231
  return NextResponse.json({ evaluated: rules.length, triggered, results })
232
  }
233
 
234
+ function evaluateRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number, workspaceId: number): boolean {
235
  try {
236
  switch (rule.entity_type) {
237
+ case 'agent': return evaluateAgentRule(db, rule, now, workspaceId)
238
+ case 'task': return evaluateTaskRule(db, rule, now, workspaceId)
239
+ case 'session': return evaluateSessionRule(db, rule, now, workspaceId)
240
+ case 'activity': return evaluateActivityRule(db, rule, now, workspaceId)
241
  default: return false
242
  }
243
  } catch {
 
245
  }
246
  }
247
 
248
+ function evaluateAgentRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number, workspaceId: number): boolean {
249
  const { condition_field, condition_operator, condition_value } = rule
250
 
251
  if (condition_operator === 'count_above' || condition_operator === 'count_below') {
252
+ 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
253
  return condition_operator === 'count_above' ? count > parseInt(condition_value) : count < parseInt(condition_value)
254
  }
255
 
256
  if (condition_operator === 'age_minutes_above') {
257
  // Check agents where field value is older than N minutes (e.g., last_seen)
258
  const threshold = now - parseInt(condition_value) * 60
259
+ 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
260
  return count > 0
261
  }
262
 
263
+ const agents = db.prepare(`SELECT ${safeColumn('agents', condition_field)} as val FROM agents WHERE workspace_id = ? AND status != 'offline'`).all(workspaceId) as any[]
264
  return agents.some(a => compareValue(a.val, condition_operator, condition_value))
265
  }
266
 
267
+ function evaluateTaskRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, _now: number, workspaceId: number): boolean {
268
  const { condition_field, condition_operator, condition_value } = rule
269
 
270
  if (condition_operator === 'count_above') {
271
+ 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
272
  return count > parseInt(condition_value)
273
  }
274
 
275
  if (condition_operator === 'count_below') {
276
+ const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks WHERE workspace_id = ?`).get(workspaceId) as any)?.c || 0
277
  return count < parseInt(condition_value)
278
  }
279
 
280
+ const tasks = db.prepare(`SELECT ${safeColumn('tasks', condition_field)} as val FROM tasks WHERE workspace_id = ?`).all(workspaceId) as any[]
281
  return tasks.some(t => compareValue(t.val, condition_operator, condition_value))
282
  }
283
 
284
+ function evaluateSessionRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, _now: number, workspaceId: number): boolean {
285
  // Session data comes from the gateway, not the DB, so we check the agents table for session info
286
  const { condition_operator, condition_value } = rule
287
 
288
  if (condition_operator === 'count_above') {
289
+ const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE workspace_id = ? AND status = 'busy'`).get(workspaceId) as any)?.c || 0
290
  return count > parseInt(condition_value)
291
  }
292
 
293
  return false
294
  }
295
 
296
+ function evaluateActivityRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number, workspaceId: number): boolean {
297
  const { condition_field, condition_operator, condition_value } = rule
298
 
299
  if (condition_operator === 'count_above') {
300
  // Count activities in the last hour
301
  const hourAgo = now - 3600
302
+ 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
303
  return count > parseInt(condition_value)
304
  }
305
 
src/app/api/auth/google/route.ts CHANGED
@@ -38,7 +38,7 @@ export async function POST(request: Request) {
38
  const avatar = profile.picture ? String(profile.picture) : null
39
 
40
  const row = db.prepare(`
41
- SELECT id, username, display_name, role, provider, email, avatar_url, is_approved, created_at, updated_at, last_login_at
42
  FROM users
43
  WHERE (provider = 'google' AND provider_user_id = ?) OR lower(email) = ?
44
  ORDER BY id ASC
@@ -76,7 +76,7 @@ export async function POST(request: Request) {
76
  WHERE id = ?
77
  `).run(sub, email, avatar, row.id)
78
 
79
- const { token, expiresAt } = createSession(row.id, ipAddress, userAgent)
80
 
81
  logAuditEvent({ action: 'login_google', actor: row.username, actor_id: row.id, ip_address: ipAddress, user_agent: userAgent })
82
 
@@ -89,6 +89,7 @@ export async function POST(request: Request) {
89
  provider: 'google',
90
  email,
91
  avatar_url: avatar,
 
92
  },
93
  })
94
 
 
38
  const avatar = profile.picture ? String(profile.picture) : null
39
 
40
  const row = db.prepare(`
41
+ SELECT id, username, display_name, role, provider, email, avatar_url, is_approved, created_at, updated_at, last_login_at, workspace_id
42
  FROM users
43
  WHERE (provider = 'google' AND provider_user_id = ?) OR lower(email) = ?
44
  ORDER BY id ASC
 
76
  WHERE id = ?
77
  `).run(sub, email, avatar, row.id)
78
 
79
+ const { token, expiresAt } = createSession(row.id, ipAddress, userAgent, row.workspace_id ?? 1)
80
 
81
  logAuditEvent({ action: 'login_google', actor: row.username, actor_id: row.id, ip_address: ipAddress, user_agent: userAgent })
82
 
 
89
  provider: 'google',
90
  email,
91
  avatar_url: avatar,
92
+ workspace_id: row.workspace_id ?? 1,
93
  },
94
  })
95
 
src/app/api/auth/login/route.ts CHANGED
@@ -25,7 +25,7 @@ export async function POST(request: Request) {
25
  return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
26
  }
27
 
28
- const { token, expiresAt } = createSession(user.id, ipAddress, userAgent)
29
 
30
  logAuditEvent({ action: 'login', actor: user.username, actor_id: user.id, ip_address: ipAddress, user_agent: userAgent })
31
 
@@ -38,6 +38,7 @@ export async function POST(request: Request) {
38
  provider: user.provider || 'local',
39
  email: user.email || null,
40
  avatar_url: user.avatar_url || null,
 
41
  },
42
  })
43
 
 
25
  return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
26
  }
27
 
28
+ const { token, expiresAt } = createSession(user.id, ipAddress, userAgent, user.workspace_id)
29
 
30
  logAuditEvent({ action: 'login', actor: user.username, actor_id: user.id, ip_address: ipAddress, user_agent: userAgent })
31
 
 
38
  provider: user.provider || 'local',
39
  email: user.email || null,
40
  avatar_url: user.avatar_url || null,
41
+ workspace_id: user.workspace_id ?? 1,
42
  },
43
  })
44
 
src/app/api/auth/me/route.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
- import { getUserFromRequest, updateUser , requireRole } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
  import { verifyPassword } from '@/lib/password'
5
  import { logger } from '@/lib/logger'
@@ -23,6 +23,7 @@ export async function GET(request: Request) {
23
  provider: user.provider || 'local',
24
  email: user.email || null,
25
  avatar_url: user.avatar_url || null,
 
26
  },
27
  })
28
  }
@@ -103,6 +104,7 @@ export async function PATCH(request: NextRequest) {
103
  provider: updated.provider || 'local',
104
  email: updated.email || null,
105
  avatar_url: updated.avatar_url || null,
 
106
  },
107
  })
108
  } catch (error) {
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
+ import { getUserFromRequest, updateUser, requireRole } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
  import { verifyPassword } from '@/lib/password'
5
  import { logger } from '@/lib/logger'
 
23
  provider: user.provider || 'local',
24
  email: user.email || null,
25
  avatar_url: user.avatar_url || null,
26
+ workspace_id: user.workspace_id ?? 1,
27
  },
28
  })
29
  }
 
104
  provider: updated.provider || 'local',
105
  email: updated.email || null,
106
  avatar_url: updated.avatar_url || null,
107
+ workspace_id: updated.workspace_id ?? 1,
108
  },
109
  })
110
  } catch (error) {
src/app/api/auth/users/route.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
- import { getUserFromRequest, getAllUsers, createUser, updateUser, deleteUser , requireRole } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
  import { validateBody, createUserSchema } from '@/lib/validation'
5
  import { mutationLimiter } from '@/lib/rate-limit'
@@ -18,7 +18,8 @@ export async function GET(request: NextRequest) {
18
  }
19
 
20
  const users = getAllUsers()
21
- return NextResponse.json({ users })
 
22
  }
23
 
24
  /**
@@ -38,7 +39,12 @@ export async function POST(request: NextRequest) {
38
  if ('error' in result) return result.error
39
  const { username, password, display_name, role, provider, email } = result.data
40
 
41
- const newUser = createUser(username, password, display_name || username, role, { provider, email: email || null })
 
 
 
 
 
42
 
43
  const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
44
  logAuditEvent({
@@ -57,6 +63,7 @@ export async function POST(request: NextRequest) {
57
  email: newUser.email || null,
58
  avatar_url: newUser.avatar_url || null,
59
  is_approved: newUser.is_approved ?? 1,
 
60
  }
61
  }, { status: 201 })
62
  } catch (error: any) {
@@ -79,8 +86,9 @@ export async function PUT(request: NextRequest) {
79
 
80
  try {
81
  const { id, display_name, role, password, is_approved, email, avatar_url } = await request.json()
 
82
 
83
- if (!id) {
84
  return NextResponse.json({ error: 'User ID is required' }, { status: 400 })
85
  }
86
 
@@ -89,11 +97,17 @@ export async function PUT(request: NextRequest) {
89
  }
90
 
91
  // Prevent demoting yourself
92
- if (id === currentUser.id && role && role !== currentUser.role) {
93
  return NextResponse.json({ error: 'Cannot change your own role' }, { status: 400 })
94
  }
95
 
96
- const updated = updateUser(id, { display_name, role, password: password || undefined, is_approved, email, avatar_url })
 
 
 
 
 
 
97
  if (!updated) {
98
  return NextResponse.json({ error: 'User not found' }, { status: 404 })
99
  }
@@ -101,7 +115,7 @@ export async function PUT(request: NextRequest) {
101
  const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
102
  logAuditEvent({
103
  action: 'user_update', actor: currentUser.username, actor_id: currentUser.id,
104
- target_type: 'user', target_id: id,
105
  detail: { display_name, role, password_changed: !!password, is_approved }, ip_address: ipAddress,
106
  })
107
 
@@ -115,6 +129,7 @@ export async function PUT(request: NextRequest) {
115
  email: updated.email || null,
116
  avatar_url: updated.avatar_url || null,
117
  is_approved: updated.is_approved ?? 1,
 
118
  }
119
  })
120
  } catch (error) {
@@ -147,6 +162,12 @@ export async function DELETE(request: NextRequest) {
147
  return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 400 })
148
  }
149
 
 
 
 
 
 
 
150
  const deleted = deleteUser(userId)
151
  if (!deleted) {
152
  return NextResponse.json({ error: 'User not found' }, { status: 404 })
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
+ import { getUserFromRequest, getAllUsers, createUser, updateUser, deleteUser, getUserById, requireRole } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
  import { validateBody, createUserSchema } from '@/lib/validation'
5
  import { mutationLimiter } from '@/lib/rate-limit'
 
18
  }
19
 
20
  const users = getAllUsers()
21
+ const workspaceId = user.workspace_id ?? 1
22
+ return NextResponse.json({ users: users.filter((u) => (u.workspace_id ?? 1) === workspaceId) })
23
  }
24
 
25
  /**
 
39
  if ('error' in result) return result.error
40
  const { username, password, display_name, role, provider, email } = result.data
41
 
42
+ const workspaceId = currentUser.workspace_id ?? 1
43
+ const newUser = createUser(username, password, display_name || username, role, {
44
+ provider,
45
+ email: email || null,
46
+ workspace_id: workspaceId,
47
+ })
48
 
49
  const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
50
  logAuditEvent({
 
63
  email: newUser.email || null,
64
  avatar_url: newUser.avatar_url || null,
65
  is_approved: newUser.is_approved ?? 1,
66
+ workspace_id: newUser.workspace_id ?? 1,
67
  }
68
  }, { status: 201 })
69
  } catch (error: any) {
 
86
 
87
  try {
88
  const { id, display_name, role, password, is_approved, email, avatar_url } = await request.json()
89
+ const userId = parseInt(String(id))
90
 
91
+ if (!id || Number.isNaN(userId)) {
92
  return NextResponse.json({ error: 'User ID is required' }, { status: 400 })
93
  }
94
 
 
97
  }
98
 
99
  // Prevent demoting yourself
100
+ if (userId === currentUser.id && role && role !== currentUser.role) {
101
  return NextResponse.json({ error: 'Cannot change your own role' }, { status: 400 })
102
  }
103
 
104
+ const workspaceId = currentUser.workspace_id ?? 1
105
+ const existing = getUserById(userId)
106
+ if (!existing || (existing.workspace_id ?? 1) !== workspaceId) {
107
+ return NextResponse.json({ error: 'User not found' }, { status: 404 })
108
+ }
109
+
110
+ const updated = updateUser(userId, { display_name, role, password: password || undefined, is_approved, email, avatar_url })
111
  if (!updated) {
112
  return NextResponse.json({ error: 'User not found' }, { status: 404 })
113
  }
 
115
  const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
116
  logAuditEvent({
117
  action: 'user_update', actor: currentUser.username, actor_id: currentUser.id,
118
+ target_type: 'user', target_id: userId,
119
  detail: { display_name, role, password_changed: !!password, is_approved }, ip_address: ipAddress,
120
  })
121
 
 
129
  email: updated.email || null,
130
  avatar_url: updated.avatar_url || null,
131
  is_approved: updated.is_approved ?? 1,
132
+ workspace_id: updated.workspace_id ?? 1,
133
  }
134
  })
135
  } catch (error) {
 
162
  return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 400 })
163
  }
164
 
165
+ const workspaceId = currentUser.workspace_id ?? 1
166
+ const existing = getUserById(userId)
167
+ if (!existing || (existing.workspace_id ?? 1) !== workspaceId) {
168
+ return NextResponse.json({ error: 'User not found' }, { status: 404 })
169
+ }
170
+
171
  const deleted = deleteUser(userId)
172
  if (!deleted) {
173
  return NextResponse.json({ error: 'User not found' }, { status: 404 })
src/app/api/chat/conversations/route.ts CHANGED
@@ -14,6 +14,7 @@ export async function GET(request: NextRequest) {
14
  try {
15
  const db = getDatabase()
16
  const { searchParams } = new URL(request.url)
 
17
 
18
  const agent = searchParams.get('agent')
19
  const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200)
@@ -32,12 +33,12 @@ export async function GET(request: NextRequest) {
32
  COUNT(DISTINCT m.from_agent) + COUNT(DISTINCT CASE WHEN m.to_agent IS NOT NULL THEN m.to_agent END) as participant_count,
33
  SUM(CASE WHEN m.to_agent = ? AND m.read_at IS NULL THEN 1 ELSE 0 END) as unread_count
34
  FROM messages m
35
- WHERE m.from_agent = ? OR m.to_agent = ? OR m.to_agent IS NULL
36
  GROUP BY m.conversation_id
37
  ORDER BY last_message_at DESC
38
  LIMIT ? OFFSET ?
39
  `
40
- params.push(agent, agent, agent, limit, offset)
41
  } else {
42
  query = `
43
  SELECT
@@ -47,11 +48,12 @@ export async function GET(request: NextRequest) {
47
  COUNT(DISTINCT m.from_agent) + COUNT(DISTINCT CASE WHEN m.to_agent IS NOT NULL THEN m.to_agent END) as participant_count,
48
  0 as unread_count
49
  FROM messages m
 
50
  GROUP BY m.conversation_id
51
  ORDER BY last_message_at DESC
52
  LIMIT ? OFFSET ?
53
  `
54
- params.push(limit, offset)
55
  }
56
 
57
  const conversations = db.prepare(query).all(...params) as any[]
@@ -59,13 +61,13 @@ export async function GET(request: NextRequest) {
59
  // Prepare last message statement once (avoids N+1)
60
  const lastMsgStmt = db.prepare(`
61
  SELECT * FROM messages
62
- WHERE conversation_id = ?
63
  ORDER BY created_at DESC
64
  LIMIT 1
65
  `);
66
 
67
  const withLastMessage = conversations.map((conv) => {
68
- const lastMsg = lastMsgStmt.get(conv.conversation_id) as any;
69
 
70
  return {
71
  ...conv,
@@ -80,16 +82,16 @@ export async function GET(request: NextRequest) {
80
 
81
  // Get total count for pagination
82
  let countQuery: string
83
- const countParams: any[] = []
84
  if (agent) {
85
  countQuery = `
86
  SELECT COUNT(DISTINCT m.conversation_id) as total
87
  FROM messages m
88
- WHERE m.from_agent = ? OR m.to_agent = ? OR m.to_agent IS NULL
89
  `
90
  countParams.push(agent, agent)
91
  } else {
92
- countQuery = 'SELECT COUNT(DISTINCT conversation_id) as total FROM messages'
93
  }
94
  const countRow = db.prepare(countQuery).get(...countParams) as { total: number }
95
 
 
14
  try {
15
  const db = getDatabase()
16
  const { searchParams } = new URL(request.url)
17
+ const workspaceId = auth.user.workspace_id ?? 1
18
 
19
  const agent = searchParams.get('agent')
20
  const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200)
 
33
  COUNT(DISTINCT m.from_agent) + COUNT(DISTINCT CASE WHEN m.to_agent IS NOT NULL THEN m.to_agent END) as participant_count,
34
  SUM(CASE WHEN m.to_agent = ? AND m.read_at IS NULL THEN 1 ELSE 0 END) as unread_count
35
  FROM messages m
36
+ WHERE m.workspace_id = ? AND (m.from_agent = ? OR m.to_agent = ? OR m.to_agent IS NULL)
37
  GROUP BY m.conversation_id
38
  ORDER BY last_message_at DESC
39
  LIMIT ? OFFSET ?
40
  `
41
+ params.push(agent, workspaceId, agent, agent, limit, offset)
42
  } else {
43
  query = `
44
  SELECT
 
48
  COUNT(DISTINCT m.from_agent) + COUNT(DISTINCT CASE WHEN m.to_agent IS NOT NULL THEN m.to_agent END) as participant_count,
49
  0 as unread_count
50
  FROM messages m
51
+ WHERE m.workspace_id = ?
52
  GROUP BY m.conversation_id
53
  ORDER BY last_message_at DESC
54
  LIMIT ? OFFSET ?
55
  `
56
+ params.push(workspaceId, limit, offset)
57
  }
58
 
59
  const conversations = db.prepare(query).all(...params) as any[]
 
61
  // Prepare last message statement once (avoids N+1)
62
  const lastMsgStmt = db.prepare(`
63
  SELECT * FROM messages
64
+ WHERE conversation_id = ? AND workspace_id = ?
65
  ORDER BY created_at DESC
66
  LIMIT 1
67
  `);
68
 
69
  const withLastMessage = conversations.map((conv) => {
70
+ const lastMsg = lastMsgStmt.get(conv.conversation_id, workspaceId) as any;
71
 
72
  return {
73
  ...conv,
 
82
 
83
  // Get total count for pagination
84
  let countQuery: string
85
+ const countParams: any[] = [workspaceId]
86
  if (agent) {
87
  countQuery = `
88
  SELECT COUNT(DISTINCT m.conversation_id) as total
89
  FROM messages m
90
+ WHERE m.workspace_id = ? AND (m.from_agent = ? OR m.to_agent = ? OR m.to_agent IS NULL)
91
  `
92
  countParams.push(agent, agent)
93
  } else {
94
+ countQuery = 'SELECT COUNT(DISTINCT conversation_id) as total FROM messages WHERE workspace_id = ?'
95
  }
96
  const countRow = db.prepare(countQuery).get(...countParams) as { total: number }
97
 
src/app/api/chat/messages/[id]/route.ts CHANGED
@@ -16,8 +16,11 @@ export async function GET(
16
  try {
17
  const db = getDatabase()
18
  const { id } = await params
 
19
 
20
- const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(parseInt(id)) as Message | undefined
 
 
21
 
22
  if (!message) {
23
  return NextResponse.json({ error: 'Message not found' }, { status: 404 })
@@ -48,9 +51,12 @@ export async function PATCH(
48
  try {
49
  const db = getDatabase()
50
  const { id } = await params
 
51
  const body = await request.json()
52
 
53
- const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(parseInt(id)) as Message | undefined
 
 
54
 
55
  if (!message) {
56
  return NextResponse.json({ error: 'Message not found' }, { status: 404 })
@@ -58,10 +64,12 @@ export async function PATCH(
58
 
59
  if (body.read) {
60
  const now = Math.floor(Date.now() / 1000)
61
- db.prepare('UPDATE messages SET read_at = ? WHERE id = ?').run(now, parseInt(id))
62
  }
63
 
64
- const updated = db.prepare('SELECT * FROM messages WHERE id = ?').get(parseInt(id)) as Message
 
 
65
 
66
  return NextResponse.json({
67
  message: {
 
16
  try {
17
  const db = getDatabase()
18
  const { id } = await params
19
+ const workspaceId = auth.user.workspace_id ?? 1
20
 
21
+ const message = db
22
+ .prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?')
23
+ .get(parseInt(id), workspaceId) as Message | undefined
24
 
25
  if (!message) {
26
  return NextResponse.json({ error: 'Message not found' }, { status: 404 })
 
51
  try {
52
  const db = getDatabase()
53
  const { id } = await params
54
+ const workspaceId = auth.user.workspace_id ?? 1
55
  const body = await request.json()
56
 
57
+ const message = db
58
+ .prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?')
59
+ .get(parseInt(id), workspaceId) as Message | undefined
60
 
61
  if (!message) {
62
  return NextResponse.json({ error: 'Message not found' }, { status: 404 })
 
64
 
65
  if (body.read) {
66
  const now = Math.floor(Date.now() / 1000)
67
+ db.prepare('UPDATE messages SET read_at = ? WHERE id = ? AND workspace_id = ?').run(now, parseInt(id), workspaceId)
68
  }
69
 
70
+ const updated = db
71
+ .prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?')
72
+ .get(parseInt(id), workspaceId) as Message
73
 
74
  return NextResponse.json({
75
  message: {
src/app/api/chat/messages/route.ts CHANGED
@@ -33,6 +33,7 @@ function parseGatewayJson(raw: string): any | null {
33
 
34
  function createChatReply(
35
  db: ReturnType<typeof getDatabase>,
 
36
  conversationId: string,
37
  fromAgent: string,
38
  toAgent: string,
@@ -42,8 +43,8 @@ function createChatReply(
42
  ) {
43
  const replyInsert = db
44
  .prepare(`
45
- INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata)
46
- VALUES (?, ?, ?, ?, ?, ?)
47
  `)
48
  .run(
49
  conversationId,
@@ -51,12 +52,13 @@ function createChatReply(
51
  toAgent,
52
  content,
53
  messageType,
54
- metadata ? JSON.stringify(metadata) : null
 
55
  )
56
 
57
  const row = db
58
- .prepare('SELECT * FROM messages WHERE id = ?')
59
- .get(replyInsert.lastInsertRowid) as Message
60
 
61
  eventBus.broadcast('chat.message', {
62
  ...row,
@@ -102,6 +104,7 @@ export async function GET(request: NextRequest) {
102
 
103
  try {
104
  const db = getDatabase()
 
105
  const { searchParams } = new URL(request.url)
106
 
107
  const conversation_id = searchParams.get('conversation_id')
@@ -111,8 +114,8 @@ export async function GET(request: NextRequest) {
111
  const offset = parseInt(searchParams.get('offset') || '0')
112
  const since = searchParams.get('since')
113
 
114
- let query = 'SELECT * FROM messages WHERE 1=1'
115
- const params: any[] = []
116
 
117
  if (conversation_id) {
118
  query += ' AND conversation_id = ?'
@@ -145,8 +148,8 @@ export async function GET(request: NextRequest) {
145
  }))
146
 
147
  // Get total count for pagination
148
- let countQuery = 'SELECT COUNT(*) as total FROM messages WHERE 1=1'
149
- const countParams: any[] = []
150
  if (conversation_id) {
151
  countQuery += ' AND conversation_id = ?'
152
  countParams.push(conversation_id)
@@ -182,6 +185,7 @@ export async function POST(request: NextRequest) {
182
 
183
  try {
184
  const db = getDatabase()
 
185
  const body = await request.json()
186
 
187
  const from = (body.from || '').trim()
@@ -199,8 +203,8 @@ export async function POST(request: NextRequest) {
199
  }
200
 
201
  const stmt = db.prepare(`
202
- INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata)
203
- VALUES (?, ?, ?, ?, ?, ?)
204
  `)
205
 
206
  const result = stmt.run(
@@ -209,7 +213,8 @@ export async function POST(request: NextRequest) {
209
  to,
210
  content,
211
  message_type,
212
- metadata ? JSON.stringify(metadata) : null
 
213
  )
214
 
215
  const messageId = result.lastInsertRowid as number
@@ -223,7 +228,8 @@ export async function POST(request: NextRequest) {
223
  messageId,
224
  from,
225
  `Sent ${message_type} message${to ? ` to ${to}` : ' (broadcast)'}`,
226
- { conversation_id, to, message_type }
 
227
  )
228
 
229
  // Create notification for recipient if specified
@@ -234,7 +240,8 @@ export async function POST(request: NextRequest) {
234
  `Message from ${from}`,
235
  content.substring(0, 200) + (content.length > 200 ? '...' : ''),
236
  'message',
237
- messageId
 
238
  )
239
 
240
  // Optionally forward to agent via gateway
@@ -242,8 +249,8 @@ export async function POST(request: NextRequest) {
242
  forwardInfo = { attempted: true, delivered: false }
243
 
244
  const agent = db
245
- .prepare('SELECT * FROM agents WHERE lower(name) = lower(?)')
246
- .get(to) as any
247
 
248
  let sessionKey: string | null = agent?.session_key || null
249
 
@@ -280,6 +287,7 @@ export async function POST(request: NextRequest) {
280
  try {
281
  createChatReply(
282
  db,
 
283
  conversation_id,
284
  COORDINATOR_AGENT,
285
  from,
@@ -340,6 +348,7 @@ export async function POST(request: NextRequest) {
340
  try {
341
  createChatReply(
342
  db,
 
343
  conversation_id,
344
  COORDINATOR_AGENT,
345
  from,
@@ -363,6 +372,7 @@ export async function POST(request: NextRequest) {
363
  try {
364
  createChatReply(
365
  db,
 
366
  conversation_id,
367
  COORDINATOR_AGENT,
368
  from,
@@ -401,6 +411,7 @@ export async function POST(request: NextRequest) {
401
  : 'Unknown runtime error'
402
  createChatReply(
403
  db,
 
404
  conversation_id,
405
  COORDINATOR_AGENT,
406
  from,
@@ -411,6 +422,7 @@ export async function POST(request: NextRequest) {
411
  } else if (waitStatus === 'timeout') {
412
  createChatReply(
413
  db,
 
414
  conversation_id,
415
  COORDINATOR_AGENT,
416
  from,
@@ -423,6 +435,7 @@ export async function POST(request: NextRequest) {
423
  if (replyText) {
424
  createChatReply(
425
  db,
 
426
  conversation_id,
427
  COORDINATOR_AGENT,
428
  from,
@@ -433,6 +446,7 @@ export async function POST(request: NextRequest) {
433
  } else {
434
  createChatReply(
435
  db,
 
436
  conversation_id,
437
  COORDINATOR_AGENT,
438
  from,
@@ -453,6 +467,7 @@ export async function POST(request: NextRequest) {
453
 
454
  createChatReply(
455
  db,
 
456
  conversation_id,
457
  COORDINATOR_AGENT,
458
  from,
@@ -467,7 +482,7 @@ export async function POST(request: NextRequest) {
467
  }
468
  }
469
 
470
- const created = db.prepare('SELECT * FROM messages WHERE id = ?').get(messageId) as Message
471
  const parsedMessage = {
472
  ...created,
473
  metadata: created.metadata ? JSON.parse(created.metadata) : null
 
33
 
34
  function createChatReply(
35
  db: ReturnType<typeof getDatabase>,
36
+ workspaceId: number,
37
  conversationId: string,
38
  fromAgent: string,
39
  toAgent: string,
 
43
  ) {
44
  const replyInsert = db
45
  .prepare(`
46
+ INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata, workspace_id)
47
+ VALUES (?, ?, ?, ?, ?, ?, ?)
48
  `)
49
  .run(
50
  conversationId,
 
52
  toAgent,
53
  content,
54
  messageType,
55
+ metadata ? JSON.stringify(metadata) : null,
56
+ workspaceId
57
  )
58
 
59
  const row = db
60
+ .prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?')
61
+ .get(replyInsert.lastInsertRowid, workspaceId) as Message
62
 
63
  eventBus.broadcast('chat.message', {
64
  ...row,
 
104
 
105
  try {
106
  const db = getDatabase()
107
+ const workspaceId = auth.user.workspace_id ?? 1
108
  const { searchParams } = new URL(request.url)
109
 
110
  const conversation_id = searchParams.get('conversation_id')
 
114
  const offset = parseInt(searchParams.get('offset') || '0')
115
  const since = searchParams.get('since')
116
 
117
+ let query = 'SELECT * FROM messages WHERE workspace_id = ?'
118
+ const params: any[] = [workspaceId]
119
 
120
  if (conversation_id) {
121
  query += ' AND conversation_id = ?'
 
148
  }))
149
 
150
  // Get total count for pagination
151
+ let countQuery = 'SELECT COUNT(*) as total FROM messages WHERE workspace_id = ?'
152
+ const countParams: any[] = [workspaceId]
153
  if (conversation_id) {
154
  countQuery += ' AND conversation_id = ?'
155
  countParams.push(conversation_id)
 
185
 
186
  try {
187
  const db = getDatabase()
188
+ const workspaceId = auth.user.workspace_id ?? 1
189
  const body = await request.json()
190
 
191
  const from = (body.from || '').trim()
 
203
  }
204
 
205
  const stmt = db.prepare(`
206
+ INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata, workspace_id)
207
+ VALUES (?, ?, ?, ?, ?, ?, ?)
208
  `)
209
 
210
  const result = stmt.run(
 
213
  to,
214
  content,
215
  message_type,
216
+ metadata ? JSON.stringify(metadata) : null,
217
+ workspaceId
218
  )
219
 
220
  const messageId = result.lastInsertRowid as number
 
228
  messageId,
229
  from,
230
  `Sent ${message_type} message${to ? ` to ${to}` : ' (broadcast)'}`,
231
+ { conversation_id, to, message_type },
232
+ workspaceId
233
  )
234
 
235
  // Create notification for recipient if specified
 
240
  `Message from ${from}`,
241
  content.substring(0, 200) + (content.length > 200 ? '...' : ''),
242
  'message',
243
+ messageId,
244
+ workspaceId
245
  )
246
 
247
  // Optionally forward to agent via gateway
 
249
  forwardInfo = { attempted: true, delivered: false }
250
 
251
  const agent = db
252
+ .prepare('SELECT * FROM agents WHERE lower(name) = lower(?) AND workspace_id = ?')
253
+ .get(to, workspaceId) as any
254
 
255
  let sessionKey: string | null = agent?.session_key || null
256
 
 
287
  try {
288
  createChatReply(
289
  db,
290
+ workspaceId,
291
  conversation_id,
292
  COORDINATOR_AGENT,
293
  from,
 
348
  try {
349
  createChatReply(
350
  db,
351
+ workspaceId,
352
  conversation_id,
353
  COORDINATOR_AGENT,
354
  from,
 
372
  try {
373
  createChatReply(
374
  db,
375
+ workspaceId,
376
  conversation_id,
377
  COORDINATOR_AGENT,
378
  from,
 
411
  : 'Unknown runtime error'
412
  createChatReply(
413
  db,
414
+ workspaceId,
415
  conversation_id,
416
  COORDINATOR_AGENT,
417
  from,
 
422
  } else if (waitStatus === 'timeout') {
423
  createChatReply(
424
  db,
425
+ workspaceId,
426
  conversation_id,
427
  COORDINATOR_AGENT,
428
  from,
 
435
  if (replyText) {
436
  createChatReply(
437
  db,
438
+ workspaceId,
439
  conversation_id,
440
  COORDINATOR_AGENT,
441
  from,
 
446
  } else {
447
  createChatReply(
448
  db,
449
+ workspaceId,
450
  conversation_id,
451
  COORDINATOR_AGENT,
452
  from,
 
467
 
468
  createChatReply(
469
  db,
470
+ workspaceId,
471
  conversation_id,
472
  COORDINATOR_AGENT,
473
  from,
 
482
  }
483
  }
484
 
485
+ const created = db.prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?').get(messageId, workspaceId) as Message
486
  const parsedMessage = {
487
  ...created,
488
  metadata: created.metadata ? JSON.parse(created.metadata) : null
src/app/api/connect/route.ts CHANGED
@@ -21,22 +21,23 @@ export async function POST(request: NextRequest) {
21
  const { tool_name, tool_version, agent_name, agent_role, metadata } = validation.data
22
  const db = getDatabase()
23
  const now = Math.floor(Date.now() / 1000)
 
24
 
25
  // Find or create agent
26
- let agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agent_name) as any
27
  if (!agent) {
28
  const result = db.prepare(
29
- `INSERT INTO agents (name, role, status, created_at, updated_at)
30
- VALUES (?, ?, 'online', ?, ?)`
31
- ).run(agent_name, agent_role || 'cli', now, now)
32
  agent = { id: result.lastInsertRowid, name: agent_name }
33
  db_helpers.logActivity('agent_created', 'agent', agent.id as number, 'system',
34
- `Auto-created agent "${agent_name}" via direct CLI connection`)
35
  eventBus.broadcast('agent.created', { id: agent.id, name: agent_name })
36
  } else {
37
  // Set agent online
38
- db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ?')
39
- .run('online', now, agent.id)
40
  eventBus.broadcast('agent.status_changed', { id: agent.id, name: agent.name, status: 'online' })
41
  }
42
 
@@ -48,12 +49,12 @@ export async function POST(request: NextRequest) {
48
  // Create new connection
49
  const connectionId = randomUUID()
50
  db.prepare(
51
- `INSERT INTO direct_connections (agent_id, tool_name, tool_version, connection_id, status, last_heartbeat, metadata, created_at, updated_at)
52
- VALUES (?, ?, ?, ?, 'connected', ?, ?, ?, ?)`
53
- ).run(agent.id, tool_name, tool_version || null, connectionId, now, metadata ? JSON.stringify(metadata) : null, now, now)
54
 
55
  db_helpers.logActivity('connection_created', 'agent', agent.id as number, agent_name,
56
- `CLI connection established via ${tool_name}${tool_version ? ` v${tool_version}` : ''}`)
57
 
58
  eventBus.broadcast('connection.created', {
59
  connection_id: connectionId,
@@ -81,12 +82,14 @@ export async function GET(request: NextRequest) {
81
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
82
 
83
  const db = getDatabase()
 
84
  const connections = db.prepare(`
85
  SELECT dc.*, a.name as agent_name, a.status as agent_status, a.role as agent_role
86
  FROM direct_connections dc
87
  JOIN agents a ON dc.agent_id = a.id
 
88
  ORDER BY dc.created_at DESC
89
- `).all()
90
 
91
  return NextResponse.json({ connections })
92
  }
@@ -112,8 +115,14 @@ export async function DELETE(request: NextRequest) {
112
 
113
  const db = getDatabase()
114
  const now = Math.floor(Date.now() / 1000)
 
115
 
116
- const conn = db.prepare('SELECT * FROM direct_connections WHERE connection_id = ?').get(connection_id) as any
 
 
 
 
 
117
  if (!conn) {
118
  return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
119
  }
@@ -126,13 +135,13 @@ export async function DELETE(request: NextRequest) {
126
  'SELECT COUNT(*) as count FROM direct_connections WHERE agent_id = ? AND status = ? AND connection_id != ?'
127
  ).get(conn.agent_id, 'connected', connection_id) as any
128
  if (!otherActive?.count) {
129
- db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ?')
130
- .run('offline', now, conn.agent_id)
131
  }
132
 
133
- const agent = db.prepare('SELECT name FROM agents WHERE id = ?').get(conn.agent_id) as any
134
  db_helpers.logActivity('connection_disconnected', 'agent', conn.agent_id, agent?.name || 'unknown',
135
- `CLI connection disconnected (${conn.tool_name})`)
136
 
137
  eventBus.broadcast('connection.disconnected', {
138
  connection_id,
 
21
  const { tool_name, tool_version, agent_name, agent_role, metadata } = validation.data
22
  const db = getDatabase()
23
  const now = Math.floor(Date.now() / 1000)
24
+ const workspaceId = auth.user.workspace_id ?? 1;
25
 
26
  // Find or create agent
27
+ let agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agent_name, workspaceId) as any
28
  if (!agent) {
29
  const result = db.prepare(
30
+ `INSERT INTO agents (name, role, status, created_at, updated_at, workspace_id)
31
+ VALUES (?, ?, 'online', ?, ?, ?)`
32
+ ).run(agent_name, agent_role || 'cli', now, now, workspaceId)
33
  agent = { id: result.lastInsertRowid, name: agent_name }
34
  db_helpers.logActivity('agent_created', 'agent', agent.id as number, 'system',
35
+ `Auto-created agent "${agent_name}" via direct CLI connection`, undefined, workspaceId)
36
  eventBus.broadcast('agent.created', { id: agent.id, name: agent_name })
37
  } else {
38
  // Set agent online
39
+ db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ? AND workspace_id = ?')
40
+ .run('online', now, agent.id, workspaceId)
41
  eventBus.broadcast('agent.status_changed', { id: agent.id, name: agent.name, status: 'online' })
42
  }
43
 
 
49
  // Create new connection
50
  const connectionId = randomUUID()
51
  db.prepare(
52
+ `INSERT INTO direct_connections (agent_id, tool_name, tool_version, connection_id, status, last_heartbeat, metadata, created_at, updated_at, workspace_id)
53
+ VALUES (?, ?, ?, ?, 'connected', ?, ?, ?, ?, ?)`
54
+ ).run(agent.id, tool_name, tool_version || null, connectionId, now, metadata ? JSON.stringify(metadata) : null, now, now, workspaceId)
55
 
56
  db_helpers.logActivity('connection_created', 'agent', agent.id as number, agent_name,
57
+ `CLI connection established via ${tool_name}${tool_version ? ` v${tool_version}` : ''}`, undefined, workspaceId)
58
 
59
  eventBus.broadcast('connection.created', {
60
  connection_id: connectionId,
 
82
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
83
 
84
  const db = getDatabase()
85
+ const workspaceId = auth.user.workspace_id ?? 1;
86
  const connections = db.prepare(`
87
  SELECT dc.*, a.name as agent_name, a.status as agent_status, a.role as agent_role
88
  FROM direct_connections dc
89
  JOIN agents a ON dc.agent_id = a.id
90
+ WHERE a.workspace_id = ?
91
  ORDER BY dc.created_at DESC
92
+ `).all(workspaceId)
93
 
94
  return NextResponse.json({ connections })
95
  }
 
115
 
116
  const db = getDatabase()
117
  const now = Math.floor(Date.now() / 1000)
118
+ const workspaceId = auth.user.workspace_id ?? 1;
119
 
120
+ const conn = db.prepare(`
121
+ SELECT dc.*
122
+ FROM direct_connections dc
123
+ JOIN agents a ON a.id = dc.agent_id
124
+ WHERE dc.connection_id = ? AND a.workspace_id = ?
125
+ `).get(connection_id, workspaceId) as any
126
  if (!conn) {
127
  return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
128
  }
 
135
  'SELECT COUNT(*) as count FROM direct_connections WHERE agent_id = ? AND status = ? AND connection_id != ?'
136
  ).get(conn.agent_id, 'connected', connection_id) as any
137
  if (!otherActive?.count) {
138
+ db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ? AND workspace_id = ?')
139
+ .run('offline', now, conn.agent_id, workspaceId)
140
  }
141
 
142
+ const agent = db.prepare('SELECT name FROM agents WHERE id = ? AND workspace_id = ?').get(conn.agent_id, workspaceId) as any
143
  db_helpers.logActivity('connection_disconnected', 'agent', conn.agent_id, agent?.name || 'unknown',
144
+ `CLI connection disconnected (${conn.tool_name})`, undefined, workspaceId)
145
 
146
  eventBus.broadcast('connection.disconnected', {
147
  connection_id,
src/app/api/cron/route.ts CHANGED
@@ -308,7 +308,7 @@ export async function POST(request: NextRequest) {
308
  }
309
 
310
  if (action === 'add') {
311
- const { schedule, command, description } = body
312
  const name = jobName || body.name
313
  if (!schedule || !command || !name) {
314
  return NextResponse.json(
@@ -336,6 +336,7 @@ export async function POST(request: NextRequest) {
336
  payload: {
337
  kind: 'agentTurn',
338
  message: command,
 
339
  },
340
  delivery: {
341
  mode: 'none',
 
308
  }
309
 
310
  if (action === 'add') {
311
+ const { schedule, command, model, description } = body
312
  const name = jobName || body.name
313
  if (!schedule || !command || !name) {
314
  return NextResponse.json(
 
336
  payload: {
337
  kind: 'agentTurn',
338
  message: command,
339
+ ...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
340
  },
341
  delivery: {
342
  mode: 'none',
src/app/api/export/route.ts CHANGED
@@ -28,6 +28,7 @@ export async function GET(request: NextRequest) {
28
  }
29
 
30
  const db = getDatabase()
 
31
  const conditions: string[] = []
32
  const params: any[] = []
33
 
@@ -58,13 +59,19 @@ export async function GET(request: NextRequest) {
58
  break
59
  }
60
  case 'tasks': {
61
- rows = db.prepare(`SELECT * FROM tasks ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
 
 
 
62
  headers = ['id', 'title', 'description', 'status', 'priority', 'assigned_to', 'created_by', 'created_at', 'updated_at', 'due_date', 'estimated_hours', 'actual_hours', 'tags']
63
  filename = 'tasks'
64
  break
65
  }
66
  case 'activities': {
67
- rows = db.prepare(`SELECT * FROM activities ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
 
 
 
68
  headers = ['id', 'type', 'entity_type', 'entity_id', 'actor', 'description', 'data', 'created_at']
69
  filename = 'activities'
70
  break
 
28
  }
29
 
30
  const db = getDatabase()
31
+ const workspaceId = auth.user.workspace_id ?? 1
32
  const conditions: string[] = []
33
  const params: any[] = []
34
 
 
59
  break
60
  }
61
  case 'tasks': {
62
+ conditions.unshift('workspace_id = ?')
63
+ params.unshift(workspaceId)
64
+ const scopedWhere = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
65
+ rows = db.prepare(`SELECT * FROM tasks ${scopedWhere} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
66
  headers = ['id', 'title', 'description', 'status', 'priority', 'assigned_to', 'created_by', 'created_at', 'updated_at', 'due_date', 'estimated_hours', 'actual_hours', 'tags']
67
  filename = 'tasks'
68
  break
69
  }
70
  case 'activities': {
71
+ conditions.unshift('workspace_id = ?')
72
+ params.unshift(workspaceId)
73
+ const scopedWhere = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
74
+ rows = db.prepare(`SELECT * FROM activities ${scopedWhere} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
75
  headers = ['id', 'type', 'entity_type', 'entity_id', 'actor', 'description', 'data', 'created_at']
76
  filename = 'activities'
77
  break
src/app/api/github/route.ts CHANGED
@@ -76,13 +76,13 @@ export async function POST(request: NextRequest) {
76
  try {
77
  switch (action) {
78
  case 'sync':
79
- return await handleSync(body, auth.user.username)
80
  case 'comment':
81
- return await handleComment(body, auth.user.username)
82
  case 'close':
83
- return await handleClose(body, auth.user.username)
84
  case 'status':
85
- return handleStatus()
86
  default:
87
  return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
88
  }
@@ -96,7 +96,8 @@ export async function POST(request: NextRequest) {
96
 
97
  async function handleSync(
98
  body: { repo?: string; labels?: string; state?: 'open' | 'closed' | 'all'; assignAgent?: string },
99
- actor: string
 
100
  ) {
101
  const repo = body.repo || process.env.GITHUB_DEFAULT_REPO
102
  if (!repo) {
@@ -128,7 +129,8 @@ async function handleSync(
128
  SELECT id FROM tasks
129
  WHERE json_extract(metadata, '$.github_repo') = ?
130
  AND json_extract(metadata, '$.github_issue_number') = ?
131
- `).get(repo, issue.number) as { id: number } | undefined
 
132
 
133
  if (existing) {
134
  skipped++
@@ -151,8 +153,8 @@ async function handleSync(
151
  const stmt = db.prepare(`
152
  INSERT INTO tasks (
153
  title, description, status, priority, assigned_to, created_by,
154
- created_at, updated_at, tags, metadata
155
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
156
  `)
157
 
158
  const dbResult = stmt.run(
@@ -165,7 +167,8 @@ async function handleSync(
165
  now,
166
  now,
167
  JSON.stringify(tags),
168
- JSON.stringify(metadata)
 
169
  )
170
 
171
  const taskId = dbResult.lastInsertRowid as number
@@ -176,10 +179,11 @@ async function handleSync(
176
  taskId,
177
  actor,
178
  `Imported from GitHub: ${repo}#${issue.number}`,
179
- { github_issue: issue.number, github_repo: repo }
 
180
  )
181
 
182
- const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task
183
  const parsedTask = {
184
  ...createdTask,
185
  tags: JSON.parse(createdTask.tags || '[]'),
@@ -196,16 +200,33 @@ async function handleSync(
196
  }
197
 
198
  // Log sync to github_syncs table
199
- db.prepare(`
200
- INSERT INTO github_syncs (repo, last_synced_at, issue_count, sync_direction, status, error)
201
- VALUES (?, ?, ?, 'inbound', ?, ?)
202
- `).run(
203
- repo,
204
- now,
205
- imported,
206
- errors > 0 ? 'partial' : 'success',
207
- errors > 0 ? `${errors} issues failed to import` : null
208
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  eventBus.broadcast('github.synced', {
211
  repo,
@@ -227,7 +248,8 @@ async function handleSync(
227
 
228
  async function handleComment(
229
  body: { repo?: string; issueNumber?: number; body?: string },
230
- actor: string
 
231
  ) {
232
  if (!body.repo || !body.issueNumber || !body.body) {
233
  return NextResponse.json(
@@ -244,7 +266,8 @@ async function handleComment(
244
  0,
245
  actor,
246
  `Commented on ${body.repo}#${body.issueNumber}`,
247
- { github_repo: body.repo, github_issue: body.issueNumber }
 
248
  )
249
 
250
  return NextResponse.json({ ok: true })
@@ -254,7 +277,8 @@ async function handleComment(
254
 
255
  async function handleClose(
256
  body: { repo?: string; issueNumber?: number; comment?: string },
257
- actor: string
 
258
  ) {
259
  if (!body.repo || !body.issueNumber) {
260
  return NextResponse.json(
@@ -279,7 +303,8 @@ async function handleClose(
279
  updated_at = ?
280
  WHERE json_extract(metadata, '$.github_repo') = ?
281
  AND json_extract(metadata, '$.github_issue_number') = ?
282
- `).run(now, body.repo, body.issueNumber)
 
283
 
284
  db_helpers.logActivity(
285
  'github_close',
@@ -287,7 +312,8 @@ async function handleClose(
287
  0,
288
  actor,
289
  `Closed GitHub issue ${body.repo}#${body.issueNumber}`,
290
- { github_repo: body.repo, github_issue: body.issueNumber }
 
291
  )
292
 
293
  return NextResponse.json({ ok: true })
@@ -295,13 +321,17 @@ async function handleClose(
295
 
296
  // ── Status: return recent sync history ──────────────────────────
297
 
298
- function handleStatus() {
299
  const db = getDatabase()
 
 
 
300
  const syncs = db.prepare(`
301
  SELECT * FROM github_syncs
 
302
  ORDER BY created_at DESC
303
  LIMIT 20
304
- `).all()
305
 
306
  return NextResponse.json({ syncs })
307
  }
 
76
  try {
77
  switch (action) {
78
  case 'sync':
79
+ return await handleSync(body, auth.user.username, auth.user.workspace_id ?? 1)
80
  case 'comment':
81
+ return await handleComment(body, auth.user.username, auth.user.workspace_id ?? 1)
82
  case 'close':
83
+ return await handleClose(body, auth.user.username, auth.user.workspace_id ?? 1)
84
  case 'status':
85
+ return handleStatus(auth.user.workspace_id ?? 1)
86
  default:
87
  return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
88
  }
 
96
 
97
  async function handleSync(
98
  body: { repo?: string; labels?: string; state?: 'open' | 'closed' | 'all'; assignAgent?: string },
99
+ actor: string,
100
+ workspaceId: number
101
  ) {
102
  const repo = body.repo || process.env.GITHUB_DEFAULT_REPO
103
  if (!repo) {
 
129
  SELECT id FROM tasks
130
  WHERE json_extract(metadata, '$.github_repo') = ?
131
  AND json_extract(metadata, '$.github_issue_number') = ?
132
+ AND workspace_id = ?
133
+ `).get(repo, issue.number, workspaceId) as { id: number } | undefined
134
 
135
  if (existing) {
136
  skipped++
 
153
  const stmt = db.prepare(`
154
  INSERT INTO tasks (
155
  title, description, status, priority, assigned_to, created_by,
156
+ created_at, updated_at, tags, metadata, workspace_id
157
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
158
  `)
159
 
160
  const dbResult = stmt.run(
 
167
  now,
168
  now,
169
  JSON.stringify(tags),
170
+ JSON.stringify(metadata),
171
+ workspaceId
172
  )
173
 
174
  const taskId = dbResult.lastInsertRowid as number
 
179
  taskId,
180
  actor,
181
  `Imported from GitHub: ${repo}#${issue.number}`,
182
+ { github_issue: issue.number, github_repo: repo },
183
+ workspaceId
184
  )
185
 
186
+ const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId) as Task
187
  const parsedTask = {
188
  ...createdTask,
189
  tags: JSON.parse(createdTask.tags || '[]'),
 
200
  }
201
 
202
  // Log sync to github_syncs table
203
+ const syncTableHasWorkspace = db
204
+ .prepare("SELECT 1 as ok FROM pragma_table_info('github_syncs') WHERE name = 'workspace_id'")
205
+ .get() as { ok?: number } | undefined
206
+ if (syncTableHasWorkspace?.ok) {
207
+ db.prepare(`
208
+ INSERT INTO github_syncs (repo, last_synced_at, issue_count, sync_direction, status, error, workspace_id)
209
+ VALUES (?, ?, ?, 'inbound', ?, ?, ?)
210
+ `).run(
211
+ repo,
212
+ now,
213
+ imported,
214
+ errors > 0 ? 'partial' : 'success',
215
+ errors > 0 ? `${errors} issues failed to import` : null,
216
+ workspaceId
217
+ )
218
+ } else {
219
+ db.prepare(`
220
+ INSERT INTO github_syncs (repo, last_synced_at, issue_count, sync_direction, status, error)
221
+ VALUES (?, ?, ?, 'inbound', ?, ?)
222
+ `).run(
223
+ repo,
224
+ now,
225
+ imported,
226
+ errors > 0 ? 'partial' : 'success',
227
+ errors > 0 ? `${errors} issues failed to import` : null
228
+ )
229
+ }
230
 
231
  eventBus.broadcast('github.synced', {
232
  repo,
 
248
 
249
  async function handleComment(
250
  body: { repo?: string; issueNumber?: number; body?: string },
251
+ actor: string,
252
+ workspaceId: number
253
  ) {
254
  if (!body.repo || !body.issueNumber || !body.body) {
255
  return NextResponse.json(
 
266
  0,
267
  actor,
268
  `Commented on ${body.repo}#${body.issueNumber}`,
269
+ { github_repo: body.repo, github_issue: body.issueNumber },
270
+ workspaceId
271
  )
272
 
273
  return NextResponse.json({ ok: true })
 
277
 
278
  async function handleClose(
279
  body: { repo?: string; issueNumber?: number; comment?: string },
280
+ actor: string,
281
+ workspaceId: number
282
  ) {
283
  if (!body.repo || !body.issueNumber) {
284
  return NextResponse.json(
 
303
  updated_at = ?
304
  WHERE json_extract(metadata, '$.github_repo') = ?
305
  AND json_extract(metadata, '$.github_issue_number') = ?
306
+ AND workspace_id = ?
307
+ `).run(now, body.repo, body.issueNumber, workspaceId)
308
 
309
  db_helpers.logActivity(
310
  'github_close',
 
312
  0,
313
  actor,
314
  `Closed GitHub issue ${body.repo}#${body.issueNumber}`,
315
+ { github_repo: body.repo, github_issue: body.issueNumber },
316
+ workspaceId
317
  )
318
 
319
  return NextResponse.json({ ok: true })
 
321
 
322
  // ── Status: return recent sync history ──────────────────────────
323
 
324
+ function handleStatus(workspaceId: number) {
325
  const db = getDatabase()
326
+ const tableHasWorkspace = db
327
+ .prepare("SELECT 1 as ok FROM pragma_table_info('github_syncs') WHERE name = 'workspace_id'")
328
+ .get() as { ok?: number } | undefined
329
  const syncs = db.prepare(`
330
  SELECT * FROM github_syncs
331
+ ${tableHasWorkspace?.ok ? 'WHERE workspace_id = ?' : ''}
332
  ORDER BY created_at DESC
333
  LIMIT 20
334
+ `).all(...(tableHasWorkspace?.ok ? [workspaceId] : []))
335
 
336
  return NextResponse.json({ syncs })
337
  }
src/app/api/notifications/deliver/route.ts CHANGED
@@ -17,6 +17,7 @@ export async function POST(request: NextRequest) {
17
  try {
18
  const db = getDatabase();
19
  const body = await request.json();
 
20
  const {
21
  agent_filter, // Optional: only deliver to specific agent
22
  limit = 50, // Max notifications to process per call
@@ -27,11 +28,11 @@ export async function POST(request: NextRequest) {
27
  let query = `
28
  SELECT n.*, a.session_key
29
  FROM notifications n
30
- LEFT JOIN agents a ON n.recipient = a.name
31
- WHERE n.delivered_at IS NULL
32
  `;
33
 
34
- const params: any[] = [];
35
 
36
  if (agent_filter) {
37
  query += ' AND n.recipient = ?';
@@ -59,7 +60,7 @@ export async function POST(request: NextRequest) {
59
  const deliveryResults: any[] = [];
60
 
61
  // Prepare update statement once (avoids N+1)
62
- const markDeliveredStmt = db.prepare('UPDATE notifications SET delivered_at = ? WHERE id = ?');
63
 
64
  for (const notification of undeliveredNotifications) {
65
  try {
@@ -98,7 +99,7 @@ export async function POST(request: NextRequest) {
98
 
99
  // Mark as delivered
100
  const now = Math.floor(Date.now() / 1000);
101
- markDeliveredStmt.run(now, notification.id);
102
 
103
  deliveredCount++;
104
  deliveryResults.push({
@@ -121,7 +122,8 @@ export async function POST(request: NextRequest) {
121
  notification_type: notification.type,
122
  session_key: notification.session_key,
123
  title: notification.title
124
- }
 
125
  );
126
  } catch (cmdError: any) {
127
  throw new Error(`Command failed: ${cmdError.message}`);
@@ -162,7 +164,8 @@ export async function POST(request: NextRequest) {
162
  errors: errorCount,
163
  dry_run,
164
  agent_filter: agent_filter || null
165
- }
 
166
  );
167
 
168
  return NextResponse.json({
@@ -191,25 +194,26 @@ export async function GET(request: NextRequest) {
191
  try {
192
  const db = getDatabase();
193
  const { searchParams } = new URL(request.url);
 
194
  const agent = searchParams.get('agent');
195
 
196
  // Get delivery statistics
197
- let baseQuery = 'SELECT COUNT(*) as count FROM notifications';
198
- let params: any[] = [];
199
 
200
  if (agent) {
201
- baseQuery += ' WHERE recipient = ?';
202
  params.push(agent);
203
  }
204
 
205
  const totalNotifications = db.prepare(baseQuery).get(...params) as { count: number };
206
 
207
  const undeliveredCount = db.prepare(
208
- baseQuery + (agent ? ' AND' : ' WHERE') + ' delivered_at IS NULL'
209
  ).get(...params) as { count: number };
210
 
211
  const deliveredCount = db.prepare(
212
- baseQuery + (agent ? ' AND' : ' WHERE') + ' delivered_at IS NOT NULL'
213
  ).get(...params) as { count: number };
214
 
215
  // Get recent delivery activity
@@ -221,11 +225,11 @@ export async function GET(request: NextRequest) {
221
  delivered_at,
222
  created_at
223
  FROM notifications
224
- WHERE delivered_at IS NOT NULL
225
  ${agent ? 'AND recipient = ?' : ''}
226
  ORDER BY delivered_at DESC
227
  LIMIT 10
228
- `).all(...(agent ? [agent] : []));
229
 
230
  // Get agents with pending notifications
231
  const agentsPending = db.prepare(`
@@ -234,11 +238,11 @@ export async function GET(request: NextRequest) {
234
  a.session_key,
235
  COUNT(*) as pending_count
236
  FROM notifications n
237
- LEFT JOIN agents a ON n.recipient = a.name
238
- WHERE n.delivered_at IS NULL
239
  GROUP BY n.recipient, a.session_key
240
  ORDER BY pending_count DESC
241
- `).all() as any[];
242
 
243
  return NextResponse.json({
244
  statistics: {
 
17
  try {
18
  const db = getDatabase();
19
  const body = await request.json();
20
+ const workspaceId = auth.user.workspace_id ?? 1;
21
  const {
22
  agent_filter, // Optional: only deliver to specific agent
23
  limit = 50, // Max notifications to process per call
 
28
  let query = `
29
  SELECT n.*, a.session_key
30
  FROM notifications n
31
+ LEFT JOIN agents a ON n.recipient = a.name AND a.workspace_id = n.workspace_id
32
+ WHERE n.delivered_at IS NULL AND n.workspace_id = ?
33
  `;
34
 
35
+ const params: any[] = [workspaceId];
36
 
37
  if (agent_filter) {
38
  query += ' AND n.recipient = ?';
 
60
  const deliveryResults: any[] = [];
61
 
62
  // Prepare update statement once (avoids N+1)
63
+ const markDeliveredStmt = db.prepare('UPDATE notifications SET delivered_at = ? WHERE id = ? AND workspace_id = ?');
64
 
65
  for (const notification of undeliveredNotifications) {
66
  try {
 
99
 
100
  // Mark as delivered
101
  const now = Math.floor(Date.now() / 1000);
102
+ markDeliveredStmt.run(now, notification.id, workspaceId);
103
 
104
  deliveredCount++;
105
  deliveryResults.push({
 
122
  notification_type: notification.type,
123
  session_key: notification.session_key,
124
  title: notification.title
125
+ },
126
+ workspaceId
127
  );
128
  } catch (cmdError: any) {
129
  throw new Error(`Command failed: ${cmdError.message}`);
 
164
  errors: errorCount,
165
  dry_run,
166
  agent_filter: agent_filter || null
167
+ },
168
+ workspaceId
169
  );
170
 
171
  return NextResponse.json({
 
194
  try {
195
  const db = getDatabase();
196
  const { searchParams } = new URL(request.url);
197
+ const workspaceId = auth.user.workspace_id ?? 1;
198
  const agent = searchParams.get('agent');
199
 
200
  // Get delivery statistics
201
+ let baseQuery = 'SELECT COUNT(*) as count FROM notifications WHERE workspace_id = ?';
202
+ let params: any[] = [workspaceId];
203
 
204
  if (agent) {
205
+ baseQuery += ' AND recipient = ?';
206
  params.push(agent);
207
  }
208
 
209
  const totalNotifications = db.prepare(baseQuery).get(...params) as { count: number };
210
 
211
  const undeliveredCount = db.prepare(
212
+ baseQuery + ' AND delivered_at IS NULL'
213
  ).get(...params) as { count: number };
214
 
215
  const deliveredCount = db.prepare(
216
+ baseQuery + ' AND delivered_at IS NOT NULL'
217
  ).get(...params) as { count: number };
218
 
219
  // Get recent delivery activity
 
225
  delivered_at,
226
  created_at
227
  FROM notifications
228
+ WHERE delivered_at IS NOT NULL AND workspace_id = ?
229
  ${agent ? 'AND recipient = ?' : ''}
230
  ORDER BY delivered_at DESC
231
  LIMIT 10
232
+ `).all(...(agent ? [workspaceId, agent] : [workspaceId]));
233
 
234
  // Get agents with pending notifications
235
  const agentsPending = db.prepare(`
 
238
  a.session_key,
239
  COUNT(*) as pending_count
240
  FROM notifications n
241
+ LEFT JOIN agents a ON n.recipient = a.name AND a.workspace_id = n.workspace_id
242
+ WHERE n.delivered_at IS NULL AND n.workspace_id = ?
243
  GROUP BY n.recipient, a.session_key
244
  ORDER BY pending_count DESC
245
+ `).all(workspaceId) as any[];
246
 
247
  return NextResponse.json({
248
  statistics: {
src/app/api/notifications/route.ts CHANGED
@@ -16,6 +16,7 @@ export async function GET(request: NextRequest) {
16
  try {
17
  const db = getDatabase();
18
  const { searchParams } = new URL(request.url);
 
19
 
20
  // Parse query parameters
21
  const recipient = searchParams.get('recipient');
@@ -29,8 +30,8 @@ export async function GET(request: NextRequest) {
29
  }
30
 
31
  // Build dynamic query
32
- let query = 'SELECT * FROM notifications WHERE recipient = ?';
33
- const params: any[] = [recipient];
34
 
35
  if (unread_only) {
36
  query += ' AND read_at IS NULL';
@@ -48,14 +49,14 @@ export async function GET(request: NextRequest) {
48
  const notifications = stmt.all(...params) as Notification[];
49
 
50
  // Prepare source detail statements once (avoids N+1)
51
- const taskDetailStmt = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?');
52
  const commentDetailStmt = db.prepare(`
53
  SELECT c.id, c.content, c.task_id, t.title as task_title
54
  FROM comments c
55
  LEFT JOIN tasks t ON c.task_id = t.id
56
- WHERE c.id = ?
57
  `);
58
- const agentDetailStmt = db.prepare('SELECT id, name, role, status FROM agents WHERE id = ?');
59
 
60
  // Enhance notifications with related entity data
61
  const enhancedNotifications = notifications.map(notification => {
@@ -65,14 +66,14 @@ export async function GET(request: NextRequest) {
65
  if (notification.source_type && notification.source_id) {
66
  switch (notification.source_type) {
67
  case 'task': {
68
- const task = taskDetailStmt.get(notification.source_id) as any;
69
  if (task) {
70
  sourceDetails = { type: 'task', ...task };
71
  }
72
  break;
73
  }
74
  case 'comment': {
75
- const comment = commentDetailStmt.get(notification.source_id) as any;
76
  if (comment) {
77
  sourceDetails = {
78
  type: 'comment',
@@ -83,7 +84,7 @@ export async function GET(request: NextRequest) {
83
  break;
84
  }
85
  case 'agent': {
86
- const agent = agentDetailStmt.get(notification.source_id) as any;
87
  if (agent) {
88
  sourceDetails = { type: 'agent', ...agent };
89
  }
@@ -105,12 +106,12 @@ export async function GET(request: NextRequest) {
105
  const unreadCount = db.prepare(`
106
  SELECT COUNT(*) as count
107
  FROM notifications
108
- WHERE recipient = ? AND read_at IS NULL
109
- `).get(recipient) as { count: number };
110
 
111
  // Get total count for pagination
112
- let countQuery = 'SELECT COUNT(*) as total FROM notifications WHERE recipient = ?';
113
- const countParams: any[] = [recipient];
114
  if (unread_only) {
115
  countQuery += ' AND read_at IS NULL';
116
  }
@@ -146,6 +147,7 @@ export async function PUT(request: NextRequest) {
146
 
147
  try {
148
  const db = getDatabase();
 
149
  const body = await request.json();
150
  const { ids, recipient, markAllRead } = body;
151
 
@@ -156,10 +158,10 @@ export async function PUT(request: NextRequest) {
156
  const stmt = db.prepare(`
157
  UPDATE notifications
158
  SET read_at = ?
159
- WHERE recipient = ? AND read_at IS NULL
160
  `);
161
 
162
- const result = stmt.run(now, recipient);
163
 
164
  return NextResponse.json({
165
  success: true,
@@ -171,10 +173,10 @@ export async function PUT(request: NextRequest) {
171
  const stmt = db.prepare(`
172
  UPDATE notifications
173
  SET read_at = ?
174
- WHERE id IN (${placeholders}) AND read_at IS NULL
175
  `);
176
 
177
- const result = stmt.run(now, ...ids);
178
 
179
  return NextResponse.json({
180
  success: true,
@@ -204,6 +206,7 @@ export async function DELETE(request: NextRequest) {
204
 
205
  try {
206
  const db = getDatabase();
 
207
  const body = await request.json();
208
  const { ids, recipient, olderThan } = body;
209
 
@@ -212,10 +215,10 @@ export async function DELETE(request: NextRequest) {
212
  const placeholders = ids.map(() => '?').join(',');
213
  const stmt = db.prepare(`
214
  DELETE FROM notifications
215
- WHERE id IN (${placeholders})
216
  `);
217
 
218
- const result = stmt.run(...ids);
219
 
220
  return NextResponse.json({
221
  success: true,
@@ -225,10 +228,10 @@ export async function DELETE(request: NextRequest) {
225
  // Delete old notifications for recipient
226
  const stmt = db.prepare(`
227
  DELETE FROM notifications
228
- WHERE recipient = ? AND created_at < ?
229
  `);
230
 
231
- const result = stmt.run(recipient, olderThan);
232
 
233
  return NextResponse.json({
234
  success: true,
@@ -258,6 +261,7 @@ export async function POST(request: NextRequest) {
258
 
259
  try {
260
  const db = getDatabase();
 
261
 
262
  const result = await validateBody(request, notificationActionSchema);
263
  if ('error' in result) return result.error;
@@ -271,17 +275,17 @@ export async function POST(request: NextRequest) {
271
  const stmt = db.prepare(`
272
  UPDATE notifications
273
  SET delivered_at = ?
274
- WHERE recipient = ? AND delivered_at IS NULL
275
  `);
276
 
277
- const result = stmt.run(now, agent);
278
 
279
  // Get the notifications that were just marked as delivered
280
  const deliveredNotifications = db.prepare(`
281
  SELECT * FROM notifications
282
- WHERE recipient = ? AND delivered_at = ?
283
  ORDER BY created_at DESC
284
- `).all(agent, now) as Notification[];
285
 
286
  return NextResponse.json({
287
  success: true,
@@ -295,4 +299,4 @@ export async function POST(request: NextRequest) {
295
  logger.error({ err: error }, 'POST /api/notifications error');
296
  return NextResponse.json({ error: 'Failed to process notification action' }, { status: 500 });
297
  }
298
- }
 
16
  try {
17
  const db = getDatabase();
18
  const { searchParams } = new URL(request.url);
19
+ const workspaceId = auth.user.workspace_id ?? 1;
20
 
21
  // Parse query parameters
22
  const recipient = searchParams.get('recipient');
 
30
  }
31
 
32
  // Build dynamic query
33
+ let query = 'SELECT * FROM notifications WHERE recipient = ? AND workspace_id = ?';
34
+ const params: any[] = [recipient, workspaceId];
35
 
36
  if (unread_only) {
37
  query += ' AND read_at IS NULL';
 
49
  const notifications = stmt.all(...params) as Notification[];
50
 
51
  // Prepare source detail statements once (avoids N+1)
52
+ const taskDetailStmt = db.prepare('SELECT id, title, status FROM tasks WHERE id = ? AND workspace_id = ?');
53
  const commentDetailStmt = db.prepare(`
54
  SELECT c.id, c.content, c.task_id, t.title as task_title
55
  FROM comments c
56
  LEFT JOIN tasks t ON c.task_id = t.id
57
+ WHERE c.id = ? AND c.workspace_id = ? AND t.workspace_id = ?
58
  `);
59
+ const agentDetailStmt = db.prepare('SELECT id, name, role, status FROM agents WHERE id = ? AND workspace_id = ?');
60
 
61
  // Enhance notifications with related entity data
62
  const enhancedNotifications = notifications.map(notification => {
 
66
  if (notification.source_type && notification.source_id) {
67
  switch (notification.source_type) {
68
  case 'task': {
69
+ const task = taskDetailStmt.get(notification.source_id, workspaceId) as any;
70
  if (task) {
71
  sourceDetails = { type: 'task', ...task };
72
  }
73
  break;
74
  }
75
  case 'comment': {
76
+ const comment = commentDetailStmt.get(notification.source_id, workspaceId, workspaceId) as any;
77
  if (comment) {
78
  sourceDetails = {
79
  type: 'comment',
 
84
  break;
85
  }
86
  case 'agent': {
87
+ const agent = agentDetailStmt.get(notification.source_id, workspaceId) as any;
88
  if (agent) {
89
  sourceDetails = { type: 'agent', ...agent };
90
  }
 
106
  const unreadCount = db.prepare(`
107
  SELECT COUNT(*) as count
108
  FROM notifications
109
+ WHERE recipient = ? AND read_at IS NULL AND workspace_id = ?
110
+ `).get(recipient, workspaceId) as { count: number };
111
 
112
  // Get total count for pagination
113
+ let countQuery = 'SELECT COUNT(*) as total FROM notifications WHERE recipient = ? AND workspace_id = ?';
114
+ const countParams: any[] = [recipient, workspaceId];
115
  if (unread_only) {
116
  countQuery += ' AND read_at IS NULL';
117
  }
 
147
 
148
  try {
149
  const db = getDatabase();
150
+ const workspaceId = auth.user.workspace_id ?? 1;
151
  const body = await request.json();
152
  const { ids, recipient, markAllRead } = body;
153
 
 
158
  const stmt = db.prepare(`
159
  UPDATE notifications
160
  SET read_at = ?
161
+ WHERE recipient = ? AND read_at IS NULL AND workspace_id = ?
162
  `);
163
 
164
+ const result = stmt.run(now, recipient, workspaceId);
165
 
166
  return NextResponse.json({
167
  success: true,
 
173
  const stmt = db.prepare(`
174
  UPDATE notifications
175
  SET read_at = ?
176
+ WHERE id IN (${placeholders}) AND read_at IS NULL AND workspace_id = ?
177
  `);
178
 
179
+ const result = stmt.run(now, ...ids, workspaceId);
180
 
181
  return NextResponse.json({
182
  success: true,
 
206
 
207
  try {
208
  const db = getDatabase();
209
+ const workspaceId = auth.user.workspace_id ?? 1;
210
  const body = await request.json();
211
  const { ids, recipient, olderThan } = body;
212
 
 
215
  const placeholders = ids.map(() => '?').join(',');
216
  const stmt = db.prepare(`
217
  DELETE FROM notifications
218
+ WHERE id IN (${placeholders}) AND workspace_id = ?
219
  `);
220
 
221
+ const result = stmt.run(...ids, workspaceId);
222
 
223
  return NextResponse.json({
224
  success: true,
 
228
  // Delete old notifications for recipient
229
  const stmt = db.prepare(`
230
  DELETE FROM notifications
231
+ WHERE recipient = ? AND created_at < ? AND workspace_id = ?
232
  `);
233
 
234
+ const result = stmt.run(recipient, olderThan, workspaceId);
235
 
236
  return NextResponse.json({
237
  success: true,
 
261
 
262
  try {
263
  const db = getDatabase();
264
+ const workspaceId = auth.user.workspace_id ?? 1;
265
 
266
  const result = await validateBody(request, notificationActionSchema);
267
  if ('error' in result) return result.error;
 
275
  const stmt = db.prepare(`
276
  UPDATE notifications
277
  SET delivered_at = ?
278
+ WHERE recipient = ? AND delivered_at IS NULL AND workspace_id = ?
279
  `);
280
 
281
+ const result = stmt.run(now, agent, workspaceId);
282
 
283
  // Get the notifications that were just marked as delivered
284
  const deliveredNotifications = db.prepare(`
285
  SELECT * FROM notifications
286
+ WHERE recipient = ? AND delivered_at = ? AND workspace_id = ?
287
  ORDER BY created_at DESC
288
+ `).all(agent, now, workspaceId) as Notification[];
289
 
290
  return NextResponse.json({
291
  success: true,
 
299
  logger.error({ err: error }, 'POST /api/notifications error');
300
  return NextResponse.json({ error: 'Failed to process notification action' }, { status: 500 });
301
  }
302
+ }
src/app/api/pipelines/route.ts CHANGED
@@ -32,9 +32,10 @@ export async function GET(request: NextRequest) {
32
 
33
  try {
34
  const db = getDatabase()
 
35
  const pipelines = db.prepare(
36
- 'SELECT * FROM workflow_pipelines ORDER BY use_count DESC, updated_at DESC'
37
- ).all() as Pipeline[]
38
 
39
  // Enrich steps with template names
40
  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) {
46
  SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
47
  SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
48
  SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running
49
- FROM pipeline_runs GROUP BY pipeline_id
50
- `).all() as Array<{ pipeline_id: number; total: number; completed: number; failed: number; running: number }>
51
  const runMap = new Map(runCounts.map(r => [r.pipeline_id, r]))
52
 
53
  const parsed = pipelines.map(p => {
@@ -82,6 +83,7 @@ export async function POST(request: NextRequest) {
82
  const { name, description, steps } = result.data
83
 
84
  const db = getDatabase()
 
85
 
86
  // Validate template IDs exist
87
  const templateIds = steps.map((s: PipelineStep) => s.template_id)
@@ -98,13 +100,23 @@ export async function POST(request: NextRequest) {
98
  }))
99
 
100
  const insertResult = db.prepare(`
101
- INSERT INTO workflow_pipelines (name, description, steps, created_by)
102
- VALUES (?, ?, ?, ?)
103
- `).run(name, description || null, JSON.stringify(cleanSteps), auth.user?.username || 'system')
104
-
105
- db_helpers.logActivity('pipeline_created', 'pipeline', Number(insertResult.lastInsertRowid), auth.user?.username || 'system', `Created pipeline: ${name}`)
106
-
107
- const pipeline = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(insertResult.lastInsertRowid) as Pipeline
 
 
 
 
 
 
 
 
 
 
108
  return NextResponse.json({ pipeline: { ...pipeline, steps: JSON.parse(pipeline.steps) } }, { status: 201 })
109
  } catch (error) {
110
  logger.error({ err: error }, 'POST /api/pipelines error')
@@ -121,12 +133,15 @@ export async function PUT(request: NextRequest) {
121
 
122
  try {
123
  const db = getDatabase()
 
124
  const body = await request.json()
125
  const { id, ...updates } = body
126
 
127
  if (!id) return NextResponse.json({ error: 'Pipeline ID required' }, { status: 400 })
128
 
129
- const existing = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(id) as Pipeline
 
 
130
  if (!existing) return NextResponse.json({ error: 'Pipeline not found' }, { status: 404 })
131
 
132
  const fields: string[] = []
@@ -147,11 +162,13 @@ export async function PUT(request: NextRequest) {
147
 
148
  fields.push('updated_at = ?')
149
  params.push(Math.floor(Date.now() / 1000))
150
- params.push(id)
151
 
152
- db.prepare(`UPDATE workflow_pipelines SET ${fields.join(', ')} WHERE id = ?`).run(...params)
153
 
154
- const updated = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(id) as Pipeline
 
 
155
  return NextResponse.json({ pipeline: { ...updated, steps: JSON.parse(updated.steps) } })
156
  } catch (error) {
157
  logger.error({ err: error }, 'PUT /api/pipelines error')
@@ -168,12 +185,13 @@ export async function DELETE(request: NextRequest) {
168
 
169
  try {
170
  const db = getDatabase()
 
171
  let body: any
172
  try { body = await request.json() } catch { return NextResponse.json({ error: 'Request body required' }, { status: 400 }) }
173
  const id = body.id
174
  if (!id) return NextResponse.json({ error: 'Pipeline ID required' }, { status: 400 })
175
 
176
- db.prepare('DELETE FROM workflow_pipelines WHERE id = ?').run(parseInt(id))
177
  return NextResponse.json({ success: true })
178
  } catch (error) {
179
  logger.error({ err: error }, 'DELETE /api/pipelines error')
 
32
 
33
  try {
34
  const db = getDatabase()
35
+ const workspaceId = auth.user.workspace_id ?? 1
36
  const pipelines = db.prepare(
37
+ 'SELECT * FROM workflow_pipelines WHERE workspace_id = ? ORDER BY use_count DESC, updated_at DESC'
38
+ ).all(workspaceId) as Pipeline[]
39
 
40
  // Enrich steps with template names
41
  const templates = db.prepare('SELECT id, name FROM workflow_templates').all() as Array<{ id: number; name: string }>
 
47
  SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
48
  SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
49
  SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running
50
+ FROM pipeline_runs WHERE workspace_id = ? GROUP BY pipeline_id
51
+ `).all(workspaceId) as Array<{ pipeline_id: number; total: number; completed: number; failed: number; running: number }>
52
  const runMap = new Map(runCounts.map(r => [r.pipeline_id, r]))
53
 
54
  const parsed = pipelines.map(p => {
 
83
  const { name, description, steps } = result.data
84
 
85
  const db = getDatabase()
86
+ const workspaceId = auth.user.workspace_id ?? 1
87
 
88
  // Validate template IDs exist
89
  const templateIds = steps.map((s: PipelineStep) => s.template_id)
 
100
  }))
101
 
102
  const insertResult = db.prepare(`
103
+ INSERT INTO workflow_pipelines (name, description, steps, created_by, workspace_id)
104
+ VALUES (?, ?, ?, ?, ?)
105
+ `).run(name, description || null, JSON.stringify(cleanSteps), auth.user?.username || 'system', workspaceId)
106
+
107
+ db_helpers.logActivity(
108
+ 'pipeline_created',
109
+ 'pipeline',
110
+ Number(insertResult.lastInsertRowid),
111
+ auth.user?.username || 'system',
112
+ `Created pipeline: ${name}`,
113
+ undefined,
114
+ workspaceId
115
+ )
116
+
117
+ const pipeline = db
118
+ .prepare('SELECT * FROM workflow_pipelines WHERE id = ? AND workspace_id = ?')
119
+ .get(insertResult.lastInsertRowid, workspaceId) as Pipeline
120
  return NextResponse.json({ pipeline: { ...pipeline, steps: JSON.parse(pipeline.steps) } }, { status: 201 })
121
  } catch (error) {
122
  logger.error({ err: error }, 'POST /api/pipelines error')
 
133
 
134
  try {
135
  const db = getDatabase()
136
+ const workspaceId = auth.user.workspace_id ?? 1
137
  const body = await request.json()
138
  const { id, ...updates } = body
139
 
140
  if (!id) return NextResponse.json({ error: 'Pipeline ID required' }, { status: 400 })
141
 
142
+ const existing = db
143
+ .prepare('SELECT * FROM workflow_pipelines WHERE id = ? AND workspace_id = ?')
144
+ .get(id, workspaceId) as Pipeline
145
  if (!existing) return NextResponse.json({ error: 'Pipeline not found' }, { status: 404 })
146
 
147
  const fields: string[] = []
 
162
 
163
  fields.push('updated_at = ?')
164
  params.push(Math.floor(Date.now() / 1000))
165
+ params.push(id, workspaceId)
166
 
167
+ db.prepare(`UPDATE workflow_pipelines SET ${fields.join(', ')} WHERE id = ? AND workspace_id = ?`).run(...params)
168
 
169
+ const updated = db
170
+ .prepare('SELECT * FROM workflow_pipelines WHERE id = ? AND workspace_id = ?')
171
+ .get(id, workspaceId) as Pipeline
172
  return NextResponse.json({ pipeline: { ...updated, steps: JSON.parse(updated.steps) } })
173
  } catch (error) {
174
  logger.error({ err: error }, 'PUT /api/pipelines error')
 
185
 
186
  try {
187
  const db = getDatabase()
188
+ const workspaceId = auth.user.workspace_id ?? 1
189
  let body: any
190
  try { body = await request.json() } catch { return NextResponse.json({ error: 'Request body required' }, { status: 400 }) }
191
  const id = body.id
192
  if (!id) return NextResponse.json({ error: 'Pipeline ID required' }, { status: 400 })
193
 
194
+ db.prepare('DELETE FROM workflow_pipelines WHERE id = ? AND workspace_id = ?').run(parseInt(id), workspaceId)
195
  return NextResponse.json({ success: true })
196
  } catch (error) {
197
  logger.error({ err: error }, 'DELETE /api/pipelines error')
src/app/api/pipelines/run/route.ts CHANGED
@@ -42,21 +42,24 @@ export async function GET(request: NextRequest) {
42
  try {
43
  const db = getDatabase()
44
  const { searchParams } = new URL(request.url)
 
45
  const pipelineId = searchParams.get('pipeline_id')
46
  const runId = searchParams.get('id')
47
  const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 200)
48
 
49
  if (runId) {
50
- const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ?').get(parseInt(runId)) as PipelineRun | undefined
 
 
51
  if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 })
52
  return NextResponse.json({ run: { ...run, steps_snapshot: JSON.parse(run.steps_snapshot) } })
53
  }
54
 
55
- let query = 'SELECT * FROM pipeline_runs'
56
- const params: any[] = []
57
 
58
  if (pipelineId) {
59
- query += ' WHERE pipeline_id = ?'
60
  params.push(parseInt(pipelineId))
61
  }
62
 
@@ -68,7 +71,7 @@ export async function GET(request: NextRequest) {
68
  // Enrich with pipeline names
69
  const pipelineIds = [...new Set(runs.map(r => r.pipeline_id))]
70
  const pipelines = pipelineIds.length > 0
71
- ? db.prepare(`SELECT id, name FROM workflow_pipelines WHERE id IN (${pipelineIds.map(() => '?').join(',')})`).all(...pipelineIds) as Array<{ id: number; name: string }>
72
  : []
73
  const nameMap = new Map(pipelines.map(p => [p.id, p.name]))
74
 
@@ -94,15 +97,16 @@ export async function POST(request: NextRequest) {
94
 
95
  try {
96
  const db = getDatabase()
 
97
  const body = await request.json()
98
  const { action, pipeline_id, run_id } = body
99
 
100
  if (action === 'start') {
101
- return startPipeline(db, pipeline_id, auth.user?.username || 'system')
102
  } else if (action === 'advance') {
103
- return advanceRun(db, run_id, body.success ?? true, body.error)
104
  } else if (action === 'cancel') {
105
- return cancelRun(db, run_id)
106
  }
107
 
108
  return NextResponse.json({ error: 'Invalid action. Use: start, advance, cancel' }, { status: 400 })
@@ -119,7 +123,8 @@ async function spawnStep(
119
  template: { name: string; model: string; task_prompt: string; timeout_seconds: number },
120
  steps: RunStepState[],
121
  stepIdx: number,
122
- runId: number
 
123
  ): Promise<{ success: boolean; stdout?: string; error?: string }> {
124
  try {
125
  const { runOpenClaw } = await import('@/lib/command')
@@ -133,20 +138,20 @@ async function spawnStep(
133
 
134
  const spawnId = `pipeline-${runId}-step-${stepIdx}-${Date.now()}`
135
  steps[stepIdx].spawn_id = spawnId
136
- db.prepare('UPDATE pipeline_runs SET steps_snapshot = ? WHERE id = ?').run(JSON.stringify(steps), runId)
137
 
138
  return { success: true, stdout: stdout.trim() }
139
  } catch (err: any) {
140
  // Spawn failed - record error but keep pipeline running for manual advance
141
  steps[stepIdx].error = err.message
142
- db.prepare('UPDATE pipeline_runs SET steps_snapshot = ? WHERE id = ?').run(JSON.stringify(steps), runId)
143
 
144
  return { success: false, error: err.message }
145
  }
146
  }
147
 
148
- async function startPipeline(db: ReturnType<typeof getDatabase>, pipelineId: number, triggeredBy: string) {
149
- const pipeline = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(pipelineId) as any
150
  if (!pipeline) return NextResponse.json({ error: 'Pipeline not found' }, { status: 404 })
151
 
152
  const steps: PipelineStep[] = JSON.parse(pipeline.steps || '[]')
@@ -174,25 +179,25 @@ async function startPipeline(db: ReturnType<typeof getDatabase>, pipelineId: num
174
 
175
  const now = Math.floor(Date.now() / 1000)
176
  const result = db.prepare(`
177
- INSERT INTO pipeline_runs (pipeline_id, status, current_step, steps_snapshot, started_at, triggered_by)
178
- VALUES (?, 'running', 0, ?, ?, ?)
179
- `).run(pipelineId, JSON.stringify(stepsSnapshot), now, triggeredBy)
180
 
181
  const runId = Number(result.lastInsertRowid)
182
 
183
  // Update pipeline usage
184
  db.prepare(`
185
- UPDATE workflow_pipelines SET use_count = use_count + 1, last_used_at = ?, updated_at = ? WHERE id = ?
186
- `).run(now, now, pipelineId)
187
 
188
  // Spawn first step
189
  const firstTemplate = templateMap.get(steps[0].template_id)
190
  let spawnResult: any = null
191
  if (firstTemplate) {
192
- spawnResult = await spawnStep(db, pipeline.name, firstTemplate, stepsSnapshot, 0, runId)
193
  }
194
 
195
- db_helpers.logActivity('pipeline_started', 'pipeline', pipelineId, triggeredBy, `Started pipeline: ${pipeline.name}`, { run_id: runId })
196
 
197
  eventBus.broadcast('activity.created', {
198
  type: 'pipeline_started',
@@ -214,10 +219,10 @@ async function startPipeline(db: ReturnType<typeof getDatabase>, pipelineId: num
214
  }, { status: 201 })
215
  }
216
 
217
- async function advanceRun(db: ReturnType<typeof getDatabase>, runId: number, success: boolean, errorMsg?: string) {
218
  if (!runId) return NextResponse.json({ error: 'run_id required' }, { status: 400 })
219
 
220
- const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ?').get(runId) as PipelineRun | undefined
221
  if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 })
222
  if (run.status !== 'running') return NextResponse.json({ error: `Run is ${run.status}, not running` }, { status: 400 })
223
 
@@ -237,16 +242,16 @@ async function advanceRun(db: ReturnType<typeof getDatabase>, runId: number, suc
237
  if (!success && onFailure === 'stop') {
238
  // Mark remaining steps as skipped
239
  for (let i = nextIdx; i < steps.length; i++) steps[i].status = 'skipped'
240
- db.prepare('UPDATE pipeline_runs SET status = ?, current_step = ?, steps_snapshot = ?, completed_at = ? WHERE id = ?')
241
- .run('failed', currentIdx, JSON.stringify(steps), now, runId)
242
  return NextResponse.json({ run: { id: runId, status: 'failed', steps_snapshot: steps } })
243
  }
244
 
245
  if (nextIdx >= steps.length) {
246
  // Pipeline complete
247
  const finalStatus = steps.some(s => s.status === 'failed') ? 'completed' : 'completed'
248
- db.prepare('UPDATE pipeline_runs SET status = ?, current_step = ?, steps_snapshot = ?, completed_at = ? WHERE id = ?')
249
- .run(finalStatus, currentIdx, JSON.stringify(steps), now, runId)
250
 
251
  eventBus.broadcast('activity.created', {
252
  type: 'pipeline_completed',
@@ -267,22 +272,22 @@ async function advanceRun(db: ReturnType<typeof getDatabase>, runId: number, suc
267
 
268
  let spawnResult: any = null
269
  if (template) {
270
- const pipeline = db.prepare('SELECT name FROM workflow_pipelines WHERE id = ?').get(run.pipeline_id) as any
271
- spawnResult = await spawnStep(db, pipeline?.name || '?', template, steps, nextIdx, runId)
272
  }
273
 
274
- db.prepare('UPDATE pipeline_runs SET current_step = ?, steps_snapshot = ? WHERE id = ?')
275
- .run(nextIdx, JSON.stringify(steps), runId)
276
 
277
  return NextResponse.json({
278
  run: { id: runId, status: 'running', current_step: nextIdx, steps_snapshot: steps, spawn: spawnResult }
279
  })
280
  }
281
 
282
- function cancelRun(db: ReturnType<typeof getDatabase>, runId: number) {
283
  if (!runId) return NextResponse.json({ error: 'run_id required' }, { status: 400 })
284
 
285
- const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ?').get(runId) as PipelineRun | undefined
286
  if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 })
287
  if (run.status !== 'running' && run.status !== 'pending') {
288
  return NextResponse.json({ error: `Run is ${run.status}, cannot cancel` }, { status: 400 })
@@ -298,8 +303,8 @@ function cancelRun(db: ReturnType<typeof getDatabase>, runId: number) {
298
  }
299
  }
300
 
301
- db.prepare('UPDATE pipeline_runs SET status = ?, steps_snapshot = ?, completed_at = ? WHERE id = ?')
302
- .run('cancelled', JSON.stringify(steps), now, runId)
303
 
304
  return NextResponse.json({ run: { id: runId, status: 'cancelled', steps_snapshot: steps } })
305
  }
 
42
  try {
43
  const db = getDatabase()
44
  const { searchParams } = new URL(request.url)
45
+ const workspaceId = auth.user.workspace_id ?? 1
46
  const pipelineId = searchParams.get('pipeline_id')
47
  const runId = searchParams.get('id')
48
  const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 200)
49
 
50
  if (runId) {
51
+ const run = db
52
+ .prepare('SELECT * FROM pipeline_runs WHERE id = ? AND workspace_id = ?')
53
+ .get(parseInt(runId), workspaceId) as PipelineRun | undefined
54
  if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 })
55
  return NextResponse.json({ run: { ...run, steps_snapshot: JSON.parse(run.steps_snapshot) } })
56
  }
57
 
58
+ let query = 'SELECT * FROM pipeline_runs WHERE workspace_id = ?'
59
+ const params: any[] = [workspaceId]
60
 
61
  if (pipelineId) {
62
+ query += ' AND pipeline_id = ?'
63
  params.push(parseInt(pipelineId))
64
  }
65
 
 
71
  // Enrich with pipeline names
72
  const pipelineIds = [...new Set(runs.map(r => r.pipeline_id))]
73
  const pipelines = pipelineIds.length > 0
74
+ ? 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 }>
75
  : []
76
  const nameMap = new Map(pipelines.map(p => [p.id, p.name]))
77
 
 
97
 
98
  try {
99
  const db = getDatabase()
100
+ const workspaceId = auth.user.workspace_id ?? 1
101
  const body = await request.json()
102
  const { action, pipeline_id, run_id } = body
103
 
104
  if (action === 'start') {
105
+ return startPipeline(db, pipeline_id, auth.user?.username || 'system', workspaceId)
106
  } else if (action === 'advance') {
107
+ return advanceRun(db, run_id, body.success ?? true, body.error, workspaceId)
108
  } else if (action === 'cancel') {
109
+ return cancelRun(db, run_id, workspaceId)
110
  }
111
 
112
  return NextResponse.json({ error: 'Invalid action. Use: start, advance, cancel' }, { status: 400 })
 
123
  template: { name: string; model: string; task_prompt: string; timeout_seconds: number },
124
  steps: RunStepState[],
125
  stepIdx: number,
126
+ runId: number,
127
+ workspaceId: number
128
  ): Promise<{ success: boolean; stdout?: string; error?: string }> {
129
  try {
130
  const { runOpenClaw } = await import('@/lib/command')
 
138
 
139
  const spawnId = `pipeline-${runId}-step-${stepIdx}-${Date.now()}`
140
  steps[stepIdx].spawn_id = spawnId
141
+ db.prepare('UPDATE pipeline_runs SET steps_snapshot = ? WHERE id = ? AND workspace_id = ?').run(JSON.stringify(steps), runId, workspaceId)
142
 
143
  return { success: true, stdout: stdout.trim() }
144
  } catch (err: any) {
145
  // Spawn failed - record error but keep pipeline running for manual advance
146
  steps[stepIdx].error = err.message
147
+ db.prepare('UPDATE pipeline_runs SET steps_snapshot = ? WHERE id = ? AND workspace_id = ?').run(JSON.stringify(steps), runId, workspaceId)
148
 
149
  return { success: false, error: err.message }
150
  }
151
  }
152
 
153
+ async function startPipeline(db: ReturnType<typeof getDatabase>, pipelineId: number, triggeredBy: string, workspaceId: number) {
154
+ const pipeline = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ? AND workspace_id = ?').get(pipelineId, workspaceId) as any
155
  if (!pipeline) return NextResponse.json({ error: 'Pipeline not found' }, { status: 404 })
156
 
157
  const steps: PipelineStep[] = JSON.parse(pipeline.steps || '[]')
 
179
 
180
  const now = Math.floor(Date.now() / 1000)
181
  const result = db.prepare(`
182
+ INSERT INTO pipeline_runs (pipeline_id, status, current_step, steps_snapshot, started_at, triggered_by, workspace_id)
183
+ VALUES (?, 'running', 0, ?, ?, ?, ?)
184
+ `).run(pipelineId, JSON.stringify(stepsSnapshot), now, triggeredBy, workspaceId)
185
 
186
  const runId = Number(result.lastInsertRowid)
187
 
188
  // Update pipeline usage
189
  db.prepare(`
190
+ UPDATE workflow_pipelines SET use_count = use_count + 1, last_used_at = ?, updated_at = ? WHERE id = ? AND workspace_id = ?
191
+ `).run(now, now, pipelineId, workspaceId)
192
 
193
  // Spawn first step
194
  const firstTemplate = templateMap.get(steps[0].template_id)
195
  let spawnResult: any = null
196
  if (firstTemplate) {
197
+ spawnResult = await spawnStep(db, pipeline.name, firstTemplate, stepsSnapshot, 0, runId, workspaceId)
198
  }
199
 
200
+ db_helpers.logActivity('pipeline_started', 'pipeline', pipelineId, triggeredBy, `Started pipeline: ${pipeline.name}`, { run_id: runId }, workspaceId)
201
 
202
  eventBus.broadcast('activity.created', {
203
  type: 'pipeline_started',
 
219
  }, { status: 201 })
220
  }
221
 
222
+ async function advanceRun(db: ReturnType<typeof getDatabase>, runId: number, success: boolean, errorMsg: string | undefined, workspaceId: number) {
223
  if (!runId) return NextResponse.json({ error: 'run_id required' }, { status: 400 })
224
 
225
+ const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ? AND workspace_id = ?').get(runId, workspaceId) as PipelineRun | undefined
226
  if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 })
227
  if (run.status !== 'running') return NextResponse.json({ error: `Run is ${run.status}, not running` }, { status: 400 })
228
 
 
242
  if (!success && onFailure === 'stop') {
243
  // Mark remaining steps as skipped
244
  for (let i = nextIdx; i < steps.length; i++) steps[i].status = 'skipped'
245
+ db.prepare('UPDATE pipeline_runs SET status = ?, current_step = ?, steps_snapshot = ?, completed_at = ? WHERE id = ? AND workspace_id = ?')
246
+ .run('failed', currentIdx, JSON.stringify(steps), now, runId, workspaceId)
247
  return NextResponse.json({ run: { id: runId, status: 'failed', steps_snapshot: steps } })
248
  }
249
 
250
  if (nextIdx >= steps.length) {
251
  // Pipeline complete
252
  const finalStatus = steps.some(s => s.status === 'failed') ? 'completed' : 'completed'
253
+ db.prepare('UPDATE pipeline_runs SET status = ?, current_step = ?, steps_snapshot = ?, completed_at = ? WHERE id = ? AND workspace_id = ?')
254
+ .run(finalStatus, currentIdx, JSON.stringify(steps), now, runId, workspaceId)
255
 
256
  eventBus.broadcast('activity.created', {
257
  type: 'pipeline_completed',
 
272
 
273
  let spawnResult: any = null
274
  if (template) {
275
+ const pipeline = db.prepare('SELECT name FROM workflow_pipelines WHERE id = ? AND workspace_id = ?').get(run.pipeline_id, workspaceId) as any
276
+ spawnResult = await spawnStep(db, pipeline?.name || '?', template, steps, nextIdx, runId, workspaceId)
277
  }
278
 
279
+ db.prepare('UPDATE pipeline_runs SET current_step = ?, steps_snapshot = ? WHERE id = ? AND workspace_id = ?')
280
+ .run(nextIdx, JSON.stringify(steps), runId, workspaceId)
281
 
282
  return NextResponse.json({
283
  run: { id: runId, status: 'running', current_step: nextIdx, steps_snapshot: steps, spawn: spawnResult }
284
  })
285
  }
286
 
287
+ function cancelRun(db: ReturnType<typeof getDatabase>, runId: number, workspaceId: number) {
288
  if (!runId) return NextResponse.json({ error: 'run_id required' }, { status: 400 })
289
 
290
+ const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ? AND workspace_id = ?').get(runId, workspaceId) as PipelineRun | undefined
291
  if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 })
292
  if (run.status !== 'running' && run.status !== 'pending') {
293
  return NextResponse.json({ error: `Run is ${run.status}, cannot cancel` }, { status: 400 })
 
303
  }
304
  }
305
 
306
+ db.prepare('UPDATE pipeline_runs SET status = ?, steps_snapshot = ?, completed_at = ? WHERE id = ? AND workspace_id = ?')
307
+ .run('cancelled', JSON.stringify(steps), now, runId, workspaceId)
308
 
309
  return NextResponse.json({ run: { id: runId, status: 'cancelled', steps_snapshot: steps } })
310
  }
src/app/api/quality-review/route.ts CHANGED
@@ -13,6 +13,7 @@ export async function GET(request: NextRequest) {
13
  try {
14
  const db = getDatabase()
15
  const { searchParams } = new URL(request.url)
 
16
  const taskIdsParam = searchParams.get('taskIds')
17
  const taskId = parseInt(searchParams.get('taskId') || '')
18
 
@@ -29,9 +30,9 @@ export async function GET(request: NextRequest) {
29
  const placeholders = ids.map(() => '?').join(',')
30
  const rows = db.prepare(`
31
  SELECT * FROM quality_reviews
32
- WHERE task_id IN (${placeholders})
33
  ORDER BY task_id ASC, created_at DESC
34
- `).all(...ids) as Array<{ task_id: number; reviewer?: string; status?: string; created_at?: number }>
35
 
36
  const byTask: Record<number, { status?: string; reviewer?: string; created_at?: number } | null> = {}
37
  for (const id of ids) {
@@ -54,10 +55,10 @@ export async function GET(request: NextRequest) {
54
 
55
  const reviews = db.prepare(`
56
  SELECT * FROM quality_reviews
57
- WHERE task_id = ?
58
  ORDER BY created_at DESC
59
  LIMIT 10
60
- `).all(taskId)
61
 
62
  return NextResponse.json({ reviews })
63
  } catch (error) {
@@ -79,16 +80,19 @@ export async function POST(request: NextRequest) {
79
  const { taskId, reviewer, status, notes } = validated.data
80
 
81
  const db = getDatabase()
 
82
 
83
- const task = db.prepare('SELECT id, title FROM tasks WHERE id = ?').get(taskId) as any
 
 
84
  if (!task) {
85
  return NextResponse.json({ error: 'Task not found' }, { status: 404 })
86
  }
87
 
88
  const result = db.prepare(`
89
- INSERT INTO quality_reviews (task_id, reviewer, status, notes)
90
- VALUES (?, ?, ?, ?)
91
- `).run(taskId, reviewer, status, notes)
92
 
93
  db_helpers.logActivity(
94
  'quality_review',
@@ -96,13 +100,14 @@ export async function POST(request: NextRequest) {
96
  taskId,
97
  reviewer,
98
  `Quality review ${status} for task: ${task.title}`,
99
- { status, notes }
 
100
  )
101
 
102
  // Auto-advance task to 'done' when aegis approves
103
  if (status === 'approved' && reviewer === 'aegis') {
104
- db.prepare('UPDATE tasks SET status = ?, updated_at = unixepoch() WHERE id = ?')
105
- .run('done', taskId)
106
  eventBus.broadcast('task.status_changed', {
107
  id: taskId,
108
  status: 'done',
 
13
  try {
14
  const db = getDatabase()
15
  const { searchParams } = new URL(request.url)
16
+ const workspaceId = auth.user.workspace_id ?? 1;
17
  const taskIdsParam = searchParams.get('taskIds')
18
  const taskId = parseInt(searchParams.get('taskId') || '')
19
 
 
30
  const placeholders = ids.map(() => '?').join(',')
31
  const rows = db.prepare(`
32
  SELECT * FROM quality_reviews
33
+ WHERE task_id IN (${placeholders}) AND workspace_id = ?
34
  ORDER BY task_id ASC, created_at DESC
35
+ `).all(...ids, workspaceId) as Array<{ task_id: number; reviewer?: string; status?: string; created_at?: number }>
36
 
37
  const byTask: Record<number, { status?: string; reviewer?: string; created_at?: number } | null> = {}
38
  for (const id of ids) {
 
55
 
56
  const reviews = db.prepare(`
57
  SELECT * FROM quality_reviews
58
+ WHERE task_id = ? AND workspace_id = ?
59
  ORDER BY created_at DESC
60
  LIMIT 10
61
+ `).all(taskId, workspaceId)
62
 
63
  return NextResponse.json({ reviews })
64
  } catch (error) {
 
80
  const { taskId, reviewer, status, notes } = validated.data
81
 
82
  const db = getDatabase()
83
+ const workspaceId = auth.user.workspace_id ?? 1;
84
 
85
+ const task = db
86
+ .prepare('SELECT id, title FROM tasks WHERE id = ? AND workspace_id = ?')
87
+ .get(taskId, workspaceId) as any
88
  if (!task) {
89
  return NextResponse.json({ error: 'Task not found' }, { status: 404 })
90
  }
91
 
92
  const result = db.prepare(`
93
+ INSERT INTO quality_reviews (task_id, reviewer, status, notes, workspace_id)
94
+ VALUES (?, ?, ?, ?, ?)
95
+ `).run(taskId, reviewer, status, notes, workspaceId)
96
 
97
  db_helpers.logActivity(
98
  'quality_review',
 
100
  taskId,
101
  reviewer,
102
  `Quality review ${status} for task: ${task.title}`,
103
+ { status, notes },
104
+ workspaceId
105
  )
106
 
107
  // Auto-advance task to 'done' when aegis approves
108
  if (status === 'approved' && reviewer === 'aegis') {
109
+ db.prepare('UPDATE tasks SET status = ?, updated_at = unixepoch() WHERE id = ? AND workspace_id = ?')
110
+ .run('done', taskId, workspaceId)
111
  eventBus.broadcast('task.status_changed', {
112
  id: taskId,
113
  status: 'done',
src/app/api/search/route.ts CHANGED
@@ -34,6 +34,7 @@ export async function GET(request: NextRequest) {
34
  }
35
 
36
  const db = getDatabase()
 
37
  const likeQ = `%${query}%`
38
  const results: SearchResult[] = []
39
 
@@ -42,9 +43,9 @@ export async function GET(request: NextRequest) {
42
  try {
43
  const tasks = db.prepare(`
44
  SELECT id, title, description, status, assigned_to, created_at
45
- FROM tasks WHERE title LIKE ? OR description LIKE ? OR assigned_to LIKE ?
46
  ORDER BY created_at DESC LIMIT ?
47
- `).all(likeQ, likeQ, likeQ, limit) as any[]
48
  for (const t of tasks) {
49
  results.push({
50
  type: 'task',
@@ -64,9 +65,9 @@ export async function GET(request: NextRequest) {
64
  try {
65
  const agents = db.prepare(`
66
  SELECT id, name, role, status, last_activity, created_at
67
- FROM agents WHERE name LIKE ? OR role LIKE ? OR last_activity LIKE ?
68
  ORDER BY created_at DESC LIMIT ?
69
- `).all(likeQ, likeQ, likeQ, limit) as any[]
70
  for (const a of agents) {
71
  results.push({
72
  type: 'agent',
@@ -86,9 +87,9 @@ export async function GET(request: NextRequest) {
86
  try {
87
  const activities = db.prepare(`
88
  SELECT id, type, actor, description, created_at
89
- FROM activities WHERE description LIKE ? OR actor LIKE ?
90
  ORDER BY created_at DESC LIMIT ?
91
- `).all(likeQ, likeQ, limit) as any[]
92
  for (const a of activities) {
93
  results.push({
94
  type: 'activity',
 
34
  }
35
 
36
  const db = getDatabase()
37
+ const workspaceId = auth.user.workspace_id ?? 1
38
  const likeQ = `%${query}%`
39
  const results: SearchResult[] = []
40
 
 
43
  try {
44
  const tasks = db.prepare(`
45
  SELECT id, title, description, status, assigned_to, created_at
46
+ FROM tasks WHERE workspace_id = ? AND (title LIKE ? OR description LIKE ? OR assigned_to LIKE ?)
47
  ORDER BY created_at DESC LIMIT ?
48
+ `).all(workspaceId, likeQ, likeQ, likeQ, limit) as any[]
49
  for (const t of tasks) {
50
  results.push({
51
  type: 'task',
 
65
  try {
66
  const agents = db.prepare(`
67
  SELECT id, name, role, status, last_activity, created_at
68
+ FROM agents WHERE workspace_id = ? AND (name LIKE ? OR role LIKE ? OR last_activity LIKE ?)
69
  ORDER BY created_at DESC LIMIT ?
70
+ `).all(workspaceId, likeQ, likeQ, likeQ, limit) as any[]
71
  for (const a of agents) {
72
  results.push({
73
  type: 'agent',
 
87
  try {
88
  const activities = db.prepare(`
89
  SELECT id, type, actor, description, created_at
90
+ FROM activities WHERE workspace_id = ? AND (description LIKE ? OR actor LIKE ?)
91
  ORDER BY created_at DESC LIMIT ?
92
+ `).all(workspaceId, likeQ, likeQ, limit) as any[]
93
  for (const a of activities) {
94
  results.push({
95
  type: 'activity',
src/app/api/standup/route.ts CHANGED
@@ -14,6 +14,7 @@ export async function POST(request: NextRequest) {
14
  try {
15
  const db = getDatabase();
16
  const body = await request.json();
 
17
 
18
  // Parse parameters
19
  const targetDate = body.date || new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
@@ -24,12 +25,12 @@ export async function POST(request: NextRequest) {
24
  const endOfDay = Math.floor(new Date(`${targetDate}T23:59:59Z`).getTime() / 1000);
25
 
26
  // Get all active agents or filter by specific agents
27
- let agentQuery = 'SELECT * FROM agents';
28
- const agentParams: any[] = [];
29
 
30
  if (specificAgents && Array.isArray(specificAgents) && specificAgents.length > 0) {
31
  const placeholders = specificAgents.map(() => '?').join(',');
32
- agentQuery += ` WHERE name IN (${placeholders})`;
33
  agentParams.push(...specificAgents);
34
  }
35
 
@@ -42,6 +43,7 @@ export async function POST(request: NextRequest) {
42
  SELECT id, title, status, updated_at
43
  FROM tasks
44
  WHERE assigned_to = ?
 
45
  AND status = 'done'
46
  AND updated_at BETWEEN ? AND ?
47
  ORDER BY updated_at DESC
@@ -50,6 +52,7 @@ export async function POST(request: NextRequest) {
50
  SELECT id, title, status, created_at, due_date
51
  FROM tasks
52
  WHERE assigned_to = ?
 
53
  AND status = 'in_progress'
54
  ORDER BY created_at ASC
55
  `);
@@ -57,6 +60,7 @@ export async function POST(request: NextRequest) {
57
  SELECT id, title, status, created_at, due_date, priority
58
  FROM tasks
59
  WHERE assigned_to = ?
 
60
  AND status = 'assigned'
61
  ORDER BY priority DESC, created_at ASC
62
  `);
@@ -64,6 +68,7 @@ export async function POST(request: NextRequest) {
64
  SELECT id, title, status, updated_at
65
  FROM tasks
66
  WHERE assigned_to = ?
 
67
  AND status IN ('review', 'quality_review')
68
  ORDER BY updated_at ASC
69
  `);
@@ -71,6 +76,7 @@ export async function POST(request: NextRequest) {
71
  SELECT id, title, status, priority, created_at, metadata
72
  FROM tasks
73
  WHERE assigned_to = ?
 
74
  AND (priority = 'urgent' OR metadata LIKE '%blocked%')
75
  AND status NOT IN ('done')
76
  ORDER BY priority DESC, created_at ASC
@@ -79,24 +85,26 @@ export async function POST(request: NextRequest) {
79
  SELECT COUNT(*) as count
80
  FROM activities
81
  WHERE actor = ?
 
82
  AND created_at BETWEEN ? AND ?
83
  `);
84
  const commentCountStmt = db.prepare(`
85
  SELECT COUNT(*) as count
86
  FROM comments
87
  WHERE author = ?
 
88
  AND created_at BETWEEN ? AND ?
89
  `);
90
 
91
  // Generate standup data for each agent
92
  const standupData = agents.map(agent => {
93
- const completedTasks = completedTasksStmt.all(agent.name, startOfDay, endOfDay);
94
- const inProgressTasks = inProgressTasksStmt.all(agent.name);
95
- const assignedTasks = assignedTasksStmt.all(agent.name);
96
- const reviewTasks = reviewTasksStmt.all(agent.name);
97
- const blockedTasks = blockedTasksStmt.all(agent.name);
98
- const activityCount = activityCountStmt.get(agent.name, startOfDay, endOfDay) as { count: number };
99
- const commentsToday = commentCountStmt.get(agent.name, startOfDay, endOfDay) as { count: number };
100
 
101
  return {
102
  agent: {
@@ -145,10 +153,12 @@ export async function POST(request: NextRequest) {
145
  SELECT t.*, a.name as agent_name
146
  FROM tasks t
147
  LEFT JOIN agents a ON t.assigned_to = a.name
 
148
  WHERE t.due_date < ?
 
149
  AND t.status NOT IN ('done')
150
  ORDER BY t.due_date ASC
151
- `).all(now);
152
 
153
  const standupReport = {
154
  date: targetDate,
@@ -172,16 +182,16 @@ export async function POST(request: NextRequest) {
172
  // Persist standup report
173
  const createdAt = Math.floor(Date.now() / 1000);
174
  db.prepare(`
175
- INSERT OR REPLACE INTO standup_reports (date, report, created_at)
176
- VALUES (?, ?, ?)
177
- `).run(targetDate, JSON.stringify(standupReport), createdAt);
178
 
179
  // Log the standup generation
180
  db_helpers.logActivity(
181
  'standup_generated',
182
  'standup',
183
  0, // No specific entity
184
- 'system',
185
  `Generated daily standup for ${targetDate}`,
186
  {
187
  date: targetDate,
@@ -193,7 +203,8 @@ export async function POST(request: NextRequest) {
193
  review: totalReview,
194
  blocked: totalBlocked
195
  }
196
- }
 
197
  );
198
 
199
  return NextResponse.json({ standup: standupReport });
@@ -214,6 +225,7 @@ export async function GET(request: NextRequest) {
214
  try {
215
  const db = getDatabase();
216
  const { searchParams } = new URL(request.url);
 
217
 
218
  const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 200);
219
  const offset = parseInt(searchParams.get('offset') || '0');
@@ -221,9 +233,10 @@ export async function GET(request: NextRequest) {
221
  const standupRows = db.prepare(`
222
  SELECT date, report, created_at
223
  FROM standup_reports
 
224
  ORDER BY created_at DESC
225
  LIMIT ? OFFSET ?
226
- `).all(limit, offset) as Array<{ date: string; report: string; created_at: number }>;
227
 
228
  const standupHistory = standupRows.map((row, index) => {
229
  const report = row.report ? JSON.parse(row.report) : {};
@@ -236,7 +249,9 @@ export async function GET(request: NextRequest) {
236
  };
237
  });
238
 
239
- const countRow = db.prepare('SELECT COUNT(*) as total FROM standup_reports').get() as { total: number };
 
 
240
 
241
  return NextResponse.json({
242
  history: standupHistory,
 
14
  try {
15
  const db = getDatabase();
16
  const body = await request.json();
17
+ const workspaceId = auth.user.workspace_id ?? 1;
18
 
19
  // Parse parameters
20
  const targetDate = body.date || new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
 
25
  const endOfDay = Math.floor(new Date(`${targetDate}T23:59:59Z`).getTime() / 1000);
26
 
27
  // Get all active agents or filter by specific agents
28
+ let agentQuery = 'SELECT * FROM agents WHERE workspace_id = ?';
29
+ const agentParams: any[] = [workspaceId];
30
 
31
  if (specificAgents && Array.isArray(specificAgents) && specificAgents.length > 0) {
32
  const placeholders = specificAgents.map(() => '?').join(',');
33
+ agentQuery += ` AND name IN (${placeholders})`;
34
  agentParams.push(...specificAgents);
35
  }
36
 
 
43
  SELECT id, title, status, updated_at
44
  FROM tasks
45
  WHERE assigned_to = ?
46
+ AND workspace_id = ?
47
  AND status = 'done'
48
  AND updated_at BETWEEN ? AND ?
49
  ORDER BY updated_at DESC
 
52
  SELECT id, title, status, created_at, due_date
53
  FROM tasks
54
  WHERE assigned_to = ?
55
+ AND workspace_id = ?
56
  AND status = 'in_progress'
57
  ORDER BY created_at ASC
58
  `);
 
60
  SELECT id, title, status, created_at, due_date, priority
61
  FROM tasks
62
  WHERE assigned_to = ?
63
+ AND workspace_id = ?
64
  AND status = 'assigned'
65
  ORDER BY priority DESC, created_at ASC
66
  `);
 
68
  SELECT id, title, status, updated_at
69
  FROM tasks
70
  WHERE assigned_to = ?
71
+ AND workspace_id = ?
72
  AND status IN ('review', 'quality_review')
73
  ORDER BY updated_at ASC
74
  `);
 
76
  SELECT id, title, status, priority, created_at, metadata
77
  FROM tasks
78
  WHERE assigned_to = ?
79
+ AND workspace_id = ?
80
  AND (priority = 'urgent' OR metadata LIKE '%blocked%')
81
  AND status NOT IN ('done')
82
  ORDER BY priority DESC, created_at ASC
 
85
  SELECT COUNT(*) as count
86
  FROM activities
87
  WHERE actor = ?
88
+ AND workspace_id = ?
89
  AND created_at BETWEEN ? AND ?
90
  `);
91
  const commentCountStmt = db.prepare(`
92
  SELECT COUNT(*) as count
93
  FROM comments
94
  WHERE author = ?
95
+ AND workspace_id = ?
96
  AND created_at BETWEEN ? AND ?
97
  `);
98
 
99
  // Generate standup data for each agent
100
  const standupData = agents.map(agent => {
101
+ const completedTasks = completedTasksStmt.all(agent.name, workspaceId, startOfDay, endOfDay);
102
+ const inProgressTasks = inProgressTasksStmt.all(agent.name, workspaceId);
103
+ const assignedTasks = assignedTasksStmt.all(agent.name, workspaceId);
104
+ const reviewTasks = reviewTasksStmt.all(agent.name, workspaceId);
105
+ const blockedTasks = blockedTasksStmt.all(agent.name, workspaceId);
106
+ const activityCount = activityCountStmt.get(agent.name, workspaceId, startOfDay, endOfDay) as { count: number };
107
+ const commentsToday = commentCountStmt.get(agent.name, workspaceId, startOfDay, endOfDay) as { count: number };
108
 
109
  return {
110
  agent: {
 
153
  SELECT t.*, a.name as agent_name
154
  FROM tasks t
155
  LEFT JOIN agents a ON t.assigned_to = a.name
156
+ AND a.workspace_id = t.workspace_id
157
  WHERE t.due_date < ?
158
+ AND t.workspace_id = ?
159
  AND t.status NOT IN ('done')
160
  ORDER BY t.due_date ASC
161
+ `).all(now, workspaceId);
162
 
163
  const standupReport = {
164
  date: targetDate,
 
182
  // Persist standup report
183
  const createdAt = Math.floor(Date.now() / 1000);
184
  db.prepare(`
185
+ INSERT OR REPLACE INTO standup_reports (date, report, created_at, workspace_id)
186
+ VALUES (?, ?, ?, ?)
187
+ `).run(targetDate, JSON.stringify(standupReport), createdAt, workspaceId);
188
 
189
  // Log the standup generation
190
  db_helpers.logActivity(
191
  'standup_generated',
192
  'standup',
193
  0, // No specific entity
194
+ auth.user.username,
195
  `Generated daily standup for ${targetDate}`,
196
  {
197
  date: targetDate,
 
203
  review: totalReview,
204
  blocked: totalBlocked
205
  }
206
+ },
207
+ workspaceId
208
  );
209
 
210
  return NextResponse.json({ standup: standupReport });
 
225
  try {
226
  const db = getDatabase();
227
  const { searchParams } = new URL(request.url);
228
+ const workspaceId = auth.user.workspace_id ?? 1;
229
 
230
  const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 200);
231
  const offset = parseInt(searchParams.get('offset') || '0');
 
233
  const standupRows = db.prepare(`
234
  SELECT date, report, created_at
235
  FROM standup_reports
236
+ WHERE workspace_id = ?
237
  ORDER BY created_at DESC
238
  LIMIT ? OFFSET ?
239
+ `).all(workspaceId, limit, offset) as Array<{ date: string; report: string; created_at: number }>;
240
 
241
  const standupHistory = standupRows.map((row, index) => {
242
  const report = row.report ? JSON.parse(row.report) : {};
 
249
  };
250
  });
251
 
252
+ const countRow = db
253
+ .prepare('SELECT COUNT(*) as total FROM standup_reports WHERE workspace_id = ?')
254
+ .get(workspaceId) as { total: number };
255
 
256
  return NextResponse.json({
257
  history: standupHistory,
src/app/api/status/route.ts CHANGED
@@ -19,12 +19,12 @@ export async function GET(request: NextRequest) {
19
  const action = searchParams.get('action') || 'overview'
20
 
21
  if (action === 'overview') {
22
- const status = await getSystemStatus()
23
  return NextResponse.json(status)
24
  }
25
 
26
  if (action === 'dashboard') {
27
- const data = await getDashboardData()
28
  return NextResponse.json(data)
29
  }
30
 
@@ -59,16 +59,16 @@ export async function GET(request: NextRequest) {
59
  * Aggregate all dashboard data in a single request.
60
  * Combines system health, DB stats, audit summary, and recent activity.
61
  */
62
- async function getDashboardData() {
63
  const [system, dbStats] = await Promise.all([
64
- getSystemStatus(),
65
- getDbStats(),
66
  ])
67
 
68
  return { ...system, db: dbStats }
69
  }
70
 
71
- function getDbStats() {
72
  try {
73
  const db = getDatabase()
74
  const now = Math.floor(Date.now() / 1000)
@@ -77,8 +77,8 @@ function getDbStats() {
77
 
78
  // Task breakdown
79
  const taskStats = db.prepare(`
80
- SELECT status, COUNT(*) as count FROM tasks GROUP BY status
81
- `).all() as Array<{ status: string; count: number }>
82
  const tasksByStatus: Record<string, number> = {}
83
  let totalTasks = 0
84
  for (const row of taskStats) {
@@ -88,8 +88,8 @@ function getDbStats() {
88
 
89
  // Agent breakdown
90
  const agentStats = db.prepare(`
91
- SELECT status, COUNT(*) as count FROM agents GROUP BY status
92
- `).all() as Array<{ status: string; count: number }>
93
  const agentsByStatus: Record<string, number> = {}
94
  let totalAgents = 0
95
  for (const row of agentStats) {
@@ -107,10 +107,14 @@ function getDbStats() {
107
  ).get(day) as any).c
108
 
109
  // Activities (24h)
110
- const activityDay = (db.prepare('SELECT COUNT(*) as c FROM activities WHERE created_at > ?').get(day) as any).c
 
 
111
 
112
  // Notifications (unread)
113
- const unreadNotifs = (db.prepare('SELECT COUNT(*) as c FROM notifications WHERE read_at IS NULL').get() as any).c
 
 
114
 
115
  // Pipeline runs (active + recent)
116
  let pipelineActive = 0
@@ -179,7 +183,7 @@ function getDbStats() {
179
  }
180
  }
181
 
182
- async function getSystemStatus() {
183
  const status: any = {
184
  timestamp: Date.now(),
185
  uptime: 0,
@@ -277,14 +281,16 @@ async function getSystemStatus() {
277
  // Match by: exact name, lowercase, or normalized (spaces→hyphens)
278
  const updateStmt = db.prepare(
279
  `UPDATE agents SET status = ?, last_seen = ?, updated_at = ?
280
- WHERE LOWER(name) = LOWER(?)
281
- OR LOWER(REPLACE(name, ' ', '-')) = LOWER(?)`
 
282
  )
283
  for (const [agentName, info] of liveStatuses) {
284
  updateStmt.run(
285
  info.status,
286
  Math.floor(info.lastActivity / 1000),
287
  now,
 
288
  agentName,
289
  agentName
290
  )
 
19
  const action = searchParams.get('action') || 'overview'
20
 
21
  if (action === 'overview') {
22
+ const status = await getSystemStatus(auth.user.workspace_id ?? 1)
23
  return NextResponse.json(status)
24
  }
25
 
26
  if (action === 'dashboard') {
27
+ const data = await getDashboardData(auth.user.workspace_id ?? 1)
28
  return NextResponse.json(data)
29
  }
30
 
 
59
  * Aggregate all dashboard data in a single request.
60
  * Combines system health, DB stats, audit summary, and recent activity.
61
  */
62
+ async function getDashboardData(workspaceId: number) {
63
  const [system, dbStats] = await Promise.all([
64
+ getSystemStatus(workspaceId),
65
+ getDbStats(workspaceId),
66
  ])
67
 
68
  return { ...system, db: dbStats }
69
  }
70
 
71
+ function getDbStats(workspaceId: number) {
72
  try {
73
  const db = getDatabase()
74
  const now = Math.floor(Date.now() / 1000)
 
77
 
78
  // Task breakdown
79
  const taskStats = db.prepare(`
80
+ SELECT status, COUNT(*) as count FROM tasks WHERE workspace_id = ? GROUP BY status
81
+ `).all(workspaceId) as Array<{ status: string; count: number }>
82
  const tasksByStatus: Record<string, number> = {}
83
  let totalTasks = 0
84
  for (const row of taskStats) {
 
88
 
89
  // Agent breakdown
90
  const agentStats = db.prepare(`
91
+ SELECT status, COUNT(*) as count FROM agents WHERE workspace_id = ? GROUP BY status
92
+ `).all(workspaceId) as Array<{ status: string; count: number }>
93
  const agentsByStatus: Record<string, number> = {}
94
  let totalAgents = 0
95
  for (const row of agentStats) {
 
107
  ).get(day) as any).c
108
 
109
  // Activities (24h)
110
+ const activityDay = (
111
+ db.prepare('SELECT COUNT(*) as c FROM activities WHERE created_at > ? AND workspace_id = ?').get(day, workspaceId) as any
112
+ ).c
113
 
114
  // Notifications (unread)
115
+ const unreadNotifs = (
116
+ db.prepare('SELECT COUNT(*) as c FROM notifications WHERE read_at IS NULL AND workspace_id = ?').get(workspaceId) as any
117
+ ).c
118
 
119
  // Pipeline runs (active + recent)
120
  let pipelineActive = 0
 
183
  }
184
  }
185
 
186
+ async function getSystemStatus(workspaceId: number) {
187
  const status: any = {
188
  timestamp: Date.now(),
189
  uptime: 0,
 
281
  // Match by: exact name, lowercase, or normalized (spaces→hyphens)
282
  const updateStmt = db.prepare(
283
  `UPDATE agents SET status = ?, last_seen = ?, updated_at = ?
284
+ WHERE workspace_id = ?
285
+ AND (LOWER(name) = LOWER(?)
286
+ OR LOWER(REPLACE(name, ' ', '-')) = LOWER(?))`
287
  )
288
  for (const [agentName, info] of liveStatuses) {
289
  updateStmt.run(
290
  info.status,
291
  Math.floor(info.lastActivity / 1000),
292
  now,
293
+ workspaceId,
294
  agentName,
295
  agentName
296
  )
src/app/api/tasks/[id]/broadcast/route.ts CHANGED
@@ -15,6 +15,7 @@ export async function POST(
15
  const resolvedParams = await params
16
  const taskId = parseInt(resolvedParams.id)
17
  const body = await request.json()
 
18
  const author = (body.author || 'system') as string
19
  const message = (body.message || '').trim()
20
 
@@ -26,12 +27,14 @@ export async function POST(
26
  }
27
 
28
  const db = getDatabase()
29
- const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as any
 
 
30
  if (!task) {
31
  return NextResponse.json({ error: 'Task not found' }, { status: 404 })
32
  }
33
 
34
- const subscribers = new Set(db_helpers.getTaskSubscribers(taskId))
35
  subscribers.delete(author)
36
 
37
  if (subscribers.size === 0) {
@@ -39,8 +42,8 @@ export async function POST(
39
  }
40
 
41
  const agents = db
42
- .prepare('SELECT name, session_key FROM agents WHERE name IN (' + Array.from(subscribers).map(() => '?').join(',') + ')')
43
- .all(...Array.from(subscribers)) as Array<{ name: string; session_key?: string }>
44
 
45
  const results = await Promise.allSettled(
46
  agents.map(async (agent) => {
@@ -62,7 +65,8 @@ export async function POST(
62
  'Task Broadcast',
63
  `${author} broadcasted a message on "${task.title}": ${message.substring(0, 100)}${message.length > 100 ? '...' : ''}`,
64
  'task',
65
- taskId
 
66
  )
67
  return 'sent'
68
  })
@@ -81,7 +85,8 @@ export async function POST(
81
  taskId,
82
  author,
83
  `Broadcasted message to ${sent} subscribers`,
84
- { sent, skipped }
 
85
  )
86
 
87
  return NextResponse.json({ sent, skipped })
 
15
  const resolvedParams = await params
16
  const taskId = parseInt(resolvedParams.id)
17
  const body = await request.json()
18
+ const workspaceId = auth.user.workspace_id ?? 1;
19
  const author = (body.author || 'system') as string
20
  const message = (body.message || '').trim()
21
 
 
27
  }
28
 
29
  const db = getDatabase()
30
+ const task = db
31
+ .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
32
+ .get(taskId, workspaceId) as any
33
  if (!task) {
34
  return NextResponse.json({ error: 'Task not found' }, { status: 404 })
35
  }
36
 
37
+ const subscribers = new Set(db_helpers.getTaskSubscribers(taskId, workspaceId))
38
  subscribers.delete(author)
39
 
40
  if (subscribers.size === 0) {
 
42
  }
43
 
44
  const agents = db
45
+ .prepare('SELECT name, session_key FROM agents WHERE workspace_id = ? AND name IN (' + Array.from(subscribers).map(() => '?').join(',') + ')')
46
+ .all(workspaceId, ...Array.from(subscribers)) as Array<{ name: string; session_key?: string }>
47
 
48
  const results = await Promise.allSettled(
49
  agents.map(async (agent) => {
 
65
  'Task Broadcast',
66
  `${author} broadcasted a message on "${task.title}": ${message.substring(0, 100)}${message.length > 100 ? '...' : ''}`,
67
  'task',
68
+ taskId,
69
+ workspaceId
70
  )
71
  return 'sent'
72
  })
 
85
  taskId,
86
  author,
87
  `Broadcasted message to ${sent} subscribers`,
88
+ { sent, skipped },
89
+ workspaceId
90
  )
91
 
92
  return NextResponse.json({ sent, skipped })
src/app/api/tasks/[id]/comments/route.ts CHANGED
@@ -19,13 +19,16 @@ export async function GET(
19
  const db = getDatabase();
20
  const resolvedParams = await params;
21
  const taskId = parseInt(resolvedParams.id);
 
22
 
23
  if (isNaN(taskId)) {
24
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
25
  }
26
 
27
  // Verify task exists
28
- const task = db.prepare('SELECT id FROM tasks WHERE id = ?').get(taskId);
 
 
29
  if (!task) {
30
  return NextResponse.json({ error: 'Task not found' }, { status: 404 });
31
  }
@@ -33,11 +36,11 @@ export async function GET(
33
  // Get comments ordered by creation time
34
  const stmt = db.prepare(`
35
  SELECT * FROM comments
36
- WHERE task_id = ?
37
  ORDER BY created_at ASC
38
  `);
39
 
40
- const comments = stmt.all(taskId) as Comment[];
41
 
42
  // Parse JSON fields and build thread structure
43
  const commentsWithParsedData = comments.map(comment => ({
@@ -97,6 +100,7 @@ export async function POST(
97
  const db = getDatabase();
98
  const resolvedParams = await params;
99
  const taskId = parseInt(resolvedParams.id);
 
100
 
101
  if (isNaN(taskId)) {
102
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
@@ -107,14 +111,18 @@ export async function POST(
107
  const { content, author = 'system', parent_id } = result.data;
108
 
109
  // Verify task exists
110
- const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as any;
 
 
111
  if (!task) {
112
  return NextResponse.json({ error: 'Task not found' }, { status: 404 });
113
  }
114
 
115
  // Verify parent comment exists if specified
116
  if (parent_id) {
117
- const parentComment = db.prepare('SELECT id FROM comments WHERE id = ? AND task_id = ?').get(parent_id, taskId);
 
 
118
  if (!parentComment) {
119
  return NextResponse.json({ error: 'Parent comment not found' }, { status: 404 });
120
  }
@@ -127,8 +135,8 @@ export async function POST(
127
 
128
  // Insert comment
129
  const stmt = db.prepare(`
130
- INSERT INTO comments (task_id, author, content, created_at, parent_id, mentions)
131
- VALUES (?, ?, ?, ?, ?, ?)
132
  `);
133
 
134
  const insertResult = stmt.run(
@@ -137,7 +145,8 @@ export async function POST(
137
  content,
138
  now,
139
  parent_id || null,
140
- mentions.length > 0 ? JSON.stringify(mentions) : null
 
141
  );
142
 
143
  const commentId = insertResult.lastInsertRowid as number;
@@ -159,21 +168,22 @@ export async function POST(
159
  parent_id,
160
  mentions,
161
  content_preview: content.substring(0, 100)
162
- }
 
163
  );
164
 
165
  // Ensure subscriptions for author, mentions, and assignee
166
- db_helpers.ensureTaskSubscription(taskId, author);
167
  const uniqueMentions = Array.from(new Set(mentions));
168
  uniqueMentions.forEach((mentionedAgent) => {
169
- db_helpers.ensureTaskSubscription(taskId, mentionedAgent);
170
  });
171
  if (task.assigned_to) {
172
- db_helpers.ensureTaskSubscription(taskId, task.assigned_to);
173
  }
174
 
175
  // Notify subscribers
176
- const subscribers = new Set(db_helpers.getTaskSubscribers(taskId));
177
  subscribers.delete(author);
178
  const mentionSet = new Set(uniqueMentions);
179
 
@@ -187,12 +197,15 @@ export async function POST(
187
  ? `${author} mentioned you in a comment on "${task.title}": ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`
188
  : `${author} commented on "${task.title}": ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`,
189
  'comment',
190
- commentId
 
191
  );
192
  }
193
 
194
  // Fetch the created comment
195
- const createdComment = db.prepare('SELECT * FROM comments WHERE id = ?').get(commentId) as Comment;
 
 
196
 
197
  return NextResponse.json({
198
  comment: {
 
19
  const db = getDatabase();
20
  const resolvedParams = await params;
21
  const taskId = parseInt(resolvedParams.id);
22
+ const workspaceId = auth.user.workspace_id ?? 1;
23
 
24
  if (isNaN(taskId)) {
25
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
26
  }
27
 
28
  // Verify task exists
29
+ const task = db
30
+ .prepare('SELECT id FROM tasks WHERE id = ? AND workspace_id = ?')
31
+ .get(taskId, workspaceId);
32
  if (!task) {
33
  return NextResponse.json({ error: 'Task not found' }, { status: 404 });
34
  }
 
36
  // Get comments ordered by creation time
37
  const stmt = db.prepare(`
38
  SELECT * FROM comments
39
+ WHERE task_id = ? AND workspace_id = ?
40
  ORDER BY created_at ASC
41
  `);
42
 
43
+ const comments = stmt.all(taskId, workspaceId) as Comment[];
44
 
45
  // Parse JSON fields and build thread structure
46
  const commentsWithParsedData = comments.map(comment => ({
 
100
  const db = getDatabase();
101
  const resolvedParams = await params;
102
  const taskId = parseInt(resolvedParams.id);
103
+ const workspaceId = auth.user.workspace_id ?? 1;
104
 
105
  if (isNaN(taskId)) {
106
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
 
111
  const { content, author = 'system', parent_id } = result.data;
112
 
113
  // Verify task exists
114
+ const task = db
115
+ .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
116
+ .get(taskId, workspaceId) as any;
117
  if (!task) {
118
  return NextResponse.json({ error: 'Task not found' }, { status: 404 });
119
  }
120
 
121
  // Verify parent comment exists if specified
122
  if (parent_id) {
123
+ const parentComment = db
124
+ .prepare('SELECT id FROM comments WHERE id = ? AND task_id = ? AND workspace_id = ?')
125
+ .get(parent_id, taskId, workspaceId);
126
  if (!parentComment) {
127
  return NextResponse.json({ error: 'Parent comment not found' }, { status: 404 });
128
  }
 
135
 
136
  // Insert comment
137
  const stmt = db.prepare(`
138
+ INSERT INTO comments (task_id, author, content, created_at, parent_id, mentions, workspace_id)
139
+ VALUES (?, ?, ?, ?, ?, ?, ?)
140
  `);
141
 
142
  const insertResult = stmt.run(
 
145
  content,
146
  now,
147
  parent_id || null,
148
+ mentions.length > 0 ? JSON.stringify(mentions) : null,
149
+ workspaceId
150
  );
151
 
152
  const commentId = insertResult.lastInsertRowid as number;
 
168
  parent_id,
169
  mentions,
170
  content_preview: content.substring(0, 100)
171
+ },
172
+ workspaceId
173
  );
174
 
175
  // Ensure subscriptions for author, mentions, and assignee
176
+ db_helpers.ensureTaskSubscription(taskId, author, workspaceId);
177
  const uniqueMentions = Array.from(new Set(mentions));
178
  uniqueMentions.forEach((mentionedAgent) => {
179
+ db_helpers.ensureTaskSubscription(taskId, mentionedAgent, workspaceId);
180
  });
181
  if (task.assigned_to) {
182
+ db_helpers.ensureTaskSubscription(taskId, task.assigned_to, workspaceId);
183
  }
184
 
185
  // Notify subscribers
186
+ const subscribers = new Set(db_helpers.getTaskSubscribers(taskId, workspaceId));
187
  subscribers.delete(author);
188
  const mentionSet = new Set(uniqueMentions);
189
 
 
197
  ? `${author} mentioned you in a comment on "${task.title}": ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`
198
  : `${author} commented on "${task.title}": ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`,
199
  'comment',
200
+ commentId,
201
+ workspaceId
202
  );
203
  }
204
 
205
  // Fetch the created comment
206
+ const createdComment = db
207
+ .prepare('SELECT * FROM comments WHERE id = ? AND workspace_id = ?')
208
+ .get(commentId, workspaceId) as Comment;
209
 
210
  return NextResponse.json({
211
  comment: {
src/app/api/tasks/[id]/route.ts CHANGED
@@ -1,18 +1,22 @@
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, Task, db_helpers } from '@/lib/db';
3
  import { eventBus } from '@/lib/event-bus';
4
- import { getUserFromRequest, requireRole } from '@/lib/auth';
5
  import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, updateTaskSchema } from '@/lib/validation';
8
 
9
- function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean {
 
 
 
 
10
  const review = db.prepare(`
11
  SELECT status FROM quality_reviews
12
- WHERE task_id = ? AND reviewer = 'aegis'
13
  ORDER BY created_at DESC
14
  LIMIT 1
15
- `).get(taskId) as { status?: string } | undefined
16
  return review?.status === 'approved'
17
  }
18
 
@@ -30,13 +34,14 @@ export async function GET(
30
  const db = getDatabase();
31
  const resolvedParams = await params;
32
  const taskId = parseInt(resolvedParams.id);
 
33
 
34
  if (isNaN(taskId)) {
35
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
36
  }
37
 
38
- const stmt = db.prepare('SELECT * FROM tasks WHERE id = ?');
39
- const task = stmt.get(taskId) as Task;
40
 
41
  if (!task) {
42
  return NextResponse.json({ error: 'Task not found' }, { status: 404 });
@@ -73,6 +78,7 @@ export async function PUT(
73
  const db = getDatabase();
74
  const resolvedParams = await params;
75
  const taskId = parseInt(resolvedParams.id);
 
76
  const validated = await validateBody(request, updateTaskSchema);
77
  if ('error' in validated) return validated.error;
78
  const body = validated.data;
@@ -82,7 +88,9 @@ export async function PUT(
82
  }
83
 
84
  // Get current task for comparison
85
- const currentTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task;
 
 
86
 
87
  if (!currentTask) {
88
  return NextResponse.json({ error: 'Task not found' }, { status: 404 });
@@ -116,7 +124,7 @@ export async function PUT(
116
  updateParams.push(description);
117
  }
118
  if (status !== undefined) {
119
- if (status === 'done' && !hasAegisApproval(db, taskId)) {
120
  return NextResponse.json(
121
  { error: 'Aegis approval is required to move task to done.' },
122
  { status: 403 }
@@ -156,7 +164,7 @@ export async function PUT(
156
 
157
  fieldsToUpdate.push('updated_at = ?');
158
  updateParams.push(now);
159
- updateParams.push(taskId);
160
 
161
  if (fieldsToUpdate.length === 1) { // Only updated_at
162
  return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
@@ -165,7 +173,7 @@ export async function PUT(
165
  const stmt = db.prepare(`
166
  UPDATE tasks
167
  SET ${fieldsToUpdate.join(', ')}
168
- WHERE id = ?
169
  `);
170
 
171
  stmt.run(...updateParams);
@@ -184,7 +192,8 @@ export async function PUT(
184
  'Task Status Updated',
185
  `Task "${currentTask.title}" status changed to ${status}`,
186
  'task',
187
- taskId
 
188
  );
189
  }
190
  }
@@ -194,14 +203,15 @@ export async function PUT(
194
 
195
  // Create notification for new assignee
196
  if (assigned_to) {
197
- db_helpers.ensureTaskSubscription(taskId, assigned_to);
198
  db_helpers.createNotification(
199
  assigned_to,
200
  'assignment',
201
  'Task Assigned',
202
  `You have been assigned to task: ${currentTask.title}`,
203
  'task',
204
- taskId
 
205
  );
206
  }
207
  }
@@ -220,7 +230,7 @@ export async function PUT(
220
  'task_updated',
221
  'task',
222
  taskId,
223
- getUserFromRequest(request)?.username || 'system',
224
  `Task updated: ${changes.join(', ')}`,
225
  {
226
  changes: changes,
@@ -231,12 +241,15 @@ export async function PUT(
231
  assigned_to: currentTask.assigned_to
232
  },
233
  newValues: { title, status, priority, assigned_to }
234
- }
 
235
  );
236
  }
237
 
238
  // Fetch updated task
239
- const updatedTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task;
 
 
240
  const parsedTask = {
241
  ...updatedTask,
242
  tags: updatedTask.tags ? JSON.parse(updatedTask.tags) : [],
@@ -270,34 +283,38 @@ export async function DELETE(
270
  const db = getDatabase();
271
  const resolvedParams = await params;
272
  const taskId = parseInt(resolvedParams.id);
 
273
 
274
  if (isNaN(taskId)) {
275
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
276
  }
277
 
278
  // Get task before deletion for logging
279
- const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task;
 
 
280
 
281
  if (!task) {
282
  return NextResponse.json({ error: 'Task not found' }, { status: 404 });
283
  }
284
 
285
  // Delete task (cascades will handle comments)
286
- const stmt = db.prepare('DELETE FROM tasks WHERE id = ?');
287
- stmt.run(taskId);
288
 
289
  // Log deletion
290
  db_helpers.logActivity(
291
  'task_deleted',
292
  'task',
293
  taskId,
294
- getUserFromRequest(request)?.username || 'system',
295
  `Deleted task: ${task.title}`,
296
  {
297
  title: task.title,
298
  status: task.status,
299
  assigned_to: task.assigned_to
300
- }
 
301
  );
302
 
303
  // Broadcast to SSE clients
 
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, Task, db_helpers } from '@/lib/db';
3
  import { eventBus } from '@/lib/event-bus';
4
+ import { requireRole } from '@/lib/auth';
5
  import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, updateTaskSchema } from '@/lib/validation';
8
 
9
+ function hasAegisApproval(
10
+ db: ReturnType<typeof getDatabase>,
11
+ taskId: number,
12
+ workspaceId: number
13
+ ): boolean {
14
  const review = db.prepare(`
15
  SELECT status FROM quality_reviews
16
+ WHERE task_id = ? AND reviewer = 'aegis' AND workspace_id = ?
17
  ORDER BY created_at DESC
18
  LIMIT 1
19
+ `).get(taskId, workspaceId) as { status?: string } | undefined
20
  return review?.status === 'approved'
21
  }
22
 
 
34
  const db = getDatabase();
35
  const resolvedParams = await params;
36
  const taskId = parseInt(resolvedParams.id);
37
+ const workspaceId = auth.user.workspace_id ?? 1;
38
 
39
  if (isNaN(taskId)) {
40
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
41
  }
42
 
43
+ const stmt = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?');
44
+ const task = stmt.get(taskId, workspaceId) as Task;
45
 
46
  if (!task) {
47
  return NextResponse.json({ error: 'Task not found' }, { status: 404 });
 
78
  const db = getDatabase();
79
  const resolvedParams = await params;
80
  const taskId = parseInt(resolvedParams.id);
81
+ const workspaceId = auth.user.workspace_id ?? 1;
82
  const validated = await validateBody(request, updateTaskSchema);
83
  if ('error' in validated) return validated.error;
84
  const body = validated.data;
 
88
  }
89
 
90
  // Get current task for comparison
91
+ const currentTask = db
92
+ .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
93
+ .get(taskId, workspaceId) as Task;
94
 
95
  if (!currentTask) {
96
  return NextResponse.json({ error: 'Task not found' }, { status: 404 });
 
124
  updateParams.push(description);
125
  }
126
  if (status !== undefined) {
127
+ if (status === 'done' && !hasAegisApproval(db, taskId, workspaceId)) {
128
  return NextResponse.json(
129
  { error: 'Aegis approval is required to move task to done.' },
130
  { status: 403 }
 
164
 
165
  fieldsToUpdate.push('updated_at = ?');
166
  updateParams.push(now);
167
+ updateParams.push(taskId, workspaceId);
168
 
169
  if (fieldsToUpdate.length === 1) { // Only updated_at
170
  return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
 
173
  const stmt = db.prepare(`
174
  UPDATE tasks
175
  SET ${fieldsToUpdate.join(', ')}
176
+ WHERE id = ? AND workspace_id = ?
177
  `);
178
 
179
  stmt.run(...updateParams);
 
192
  'Task Status Updated',
193
  `Task "${currentTask.title}" status changed to ${status}`,
194
  'task',
195
+ taskId,
196
+ workspaceId
197
  );
198
  }
199
  }
 
203
 
204
  // Create notification for new assignee
205
  if (assigned_to) {
206
+ db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId);
207
  db_helpers.createNotification(
208
  assigned_to,
209
  'assignment',
210
  'Task Assigned',
211
  `You have been assigned to task: ${currentTask.title}`,
212
  'task',
213
+ taskId,
214
+ workspaceId
215
  );
216
  }
217
  }
 
230
  'task_updated',
231
  'task',
232
  taskId,
233
+ auth.user.username,
234
  `Task updated: ${changes.join(', ')}`,
235
  {
236
  changes: changes,
 
241
  assigned_to: currentTask.assigned_to
242
  },
243
  newValues: { title, status, priority, assigned_to }
244
+ },
245
+ workspaceId
246
  );
247
  }
248
 
249
  // Fetch updated task
250
+ const updatedTask = db
251
+ .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
252
+ .get(taskId, workspaceId) as Task;
253
  const parsedTask = {
254
  ...updatedTask,
255
  tags: updatedTask.tags ? JSON.parse(updatedTask.tags) : [],
 
283
  const db = getDatabase();
284
  const resolvedParams = await params;
285
  const taskId = parseInt(resolvedParams.id);
286
+ const workspaceId = auth.user.workspace_id ?? 1;
287
 
288
  if (isNaN(taskId)) {
289
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
290
  }
291
 
292
  // Get task before deletion for logging
293
+ const task = db
294
+ .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
295
+ .get(taskId, workspaceId) as Task;
296
 
297
  if (!task) {
298
  return NextResponse.json({ error: 'Task not found' }, { status: 404 });
299
  }
300
 
301
  // Delete task (cascades will handle comments)
302
+ const stmt = db.prepare('DELETE FROM tasks WHERE id = ? AND workspace_id = ?');
303
+ stmt.run(taskId, workspaceId);
304
 
305
  // Log deletion
306
  db_helpers.logActivity(
307
  'task_deleted',
308
  'task',
309
  taskId,
310
+ auth.user.username,
311
  `Deleted task: ${task.title}`,
312
  {
313
  title: task.title,
314
  status: task.status,
315
  assigned_to: task.assigned_to
316
+ },
317
+ workspaceId
318
  );
319
 
320
  // Broadcast to SSE clients
src/app/api/tasks/route.ts CHANGED
@@ -6,13 +6,13 @@ import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
8
 
9
- function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean {
10
  const review = db.prepare(`
11
  SELECT status FROM quality_reviews
12
- WHERE task_id = ? AND reviewer = 'aegis'
13
  ORDER BY created_at DESC
14
  LIMIT 1
15
- `).get(taskId) as { status?: string } | undefined
16
  return review?.status === 'approved'
17
  }
18
 
@@ -26,6 +26,7 @@ export async function GET(request: NextRequest) {
26
 
27
  try {
28
  const db = getDatabase();
 
29
  const { searchParams } = new URL(request.url);
30
 
31
  // Parse query parameters
@@ -36,8 +37,8 @@ export async function GET(request: NextRequest) {
36
  const offset = parseInt(searchParams.get('offset') || '0');
37
 
38
  // Build dynamic query
39
- let query = 'SELECT * FROM tasks WHERE 1=1';
40
- const params: any[] = [];
41
 
42
  if (status) {
43
  query += ' AND status = ?';
@@ -68,8 +69,8 @@ export async function GET(request: NextRequest) {
68
  }));
69
 
70
  // Get total count for pagination
71
- let countQuery = 'SELECT COUNT(*) as total FROM tasks WHERE 1=1';
72
- const countParams: any[] = [];
73
  if (status) {
74
  countQuery += ' AND status = ?';
75
  countParams.push(status);
@@ -103,6 +104,7 @@ export async function POST(request: NextRequest) {
103
 
104
  try {
105
  const db = getDatabase();
 
106
  const validated = await validateBody(request, createTaskSchema);
107
  if ('error' in validated) return validated.error;
108
  const body = validated.data;
@@ -122,7 +124,7 @@ export async function POST(request: NextRequest) {
122
  } = body;
123
 
124
  // Check for duplicate title
125
- const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ?').get(title);
126
  if (existingTask) {
127
  return NextResponse.json({ error: 'Task with this title already exists' }, { status: 409 });
128
  }
@@ -132,8 +134,8 @@ export async function POST(request: NextRequest) {
132
  const stmt = db.prepare(`
133
  INSERT INTO tasks (
134
  title, description, status, priority, assigned_to, created_by,
135
- created_at, updated_at, due_date, estimated_hours, tags, metadata
136
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
137
  `);
138
 
139
  const dbResult = stmt.run(
@@ -148,7 +150,8 @@ export async function POST(request: NextRequest) {
148
  due_date,
149
  estimated_hours,
150
  JSON.stringify(tags),
151
- JSON.stringify(metadata)
 
152
  );
153
 
154
  const taskId = dbResult.lastInsertRowid as number;
@@ -159,27 +162,28 @@ export async function POST(request: NextRequest) {
159
  status,
160
  priority,
161
  assigned_to
162
- });
163
 
164
  if (created_by) {
165
- db_helpers.ensureTaskSubscription(taskId, created_by)
166
  }
167
 
168
  // Create notification if assigned
169
  if (assigned_to) {
170
- db_helpers.ensureTaskSubscription(taskId, assigned_to)
171
  db_helpers.createNotification(
172
  assigned_to,
173
  'assignment',
174
  'Task Assigned',
175
  `You have been assigned to task: ${title}`,
176
  'task',
177
- taskId
 
178
  );
179
  }
180
 
181
  // Fetch the created task
182
- const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task;
183
  const parsedTask = {
184
  ...createdTask,
185
  tags: JSON.parse(createdTask.tags || '[]'),
@@ -208,6 +212,7 @@ export async function PUT(request: NextRequest) {
208
 
209
  try {
210
  const db = getDatabase();
 
211
  const validated = await validateBody(request, bulkUpdateTaskStatusSchema);
212
  if ('error' in validated) return validated.error;
213
  const { tasks } = validated.data;
@@ -217,20 +222,21 @@ export async function PUT(request: NextRequest) {
217
  const updateStmt = db.prepare(`
218
  UPDATE tasks
219
  SET status = ?, updated_at = ?
220
- WHERE id = ?
221
  `);
222
 
223
  const actor = auth.user.username
224
 
225
  const transaction = db.transaction((tasksToUpdate: any[]) => {
226
  for (const task of tasksToUpdate) {
227
- const oldTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(task.id) as Task;
 
228
 
229
- if (task.status === 'done' && !hasAegisApproval(db, task.id)) {
230
  throw new Error(`Aegis approval required for task ${task.id}`)
231
  }
232
 
233
- updateStmt.run(task.status, now, task.id);
234
 
235
  // Log status change if different
236
  if (oldTask && oldTask.status !== task.status) {
@@ -240,7 +246,8 @@ export async function PUT(request: NextRequest) {
240
  task.id,
241
  actor,
242
  `Task moved from ${oldTask.status} to ${task.status}`,
243
- { oldStatus: oldTask.status, newStatus: task.status }
 
244
  );
245
  }
246
  }
 
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
8
 
9
+ function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number, workspaceId: number): boolean {
10
  const review = db.prepare(`
11
  SELECT status FROM quality_reviews
12
+ WHERE task_id = ? AND reviewer = 'aegis' AND workspace_id = ?
13
  ORDER BY created_at DESC
14
  LIMIT 1
15
+ `).get(taskId, workspaceId) as { status?: string } | undefined
16
  return review?.status === 'approved'
17
  }
18
 
 
26
 
27
  try {
28
  const db = getDatabase();
29
+ const workspaceId = auth.user.workspace_id;
30
  const { searchParams } = new URL(request.url);
31
 
32
  // Parse query parameters
 
37
  const offset = parseInt(searchParams.get('offset') || '0');
38
 
39
  // Build dynamic query
40
+ let query = 'SELECT * FROM tasks WHERE workspace_id = ?';
41
+ const params: any[] = [workspaceId];
42
 
43
  if (status) {
44
  query += ' AND status = ?';
 
69
  }));
70
 
71
  // Get total count for pagination
72
+ let countQuery = 'SELECT COUNT(*) as total FROM tasks WHERE workspace_id = ?';
73
+ const countParams: any[] = [workspaceId];
74
  if (status) {
75
  countQuery += ' AND status = ?';
76
  countParams.push(status);
 
104
 
105
  try {
106
  const db = getDatabase();
107
+ const workspaceId = auth.user.workspace_id;
108
  const validated = await validateBody(request, createTaskSchema);
109
  if ('error' in validated) return validated.error;
110
  const body = validated.data;
 
124
  } = body;
125
 
126
  // Check for duplicate title
127
+ const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ? AND workspace_id = ?').get(title, workspaceId);
128
  if (existingTask) {
129
  return NextResponse.json({ error: 'Task with this title already exists' }, { status: 409 });
130
  }
 
134
  const stmt = db.prepare(`
135
  INSERT INTO tasks (
136
  title, description, status, priority, assigned_to, created_by,
137
+ created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id
138
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
139
  `);
140
 
141
  const dbResult = stmt.run(
 
150
  due_date,
151
  estimated_hours,
152
  JSON.stringify(tags),
153
+ JSON.stringify(metadata),
154
+ workspaceId
155
  );
156
 
157
  const taskId = dbResult.lastInsertRowid as number;
 
162
  status,
163
  priority,
164
  assigned_to
165
+ }, workspaceId);
166
 
167
  if (created_by) {
168
+ db_helpers.ensureTaskSubscription(taskId, created_by, workspaceId)
169
  }
170
 
171
  // Create notification if assigned
172
  if (assigned_to) {
173
+ db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId)
174
  db_helpers.createNotification(
175
  assigned_to,
176
  'assignment',
177
  'Task Assigned',
178
  `You have been assigned to task: ${title}`,
179
  'task',
180
+ taskId,
181
+ workspaceId
182
  );
183
  }
184
 
185
  // Fetch the created task
186
+ const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId) as Task;
187
  const parsedTask = {
188
  ...createdTask,
189
  tags: JSON.parse(createdTask.tags || '[]'),
 
212
 
213
  try {
214
  const db = getDatabase();
215
+ const workspaceId = auth.user.workspace_id;
216
  const validated = await validateBody(request, bulkUpdateTaskStatusSchema);
217
  if ('error' in validated) return validated.error;
218
  const { tasks } = validated.data;
 
222
  const updateStmt = db.prepare(`
223
  UPDATE tasks
224
  SET status = ?, updated_at = ?
225
+ WHERE id = ? AND workspace_id = ?
226
  `);
227
 
228
  const actor = auth.user.username
229
 
230
  const transaction = db.transaction((tasksToUpdate: any[]) => {
231
  for (const task of tasksToUpdate) {
232
+ const oldTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(task.id, workspaceId) as Task;
233
+ if (!oldTask) continue;
234
 
235
+ if (task.status === 'done' && !hasAegisApproval(db, task.id, workspaceId)) {
236
  throw new Error(`Aegis approval required for task ${task.id}`)
237
  }
238
 
239
+ updateStmt.run(task.status, now, task.id, workspaceId);
240
 
241
  // Log status change if different
242
  if (oldTask && oldTask.status !== task.status) {
 
246
  task.id,
247
  actor,
248
  `Task moved from ${oldTask.status} to ${task.status}`,
249
+ { oldStatus: oldTask.status, newStatus: task.status },
250
+ workspaceId
251
  );
252
  }
253
  }
src/components/markdown-renderer.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import ReactMarkdown from 'react-markdown'
4
+ import remarkGfm from 'remark-gfm'
5
+
6
+ interface MarkdownRendererProps {
7
+ content: string
8
+ preview?: boolean
9
+ }
10
+
11
+ function getPreviewContent(content: string): string {
12
+ const firstParagraph = content.trim().split(/\n\s*\n/)[0] || ''
13
+ if (firstParagraph.length <= 240) return firstParagraph
14
+ return `${firstParagraph.slice(0, 240)}...`
15
+ }
16
+
17
+ export function MarkdownRenderer({ content, preview = false }: MarkdownRendererProps) {
18
+ if (!content?.trim()) return null
19
+
20
+ const markdownContent = preview ? getPreviewContent(content) : content
21
+
22
+ return (
23
+ <div className={`prose prose-invert max-w-none ${preview ? 'text-xs' : 'text-sm'}`}>
24
+ <ReactMarkdown
25
+ remarkPlugins={[remarkGfm]}
26
+ components={{
27
+ h1: ({ children }) => <h1 className={`${preview ? 'text-sm' : 'text-xl'} font-semibold mb-2`}>{children}</h1>,
28
+ h2: ({ children }) => <h2 className={`${preview ? 'text-xs' : 'text-lg'} font-semibold mb-2`}>{children}</h2>,
29
+ h3: ({ children }) => <h3 className={`${preview ? 'text-xs' : 'text-base'} font-semibold mb-1`}>{children}</h3>,
30
+ p: ({ children }) => <p className={`text-foreground/85 ${preview ? 'text-xs mb-1' : 'text-sm mb-2'} leading-relaxed`}>{children}</p>,
31
+ ul: ({ children }) => <ul className={`list-disc ml-4 ${preview ? 'text-xs mb-1' : 'text-sm mb-2'}`}>{children}</ul>,
32
+ ol: ({ children }) => <ol className={`list-decimal ml-4 ${preview ? 'text-xs mb-1' : 'text-sm mb-2'}`}>{children}</ol>,
33
+ li: ({ children }) => <li className="mb-0.5 text-foreground/85">{children}</li>,
34
+ code: ({ children, className }) => {
35
+ const isInline = !className
36
+ if (isInline) {
37
+ return <code className="bg-surface-2 text-primary px-1 py-0.5 rounded text-[0.85em]">{children}</code>
38
+ }
39
+ return (
40
+ <code className="block bg-surface-2 border border-border rounded p-2 overflow-x-auto text-[0.85em]">
41
+ {children}
42
+ </code>
43
+ )
44
+ },
45
+ blockquote: ({ children }) => (
46
+ <blockquote className="border-l-2 border-border pl-3 italic text-muted-foreground mb-2">
47
+ {children}
48
+ </blockquote>
49
+ ),
50
+ a: ({ href, children }) => (
51
+ <a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300 underline">
52
+ {children}
53
+ </a>
54
+ ),
55
+ strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
56
+ em: ({ children }) => <em className="italic text-foreground/90">{children}</em>,
57
+ }}
58
+ >
59
+ {markdownContent}
60
+ </ReactMarkdown>
61
+ </div>
62
+ )
63
+ }
64
+
src/components/panels/agent-detail-tabs.tsx CHANGED
@@ -808,6 +808,12 @@ const MODEL_TIER_LABELS: Record<string, string> = {
808
  haiku: 'Haiku $',
809
  }
810
 
 
 
 
 
 
 
811
  // Enhanced Create Agent Modal with Template Wizard
812
  export function CreateAgentModal({
813
  onClose,
@@ -818,12 +824,14 @@ export function CreateAgentModal({
818
  }) {
819
  const [step, setStep] = useState<1 | 2 | 3>(1)
820
  const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
 
821
  const [formData, setFormData] = useState({
822
  name: '',
823
  id: '',
824
  role: '',
825
  emoji: '',
826
- model: 'sonnet',
 
827
  workspaceAccess: 'rw' as 'rw' | 'ro' | 'none',
828
  sandboxMode: 'all' as 'all' | 'non-main',
829
  dockerNetwork: 'none' as 'none' | 'bridge',
@@ -841,6 +849,24 @@ export function CreateAgentModal({
841
  setFormData(prev => ({ ...prev, name, id }))
842
  }
843
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
844
  // When template is selected, pre-fill form
845
  const selectTemplate = (type: string | null) => {
846
  setSelectedTemplate(type)
@@ -851,7 +877,8 @@ export function CreateAgentModal({
851
  ...prev,
852
  role: tmpl.theme,
853
  emoji: tmpl.emoji,
854
- model: tmpl.modelTier === 'opus' ? 'opus' : tmpl.modelTier === 'haiku' ? 'haiku' : 'sonnet',
 
855
  workspaceAccess: type === 'researcher' || type === 'content-creator' ? 'none' : type === 'reviewer' || type === 'security-auditor' ? 'ro' : 'rw',
856
  sandboxMode: type === 'orchestrator' ? 'non-main' : 'all',
857
  dockerNetwork: type === 'developer' || type === 'specialist-dev' ? 'bridge' : 'none',
@@ -868,6 +895,7 @@ export function CreateAgentModal({
868
  setIsCreating(true)
869
  setError(null)
870
  try {
 
871
  const response = await fetch('/api/agents', {
872
  method: 'POST',
873
  headers: { 'Content-Type': 'application/json' },
@@ -878,7 +906,7 @@ export function CreateAgentModal({
878
  template: selectedTemplate || undefined,
879
  write_to_gateway: formData.write_to_gateway,
880
  gateway_config: {
881
- model: { primary: `anthropic/claude-${formData.model === 'opus' ? 'opus-4-5' : formData.model === 'haiku' ? 'haiku-4-5' : 'sonnet-4-20250514'}` },
882
  identity: { name: formData.name, theme: formData.role, emoji: formData.emoji },
883
  sandbox: {
884
  mode: formData.sandboxMode,
@@ -1033,14 +1061,18 @@ export function CreateAgentModal({
1033
  </div>
1034
 
1035
  <div>
1036
- <label className="block text-sm text-muted-foreground mb-1">Model</label>
1037
  <div className="flex gap-2">
1038
  {(['opus', 'sonnet', 'haiku'] as const).map(tier => (
1039
  <button
1040
  key={tier}
1041
- onClick={() => setFormData(prev => ({ ...prev, model: tier }))}
 
 
 
 
1042
  className={`flex-1 px-3 py-2 text-sm rounded-md border transition-smooth ${
1043
- formData.model === tier ? MODEL_TIER_COLORS[tier] + ' border' : 'bg-surface-1 text-muted-foreground border-border'
1044
  }`}
1045
  >
1046
  {MODEL_TIER_LABELS[tier]}
@@ -1049,6 +1081,23 @@ export function CreateAgentModal({
1049
  </div>
1050
  </div>
1051
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1052
  <div className="grid grid-cols-3 gap-4">
1053
  <div>
1054
  <label className="block text-sm text-muted-foreground mb-1">Workspace</label>
@@ -1114,8 +1163,9 @@ export function CreateAgentModal({
1114
  <div className="grid grid-cols-2 gap-2 text-sm">
1115
  <div><span className="text-muted-foreground">ID:</span> <span className="text-foreground font-mono">{formData.id}</span></div>
1116
  <div><span className="text-muted-foreground">Template:</span> <span className="text-foreground">{selectedTemplateData?.label || 'Custom'}</span></div>
1117
- <div><span className="text-muted-foreground">Model:</span> <span className={`px-2 py-0.5 rounded text-xs ${MODEL_TIER_COLORS[formData.model]}`}>{MODEL_TIER_LABELS[formData.model]}</span></div>
1118
  <div><span className="text-muted-foreground">Tools:</span> <span className="text-foreground">{selectedTemplateData?.toolCount || 'Custom'}</span></div>
 
1119
  <div><span className="text-muted-foreground">Workspace:</span> <span className="text-foreground">{formData.workspaceAccess}</span></div>
1120
  <div><span className="text-muted-foreground">Sandbox:</span> <span className="text-foreground">{formData.sandboxMode}</span></div>
1121
  <div><span className="text-muted-foreground">Network:</span> <span className="text-foreground">{formData.dockerNetwork}</span></div>
 
808
  haiku: 'Haiku $',
809
  }
810
 
811
+ const DEFAULT_MODEL_BY_TIER: Record<'opus' | 'sonnet' | 'haiku', string> = {
812
+ opus: 'anthropic/claude-opus-4-5',
813
+ sonnet: 'anthropic/claude-sonnet-4-20250514',
814
+ haiku: 'anthropic/claude-haiku-4-5',
815
+ }
816
+
817
  // Enhanced Create Agent Modal with Template Wizard
818
  export function CreateAgentModal({
819
  onClose,
 
824
  }) {
825
  const [step, setStep] = useState<1 | 2 | 3>(1)
826
  const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
827
+ const [availableModels, setAvailableModels] = useState<string[]>([])
828
  const [formData, setFormData] = useState({
829
  name: '',
830
  id: '',
831
  role: '',
832
  emoji: '',
833
+ modelTier: 'sonnet' as 'opus' | 'sonnet' | 'haiku',
834
+ modelPrimary: DEFAULT_MODEL_BY_TIER.sonnet,
835
  workspaceAccess: 'rw' as 'rw' | 'ro' | 'none',
836
  sandboxMode: 'all' as 'all' | 'non-main',
837
  dockerNetwork: 'none' as 'none' | 'bridge',
 
849
  setFormData(prev => ({ ...prev, name, id }))
850
  }
851
 
852
+ useEffect(() => {
853
+ const loadAvailableModels = async () => {
854
+ try {
855
+ const response = await fetch('/api/status?action=models')
856
+ if (!response.ok) return
857
+ const data = await response.json()
858
+ const models = Array.isArray(data.models) ? data.models : []
859
+ const names = models
860
+ .map((model: any) => String(model.name || model.alias || '').trim())
861
+ .filter(Boolean)
862
+ setAvailableModels(Array.from(new Set<string>(names)))
863
+ } catch {
864
+ // Keep modal usable without model suggestions.
865
+ }
866
+ }
867
+ loadAvailableModels()
868
+ }, [])
869
+
870
  // When template is selected, pre-fill form
871
  const selectTemplate = (type: string | null) => {
872
  setSelectedTemplate(type)
 
877
  ...prev,
878
  role: tmpl.theme,
879
  emoji: tmpl.emoji,
880
+ modelTier: tmpl.modelTier,
881
+ modelPrimary: DEFAULT_MODEL_BY_TIER[tmpl.modelTier],
882
  workspaceAccess: type === 'researcher' || type === 'content-creator' ? 'none' : type === 'reviewer' || type === 'security-auditor' ? 'ro' : 'rw',
883
  sandboxMode: type === 'orchestrator' ? 'non-main' : 'all',
884
  dockerNetwork: type === 'developer' || type === 'specialist-dev' ? 'bridge' : 'none',
 
895
  setIsCreating(true)
896
  setError(null)
897
  try {
898
+ const primaryModel = formData.modelPrimary.trim() || DEFAULT_MODEL_BY_TIER[formData.modelTier]
899
  const response = await fetch('/api/agents', {
900
  method: 'POST',
901
  headers: { 'Content-Type': 'application/json' },
 
906
  template: selectedTemplate || undefined,
907
  write_to_gateway: formData.write_to_gateway,
908
  gateway_config: {
909
+ model: { primary: primaryModel },
910
  identity: { name: formData.name, theme: formData.role, emoji: formData.emoji },
911
  sandbox: {
912
  mode: formData.sandboxMode,
 
1061
  </div>
1062
 
1063
  <div>
1064
+ <label className="block text-sm text-muted-foreground mb-1">Model Tier</label>
1065
  <div className="flex gap-2">
1066
  {(['opus', 'sonnet', 'haiku'] as const).map(tier => (
1067
  <button
1068
  key={tier}
1069
+ onClick={() => setFormData(prev => ({
1070
+ ...prev,
1071
+ modelTier: tier,
1072
+ modelPrimary: DEFAULT_MODEL_BY_TIER[tier],
1073
+ }))}
1074
  className={`flex-1 px-3 py-2 text-sm rounded-md border transition-smooth ${
1075
+ formData.modelTier === tier ? MODEL_TIER_COLORS[tier] + ' border' : 'bg-surface-1 text-muted-foreground border-border'
1076
  }`}
1077
  >
1078
  {MODEL_TIER_LABELS[tier]}
 
1081
  </div>
1082
  </div>
1083
 
1084
+ <div>
1085
+ <label className="block text-sm text-muted-foreground mb-1">Primary Model</label>
1086
+ <input
1087
+ type="text"
1088
+ value={formData.modelPrimary}
1089
+ onChange={(e) => setFormData(prev => ({ ...prev, modelPrimary: e.target.value }))}
1090
+ list="create-agent-model-suggestions"
1091
+ 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"
1092
+ placeholder={DEFAULT_MODEL_BY_TIER[formData.modelTier]}
1093
+ />
1094
+ <datalist id="create-agent-model-suggestions">
1095
+ {availableModels.map((name) => (
1096
+ <option key={name} value={name} />
1097
+ ))}
1098
+ </datalist>
1099
+ </div>
1100
+
1101
  <div className="grid grid-cols-3 gap-4">
1102
  <div>
1103
  <label className="block text-sm text-muted-foreground mb-1">Workspace</label>
 
1163
  <div className="grid grid-cols-2 gap-2 text-sm">
1164
  <div><span className="text-muted-foreground">ID:</span> <span className="text-foreground font-mono">{formData.id}</span></div>
1165
  <div><span className="text-muted-foreground">Template:</span> <span className="text-foreground">{selectedTemplateData?.label || 'Custom'}</span></div>
1166
+ <div><span className="text-muted-foreground">Model:</span> <span className={`px-2 py-0.5 rounded text-xs ${MODEL_TIER_COLORS[formData.modelTier]}`}>{MODEL_TIER_LABELS[formData.modelTier]}</span></div>
1167
  <div><span className="text-muted-foreground">Tools:</span> <span className="text-foreground">{selectedTemplateData?.toolCount || 'Custom'}</span></div>
1168
+ <div className="col-span-2"><span className="text-muted-foreground">Primary Model:</span> <span className="text-foreground font-mono">{formData.modelPrimary || DEFAULT_MODEL_BY_TIER[formData.modelTier]}</span></div>
1169
  <div><span className="text-muted-foreground">Workspace:</span> <span className="text-foreground">{formData.workspaceAccess}</span></div>
1170
  <div><span className="text-muted-foreground">Sandbox:</span> <span className="text-foreground">{formData.sandboxMode}</span></div>
1171
  <div><span className="text-muted-foreground">Network:</span> <span className="text-foreground">{formData.dockerNetwork}</span></div>
src/components/panels/agent-squad-panel-phase3.tsx CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import { useState, useEffect, useCallback } from 'react'
4
  import { useSmartPoll } from '@/lib/use-smart-poll'
 
5
  import {
6
  OverviewTab,
7
  SoulTab,
@@ -319,9 +320,12 @@ export function AgentSquadPanelPhase3() {
319
  >
320
  {/* Agent Header */}
321
  <div className="flex items-start justify-between mb-3">
322
- <div>
323
- <h3 className="font-semibold text-foreground text-lg">{agent.name}</h3>
324
- <p className="text-muted-foreground text-sm">{agent.role}</p>
 
 
 
325
  </div>
326
 
327
  <div className="flex items-center gap-2">
 
2
 
3
  import { useState, useEffect, useCallback } from 'react'
4
  import { useSmartPoll } from '@/lib/use-smart-poll'
5
+ import { AgentAvatar } from '@/components/ui/agent-avatar'
6
  import {
7
  OverviewTab,
8
  SoulTab,
 
320
  >
321
  {/* Agent Header */}
322
  <div className="flex items-start justify-between mb-3">
323
+ <div className="flex items-center gap-2 min-w-0">
324
+ <AgentAvatar name={agent.name} size="md" />
325
+ <div className="min-w-0">
326
+ <h3 className="font-semibold text-foreground text-lg truncate">{agent.name}</h3>
327
+ <p className="text-muted-foreground text-sm truncate">{agent.role}</p>
328
+ </div>
329
  </div>
330
 
331
  <div className="flex items-center gap-2">
src/components/panels/cron-management-panel.tsx CHANGED
@@ -8,6 +8,43 @@ interface NewJobForm {
8
  schedule: string
9
  command: string
10
  description: string
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  }
12
 
13
  export function CronManagementPanel() {
@@ -16,11 +53,18 @@ export function CronManagementPanel() {
16
  const [showAddForm, setShowAddForm] = useState(false)
17
  const [selectedJob, setSelectedJob] = useState<CronJob | null>(null)
18
  const [jobLogs, setJobLogs] = useState<any[]>([])
 
 
 
 
 
 
19
  const [newJob, setNewJob] = useState<NewJobForm>({
20
  name: '',
21
  schedule: '0 * * * *', // Every hour
22
  command: '',
23
- description: ''
 
24
  })
25
 
26
  const formatRelativeTime = (timestamp: string | number, future = false) => {
@@ -56,6 +100,24 @@ export function CronManagementPanel() {
56
  loadCronJobs()
57
  }, [loadCronJobs])
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  const loadJobLogs = async (jobName: string) => {
60
  try {
61
  const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(jobName)}`)
@@ -130,7 +192,8 @@ export function CronManagementPanel() {
130
  action: 'add',
131
  jobName: newJob.name,
132
  schedule: newJob.schedule,
133
- command: newJob.command
 
134
  })
135
  })
136
 
@@ -139,7 +202,8 @@ export function CronManagementPanel() {
139
  name: '',
140
  schedule: '0 * * * *',
141
  command: '',
142
- description: ''
 
143
  })
144
  setShowAddForm(false)
145
  await loadCronJobs()
@@ -217,6 +281,82 @@ export function CronManagementPanel() {
217
  { label: 'Monthly (1st)', value: '0 0 1 * *' },
218
  ]
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  return (
221
  <div className="p-6 space-y-6">
222
  <div className="border-b border-border pb-4">
@@ -246,6 +386,176 @@ export function CronManagementPanel() {
246
  </div>
247
 
248
  <div className="grid lg:grid-cols-2 gap-6">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  {/* Job List */}
250
  <div className="bg-card border border-border rounded-lg p-6">
251
  <h2 className="text-xl font-semibold mb-4">Scheduled Jobs</h2>
@@ -304,6 +614,11 @@ export function CronManagementPanel() {
304
  <div className="text-sm text-muted-foreground mt-1 truncate">
305
  {job.command}
306
  </div>
 
 
 
 
 
307
  {job.lastRun && (
308
  <div className="text-xs text-muted-foreground mt-2">
309
  Last run: {formatRelativeTime(job.lastRun)}
@@ -368,6 +683,9 @@ export function CronManagementPanel() {
368
  <div className="bg-secondary rounded p-3 space-y-2 text-sm">
369
  <div><span className="text-muted-foreground">Schedule:</span> <code className="font-mono">{selectedJob.schedule}</code></div>
370
  <div><span className="text-muted-foreground">Command:</span> <code className="font-mono text-xs">{selectedJob.command}</code></div>
 
 
 
371
  <div><span className="text-muted-foreground">Status:</span> {selectedJob.enabled ? '🟢 Enabled' : '🔴 Disabled'}</div>
372
  {selectedJob.nextRun && (
373
  <div><span className="text-muted-foreground">Next run:</span> {new Date(selectedJob.nextRun).toLocaleString()}</div>
@@ -454,6 +772,26 @@ export function CronManagementPanel() {
454
  />
455
  </div>
456
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  <div>
458
  <label className="block text-sm font-medium text-foreground mb-2">Description (Optional)</label>
459
  <input
 
8
  schedule: string
9
  command: string
10
  description: string
11
+ model: string
12
+ }
13
+
14
+ type CalendarViewMode = 'agenda' | 'day' | 'week' | 'month'
15
+
16
+ function startOfDay(date: Date): Date {
17
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate())
18
+ }
19
+
20
+ function addDays(date: Date, days: number): Date {
21
+ const next = new Date(date)
22
+ next.setDate(next.getDate() + days)
23
+ return next
24
+ }
25
+
26
+ function isSameDay(a: Date, b: Date): boolean {
27
+ return (
28
+ a.getFullYear() === b.getFullYear() &&
29
+ a.getMonth() === b.getMonth() &&
30
+ a.getDate() === b.getDate()
31
+ )
32
+ }
33
+
34
+ function getWeekStart(date: Date): Date {
35
+ const day = date.getDay()
36
+ const diffToMonday = (day + 6) % 7
37
+ return addDays(startOfDay(date), -diffToMonday)
38
+ }
39
+
40
+ function getMonthStartGrid(date: Date): Date {
41
+ const firstOfMonth = new Date(date.getFullYear(), date.getMonth(), 1)
42
+ const day = firstOfMonth.getDay()
43
+ return addDays(firstOfMonth, -day)
44
+ }
45
+
46
+ function formatDateLabel(date: Date): string {
47
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
48
  }
49
 
50
  export function CronManagementPanel() {
 
53
  const [showAddForm, setShowAddForm] = useState(false)
54
  const [selectedJob, setSelectedJob] = useState<CronJob | null>(null)
55
  const [jobLogs, setJobLogs] = useState<any[]>([])
56
+ const [availableModels, setAvailableModels] = useState<string[]>([])
57
+ const [calendarView, setCalendarView] = useState<CalendarViewMode>('week')
58
+ const [calendarDate, setCalendarDate] = useState<Date>(startOfDay(new Date()))
59
+ const [searchQuery, setSearchQuery] = useState('')
60
+ const [agentFilter, setAgentFilter] = useState('all')
61
+ const [stateFilter, setStateFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
62
  const [newJob, setNewJob] = useState<NewJobForm>({
63
  name: '',
64
  schedule: '0 * * * *', // Every hour
65
  command: '',
66
+ description: '',
67
+ model: ''
68
  })
69
 
70
  const formatRelativeTime = (timestamp: string | number, future = false) => {
 
100
  loadCronJobs()
101
  }, [loadCronJobs])
102
 
103
+ useEffect(() => {
104
+ const loadAvailableModels = async () => {
105
+ try {
106
+ const response = await fetch('/api/status?action=models')
107
+ if (!response.ok) return
108
+ const data = await response.json()
109
+ const models = Array.isArray(data.models) ? data.models : []
110
+ const names = models
111
+ .map((model: any) => String(model.name || model.alias || '').trim())
112
+ .filter(Boolean)
113
+ setAvailableModels(Array.from(new Set<string>(names)))
114
+ } catch {
115
+ // Keep cron form usable even when model discovery is unavailable.
116
+ }
117
+ }
118
+ loadAvailableModels()
119
+ }, [])
120
+
121
  const loadJobLogs = async (jobName: string) => {
122
  try {
123
  const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(jobName)}`)
 
192
  action: 'add',
193
  jobName: newJob.name,
194
  schedule: newJob.schedule,
195
+ command: newJob.command,
196
+ ...(newJob.model.trim() ? { model: newJob.model.trim() } : {})
197
  })
198
  })
199
 
 
202
  name: '',
203
  schedule: '0 * * * *',
204
  command: '',
205
+ description: '',
206
+ model: ''
207
  })
208
  setShowAddForm(false)
209
  await loadCronJobs()
 
281
  { label: 'Monthly (1st)', value: '0 0 1 * *' },
282
  ]
283
 
284
+ const uniqueAgents = Array.from(
285
+ new Set(
286
+ cronJobs
287
+ .map((job) => (job.agentId || '').trim())
288
+ .filter(Boolean)
289
+ )
290
+ )
291
+
292
+ const filteredJobs = cronJobs.filter((job) => {
293
+ const query = searchQuery.trim().toLowerCase()
294
+ const matchesQuery =
295
+ !query ||
296
+ job.name.toLowerCase().includes(query) ||
297
+ job.command.toLowerCase().includes(query) ||
298
+ (job.agentId || '').toLowerCase().includes(query) ||
299
+ (job.model || '').toLowerCase().includes(query)
300
+
301
+ const matchesAgent = agentFilter === 'all' || (job.agentId || '') === agentFilter
302
+ const matchesState =
303
+ stateFilter === 'all' ||
304
+ (stateFilter === 'enabled' && job.enabled) ||
305
+ (stateFilter === 'disabled' && !job.enabled)
306
+
307
+ return matchesQuery && matchesAgent && matchesState
308
+ })
309
+
310
+ const agendaJobs = [...filteredJobs].sort((a, b) => {
311
+ const aRun = typeof a.nextRun === 'number' ? a.nextRun : Number.POSITIVE_INFINITY
312
+ const bRun = typeof b.nextRun === 'number' ? b.nextRun : Number.POSITIVE_INFINITY
313
+ return aRun - bRun
314
+ })
315
+
316
+ const dayStart = startOfDay(calendarDate)
317
+ const dayEnd = addDays(dayStart, 1)
318
+ const dayJobs = filteredJobs
319
+ .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= dayStart.getTime() && job.nextRun < dayEnd.getTime())
320
+ .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0))
321
+
322
+ const weekStart = getWeekStart(calendarDate)
323
+ const weekDays = Array.from({ length: 7 }, (_, idx) => addDays(weekStart, idx))
324
+ const jobsByWeekDay = weekDays.map((date) => {
325
+ const start = startOfDay(date).getTime()
326
+ const end = addDays(date, 1).getTime()
327
+ const jobs = filteredJobs
328
+ .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= start && job.nextRun < end)
329
+ .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0))
330
+ return { date, jobs }
331
+ })
332
+
333
+ const monthGridStart = getMonthStartGrid(calendarDate)
334
+ const monthDays = Array.from({ length: 42 }, (_, idx) => addDays(monthGridStart, idx))
335
+ const jobsByMonthDay = monthDays.map((date) => {
336
+ const start = startOfDay(date).getTime()
337
+ const end = addDays(date, 1).getTime()
338
+ const jobs = filteredJobs
339
+ .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= start && job.nextRun < end)
340
+ .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0))
341
+ return { date, jobs }
342
+ })
343
+
344
+ const moveCalendar = (direction: -1 | 1) => {
345
+ setCalendarDate((prev) => {
346
+ if (calendarView === 'day') return addDays(prev, direction)
347
+ if (calendarView === 'week') return addDays(prev, direction * 7)
348
+ if (calendarView === 'month') return new Date(prev.getFullYear(), prev.getMonth() + direction, 1)
349
+ return addDays(prev, direction * 7)
350
+ })
351
+ }
352
+
353
+ const calendarRangeLabel =
354
+ calendarView === 'day'
355
+ ? calendarDate.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric', year: 'numeric' })
356
+ : calendarView === 'week'
357
+ ? `${formatDateLabel(weekDays[0])} - ${formatDateLabel(weekDays[6])}`
358
+ : calendarDate.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
359
+
360
  return (
361
  <div className="p-6 space-y-6">
362
  <div className="border-b border-border pb-4">
 
386
  </div>
387
 
388
  <div className="grid lg:grid-cols-2 gap-6">
389
+ {/* Calendar View - Phase A (read-only) */}
390
+ <div className="lg:col-span-2 bg-card border border-border rounded-lg p-6">
391
+ <div className="flex flex-col gap-4">
392
+ <div className="flex flex-wrap items-center justify-between gap-3">
393
+ <div>
394
+ <h2 className="text-xl font-semibold">Calendar View</h2>
395
+ <p className="text-sm text-muted-foreground">Read-only schedule visibility across all cron jobs</p>
396
+ </div>
397
+ <div className="flex items-center gap-2">
398
+ <button
399
+ onClick={() => moveCalendar(-1)}
400
+ className="px-2 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
401
+ >
402
+ Prev
403
+ </button>
404
+ <button
405
+ onClick={() => setCalendarDate(startOfDay(new Date()))}
406
+ className="px-3 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors text-sm"
407
+ >
408
+ Today
409
+ </button>
410
+ <button
411
+ onClick={() => moveCalendar(1)}
412
+ className="px-2 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
413
+ >
414
+ Next
415
+ </button>
416
+ <div className="text-sm font-medium text-foreground ml-1">{calendarRangeLabel}</div>
417
+ </div>
418
+ </div>
419
+
420
+ <div className="flex flex-wrap gap-2">
421
+ {(['agenda', 'day', 'week', 'month'] as CalendarViewMode[]).map((mode) => (
422
+ <button
423
+ key={mode}
424
+ onClick={() => setCalendarView(mode)}
425
+ className={`px-3 py-1.5 rounded text-sm border transition-colors ${
426
+ calendarView === mode
427
+ ? 'bg-primary text-primary-foreground border-primary'
428
+ : 'border-border text-muted-foreground hover:text-foreground hover:bg-secondary'
429
+ }`}
430
+ >
431
+ {mode === 'agenda' ? 'Agenda' : mode.charAt(0).toUpperCase() + mode.slice(1)}
432
+ </button>
433
+ ))}
434
+ </div>
435
+
436
+ <div className="grid md:grid-cols-3 gap-3">
437
+ <input
438
+ value={searchQuery}
439
+ onChange={(e) => setSearchQuery(e.target.value)}
440
+ placeholder="Search jobs, agents, models..."
441
+ className="px-3 py-2 border border-border rounded-md bg-background text-foreground text-sm"
442
+ />
443
+ <select
444
+ value={agentFilter}
445
+ onChange={(e) => setAgentFilter(e.target.value)}
446
+ className="px-3 py-2 border border-border rounded-md bg-background text-foreground text-sm"
447
+ >
448
+ <option value="all">All Agents</option>
449
+ {uniqueAgents.map((agentId) => (
450
+ <option key={agentId} value={agentId}>
451
+ {agentId}
452
+ </option>
453
+ ))}
454
+ </select>
455
+ <select
456
+ value={stateFilter}
457
+ onChange={(e) => setStateFilter(e.target.value as 'all' | 'enabled' | 'disabled')}
458
+ className="px-3 py-2 border border-border rounded-md bg-background text-foreground text-sm"
459
+ >
460
+ <option value="all">All States</option>
461
+ <option value="enabled">Enabled</option>
462
+ <option value="disabled">Disabled</option>
463
+ </select>
464
+ </div>
465
+
466
+ {calendarView === 'agenda' && (
467
+ <div className="border border-border rounded-lg overflow-hidden">
468
+ <div className="max-h-80 overflow-y-auto divide-y divide-border">
469
+ {agendaJobs.length === 0 ? (
470
+ <div className="p-4 text-sm text-muted-foreground">No jobs match the current filters.</div>
471
+ ) : (
472
+ agendaJobs.map((job) => (
473
+ <div key={`agenda-${job.id || job.name}`} className="p-3 flex flex-col md:flex-row md:items-center md:justify-between gap-2">
474
+ <div>
475
+ <div className="font-medium text-foreground">{job.name}</div>
476
+ <div className="text-xs text-muted-foreground">
477
+ {job.agentId || 'system'} · {job.enabled ? 'enabled' : 'disabled'} · {job.schedule}
478
+ </div>
479
+ </div>
480
+ <div className="text-sm text-muted-foreground">
481
+ {job.nextRun ? new Date(job.nextRun).toLocaleString() : 'No upcoming run'}
482
+ </div>
483
+ </div>
484
+ ))
485
+ )}
486
+ </div>
487
+ </div>
488
+ )}
489
+
490
+ {calendarView === 'day' && (
491
+ <div className="border border-border rounded-lg p-3">
492
+ {dayJobs.length === 0 ? (
493
+ <div className="text-sm text-muted-foreground">No scheduled jobs for this day.</div>
494
+ ) : (
495
+ <div className="space-y-2">
496
+ {dayJobs.map((job) => (
497
+ <div key={`day-${job.id || job.name}`} className="p-2 rounded border border-border bg-secondary/40">
498
+ <div className="text-sm font-medium text-foreground">{job.name}</div>
499
+ <div className="text-xs text-muted-foreground">
500
+ {job.nextRun ? new Date(job.nextRun).toLocaleTimeString() : 'Unknown time'} · {job.agentId || 'system'} · {job.enabled ? 'enabled' : 'disabled'}
501
+ </div>
502
+ </div>
503
+ ))}
504
+ </div>
505
+ )}
506
+ </div>
507
+ )}
508
+
509
+ {calendarView === 'week' && (
510
+ <div className="grid grid-cols-1 md:grid-cols-7 gap-2">
511
+ {jobsByWeekDay.map(({ date, jobs }) => (
512
+ <div key={`week-${date.toISOString()}`} className="border border-border rounded-lg p-2 min-h-36">
513
+ <div className={`text-xs font-medium mb-2 ${isSameDay(date, new Date()) ? 'text-primary' : 'text-muted-foreground'}`}>
514
+ {date.toLocaleDateString(undefined, { weekday: 'short', month: 'numeric', day: 'numeric' })}
515
+ </div>
516
+ <div className="space-y-1">
517
+ {jobs.slice(0, 4).map((job) => (
518
+ <div key={`week-job-${job.id || job.name}`} className="text-xs px-2 py-1 rounded bg-secondary text-foreground truncate" title={job.name}>
519
+ {job.nextRun ? new Date(job.nextRun).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '--:--'} {job.name}
520
+ </div>
521
+ ))}
522
+ {jobs.length > 4 && (
523
+ <div className="text-xs text-muted-foreground">+{jobs.length - 4} more</div>
524
+ )}
525
+ </div>
526
+ </div>
527
+ ))}
528
+ </div>
529
+ )}
530
+
531
+ {calendarView === 'month' && (
532
+ <div className="grid grid-cols-7 gap-2">
533
+ {jobsByMonthDay.map(({ date, jobs }) => {
534
+ const inCurrentMonth = date.getMonth() === calendarDate.getMonth()
535
+ return (
536
+ <div
537
+ key={`month-${date.toISOString()}`}
538
+ className={`border border-border rounded-lg p-2 min-h-24 ${inCurrentMonth ? 'bg-transparent' : 'bg-secondary/30'}`}
539
+ >
540
+ <div className={`text-xs mb-1 ${isSameDay(date, new Date()) ? 'text-primary font-semibold' : inCurrentMonth ? 'text-foreground' : 'text-muted-foreground'}`}>
541
+ {date.getDate()}
542
+ </div>
543
+ <div className="space-y-1">
544
+ {jobs.slice(0, 2).map((job) => (
545
+ <div key={`month-job-${job.id || job.name}`} className="text-[11px] px-1.5 py-0.5 rounded bg-secondary text-foreground truncate" title={job.name}>
546
+ {job.name}
547
+ </div>
548
+ ))}
549
+ {jobs.length > 2 && <div className="text-[11px] text-muted-foreground">+{jobs.length - 2}</div>}
550
+ </div>
551
+ </div>
552
+ )
553
+ })}
554
+ </div>
555
+ )}
556
+ </div>
557
+ </div>
558
+
559
  {/* Job List */}
560
  <div className="bg-card border border-border rounded-lg p-6">
561
  <h2 className="text-xl font-semibold mb-4">Scheduled Jobs</h2>
 
614
  <div className="text-sm text-muted-foreground mt-1 truncate">
615
  {job.command}
616
  </div>
617
+ {job.model && (
618
+ <div className="text-xs text-muted-foreground mt-1">
619
+ Model: <span className="font-mono">{job.model}</span>
620
+ </div>
621
+ )}
622
  {job.lastRun && (
623
  <div className="text-xs text-muted-foreground mt-2">
624
  Last run: {formatRelativeTime(job.lastRun)}
 
683
  <div className="bg-secondary rounded p-3 space-y-2 text-sm">
684
  <div><span className="text-muted-foreground">Schedule:</span> <code className="font-mono">{selectedJob.schedule}</code></div>
685
  <div><span className="text-muted-foreground">Command:</span> <code className="font-mono text-xs">{selectedJob.command}</code></div>
686
+ {selectedJob.model && (
687
+ <div><span className="text-muted-foreground">Model:</span> <code className="font-mono text-xs">{selectedJob.model}</code></div>
688
+ )}
689
  <div><span className="text-muted-foreground">Status:</span> {selectedJob.enabled ? '🟢 Enabled' : '🔴 Disabled'}</div>
690
  {selectedJob.nextRun && (
691
  <div><span className="text-muted-foreground">Next run:</span> {new Date(selectedJob.nextRun).toLocaleString()}</div>
 
772
  />
773
  </div>
774
 
775
+ <div>
776
+ <label className="block text-sm font-medium text-foreground mb-2">Model (Optional)</label>
777
+ <input
778
+ type="text"
779
+ value={newJob.model}
780
+ onChange={(e) => setNewJob(prev => ({ ...prev, model: e.target.value }))}
781
+ list="cron-model-suggestions"
782
+ placeholder="anthropic/claude-sonnet-4-20250514"
783
+ className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground font-mono text-sm"
784
+ />
785
+ <datalist id="cron-model-suggestions">
786
+ {availableModels.map((modelName) => (
787
+ <option key={modelName} value={modelName} />
788
+ ))}
789
+ </datalist>
790
+ <div className="mt-1 text-xs text-muted-foreground">
791
+ Leave empty to use the agent or gateway default model.
792
+ </div>
793
+ </div>
794
+
795
  <div>
796
  <label className="block text-sm font-medium text-foreground mb-2">Description (Optional)</label>
797
  <input
src/components/panels/task-board-panel.tsx CHANGED
@@ -3,6 +3,8 @@
3
  import { useState, useEffect, useCallback, useRef } from 'react'
4
  import { useMissionControl } from '@/store'
5
  import { useSmartPoll } from '@/lib/use-smart-poll'
 
 
6
 
7
  interface Task {
8
  id: number
@@ -360,13 +362,22 @@ export function TaskBoardPanel() {
360
  </div>
361
 
362
  {task.description && (
363
- <p className="text-foreground/80 text-xs mb-2 line-clamp-2">
364
- {task.description}
365
- </p>
366
  )}
367
 
368
  <div className="flex justify-between items-center text-xs text-muted-foreground">
369
- <span>{getAgentName(task.assigned_to)}</span>
 
 
 
 
 
 
 
 
 
370
  <span className="font-medium">{formatTaskTimestamp(task.created_at)}</span>
371
  </div>
372
 
@@ -620,7 +631,13 @@ function TaskDetailModal({
620
  </button>
621
  </div>
622
  </div>
623
- <p className="text-foreground/80 mb-4">{task.description || 'No description'}</p>
 
 
 
 
 
 
624
  <div className="flex gap-2 mt-4">
625
  {(['details', 'comments', 'quality'] as const).map(tab => (
626
  <button
@@ -647,7 +664,16 @@ function TaskDetailModal({
647
  </div>
648
  <div>
649
  <span className="text-muted-foreground">Assigned to:</span>
650
- <span className="text-foreground ml-2">{task.assigned_to || 'Unassigned'}</span>
 
 
 
 
 
 
 
 
 
651
  </div>
652
  <div>
653
  <span className="text-muted-foreground">Created:</span>
 
3
  import { useState, useEffect, useCallback, useRef } from 'react'
4
  import { useMissionControl } from '@/store'
5
  import { useSmartPoll } from '@/lib/use-smart-poll'
6
+ import { AgentAvatar } from '@/components/ui/agent-avatar'
7
+ import { MarkdownRenderer } from '@/components/markdown-renderer'
8
 
9
  interface Task {
10
  id: number
 
362
  </div>
363
 
364
  {task.description && (
365
+ <div className="mb-2 line-clamp-3 overflow-hidden">
366
+ <MarkdownRenderer content={task.description} preview />
367
+ </div>
368
  )}
369
 
370
  <div className="flex justify-between items-center text-xs text-muted-foreground">
371
+ <span className="flex items-center gap-1.5 min-w-0">
372
+ {task.assigned_to ? (
373
+ <>
374
+ <AgentAvatar name={getAgentName(task.assigned_to)} size="xs" />
375
+ <span className="truncate">{getAgentName(task.assigned_to)}</span>
376
+ </>
377
+ ) : (
378
+ <span>Unassigned</span>
379
+ )}
380
+ </span>
381
  <span className="font-medium">{formatTaskTimestamp(task.created_at)}</span>
382
  </div>
383
 
 
631
  </button>
632
  </div>
633
  </div>
634
+ {task.description ? (
635
+ <div className="mb-4">
636
+ <MarkdownRenderer content={task.description} />
637
+ </div>
638
+ ) : (
639
+ <p className="text-foreground/80 mb-4">No description</p>
640
+ )}
641
  <div className="flex gap-2 mt-4">
642
  {(['details', 'comments', 'quality'] as const).map(tab => (
643
  <button
 
664
  </div>
665
  <div>
666
  <span className="text-muted-foreground">Assigned to:</span>
667
+ <span className="text-foreground ml-2 inline-flex items-center gap-1.5">
668
+ {task.assigned_to ? (
669
+ <>
670
+ <AgentAvatar name={task.assigned_to} size="xs" />
671
+ <span>{task.assigned_to}</span>
672
+ </>
673
+ ) : (
674
+ <span>Unassigned</span>
675
+ )}
676
+ </span>
677
  </div>
678
  <div>
679
  <span className="text-muted-foreground">Created:</span>
src/components/ui/agent-avatar.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ interface AgentAvatarProps {
4
+ name: string
5
+ size?: 'xs' | 'sm' | 'md'
6
+ className?: string
7
+ }
8
+
9
+ function getInitials(name: string): string {
10
+ const parts = name
11
+ .trim()
12
+ .split(/\s+/)
13
+ .filter(Boolean)
14
+
15
+ if (parts.length === 0) return '?'
16
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
17
+ return `${parts[0][0] || ''}${parts[1][0] || ''}`.toUpperCase()
18
+ }
19
+
20
+ function hashString(value: string): number {
21
+ let hash = 0
22
+ for (let i = 0; i < value.length; i += 1) {
23
+ hash = (hash * 31 + value.charCodeAt(i)) >>> 0
24
+ }
25
+ return hash
26
+ }
27
+
28
+ function getAvatarColors(name: string): { backgroundColor: string; color: string } {
29
+ const hash = hashString(name.toLowerCase())
30
+ const hue = hash % 360
31
+ return {
32
+ backgroundColor: `hsl(${hue} 70% 38%)`,
33
+ color: 'hsl(0 0% 98%)',
34
+ }
35
+ }
36
+
37
+ const sizeClasses: Record<NonNullable<AgentAvatarProps['size']>, string> = {
38
+ xs: 'w-5 h-5 text-[10px]',
39
+ sm: 'w-6 h-6 text-[10px]',
40
+ md: 'w-8 h-8 text-xs',
41
+ }
42
+
43
+ export function AgentAvatar({ name, size = 'sm', className = '' }: AgentAvatarProps) {
44
+ const initials = getInitials(name)
45
+ const colors = getAvatarColors(name)
46
+
47
+ return (
48
+ <div
49
+ className={`rounded-full flex items-center justify-center font-semibold shrink-0 ${sizeClasses[size]} ${className}`}
50
+ style={colors}
51
+ title={name}
52
+ aria-label={name}
53
+ >
54
+ {initials}
55
+ </div>
56
+ )
57
+ }
58
+
src/lib/__tests__/db-helpers.test.ts CHANGED
@@ -87,7 +87,7 @@ describe('logActivity', () => {
87
 
88
  expect(mockPrepare).toHaveBeenCalled()
89
  expect(mockRun).toHaveBeenCalledWith(
90
- 'task_created', 'task', 1, 'alice', 'Created task', null,
91
  )
92
  expect(mockBroadcast).toHaveBeenCalledWith(
93
  'activity.created',
@@ -105,7 +105,7 @@ describe('logActivity', () => {
105
  db_helpers.logActivity('update', 'agent', 2, 'bob', 'Updated agent', data)
106
 
107
  expect(mockRun).toHaveBeenCalledWith(
108
- 'update', 'agent', 2, 'bob', 'Updated agent', JSON.stringify(data),
109
  )
110
  })
111
  })
@@ -119,7 +119,7 @@ describe('createNotification', () => {
119
  db_helpers.createNotification('alice', 'mention', 'Mentioned', 'You were mentioned')
120
 
121
  expect(mockRun).toHaveBeenCalledWith(
122
- 'alice', 'mention', 'Mentioned', 'You were mentioned', undefined, undefined,
123
  )
124
  expect(mockBroadcast).toHaveBeenCalledWith(
125
  'notification.created',
@@ -135,7 +135,7 @@ describe('createNotification', () => {
135
  db_helpers.createNotification('bob', 'alert', 'Alert', 'CPU high', 'agent', 5)
136
 
137
  expect(mockRun).toHaveBeenCalledWith(
138
- 'bob', 'alert', 'Alert', 'CPU high', 'agent', 5,
139
  )
140
  })
141
  })
 
87
 
88
  expect(mockPrepare).toHaveBeenCalled()
89
  expect(mockRun).toHaveBeenCalledWith(
90
+ 'task_created', 'task', 1, 'alice', 'Created task', null, 1,
91
  )
92
  expect(mockBroadcast).toHaveBeenCalledWith(
93
  'activity.created',
 
105
  db_helpers.logActivity('update', 'agent', 2, 'bob', 'Updated agent', data)
106
 
107
  expect(mockRun).toHaveBeenCalledWith(
108
+ 'update', 'agent', 2, 'bob', 'Updated agent', JSON.stringify(data), 1,
109
  )
110
  })
111
  })
 
119
  db_helpers.createNotification('alice', 'mention', 'Mentioned', 'You were mentioned')
120
 
121
  expect(mockRun).toHaveBeenCalledWith(
122
+ 'alice', 'mention', 'Mentioned', 'You were mentioned', undefined, undefined, 1,
123
  )
124
  expect(mockBroadcast).toHaveBeenCalledWith(
125
  'notification.created',
 
135
  db_helpers.createNotification('bob', 'alert', 'Alert', 'CPU high', 'agent', 5)
136
 
137
  expect(mockRun).toHaveBeenCalledWith(
138
+ 'bob', 'alert', 'Alert', 'CPU high', 'agent', 5, 1,
139
  )
140
  })
141
  })
src/lib/auth.ts CHANGED
@@ -23,6 +23,7 @@ export interface User {
23
  username: string
24
  display_name: string
25
  role: 'admin' | 'operator' | 'viewer'
 
26
  provider?: 'local' | 'google'
27
  email?: string | null
28
  avatar_url?: string | null
@@ -36,6 +37,7 @@ export interface UserSession {
36
  id: number
37
  token: string
38
  user_id: number
 
39
  expires_at: number
40
  created_at: number
41
  ip_address: string | null
@@ -51,6 +53,7 @@ interface SessionQueryRow {
51
  email: string | null
52
  avatar_url: string | null
53
  is_approved: number
 
54
  created_at: number
55
  updated_at: number
56
  last_login_at: number | null
@@ -66,6 +69,7 @@ interface UserQueryRow {
66
  email: string | null
67
  avatar_url: string | null
68
  is_approved: number
 
69
  created_at: number
70
  updated_at: number
71
  last_login_at: number | null
@@ -75,16 +79,37 @@ interface UserQueryRow {
75
  // Session management
76
  const SESSION_DURATION = 7 * 24 * 60 * 60 // 7 days in seconds
77
 
78
- export function createSession(userId: number, ipAddress?: string, userAgent?: string): { token: string; expiresAt: number } {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  const db = getDatabase()
80
  const token = randomBytes(32).toString('hex')
81
  const now = Math.floor(Date.now() / 1000)
82
  const expiresAt = now + SESSION_DURATION
 
83
 
84
  db.prepare(`
85
- INSERT INTO user_sessions (token, user_id, expires_at, ip_address, user_agent)
86
- VALUES (?, ?, ?, ?, ?)
87
- `).run(token, userId, expiresAt, ipAddress || null, userAgent || null)
88
 
89
  // Update user's last login
90
  db.prepare('UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?').run(now, now, userId)
@@ -101,7 +126,7 @@ export function validateSession(token: string): (User & { sessionId: number }) |
101
  const now = Math.floor(Date.now() / 1000)
102
 
103
  const row = db.prepare(`
104
- SELECT u.id, u.username, u.display_name, u.role, u.provider, u.email, u.avatar_url, u.is_approved, u.created_at, u.updated_at, u.last_login_at,
105
  s.id as session_id
106
  FROM user_sessions s
107
  JOIN users u ON u.id = s.user_id
@@ -115,6 +140,7 @@ export function validateSession(token: string): (User & { sessionId: number }) |
115
  username: row.username,
116
  display_name: row.display_name,
117
  role: row.role,
 
118
  provider: row.provider || 'local',
119
  email: row.email ?? null,
120
  avatar_url: row.avatar_url ?? null,
@@ -149,6 +175,7 @@ export function authenticateUser(username: string, password: string): User | nul
149
  username: row.username,
150
  display_name: row.display_name,
151
  role: row.role,
 
152
  provider: row.provider || 'local',
153
  email: row.email ?? null,
154
  avatar_url: row.avatar_url ?? null,
@@ -161,13 +188,13 @@ export function authenticateUser(username: string, password: string): User | nul
161
 
162
  export function getUserById(id: number): User | null {
163
  const db = getDatabase()
164
- const row = db.prepare('SELECT id, username, display_name, role, provider, email, avatar_url, is_approved, created_at, updated_at, last_login_at FROM users WHERE id = ?').get(id) as User | undefined
165
  return row || null
166
  }
167
 
168
  export function getAllUsers(): User[] {
169
  const db = getDatabase()
170
- return db.prepare('SELECT id, username, display_name, role, provider, email, avatar_url, is_approved, created_at, updated_at, last_login_at FROM users ORDER BY created_at').all() as User[]
171
  }
172
 
173
  export function createUser(
@@ -175,15 +202,16 @@ export function createUser(
175
  password: string,
176
  displayName: string,
177
  role: User['role'] = 'operator',
178
- options?: { provider?: 'local' | 'google'; provider_user_id?: string | null; email?: string | null; avatar_url?: string | null; is_approved?: 0 | 1; approved_by?: string | null; approved_at?: number | null }
179
  ): User {
180
  const db = getDatabase()
181
  if (password.length < 12) throw new Error('Password must be at least 12 characters')
182
  const passwordHash = hashPassword(password)
183
  const provider = options?.provider || 'local'
 
184
  const result = db.prepare(`
185
- INSERT INTO users (username, display_name, password_hash, role, provider, provider_user_id, email, avatar_url, is_approved, approved_by, approved_at)
186
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
187
  `).run(
188
  username,
189
  displayName,
@@ -196,6 +224,7 @@ export function createUser(
196
  typeof options?.is_approved === 'number' ? options.is_approved : 1,
197
  options?.approved_by || null,
198
  options?.approved_at || null,
 
199
  )
200
 
201
  return getUserById(Number(result.lastInsertRowid))!
@@ -255,6 +284,7 @@ export function getUserFromRequest(request: Request): User | null {
255
  username: 'api',
256
  display_name: 'API Access',
257
  role: 'admin',
 
258
  created_at: 0,
259
  updated_at: 0,
260
  last_login_at: null,
 
23
  username: string
24
  display_name: string
25
  role: 'admin' | 'operator' | 'viewer'
26
+ workspace_id: number
27
  provider?: 'local' | 'google'
28
  email?: string | null
29
  avatar_url?: string | null
 
37
  id: number
38
  token: string
39
  user_id: number
40
+ workspace_id: number
41
  expires_at: number
42
  created_at: number
43
  ip_address: string | null
 
53
  email: string | null
54
  avatar_url: string | null
55
  is_approved: number
56
+ workspace_id: number
57
  created_at: number
58
  updated_at: number
59
  last_login_at: number | null
 
69
  email: string | null
70
  avatar_url: string | null
71
  is_approved: number
72
+ workspace_id: number
73
  created_at: number
74
  updated_at: number
75
  last_login_at: number | null
 
79
  // Session management
80
  const SESSION_DURATION = 7 * 24 * 60 * 60 // 7 days in seconds
81
 
82
+ function getDefaultWorkspaceId(): number {
83
+ try {
84
+ const db = getDatabase()
85
+ const row = db.prepare(`SELECT id FROM workspaces WHERE slug = 'default' LIMIT 1`).get() as { id?: number } | undefined
86
+ return row?.id || 1
87
+ } catch {
88
+ return 1
89
+ }
90
+ }
91
+
92
+ export function getWorkspaceIdFromRequest(request: Request): number {
93
+ const user = getUserFromRequest(request)
94
+ return user?.workspace_id || getDefaultWorkspaceId()
95
+ }
96
+
97
+ export function createSession(
98
+ userId: number,
99
+ ipAddress?: string,
100
+ userAgent?: string,
101
+ workspaceId?: number
102
+ ): { token: string; expiresAt: number } {
103
  const db = getDatabase()
104
  const token = randomBytes(32).toString('hex')
105
  const now = Math.floor(Date.now() / 1000)
106
  const expiresAt = now + SESSION_DURATION
107
+ const resolvedWorkspaceId = workspaceId ?? ((db.prepare('SELECT workspace_id FROM users WHERE id = ?').get(userId) as { workspace_id?: number } | undefined)?.workspace_id || getDefaultWorkspaceId())
108
 
109
  db.prepare(`
110
+ INSERT INTO user_sessions (token, user_id, expires_at, ip_address, user_agent, workspace_id)
111
+ VALUES (?, ?, ?, ?, ?, ?)
112
+ `).run(token, userId, expiresAt, ipAddress || null, userAgent || null, resolvedWorkspaceId)
113
 
114
  // Update user's last login
115
  db.prepare('UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?').run(now, now, userId)
 
126
  const now = Math.floor(Date.now() / 1000)
127
 
128
  const row = db.prepare(`
129
+ SELECT u.id, u.username, u.display_name, u.role, u.provider, u.email, u.avatar_url, u.is_approved, COALESCE(s.workspace_id, u.workspace_id, 1) as workspace_id, u.created_at, u.updated_at, u.last_login_at,
130
  s.id as session_id
131
  FROM user_sessions s
132
  JOIN users u ON u.id = s.user_id
 
140
  username: row.username,
141
  display_name: row.display_name,
142
  role: row.role,
143
+ workspace_id: row.workspace_id || getDefaultWorkspaceId(),
144
  provider: row.provider || 'local',
145
  email: row.email ?? null,
146
  avatar_url: row.avatar_url ?? null,
 
175
  username: row.username,
176
  display_name: row.display_name,
177
  role: row.role,
178
+ workspace_id: row.workspace_id || getDefaultWorkspaceId(),
179
  provider: row.provider || 'local',
180
  email: row.email ?? null,
181
  avatar_url: row.avatar_url ?? null,
 
188
 
189
  export function getUserById(id: number): User | null {
190
  const db = getDatabase()
191
+ const row = db.prepare('SELECT id, username, display_name, role, workspace_id, provider, email, avatar_url, is_approved, created_at, updated_at, last_login_at FROM users WHERE id = ?').get(id) as User | undefined
192
  return row || null
193
  }
194
 
195
  export function getAllUsers(): User[] {
196
  const db = getDatabase()
197
+ return db.prepare('SELECT id, username, display_name, role, workspace_id, provider, email, avatar_url, is_approved, created_at, updated_at, last_login_at FROM users ORDER BY created_at').all() as User[]
198
  }
199
 
200
  export function createUser(
 
202
  password: string,
203
  displayName: string,
204
  role: User['role'] = 'operator',
205
+ options?: { provider?: 'local' | 'google'; provider_user_id?: string | null; email?: string | null; avatar_url?: string | null; is_approved?: 0 | 1; approved_by?: string | null; approved_at?: number | null; workspace_id?: number }
206
  ): User {
207
  const db = getDatabase()
208
  if (password.length < 12) throw new Error('Password must be at least 12 characters')
209
  const passwordHash = hashPassword(password)
210
  const provider = options?.provider || 'local'
211
+ const workspaceId = options?.workspace_id || getDefaultWorkspaceId()
212
  const result = db.prepare(`
213
+ INSERT INTO users (username, display_name, password_hash, role, provider, provider_user_id, email, avatar_url, is_approved, approved_by, approved_at, workspace_id)
214
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
215
  `).run(
216
  username,
217
  displayName,
 
224
  typeof options?.is_approved === 'number' ? options.is_approved : 1,
225
  options?.approved_by || null,
226
  options?.approved_at || null,
227
+ workspaceId,
228
  )
229
 
230
  return getUserById(Number(result.lastInsertRowid))!
 
284
  username: 'api',
285
  display_name: 'API Access',
286
  role: 'admin',
287
+ workspace_id: getDefaultWorkspaceId(),
288
  created_at: 0,
289
  updated_at: 0,
290
  last_login_at: null,
src/lib/db.ts CHANGED
@@ -233,14 +233,22 @@ export const db_helpers = {
233
  /**
234
  * Log an activity to the activity stream
235
  */
236
- logActivity: (type: string, entity_type: string, entity_id: number, actor: string, description: string, data?: any) => {
 
 
 
 
 
 
 
 
237
  const db = getDatabase();
238
  const stmt = db.prepare(`
239
- INSERT INTO activities (type, entity_type, entity_id, actor, description, data)
240
- VALUES (?, ?, ?, ?, ?, ?)
241
  `);
242
 
243
- const result = stmt.run(type, entity_type, entity_id, actor, description, data ? JSON.stringify(data) : null);
244
 
245
  const activityPayload = {
246
  id: result.lastInsertRowid,
@@ -251,6 +259,7 @@ export const db_helpers = {
251
  description,
252
  data: data || null,
253
  created_at: Math.floor(Date.now() / 1000),
 
254
  };
255
 
256
  // Broadcast to SSE clients (webhooks listen here too)
@@ -260,14 +269,22 @@ export const db_helpers = {
260
  /**
261
  * Create notification for @mentions
262
  */
263
- createNotification: (recipient: string, type: string, title: string, message: string, source_type?: string, source_id?: number) => {
 
 
 
 
 
 
 
 
264
  const db = getDatabase();
265
  const stmt = db.prepare(`
266
- INSERT INTO notifications (recipient, type, title, message, source_type, source_id)
267
- VALUES (?, ?, ?, ?, ?, ?)
268
  `);
269
 
270
- const result = stmt.run(recipient, type, title, message, source_type, source_id);
271
 
272
  const notificationPayload = {
273
  id: result.lastInsertRowid,
@@ -278,6 +295,7 @@ export const db_helpers = {
278
  source_type: source_type || null,
279
  source_id: source_id || null,
280
  created_at: Math.floor(Date.now() / 1000),
 
281
  };
282
 
283
  // Broadcast to SSE clients (webhooks listen here too)
@@ -304,19 +322,19 @@ export const db_helpers = {
304
  /**
305
  * Update agent status and last seen
306
  */
307
- updateAgentStatus: (agentName: string, status: Agent['status'], activity?: string) => {
308
  const db = getDatabase();
309
  const now = Math.floor(Date.now() / 1000);
310
 
311
  // Get agent ID before update
312
- const agent = db.prepare('SELECT id FROM agents WHERE name = ?').get(agentName) as { id: number } | undefined;
313
 
314
  const stmt = db.prepare(`
315
  UPDATE agents
316
  SET status = ?, last_seen = ?, last_activity = ?, updated_at = ?
317
- WHERE name = ?
318
  `);
319
- stmt.run(status, now, activity, now, agentName);
320
 
321
  // Broadcast agent status change to SSE clients
322
  if (agent) {
@@ -330,7 +348,7 @@ export const db_helpers = {
330
  }
331
 
332
  // Log the status change
333
- db_helpers.logActivity('agent_status_change', 'agent', agent?.id || 0, agentName, `Agent status changed to ${status}`, { status, activity });
334
  },
335
 
336
  /**
@@ -350,52 +368,57 @@ export const db_helpers = {
350
  /**
351
  * Get unread notifications for recipient
352
  */
353
- getUnreadNotifications: (recipient: string): Notification[] => {
354
  const db = getDatabase();
355
  const stmt = db.prepare(`
356
  SELECT * FROM notifications
357
- WHERE recipient = ? AND read_at IS NULL
358
  ORDER BY created_at DESC
359
  `);
360
 
361
- return stmt.all(recipient) as Notification[];
362
  },
363
 
364
  /**
365
  * Mark notification as read
366
  */
367
- markNotificationRead: (notificationId: number) => {
368
  const db = getDatabase();
369
  const stmt = db.prepare(`
370
  UPDATE notifications
371
  SET read_at = ?
372
- WHERE id = ?
373
  `);
374
 
375
- stmt.run(Math.floor(Date.now() / 1000), notificationId);
376
  },
377
 
378
  /**
379
  * Ensure an agent is subscribed to a task
380
  */
381
- ensureTaskSubscription: (taskId: number, agentName: string) => {
382
  if (!agentName) return;
383
  const db = getDatabase();
384
  const stmt = db.prepare(`
385
  INSERT OR IGNORE INTO task_subscriptions (task_id, agent_name)
386
- VALUES (?, ?)
 
 
387
  `);
388
- stmt.run(taskId, agentName);
389
  },
390
 
391
  /**
392
  * Get subscribers for a task
393
  */
394
- getTaskSubscribers: (taskId: number): string[] => {
395
  const db = getDatabase();
396
  const rows = db.prepare(`
397
- SELECT agent_name FROM task_subscriptions WHERE task_id = ?
398
- `).all(taskId) as Array<{ agent_name: string }>;
 
 
 
399
  return rows.map((row) => row.agent_name);
400
  }
401
  };
 
233
  /**
234
  * Log an activity to the activity stream
235
  */
236
+ logActivity: (
237
+ type: string,
238
+ entity_type: string,
239
+ entity_id: number,
240
+ actor: string,
241
+ description: string,
242
+ data?: any,
243
+ workspaceId: number = 1
244
+ ) => {
245
  const db = getDatabase();
246
  const stmt = db.prepare(`
247
+ INSERT INTO activities (type, entity_type, entity_id, actor, description, data, workspace_id)
248
+ VALUES (?, ?, ?, ?, ?, ?, ?)
249
  `);
250
 
251
+ const result = stmt.run(type, entity_type, entity_id, actor, description, data ? JSON.stringify(data) : null, workspaceId);
252
 
253
  const activityPayload = {
254
  id: result.lastInsertRowid,
 
259
  description,
260
  data: data || null,
261
  created_at: Math.floor(Date.now() / 1000),
262
+ workspace_id: workspaceId,
263
  };
264
 
265
  // Broadcast to SSE clients (webhooks listen here too)
 
269
  /**
270
  * Create notification for @mentions
271
  */
272
+ createNotification: (
273
+ recipient: string,
274
+ type: string,
275
+ title: string,
276
+ message: string,
277
+ source_type?: string,
278
+ source_id?: number,
279
+ workspaceId: number = 1
280
+ ) => {
281
  const db = getDatabase();
282
  const stmt = db.prepare(`
283
+ INSERT INTO notifications (recipient, type, title, message, source_type, source_id, workspace_id)
284
+ VALUES (?, ?, ?, ?, ?, ?, ?)
285
  `);
286
 
287
+ const result = stmt.run(recipient, type, title, message, source_type, source_id, workspaceId);
288
 
289
  const notificationPayload = {
290
  id: result.lastInsertRowid,
 
295
  source_type: source_type || null,
296
  source_id: source_id || null,
297
  created_at: Math.floor(Date.now() / 1000),
298
+ workspace_id: workspaceId,
299
  };
300
 
301
  // Broadcast to SSE clients (webhooks listen here too)
 
322
  /**
323
  * Update agent status and last seen
324
  */
325
+ updateAgentStatus: (agentName: string, status: Agent['status'], activity?: string, workspaceId: number = 1) => {
326
  const db = getDatabase();
327
  const now = Math.floor(Date.now() / 1000);
328
 
329
  // Get agent ID before update
330
+ const agent = db.prepare('SELECT id FROM agents WHERE name = ? AND workspace_id = ?').get(agentName, workspaceId) as { id: number } | undefined;
331
 
332
  const stmt = db.prepare(`
333
  UPDATE agents
334
  SET status = ?, last_seen = ?, last_activity = ?, updated_at = ?
335
+ WHERE name = ? AND workspace_id = ?
336
  `);
337
+ stmt.run(status, now, activity, now, agentName, workspaceId);
338
 
339
  // Broadcast agent status change to SSE clients
340
  if (agent) {
 
348
  }
349
 
350
  // Log the status change
351
+ db_helpers.logActivity('agent_status_change', 'agent', agent?.id || 0, agentName, `Agent status changed to ${status}`, { status, activity }, workspaceId);
352
  },
353
 
354
  /**
 
368
  /**
369
  * Get unread notifications for recipient
370
  */
371
+ getUnreadNotifications: (recipient: string, workspaceId: number = 1): Notification[] => {
372
  const db = getDatabase();
373
  const stmt = db.prepare(`
374
  SELECT * FROM notifications
375
+ WHERE recipient = ? AND read_at IS NULL AND workspace_id = ?
376
  ORDER BY created_at DESC
377
  `);
378
 
379
+ return stmt.all(recipient, workspaceId) as Notification[];
380
  },
381
 
382
  /**
383
  * Mark notification as read
384
  */
385
+ markNotificationRead: (notificationId: number, workspaceId: number = 1) => {
386
  const db = getDatabase();
387
  const stmt = db.prepare(`
388
  UPDATE notifications
389
  SET read_at = ?
390
+ WHERE id = ? AND workspace_id = ?
391
  `);
392
 
393
+ stmt.run(Math.floor(Date.now() / 1000), notificationId, workspaceId);
394
  },
395
 
396
  /**
397
  * Ensure an agent is subscribed to a task
398
  */
399
+ ensureTaskSubscription: (taskId: number, agentName: string, workspaceId: number = 1) => {
400
  if (!agentName) return;
401
  const db = getDatabase();
402
  const stmt = db.prepare(`
403
  INSERT OR IGNORE INTO task_subscriptions (task_id, agent_name)
404
+ SELECT t.id, ?
405
+ FROM tasks t
406
+ WHERE t.id = ? AND t.workspace_id = ?
407
  `);
408
+ stmt.run(agentName, taskId, workspaceId);
409
  },
410
 
411
  /**
412
  * Get subscribers for a task
413
  */
414
+ getTaskSubscribers: (taskId: number, workspaceId: number = 1): string[] => {
415
  const db = getDatabase();
416
  const rows = db.prepare(`
417
+ SELECT ts.agent_name
418
+ FROM task_subscriptions ts
419
+ JOIN tasks t ON t.id = ts.task_id
420
+ WHERE ts.task_id = ? AND t.workspace_id = ?
421
+ `).all(taskId, workspaceId) as Array<{ agent_name: string }>;
422
  return rows.map((row) => row.agent_name);
423
  }
424
  };
src/lib/migrations.ts CHANGED
@@ -547,6 +547,102 @@ const migrations: Migration[] = [
547
  db.exec(`CREATE INDEX IF NOT EXISTS idx_claude_sessions_active ON claude_sessions(is_active) WHERE is_active = 1`)
548
  db.exec(`CREATE INDEX IF NOT EXISTS idx_claude_sessions_project ON claude_sessions(project_slug)`)
549
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  }
551
  ]
552
 
 
547
  db.exec(`CREATE INDEX IF NOT EXISTS idx_claude_sessions_active ON claude_sessions(is_active) WHERE is_active = 1`)
548
  db.exec(`CREATE INDEX IF NOT EXISTS idx_claude_sessions_project ON claude_sessions(project_slug)`)
549
  }
550
+ },
551
+ {
552
+ id: '021_workspace_isolation_phase1',
553
+ up: (db) => {
554
+ db.exec(`
555
+ CREATE TABLE IF NOT EXISTS workspaces (
556
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
557
+ slug TEXT NOT NULL UNIQUE,
558
+ name TEXT NOT NULL,
559
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
560
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
561
+ );
562
+ `)
563
+
564
+ db.prepare(`
565
+ INSERT OR IGNORE INTO workspaces (id, slug, name, created_at, updated_at)
566
+ VALUES (1, 'default', 'Default Workspace', unixepoch(), unixepoch())
567
+ `).run()
568
+
569
+ const addWorkspaceIdColumn = (table: string) => {
570
+ const tableExists = db
571
+ .prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = ?`)
572
+ .get(table) as { ok?: number } | undefined
573
+ if (!tableExists?.ok) return
574
+
575
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>
576
+ if (!cols.some((c) => c.name === 'workspace_id')) {
577
+ db.exec(`ALTER TABLE ${table} ADD COLUMN workspace_id INTEGER NOT NULL DEFAULT 1`)
578
+ }
579
+ db.exec(`UPDATE ${table} SET workspace_id = COALESCE(workspace_id, 1)`)
580
+ }
581
+
582
+ const scopedTables = [
583
+ 'users',
584
+ 'user_sessions',
585
+ 'tasks',
586
+ 'agents',
587
+ 'comments',
588
+ 'activities',
589
+ 'notifications',
590
+ 'quality_reviews',
591
+ 'standup_reports',
592
+ ]
593
+
594
+ for (const table of scopedTables) {
595
+ addWorkspaceIdColumn(table)
596
+ }
597
+
598
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_workspaces_slug ON workspaces(slug)`)
599
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_users_workspace_id ON users(workspace_id)`)
600
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_user_sessions_workspace_id ON user_sessions(workspace_id)`)
601
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id)`)
602
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_agents_workspace_id ON agents(workspace_id)`)
603
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_workspace_id ON comments(workspace_id)`)
604
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_activities_workspace_id ON activities(workspace_id)`)
605
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_notifications_workspace_id ON notifications(workspace_id)`)
606
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_quality_reviews_workspace_id ON quality_reviews(workspace_id)`)
607
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_standup_reports_workspace_id ON standup_reports(workspace_id)`)
608
+ }
609
+ },
610
+ {
611
+ id: '022_workspace_isolation_phase2',
612
+ up: (db) => {
613
+ const addWorkspaceIdColumn = (table: string) => {
614
+ const tableExists = db
615
+ .prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = ?`)
616
+ .get(table) as { ok?: number } | undefined
617
+ if (!tableExists?.ok) return
618
+
619
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>
620
+ if (!cols.some((c) => c.name === 'workspace_id')) {
621
+ db.exec(`ALTER TABLE ${table} ADD COLUMN workspace_id INTEGER NOT NULL DEFAULT 1`)
622
+ }
623
+ db.exec(`UPDATE ${table} SET workspace_id = COALESCE(workspace_id, 1)`)
624
+ }
625
+
626
+ const scopedTables = [
627
+ 'messages',
628
+ 'alert_rules',
629
+ 'direct_connections',
630
+ 'github_syncs',
631
+ 'workflow_pipelines',
632
+ 'pipeline_runs',
633
+ ]
634
+
635
+ for (const table of scopedTables) {
636
+ addWorkspaceIdColumn(table)
637
+ }
638
+
639
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_workspace_id ON messages(workspace_id)`)
640
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_alert_rules_workspace_id ON alert_rules(workspace_id)`)
641
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_direct_connections_workspace_id ON direct_connections(workspace_id)`)
642
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_github_syncs_workspace_id ON github_syncs(workspace_id)`)
643
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_workflow_pipelines_workspace_id ON workflow_pipelines(workspace_id)`)
644
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_pipeline_runs_workspace_id ON pipeline_runs(workspace_id)`)
645
+ }
646
  }
647
  ]
648
 
src/store/index.ts CHANGED
@@ -35,6 +35,10 @@ export interface CronJob {
35
  name: string
36
  schedule: string
37
  command: string
 
 
 
 
38
  enabled: boolean
39
  lastRun?: number
40
  nextRun?: number
 
35
  name: string
36
  schedule: string
37
  command: string
38
+ model?: string
39
+ agentId?: string
40
+ timezone?: string
41
+ delivery?: string
42
  enabled: boolean
43
  lastRun?: number
44
  nextRun?: number