mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(hub,cli): coerce null session activeAt so resume cannot 500 (#1026)
Legacy rows and inserts left sessions.active_at NULL while SessionSchema required a number, so CLI GET /cli/sessions/:id failed Zod and resume surfaced HTTP 500. Persist active_at on insert, harden hub read coerce, and nullish-transform activeAt in SessionSchema (output stays number). Fixes #1025 Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Debian
Cursor
parent
f6ad345339
commit
8ee04500b9
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,26 @@ function getMetadata(store: Store, id: string): Record<string, unknown> | null {
|
||||
return (row?.metadata ?? null) as Record<string, unknown> | 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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionSchema } from './schemas'
|
||||
|
||||
function baseSession(overrides: Record<string, unknown> = {}) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user