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

* feat: add workspace-scoped auth sessions and core API filtering

* feat: extend workspace scoping to search status standup and messaging

* feat: scope agent connect github and alert workflows by workspace

* fix: scope status sync and session alerts by workspace

* feat: add phase2 workspace migration and scope chat pipeline alerts

* feat: add model selection for agents and cron jobs

* feat: add deterministic agent avatars to task and squad views

* feat: add read-only cron calendar and agenda views

* feat: render task descriptions with markdown
This commit is contained in:
nyk 2026-03-04 00:27:47 +07:00 committed by GitHub
parent d682395266
commit 498cb2f8d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1623 additions and 490 deletions

View File

@ -29,8 +29,10 @@
"postcss": "^8.5.2",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"react-markdown": "^10.1.0",
"reactflow": "^11.11.4",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",

View File

@ -47,12 +47,18 @@ importers:
react-dom:
specifier: ^19.0.1
version: 19.2.4(react@19.2.4)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.13)(react@19.2.4)
reactflow:
specifier: ^11.11.4
version: 11.11.4(@types/react@19.2.13)(immer@11.1.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
recharts:
specifier: ^3.7.0
version: 3.7.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@ -1286,6 +1292,9 @@ packages:
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -1321,6 +1330,9 @@ packages:
'@types/react@19.2.13':
resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@ -1890,6 +1902,9 @@ packages:
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
@ -2331,6 +2346,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@ -2590,6 +2608,9 @@ packages:
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
@ -2625,6 +2646,9 @@ packages:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@ -2682,6 +2706,9 @@ packages:
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@ -2694,6 +2721,12 @@ packages:
resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@ -2733,6 +2766,9 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@ -2749,6 +2785,9 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
is-identifier@1.0.1:
resolution: {integrity: sha512-HQ5v4rEJ7REUV54bCd2l5FaD299SGDEn2UPoVXaTHAyGviLq2menVUD2udi3trQ32uvB6LdAh/0ck2EuizrtpA==}
engines: {node: '>=18'}
@ -2990,6 +3029,15 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
mdast-util-mdx-jsx@3.2.0:
resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
mdast-util-mdxjs-esm@2.0.1:
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
@ -3262,6 +3310,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
@ -3451,6 +3502,12 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@ -3777,6 +3834,12 @@ packages:
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@ -5702,6 +5765,10 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
'@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {}
@ -5734,6 +5801,8 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
@ -6336,6 +6405,8 @@ snapshots:
character-entities@2.0.2: {}
character-reference-invalid@2.0.1: {}
check-error@2.1.3: {}
chokidar@3.6.0:
@ -6914,6 +6985,8 @@ snapshots:
estraverse@5.3.0: {}
estree-util-is-identifier-name@3.0.0: {}
estree-walker@2.0.2: {}
estree-walker@3.0.3:
@ -7211,6 +7284,26 @@ snapshots:
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
'@types/hast': 3.0.4
'@types/unist': 3.0.3
comma-separated-tokens: 2.0.3
devlop: 1.1.0
estree-util-is-identifier-name: 3.0.0
hast-util-whitespace: 3.0.0
mdast-util-mdx-expression: 2.0.1
mdast-util-mdx-jsx: 3.2.0
mdast-util-mdxjs-esm: 2.0.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
style-to-js: 1.1.21
unist-util-position: 5.0.0
vfile-message: 4.0.3
transitivePeerDependencies:
- supports-color
hast-util-to-parse5@8.0.1:
dependencies:
'@types/hast': 3.0.4
@ -7258,6 +7351,8 @@ snapshots:
dependencies:
whatwg-encoding: 3.1.1
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
html-whitespace-sensitive-tag-names@3.0.1: {}
@ -7307,6 +7402,8 @@ snapshots:
ini@1.3.8: {}
inline-style-parser@0.2.7: {}
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@ -7317,6 +7414,13 @@ snapshots:
is-absolute-url@4.0.1: {}
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
dependencies:
is-alphabetical: 2.0.1
is-decimal: 2.0.1
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@ -7365,6 +7469,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-decimal@2.0.1: {}
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
@ -7383,6 +7489,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-hexadecimal@2.0.1: {}
is-identifier@1.0.1:
dependencies:
identifier-regex: 1.0.1
@ -7671,6 +7779,45 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-mdx-jsx@3.2.0:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
parse-entities: 4.0.2
stringify-entities: 4.0.4
unist-util-stringify-position: 4.0.0
vfile-message: 4.0.3
transitivePeerDependencies:
- supports-color
mdast-util-mdxjs-esm@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
@ -8066,6 +8213,16 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
character-entities-legacy: 3.0.0
character-reference-invalid: 2.0.1
decode-named-character-reference: 1.3.0
is-alphanumerical: 2.0.1
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
parse-ms@4.0.0: {}
parse5@7.3.0:
@ -8267,6 +8424,24 @@ snapshots:
react-is@17.0.2: {}
react-markdown@10.1.0(@types/react@19.2.13)(react@19.2.4):
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/react': 19.2.13
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.2.4
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.1.0
vfile: 6.0.3
transitivePeerDependencies:
- supports-color
react-redux@9.2.0(@types/react@19.2.13)(react@19.2.4)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
@ -8735,6 +8910,14 @@ snapshots:
style-mod@4.1.3: {}
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
style-to-object@1.0.14:
dependencies:
inline-style-parser: 0.2.7
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4):
dependencies:
client-only: 0.0.1

View File

