diff --git a/apps/agor-daemon/src/register-hooks.test.ts b/apps/agor-daemon/src/register-hooks.test.ts index 8c80b396d3..aca804ae86 100644 --- a/apps/agor-daemon/src/register-hooks.test.ts +++ b/apps/agor-daemon/src/register-hooks.test.ts @@ -21,6 +21,7 @@ import { describe, expect, it } from 'vitest'; import { enrichSessionFindResultWithRemoteRelationships, + getTrustedSessionTenantId, isPromptFlowPatchOnly, PROMPT_FLOW_PATCH_FIELDS, shouldDrainQueueAfterSessionPostTurnPatch, @@ -100,6 +101,25 @@ describe('shouldRunSessionPostTurnHooks', () => { }); }); +describe('getTrustedSessionTenantId', () => { + it('reads non-enumerable tenant metadata from session DTOs without requiring JSON exposure', () => { + const session = makeSession('session-1'); + Object.defineProperty(session, 'tenant_id', { + value: 'tenant-from-row', + enumerable: false, + }); + + expect(getTrustedSessionTenantId(session)).toBe('tenant-from-row'); + expect(Object.keys(session)).not.toContain('tenant_id'); + expect(JSON.stringify(session)).not.toContain('tenant_id'); + }); + + it('ignores absent or empty tenant metadata', () => { + expect(getTrustedSessionTenantId(makeSession('session-1'))).toBeUndefined(); + expect(getTrustedSessionTenantId({ tenant_id: '' })).toBeUndefined(); + }); +}); + describe('shouldDrainQueueAfterSessionPostTurnPatch', () => { it('drains for promptable ready sessions by default', () => { expect( diff --git a/apps/agor-daemon/src/register-hooks.ts b/apps/agor-daemon/src/register-hooks.ts index 0f19369248..52f3543ca8 100755 --- a/apps/agor-daemon/src/register-hooks.ts +++ b/apps/agor-daemon/src/register-hooks.ts @@ -139,6 +139,7 @@ import { recomputeNextRunAt, validateScheduleConfig, } from './utils/schedule-hooks.js'; +import { deferWithSessionQueueTenantScope } from './utils/session-queue-tenant-scope.js'; import { isTerminalQueueProcessingSuppressed, sessionCanStartTask, @@ -329,6 +330,11 @@ export function shouldDrainQueueAfterSessionPostTurnPatch( ); } +export function getTrustedSessionTenantId(session: unknown): string | undefined { + const tenantId = (session as { tenant_id?: unknown } | undefined)?.tenant_id; + return typeof tenantId === 'string' && tenantId.length > 0 ? tenantId : undefined; +} + export async function enrichSessionFindResultWithRemoteRelationships( result: Paginated | Session[], sessionsService: Pick @@ -2726,23 +2732,37 @@ export function registerHooks(ctx: RegisterHooksContext): void { }); if (shouldDrainQueueAfterSessionPostTurnPatch(session, context.params)) { + const sessionTenantId = getTrustedSessionTenantId(session); // Same fresh-scope pattern: queue processing must run outside the // outer transaction but still inside the session tenant for RLS. - deferWithTenantDatabaseScope(db, context.params, async () => { - try { + // Some completion/background paths have minimal params, so this + // relies on params.tenant, current tenant ALS, the already-returned + // session row tenant_id, or static tenant config and otherwise + // fails closed. + deferWithSessionQueueTenantScope( + { + db, + config, + sessionId: session.session_id, + params: context.params, + tenantIdHint: sessionTenantId, + label: 'SessionsService.after.patch queue drain', + }, + async (queueParams) => { console.log( `🔄 [SessionsService.after.patch] Session ${shortId(session.session_id)} became promptable (${session.status}), checking for queued tasks...` ); - await sessionsService.triggerQueueProcessing(session.session_id, context.params); - } catch (error) { + await sessionsService.triggerQueueProcessing(session.session_id, queueParams); + }, + (error) => { console.error( `❌ [SessionsService.after.patch] Failed to process queue for session ${shortId(session.session_id)}:`, error ); // Don't throw - queue processing failure shouldn't break session patches } - }); + ); } else { console.log( `⏭️ [SessionsService.after.patch] Queue drain suppressed for session ${shortId(session.session_id)} (suppressTerminalQueueProcessing or not ready)` diff --git a/apps/agor-daemon/src/register-routes.ts b/apps/agor-daemon/src/register-routes.ts index 95db2953b0..977a47abf3 100755 --- a/apps/agor-daemon/src/register-routes.ts +++ b/apps/agor-daemon/src/register-routes.ts @@ -125,6 +125,10 @@ import { } from './utils/mcp-header-secrets.js'; import { canControlCliSession } from './utils/mcp-token-authorization.js'; import { ensureScheduleRunsAsCaller } from './utils/schedule-hooks.js'; +import { + deferWithSessionQueueTenantScope, + runWithSessionQueueTenantScope, +} from './utils/session-queue-tenant-scope.js'; import { stopSessionPreserveQueue } from './utils/session-stop.js'; import { sessionCanStartTask, @@ -2181,6 +2185,22 @@ export async function registerRoutes(ctx: RegisterRoutesContext): Promise const queueRetryScheduled = new Set(); async function processNextQueuedTask(sessionId: SessionID, params: RouteParams): Promise { + await runWithSessionQueueTenantScope( + { + db, + config, + sessionId, + params, + label: 'processNextQueuedTask', + }, + async (scopedParams) => processNextQueuedTaskInTenantScope(sessionId, scopedParams) + ); + } + + async function processNextQueuedTaskInTenantScope( + sessionId: SessionID, + params: RouteParams + ): Promise { const existingLock = sessionTurnLocks.get(sessionId); if (existingLock) { console.log(`⏳ [Queue] Session turn in progress for ${shortId(sessionId)}, waiting...`); @@ -2208,14 +2228,27 @@ export async function registerRoutes(ctx: RegisterRoutesContext): Promise if (!queueRetryScheduled.has(sessionId)) { queueRetryScheduled.add(sessionId); - deferInFreshTenantScope(params, async () => { - queueRetryScheduled.delete(sessionId); - try { - await processNextQueuedTask(sessionId, params); - } catch (error) { + deferWithSessionQueueTenantScope( + { + db, + config, + sessionId, + params, + label: 'processNextQueuedTask retry', + }, + async (retryParams) => { + queueRetryScheduled.delete(sessionId); + try { + await processNextQueuedTask(sessionId, retryParams); + } catch (error) { + console.error(`❌ [Queue] Retry failed for session ${shortId(sessionId)}:`, error); + } + }, + (error) => { + queueRetryScheduled.delete(sessionId); console.error(`❌ [Queue] Retry failed for session ${shortId(sessionId)}:`, error); } - }); + ); } else { console.log( `⏭️ [Queue] Retry already scheduled for session ${shortId(sessionId)}, not queueing another` diff --git a/apps/agor-daemon/src/utils/session-queue-tenant-scope.test.ts b/apps/agor-daemon/src/utils/session-queue-tenant-scope.test.ts new file mode 100644 index 0000000000..6bf4a8b59b --- /dev/null +++ b/apps/agor-daemon/src/utils/session-queue-tenant-scope.test.ts @@ -0,0 +1,257 @@ +import { getCurrentTenantId, runWithTenantDatabaseScope } from '@agor/core/db'; +import type { SessionID } from '@agor/core/types'; +import { describe, expect, it, vi } from 'vitest'; +import { + deferWithSessionQueueTenantScope, + runWithSessionQueueTenantScope, +} from './session-queue-tenant-scope.js'; + +function makePgDb() { + const tx = { execute: vi.fn(async () => []) }; + const db = { + transaction: vi.fn(async (callback: (tx: unknown) => Promise) => callback(tx)), + }; + return { db, tx }; +} + +describe('session queue tenant scope', () => { + it('uses params tenant before running queue work', async () => { + const { db } = makePgDb(); + const seen: string[] = []; + + await runWithSessionQueueTenantScope( + { + db: db as never, + config: { + database: { dialect: 'postgresql' }, + multi_tenancy: { mode: 'required_from_auth', auth_claim: 'tenant_id' }, + }, + sessionId: 'session-1' as SessionID, + params: { tenant: { tenant_id: 'tenant-a', source: 'explicit' } }, + label: 'test drain', + }, + async (params) => { + seen.push(`tenant:${getCurrentTenantId()}`); + seen.push(`params:${params.tenant?.tenant_id}`); + } + ); + + expect(seen).toEqual(['tenant:tenant-a', 'params:tenant-a']); + }); + + it('uses the active tenant scope when params are minimal', async () => { + const { db } = makePgDb(); + const seen: string[] = []; + + await runWithTenantDatabaseScope(db as never, 'tenant-active', async () => { + await runWithSessionQueueTenantScope( + { + db: db as never, + config: { + database: { dialect: 'postgresql' }, + multi_tenancy: { mode: 'required_from_auth', auth_claim: 'tenant_id' }, + }, + sessionId: 'session-1' as SessionID, + params: {}, + label: 'test drain', + }, + async (params) => { + seen.push(`tenant:${getCurrentTenantId()}`); + seen.push(`params:${params.tenant?.tenant_id}`); + } + ); + }); + + expect(seen).toEqual(['tenant:tenant-active', 'params:tenant-active']); + }); + + it('uses a trusted tenant hint when params and active scope are absent', async () => { + const { db } = makePgDb(); + const seen: string[] = []; + + await runWithSessionQueueTenantScope( + { + db: db as never, + config: { + database: { dialect: 'postgresql' }, + multi_tenancy: { mode: 'required_from_auth', auth_claim: 'tenant_id' }, + }, + sessionId: 'session-1' as SessionID, + params: {}, + tenantIdHint: 'tenant-from-row', + label: 'test hinted drain', + }, + async (params) => { + seen.push(`tenant:${getCurrentTenantId()}`); + seen.push(`params:${params.tenant?.tenant_id}`); + seen.push(`source:${params.tenant?.source}`); + } + ); + + expect(seen).toEqual(['tenant:tenant-from-row', 'params:tenant-from-row', 'source:explicit']); + }); + + it('prefers params tenant over trusted tenant hint', async () => { + const { db } = makePgDb(); + const seen: string[] = []; + + await runWithSessionQueueTenantScope( + { + db: db as never, + config: { + database: { dialect: 'postgresql' }, + multi_tenancy: { mode: 'required_from_auth', auth_claim: 'tenant_id' }, + }, + sessionId: 'session-1' as SessionID, + params: { tenant: { tenant_id: 'tenant-from-params', source: 'auth_claim' } }, + tenantIdHint: 'tenant-from-row', + label: 'test hinted drain', + }, + async (params) => { + seen.push(`tenant:${getCurrentTenantId()}`); + seen.push(`params:${params.tenant?.tenant_id}`); + seen.push(`source:${params.tenant?.source}`); + } + ); + + expect(seen).toEqual([ + 'tenant:tenant-from-params', + 'params:tenant-from-params', + 'source:auth_claim', + ]); + }); + + it('uses configured static tenant mode when params and active scope are absent', async () => { + const { db } = makePgDb(); + const seen: string[] = []; + + await runWithSessionQueueTenantScope( + { + db: db as never, + config: { + database: { dialect: 'postgresql' }, + multi_tenancy: { mode: 'static', static_tenant_id: 'tenant-static' }, + }, + sessionId: 'session-1' as SessionID, + params: {}, + label: 'test static drain', + }, + async (params) => { + seen.push(`tenant:${getCurrentTenantId()}`); + seen.push(`params:${params.tenant?.tenant_id}`); + seen.push(`source:${params.tenant?.source}`); + } + ); + + expect(seen).toEqual(['tenant:tenant-static', 'params:tenant-static', 'source:static']); + }); + + it('fails closed in required tenant mode when params and active scope are absent', async () => { + const { db } = makePgDb(); + const work = vi.fn(async () => undefined); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await runWithSessionQueueTenantScope( + { + db: db as never, + config: { + database: { dialect: 'postgresql' }, + multi_tenancy: { mode: 'required_from_auth', auth_claim: 'tenant_id' }, + }, + sessionId: 'session-1' as SessionID, + params: {}, + label: 'test drain', + }, + work + ); + + expect(work).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('missing tenant context')); + consoleError.mockRestore(); + }); + + it('defers promptable-session queue drains with active tenant params', async () => { + const { db } = makePgDb(); + const seen: string[] = []; + + const drained = new Promise((resolve, reject) => { + void runWithTenantDatabaseScope(db as never, 'tenant-active', async () => { + deferWithSessionQueueTenantScope( + { + db: db as never, + config: { + database: { dialect: 'postgresql' }, + multi_tenancy: { mode: 'required_from_auth', auth_claim: 'tenant_id' }, + }, + sessionId: 'session-1' as SessionID, + params: {}, + label: 'session after.patch drain', + }, + async (params) => { + seen.push(`tenant:${getCurrentTenantId()}`); + seen.push(`params:${params.tenant?.tenant_id}`); + resolve(); + }, + reject + ); + }).catch(reject); + }); + await drained; + + expect(seen).toEqual(['tenant:tenant-active', 'params:tenant-active']); + }); + + it('does not defer request-less queue drains in required tenant mode without params or ALS', async () => { + const { db } = makePgDb(); + const work = vi.fn(async () => undefined); + const onError = vi.fn(); + + deferWithSessionQueueTenantScope( + { + db: db as never, + config: { + database: { dialect: 'postgresql' }, + multi_tenancy: { mode: 'required_from_auth', auth_claim: 'tenant_id' }, + }, + sessionId: 'session-1' as SessionID, + params: {}, + label: 'session after.patch drain', + }, + work, + onError + ); + + expect(work).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) })); + }); + + it('defers request-less queue drains with a trusted tenant hint', async () => { + const { db } = makePgDb(); + const seen: string[] = []; + + const drained = new Promise((resolve, reject) => { + deferWithSessionQueueTenantScope( + { + db: db as never, + config: { + database: { dialect: 'postgresql' }, + multi_tenancy: { mode: 'required_from_auth', auth_claim: 'tenant_id' }, + }, + sessionId: 'session-1' as SessionID, + params: {}, + tenantIdHint: 'tenant-from-row', + label: 'session after.patch drain', + }, + async (params) => { + seen.push(`tenant:${getCurrentTenantId()}`); + seen.push(`params:${params.tenant?.tenant_id}`); + resolve(); + }, + reject + ); + }); + await drained; + + expect(seen).toEqual(['tenant:tenant-from-row', 'params:tenant-from-row']); + }); +}); diff --git a/apps/agor-daemon/src/utils/session-queue-tenant-scope.ts b/apps/agor-daemon/src/utils/session-queue-tenant-scope.ts new file mode 100644 index 0000000000..0f184ffe40 --- /dev/null +++ b/apps/agor-daemon/src/utils/session-queue-tenant-scope.ts @@ -0,0 +1,119 @@ +import { type AgorConfig, resolveMultiTenancyConfig } from '@agor/core/config'; +import { runWithTenantDatabaseScope, type TenantScopeAwareDatabase } from '@agor/core/db'; +import type { Params, SessionID, TenantContext, TenantID } from '@agor/core/types'; +import { + deferWithTenantDatabaseScope, + resolveTenantIdForDeferredScope, +} from './tenant-db-scope.js'; + +type QueueTenantParams = Params & { + tenant?: Pick; +}; + +export interface SessionQueueTenantScopeOptions { + db: TenantScopeAwareDatabase; + config: AgorConfig; + sessionId: SessionID; + params?: Params; + /** + * Trusted tenant id from an already-loaded tenant-owned row (for example a + * Postgres sessions/tasks patch result). Do not populate this from request + * payloads or request-less tenant discovery reads. + */ + tenantIdHint?: string; + label: string; +} + +function staticTenantId(config: AgorConfig): string | undefined { + const multiTenancy = resolveMultiTenancyConfig(config); + return multiTenancy.mode === 'static' ? multiTenancy.static_tenant_id : undefined; +} + +function trustedTenantIdHint(options: SessionQueueTenantScopeOptions): string | undefined { + const hint = options.tenantIdHint?.trim(); + return hint || undefined; +} + +export function queueTenantParams( + params: Params | undefined, + tenantId: string, + source: TenantContext['source'] = 'explicit' +): QueueTenantParams { + const currentTenant = (params as QueueTenantParams | undefined)?.tenant; + return { + ...(params ?? {}), + tenant: { + ...currentTenant, + tenant_id: tenantId as TenantID, + source: currentTenant?.source ?? source, + }, + }; +} + +export async function runWithSessionQueueTenantScope( + options: SessionQueueTenantScopeOptions, + work: (params: QueueTenantParams) => Promise +): Promise { + const capturedTenantId = resolveTenantIdForDeferredScope(options.params); + + if (capturedTenantId) { + const scopedParams = queueTenantParams(options.params, capturedTenantId, 'explicit'); + return runWithTenantDatabaseScope(options.db, capturedTenantId, () => work(scopedParams)); + } + + const tenantIdHint = trustedTenantIdHint(options); + if (tenantIdHint) { + const scopedParams = queueTenantParams(options.params, tenantIdHint, 'explicit'); + return runWithTenantDatabaseScope(options.db, tenantIdHint, () => work(scopedParams)); + } + + const configuredStaticTenantId = staticTenantId(options.config); + if (!configuredStaticTenantId) { + console.error( + `❌ [Queue] ${options.label} skipped for session ${options.sessionId}: missing tenant context` + ); + return undefined; + } + + const scopedParams = queueTenantParams(options.params, configuredStaticTenantId, 'static'); + return runWithTenantDatabaseScope(options.db, configuredStaticTenantId, () => work(scopedParams)); +} + +export function deferWithSessionQueueTenantScope( + options: SessionQueueTenantScopeOptions, + work: (params: QueueTenantParams) => Promise, + onError?: (error: unknown) => void +): void { + const handleError = (error: unknown) => { + if (onError) { + onError(error); + return; + } + console.error(`❌ [Queue] ${options.label} failed:`, error); + }; + + const capturedTenantId = resolveTenantIdForDeferredScope(options.params); + if (capturedTenantId) { + const scopedParams = queueTenantParams(options.params, capturedTenantId, 'explicit'); + deferWithTenantDatabaseScope(options.db, scopedParams, () => work(scopedParams), handleError); + return; + } + + const tenantIdHint = trustedTenantIdHint(options); + if (tenantIdHint) { + const scopedParams = queueTenantParams(options.params, tenantIdHint, 'explicit'); + deferWithTenantDatabaseScope(options.db, scopedParams, () => work(scopedParams), handleError); + return; + } + + const configuredStaticTenantId = staticTenantId(options.config); + if (!configuredStaticTenantId) { + handleError( + new Error(`${options.label} skipped for session ${options.sessionId}: missing tenant context`) + ); + return; + } + + const scopedParams = queueTenantParams(options.params, configuredStaticTenantId, 'static'); + deferWithTenantDatabaseScope(options.db, scopedParams, () => work(scopedParams), handleError); +} diff --git a/packages/core/src/db/repositories/sessions.test.ts b/packages/core/src/db/repositories/sessions.test.ts index afa4adf109..c7854df6d9 100644 --- a/packages/core/src/db/repositories/sessions.test.ts +++ b/packages/core/src/db/repositories/sessions.test.ts @@ -7,10 +7,11 @@ import type { Session, UUID } from '@agor/core/types'; import { SessionStatus } from '@agor/core/types'; -import { describe, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { generateId, shortId, toShortId } from '../../lib/ids'; +import type { SessionRow } from '../schema'; import { dbTest } from '../test-helpers'; -import { AmbiguousIdError, EntityNotFoundError, RepositoryError } from './base'; +import { AmbiguousIdError, EntityNotFoundError, getHiddenTenantId, RepositoryError } from './base'; import { BranchRepository } from './branches'; import { RepoRepository } from './repos'; import { ScheduleRepository } from './schedules'; @@ -75,6 +76,67 @@ async function createTestBranch(db: any, overrides?: { branch_id?: UUID; repo_id return branch; } +type SessionRowMapper = { + rowToSession(row: SessionRow, branchBoardId?: UUID | null, baseUrl?: string): Session; +}; + +function createPostgresStyleSessionRow(overrides?: Partial & { tenant_id?: string }) { + const now = new Date('2026-01-01T00:00:00.000Z'); + return { + tenant_id: 'tenant-session-row', + session_id: generateId(), + created_at: now, + updated_at: now, + created_by: generateId(), + unix_username: null, + status: SessionStatus.IDLE, + agentic_tool: 'claude-code', + board_id: null, + parent_session_id: null, + forked_from_session_id: null, + branch_id: generateId(), + scheduled_run_at: null, + scheduled_from_branch: false, + schedule_id: null, + ready_for_prompt: false, + archived: false, + archived_reason: null, + data: { + git_state: { + ref: 'main', + base_sha: 'abc123', + current_sha: 'def456', + }, + genealogy: { children: [] }, + contextFiles: [], + tasks: [], + }, + ...overrides, + } satisfies SessionRow & { tenant_id: string }; +} + +function rowToSessionForTest(repo: SessionRepository, row: SessionRow): Session { + return (repo as unknown as SessionRowMapper).rowToSession(row); +} + +// ============================================================================ +// Mapping +// ============================================================================ + +describe('SessionRepository row mapping', () => { + it('attaches Postgres tenant_id as hidden non-enumerable metadata', () => { + const repo = new SessionRepository({} as never); + const row = createPostgresStyleSessionRow(); + + const session = rowToSessionForTest(repo, row); + + expect(getHiddenTenantId(session)).toBe('tenant-session-row'); + expect((session as { tenant_id?: string }).tenant_id).toBe('tenant-session-row'); + expect(Object.keys(session)).not.toContain('tenant_id'); + expect(JSON.stringify(session)).not.toContain('tenant_id'); + }); +}); + // ============================================================================ // Create // ============================================================================ diff --git a/packages/core/src/db/repositories/sessions.ts b/packages/core/src/db/repositories/sessions.ts index 8860293629..ceed8d2006 100644 --- a/packages/core/src/db/repositories/sessions.ts +++ b/packages/core/src/db/repositories/sessions.ts @@ -22,6 +22,7 @@ import { } from '../schema'; import { AmbiguousIdError, + attachHiddenTenant, type BaseRepository, EntityNotFoundError, RESOLVE_SHORT_ID_FETCH_LIMIT, @@ -77,41 +78,44 @@ export class SessionRepository implements BaseRepository id as UUID), - genealogy: { - parent_session_id: row.parent_session_id as UUID | undefined, - forked_from_session_id: row.forked_from_session_id as UUID | undefined, - fork_point_task_id: genealogyData.fork_point_task_id as UUID | undefined, - fork_point_message_index: genealogyData.fork_point_message_index, - spawn_point_task_id: genealogyData.spawn_point_task_id as UUID | undefined, - spawn_point_message_index: genealogyData.spawn_point_message_index, - children: genealogyData.children.map((id) => id as UUID), + return attachHiddenTenant( + { + session_id: sessionId, + status: row.status, + agentic_tool: row.agentic_tool, + created_at: new Date(row.created_at).toISOString(), + last_updated: row.updated_at + ? new Date(row.updated_at).toISOString() + : new Date(row.created_at).toISOString(), + created_by: row.created_by, + unix_username: row.unix_username || null, + branch_id: row.branch_id as UUID, + branch_board_id: boardId, + url, + ...row.data, + tasks: row.data.tasks.map((id) => id as UUID), + genealogy: { + parent_session_id: row.parent_session_id as UUID | undefined, + forked_from_session_id: row.forked_from_session_id as UUID | undefined, + fork_point_task_id: genealogyData.fork_point_task_id as UUID | undefined, + fork_point_message_index: genealogyData.fork_point_message_index, + spawn_point_task_id: genealogyData.spawn_point_task_id as UUID | undefined, + spawn_point_message_index: genealogyData.spawn_point_message_index, + children: genealogyData.children.map((id) => id as UUID), + }, + permission_config: row.data.permission_config, + scheduled_run_at: row.scheduled_run_at ?? undefined, + scheduled_from_branch: row.scheduled_from_branch ?? false, + schedule_id: (row.schedule_id as UUID | null) ?? undefined, + ready_for_prompt: row.ready_for_prompt ?? false, + archived: Boolean(row.archived), // Convert SQLite integer (0/1) to boolean + archived_reason: row.archived_reason ?? undefined, + current_context_usage: row.data.current_context_usage, + context_window_limit: row.data.context_window_limit, + last_context_update_at: row.data.last_context_update_at, }, - permission_config: row.data.permission_config, - scheduled_run_at: row.scheduled_run_at ?? undefined, - scheduled_from_branch: row.scheduled_from_branch ?? false, - schedule_id: (row.schedule_id as UUID | null) ?? undefined, - ready_for_prompt: row.ready_for_prompt ?? false, - archived: Boolean(row.archived), // Convert SQLite integer (0/1) to boolean - archived_reason: row.archived_reason ?? undefined, - current_context_usage: row.data.current_context_usage, - context_window_limit: row.data.context_window_limit, - last_context_update_at: row.data.last_context_update_at, - }; + row + ); } /** @@ -688,9 +692,12 @@ export class SessionRepository implements BaseRepository