diff --git a/cli/src/api/api.getSession.activeAt.test.ts b/cli/src/api/api.getSession.activeAt.test.ts new file mode 100644 index 00000000..25da4c95 --- /dev/null +++ b/cli/src/api/api.getSession.activeAt.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { configuration } from '@/configuration' + +const axiosGetMock = vi.hoisted(() => vi.fn()) + +vi.mock('axios', () => ({ + default: { + get: axiosGetMock, + post: vi.fn() + } +})) + +vi.mock('@/api/auth', () => ({ + getAuthToken: () => 'cli-token' +})) + +import { ApiClient } from './api' + +describe('ApiClient.getSession activeAt coerce', () => { + const now = 1_710_000_000_000 + + beforeEach(() => { + configuration._setApiUrl('https://hapi.example.com') + configuration._setExtraHeaders({}) + axiosGetMock.mockReset() + }) + + it('accepts hub payloads with null activeAt without throwing', async () => { + axiosGetMock.mockResolvedValue({ + data: { + session: { + id: '11111111-1111-4111-8111-111111111111', + namespace: 'default', + seq: 1, + createdAt: now, + updatedAt: now, + active: false, + activeAt: null, + metadata: { + path: '/tmp/project', + host: 'test-host' + }, + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: now, + todos: [], + model: null, + modelReasoningEffort: null, + effort: null, + serviceTier: null + } + } + }) + + const client = await ApiClient.create() + const session = await client.getSession('11111111-1111-4111-8111-111111111111') + + expect(session.activeAt).toBe(0) + expect(typeof session.activeAt).toBe('number') + }) +}) diff --git a/hub/src/store/sessions.test.ts b/hub/src/store/sessions.test.ts index ae5a92dc..fb7f983c 100644 --- a/hub/src/store/sessions.test.ts +++ b/hub/src/store/sessions.test.ts @@ -12,6 +12,26 @@ function getMetadata(store: Store, id: string): Record | null { return (row?.metadata ?? null) as Record | null } +describe('getOrCreateSession: active_at', () => { + it('persists a non-null active_at on insert (never NULL)', () => { + const store = makeStore() + const created = store.sessions.getOrCreateSession( + 'active-at-write', + { path: '/tmp/project', host: 'localhost' }, + null, + 'default' + ) + + expect(typeof created.activeAt).toBe('number') + expect(created.activeAt).not.toBeNull() + expect(created.activeAt).toBe(created.createdAt) + + const reloaded = store.sessions.getSession(created.id) + expect(reloaded?.activeAt).toBe(created.createdAt) + store.close() + }) +}) + describe('getOrCreateSession: requested identity', () => { it('creates and idempotently reloads a client-requested id', () => { const store = makeStore() diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 70543354..406e473a 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -236,7 +236,7 @@ export function getOrCreateSession( @model_reasoning_effort, @effort, NULL, NULL, - 0, NULL, 0 + 0, @active_at, 0 ) `).run({ id, @@ -244,6 +244,9 @@ export function getOrCreateSession( namespace, created_at: now, updated_at: now, + // Never persist NULL — CLI SessionSchema requires numeric activeAt. + // Legacy rows may still be NULL; sessionCache coerces on read. + active_at: now, metadata: metadataJson, agent_state: agentStateJson, model: model ?? null, diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 7cec37f7..e056bbf1 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -150,7 +150,13 @@ export class SessionCache { createdAt: stored.createdAt, updatedAt: stored.updatedAt, active: existing?.active ?? stored.active, - activeAt: existing?.activeAt ?? (stored.activeAt ?? stored.createdAt), + // Legacy / idle rows may still have active_at NULL in SQLite. + // Public Session.activeAt is always a number for CLI Zod parse. + activeAt: existing?.activeAt + ?? stored.activeAt + ?? stored.updatedAt + ?? stored.createdAt + ?? 0, metadata, metadataVersion: stored.metadataVersion, agentState, diff --git a/hub/src/web/routes/cli.activeAt.test.ts b/hub/src/web/routes/cli.activeAt.test.ts new file mode 100644 index 00000000..a8b72676 --- /dev/null +++ b/hub/src/web/routes/cli.activeAt.test.ts @@ -0,0 +1,68 @@ +import { beforeAll, describe, expect, it } from 'bun:test' +import type { Database } from 'bun:sqlite' +import { Hono } from 'hono' +import { createConfiguration } from '../../configuration' +import { Store } from '../../store' +import { RpcRegistry } from '../../socket/rpcRegistry' +import { SyncEngine } from '../../sync/syncEngine' +import { createCliRoutes } from './cli' + +function createEngine(store: Store): SyncEngine { + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + engine.stop() + return engine +} + +function authHeaders() { + return { + authorization: 'Bearer test-token' + } +} + +function nullOutActiveAt(store: Store, sessionId: string): void { + const db = (store as unknown as { db: Database }).db + db.prepare('UPDATE sessions SET active_at = NULL WHERE id = ?').run(sessionId) +} + +beforeAll(async () => { + const config = await createConfiguration() + config._setCliApiToken('test-token', 'env', false) +}) + +describe('CLI GET /sessions/:id with null active_at', () => { + it('returns numeric activeAt coerced from createdAt', async () => { + const store = new Store(':memory:') + const engine = createEngine(store) + const created = engine.getOrCreateSession( + 'null-active-at-cli', + { path: '/tmp/project', host: 'localhost', flavor: 'claude' }, + { requests: {}, completedRequests: {} }, + 'default' + ) + + nullOutActiveAt(store, created.id) + expect(store.sessions.getSession(created.id)?.activeAt).toBeNull() + + // Simulate hub restart so cache reloads from the NULL row. + const reloaded = createEngine(store) + const app = new Hono() + app.route('/cli', createCliRoutes(() => reloaded)) + + const response = await app.request(`/cli/sessions/${created.id}`, { + headers: authHeaders() + }) + + expect(response.status).toBe(200) + const body = await response.json() as { + session: { activeAt: unknown; createdAt: number; updatedAt: number } + } + expect(typeof body.session.activeAt).toBe('number') + expect(body.session.activeAt).toBe(created.createdAt) + store.close() + }) +}) diff --git a/shared/src/schemas.sessionActiveAt.test.ts b/shared/src/schemas.sessionActiveAt.test.ts new file mode 100644 index 00000000..7c18c82f --- /dev/null +++ b/shared/src/schemas.sessionActiveAt.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { SessionSchema } from './schemas' + +function baseSession(overrides: Record = {}) { + return { + id: '11111111-1111-4111-8111-111111111111', + namespace: 'default', + seq: 0, + createdAt: 1_000, + updatedAt: 2_000, + active: false, + activeAt: 1_000, + metadata: null, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + ...overrides + } +} + +describe('SessionSchema activeAt coerce', () => { + it('keeps a numeric activeAt unchanged', () => { + const parsed = SessionSchema.safeParse(baseSession({ activeAt: 42 })) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.activeAt).toBe(42) + } + }) + + it('coerces null activeAt to 0 without failing parse', () => { + const parsed = SessionSchema.safeParse(baseSession({ activeAt: null })) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.activeAt).toBe(0) + } + }) + + it('coerces missing activeAt to 0 without failing parse', () => { + const raw = baseSession() + delete (raw as { activeAt?: number }).activeAt + const parsed = SessionSchema.safeParse(raw) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.activeAt).toBe(0) + } + }) +}) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 26961e72..196f6811 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -212,7 +212,8 @@ export const SessionSchema = z.object({ createdAt: z.number(), updatedAt: z.number(), active: z.boolean(), - activeAt: z.number(), + // Hub may still emit null for legacy SQLite rows; keep output type number. + activeAt: z.number().nullish().transform((value) => value ?? 0), metadata: MetadataSchema.nullable(), metadataVersion: z.number(), agentState: AgentStateSchema.nullable(),