@ -13,14 +13,15 @@ export async function GET(request: NextRequest) {
try {
const { searchParams, pathname } = new URL(request.url);
const workspaceId = auth.user.workspace_id ?? 1;
// Route to stats endpoint if requested
if (pathname.endsWith('/stats') || searchParams.has('stats')) {
return handleStatsRequest(request);
return handleStatsRequest(request, workspaceId);
}
// Default activities endpoint
return handleActivitiesRequest(request);
return handleActivitiesRequest(request, workspaceId);
} catch (error) {
logger.error({ err: error }, 'GET /api/activities error');
return NextResponse.json({ error: 'Failed to process request' }, { status: 500 });
@ -30,7 +31,7 @@ export async function GET(request: NextRequest) {
/**
* Handle regular activities request
*/
async function handleActivitiesRequest(request: NextRequest) {
async function handleActivitiesRequest(request: NextRequest, workspaceId: number) {
try {
const db = getDatabase();
const { searchParams } = new URL(request.url);
@ -44,8 +45,8 @@ async function handleActivitiesRequest(request: NextRequest) {
const since = searchParams.get('since'); // Unix timestamp for real-time updates
// Build dynamic query
let query = 'SELECT * FROM activities WHERE 1=1';
const params: any[] = [];
let query = 'SELECT * FROM activities WHERE workspace_id = ?';
const params: any[] = [workspaceId];
if (type) {
query += ' AND type = ?';
@ -74,13 +75,13 @@ async function handleActivitiesRequest(request: NextRequest) {
const activities = stmt.all(...params) as Activity[];
// Prepare entity detail statements once (avoids N+1)
const taskDetailStmt = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?');
const agentDetailStmt = db.prepare('SELECT id, name, role, status FROM agents WHERE id = ?');
const taskDetailStmt = db.prepare('SELECT id, title, status FROM tasks WHERE id = ? AND workspace_id = ?');
const agentDetailStmt = db.prepare('SELECT id, name, role, status FROM agents WHERE id = ? AND workspace_id = ?');
const commentDetailStmt = db.prepare(`
SELECT c.id, c.content, c.task_id, t.title as task_title
FROM comments c
LEFT JOIN tasks t ON c.task_id = t.id
WHERE c.id = ?
WHERE c.id = ? AND c.workspace_id = ? AND t.workspace_id = ?
`);
// Parse JSON data field and enhance with related entity data
@ -90,21 +91,21 @@ async function handleActivitiesRequest(request: NextRequest) {
try {
switch (activity.entity_type) {
case 'task': {
const task = taskDetailStmt.get(activity.entity_id) as any;
const task = taskDetailStmt.get(activity.entity_id, workspaceId) as any;
if (task) {
entityDetails = { type: 'task', ...task };
}
break;
}
case 'agent': {
const agent = agentDetailStmt.get(activity.entity_id) as any;
const agent = agentDetailStmt.get(activity.entity_id, workspaceId) as any;
if (agent) {
entityDetails = { type: 'agent', ...agent };
}
break;
}
case 'comment': {
const comment = commentDetailStmt.get(activity.entity_id) as any;
const comment = commentDetailStmt.get(activity.entity_id, workspaceId, workspaceId) as any;
if (comment) {
entityDetails = {
type: 'comment',
@ -127,8 +128,8 @@ async function handleActivitiesRequest(request: NextRequest) {
});
// Get total count for pagination
let countQuery = 'SELECT COUNT(*) as total FROM activities WHERE 1=1';
const countParams: any[] = [];
let countQuery = 'SELECT COUNT(*) as total FROM activities WHERE workspace_id = ?';
const countParams: any[] = [workspaceId];
if (type) {
countQuery += ' AND type = ?';
@ -166,7 +167,7 @@ async function handleActivitiesRequest(request: NextRequest) {
/**
* Handle stats request
*/
async function handleStatsRequest(request: NextRequest) {
async function handleStatsRequest(request: NextRequest, workspaceId: number) {
try {
const db = getDatabase();
const { searchParams } = new URL(request.url);
@ -181,10 +182,10 @@ async function handleStatsRequest(request: NextRequest) {
type,
COUNT(*) as count
FROM activities
WHERE created_at > ?
WHERE created_at > ? AND workspace_id = ?
GROUP BY type
ORDER BY count DESC
`).all(since) as { type: string; count: number }[];
`).all(since, workspaceId) as { type: string; count: number }[];
// Get most active actors
const activeActors = db.prepare(`
@ -192,11 +193,11 @@ async function handleStatsRequest(request: NextRequest) {
actor,
COUNT(*) as activity_count
FROM activities
WHERE created_at > ?
WHERE created_at > ? AND workspace_id = ?
GROUP BY actor
ORDER BY activity_count DESC
LIMIT 10
`).all(since) as { actor: string; activity_count: number }[];
`).all(since, workspaceId) as { actor: string; activity_count: number }[];
// Get activity timeline (hourly buckets)
const timeline = db.prepare(`
@ -204,10 +205,10 @@ async function handleStatsRequest(request: NextRequest) {
(created_at / 3600) * 3600 as hour_bucket,
COUNT(*) as count
FROM activities
WHERE created_at > ?
WHERE created_at > ? AND workspace_id = ?
GROUP BY hour_bucket
ORDER BY hour_bucket ASC
`).all(since) as { hour_bucket: number; count: number }[];
`).all(since, workspaceId) as { hour_bucket: number; count: number }[];
return NextResponse.json({
timeframe: `${hours} hours`,

View File

@ -24,15 +24,16 @@ export async function GET(
const db = getDatabase();
const resolvedParams = await params;
const agentId = resolvedParams.id;
const workspaceId = auth.user.workspace_id ?? 1;
// Get agent by ID or name
let agent: any;
if (isNaN(Number(agentId))) {
// Lookup by name
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
} else {
// Lookup by ID
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
}
if (!agent) {
@ -49,10 +50,12 @@ export async function GET(
FROM comments c
JOIN tasks t ON c.task_id = t.id
WHERE c.mentions LIKE ?
AND c.workspace_id = ?
AND t.workspace_id = ?
AND c.created_at > ?
ORDER BY c.created_at DESC
LIMIT 10
`).all(`%"${agent.name}"%`, fourHoursAgo);
`).all(`%"${agent.name}"%`, workspaceId, workspaceId, fourHoursAgo);
if (mentions.length > 0) {
workItems.push({
@ -72,10 +75,11 @@ export async function GET(
const assignedTasks = db.prepare(`
SELECT * FROM tasks
WHERE assigned_to = ?
AND workspace_id = ?
AND status IN ('assigned', 'in_progress')
ORDER BY priority DESC, created_at ASC
LIMIT 10
`).all(agent.name);
`).all(agent.name, workspaceId);
if (assignedTasks.length > 0) {
workItems.push({
@ -92,7 +96,7 @@ export async function GET(
}
// 3. Check for unread notifications
const notifications = db_helpers.getUnreadNotifications(agent.name);
const notifications = db_helpers.getUnreadNotifications(agent.name, workspaceId);
if (notifications.length > 0) {
workItems.push({
@ -112,11 +116,12 @@ export async function GET(
const urgentActivities = db.prepare(`
SELECT * FROM activities
WHERE type IN ('task_created', 'task_assigned', 'high_priority_alert')
AND workspace_id = ?
AND created_at > ?
AND description LIKE ?
ORDER BY created_at DESC
LIMIT 5
`).all(fourHoursAgo, `%${agent.name}%`);
`).all(workspaceId, fourHoursAgo, `%${agent.name}%`);
if (urgentActivities.length > 0) {
workItems.push({
@ -132,7 +137,7 @@ export async function GET(
}
// Update agent last_seen and status to show heartbeat activity
db_helpers.updateAgentStatus(agent.name, 'idle', 'Heartbeat check');
db_helpers.updateAgentStatus(agent.name, 'idle', 'Heartbeat check', workspaceId);
// Log heartbeat activity
db_helpers.logActivity(
@ -141,7 +146,8 @@ export async function GET(
agent.id,
agent.name,
`Heartbeat check completed - ${workItems.length > 0 ? `${workItems.length} work items found` : 'no work items'}`,
{ workItemsCount: workItems.length, workItemTypes: workItems.map(w => w.type) }
{ workItemsCount: workItems.length, workItemTypes: workItems.map(w => w.type) },
workspaceId
);
if (workItems.length === 0) {
@ -193,11 +199,12 @@ export async function POST(
const { connection_id, token_usage } = body;
const db = getDatabase();
const now = Math.floor(Date.now() / 1000);
const workspaceId = auth.user.workspace_id ?? 1;
// Update direct connection heartbeat if connection_id provided
if (connection_id) {
db.prepare('UPDATE direct_connections SET last_heartbeat = ?, updated_at = ? WHERE connection_id = ? AND status = ?')
.run(now, now, connection_id, 'connected');
db.prepare('UPDATE direct_connections SET last_heartbeat = ?, updated_at = ? WHERE connection_id = ? AND status = ? AND workspace_id = ?')
.run(now, now, connection_id, 'connected', workspaceId);
}
// Inline token reporting
@ -207,9 +214,9 @@ export async function POST(
const agentId = resolvedParams.id;
let agent: any;
if (isNaN(Number(agentId))) {
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
} else {
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
}
if (agent) {

View File

@ -20,13 +20,14 @@ export async function GET(
const db = getDatabase();
const resolvedParams = await params;
const agentId = resolvedParams.id;
const workspaceId = auth.user.workspace_id ?? 1;
// Get agent by ID or name
let agent: any;
if (isNaN(Number(agentId))) {
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
} else {
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
}
if (!agent) {
@ -43,8 +44,8 @@ export async function GET(
}
// Get working memory content
const memoryStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?`);
const result = memoryStmt.get(agentId) as any;
const memoryStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ?`);
const result = memoryStmt.get(agentId, workspaceId) as any;
const workingMemory = result?.working_memory || '';
@ -78,15 +79,16 @@ export async function PUT(
const db = getDatabase();
const resolvedParams = await params;
const agentId = resolvedParams.id;
const workspaceId = auth.user.workspace_id ?? 1;
const body = await request.json();
const { working_memory, append } = body;
// Get agent by ID or name
let agent: any;
if (isNaN(Number(agentId))) {
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
} else {
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
}
if (!agent) {
@ -105,8 +107,8 @@ export async function PUT(
// Handle append mode
if (append) {
const currentStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?`);
const current = currentStmt.get(agentId) as any;
const currentStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ?`);
const current = currentStmt.get(agentId, workspaceId) as any;
const currentContent = current?.working_memory || '';
// Add timestamp and append
@ -121,10 +123,10 @@ export async function PUT(
const updateStmt = db.prepare(`
UPDATE agents
SET working_memory = ?, updated_at = ?
WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?
WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ?
`);
updateStmt.run(newContent, now, agentId);
updateStmt.run(newContent, now, agentId, workspaceId);
// Log activity
db_helpers.logActivity(
@ -137,7 +139,8 @@ export async function PUT(
content_length: newContent.length,
append_mode: append || false,
timestamp: now
}
},
workspaceId
);
return NextResponse.json({
@ -167,13 +170,14 @@ export async function DELETE(
const db = getDatabase();
const resolvedParams = await params;
const agentId = resolvedParams.id;
const workspaceId = auth.user.workspace_id ?? 1;
// Get agent by ID or name
let agent: any;
if (isNaN(Number(agentId))) {
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
} else {
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
}
if (!agent) {
@ -186,10 +190,10 @@ export async function DELETE(
const updateStmt = db.prepare(`
UPDATE agents
SET working_memory = '', updated_at = ?
WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?
WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ?
`);
updateStmt.run(now, agentId);
updateStmt.run(now, agentId, workspaceId);
// Log activity
db_helpers.logActivity(
@ -198,7 +202,8 @@ export async function DELETE(
agent.id,
agent.name,
`Working memory cleared for agent ${agent.name}`,
{ timestamp: now }
{ timestamp: now },
workspaceId
);
return NextResponse.json({

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDatabase, db_helpers, logAuditEvent } from '@/lib/db'
import { getUserFromRequest, requireRole } from '@/lib/auth'
import { requireRole } from '@/lib/auth'
import { writeAgentToConfig, enrichAgentConfigFromWorkspace } from '@/lib/agent-sync'
import { eventBus } from '@/lib/event-bus'
import { logger } from '@/lib/logger'
@ -18,12 +18,13 @@ export async function GET(
try {
const db = getDatabase()
const { id } = await params
const workspaceId = auth.user.workspace_id ?? 1;
let agent
if (isNaN(Number(id))) {
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(id)
agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(id, workspaceId)
} else {
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(id))
agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(id), workspaceId)
}
if (!agent) {
@ -61,14 +62,15 @@ export async function PUT(
try {
const db = getDatabase()
const { id } = await params
const workspaceId = auth.user.workspace_id ?? 1;
const body = await request.json()
const { role, gateway_config, write_to_gateway } = body
let agent
if (isNaN(Number(id))) {
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(id) as any
agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(id, workspaceId) as any
} else {
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(id)) as any
agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(id), workspaceId) as any
}
if (!agent) {
@ -98,8 +100,8 @@ export async function PUT(
values.push(JSON.stringify(newConfig))
}
values.push(agent.id)
db.prepare(`UPDATE agents SET ${fields.join(', ')} WHERE id = ?`).run(...values)
values.push(agent.id, workspaceId)
db.prepare(`UPDATE agents SET ${fields.join(', ')} WHERE id = ? AND workspace_id = ?`).run(...values)
// Write back to openclaw.json if requested
if (write_to_gateway && gateway_config) {
@ -143,7 +145,8 @@ export async function PUT(
agent.id,
auth.user.username,
`Config updated for agent ${agent.name}${write_to_gateway ? ' (+ gateway)' : ''}`,
{ fields: Object.keys(gateway_config || {}), write_to_gateway }
{ fields: Object.keys(gateway_config || {}), write_to_gateway },
workspaceId
)
// Broadcast update
@ -179,19 +182,20 @@ export async function DELETE(
try {
const db = getDatabase()
const { id } = await params
const workspaceId = auth.user.workspace_id ?? 1;
let agent
if (isNaN(Number(id))) {
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(id) as any
agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(id, workspaceId) as any
} else {
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(id)) as any
agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(id), workspaceId) as any
}
if (!agent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
db.prepare('DELETE FROM agents WHERE id = ?').run(agent.id)
db.prepare('DELETE FROM agents WHERE id = ? AND workspace_id = ?').run(agent.id, workspaceId)
db_helpers.logActivity(
'agent_deleted',
@ -199,7 +203,8 @@ export async function DELETE(
agent.id,
auth.user.username,
`Deleted agent: ${agent.name}`,
{ name: agent.name, role: agent.role }
{ name: agent.name, role: agent.role },
workspaceId
)
eventBus.broadcast('agent.deleted', { id: agent.id, name: agent.name })

View File

@ -4,7 +4,7 @@ import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from
import { join, dirname } from 'path';
import { config } from '@/lib/config';
import { resolveWithin } from '@/lib/paths';
import { getUserFromRequest, requireRole } from '@/lib/auth';
import { requireRole } from '@/lib/auth';
import { logger } from '@/lib/logger';
/**
@ -21,13 +21,14 @@ export async function GET(
const db = getDatabase();
const resolvedParams = await params;
const agentId = resolvedParams.id;
const workspaceId = auth.user.workspace_id ?? 1;
// Get agent by ID or name
let agent: any;
if (isNaN(Number(agentId))) {
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
} else {
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
}
if (!agent) {
@ -103,15 +104,16 @@ export async function PUT(
const db = getDatabase();
const resolvedParams = await params;
const agentId = resolvedParams.id;
const workspaceId = auth.user.workspace_id ?? 1;
const body = await request.json();
const { soul_content, template_name } = body;
// Get agent by ID or name
let agent: any;
if (isNaN(Number(agentId))) {
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
} else {
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
agent = db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
}
if (!agent) {
@ -170,24 +172,25 @@ export async function PUT(
const updateStmt = db.prepare(`
UPDATE agents
SET soul_content = ?, updated_at = ?
WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?
WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ? AND workspace_id = ?
`);
updateStmt.run(newSoulContent, now, agentId);
updateStmt.run(newSoulContent, now, agentId, workspaceId);
// Log activity
db_helpers.logActivity(
'agent_soul_updated',
'agent',
agent.id,
getUserFromRequest(request)?.username || 'system',
auth.user.username,
`SOUL content updated for agent ${agent.name}${template_name ? ` using template: ${template_name}` : ''}${savedToWorkspace ? ' (synced to workspace)' : ''}`,
{
template_used: template_name || null,
content_length: newSoulContent ? newSoulContent.length : 0,
previous_content_length: agent.soul_content ? agent.soul_content.length : 0,
saved_to_workspace: savedToWorkspace
}
},
workspaceId
);
return NextResponse.json({

View File

@ -14,14 +14,15 @@ export async function POST(
try {
const resolvedParams = await params
const agentId = resolvedParams.id
const workspaceId = auth.user.workspace_id ?? 1;
const body = await request.json().catch(() => ({}))
const customMessage =
typeof body?.message === 'string' ? body.message.trim() : ''
const db = getDatabase()
const agent: any = isNaN(Number(agentId))
? db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId)
: db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId))
? db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId)
: db.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId)
if (!agent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
@ -50,7 +51,7 @@ export async function POST(
)
}
db_helpers.updateAgentStatus(agent.name, 'idle', 'Manual wake')
db_helpers.updateAgentStatus(agent.name, 'idle', 'Manual wake', workspaceId)
return NextResponse.json({
success: true,

View File

@ -14,6 +14,7 @@ export async function GET(request: NextRequest) {
try {
const db = getDatabase()
const { searchParams } = new URL(request.url)
const workspaceId = auth.user.workspace_id ?? 1
const limit = parseInt(searchParams.get("limit") || "100")
const offset = parseInt(searchParams.get("offset") || "0")
@ -27,11 +28,12 @@ export async function GET(request: NextRequest) {
// 1. Get inter-agent messages
let messagesQuery = `
SELECT * FROM messages
WHERE to_agent IS NOT NULL
WHERE workspace_id = ?
AND to_agent IS NOT NULL
AND from_agent NOT IN (${humanPlaceholders})
AND to_agent NOT IN (${humanPlaceholders})
`
const messagesParams: any[] = [...humanNames, ...humanNames]
const messagesParams: any[] = [workspaceId, ...humanNames, ...humanNames]
if (since) {
messagesQuery += " AND created_at > ?"
@ -55,11 +57,12 @@ export async function GET(request: NextRequest) {
COUNT(*) as message_count,
MAX(created_at) as last_message_at
FROM messages
WHERE to_agent IS NOT NULL
WHERE workspace_id = ?
AND to_agent IS NOT NULL
AND from_agent NOT IN (${humanPlaceholders})
AND to_agent NOT IN (${humanPlaceholders})
`
const graphParams: any[] = [...humanNames, ...humanNames]
const graphParams: any[] = [workspaceId, ...humanNames, ...humanNames]
if (since) {
graphQuery += " AND created_at > ?"
graphParams.push(parseInt(since))
@ -72,29 +75,30 @@ export async function GET(request: NextRequest) {
const statsQuery = `
SELECT agent, SUM(sent) as sent, SUM(received) as received FROM (
SELECT from_agent as agent, COUNT(*) as sent, 0 as received
FROM messages WHERE to_agent IS NOT NULL
FROM messages WHERE workspace_id = ? AND to_agent IS NOT NULL
AND from_agent NOT IN (${humanPlaceholders})
AND to_agent NOT IN (${humanPlaceholders})
GROUP BY from_agent
UNION ALL
SELECT to_agent as agent, 0 as sent, COUNT(*) as received
FROM messages WHERE to_agent IS NOT NULL
FROM messages WHERE workspace_id = ? AND to_agent IS NOT NULL
AND from_agent NOT IN (${humanPlaceholders})
AND to_agent NOT IN (${humanPlaceholders})
GROUP BY to_agent
) GROUP BY agent ORDER BY (sent + received) DESC
`
const statsParams = [...humanNames, ...humanNames, ...humanNames, ...humanNames]
const statsParams = [workspaceId, ...humanNames, ...humanNames, workspaceId, ...humanNames, ...humanNames]
const agentStats = db.prepare(statsQuery).all(...statsParams)
// 4. Total count
let countQuery = `
SELECT COUNT(*) as total FROM messages
WHERE to_agent IS NOT NULL
WHERE workspace_id = ?
AND to_agent IS NOT NULL
AND from_agent NOT IN (${humanPlaceholders})
AND to_agent NOT IN (${humanPlaceholders})
`
const countParams: any[] = [...humanNames, ...humanNames]
const countParams: any[] = [workspaceId, ...humanNames, ...humanNames]
if (since) {
countQuery += " AND created_at > ?"
countParams.push(parseInt(since))
@ -107,12 +111,13 @@ export async function GET(request: NextRequest) {
let seededCountQuery = `
SELECT COUNT(*) as seeded FROM messages
WHERE to_agent IS NOT NULL
WHERE workspace_id = ?
AND to_agent IS NOT NULL
AND from_agent NOT IN (${humanPlaceholders})
AND to_agent NOT IN (${humanPlaceholders})
AND conversation_id LIKE ?
`
const seededParams: any[] = [...humanNames, ...humanNames, "conv-multi-%"]
const seededParams: any[] = [workspaceId, ...humanNames, ...humanNames, "conv-multi-%"]
if (since) {
seededCountQuery += " AND created_at > ?"
seededParams.push(parseInt(since))

View File

@ -19,7 +19,10 @@ export async function POST(request: NextRequest) {
const { from, to, message } = result.data
const db = getDatabase()
const agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(to) as any
const workspaceId = auth.user.workspace_id ?? 1;
const agent = db
.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?')
.get(to, workspaceId) as any
if (!agent) {
return NextResponse.json({ error: 'Recipient agent not found' }, { status: 404 })
}
@ -48,7 +51,8 @@ export async function POST(request: NextRequest) {
'Direct Message',
`${from}: ${message.substring(0, 200)}${message.length > 200 ? '...' : ''}`,
'agent',
agent.id
agent.id,
workspaceId
)
db_helpers.logActivity(
@ -57,7 +61,8 @@ export async function POST(request: NextRequest) {
agent.id,
from,
`Sent message to ${to}`,
{ to }
{ to },
workspaceId
)
return NextResponse.json({ success: true })

View File

@ -4,7 +4,7 @@ import { eventBus } from '@/lib/event-bus';
import { getTemplate, buildAgentConfig } from '@/lib/agent-templates';
import { writeAgentToConfig, enrichAgentConfigFromWorkspace } from '@/lib/agent-sync';
import { logAuditEvent } from '@/lib/db';
import { getUserFromRequest, requireRole } from '@/lib/auth';
import { requireRole } from '@/lib/auth';
import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger';
import { validateBody, createAgentSchema } from '@/lib/validation';
@ -20,6 +20,7 @@ export async function GET(request: NextRequest) {
try {
const db = getDatabase();
const { searchParams } = new URL(request.url);
const workspaceId = auth.user.workspace_id ?? 1;
// Parse query parameters
const status = searchParams.get('status');
@ -28,8 +29,8 @@ export async function GET(request: NextRequest) {
const offset = parseInt(searchParams.get('offset') || '0');
// Build dynamic query
let query = 'SELECT * FROM agents WHERE 1=1';
const params: any[] = [];
let query = 'SELECT * FROM agents WHERE workspace_id = ?';
const params: any[] = [workspaceId];
if (status) {
query += ' AND status = ?';
@ -61,11 +62,11 @@ export async function GET(request: NextRequest) {
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as completed
FROM tasks
WHERE assigned_to = ?
WHERE assigned_to = ? AND workspace_id = ?
`);
const agentsWithStats = agentsWithParsedData.map(agent => {
const taskStats = taskCountStmt.get(agent.name) as any;
const taskStats = taskCountStmt.get(agent.name, workspaceId) as any;
return {
...agent,
@ -79,8 +80,8 @@ export async function GET(request: NextRequest) {
});
// Get total count for pagination
let countQuery = 'SELECT COUNT(*) as total FROM agents WHERE 1=1';
const countParams: any[] = [];
let countQuery = 'SELECT COUNT(*) as total FROM agents WHERE workspace_id = ?';
const countParams: any[] = [workspaceId];
if (status) {
countQuery += ' AND status = ?';
countParams.push(status);
@ -115,6 +116,7 @@ export async function POST(request: NextRequest) {
try {
const db = getDatabase();
const workspaceId = auth.user.workspace_id ?? 1;
const validated = await validateBody(request, createAgentSchema);
if ('error' in validated) return validated.error;
const body = validated.data;
@ -150,7 +152,9 @@ export async function POST(request: NextRequest) {
}
// Check if agent name already exists
const existingAgent = db.prepare('SELECT id FROM agents WHERE name = ?').get(name);
const existingAgent = db
.prepare('SELECT id FROM agents WHERE name = ? AND workspace_id = ?')
.get(name, workspaceId);
if (existingAgent) {
return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 });
}
@ -160,8 +164,8 @@ export async function POST(request: NextRequest) {
const stmt = db.prepare(`
INSERT INTO agents (
name, role, session_key, soul_content, status,
created_at, updated_at, config
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
created_at, updated_at, config, workspace_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const dbResult = stmt.run(
@ -172,7 +176,8 @@ export async function POST(request: NextRequest) {
status,
now,
now,
JSON.stringify(finalConfig)
JSON.stringify(finalConfig),
workspaceId
);
const agentId = dbResult.lastInsertRowid as number;
@ -182,7 +187,7 @@ export async function POST(request: NextRequest) {
'agent_created',
'agent',
agentId,
getUserFromRequest(request)?.username || 'system',
auth.user.username,
`Created agent: ${name} (${finalRole})${template ? ` from template: ${template}` : ''}`,
{
name,
@ -190,11 +195,14 @@ export async function POST(request: NextRequest) {
status,
session_key,
template: template || null
}
},
workspaceId
);
// Fetch the created agent
const createdAgent = db.prepare('SELECT * FROM agents WHERE id = ?').get(agentId) as Agent;
const createdAgent = db
.prepare('SELECT * FROM agents WHERE id = ? AND workspace_id = ?')
.get(agentId, workspaceId) as Agent;
const parsedAgent = {
...createdAgent,
config: JSON.parse(createdAgent.config || '{}'),
@ -222,7 +230,8 @@ export async function POST(request: NextRequest) {
const ipAddress = request.headers.get('x-forwarded-for') || 'unknown';
logAuditEvent({
action: 'agent_gateway_create',
actor: getUserFromRequest(request)?.username || 'system',
actor: auth.user.username,
actor_id: auth.user.id,
target_type: 'agent',
target_id: agentId as number,
detail: { name, openclaw_id: openclawId, template: template || null },
@ -256,6 +265,7 @@ export async function PUT(request: NextRequest) {
try {
const db = getDatabase();
const workspaceId = auth.user.workspace_id ?? 1;
const body = await request.json();
// Handle single agent update or bulk updates
@ -263,7 +273,9 @@ export async function PUT(request: NextRequest) {
// Single agent update
const { name, status, last_activity, config, session_key, soul_content, role } = body;
const agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(name) as Agent;
const agent = db
.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?')
.get(name, workspaceId) as Agent;
if (!agent) {
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
}
@ -309,7 +321,7 @@ export async function PUT(request: NextRequest) {
fieldsToUpdate.push('updated_at = ?');
params.push(now);
params.push(name);
params.push(name, workspaceId);
if (fieldsToUpdate.length === 1) { // Only updated_at
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
@ -318,7 +330,7 @@ export async function PUT(request: NextRequest) {
const stmt = db.prepare(`
UPDATE agents
SET ${fieldsToUpdate.join(', ')}
WHERE name = ?
WHERE name = ? AND workspace_id = ?
`);
stmt.run(...params);
@ -335,7 +347,8 @@ export async function PUT(request: NextRequest) {
oldStatus: agent.status,
newStatus: status,
last_activity
}
},
workspaceId
);
}

View File

@ -31,8 +31,11 @@ export async function GET(request: NextRequest) {
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
try {
const rules = db.prepare('SELECT * FROM alert_rules ORDER BY created_at DESC').all() as AlertRule[]
const rules = db
.prepare('SELECT * FROM alert_rules WHERE workspace_id = ? ORDER BY created_at DESC')
.all(workspaceId) as AlertRule[]
return NextResponse.json({ rules })
} catch {
return NextResponse.json({ rules: [] })
@ -50,6 +53,7 @@ export async function POST(request: NextRequest) {
if (rateCheck) return rateCheck
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
// Check for evaluate action first (peek at body without consuming)
let rawBody: any
@ -58,7 +62,7 @@ export async function POST(request: NextRequest) {
}
if (rawBody.action === 'evaluate') {
return evaluateRules(db)
return evaluateRules(db, workspaceId)
}
// Validate for create using schema
@ -73,8 +77,8 @@ export async function POST(request: NextRequest) {
try {
const result = db.prepare(`
INSERT INTO alert_rules (name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO alert_rules (name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes, created_by, workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
name,
description || null,
@ -85,7 +89,8 @@ export async function POST(request: NextRequest) {
action_type || 'notification',
JSON.stringify(action_config || {}),
cooldown_minutes || 60,
auth.user?.username || 'system'
auth.user?.username || 'system',
workspaceId
)
// Audit log
@ -97,7 +102,9 @@ export async function POST(request: NextRequest) {
)
} catch { /* audit table might not exist */ }
const rule = db.prepare('SELECT * FROM alert_rules WHERE id = ?').get(result.lastInsertRowid) as AlertRule
const rule = db
.prepare('SELECT * FROM alert_rules WHERE id = ? AND workspace_id = ?')
.get(result.lastInsertRowid, workspaceId) as AlertRule
return NextResponse.json({ rule }, { status: 201 })
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to create rule' }, { status: 500 })
@ -115,12 +122,15 @@ export async function PUT(request: NextRequest) {
if (rateCheck) return rateCheck
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const body = await request.json()
const { id, ...updates } = body
if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 })
const existing = db.prepare('SELECT * FROM alert_rules WHERE id = ?').get(id) as AlertRule | undefined
const existing = db
.prepare('SELECT * FROM alert_rules WHERE id = ? AND workspace_id = ?')
.get(id, workspaceId) as AlertRule | undefined
if (!existing) return NextResponse.json({ error: 'Rule not found' }, { status: 404 })
const allowed = ['name', 'description', 'enabled', 'entity_type', 'condition_field', 'condition_operator', 'condition_value', 'action_type', 'action_config', 'cooldown_minutes']
@ -137,11 +147,13 @@ export async function PUT(request: NextRequest) {
if (sets.length === 0) return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
sets.push('updated_at = (unixepoch())')
values.push(id)
values.push(id, workspaceId)
db.prepare(`UPDATE alert_rules SET ${sets.join(', ')} WHERE id = ?`).run(...values)
db.prepare(`UPDATE alert_rules SET ${sets.join(', ')} WHERE id = ? AND workspace_id = ?`).run(...values)
const updated = db.prepare('SELECT * FROM alert_rules WHERE id = ?').get(id) as AlertRule
const updated = db
.prepare('SELECT * FROM alert_rules WHERE id = ? AND workspace_id = ?')
.get(id, workspaceId) as AlertRule
return NextResponse.json({ rule: updated })
}
@ -156,12 +168,13 @@ export async function DELETE(request: NextRequest) {
if (rateCheck) return rateCheck
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const body = await request.json()
const { id } = body
if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 })
const result = db.prepare('DELETE FROM alert_rules WHERE id = ?').run(id)
const result = db.prepare('DELETE FROM alert_rules WHERE id = ? AND workspace_id = ?').run(id, workspaceId)
try {
db.prepare('INSERT INTO audit_log (action, actor, detail) VALUES (?, ?, ?)').run(
@ -177,10 +190,10 @@ export async function DELETE(request: NextRequest) {
/**
* Evaluate all enabled alert rules against current data
*/
function evaluateRules(db: ReturnType<typeof getDatabase>) {
function evaluateRules(db: ReturnType<typeof getDatabase>, workspaceId: number) {
let rules: AlertRule[]
try {
rules = db.prepare('SELECT * FROM alert_rules WHERE enabled = 1').all() as AlertRule[]
rules = db.prepare('SELECT * FROM alert_rules WHERE enabled = 1 AND workspace_id = ?').all(workspaceId) as AlertRule[]
} catch {
return NextResponse.json({ evaluated: 0, triggered: 0, results: [] })
}
@ -195,7 +208,7 @@ function evaluateRules(db: ReturnType<typeof getDatabase>) {
continue
}
const triggered = evaluateRule(db, rule, now)
const triggered = evaluateRule(db, rule, now, workspaceId)
results.push({ rule_id: rule.id, rule_name: rule.name, triggered, reason: triggered ? 'Condition met' : 'Condition not met' })
if (triggered) {
@ -207,9 +220,9 @@ function evaluateRules(db: ReturnType<typeof getDatabase>) {
const config = JSON.parse(rule.action_config || '{}')
const recipient = config.recipient || 'system'
db.prepare(`
INSERT INTO notifications (recipient, type, title, message, source_type, source_id)
VALUES (?, 'alert', ?, ?, 'alert_rule', ?)
`).run(recipient, `Alert: ${rule.name}`, rule.description || `Rule "${rule.name}" triggered`, rule.id)
INSERT INTO notifications (recipient, type, title, message, source_type, source_id, workspace_id)
VALUES (?, 'alert', ?, ?, 'alert_rule', ?, ?)
`).run(recipient, `Alert: ${rule.name}`, rule.description || `Rule "${rule.name}" triggered`, rule.id, workspaceId)
} catch { /* notification creation failed */ }
}
}
@ -218,13 +231,13 @@ function evaluateRules(db: ReturnType<typeof getDatabase>) {
return NextResponse.json({ evaluated: rules.length, triggered, results })
}
function evaluateRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number): boolean {
function evaluateRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number, workspaceId: number): boolean {
try {
switch (rule.entity_type) {
case 'agent': return evaluateAgentRule(db, rule, now)
case 'task': return evaluateTaskRule(db, rule, now)
case 'session': return evaluateSessionRule(db, rule, now)
case 'activity': return evaluateActivityRule(db, rule, now)
case 'agent': return evaluateAgentRule(db, rule, now, workspaceId)
case 'task': return evaluateTaskRule(db, rule, now, workspaceId)
case 'session': return evaluateSessionRule(db, rule, now, workspaceId)
case 'activity': return evaluateActivityRule(db, rule, now, workspaceId)
default: return false
}
} catch {
@ -232,61 +245,61 @@ function evaluateRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now:
}
}
function evaluateAgentRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number): boolean {
function evaluateAgentRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number, workspaceId: number): boolean {
const { condition_field, condition_operator, condition_value } = rule
if (condition_operator === 'count_above' || condition_operator === 'count_below') {
const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE ${safeColumn('agents', condition_field)} = ?`).get(condition_value) as any)?.c || 0
const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE workspace_id = ? AND ${safeColumn('agents', condition_field)} = ?`).get(workspaceId, condition_value) as any)?.c || 0
return condition_operator === 'count_above' ? count > parseInt(condition_value) : count < parseInt(condition_value)
}
if (condition_operator === 'age_minutes_above') {
// Check agents where field value is older than N minutes (e.g., last_seen)
const threshold = now - parseInt(condition_value) * 60
const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND ${safeColumn('agents', condition_field)} < ?`).get(threshold) as any)?.c || 0
const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE workspace_id = ? AND status != 'offline' AND ${safeColumn('agents', condition_field)} < ?`).get(workspaceId, threshold) as any)?.c || 0
return count > 0
}
const agents = db.prepare(`SELECT ${safeColumn('agents', condition_field)} as val FROM agents WHERE status != 'offline'`).all() as any[]
const agents = db.prepare(`SELECT ${safeColumn('agents', condition_field)} as val FROM agents WHERE workspace_id = ? AND status != 'offline'`).all(workspaceId) as any[]
return agents.some(a => compareValue(a.val, condition_operator, condition_value))
}
function evaluateTaskRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, _now: number): boolean {
function evaluateTaskRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, _now: number, workspaceId: number): boolean {
const { condition_field, condition_operator, condition_value } = rule
if (condition_operator === 'count_above') {
const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks WHERE ${safeColumn('tasks', condition_field)} = ?`).get(condition_value) as any)?.c || 0
const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks WHERE workspace_id = ? AND ${safeColumn('tasks', condition_field)} = ?`).get(workspaceId, condition_value) as any)?.c || 0
return count > parseInt(condition_value)
}
if (condition_operator === 'count_below') {
const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks`).get() as any)?.c || 0
const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks WHERE workspace_id = ?`).get(workspaceId) as any)?.c || 0
return count < parseInt(condition_value)
}
const tasks = db.prepare(`SELECT ${safeColumn('tasks', condition_field)} as val FROM tasks`).all() as any[]
const tasks = db.prepare(`SELECT ${safeColumn('tasks', condition_field)} as val FROM tasks WHERE workspace_id = ?`).all(workspaceId) as any[]
return tasks.some(t => compareValue(t.val, condition_operator, condition_value))
}
function evaluateSessionRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, _now: number): boolean {
function evaluateSessionRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, _now: number, workspaceId: number): boolean {
// Session data comes from the gateway, not the DB, so we check the agents table for session info
const { condition_operator, condition_value } = rule
if (condition_operator === 'count_above') {
const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE status = 'busy'`).get() as any)?.c || 0
const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE workspace_id = ? AND status = 'busy'`).get(workspaceId) as any)?.c || 0
return count > parseInt(condition_value)
}
return false
}
function evaluateActivityRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number): boolean {
function evaluateActivityRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number, workspaceId: number): boolean {
const { condition_field, condition_operator, condition_value } = rule
if (condition_operator === 'count_above') {
// Count activities in the last hour
const hourAgo = now - 3600
const count = (db.prepare(`SELECT COUNT(*) as c FROM activities WHERE created_at > ? AND ${safeColumn('activities', condition_field)} = ?`).get(hourAgo, condition_value) as any)?.c || 0
const count = (db.prepare(`SELECT COUNT(*) as c FROM activities WHERE workspace_id = ? AND created_at > ? AND ${safeColumn('activities', condition_field)} = ?`).get(workspaceId, hourAgo, condition_value) as any)?.c || 0
return count > parseInt(condition_value)
}

View File

@ -38,7 +38,7 @@ export async function POST(request: Request) {
const avatar = profile.picture ? String(profile.picture) : null
const row = db.prepare(`
SELECT id, username, display_name, role, provider, email, avatar_url, is_approved, created_at, updated_at, last_login_at
SELECT id, username, display_name, role, provider, email, avatar_url, is_approved, created_at, updated_at, last_login_at, workspace_id
FROM users
WHERE (provider = 'google' AND provider_user_id = ?) OR lower(email) = ?
ORDER BY id ASC
@ -76,7 +76,7 @@ export async function POST(request: Request) {
WHERE id = ?
`).run(sub, email, avatar, row.id)
const { token, expiresAt } = createSession(row.id, ipAddress, userAgent)
const { token, expiresAt } = createSession(row.id, ipAddress, userAgent, row.workspace_id ?? 1)
logAuditEvent({ action: 'login_google', actor: row.username, actor_id: row.id, ip_address: ipAddress, user_agent: userAgent })
@ -89,6 +89,7 @@ export async function POST(request: Request) {
provider: 'google',
email,
avatar_url: avatar,
workspace_id: row.workspace_id ?? 1,
},
})

View File

@ -25,7 +25,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
const { token, expiresAt } = createSession(user.id, ipAddress, userAgent)
const { token, expiresAt } = createSession(user.id, ipAddress, userAgent, user.workspace_id)
logAuditEvent({ action: 'login', actor: user.username, actor_id: user.id, ip_address: ipAddress, user_agent: userAgent })
@ -38,6 +38,7 @@ export async function POST(request: Request) {
provider: user.provider || 'local',
email: user.email || null,
avatar_url: user.avatar_url || null,
workspace_id: user.workspace_id ?? 1,
},
})

View File

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { getUserFromRequest, updateUser , requireRole } from '@/lib/auth'
import { getUserFromRequest, updateUser, requireRole } from '@/lib/auth'
import { logAuditEvent } from '@/lib/db'
import { verifyPassword } from '@/lib/password'
import { logger } from '@/lib/logger'
@ -23,6 +23,7 @@ export async function GET(request: Request) {
provider: user.provider || 'local',
email: user.email || null,
avatar_url: user.avatar_url || null,
workspace_id: user.workspace_id ?? 1,
},
})
}
@ -103,6 +104,7 @@ export async function PATCH(request: NextRequest) {
provider: updated.provider || 'local',
email: updated.email || null,
avatar_url: updated.avatar_url || null,
workspace_id: updated.workspace_id ?? 1,
},
})
} catch (error) {

View File

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { getUserFromRequest, getAllUsers, createUser, updateUser, deleteUser , requireRole } from '@/lib/auth'
import { getUserFromRequest, getAllUsers, createUser, updateUser, deleteUser, getUserById, requireRole } from '@/lib/auth'
import { logAuditEvent } from '@/lib/db'
import { validateBody, createUserSchema } from '@/lib/validation'
import { mutationLimiter } from '@/lib/rate-limit'
@ -18,7 +18,8 @@ export async function GET(request: NextRequest) {
}
const users = getAllUsers()
return NextResponse.json({ users })
const workspaceId = user.workspace_id ?? 1
return NextResponse.json({ users: users.filter((u) => (u.workspace_id ?? 1) === workspaceId) })
}
/**
@ -38,7 +39,12 @@ export async function POST(request: NextRequest) {
if ('error' in result) return result.error
const { username, password, display_name, role, provider, email } = result.data
const newUser = createUser(username, password, display_name || username, role, { provider, email: email || null })
const workspaceId = currentUser.workspace_id ?? 1
const newUser = createUser(username, password, display_name || username, role, {
provider,
email: email || null,
workspace_id: workspaceId,
})
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
logAuditEvent({
@ -57,6 +63,7 @@ export async function POST(request: NextRequest) {
email: newUser.email || null,
avatar_url: newUser.avatar_url || null,
is_approved: newUser.is_approved ?? 1,
workspace_id: newUser.workspace_id ?? 1,
}
}, { status: 201 })
} catch (error: any) {
@ -79,8 +86,9 @@ export async function PUT(request: NextRequest) {
try {
const { id, display_name, role, password, is_approved, email, avatar_url } = await request.json()
const userId = parseInt(String(id))
if (!id) {
if (!id || Number.isNaN(userId)) {
return NextResponse.json({ error: 'User ID is required' }, { status: 400 })
}
@ -89,11 +97,17 @@ export async function PUT(request: NextRequest) {
}
// Prevent demoting yourself
if (id === currentUser.id && role && role !== currentUser.role) {
if (userId === currentUser.id && role && role !== currentUser.role) {
return NextResponse.json({ error: 'Cannot change your own role' }, { status: 400 })
}
const updated = updateUser(id, { display_name, role, password: password || undefined, is_approved, email, avatar_url })
const workspaceId = currentUser.workspace_id ?? 1
const existing = getUserById(userId)
if (!existing || (existing.workspace_id ?? 1) !== workspaceId) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
const updated = updateUser(userId, { display_name, role, password: password || undefined, is_approved, email, avatar_url })
if (!updated) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
@ -101,7 +115,7 @@ export async function PUT(request: NextRequest) {
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
logAuditEvent({
action: 'user_update', actor: currentUser.username, actor_id: currentUser.id,
target_type: 'user', target_id: id,
target_type: 'user', target_id: userId,
detail: { display_name, role, password_changed: !!password, is_approved }, ip_address: ipAddress,
})
@ -115,6 +129,7 @@ export async function PUT(request: NextRequest) {
email: updated.email || null,
avatar_url: updated.avatar_url || null,
is_approved: updated.is_approved ?? 1,
workspace_id: updated.workspace_id ?? 1,
}
})
} catch (error) {
@ -147,6 +162,12 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 400 })
}
const workspaceId = currentUser.workspace_id ?? 1
const existing = getUserById(userId)
if (!existing || (existing.workspace_id ?? 1) !== workspaceId) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
const deleted = deleteUser(userId)
if (!deleted) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })

View File

@ -14,6 +14,7 @@ export async function GET(request: NextRequest) {
try {
const db = getDatabase()
const { searchParams } = new URL(request.url)
const workspaceId = auth.user.workspace_id ?? 1
const agent = searchParams.get('agent')
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200)
@ -32,12 +33,12 @@ export async function GET(request: NextRequest) {
COUNT(DISTINCT m.from_agent) + COUNT(DISTINCT CASE WHEN m.to_agent IS NOT NULL THEN m.to_agent END) as participant_count,
SUM(CASE WHEN m.to_agent = ? AND m.read_at IS NULL THEN 1 ELSE 0 END) as unread_count
FROM messages m
WHERE m.from_agent = ? OR m.to_agent = ? OR m.to_agent IS NULL
WHERE m.workspace_id = ? AND (m.from_agent = ? OR m.to_agent = ? OR m.to_agent IS NULL)
GROUP BY m.conversation_id
ORDER BY last_message_at DESC
LIMIT ? OFFSET ?
`
params.push(agent, agent, agent, limit, offset)
params.push(agent, workspaceId, agent, agent, limit, offset)
} else {
query = `
SELECT
@ -47,11 +48,12 @@ export async function GET(request: NextRequest) {
COUNT(DISTINCT m.from_agent) + COUNT(DISTINCT CASE WHEN m.to_agent IS NOT NULL THEN m.to_agent END) as participant_count,
0 as unread_count
FROM messages m
WHERE m.workspace_id = ?
GROUP BY m.conversation_id
ORDER BY last_message_at DESC
LIMIT ? OFFSET ?
`
params.push(limit, offset)
params.push(workspaceId, limit, offset)
}
const conversations = db.prepare(query).all(...params) as any[]
@ -59,13 +61,13 @@ export async function GET(request: NextRequest) {
// Prepare last message statement once (avoids N+1)
const lastMsgStmt = db.prepare(`
SELECT * FROM messages
WHERE conversation_id = ?
WHERE conversation_id = ? AND workspace_id = ?
ORDER BY created_at DESC
LIMIT 1
`);
const withLastMessage = conversations.map((conv) => {
const lastMsg = lastMsgStmt.get(conv.conversation_id) as any;
const lastMsg = lastMsgStmt.get(conv.conversation_id, workspaceId) as any;
return {
...conv,
@ -80,16 +82,16 @@ export async function GET(request: NextRequest) {
// Get total count for pagination
let countQuery: string
const countParams: any[] = []
const countParams: any[] = [workspaceId]
if (agent) {
countQuery = `
SELECT COUNT(DISTINCT m.conversation_id) as total
FROM messages m
WHERE m.from_agent = ? OR m.to_agent = ? OR m.to_agent IS NULL
WHERE m.workspace_id = ? AND (m.from_agent = ? OR m.to_agent = ? OR m.to_agent IS NULL)
`
countParams.push(agent, agent)
} else {
countQuery = 'SELECT COUNT(DISTINCT conversation_id) as total FROM messages'
countQuery = 'SELECT COUNT(DISTINCT conversation_id) as total FROM messages WHERE workspace_id = ?'
}
const countRow = db.prepare(countQuery).get(...countParams) as { total: number }

View File

@ -16,8 +16,11 @@ export async function GET(
try {
const db = getDatabase()
const { id } = await params
const workspaceId = auth.user.workspace_id ?? 1
const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(parseInt(id)) as Message | undefined
const message = db
.prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?')
.get(parseInt(id), workspaceId) as Message | undefined
if (!message) {
return NextResponse.json({ error: 'Message not found' }, { status: 404 })
@ -48,9 +51,12 @@ export async function PATCH(
try {
const db = getDatabase()
const { id } = await params
const workspaceId = auth.user.workspace_id ?? 1
const body = await request.json()
const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(parseInt(id)) as Message | undefined
const message = db
.prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?')
.get(parseInt(id), workspaceId) as Message | undefined
if (!message) {
return NextResponse.json({ error: 'Message not found' }, { status: 404 })
@ -58,10 +64,12 @@ export async function PATCH(
if (body.read) {
const now = Math.floor(Date.now() / 1000)
db.prepare('UPDATE messages SET read_at = ? WHERE id = ?').run(now, parseInt(id))
db.prepare('UPDATE messages SET read_at = ? WHERE id = ? AND workspace_id = ?').run(now, parseInt(id), workspaceId)
}
const updated = db.prepare('SELECT * FROM messages WHERE id = ?').get(parseInt(id)) as Message
const updated = db
.prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?')
.get(parseInt(id), workspaceId) as Message
return NextResponse.json({
message: {

View File

@ -33,6 +33,7 @@ function parseGatewayJson(raw: string): any | null {
function createChatReply(
db: ReturnType<typeof getDatabase>,
workspaceId: number,
conversationId: string,
fromAgent: string,
toAgent: string,
@ -42,8 +43,8 @@ function createChatReply(
) {
const replyInsert = db
.prepare(`
INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata, workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
.run(
conversationId,
@ -51,12 +52,13 @@ function createChatReply(
toAgent,
content,
messageType,
metadata ? JSON.stringify(metadata) : null
metadata ? JSON.stringify(metadata) : null,
workspaceId
)
const row = db
.prepare('SELECT * FROM messages WHERE id = ?')
.get(replyInsert.lastInsertRowid) as Message
.prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?')
.get(replyInsert.lastInsertRowid, workspaceId) as Message
eventBus.broadcast('chat.message', {
...row,
@ -102,6 +104,7 @@ export async function GET(request: NextRequest) {
try {
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const { searchParams } = new URL(request.url)
const conversation_id = searchParams.get('conversation_id')
@ -111,8 +114,8 @@ export async function GET(request: NextRequest) {
const offset = parseInt(searchParams.get('offset') || '0')
const since = searchParams.get('since')
let query = 'SELECT * FROM messages WHERE 1=1'
const params: any[] = []
let query = 'SELECT * FROM messages WHERE workspace_id = ?'
const params: any[] = [workspaceId]
if (conversation_id) {
query += ' AND conversation_id = ?'
@ -145,8 +148,8 @@ export async function GET(request: NextRequest) {
}))
// Get total count for pagination
let countQuery = 'SELECT COUNT(*) as total FROM messages WHERE 1=1'
const countParams: any[] = []
let countQuery = 'SELECT COUNT(*) as total FROM messages WHERE workspace_id = ?'
const countParams: any[] = [workspaceId]
if (conversation_id) {
countQuery += ' AND conversation_id = ?'
countParams.push(conversation_id)
@ -182,6 +185,7 @@ export async function POST(request: NextRequest) {
try {
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const body = await request.json()
const from = (body.from || '').trim()
@ -199,8 +203,8 @@ export async function POST(request: NextRequest) {
}
const stmt = db.prepare(`
INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata, workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
const result = stmt.run(
@ -209,7 +213,8 @@ export async function POST(request: NextRequest) {
to,
content,
message_type,
metadata ? JSON.stringify(metadata) : null
metadata ? JSON.stringify(metadata) : null,
workspaceId
)
const messageId = result.lastInsertRowid as number
@ -223,7 +228,8 @@ export async function POST(request: NextRequest) {
messageId,
from,
`Sent ${message_type} message${to ? ` to ${to}` : ' (broadcast)'}`,
{ conversation_id, to, message_type }
{ conversation_id, to, message_type },
workspaceId
)
// Create notification for recipient if specified
@ -234,7 +240,8 @@ export async function POST(request: NextRequest) {
`Message from ${from}`,
content.substring(0, 200) + (content.length > 200 ? '...' : ''),
'message',
messageId
messageId,
workspaceId
)
// Optionally forward to agent via gateway
@ -242,8 +249,8 @@ export async function POST(request: NextRequest) {
forwardInfo = { attempted: true, delivered: false }
const agent = db
.prepare('SELECT * FROM agents WHERE lower(name) = lower(?)')
.get(to) as any
.prepare('SELECT * FROM agents WHERE lower(name) = lower(?) AND workspace_id = ?')
.get(to, workspaceId) as any
let sessionKey: string | null = agent?.session_key || null
@ -280,6 +287,7 @@ export async function POST(request: NextRequest) {
try {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
@ -340,6 +348,7 @@ export async function POST(request: NextRequest) {
try {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
@ -363,6 +372,7 @@ export async function POST(request: NextRequest) {
try {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
@ -401,6 +411,7 @@ export async function POST(request: NextRequest) {
: 'Unknown runtime error'
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
@ -411,6 +422,7 @@ export async function POST(request: NextRequest) {
} else if (waitStatus === 'timeout') {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
@ -423,6 +435,7 @@ export async function POST(request: NextRequest) {
if (replyText) {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
@ -433,6 +446,7 @@ export async function POST(request: NextRequest) {
} else {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
@ -453,6 +467,7 @@ export async function POST(request: NextRequest) {
createChatReply(
db,
workspaceId,
conversation_id,
COORDINATOR_AGENT,
from,
@ -467,7 +482,7 @@ export async function POST(request: NextRequest) {
}
}
const created = db.prepare('SELECT * FROM messages WHERE id = ?').get(messageId) as Message
const created = db.prepare('SELECT * FROM messages WHERE id = ? AND workspace_id = ?').get(messageId, workspaceId) as Message
const parsedMessage = {
...created,
metadata: created.metadata ? JSON.parse(created.metadata) : null

View File

@ -21,22 +21,23 @@ export async function POST(request: NextRequest) {
const { tool_name, tool_version, agent_name, agent_role, metadata } = validation.data
const db = getDatabase()
const now = Math.floor(Date.now() / 1000)
const workspaceId = auth.user.workspace_id ?? 1;
// Find or create agent
let agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agent_name) as any
let agent = db.prepare('SELECT * FROM agents WHERE name = ? AND workspace_id = ?').get(agent_name, workspaceId) as any
if (!agent) {
const result = db.prepare(
`INSERT INTO agents (name, role, status, created_at, updated_at)
VALUES (?, ?, 'online', ?, ?)`
).run(agent_name, agent_role || 'cli', now, now)
`INSERT INTO agents (name, role, status, created_at, updated_at, workspace_id)
VALUES (?, ?, 'online', ?, ?, ?)`
).run(agent_name, agent_role || 'cli', now, now, workspaceId)
agent = { id: result.lastInsertRowid, name: agent_name }
db_helpers.logActivity('agent_created', 'agent', agent.id as number, 'system',
`Auto-created agent "${agent_name}" via direct CLI connection`)
`Auto-created agent "${agent_name}" via direct CLI connection`, undefined, workspaceId)
eventBus.broadcast('agent.created', { id: agent.id, name: agent_name })
} else {
// Set agent online
db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ?')
.run('online', now, agent.id)
db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ? AND workspace_id = ?')
.run('online', now, agent.id, workspaceId)
eventBus.broadcast('agent.status_changed', { id: agent.id, name: agent.name, status: 'online' })
}
@ -48,12 +49,12 @@ export async function POST(request: NextRequest) {
// Create new connection
const connectionId = randomUUID()
db.prepare(
`INSERT INTO direct_connections (agent_id, tool_name, tool_version, connection_id, status, last_heartbeat, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, 'connected', ?, ?, ?, ?)`
).run(agent.id, tool_name, tool_version || null, connectionId, now, metadata ? JSON.stringify(metadata) : null, now, now)
`INSERT INTO direct_connections (agent_id, tool_name, tool_version, connection_id, status, last_heartbeat, metadata, created_at, updated_at, workspace_id)
VALUES (?, ?, ?, ?, 'connected', ?, ?, ?, ?, ?)`
).run(agent.id, tool_name, tool_version || null, connectionId, now, metadata ? JSON.stringify(metadata) : null, now, now, workspaceId)
db_helpers.logActivity('connection_created', 'agent', agent.id as number, agent_name,
`CLI connection established via ${tool_name}${tool_version ? ` v${tool_version}` : ''}`)
`CLI connection established via ${tool_name}${tool_version ? ` v${tool_version}` : ''}`, undefined, workspaceId)
eventBus.broadcast('connection.created', {
connection_id: connectionId,
@ -81,12 +82,14 @@ export async function GET(request: NextRequest) {
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1;
const connections = db.prepare(`
SELECT dc.*, a.name as agent_name, a.status as agent_status, a.role as agent_role
FROM direct_connections dc
JOIN agents a ON dc.agent_id = a.id
WHERE a.workspace_id = ?
ORDER BY dc.created_at DESC
`).all()
`).all(workspaceId)
return NextResponse.json({ connections })
}
@ -112,8 +115,14 @@ export async function DELETE(request: NextRequest) {
const db = getDatabase()
const now = Math.floor(Date.now() / 1000)
const workspaceId = auth.user.workspace_id ?? 1;
const conn = db.prepare('SELECT * FROM direct_connections WHERE connection_id = ?').get(connection_id) as any
const conn = db.prepare(`
SELECT dc.*
FROM direct_connections dc
JOIN agents a ON a.id = dc.agent_id
WHERE dc.connection_id = ? AND a.workspace_id = ?
`).get(connection_id, workspaceId) as any
if (!conn) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
@ -126,13 +135,13 @@ export async function DELETE(request: NextRequest) {
'SELECT COUNT(*) as count FROM direct_connections WHERE agent_id = ? AND status = ? AND connection_id != ?'
).get(conn.agent_id, 'connected', connection_id) as any
if (!otherActive?.count) {
db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ?')
.run('offline', now, conn.agent_id)
db.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE id = ? AND workspace_id = ?')
.run('offline', now, conn.agent_id, workspaceId)
}
const agent = db.prepare('SELECT name FROM agents WHERE id = ?').get(conn.agent_id) as any
const agent = db.prepare('SELECT name FROM agents WHERE id = ? AND workspace_id = ?').get(conn.agent_id, workspaceId) as any
db_helpers.logActivity('connection_disconnected', 'agent', conn.agent_id, agent?.name || 'unknown',
`CLI connection disconnected (${conn.tool_name})`)
`CLI connection disconnected (${conn.tool_name})`, undefined, workspaceId)
eventBus.broadcast('connection.disconnected', {
connection_id,

View File

@ -308,7 +308,7 @@ export async function POST(request: NextRequest) {
}
if (action === 'add') {
const { schedule, command, description } = body
const { schedule, command, model, description } = body
const name = jobName || body.name
if (!schedule || !command || !name) {
return NextResponse.json(
@ -336,6 +336,7 @@ export async function POST(request: NextRequest) {
payload: {
kind: 'agentTurn',
message: command,
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
},
delivery: {
mode: 'none',

View File

@ -28,6 +28,7 @@ export async function GET(request: NextRequest) {
}
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const conditions: string[] = []
const params: any[] = []
@ -58,13 +59,19 @@ export async function GET(request: NextRequest) {
break
}
case 'tasks': {
rows = db.prepare(`SELECT * FROM tasks ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
conditions.unshift('workspace_id = ?')
params.unshift(workspaceId)
const scopedWhere = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
rows = db.prepare(`SELECT * FROM tasks ${scopedWhere} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
headers = ['id', 'title', 'description', 'status', 'priority', 'assigned_to', 'created_by', 'created_at', 'updated_at', 'due_date', 'estimated_hours', 'actual_hours', 'tags']
filename = 'tasks'
break
}
case 'activities': {
rows = db.prepare(`SELECT * FROM activities ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
conditions.unshift('workspace_id = ?')
params.unshift(workspaceId)
const scopedWhere = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
rows = db.prepare(`SELECT * FROM activities ${scopedWhere} ORDER BY created_at DESC LIMIT ?`).all(...params, limit)
headers = ['id', 'type', 'entity_type', 'entity_id', 'actor', 'description', 'data', 'created_at']
filename = 'activities'
break

View File

@ -76,13 +76,13 @@ export async function POST(request: NextRequest) {
try {
switch (action) {
case 'sync':
return await handleSync(body, auth.user.username)
return await handleSync(body, auth.user.username, auth.user.workspace_id ?? 1)
case 'comment':
return await handleComment(body, auth.user.username)
return await handleComment(body, auth.user.username, auth.user.workspace_id ?? 1)
case 'close':
return await handleClose(body, auth.user.username)
return await handleClose(body, auth.user.username, auth.user.workspace_id ?? 1)
case 'status':
return handleStatus()
return handleStatus(auth.user.workspace_id ?? 1)
default:
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
}
@ -96,7 +96,8 @@ export async function POST(request: NextRequest) {
async function handleSync(
body: { repo?: string; labels?: string; state?: 'open' | 'closed' | 'all'; assignAgent?: string },
actor: string
actor: string,
workspaceId: number
) {
const repo = body.repo || process.env.GITHUB_DEFAULT_REPO
if (!repo) {
@ -128,7 +129,8 @@ async function handleSync(
SELECT id FROM tasks
WHERE json_extract(metadata, '$.github_repo') = ?
AND json_extract(metadata, '$.github_issue_number') = ?
`).get(repo, issue.number) as { id: number } | undefined
AND workspace_id = ?
`).get(repo, issue.number, workspaceId) as { id: number } | undefined
if (existing) {
skipped++
@ -151,8 +153,8 @@ async function handleSync(
const stmt = db.prepare(`
INSERT INTO tasks (
title, description, status, priority, assigned_to, created_by,
created_at, updated_at, tags, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
created_at, updated_at, tags, metadata, workspace_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
const dbResult = stmt.run(
@ -165,7 +167,8 @@ async function handleSync(
now,
now,
JSON.stringify(tags),
JSON.stringify(metadata)
JSON.stringify(metadata),
workspaceId
)
const taskId = dbResult.lastInsertRowid as number
@ -176,10 +179,11 @@ async function handleSync(
taskId,
actor,
`Imported from GitHub: ${repo}#${issue.number}`,
{ github_issue: issue.number, github_repo: repo }
{ github_issue: issue.number, github_repo: repo },
workspaceId
)
const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task
const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId) as Task
const parsedTask = {
...createdTask,
tags: JSON.parse(createdTask.tags || '[]'),
@ -196,16 +200,33 @@ async function handleSync(
}
// Log sync to github_syncs table
db.prepare(`
INSERT INTO github_syncs (repo, last_synced_at, issue_count, sync_direction, status, error)
VALUES (?, ?, ?, 'inbound', ?, ?)
`).run(
repo,
now,
imported,
errors > 0 ? 'partial' : 'success',
errors > 0 ? `${errors} issues failed to import` : null
)
const syncTableHasWorkspace = db
.prepare("SELECT 1 as ok FROM pragma_table_info('github_syncs') WHERE name = 'workspace_id'")
.get() as { ok?: number } | undefined
if (syncTableHasWorkspace?.ok) {
db.prepare(`
INSERT INTO github_syncs (repo, last_synced_at, issue_count, sync_direction, status, error, workspace_id)
VALUES (?, ?, ?, 'inbound', ?, ?, ?)
`).run(
repo,
now,
imported,
errors > 0 ? 'partial' : 'success',
errors > 0 ? `${errors} issues failed to import` : null,
workspaceId
)
} else {
db.prepare(`
INSERT INTO github_syncs (repo, last_synced_at, issue_count, sync_direction, status, error)
VALUES (?, ?, ?, 'inbound', ?, ?)
`).run(
repo,
now,
imported,
errors > 0 ? 'partial' : 'success',
errors > 0 ? `${errors} issues failed to import` : null
)
}
eventBus.broadcast('github.synced', {
repo,
@ -227,7 +248,8 @@ async function handleSync(
async function handleComment(
body: { repo?: string; issueNumber?: number; body?: string },
actor: string
actor: string,
workspaceId: number
) {
if (!body.repo || !body.issueNumber || !body.body) {
return NextResponse.json(
@ -244,7 +266,8 @@ async function handleComment(
0,
actor,
`Commented on ${body.repo}#${body.issueNumber}`,
{ github_repo: body.repo, github_issue: body.issueNumber }
{ github_repo: body.repo, github_issue: body.issueNumber },
workspaceId
)
return NextResponse.json({ ok: true })
@ -254,7 +277,8 @@ async function handleComment(
async function handleClose(
body: { repo?: string; issueNumber?: number; comment?: string },
actor: string
actor: string,
workspaceId: number
) {
if (!body.repo || !body.issueNumber) {
return NextResponse.json(
@ -279,7 +303,8 @@ async function handleClose(
updated_at = ?
WHERE json_extract(metadata, '$.github_repo') = ?
AND json_extract(metadata, '$.github_issue_number') = ?
`).run(now, body.repo, body.issueNumber)
AND workspace_id = ?
`).run(now, body.repo, body.issueNumber, workspaceId)
db_helpers.logActivity(
'github_close',
@ -287,7 +312,8 @@ async function handleClose(
0,
actor,
`Closed GitHub issue ${body.repo}#${body.issueNumber}`,
{ github_repo: body.repo, github_issue: body.issueNumber }
{ github_repo: body.repo, github_issue: body.issueNumber },
workspaceId
)
return NextResponse.json({ ok: true })
@ -295,13 +321,17 @@ async function handleClose(
// ── Status: return recent sync history ──────────────────────────
function handleStatus() {
function handleStatus(workspaceId: number) {
const db = getDatabase()
const tableHasWorkspace = db
.prepare("SELECT 1 as ok FROM pragma_table_info('github_syncs') WHERE name = 'workspace_id'")
.get() as { ok?: number } | undefined
const syncs = db.prepare(`
SELECT * FROM github_syncs
${tableHasWorkspace?.ok ? 'WHERE workspace_id = ?' : ''}
ORDER BY created_at DESC
LIMIT 20
`).all()
`).all(...(tableHasWorkspace?.ok ? [workspaceId] : []))
return NextResponse.json({ syncs })
}

View File

@ -17,6 +17,7 @@ export async function POST(request: NextRequest) {
try {
const db = getDatabase();
const body = await request.json();
const workspaceId = auth.user.workspace_id ?? 1;
const {
agent_filter, // Optional: only deliver to specific agent
limit = 50, // Max notifications to process per call
@ -27,11 +28,11 @@ export async function POST(request: NextRequest) {
let query = `
SELECT n.*, a.session_key
FROM notifications n
LEFT JOIN agents a ON n.recipient = a.name
WHERE n.delivered_at IS NULL
LEFT JOIN agents a ON n.recipient = a.name AND a.workspace_id = n.workspace_id
WHERE n.delivered_at IS NULL AND n.workspace_id = ?
`;
const params: any[] = [];
const params: any[] = [workspaceId];
if (agent_filter) {
query += ' AND n.recipient = ?';
@ -59,7 +60,7 @@ export async function POST(request: NextRequest) {
const deliveryResults: any[] = [];
// Prepare update statement once (avoids N+1)
const markDeliveredStmt = db.prepare('UPDATE notifications SET delivered_at = ? WHERE id = ?');
const markDeliveredStmt = db.prepare('UPDATE notifications SET delivered_at = ? WHERE id = ? AND workspace_id = ?');
for (const notification of undeliveredNotifications) {
try {
@ -98,7 +99,7 @@ export async function POST(request: NextRequest) {
// Mark as delivered
const now = Math.floor(Date.now() / 1000);
markDeliveredStmt.run(now, notification.id);
markDeliveredStmt.run(now, notification.id, workspaceId);
deliveredCount++;
deliveryResults.push({
@ -121,7 +122,8 @@ export async function POST(request: NextRequest) {
notification_type: notification.type,
session_key: notification.session_key,
title: notification.title
}
},
workspaceId
);
} catch (cmdError: any) {
throw new Error(`Command failed: ${cmdError.message}`);
@ -162,7 +164,8 @@ export async function POST(request: NextRequest) {
errors: errorCount,
dry_run,
agent_filter: agent_filter || null
}
},
workspaceId
);
return NextResponse.json({
@ -191,25 +194,26 @@ export async function GET(request: NextRequest) {
try {
const db = getDatabase();
const { searchParams } = new URL(request.url);
const workspaceId = auth.user.workspace_id ?? 1;
const agent = searchParams.get('agent');
// Get delivery statistics
let baseQuery = 'SELECT COUNT(*) as count FROM notifications';
let params: any[] = [];
let baseQuery = 'SELECT COUNT(*) as count FROM notifications WHERE workspace_id = ?';
let params: any[] = [workspaceId];
if (agent) {
baseQuery += ' WHERE recipient = ?';
baseQuery += ' AND recipient = ?';
params.push(agent);
}
const totalNotifications = db.prepare(baseQuery).get(...params) as { count: number };
const undeliveredCount = db.prepare(
baseQuery + (agent ? ' AND' : ' WHERE') + ' delivered_at IS NULL'
baseQuery + ' AND delivered_at IS NULL'
).get(...params) as { count: number };
const deliveredCount = db.prepare(
baseQuery + (agent ? ' AND' : ' WHERE') + ' delivered_at IS NOT NULL'
baseQuery + ' AND delivered_at IS NOT NULL'
).get(...params) as { count: number };
// Get recent delivery activity
@ -221,11 +225,11 @@ export async function GET(request: NextRequest) {
delivered_at,
created_at
FROM notifications
WHERE delivered_at IS NOT NULL
WHERE delivered_at IS NOT NULL AND workspace_id = ?
${agent ? 'AND recipient = ?' : ''}
ORDER BY delivered_at DESC
LIMIT 10
`).all(...(agent ? [agent] : []));
`).all(...(agent ? [workspaceId, agent] : [workspaceId]));
// Get agents with pending notifications
const agentsPending = db.prepare(`
@ -234,11 +238,11 @@ export async function GET(request: NextRequest) {
a.session_key,
COUNT(*) as pending_count
FROM notifications n
LEFT JOIN agents a ON n.recipient = a.name
WHERE n.delivered_at IS NULL
LEFT JOIN agents a ON n.recipient = a.name AND a.workspace_id = n.workspace_id
WHERE n.delivered_at IS NULL AND n.workspace_id = ?
GROUP BY n.recipient, a.session_key
ORDER BY pending_count DESC
`).all() as any[];
`).all(workspaceId) as any[];
return NextResponse.json({
statistics: {

View File

@ -16,6 +16,7 @@ export async function GET(request: NextRequest) {
try {
const db = getDatabase();
const { searchParams } = new URL(request.url);
const workspaceId = auth.user.workspace_id ?? 1;
// Parse query parameters
const recipient = searchParams.get('recipient');
@ -29,8 +30,8 @@ export async function GET(request: NextRequest) {
}
// Build dynamic query
let query = 'SELECT * FROM notifications WHERE recipient = ?';
const params: any[] = [recipient];
let query = 'SELECT * FROM notifications WHERE recipient = ? AND workspace_id = ?';
const params: any[] = [recipient, workspaceId];
if (unread_only) {
query += ' AND read_at IS NULL';
@ -48,14 +49,14 @@ export async function GET(request: NextRequest) {
const notifications = stmt.all(...params) as Notification[];
// Prepare source detail statements once (avoids N+1)
const taskDetailStmt = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?');
const taskDetailStmt = db.prepare('SELECT id, title, status FROM tasks WHERE id = ? AND workspace_id = ?');
const commentDetailStmt = db.prepare(`
SELECT c.id, c.content, c.task_id, t.title as task_title
FROM comments c
LEFT JOIN tasks t ON c.task_id = t.id
WHERE c.id = ?
WHERE c.id = ? AND c.workspace_id = ? AND t.workspace_id = ?
`);
const agentDetailStmt = db.prepare('SELECT id, name, role, status FROM agents WHERE id = ?');
const agentDetailStmt = db.prepare('SELECT id, name, role, status FROM agents WHERE id = ? AND workspace_id = ?');
// Enhance notifications with related entity data
const enhancedNotifications = notifications.map(notification => {
@ -65,14 +66,14 @@ export async function GET(request: NextRequest) {
if (notification.source_type && notification.source_id) {
switch (notification.source_type) {
case 'task': {
const task = taskDetailStmt.get(notification.source_id) as any;
const task = taskDetailStmt.get(notification.source_id, workspaceId) as any;
if (task) {
sourceDetails = { type: 'task', ...task };
}
break;
}
case 'comment': {
const comment = commentDetailStmt.get(notification.source_id) as any;
const comment = commentDetailStmt.get(notification.source_id, workspaceId, workspaceId) as any;
if (comment) {
sourceDetails = {
type: 'comment',
@ -83,7 +84,7 @@ export async function GET(request: NextRequest) {
break;
}
case 'agent': {
const agent = agentDetailStmt.get(notification.source_id) as any;
const agent = agentDetailStmt.get(notification.source_id, workspaceId) as any;
if (agent) {
sourceDetails = { type: 'agent', ...agent };
}
@ -105,12 +106,12 @@ export async function GET(request: NextRequest) {
const unreadCount = db.prepare(`
SELECT COUNT(*) as count
FROM notifications
WHERE recipient = ? AND read_at IS NULL
`).get(recipient) as { count: number };
WHERE recipient = ? AND read_at IS NULL AND workspace_id = ?
`).get(recipient, workspaceId) as { count: number };
// Get total count for pagination
let countQuery = 'SELECT COUNT(*) as total FROM notifications WHERE recipient = ?';
const countParams: any[] = [recipient];
let countQuery = 'SELECT COUNT(*) as total FROM notifications WHERE recipient = ? AND workspace_id = ?';
const countParams: any[] = [recipient, workspaceId];
if (unread_only) {
countQuery += ' AND read_at IS NULL';
}
@ -146,6 +147,7 @@ export async function PUT(request: NextRequest) {
try {
const db = getDatabase();
const workspaceId = auth.user.workspace_id ?? 1;
const body = await request.json();
const { ids, recipient, markAllRead } = body;
@ -156,10 +158,10 @@ export async function PUT(request: NextRequest) {
const stmt = db.prepare(`
UPDATE notifications
SET read_at = ?
WHERE recipient = ? AND read_at IS NULL
WHERE recipient = ? AND read_at IS NULL AND workspace_id = ?
`);
const result = stmt.run(now, recipient);
const result = stmt.run(now, recipient, workspaceId);
return NextResponse.json({
success: true,
@ -171,10 +173,10 @@ export async function PUT(request: NextRequest) {
const stmt = db.prepare(`
UPDATE notifications
SET read_at = ?
WHERE id IN (${placeholders}) AND read_at IS NULL
WHERE id IN (${placeholders}) AND read_at IS NULL AND workspace_id = ?
`);
const result = stmt.run(now, ...ids);
const result = stmt.run(now, ...ids, workspaceId);
return NextResponse.json({
success: true,
@ -204,6 +206,7 @@ export async function DELETE(request: NextRequest) {
try {
const db = getDatabase();
const workspaceId = auth.user.workspace_id ?? 1;
const body = await request.json();
const { ids, recipient, olderThan } = body;
@ -212,10 +215,10 @@ export async function DELETE(request: NextRequest) {
const placeholders = ids.map(() => '?').join(',');
const stmt = db.prepare(`
DELETE FROM notifications
WHERE id IN (${placeholders})
WHERE id IN (${placeholders}) AND workspace_id = ?
`);
const result = stmt.run(...ids);
const result = stmt.run(...ids, workspaceId);
return NextResponse.json({
success: true,
@ -225,10 +228,10 @@ export async function DELETE(request: NextRequest) {
// Delete old notifications for recipient
const stmt = db.prepare(`
DELETE FROM notifications
WHERE recipient = ? AND created_at < ?
WHERE recipient = ? AND created_at < ? AND workspace_id = ?
`);
const result = stmt.run(recipient, olderThan);
const result = stmt.run(recipient, olderThan, workspaceId);
return NextResponse.json({
success: true,
@ -258,6 +261,7 @@ export async function POST(request: NextRequest) {
try {
const db = getDatabase();
const workspaceId = auth.user.workspace_id ?? 1;
const result = await validateBody(request, notificationActionSchema);
if ('error' in result) return result.error;
@ -271,17 +275,17 @@ export async function POST(request: NextRequest) {
const stmt = db.prepare(`
UPDATE notifications
SET delivered_at = ?
WHERE recipient = ? AND delivered_at IS NULL
WHERE recipient = ? AND delivered_at IS NULL AND workspace_id = ?
`);
const result = stmt.run(now, agent);
const result = stmt.run(now, agent, workspaceId);
// Get the notifications that were just marked as delivered
const deliveredNotifications = db.prepare(`
SELECT * FROM notifications
WHERE recipient = ? AND delivered_at = ?
WHERE recipient = ? AND delivered_at = ? AND workspace_id = ?
ORDER BY created_at DESC
`).all(agent, now) as Notification[];
`).all(agent, now, workspaceId) as Notification[];
return NextResponse.json({
success: true,

View File

@ -32,9 +32,10 @@ export async function GET(request: NextRequest) {
try {
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const pipelines = db.prepare(
'SELECT * FROM workflow_pipelines ORDER BY use_count DESC, updated_at DESC'
).all() as Pipeline[]
'SELECT * FROM workflow_pipelines WHERE workspace_id = ? ORDER BY use_count DESC, updated_at DESC'
).all(workspaceId) as Pipeline[]
// Enrich steps with template names
const templates = db.prepare('SELECT id, name FROM workflow_templates').all() as Array<{ id: number; name: string }>
@ -46,8 +47,8 @@ export async function GET(request: NextRequest) {
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running
FROM pipeline_runs GROUP BY pipeline_id
`).all() as Array<{ pipeline_id: number; total: number; completed: number; failed: number; running: number }>
FROM pipeline_runs WHERE workspace_id = ? GROUP BY pipeline_id
`).all(workspaceId) as Array<{ pipeline_id: number; total: number; completed: number; failed: number; running: number }>
const runMap = new Map(runCounts.map(r => [r.pipeline_id, r]))
const parsed = pipelines.map(p => {
@ -82,6 +83,7 @@ export async function POST(request: NextRequest) {
const { name, description, steps } = result.data
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
// Validate template IDs exist
const templateIds = steps.map((s: PipelineStep) => s.template_id)
@ -98,13 +100,23 @@ export async function POST(request: NextRequest) {
}))
const insertResult = db.prepare(`
INSERT INTO workflow_pipelines (name, description, steps, created_by)
VALUES (?, ?, ?, ?)
`).run(name, description || null, JSON.stringify(cleanSteps), auth.user?.username || 'system')
INSERT INTO workflow_pipelines (name, description, steps, created_by, workspace_id)
VALUES (?, ?, ?, ?, ?)
`).run(name, description || null, JSON.stringify(cleanSteps), auth.user?.username || 'system', workspaceId)
db_helpers.logActivity('pipeline_created', 'pipeline', Number(insertResult.lastInsertRowid), auth.user?.username || 'system', `Created pipeline: ${name}`)
db_helpers.logActivity(
'pipeline_created',
'pipeline',
Number(insertResult.lastInsertRowid),
auth.user?.username || 'system',
`Created pipeline: ${name}`,
undefined,
workspaceId
)
const pipeline = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(insertResult.lastInsertRowid) as Pipeline
const pipeline = db
.prepare('SELECT * FROM workflow_pipelines WHERE id = ? AND workspace_id = ?')
.get(insertResult.lastInsertRowid, workspaceId) as Pipeline
return NextResponse.json({ pipeline: { ...pipeline, steps: JSON.parse(pipeline.steps) } }, { status: 201 })
} catch (error) {
logger.error({ err: error }, 'POST /api/pipelines error')
@ -121,12 +133,15 @@ export async function PUT(request: NextRequest) {
try {
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const body = await request.json()
const { id, ...updates } = body
if (!id) return NextResponse.json({ error: 'Pipeline ID required' }, { status: 400 })
const existing = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(id) as Pipeline
const existing = db
.prepare('SELECT * FROM workflow_pipelines WHERE id = ? AND workspace_id = ?')
.get(id, workspaceId) as Pipeline
if (!existing) return NextResponse.json({ error: 'Pipeline not found' }, { status: 404 })
const fields: string[] = []
@ -147,11 +162,13 @@ export async function PUT(request: NextRequest) {
fields.push('updated_at = ?')
params.push(Math.floor(Date.now() / 1000))
params.push(id)
params.push(id, workspaceId)
db.prepare(`UPDATE workflow_pipelines SET ${fields.join(', ')} WHERE id = ?`).run(...params)
db.prepare(`UPDATE workflow_pipelines SET ${fields.join(', ')} WHERE id = ? AND workspace_id = ?`).run(...params)
const updated = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(id) as Pipeline
const updated = db
.prepare('SELECT * FROM workflow_pipelines WHERE id = ? AND workspace_id = ?')
.get(id, workspaceId) as Pipeline
return NextResponse.json({ pipeline: { ...updated, steps: JSON.parse(updated.steps) } })
} catch (error) {
logger.error({ err: error }, 'PUT /api/pipelines error')
@ -168,12 +185,13 @@ export async function DELETE(request: NextRequest) {
try {
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
let body: any
try { body = await request.json() } catch { return NextResponse.json({ error: 'Request body required' }, { status: 400 }) }
const id = body.id
if (!id) return NextResponse.json({ error: 'Pipeline ID required' }, { status: 400 })
db.prepare('DELETE FROM workflow_pipelines WHERE id = ?').run(parseInt(id))
db.prepare('DELETE FROM workflow_pipelines WHERE id = ? AND workspace_id = ?').run(parseInt(id), workspaceId)
return NextResponse.json({ success: true })
} catch (error) {
logger.error({ err: error }, 'DELETE /api/pipelines error')

View File

@ -42,21 +42,24 @@ export async function GET(request: NextRequest) {
try {
const db = getDatabase()
const { searchParams } = new URL(request.url)
const workspaceId = auth.user.workspace_id ?? 1
const pipelineId = searchParams.get('pipeline_id')
const runId = searchParams.get('id')
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 200)
if (runId) {
const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ?').get(parseInt(runId)) as PipelineRun | undefined
const run = db
.prepare('SELECT * FROM pipeline_runs WHERE id = ? AND workspace_id = ?')
.get(parseInt(runId), workspaceId) as PipelineRun | undefined
if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 })
return NextResponse.json({ run: { ...run, steps_snapshot: JSON.parse(run.steps_snapshot) } })
}
let query = 'SELECT * FROM pipeline_runs'
const params: any[] = []
let query = 'SELECT * FROM pipeline_runs WHERE workspace_id = ?'
const params: any[] = [workspaceId]
if (pipelineId) {
query += ' WHERE pipeline_id = ?'
query += ' AND pipeline_id = ?'
params.push(parseInt(pipelineId))
}
@ -68,7 +71,7 @@ export async function GET(request: NextRequest) {
// Enrich with pipeline names
const pipelineIds = [...new Set(runs.map(r => r.pipeline_id))]
const pipelines = pipelineIds.length > 0
? db.prepare(`SELECT id, name FROM workflow_pipelines WHERE id IN (${pipelineIds.map(() => '?').join(',')})`).all(...pipelineIds) as Array<{ id: number; name: string }>
? db.prepare(`SELECT id, name FROM workflow_pipelines WHERE workspace_id = ? AND id IN (${pipelineIds.map(() => '?').join(',')})`).all(workspaceId, ...pipelineIds) as Array<{ id: number; name: string }>
: []
const nameMap = new Map(pipelines.map(p => [p.id, p.name]))
@ -94,15 +97,16 @@ export async function POST(request: NextRequest) {
try {
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const body = await request.json()
const { action, pipeline_id, run_id } = body
if (action === 'start') {
return startPipeline(db, pipeline_id, auth.user?.username || 'system')
return startPipeline(db, pipeline_id, auth.user?.username || 'system', workspaceId)
} else if (action === 'advance') {
return advanceRun(db, run_id, body.success ?? true, body.error)
return advanceRun(db, run_id, body.success ?? true, body.error, workspaceId)
} else if (action === 'cancel') {
return cancelRun(db, run_id)
return cancelRun(db, run_id, workspaceId)
}
return NextResponse.json({ error: 'Invalid action. Use: start, advance, cancel' }, { status: 400 })
@ -119,7 +123,8 @@ async function spawnStep(
template: { name: string; model: string; task_prompt: string; timeout_seconds: number },
steps: RunStepState[],
stepIdx: number,
runId: number
runId: number,
workspaceId: number
): Promise<{ success: boolean; stdout?: string; error?: string }> {
try {
const { runOpenClaw } = await import('@/lib/command')
@ -133,20 +138,20 @@ async function spawnStep(
const spawnId = `pipeline-${runId}-step-${stepIdx}-${Date.now()}`
steps[stepIdx].spawn_id = spawnId
db.prepare('UPDATE pipeline_runs SET steps_snapshot = ? WHERE id = ?').run(JSON.stringify(steps), runId)
db.prepare('UPDATE pipeline_runs SET steps_snapshot = ? WHERE id = ? AND workspace_id = ?').run(JSON.stringify(steps), runId, workspaceId)
return { success: true, stdout: stdout.trim() }
} catch (err: any) {
// Spawn failed - record error but keep pipeline running for manual advance
steps[stepIdx].error = err.message
db.prepare('UPDATE pipeline_runs SET steps_snapshot = ? WHERE id = ?').run(JSON.stringify(steps), runId)
db.prepare('UPDATE pipeline_runs SET steps_snapshot = ? WHERE id = ? AND workspace_id = ?').run(JSON.stringify(steps), runId, workspaceId)
return { success: false, error: err.message }
}
}
async function startPipeline(db: ReturnType<typeof getDatabase>, pipelineId: number, triggeredBy: string) {
const pipeline = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ?').get(pipelineId) as any
async function startPipeline(db: ReturnType<typeof getDatabase>, pipelineId: number, triggeredBy: string, workspaceId: number) {
const pipeline = db.prepare('SELECT * FROM workflow_pipelines WHERE id = ? AND workspace_id = ?').get(pipelineId, workspaceId) as any
if (!pipeline) return NextResponse.json({ error: 'Pipeline not found' }, { status: 404 })
const steps: PipelineStep[] = JSON.parse(pipeline.steps || '[]')
@ -174,25 +179,25 @@ async function startPipeline(db: ReturnType<typeof getDatabase>, pipelineId: num
const now = Math.floor(Date.now() / 1000)
const result = db.prepare(`
INSERT INTO pipeline_runs (pipeline_id, status, current_step, steps_snapshot, started_at, triggered_by)
VALUES (?, 'running', 0, ?, ?, ?)
`).run(pipelineId, JSON.stringify(stepsSnapshot), now, triggeredBy)
INSERT INTO pipeline_runs (pipeline_id, status, current_step, steps_snapshot, started_at, triggered_by, workspace_id)
VALUES (?, 'running', 0, ?, ?, ?, ?)
`).run(pipelineId, JSON.stringify(stepsSnapshot), now, triggeredBy, workspaceId)
const runId = Number(result.lastInsertRowid)
// Update pipeline usage
db.prepare(`
UPDATE workflow_pipelines SET use_count = use_count + 1, last_used_at = ?, updated_at = ? WHERE id = ?
`).run(now, now, pipelineId)
UPDATE workflow_pipelines SET use_count = use_count + 1, last_used_at = ?, updated_at = ? WHERE id = ? AND workspace_id = ?
`).run(now, now, pipelineId, workspaceId)
// Spawn first step
const firstTemplate = templateMap.get(steps[0].template_id)
let spawnResult: any = null
if (firstTemplate) {
spawnResult = await spawnStep(db, pipeline.name, firstTemplate, stepsSnapshot, 0, runId)
spawnResult = await spawnStep(db, pipeline.name, firstTemplate, stepsSnapshot, 0, runId, workspaceId)
}
db_helpers.logActivity('pipeline_started', 'pipeline', pipelineId, triggeredBy, `Started pipeline: ${pipeline.name}`, { run_id: runId })
db_helpers.logActivity('pipeline_started', 'pipeline', pipelineId, triggeredBy, `Started pipeline: ${pipeline.name}`, { run_id: runId }, workspaceId)
eventBus.broadcast('activity.created', {
type: 'pipeline_started',
@ -214,10 +219,10 @@ async function startPipeline(db: ReturnType<typeof getDatabase>, pipelineId: num
}, { status: 201 })
}
async function advanceRun(db: ReturnType<typeof getDatabase>, runId: number, success: boolean, errorMsg?: string) {
async function advanceRun(db: ReturnType<typeof getDatabase>, runId: number, success: boolean, errorMsg: string | undefined, workspaceId: number) {
if (!runId) return NextResponse.json({ error: 'run_id required' }, { status: 400 })
const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ?').get(runId) as PipelineRun | undefined
const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ? AND workspace_id = ?').get(runId, workspaceId) as PipelineRun | undefined
if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 })
if (run.status !== 'running') return NextResponse.json({ error: `Run is ${run.status}, not running` }, { status: 400 })
@ -237,16 +242,16 @@ async function advanceRun(db: ReturnType<typeof getDatabase>, runId: number, suc
if (!success && onFailure === 'stop') {
// Mark remaining steps as skipped
for (let i = nextIdx; i < steps.length; i++) steps[i].status = 'skipped'
db.prepare('UPDATE pipeline_runs SET status = ?, current_step = ?, steps_snapshot = ?, completed_at = ? WHERE id = ?')
.run('failed', currentIdx, JSON.stringify(steps), now, runId)
db.prepare('UPDATE pipeline_runs SET status = ?, current_step = ?, steps_snapshot = ?, completed_at = ? WHERE id = ? AND workspace_id = ?')
.run('failed', currentIdx, JSON.stringify(steps), now, runId, workspaceId)
return NextResponse.json({ run: { id: runId, status: 'failed', steps_snapshot: steps } })
}
if (nextIdx >= steps.length) {
// Pipeline complete
const finalStatus = steps.some(s => s.status === 'failed') ? 'completed' : 'completed'
db.prepare('UPDATE pipeline_runs SET status = ?, current_step = ?, steps_snapshot = ?, completed_at = ? WHERE id = ?')
.run(finalStatus, currentIdx, JSON.stringify(steps), now, runId)
db.prepare('UPDATE pipeline_runs SET status = ?, current_step = ?, steps_snapshot = ?, completed_at = ? WHERE id = ? AND workspace_id = ?')
.run(finalStatus, currentIdx, JSON.stringify(steps), now, runId, workspaceId)
eventBus.broadcast('activity.created', {
type: 'pipeline_completed',
@ -267,22 +272,22 @@ async function advanceRun(db: ReturnType<typeof getDatabase>, runId: number, suc
let spawnResult: any = null
if (template) {
const pipeline = db.prepare('SELECT name FROM workflow_pipelines WHERE id = ?').get(run.pipeline_id) as any
spawnResult = await spawnStep(db, pipeline?.name || '?', template, steps, nextIdx, runId)
const pipeline = db.prepare('SELECT name FROM workflow_pipelines WHERE id = ? AND workspace_id = ?').get(run.pipeline_id, workspaceId) as any
spawnResult = await spawnStep(db, pipeline?.name || '?', template, steps, nextIdx, runId, workspaceId)
}
db.prepare('UPDATE pipeline_runs SET current_step = ?, steps_snapshot = ? WHERE id = ?')
.run(nextIdx, JSON.stringify(steps), runId)
db.prepare('UPDATE pipeline_runs SET current_step = ?, steps_snapshot = ? WHERE id = ? AND workspace_id = ?')
.run(nextIdx, JSON.stringify(steps), runId, workspaceId)
return NextResponse.json({
run: { id: runId, status: 'running', current_step: nextIdx, steps_snapshot: steps, spawn: spawnResult }
})
}
function cancelRun(db: ReturnType<typeof getDatabase>, runId: number) {
function cancelRun(db: ReturnType<typeof getDatabase>, runId: number, workspaceId: number) {
if (!runId) return NextResponse.json({ error: 'run_id required' }, { status: 400 })
const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ?').get(runId) as PipelineRun | undefined
const run = db.prepare('SELECT * FROM pipeline_runs WHERE id = ? AND workspace_id = ?').get(runId, workspaceId) as PipelineRun | undefined
if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 })
if (run.status !== 'running' && run.status !== 'pending') {
return NextResponse.json({ error: `Run is ${run.status}, cannot cancel` }, { status: 400 })
@ -298,8 +303,8 @@ function cancelRun(db: ReturnType<typeof getDatabase>, runId: number) {
}
}
db.prepare('UPDATE pipeline_runs SET status = ?, steps_snapshot = ?, completed_at = ? WHERE id = ?')
.run('cancelled', JSON.stringify(steps), now, runId)
db.prepare('UPDATE pipeline_runs SET status = ?, steps_snapshot = ?, completed_at = ? WHERE id = ? AND workspace_id = ?')
.run('cancelled', JSON.stringify(steps), now, runId, workspaceId)
return NextResponse.json({ run: { id: runId, status: 'cancelled', steps_snapshot: steps } })
}

View File

@ -13,6 +13,7 @@ export async function GET(request: NextRequest) {
try {
const db = getDatabase()
const { searchParams } = new URL(request.url)
const workspaceId = auth.user.workspace_id ?? 1;
const taskIdsParam = searchParams.get('taskIds')
const taskId = parseInt(searchParams.get('taskId') || '')
@ -29,9 +30,9 @@ export async function GET(request: NextRequest) {
const placeholders = ids.map(() => '?').join(',')
const rows = db.prepare(`
SELECT * FROM quality_reviews
WHERE task_id IN (${placeholders})
WHERE task_id IN (${placeholders}) AND workspace_id = ?
ORDER BY task_id ASC, created_at DESC
`).all(...ids) as Array<{ task_id: number; reviewer?: string; status?: string; created_at?: number }>
`).all(...ids, workspaceId) as Array<{ task_id: number; reviewer?: string; status?: string; created_at?: number }>
const byTask: Record<number, { status?: string; reviewer?: string; created_at?: number } | null> = {}
for (const id of ids) {
@ -54,10 +55,10 @@ export async function GET(request: NextRequest) {
const reviews = db.prepare(`
SELECT * FROM quality_reviews
WHERE task_id = ?
WHERE task_id = ? AND workspace_id = ?
ORDER BY created_at DESC
LIMIT 10
`).all(taskId)
`).all(taskId, workspaceId)
return NextResponse.json({ reviews })
} catch (error) {
@ -79,16 +80,19 @@ export async function POST(request: NextRequest) {
const { taskId, reviewer, status, notes } = validated.data
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1;
const task = db.prepare('SELECT id, title FROM tasks WHERE id = ?').get(taskId) as any
const task = db
.prepare('SELECT id, title FROM tasks WHERE id = ? AND workspace_id = ?')
.get(taskId, workspaceId) as any
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 })
}
const result = db.prepare(`
INSERT INTO quality_reviews (task_id, reviewer, status, notes)
VALUES (?, ?, ?, ?)
`).run(taskId, reviewer, status, notes)
INSERT INTO quality_reviews (task_id, reviewer, status, notes, workspace_id)
VALUES (?, ?, ?, ?, ?)
`).run(taskId, reviewer, status, notes, workspaceId)
db_helpers.logActivity(
'quality_review',
@ -96,13 +100,14 @@ export async function POST(request: NextRequest) {
taskId,
reviewer,
`Quality review ${status} for task: ${task.title}`,
{ status, notes }
{ status, notes },
workspaceId
)
// Auto-advance task to 'done' when aegis approves
if (status === 'approved' && reviewer === 'aegis') {
db.prepare('UPDATE tasks SET status = ?, updated_at = unixepoch() WHERE id = ?')
.run('done', taskId)
db.prepare('UPDATE tasks SET status = ?, updated_at = unixepoch() WHERE id = ? AND workspace_id = ?')
.run('done', taskId, workspaceId)
eventBus.broadcast('task.status_changed', {
id: taskId,
status: 'done',

View File

@ -34,6 +34,7 @@ export async function GET(request: NextRequest) {
}
const db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1
const likeQ = `%${query}%`
const results: SearchResult[] = []
@ -42,9 +43,9 @@ export async function GET(request: NextRequest) {
try {
const tasks = db.prepare(`
SELECT id, title, description, status, assigned_to, created_at
FROM tasks WHERE title LIKE ? OR description LIKE ? OR assigned_to LIKE ?
FROM tasks WHERE workspace_id = ? AND (title LIKE ? OR description LIKE ? OR assigned_to LIKE ?)
ORDER BY created_at DESC LIMIT ?
`).all(likeQ, likeQ, likeQ, limit) as any[]
`).all(workspaceId, likeQ, likeQ, likeQ, limit) as any[]
for (const t of tasks) {
results.push({
type: 'task',
@ -64,9 +65,9 @@ export async function GET(request: NextRequest) {
try {
const agents = db.prepare(`
SELECT id, name, role, status, last_activity, created_at
FROM agents WHERE name LIKE ? OR role LIKE ? OR last_activity LIKE ?
FROM agents WHERE workspace_id = ? AND (name LIKE ? OR role LIKE ? OR last_activity LIKE ?)
ORDER BY created_at DESC LIMIT ?
`).all(likeQ, likeQ, likeQ, limit) as any[]
`).all(workspaceId, likeQ, likeQ, likeQ, limit) as any[]
for (const a of agents) {
results.push({
type: 'agent',
@ -86,9 +87,9 @@ export async function GET(request: NextRequest) {
try {
const activities = db.prepare(`
SELECT id, type, actor, description, created_at
FROM activities WHERE description LIKE ? OR actor LIKE ?
FROM activities WHERE workspace_id = ? AND (description LIKE ? OR actor LIKE ?)
ORDER BY created_at DESC LIMIT ?
`).all(likeQ, likeQ, limit) as any[]
`).all(workspaceId, likeQ, likeQ, limit) as any[]
for (const a of activities) {
results.push({
type: 'activity',

View File

@ -14,6 +14,7 @@ export async function POST(request: NextRequest) {
try {
const db = getDatabase();
const body = await request.json();
const workspaceId = auth.user.workspace_id ?? 1;
// Parse parameters
const targetDate = body.date || new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
@ -24,12 +25,12 @@ export async function POST(request: NextRequest) {
const endOfDay = Math.floor(new Date(`${targetDate}T23:59:59Z`).getTime() / 1000);
// Get all active agents or filter by specific agents
let agentQuery = 'SELECT * FROM agents';
const agentParams: any[] = [];
let agentQuery = 'SELECT * FROM agents WHERE workspace_id = ?';
const agentParams: any[] = [workspaceId];
if (specificAgents && Array.isArray(specificAgents) && specificAgents.length > 0) {
const placeholders = specificAgents.map(() => '?').join(',');
agentQuery += ` WHERE name IN (${placeholders})`;
agentQuery += ` AND name IN (${placeholders})`;
agentParams.push(...specificAgents);
}
@ -42,6 +43,7 @@ export async function POST(request: NextRequest) {
SELECT id, title, status, updated_at
FROM tasks
WHERE assigned_to = ?
AND workspace_id = ?
AND status = 'done'
AND updated_at BETWEEN ? AND ?
ORDER BY updated_at DESC
@ -50,6 +52,7 @@ export async function POST(request: NextRequest) {
SELECT id, title, status, created_at, due_date
FROM tasks
WHERE assigned_to = ?
AND workspace_id = ?
AND status = 'in_progress'
ORDER BY created_at ASC
`);
@ -57,6 +60,7 @@ export async function POST(request: NextRequest) {
SELECT id, title, status, created_at, due_date, priority
FROM tasks
WHERE assigned_to = ?
AND workspace_id = ?
AND status = 'assigned'
ORDER BY priority DESC, created_at ASC
`);
@ -64,6 +68,7 @@ export async function POST(request: NextRequest) {
SELECT id, title, status, updated_at
FROM tasks
WHERE assigned_to = ?
AND workspace_id = ?
AND status IN ('review', 'quality_review')
ORDER BY updated_at ASC
`);
@ -71,6 +76,7 @@ export async function POST(request: NextRequest) {
SELECT id, title, status, priority, created_at, metadata
FROM tasks
WHERE assigned_to = ?
AND workspace_id = ?
AND (priority = 'urgent' OR metadata LIKE '%blocked%')
AND status NOT IN ('done')
ORDER BY priority DESC, created_at ASC
@ -79,24 +85,26 @@ export async function POST(request: NextRequest) {
SELECT COUNT(*) as count
FROM activities
WHERE actor = ?
AND workspace_id = ?
AND created_at BETWEEN ? AND ?
`);
const commentCountStmt = db.prepare(`
SELECT COUNT(*) as count
FROM comments
WHERE author = ?
AND workspace_id = ?
AND created_at BETWEEN ? AND ?
`);
// Generate standup data for each agent
const standupData = agents.map(agent => {
const completedTasks = completedTasksStmt.all(agent.name, startOfDay, endOfDay);
const inProgressTasks = inProgressTasksStmt.all(agent.name);
const assignedTasks = assignedTasksStmt.all(agent.name);
const reviewTasks = reviewTasksStmt.all(agent.name);
const blockedTasks = blockedTasksStmt.all(agent.name);
const activityCount = activityCountStmt.get(agent.name, startOfDay, endOfDay) as { count: number };
const commentsToday = commentCountStmt.get(agent.name, startOfDay, endOfDay) as { count: number };
const completedTasks = completedTasksStmt.all(agent.name, workspaceId, startOfDay, endOfDay);
const inProgressTasks = inProgressTasksStmt.all(agent.name, workspaceId);
const assignedTasks = assignedTasksStmt.all(agent.name, workspaceId);
const reviewTasks = reviewTasksStmt.all(agent.name, workspaceId);
const blockedTasks = blockedTasksStmt.all(agent.name, workspaceId);
const activityCount = activityCountStmt.get(agent.name, workspaceId, startOfDay, endOfDay) as { count: number };
const commentsToday = commentCountStmt.get(agent.name, workspaceId, startOfDay, endOfDay) as { count: number };
return {
agent: {
@ -145,10 +153,12 @@ export async function POST(request: NextRequest) {
SELECT t.*, a.name as agent_name
FROM tasks t
LEFT JOIN agents a ON t.assigned_to = a.name
AND a.workspace_id = t.workspace_id
WHERE t.due_date < ?
AND t.workspace_id = ?
AND t.status NOT IN ('done')
ORDER BY t.due_date ASC
`).all(now);
`).all(now, workspaceId);
const standupReport = {
date: targetDate,
@ -172,16 +182,16 @@ export async function POST(request: NextRequest) {
// Persist standup report
const createdAt = Math.floor(Date.now() / 1000);
db.prepare(`
INSERT OR REPLACE INTO standup_reports (date, report, created_at)
VALUES (?, ?, ?)
`).run(targetDate, JSON.stringify(standupReport), createdAt);
INSERT OR REPLACE INTO standup_reports (date, report, created_at, workspace_id)
VALUES (?, ?, ?, ?)
`).run(targetDate, JSON.stringify(standupReport), createdAt, workspaceId);
// Log the standup generation
db_helpers.logActivity(
'standup_generated',
'standup',
0, // No specific entity
'system',
auth.user.username,
`Generated daily standup for ${targetDate}`,
{
date: targetDate,
@ -193,7 +203,8 @@ export async function POST(request: NextRequest) {
review: totalReview,
blocked: totalBlocked
}
}
},
workspaceId
);
return NextResponse.json({ standup: standupReport });
@ -214,6 +225,7 @@ export async function GET(request: NextRequest) {
try {
const db = getDatabase();
const { searchParams } = new URL(request.url);
const workspaceId = auth.user.workspace_id ?? 1;
const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 200);
const offset = parseInt(searchParams.get('offset') || '0');
@ -221,9 +233,10 @@ export async function GET(request: NextRequest) {
const standupRows = db.prepare(`
SELECT date, report, created_at
FROM standup_reports
WHERE workspace_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`).all(limit, offset) as Array<{ date: string; report: string; created_at: number }>;
`).all(workspaceId, limit, offset) as Array<{ date: string; report: string; created_at: number }>;
const standupHistory = standupRows.map((row, index) => {
const report = row.report ? JSON.parse(row.report) : {};
@ -236,7 +249,9 @@ export async function GET(request: NextRequest) {
};
});
const countRow = db.prepare('SELECT COUNT(*) as total FROM standup_reports').get() as { total: number };
const countRow = db
.prepare('SELECT COUNT(*) as total FROM standup_reports WHERE workspace_id = ?')
.get(workspaceId) as { total: number };
return NextResponse.json({
history: standupHistory,

View File

@ -19,12 +19,12 @@ export async function GET(request: NextRequest) {
const action = searchParams.get('action') || 'overview'
if (action === 'overview') {
const status = await getSystemStatus()
const status = await getSystemStatus(auth.user.workspace_id ?? 1)
return NextResponse.json(status)
}
if (action === 'dashboard') {
const data = await getDashboardData()
const data = await getDashboardData(auth.user.workspace_id ?? 1)
return NextResponse.json(data)
}
@ -59,16 +59,16 @@ export async function GET(request: NextRequest) {
* Aggregate all dashboard data in a single request.
* Combines system health, DB stats, audit summary, and recent activity.
*/
async function getDashboardData() {
async function getDashboardData(workspaceId: number) {
const [system, dbStats] = await Promise.all([
getSystemStatus(),
getDbStats(),
getSystemStatus(workspaceId),
getDbStats(workspaceId),
])
return { ...system, db: dbStats }
}
function getDbStats() {
function getDbStats(workspaceId: number) {
try {
const db = getDatabase()
const now = Math.floor(Date.now() / 1000)
@ -77,8 +77,8 @@ function getDbStats() {
// Task breakdown
const taskStats = db.prepare(`
SELECT status, COUNT(*) as count FROM tasks GROUP BY status
`).all() as Array<{ status: string; count: number }>
SELECT status, COUNT(*) as count FROM tasks WHERE workspace_id = ? GROUP BY status
`).all(workspaceId) as Array<{ status: string; count: number }>
const tasksByStatus: Record<string, number> = {}
let totalTasks = 0
for (const row of taskStats) {
@ -88,8 +88,8 @@ function getDbStats() {
// Agent breakdown
const agentStats = db.prepare(`
SELECT status, COUNT(*) as count FROM agents GROUP BY status
`).all() as Array<{ status: string; count: number }>
SELECT status, COUNT(*) as count FROM agents WHERE workspace_id = ? GROUP BY status
`).all(workspaceId) as Array<{ status: string; count: number }>
const agentsByStatus: Record<string, number> = {}
let totalAgents = 0
for (const row of agentStats) {
@ -107,10 +107,14 @@ function getDbStats() {
).get(day) as any).c
// Activities (24h)
const activityDay = (db.prepare('SELECT COUNT(*) as c FROM activities WHERE created_at > ?').get(day) as any).c
const activityDay = (
db.prepare('SELECT COUNT(*) as c FROM activities WHERE created_at > ? AND workspace_id = ?').get(day, workspaceId) as any
).c
// Notifications (unread)
const unreadNotifs = (db.prepare('SELECT COUNT(*) as c FROM notifications WHERE read_at IS NULL').get() as any).c
const unreadNotifs = (
db.prepare('SELECT COUNT(*) as c FROM notifications WHERE read_at IS NULL AND workspace_id = ?').get(workspaceId) as any
).c
// Pipeline runs (active + recent)
let pipelineActive = 0
@ -179,7 +183,7 @@ function getDbStats() {
}
}
async function getSystemStatus() {
async function getSystemStatus(workspaceId: number) {
const status: any = {
timestamp: Date.now(),
uptime: 0,
@ -277,14 +281,16 @@ async function getSystemStatus() {
// Match by: exact name, lowercase, or normalized (spaces→hyphens)
const updateStmt = db.prepare(
`UPDATE agents SET status = ?, last_seen = ?, updated_at = ?
WHERE LOWER(name) = LOWER(?)
OR LOWER(REPLACE(name, ' ', '-')) = LOWER(?)`
WHERE workspace_id = ?
AND (LOWER(name) = LOWER(?)
OR LOWER(REPLACE(name, ' ', '-')) = LOWER(?))`
)
for (const [agentName, info] of liveStatuses) {
updateStmt.run(
info.status,
Math.floor(info.lastActivity / 1000),
now,
workspaceId,
agentName,
agentName
)

View File

@ -15,6 +15,7 @@ export async function POST(
const resolvedParams = await params
const taskId = parseInt(resolvedParams.id)
const body = await request.json()
const workspaceId = auth.user.workspace_id ?? 1;
const author = (body.author || 'system') as string
const message = (body.message || '').trim()
@ -26,12 +27,14 @@ export async function POST(
}
const db = getDatabase()
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as any
const task = db
.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
.get(taskId, workspaceId) as any
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 })
}
const subscribers = new Set(db_helpers.getTaskSubscribers(taskId))
const subscribers = new Set(db_helpers.getTaskSubscribers(taskId, workspaceId))
subscribers.delete(author)
if (subscribers.size === 0) {
@ -39,8 +42,8 @@ export async function POST(
}
const agents = db
.prepare('SELECT name, session_key FROM agents WHERE name IN (' + Array.from(subscribers).map(() => '?').join(',') + ')')
.all(...Array.from(subscribers)) as Array<{ name: string; session_key?: string }>
.prepare('SELECT name, session_key FROM agents WHERE workspace_id = ? AND name IN (' + Array.from(subscribers).map(() => '?').join(',') + ')')
.all(workspaceId, ...Array.from(subscribers)) as Array<{ name: string; session_key?: string }>
const results = await Promise.allSettled(
agents.map(async (agent) => {
@ -62,7 +65,8 @@ export async function POST(
'Task Broadcast',
`${author} broadcasted a message on "${task.title}": ${message.substring(0, 100)}${message.length > 100 ? '...' : ''}`,
'task',
taskId
taskId,
workspaceId
)
return 'sent'
})
@ -81,7 +85,8 @@ export async function POST(
taskId,
author,
`Broadcasted message to ${sent} subscribers`,
{ sent, skipped }
{ sent, skipped },
workspaceId
)
return NextResponse.json({ sent, skipped })

View File

@ -19,13 +19,16 @@ export async function GET(
const db = getDatabase();
const resolvedParams = await params;
const taskId = parseInt(resolvedParams.id);
const workspaceId = auth.user.workspace_id ?? 1;
if (isNaN(taskId)) {
return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
}
// Verify task exists
const task = db.prepare('SELECT id FROM tasks WHERE id = ?').get(taskId);
const task = db
.prepare('SELECT id FROM tasks WHERE id = ? AND workspace_id = ?')
.get(taskId, workspaceId);
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
@ -33,11 +36,11 @@ export async function GET(
// Get comments ordered by creation time
const stmt = db.prepare(`
SELECT * FROM comments
WHERE task_id = ?
WHERE task_id = ? AND workspace_id = ?
ORDER BY created_at ASC
`);
const comments = stmt.all(taskId) as Comment[];
const comments = stmt.all(taskId, workspaceId) as Comment[];
// Parse JSON fields and build thread structure
const commentsWithParsedData = comments.map(comment => ({
@ -97,6 +100,7 @@ export async function POST(
const db = getDatabase();
const resolvedParams = await params;
const taskId = parseInt(resolvedParams.id);
const workspaceId = auth.user.workspace_id ?? 1;
if (isNaN(taskId)) {
return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
@ -107,14 +111,18 @@ export async function POST(
const { content, author = 'system', parent_id } = result.data;
// Verify task exists
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as any;
const task = db
.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
.get(taskId, workspaceId) as any;
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
// Verify parent comment exists if specified
if (parent_id) {
const parentComment = db.prepare('SELECT id FROM comments WHERE id = ? AND task_id = ?').get(parent_id, taskId);
const parentComment = db
.prepare('SELECT id FROM comments WHERE id = ? AND task_id = ? AND workspace_id = ?')
.get(parent_id, taskId, workspaceId);
if (!parentComment) {
return NextResponse.json({ error: 'Parent comment not found' }, { status: 404 });
}
@ -127,8 +135,8 @@ export async function POST(
// Insert comment
const stmt = db.prepare(`
INSERT INTO comments (task_id, author, content, created_at, parent_id, mentions)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO comments (task_id, author, content, created_at, parent_id, mentions, workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insertResult = stmt.run(
@ -137,7 +145,8 @@ export async function POST(
content,
now,
parent_id || null,
mentions.length > 0 ? JSON.stringify(mentions) : null
mentions.length > 0 ? JSON.stringify(mentions) : null,
workspaceId
);
const commentId = insertResult.lastInsertRowid as number;
@ -159,21 +168,22 @@ export async function POST(
parent_id,
mentions,
content_preview: content.substring(0, 100)
}
},
workspaceId
);
// Ensure subscriptions for author, mentions, and assignee
db_helpers.ensureTaskSubscription(taskId, author);
db_helpers.ensureTaskSubscription(taskId, author, workspaceId);
const uniqueMentions = Array.from(new Set(mentions));
uniqueMentions.forEach((mentionedAgent) => {
db_helpers.ensureTaskSubscription(taskId, mentionedAgent);
db_helpers.ensureTaskSubscription(taskId, mentionedAgent, workspaceId);
});
if (task.assigned_to) {
db_helpers.ensureTaskSubscription(taskId, task.assigned_to);
db_helpers.ensureTaskSubscription(taskId, task.assigned_to, workspaceId);
}
// Notify subscribers
const subscribers = new Set(db_helpers.getTaskSubscribers(taskId));
const subscribers = new Set(db_helpers.getTaskSubscribers(taskId, workspaceId));
subscribers.delete(author);
const mentionSet = new Set(uniqueMentions);
@ -187,12 +197,15 @@ export async function POST(
? `${author} mentioned you in a comment on "${task.title}": ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`
: `${author} commented on "${task.title}": ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`,
'comment',
commentId
commentId,
workspaceId
);
}
// Fetch the created comment
const createdComment = db.prepare('SELECT * FROM comments WHERE id = ?').get(commentId) as Comment;
const createdComment = db
.prepare('SELECT * FROM comments WHERE id = ? AND workspace_id = ?')
.get(commentId, workspaceId) as Comment;
return NextResponse.json({
comment: {

View File

@ -1,18 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase, Task, db_helpers } from '@/lib/db';
import { eventBus } from '@/lib/event-bus';
import { getUserFromRequest, requireRole } from '@/lib/auth';
import { requireRole } from '@/lib/auth';
import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger';
import { validateBody, updateTaskSchema } from '@/lib/validation';
function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean {
function hasAegisApproval(
db: ReturnType<typeof getDatabase>,
taskId: number,
workspaceId: number
): boolean {
const review = db.prepare(`
SELECT status FROM quality_reviews
WHERE task_id = ? AND reviewer = 'aegis'
WHERE task_id = ? AND reviewer = 'aegis' AND workspace_id = ?
ORDER BY created_at DESC
LIMIT 1
`).get(taskId) as { status?: string } | undefined
`).get(taskId, workspaceId) as { status?: string } | undefined
return review?.status === 'approved'
}
@ -30,13 +34,14 @@ export async function GET(
const db = getDatabase();
const resolvedParams = await params;
const taskId = parseInt(resolvedParams.id);
const workspaceId = auth.user.workspace_id ?? 1;
if (isNaN(taskId)) {
return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
}
const stmt = db.prepare('SELECT * FROM tasks WHERE id = ?');
const task = stmt.get(taskId) as Task;
const stmt = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?');
const task = stmt.get(taskId, workspaceId) as Task;
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
@ -73,6 +78,7 @@ export async function PUT(
const db = getDatabase();
const resolvedParams = await params;
const taskId = parseInt(resolvedParams.id);
const workspaceId = auth.user.workspace_id ?? 1;
const validated = await validateBody(request, updateTaskSchema);
if ('error' in validated) return validated.error;
const body = validated.data;
@ -82,7 +88,9 @@ export async function PUT(
}
// Get current task for comparison
const currentTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task;
const currentTask = db
.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
.get(taskId, workspaceId) as Task;
if (!currentTask) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
@ -116,7 +124,7 @@ export async function PUT(
updateParams.push(description);
}
if (status !== undefined) {
if (status === 'done' && !hasAegisApproval(db, taskId)) {
if (status === 'done' && !hasAegisApproval(db, taskId, workspaceId)) {
return NextResponse.json(
{ error: 'Aegis approval is required to move task to done.' },
{ status: 403 }
@ -156,7 +164,7 @@ export async function PUT(
fieldsToUpdate.push('updated_at = ?');
updateParams.push(now);
updateParams.push(taskId);
updateParams.push(taskId, workspaceId);
if (fieldsToUpdate.length === 1) { // Only updated_at
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
@ -165,7 +173,7 @@ export async function PUT(
const stmt = db.prepare(`
UPDATE tasks
SET ${fieldsToUpdate.join(', ')}
WHERE id = ?
WHERE id = ? AND workspace_id = ?
`);
stmt.run(...updateParams);
@ -184,7 +192,8 @@ export async function PUT(
'Task Status Updated',
`Task "${currentTask.title}" status changed to ${status}`,
'task',
taskId
taskId,
workspaceId
);
}
}
@ -194,14 +203,15 @@ export async function PUT(
// Create notification for new assignee
if (assigned_to) {
db_helpers.ensureTaskSubscription(taskId, assigned_to);
db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId);
db_helpers.createNotification(
assigned_to,
'assignment',
'Task Assigned',
`You have been assigned to task: ${currentTask.title}`,
'task',
taskId
taskId,
workspaceId
);
}
}
@ -220,7 +230,7 @@ export async function PUT(
'task_updated',
'task',
taskId,
getUserFromRequest(request)?.username || 'system',
auth.user.username,
`Task updated: ${changes.join(', ')}`,
{
changes: changes,
@ -231,12 +241,15 @@ export async function PUT(
assigned_to: currentTask.assigned_to
},
newValues: { title, status, priority, assigned_to }
}
},
workspaceId
);
}
// Fetch updated task
const updatedTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task;
const updatedTask = db
.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
.get(taskId, workspaceId) as Task;
const parsedTask = {
...updatedTask,
tags: updatedTask.tags ? JSON.parse(updatedTask.tags) : [],
@ -270,34 +283,38 @@ export async function DELETE(
const db = getDatabase();
const resolvedParams = await params;
const taskId = parseInt(resolvedParams.id);
const workspaceId = auth.user.workspace_id ?? 1;
if (isNaN(taskId)) {
return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
}
// Get task before deletion for logging
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task;
const task = db
.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
.get(taskId, workspaceId) as Task;
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
// Delete task (cascades will handle comments)
const stmt = db.prepare('DELETE FROM tasks WHERE id = ?');
stmt.run(taskId);
const stmt = db.prepare('DELETE FROM tasks WHERE id = ? AND workspace_id = ?');
stmt.run(taskId, workspaceId);
// Log deletion
db_helpers.logActivity(
'task_deleted',
'task',
taskId,
getUserFromRequest(request)?.username || 'system',
auth.user.username,
`Deleted task: ${task.title}`,
{
title: task.title,
status: task.status,
assigned_to: task.assigned_to
}
},
workspaceId
);
// Broadcast to SSE clients

View File

@ -6,13 +6,13 @@ import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger';
import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): boolean {
function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number, workspaceId: number): boolean {
const review = db.prepare(`
SELECT status FROM quality_reviews
WHERE task_id = ? AND reviewer = 'aegis'
WHERE task_id = ? AND reviewer = 'aegis' AND workspace_id = ?
ORDER BY created_at DESC
LIMIT 1
`).get(taskId) as { status?: string } | undefined
`).get(taskId, workspaceId) as { status?: string } | undefined
return review?.status === 'approved'
}
@ -26,6 +26,7 @@ export async function GET(request: NextRequest) {
try {
const db = getDatabase();
const workspaceId = auth.user.workspace_id;
const { searchParams } = new URL(request.url);
// Parse query parameters
@ -36,8 +37,8 @@ export async function GET(request: NextRequest) {
const offset = parseInt(searchParams.get('offset') || '0');
// Build dynamic query
let query = 'SELECT * FROM tasks WHERE 1=1';
const params: any[] = [];
let query = 'SELECT * FROM tasks WHERE workspace_id = ?';
const params: any[] = [workspaceId];
if (status) {
query += ' AND status = ?';
@ -68,8 +69,8 @@ export async function GET(request: NextRequest) {
}));
// Get total count for pagination
let countQuery = 'SELECT COUNT(*) as total FROM tasks WHERE 1=1';
const countParams: any[] = [];
let countQuery = 'SELECT COUNT(*) as total FROM tasks WHERE workspace_id = ?';
const countParams: any[] = [workspaceId];
if (status) {
countQuery += ' AND status = ?';
countParams.push(status);
@ -103,6 +104,7 @@ export async function POST(request: NextRequest) {
try {
const db = getDatabase();
const workspaceId = auth.user.workspace_id;
const validated = await validateBody(request, createTaskSchema);
if ('error' in validated) return validated.error;
const body = validated.data;
@ -122,7 +124,7 @@ export async function POST(request: NextRequest) {
} = body;
// Check for duplicate title
const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ?').get(title);
const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ? AND workspace_id = ?').get(title, workspaceId);
if (existingTask) {
return NextResponse.json({ error: 'Task with this title already exists' }, { status: 409 });
}
@ -132,8 +134,8 @@ export async function POST(request: NextRequest) {
const stmt = db.prepare(`
INSERT INTO tasks (
title, description, status, priority, assigned_to, created_by,
created_at, updated_at, due_date, estimated_hours, tags, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const dbResult = stmt.run(
@ -148,7 +150,8 @@ export async function POST(request: NextRequest) {
due_date,
estimated_hours,
JSON.stringify(tags),
JSON.stringify(metadata)
JSON.stringify(metadata),
workspaceId
);
const taskId = dbResult.lastInsertRowid as number;
@ -159,27 +162,28 @@ export async function POST(request: NextRequest) {
status,
priority,
assigned_to
});
}, workspaceId);
if (created_by) {
db_helpers.ensureTaskSubscription(taskId, created_by)
db_helpers.ensureTaskSubscription(taskId, created_by, workspaceId)
}
// Create notification if assigned
if (assigned_to) {
db_helpers.ensureTaskSubscription(taskId, assigned_to)
db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId)
db_helpers.createNotification(
assigned_to,
'assignment',
'Task Assigned',
`You have been assigned to task: ${title}`,
'task',
taskId
taskId,
workspaceId
);
}
// Fetch the created task
const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId) as Task;
const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId) as Task;
const parsedTask = {
...createdTask,
tags: JSON.parse(createdTask.tags || '[]'),
@ -208,6 +212,7 @@ export async function PUT(request: NextRequest) {
try {
const db = getDatabase();
const workspaceId = auth.user.workspace_id;
const validated = await validateBody(request, bulkUpdateTaskStatusSchema);
if ('error' in validated) return validated.error;
const { tasks } = validated.data;
@ -217,20 +222,21 @@ export async function PUT(request: NextRequest) {
const updateStmt = db.prepare(`
UPDATE tasks
SET status = ?, updated_at = ?
WHERE id = ?
WHERE id = ? AND workspace_id = ?
`);
const actor = auth.user.username
const transaction = db.transaction((tasksToUpdate: any[]) => {
for (const task of tasksToUpdate) {
const oldTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(task.id) as Task;
const oldTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(task.id, workspaceId) as Task;
if (!oldTask) continue;
if (task.status === 'done' && !hasAegisApproval(db, task.id)) {
if (task.status === 'done' && !hasAegisApproval(db, task.id, workspaceId)) {
throw new Error(`Aegis approval required for task ${task.id}`)
}
updateStmt.run(task.status, now, task.id);
updateStmt.run(task.status, now, task.id, workspaceId);
// Log status change if different
if (oldTask && oldTask.status !== task.status) {
@ -240,7 +246,8 @@ export async function PUT(request: NextRequest) {
task.id,
actor,
`Task moved from ${oldTask.status} to ${task.status}`,
{ oldStatus: oldTask.status, newStatus: task.status }
{ oldStatus: oldTask.status, newStatus: task.status },
workspaceId
);
}
}

View File

@ -0,0 +1,64 @@
'use client'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface MarkdownRendererProps {
content: string
preview?: boolean
}
function getPreviewContent(content: string): string {
const firstParagraph = content.trim().split(/\n\s*\n/)[0] || ''
if (firstParagraph.length <= 240) return firstParagraph
return `${firstParagraph.slice(0, 240)}...`
}
export function MarkdownRenderer({ content, preview = false }: MarkdownRendererProps) {
if (!content?.trim()) return null
const markdownContent = preview ? getPreviewContent(content) : content
return (
<div className={`prose prose-invert max-w-none ${preview ? 'text-xs' : 'text-sm'}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => <h1 className={`${preview ? 'text-sm' : 'text-xl'} font-semibold mb-2`}>{children}</h1>,
h2: ({ children }) => <h2 className={`${preview ? 'text-xs' : 'text-lg'} font-semibold mb-2`}>{children}</h2>,
h3: ({ children }) => <h3 className={`${preview ? 'text-xs' : 'text-base'} font-semibold mb-1`}>{children}</h3>,
p: ({ children }) => <p className={`text-foreground/85 ${preview ? 'text-xs mb-1' : 'text-sm mb-2'} leading-relaxed`}>{children}</p>,
ul: ({ children }) => <ul className={`list-disc ml-4 ${preview ? 'text-xs mb-1' : 'text-sm mb-2'}`}>{children}</ul>,
ol: ({ children }) => <ol className={`list-decimal ml-4 ${preview ? 'text-xs mb-1' : 'text-sm mb-2'}`}>{children}</ol>,
li: ({ children }) => <li className="mb-0.5 text-foreground/85">{children}</li>,
code: ({ children, className }) => {
const isInline = !className
if (isInline) {
return <code className="bg-surface-2 text-primary px-1 py-0.5 rounded text-[0.85em]">{children}</code>
}
return (
<code className="block bg-surface-2 border border-border rounded p-2 overflow-x-auto text-[0.85em]">
{children}
</code>
)
},
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-3 italic text-muted-foreground mb-2">
{children}
</blockquote>
),
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300 underline">
{children}
</a>
),
strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
em: ({ children }) => <em className="italic text-foreground/90">{children}</em>,
}}
>
{markdownContent}
</ReactMarkdown>
</div>
)
}

View File

@ -808,6 +808,12 @@ const MODEL_TIER_LABELS: Record<string, string> = {
haiku: 'Haiku $',
}
const DEFAULT_MODEL_BY_TIER: Record<'opus' | 'sonnet' | 'haiku', string> = {
opus: 'anthropic/claude-opus-4-5',
sonnet: 'anthropic/claude-sonnet-4-20250514',
haiku: 'anthropic/claude-haiku-4-5',
}
// Enhanced Create Agent Modal with Template Wizard
export function CreateAgentModal({
onClose,
@ -818,12 +824,14 @@ export function CreateAgentModal({
}) {
const [step, setStep] = useState<1 | 2 | 3>(1)
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
const [availableModels, setAvailableModels] = useState<string[]>([])
const [formData, setFormData] = useState({
name: '',
id: '',
role: '',
emoji: '',
model: 'sonnet',
modelTier: 'sonnet' as 'opus' | 'sonnet' | 'haiku',
modelPrimary: DEFAULT_MODEL_BY_TIER.sonnet,
workspaceAccess: 'rw' as 'rw' | 'ro' | 'none',
sandboxMode: 'all' as 'all' | 'non-main',
dockerNetwork: 'none' as 'none' | 'bridge',
@ -841,6 +849,24 @@ export function CreateAgentModal({
setFormData(prev => ({ ...prev, name, id }))
}
useEffect(() => {
const loadAvailableModels = async () => {
try {
const response = await fetch('/api/status?action=models')
if (!response.ok) return
const data = await response.json()
const models = Array.isArray(data.models) ? data.models : []
const names = models
.map((model: any) => String(model.name || model.alias || '').trim())
.filter(Boolean)
setAvailableModels(Array.from(new Set<string>(names)))
} catch {
// Keep modal usable without model suggestions.
}
}
loadAvailableModels()
}, [])
// When template is selected, pre-fill form
const selectTemplate = (type: string | null) => {
setSelectedTemplate(type)
@ -851,7 +877,8 @@ export function CreateAgentModal({
...prev,
role: tmpl.theme,
emoji: tmpl.emoji,
model: tmpl.modelTier === 'opus' ? 'opus' : tmpl.modelTier === 'haiku' ? 'haiku' : 'sonnet',
modelTier: tmpl.modelTier,
modelPrimary: DEFAULT_MODEL_BY_TIER[tmpl.modelTier],
workspaceAccess: type === 'researcher' || type === 'content-creator' ? 'none' : type === 'reviewer' || type === 'security-auditor' ? 'ro' : 'rw',
sandboxMode: type === 'orchestrator' ? 'non-main' : 'all',
dockerNetwork: type === 'developer' || type === 'specialist-dev' ? 'bridge' : 'none',
@ -868,6 +895,7 @@ export function CreateAgentModal({
setIsCreating(true)
setError(null)
try {
const primaryModel = formData.modelPrimary.trim() || DEFAULT_MODEL_BY_TIER[formData.modelTier]
const response = await fetch('/api/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -878,7 +906,7 @@ export function CreateAgentModal({
template: selectedTemplate || undefined,
write_to_gateway: formData.write_to_gateway,
gateway_config: {
model: { primary: `anthropic/claude-${formData.model === 'opus' ? 'opus-4-5' : formData.model === 'haiku' ? 'haiku-4-5' : 'sonnet-4-20250514'}` },
model: { primary: primaryModel },
identity: { name: formData.name, theme: formData.role, emoji: formData.emoji },
sandbox: {
mode: formData.sandboxMode,
@ -1033,14 +1061,18 @@ export function CreateAgentModal({
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Model</label>
<label className="block text-sm text-muted-foreground mb-1">Model Tier</label>
<div className="flex gap-2">
{(['opus', 'sonnet', 'haiku'] as const).map(tier => (
<button
key={tier}
onClick={() => setFormData(prev => ({ ...prev, model: tier }))}
onClick={() => setFormData(prev => ({
...prev,
modelTier: tier,
modelPrimary: DEFAULT_MODEL_BY_TIER[tier],
}))}
className={`flex-1 px-3 py-2 text-sm rounded-md border transition-smooth ${
formData.model === tier ? MODEL_TIER_COLORS[tier] + ' border' : 'bg-surface-1 text-muted-foreground border-border'
formData.modelTier === tier ? MODEL_TIER_COLORS[tier] + ' border' : 'bg-surface-1 text-muted-foreground border-border'
}`}
>
{MODEL_TIER_LABELS[tier]}
@ -1049,6 +1081,23 @@ export function CreateAgentModal({
</div>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1">Primary Model</label>
<input
type="text"
value={formData.modelPrimary}
onChange={(e) => setFormData(prev => ({ ...prev, modelPrimary: e.target.value }))}
list="create-agent-model-suggestions"
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50 font-mono text-sm"
placeholder={DEFAULT_MODEL_BY_TIER[formData.modelTier]}
/>
<datalist id="create-agent-model-suggestions">
{availableModels.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm text-muted-foreground mb-1">Workspace</label>
@ -1114,8 +1163,9 @@ export function CreateAgentModal({
<div className="grid grid-cols-2 gap-2 text-sm">
<div><span className="text-muted-foreground">ID:</span> <span className="text-foreground font-mono">{formData.id}</span></div>
<div><span className="text-muted-foreground">Template:</span> <span className="text-foreground">{selectedTemplateData?.label || 'Custom'}</span></div>
<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>
<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>
<div><span className="text-muted-foreground">Tools:</span> <span className="text-foreground">{selectedTemplateData?.toolCount || 'Custom'}</span></div>
<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>
<div><span className="text-muted-foreground">Workspace:</span> <span className="text-foreground">{formData.workspaceAccess}</span></div>
<div><span className="text-muted-foreground">Sandbox:</span> <span className="text-foreground">{formData.sandboxMode}</span></div>
<div><span className="text-muted-foreground">Network:</span> <span className="text-foreground">{formData.dockerNetwork}</span></div>

View File

@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useSmartPoll } from '@/lib/use-smart-poll'
import { AgentAvatar } from '@/components/ui/agent-avatar'
import {
OverviewTab,
SoulTab,
@ -319,9 +320,12 @@ export function AgentSquadPanelPhase3() {
>
{/* Agent Header */}
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold text-foreground text-lg">{agent.name}</h3>
<p className="text-muted-foreground text-sm">{agent.role}</p>
<div className="flex items-center gap-2 min-w-0">
<AgentAvatar name={agent.name} size="md" />
<div className="min-w-0">
<h3 className="font-semibold text-foreground text-lg truncate">{agent.name}</h3>
<p className="text-muted-foreground text-sm truncate">{agent.role}</p>
</div>
</div>
<div className="flex items-center gap-2">

View File

@ -8,6 +8,43 @@ interface NewJobForm {
schedule: string
command: string
description: string
model: string
}
type CalendarViewMode = 'agenda' | 'day' | 'week' | 'month'
function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
function addDays(date: Date, days: number): Date {
const next = new Date(date)
next.setDate(next.getDate() + days)
return next
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
function getWeekStart(date: Date): Date {
const day = date.getDay()
const diffToMonday = (day + 6) % 7
return addDays(startOfDay(date), -diffToMonday)
}
function getMonthStartGrid(date: Date): Date {
const firstOfMonth = new Date(date.getFullYear(), date.getMonth(), 1)
const day = firstOfMonth.getDay()
return addDays(firstOfMonth, -day)
}
function formatDateLabel(date: Date): string {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
export function CronManagementPanel() {
@ -16,11 +53,18 @@ export function CronManagementPanel() {
const [showAddForm, setShowAddForm] = useState(false)
const [selectedJob, setSelectedJob] = useState<CronJob | null>(null)
const [jobLogs, setJobLogs] = useState<any[]>([])
const [availableModels, setAvailableModels] = useState<string[]>([])
const [calendarView, setCalendarView] = useState<CalendarViewMode>('week')
const [calendarDate, setCalendarDate] = useState<Date>(startOfDay(new Date()))
const [searchQuery, setSearchQuery] = useState('')
const [agentFilter, setAgentFilter] = useState('all')
const [stateFilter, setStateFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
const [newJob, setNewJob] = useState<NewJobForm>({
name: '',
schedule: '0 * * * *', // Every hour
command: '',
description: ''
description: '',
model: ''
})
const formatRelativeTime = (timestamp: string | number, future = false) => {
@ -56,6 +100,24 @@ export function CronManagementPanel() {
loadCronJobs()
}, [loadCronJobs])
useEffect(() => {
const loadAvailableModels = async () => {
try {
const response = await fetch('/api/status?action=models')
if (!response.ok) return
const data = await response.json()
const models = Array.isArray(data.models) ? data.models : []
const names = models
.map((model: any) => String(model.name || model.alias || '').trim())
.filter(Boolean)
setAvailableModels(Array.from(new Set<string>(names)))
} catch {
// Keep cron form usable even when model discovery is unavailable.
}
}
loadAvailableModels()
}, [])
const loadJobLogs = async (jobName: string) => {
try {
const response = await fetch(`/api/cron?action=logs&job=${encodeURIComponent(jobName)}`)
@ -130,7 +192,8 @@ export function CronManagementPanel() {
action: 'add',
jobName: newJob.name,
schedule: newJob.schedule,
command: newJob.command
command: newJob.command,
...(newJob.model.trim() ? { model: newJob.model.trim() } : {})
})
})
@ -139,7 +202,8 @@ export function CronManagementPanel() {
name: '',
schedule: '0 * * * *',
command: '',
description: ''
description: '',
model: ''
})
setShowAddForm(false)
await loadCronJobs()
@ -217,6 +281,82 @@ export function CronManagementPanel() {
{ label: 'Monthly (1st)', value: '0 0 1 * *' },
]
const uniqueAgents = Array.from(
new Set(
cronJobs
.map((job) => (job.agentId || '').trim())
.filter(Boolean)
)
)
const filteredJobs = cronJobs.filter((job) => {
const query = searchQuery.trim().toLowerCase()
const matchesQuery =
!query ||
job.name.toLowerCase().includes(query) ||
job.command.toLowerCase().includes(query) ||
(job.agentId || '').toLowerCase().includes(query) ||
(job.model || '').toLowerCase().includes(query)
const matchesAgent = agentFilter === 'all' || (job.agentId || '') === agentFilter
const matchesState =
stateFilter === 'all' ||
(stateFilter === 'enabled' && job.enabled) ||
(stateFilter === 'disabled' && !job.enabled)
return matchesQuery && matchesAgent && matchesState
})
const agendaJobs = [...filteredJobs].sort((a, b) => {
const aRun = typeof a.nextRun === 'number' ? a.nextRun : Number.POSITIVE_INFINITY
const bRun = typeof b.nextRun === 'number' ? b.nextRun : Number.POSITIVE_INFINITY
return aRun - bRun
})
const dayStart = startOfDay(calendarDate)
const dayEnd = addDays(dayStart, 1)
const dayJobs = filteredJobs
.filter((job) => typeof job.nextRun === 'number' && job.nextRun >= dayStart.getTime() && job.nextRun < dayEnd.getTime())
.sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0))
const weekStart = getWeekStart(calendarDate)
const weekDays = Array.from({ length: 7 }, (_, idx) => addDays(weekStart, idx))
const jobsByWeekDay = weekDays.map((date) => {
const start = startOfDay(date).getTime()
const end = addDays(date, 1).getTime()
const jobs = filteredJobs
.filter((job) => typeof job.nextRun === 'number' && job.nextRun >= start && job.nextRun < end)
.sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0))
return { date, jobs }
})
const monthGridStart = getMonthStartGrid(calendarDate)
const monthDays = Array.from({ length: 42 }, (_, idx) => addDays(monthGridStart, idx))
const jobsByMonthDay = monthDays.map((date) => {
const start = startOfDay(date).getTime()
const end = addDays(date, 1).getTime()
const jobs = filteredJobs
.filter((job) => typeof job.nextRun === 'number' && job.nextRun >= start && job.nextRun < end)
.sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0))
return { date, jobs }
})
const moveCalendar = (direction: -1 | 1) => {
setCalendarDate((prev) => {
if (calendarView === 'day') return addDays(prev, direction)
if (calendarView === 'week') return addDays(prev, direction * 7)
if (calendarView === 'month') return new Date(prev.getFullYear(), prev.getMonth() + direction, 1)
return addDays(prev, direction * 7)
})
}
const calendarRangeLabel =
calendarView === 'day'
? calendarDate.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric', year: 'numeric' })
: calendarView === 'week'
? `${formatDateLabel(weekDays[0])} - ${formatDateLabel(weekDays[6])}`
: calendarDate.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
return (
<div className="p-6 space-y-6">
<div className="border-b border-border pb-4">
@ -246,6 +386,176 @@ export function CronManagementPanel() {
</div>
<div className="grid lg:grid-cols-2 gap-6">
{/* Calendar View - Phase A (read-only) */}
<div className="lg:col-span-2 bg-card border border-border rounded-lg p-6">
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold">Calendar View</h2>
<p className="text-sm text-muted-foreground">Read-only schedule visibility across all cron jobs</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => moveCalendar(-1)}
className="px-2 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
Prev
</button>
<button
onClick={() => setCalendarDate(startOfDay(new Date()))}
className="px-3 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors text-sm"
>
Today
</button>
<button
onClick={() => moveCalendar(1)}
className="px-2 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
Next
</button>
<div className="text-sm font-medium text-foreground ml-1">{calendarRangeLabel}</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{(['agenda', 'day', 'week', 'month'] as CalendarViewMode[]).map((mode) => (
<button
key={mode}
onClick={() => setCalendarView(mode)}
className={`px-3 py-1.5 rounded text-sm border transition-colors ${
calendarView === mode
? 'bg-primary text-primary-foreground border-primary'
: 'border-border text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
{mode === 'agenda' ? 'Agenda' : mode.charAt(0).toUpperCase() + mode.slice(1)}
</button>
))}
</div>
<div className="grid md:grid-cols-3 gap-3">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search jobs, agents, models..."
className="px-3 py-2 border border-border rounded-md bg-background text-foreground text-sm"
/>
<select
value={agentFilter}
onChange={(e) => setAgentFilter(e.target.value)}
className="px-3 py-2 border border-border rounded-md bg-background text-foreground text-sm"
>
<option value="all">All Agents</option>
{uniqueAgents.map((agentId) => (
<option key={agentId} value={agentId}>
{agentId}
</option>
))}
</select>
<select
value={stateFilter}
onChange={(e) => setStateFilter(e.target.value as 'all' | 'enabled' | 'disabled')}
className="px-3 py-2 border border-border rounded-md bg-background text-foreground text-sm"
>
<option value="all">All States</option>
<option value="enabled">Enabled</option>
<option value="disabled">Disabled</option>
</select>
</div>
{calendarView === 'agenda' && (
<div className="border border-border rounded-lg overflow-hidden">
<div className="max-h-80 overflow-y-auto divide-y divide-border">
{agendaJobs.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No jobs match the current filters.</div>
) : (
agendaJobs.map((job) => (
<div key={`agenda-${job.id || job.name}`} className="p-3 flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div>
<div className="font-medium text-foreground">{job.name}</div>
<div className="text-xs text-muted-foreground">
{job.agentId || 'system'} · {job.enabled ? 'enabled' : 'disabled'} · {job.schedule}
</div>
</div>
<div className="text-sm text-muted-foreground">
{job.nextRun ? new Date(job.nextRun).toLocaleString() : 'No upcoming run'}
</div>
</div>
))
)}
</div>
</div>
)}
{calendarView === 'day' && (
<div className="border border-border rounded-lg p-3">
{dayJobs.length === 0 ? (
<div className="text-sm text-muted-foreground">No scheduled jobs for this day.</div>
) : (
<div className="space-y-2">
{dayJobs.map((job) => (
<div key={`day-${job.id || job.name}`} className="p-2 rounded border border-border bg-secondary/40">
<div className="text-sm font-medium text-foreground">{job.name}</div>
<div className="text-xs text-muted-foreground">
{job.nextRun ? new Date(job.nextRun).toLocaleTimeString() : 'Unknown time'} · {job.agentId || 'system'} · {job.enabled ? 'enabled' : 'disabled'}
</div>
</div>
))}
</div>
)}
</div>
)}
{calendarView === 'week' && (
<div className="grid grid-cols-1 md:grid-cols-7 gap-2">
{jobsByWeekDay.map(({ date, jobs }) => (
<div key={`week-${date.toISOString()}`} className="border border-border rounded-lg p-2 min-h-36">
<div className={`text-xs font-medium mb-2 ${isSameDay(date, new Date()) ? 'text-primary' : 'text-muted-foreground'}`}>
{date.toLocaleDateString(undefined, { weekday: 'short', month: 'numeric', day: 'numeric' })}
</div>
<div className="space-y-1">
{jobs.slice(0, 4).map((job) => (
<div key={`week-job-${job.id || job.name}`} className="text-xs px-2 py-1 rounded bg-secondary text-foreground truncate" title={job.name}>
{job.nextRun ? new Date(job.nextRun).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '--:--'} {job.name}
</div>
))}
{jobs.length > 4 && (
<div className="text-xs text-muted-foreground">+{jobs.length - 4} more</div>
)}
</div>
</div>
))}
</div>
)}
{calendarView === 'month' && (
<div className="grid grid-cols-7 gap-2">
{jobsByMonthDay.map(({ date, jobs }) => {
const inCurrentMonth = date.getMonth() === calendarDate.getMonth()
return (
<div
key={`month-${date.toISOString()}`}
className={`border border-border rounded-lg p-2 min-h-24 ${inCurrentMonth ? 'bg-transparent' : 'bg-secondary/30'}`}
>
<div className={`text-xs mb-1 ${isSameDay(date, new Date()) ? 'text-primary font-semibold' : inCurrentMonth ? 'text-foreground' : 'text-muted-foreground'}`}>
{date.getDate()}
</div>
<div className="space-y-1">
{jobs.slice(0, 2).map((job) => (
<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}>
{job.name}
</div>
))}
{jobs.length > 2 && <div className="text-[11px] text-muted-foreground">+{jobs.length - 2}</div>}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
{/* Job List */}
<div className="bg-card border border-border rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Scheduled Jobs</h2>
@ -304,6 +614,11 @@ export function CronManagementPanel() {
<div className="text-sm text-muted-foreground mt-1 truncate">
{job.command}
</div>
{job.model && (
<div className="text-xs text-muted-foreground mt-1">
Model: <span className="font-mono">{job.model}</span>
</div>
)}
{job.lastRun && (
<div className="text-xs text-muted-foreground mt-2">
Last run: {formatRelativeTime(job.lastRun)}
@ -368,6 +683,9 @@ export function CronManagementPanel() {
<div className="bg-secondary rounded p-3 space-y-2 text-sm">
<div><span className="text-muted-foreground">Schedule:</span> <code className="font-mono">{selectedJob.schedule}</code></div>
<div><span className="text-muted-foreground">Command:</span> <code className="font-mono text-xs">{selectedJob.command}</code></div>
{selectedJob.model && (
<div><span className="text-muted-foreground">Model:</span> <code className="font-mono text-xs">{selectedJob.model}</code></div>
)}
<div><span className="text-muted-foreground">Status:</span> {selectedJob.enabled ? '🟢 Enabled' : '🔴 Disabled'}</div>
{selectedJob.nextRun && (
<div><span className="text-muted-foreground">Next run:</span> {new Date(selectedJob.nextRun).toLocaleString()}</div>
@ -454,6 +772,26 @@ export function CronManagementPanel() {
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">Model (Optional)</label>
<input
type="text"
value={newJob.model}
onChange={(e) => setNewJob(prev => ({ ...prev, model: e.target.value }))}
list="cron-model-suggestions"
placeholder="anthropic/claude-sonnet-4-20250514"
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground font-mono text-sm"
/>
<datalist id="cron-model-suggestions">
{availableModels.map((modelName) => (
<option key={modelName} value={modelName} />
))}
</datalist>
<div className="mt-1 text-xs text-muted-foreground">
Leave empty to use the agent or gateway default model.
</div>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">Description (Optional)</label>
<input

View File

@ -3,6 +3,8 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useMissionControl } from '@/store'
import { useSmartPoll } from '@/lib/use-smart-poll'
import { AgentAvatar } from '@/components/ui/agent-avatar'
import { MarkdownRenderer } from '@/components/markdown-renderer'
interface Task {
id: number
@ -360,13 +362,22 @@ export function TaskBoardPanel() {
</div>
{task.description && (
<p className="text-foreground/80 text-xs mb-2 line-clamp-2">
{task.description}
</p>
<div className="mb-2 line-clamp-3 overflow-hidden">
<MarkdownRenderer content={task.description} preview />
</div>
)}
<div className="flex justify-between items-center text-xs text-muted-foreground">
<span>{getAgentName(task.assigned_to)}</span>
<span className="flex items-center gap-1.5 min-w-0">
{task.assigned_to ? (
<>
<AgentAvatar name={getAgentName(task.assigned_to)} size="xs" />
<span className="truncate">{getAgentName(task.assigned_to)}</span>
</>
) : (
<span>Unassigned</span>
)}
</span>
<span className="font-medium">{formatTaskTimestamp(task.created_at)}</span>
</div>
@ -620,7 +631,13 @@ function TaskDetailModal({
</button>
</div>
</div>
<p className="text-foreground/80 mb-4">{task.description || 'No description'}</p>
{task.description ? (
<div className="mb-4">
<MarkdownRenderer content={task.description} />
</div>
) : (
<p className="text-foreground/80 mb-4">No description</p>
)}
<div className="flex gap-2 mt-4">
{(['details', 'comments', 'quality'] as const).map(tab => (
<button
@ -647,7 +664,16 @@ function TaskDetailModal({
</div>
<div>
<span className="text-muted-foreground">Assigned to:</span>
<span className="text-foreground ml-2">{task.assigned_to || 'Unassigned'}</span>
<span className="text-foreground ml-2 inline-flex items-center gap-1.5">
{task.assigned_to ? (
<>
<AgentAvatar name={task.assigned_to} size="xs" />
<span>{task.assigned_to}</span>
</>
) : (
<span>Unassigned</span>
)}
</span>
</div>
<div>
<span className="text-muted-foreground">Created:</span>

View File

@ -0,0 +1,58 @@
'use client'
interface AgentAvatarProps {
name: string
size?: 'xs' | 'sm' | 'md'
className?: string
}
function getInitials(name: string): string {
const parts = name
.trim()
.split(/\s+/)
.filter(Boolean)
if (parts.length === 0) return '?'
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return `${parts[0][0] || ''}${parts[1][0] || ''}`.toUpperCase()
}
function hashString(value: string): number {
let hash = 0
for (let i = 0; i < value.length; i += 1) {
hash = (hash * 31 + value.charCodeAt(i)) >>> 0
}
return hash
}
function getAvatarColors(name: string): { backgroundColor: string; color: string } {
const hash = hashString(name.toLowerCase())
const hue = hash % 360
return {
backgroundColor: `hsl(${hue} 70% 38%)`,
color: 'hsl(0 0% 98%)',
}
}
const sizeClasses: Record<NonNullable<AgentAvatarProps['size']>, string> = {
xs: 'w-5 h-5 text-[10px]',
sm: 'w-6 h-6 text-[10px]',
md: 'w-8 h-8 text-xs',
}
export function AgentAvatar({ name, size = 'sm', className = '' }: AgentAvatarProps) {
const initials = getInitials(name)
const colors = getAvatarColors(name)
return (
<div
className={`rounded-full flex items-center justify-center font-semibold shrink-0 ${sizeClasses[size]} ${className}`}
style={colors}
title={name}
aria-label={name}
>
{initials}
</div>
)
}

View File

@ -87,7 +87,7 @@ describe('logActivity', () => {
expect(mockPrepare).toHaveBeenCalled()
expect(mockRun).toHaveBeenCalledWith(
'task_created', 'task', 1, 'alice', 'Created task', null,
'task_created', 'task', 1, 'alice', 'Created task', null, 1,
)
expect(mockBroadcast).toHaveBeenCalledWith(
'activity.created',
@ -105,7 +105,7 @@ describe('logActivity', () => {
db_helpers.logActivity('update', 'agent', 2, 'bob', 'Updated agent', data)
expect(mockRun).toHaveBeenCalledWith(
'update', 'agent', 2, 'bob', 'Updated agent', JSON.stringify(data),
'update', 'agent', 2, 'bob', 'Updated agent', JSON.stringify(data), 1,
)
})
})
@ -119,7 +119,7 @@ describe('createNotification', () => {
db_helpers.createNotification('alice', 'mention', 'Mentioned', 'You were mentioned')
expect(mockRun).toHaveBeenCalledWith(
'alice', 'mention', 'Mentioned', 'You were mentioned', undefined, undefined,
'alice', 'mention', 'Mentioned', 'You were mentioned', undefined, undefined, 1,
)
expect(mockBroadcast).toHaveBeenCalledWith(
'notification.created',
@ -135,7 +135,7 @@ describe('createNotification', () => {
db_helpers.createNotification('bob', 'alert', 'Alert', 'CPU high', 'agent', 5)
expect(mockRun).toHaveBeenCalledWith(
'bob', 'alert', 'Alert', 'CPU high', 'agent', 5,
'bob', 'alert', 'Alert', 'CPU high', 'agent', 5, 1,
)
})
})

View File

@ -23,6 +23,7 @@ export interface User {
username: string
display_name: string
role: 'admin' | 'operator' | 'viewer'
workspace_id: number
provider?: 'local' | 'google'
email?: string | null
avatar_url?: string | null
@ -36,6 +37,7 @@ export interface UserSession {
id: number
token: string
user_id: number
workspace_id: number
expires_at: number
created_at: number
ip_address: string | null
@ -51,6 +53,7 @@ interface SessionQueryRow {
email: string | null
avatar_url: string | null
is_approved: number
workspace_id: number
created_at: number
updated_at: number
last_login_at: number | null
@ -66,6 +69,7 @@ interface UserQueryRow {
email: string | null
avatar_url: string | null
is_approved: number
workspace_id: number
created_at: number
updated_at: number
last_login_at: number | null
@ -75,16 +79,37 @@ interface UserQueryRow {
// Session management
const SESSION_DURATION = 7 * 24 * 60 * 60 // 7 days in seconds
export function createSession(userId: number, ipAddress?: string, userAgent?: string): { token: string; expiresAt: number } {
function getDefaultWorkspaceId(): number {
try {
const db = getDatabase()
const row = db.prepare(`SELECT id FROM workspaces WHERE slug = 'default' LIMIT 1`).get() as { id?: number } | undefined
return row?.id || 1
} catch {
return 1
}
}
export function getWorkspaceIdFromRequest(request: Request): number {
const user = getUserFromRequest(request)
return user?.workspace_id || getDefaultWorkspaceId()
}
export function createSession(
userId: number,
ipAddress?: string,
userAgent?: string,
workspaceId?: number
): { token: string; expiresAt: number } {
const db = getDatabase()
const token = randomBytes(32).toString('hex')
const now = Math.floor(Date.now() / 1000)
const expiresAt = now + SESSION_DURATION
const resolvedWorkspaceId = workspaceId ?? ((db.prepare('SELECT workspace_id FROM users WHERE id = ?').get(userId) as { workspace_id?: number } | undefined)?.workspace_id || getDefaultWorkspaceId())
db.prepare(`
INSERT INTO user_sessions (token, user_id, expires_at, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?)
`).run(token, userId, expiresAt, ipAddress || null, userAgent || null)
INSERT INTO user_sessions (token, user_id, expires_at, ip_address, user_agent, workspace_id)
VALUES (?, ?, ?, ?, ?, ?)
`).run(token, userId, expiresAt, ipAddress || null, userAgent || null, resolvedWorkspaceId)
// Update user's last login
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 }) |
const now = Math.floor(Date.now() / 1000)
const row = db.prepare(`
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,
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,
s.id as session_id
FROM user_sessions s
JOIN users u ON u.id = s.user_id
@ -115,6 +140,7 @@ export function validateSession(token: string): (User & { sessionId: number }) |
username: row.username,
display_name: row.display_name,
role: row.role,
workspace_id: row.workspace_id || getDefaultWorkspaceId(),
provider: row.provider || 'local',
email: row.email ?? null,
avatar_url: row.avatar_url ?? null,
@ -149,6 +175,7 @@ export function authenticateUser(username: string, password: string): User | nul
username: row.username,
display_name: row.display_name,
role: row.role,
workspace_id: row.workspace_id || getDefaultWorkspaceId(),
provider: row.provider || 'local',
email: row.email ?? null,
avatar_url: row.avatar_url ?? null,
@ -161,13 +188,13 @@ export function authenticateUser(username: string, password: string): User | nul
export function getUserById(id: number): User | null {
const db = getDatabase()
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
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
return row || null
}
export function getAllUsers(): User[] {
const db = getDatabase()
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[]
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[]
}
export function createUser(
@ -175,15 +202,16 @@ export function createUser(
password: string,
displayName: string,
role: User['role'] = 'operator',
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 }
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 }
): User {
const db = getDatabase()
if (password.length < 12) throw new Error('Password must be at least 12 characters')
const passwordHash = hashPassword(password)
const provider = options?.provider || 'local'
const workspaceId = options?.workspace_id || getDefaultWorkspaceId()
const result = db.prepare(`
INSERT INTO users (username, display_name, password_hash, role, provider, provider_user_id, email, avatar_url, is_approved, approved_by, approved_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (username, display_name, password_hash, role, provider, provider_user_id, email, avatar_url, is_approved, approved_by, approved_at, workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
username,
displayName,
@ -196,6 +224,7 @@ export function createUser(
typeof options?.is_approved === 'number' ? options.is_approved : 1,
options?.approved_by || null,
options?.approved_at || null,
workspaceId,
)
return getUserById(Number(result.lastInsertRowid))!
@ -255,6 +284,7 @@ export function getUserFromRequest(request: Request): User | null {
username: 'api',
display_name: 'API Access',
role: 'admin',
workspace_id: getDefaultWorkspaceId(),
created_at: 0,
updated_at: 0,
last_login_at: null,

View File

@ -233,14 +233,22 @@ export const db_helpers = {
/**
* Log an activity to the activity stream
*/
logActivity: (type: string, entity_type: string, entity_id: number, actor: string, description: string, data?: any) => {
logActivity: (
type: string,
entity_type: string,
entity_id: number,
actor: string,
description: string,
data?: any,
workspaceId: number = 1
) => {
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO activities (type, entity_type, entity_id, actor, description, data)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO activities (type, entity_type, entity_id, actor, description, data, workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(type, entity_type, entity_id, actor, description, data ? JSON.stringify(data) : null);
const result = stmt.run(type, entity_type, entity_id, actor, description, data ? JSON.stringify(data) : null, workspaceId);
const activityPayload = {
id: result.lastInsertRowid,
@ -251,6 +259,7 @@ export const db_helpers = {
description,
data: data || null,
created_at: Math.floor(Date.now() / 1000),
workspace_id: workspaceId,
};
// Broadcast to SSE clients (webhooks listen here too)
@ -260,14 +269,22 @@ export const db_helpers = {
/**
* Create notification for @mentions
*/
createNotification: (recipient: string, type: string, title: string, message: string, source_type?: string, source_id?: number) => {
createNotification: (
recipient: string,
type: string,
title: string,
message: string,
source_type?: string,
source_id?: number,
workspaceId: number = 1
) => {
const db = getDatabase();
const stmt = db.prepare(`
INSERT INTO notifications (recipient, type, title, message, source_type, source_id)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO notifications (recipient, type, title, message, source_type, source_id, workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(recipient, type, title, message, source_type, source_id);
const result = stmt.run(recipient, type, title, message, source_type, source_id, workspaceId);
const notificationPayload = {
id: result.lastInsertRowid,
@ -278,6 +295,7 @@ export const db_helpers = {
source_type: source_type || null,
source_id: source_id || null,
created_at: Math.floor(Date.now() / 1000),
workspace_id: workspaceId,
};
// Broadcast to SSE clients (webhooks listen here too)
@ -304,19 +322,19 @@ export const db_helpers = {
/**
* Update agent status and last seen
*/
updateAgentStatus: (agentName: string, status: Agent['status'], activity?: string) => {
updateAgentStatus: (agentName: string, status: Agent['status'], activity?: string, workspaceId: number = 1) => {
const db = getDatabase();
const now = Math.floor(Date.now() / 1000);
// Get agent ID before update
const agent = db.prepare('SELECT id FROM agents WHERE name = ?').get(agentName) as { id: number } | undefined;
const agent = db.prepare('SELECT id FROM agents WHERE name = ? AND workspace_id = ?').get(agentName, workspaceId) as { id: number } | undefined;
const stmt = db.prepare(`
UPDATE agents
SET status = ?, last_seen = ?, last_activity = ?, updated_at = ?
WHERE name = ?
WHERE name = ? AND workspace_id = ?
`);
stmt.run(status, now, activity, now, agentName);
stmt.run(status, now, activity, now, agentName, workspaceId);
// Broadcast agent status change to SSE clients
if (agent) {
@ -330,7 +348,7 @@ export const db_helpers = {
}
// Log the status change
db_helpers.logActivity('agent_status_change', 'agent', agent?.id || 0, agentName, `Agent status changed to ${status}`, { status, activity });
db_helpers.logActivity('agent_status_change', 'agent', agent?.id || 0, agentName, `Agent status changed to ${status}`, { status, activity }, workspaceId);
},
/**
@ -350,52 +368,57 @@ export const db_helpers = {
/**
* Get unread notifications for recipient
*/
getUnreadNotifications: (recipient: string): Notification[] => {
getUnreadNotifications: (recipient: string, workspaceId: number = 1): Notification[] => {
const db = getDatabase();
const stmt = db.prepare(`
SELECT * FROM notifications
WHERE recipient = ? AND read_at IS NULL
WHERE recipient = ? AND read_at IS NULL AND workspace_id = ?
ORDER BY created_at DESC
`);
return stmt.all(recipient) as Notification[];
return stmt.all(recipient, workspaceId) as Notification[];
},
/**
* Mark notification as read
*/
markNotificationRead: (notificationId: number) => {
markNotificationRead: (notificationId: number, workspaceId: number = 1) => {
const db = getDatabase();
const stmt = db.prepare(`
UPDATE notifications
SET read_at = ?
WHERE id = ?
WHERE id = ? AND workspace_id = ?
`);
stmt.run(Math.floor(Date.now() / 1000), notificationId);
stmt.run(Math.floor(Date.now() / 1000), notificationId, workspaceId);
},
/**
* Ensure an agent is subscribed to a task
*/
ensureTaskSubscription: (taskId: number, agentName: string) => {
ensureTaskSubscription: (taskId: number, agentName: string, workspaceId: number = 1) => {
if (!agentName) return;
const db = getDatabase();
const stmt = db.prepare(`
INSERT OR IGNORE INTO task_subscriptions (task_id, agent_name)
VALUES (?, ?)
SELECT t.id, ?
FROM tasks t
WHERE t.id = ? AND t.workspace_id = ?
`);
stmt.run(taskId, agentName);
stmt.run(agentName, taskId, workspaceId);
},
/**
* Get subscribers for a task
*/
getTaskSubscribers: (taskId: number): string[] => {
getTaskSubscribers: (taskId: number, workspaceId: number = 1): string[] => {
const db = getDatabase();
const rows = db.prepare(`
SELECT agent_name FROM task_subscriptions WHERE task_id = ?
`).all(taskId) as Array<{ agent_name: string }>;
SELECT ts.agent_name
FROM task_subscriptions ts
JOIN tasks t ON t.id = ts.task_id
WHERE ts.task_id = ? AND t.workspace_id = ?
`).all(taskId, workspaceId) as Array<{ agent_name: string }>;
return rows.map((row) => row.agent_name);
}
};

View File

@ -547,6 +547,102 @@ const migrations: Migration[] = [
db.exec(`CREATE INDEX IF NOT EXISTS idx_claude_sessions_active ON claude_sessions(is_active) WHERE is_active = 1`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_claude_sessions_project ON claude_sessions(project_slug)`)
}
},
{
id: '021_workspace_isolation_phase1',
up: (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
`)
db.prepare(`
INSERT OR IGNORE INTO workspaces (id, slug, name, created_at, updated_at)
VALUES (1, 'default', 'Default Workspace', unixepoch(), unixepoch())
`).run()
const addWorkspaceIdColumn = (table: string) => {
const tableExists = db
.prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = ?`)
.get(table) as { ok?: number } | undefined
if (!tableExists?.ok) return
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>
if (!cols.some((c) => c.name === 'workspace_id')) {
db.exec(`ALTER TABLE ${table} ADD COLUMN workspace_id INTEGER NOT NULL DEFAULT 1`)
}
db.exec(`UPDATE ${table} SET workspace_id = COALESCE(workspace_id, 1)`)
}
const scopedTables = [
'users',
'user_sessions',
'tasks',
'agents',
'comments',
'activities',
'notifications',
'quality_reviews',
'standup_reports',
]
for (const table of scopedTables) {
addWorkspaceIdColumn(table)
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_workspaces_slug ON workspaces(slug)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_users_workspace_id ON users(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_sessions_workspace_id ON user_sessions(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_agents_workspace_id ON agents(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_workspace_id ON comments(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_activities_workspace_id ON activities(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_notifications_workspace_id ON notifications(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_quality_reviews_workspace_id ON quality_reviews(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_standup_reports_workspace_id ON standup_reports(workspace_id)`)
}
},
{
id: '022_workspace_isolation_phase2',
up: (db) => {
const addWorkspaceIdColumn = (table: string) => {
const tableExists = db
.prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = ?`)
.get(table) as { ok?: number } | undefined
if (!tableExists?.ok) return
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>
if (!cols.some((c) => c.name === 'workspace_id')) {
db.exec(`ALTER TABLE ${table} ADD COLUMN workspace_id INTEGER NOT NULL DEFAULT 1`)
}
db.exec(`UPDATE ${table} SET workspace_id = COALESCE(workspace_id, 1)`)
}
const scopedTables = [
'messages',
'alert_rules',
'direct_connections',
'github_syncs',
'workflow_pipelines',
'pipeline_runs',
]
for (const table of scopedTables) {
addWorkspaceIdColumn(table)
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_workspace_id ON messages(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_alert_rules_workspace_id ON alert_rules(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_direct_connections_workspace_id ON direct_connections(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_github_syncs_workspace_id ON github_syncs(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_workflow_pipelines_workspace_id ON workflow_pipelines(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_pipeline_runs_workspace_id ON pipeline_runs(workspace_id)`)
}
}
]

View File

@ -35,6 +35,10 @@ export interface CronJob {
name: string
schedule: string
command: string
model?: string
agentId?: string
timezone?: string
delivery?: string
enabled: boolean
lastRun?: number
nextRun?: number