From f372d0a9058338e5ca78815a5f7baa6e18c388b5 Mon Sep 17 00:00:00 2001 From: ekko Date: Sun, 24 May 2026 08:46:49 +0800 Subject: [PATCH] Scope kanban and usage profile reads --- .../server/src/controllers/hermes/kanban.ts | 29 ++++++++++---- .../server/src/controllers/hermes/sessions.ts | 3 +- packages/server/src/db/hermes/sessions-db.ts | 7 ++-- tests/server/kanban-controller.test.ts | 39 ++++++++++++++++++- tests/server/sessions-controller.test.ts | 33 ++++++++++++++++ 5 files changed, 99 insertions(+), 12 deletions(-) diff --git a/packages/server/src/controllers/hermes/kanban.ts b/packages/server/src/controllers/hermes/kanban.ts index fede9ab..0cc8b6b 100644 --- a/packages/server/src/controllers/hermes/kanban.ts +++ b/packages/server/src/controllers/hermes/kanban.ts @@ -4,6 +4,7 @@ import { resolve, normalize } from 'path' import { homedir } from 'os' import * as kanbanCli from '../../services/hermes/hermes-kanban' import { isPathWithin } from '../../services/hermes/hermes-path' +import { listProfileNamesFromDisk } from '../../services/hermes/hermes-profile' import { searchSessionSummariesWithProfile, getSessionDetailFromDbWithProfile, @@ -29,8 +30,6 @@ function allowedProfileSet(ctx: Context): Set | null { } function visibleProfileSet(ctx: Context): Set | null { - const profile = requestedProfile(ctx) - if (profile) return new Set([profile]) return allowedProfileSet(ctx) } @@ -67,10 +66,26 @@ function statsForTasks(tasks: kanbanCli.KanbanTask[]): kanbanCli.KanbanStats { return { by_status, by_assignee, total: tasks.length } } -function filterAssigneesByVisibleProfiles(ctx: Context, assignees: kanbanCli.KanbanAssignee[]): kanbanCli.KanbanAssignee[] { - const visible = visibleProfileSet(ctx) - if (!visible) return assignees - return assignees.filter(assignee => visible.has(profileName(assignee.name))) +function assignableProfileNames(ctx: Context): Set | null { + const user = ctx.state?.user + if (!user) return null + if (user.role === 'super_admin') return new Set(listProfileNamesFromDisk()) + return new Set(listUserProfiles(user.id).map(profile => profile.profile_name)) +} + +function assigneesForUser(ctx: Context, assignees: kanbanCli.KanbanAssignee[]): kanbanCli.KanbanAssignee[] { + const assignable = assignableProfileNames(ctx) + if (!assignable) return assignees + + const byName = new Map() + for (const assignee of assignees) { + const name = profileName(assignee.name) + if (assignable.has(name)) byName.set(name, { ...assignee, name }) + } + for (const name of [...assignable].sort()) { + if (!byName.has(name)) byName.set(name, { name, on_disk: true, counts: null }) + } + return [...byName.values()] } async function getVisibleTasksForBoard(ctx: Context, board: string, opts: { @@ -671,7 +686,7 @@ export async function assignees(ctx: Context) { const board = requestBoard(ctx) if (!board) return try { - const assignees = filterAssigneesByVisibleProfiles(ctx, await kanbanCli.getAssignees({ board })) + const assignees = assigneesForUser(ctx, await kanbanCli.getAssignees({ board })) ctx.body = { assignees } } catch (err: any) { ctx.status = 500 diff --git a/packages/server/src/controllers/hermes/sessions.ts b/packages/server/src/controllers/hermes/sessions.ts index 17759c7..a748167 100644 --- a/packages/server/src/controllers/hermes/sessions.ts +++ b/packages/server/src/controllers/hermes/sessions.ts @@ -428,6 +428,7 @@ export async function contextLength(ctx: any) { export async function usageStats(ctx: any) { const rawDays = parseInt(String(ctx.query?.days ?? '30'), 10) const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 30 + const profile = requestedProfile(ctx) let hermes = { input_tokens: 0, @@ -443,7 +444,7 @@ export async function usageStats(ctx: any) { } try { - hermes = await getUsageStatsFromDb(days) + hermes = profile ? await getUsageStatsFromDb(days, undefined, profile) : await getUsageStatsFromDb(days) } catch (err) { logger.warn(err, 'usageStats: failed to load Hermes usage analytics from state.db') } diff --git a/packages/server/src/db/hermes/sessions-db.ts b/packages/server/src/db/hermes/sessions-db.ts index 8d62112..6f0f64c 100644 --- a/packages/server/src/db/hermes/sessions-db.ts +++ b/packages/server/src/db/hermes/sessions-db.ts @@ -572,12 +572,12 @@ function chainOrderSql(ids: string[]): string { return ids.map((_, index) => `WHEN ? THEN ${index}`).join(' ') } -async function openSessionDb() { +async function openSessionDb(profile?: string) { if (!SQLITE_AVAILABLE) { throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`) } const { DatabaseSync } = await import('node:sqlite') - const dbPath = sessionDbPath() + const dbPath = profile ? join(getProfileDir(profile), 'state.db') : sessionDbPath() try { return new DatabaseSync(dbPath, { open: true, readOnly: true }) } catch (err: any) { @@ -1104,6 +1104,7 @@ export async function getSkillUsageStatsFromDb( export async function getUsageStatsFromDb( days = 30, nowSeconds = Math.floor(Date.now() / 1000), + profile?: string, ): Promise { const empty: HermesUsageStats = { input_tokens: 0, @@ -1121,7 +1122,7 @@ export async function getUsageStatsFromDb( const normalizedDays = Number.isFinite(days) ? days : 30 const safeDays = Math.max(1, Math.floor(normalizedDays)) const since = nowSeconds - safeDays * 24 * 60 * 60 - const db = await openSessionDb() + const db = await openSessionDb(profile) try { const apiCallsExpr = tableHasColumn(db, 'sessions', 'api_call_count') diff --git a/tests/server/kanban-controller.test.ts b/tests/server/kanban-controller.test.ts index 030820d..cb8ee81 100644 --- a/tests/server/kanban-controller.test.ts +++ b/tests/server/kanban-controller.test.ts @@ -135,7 +135,7 @@ describe('kanban controller', () => { expect(mockListTasks).toHaveBeenLastCalledWith({ board: 'default', status: 'ready', assignee: undefined, tenant: undefined, includeArchived: false }) }) - it('filters kanban tasks, stats, and assignees to the requested profile', async () => { + it('filters kanban tasks, stats, and assignees to the user-bound profiles', async () => { const tasks = [ { id: 'task-1', assignee: 'research', status: 'todo' }, { id: 'task-2', assignee: 'travel', status: 'done' }, @@ -162,6 +162,43 @@ describe('kanban controller', () => { expect(assigneesCtx.body).toEqual({ assignees: [{ name: 'research', on_disk: true, counts: { todo: 1 } }] }) }) + it('loads kanban data for every profile bound to the user instead of only the active header profile', async () => { + mockListUserProfiles.mockReturnValue([{ profile_name: 'research' }, { profile_name: 'travel' }]) + const tasks = [ + { id: 'task-1', assignee: 'research', status: 'todo' }, + { id: 'task-2', assignee: 'travel', status: 'done' }, + { id: 'task-3', assignee: 'default', status: 'blocked' }, + ] + mockListTasks.mockResolvedValue(tasks) + mockGetAssignees.mockResolvedValue([ + { name: 'research', on_disk: true, counts: { todo: 1 } }, + ]) + + const state = { user: { id: 7, role: 'admin' }, profile: { name: 'research' } } + const listCtx = ctx({ state, query: { board: 'default', includeArchived: 'true' } }) + await ctrl.list(listCtx) + expect(listCtx.body).toEqual({ tasks: [tasks[0], tasks[1]] }) + + const statsCtx = ctx({ state, query: { board: 'default' } }) + await ctrl.stats(statsCtx) + expect(statsCtx.body).toEqual({ + stats: { + by_status: { todo: 1, done: 1 }, + by_assignee: { research: 1, travel: 1 }, + total: 2, + }, + }) + + const assigneesCtx = ctx({ state, query: { board: 'default' } }) + await ctrl.assignees(assigneesCtx) + expect(assigneesCtx.body).toEqual({ + assignees: [ + { name: 'research', on_disk: true, counts: { todo: 1 } }, + { name: 'travel', on_disk: true, counts: null }, + ], + }) + }) + it('defaults created kanban tasks to the requested profile and rejects unauthorized assignees', async () => { mockCreateTask.mockResolvedValue({ id: 'task-1', assignee: 'research' }) const state = { user: { id: 7, role: 'admin' }, profile: { name: 'research' } } diff --git a/tests/server/sessions-controller.test.ts b/tests/server/sessions-controller.test.ts index 7db9fb6..8abbf0b 100644 --- a/tests/server/sessions-controller.test.ts +++ b/tests/server/sessions-controller.test.ts @@ -374,6 +374,39 @@ describe('session conversations controller', () => { }) }) + it('loads usage analytics from the request-scoped profile state database', async () => { + getUsageStatsFromDbMock.mockResolvedValue({ + input_tokens: 12, + output_tokens: 6, + cache_read_tokens: 3, + cache_write_tokens: 1, + reasoning_tokens: 2, + sessions: 1, + cost: 0.01, + total_api_calls: 4, + by_model: [ + { model: 'research-model', input_tokens: 12, output_tokens: 6, cache_read_tokens: 3, cache_write_tokens: 1, reasoning_tokens: 2, sessions: 1 }, + ], + by_day: [], + }) + + const mod = await import('../../packages/server/src/controllers/hermes/sessions') + const ctx: any = { query: { days: '2' }, state: { profile: { name: 'research' } }, body: null } + await mod.usageStats(ctx) + + expect(getUsageStatsFromDbMock).toHaveBeenCalledWith(2, undefined, 'research') + expect(ctx.body).toMatchObject({ + total_input_tokens: 12, + total_output_tokens: 6, + total_sessions: 1, + total_cost: 0.01, + total_api_calls: 4, + }) + expect(ctx.body.model_usage).toEqual([ + { model: 'research-model', input_tokens: 12, output_tokens: 6, cache_read_tokens: 3, cache_write_tokens: 1, reasoning_tokens: 2, sessions: 1 }, + ]) + }) + it('keeps blank model usage as returned by state.db analytics', async () => { getLocalUsageStatsMock.mockReturnValue({ input_tokens: 3,