Files
hapi/hub/src/web/routes/cli.ts
T
Junmo KimandGitHub f44c9ff3e6 feat(opencode): open a fresh session on clear (#1300)
* test(opencode): specify fresh-session clear

* feat(opencode): open a fresh session on clear

* fix(opencode): release clear latch on cancel

* fix(opencode): retry transient clear handoffs

* fix(opencode): confirm clear archive delivery

* fix(web): preserve superseded session access

* fix(clear): invalidate transferred schedules

* fix(runner): restore live spawn dedupe

* fix(clear): preserve latched scheduled prompts

* fix(runner): quarantine unverified children

* fix(clear): retain handoff retry ownership

* fix(runner): release recovered spawn dedupe

* fix(clear): retain archive retry ownership

* fix(clear): settle rejected immediate prompts

* fix(clear): block reopening replaced sources

* fix(clear): settle prompts when clear is cancelled

* fix(clear): make fresh-session handoff durable

* fix(clear): finalize only after native cleanup

* fix(clear): abort failed native handoffs

* fix(clear): gate recovery on cleanup proof

* fix(clear): retry metadata persistence failures

* fix(clear): preserve handoff ownership through teardown

* fix(clear): abort incomplete cleanup reservations

* fix(clear): require explicit exit before abort

* fix(clear): verify owner exit before recovery

* fix(clear): guard recovery handoff races

* fix(clear): serialize cleanup callbacks

* fix(clear): make callback retries idempotent

* fix(clear): bind callbacks to reservations

* fix(clear): recover pending spawns

* fix(clear): deduplicate held prompts

* fix(clear): validate redirect ownership

* fix(clear): replay prompts in FIFO order

* fix(clear): gate replacement delivery
2026-08-03 18:06:39 +08:00

355 lines
14 KiB
TypeScript

import { Hono } from 'hono'
import { z } from 'zod'
import {
CreateOrLoadMachineRequestSchema,
CreateOrLoadSessionRequestSchema,
ClearOpencodeSessionCallbackRequestSchema,
CursorMigrateToAcpRequestSchema,
PROTOCOL_VERSION
} from '@hapi/protocol'
import { getConfiguration } from '../../configuration'
import { constantTimeEquals } from '../../utils/crypto'
import { parseAccessToken } from '../../utils/accessToken'
import type { Machine, Session, SyncEngine } from '../../sync/syncEngine'
import { SessionIdentityConflictError } from '../../store/sessions'
const bearerSchema = z.string().regex(/^Bearer\s+(.+)$/i)
const getMessagesQuerySchema = z.object({
afterSeq: z.coerce.number().int().min(0),
limit: z.coerce.number().int().min(1).max(200).optional()
})
type CliEnv = {
Variables: {
namespace: string
}
}
function resolveSessionForNamespace(
engine: SyncEngine,
sessionId: string,
namespace: string
): { ok: true; session: Session; sessionId: string } | { ok: false; status: 403 | 404; error: string } {
const access = engine.resolveSessionAccess(sessionId, namespace)
if (access.ok) {
return { ok: true, session: access.session, sessionId: access.sessionId }
}
return {
ok: false,
status: access.reason === 'access-denied' ? 403 : 404,
error: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found'
}
}
function resolveMachineForNamespace(
engine: SyncEngine,
machineId: string,
namespace: string
): { ok: true; machine: Machine } | { ok: false; status: 403 | 404; error: string } {
const machine = engine.getMachineByNamespace(machineId, namespace)
if (machine) {
return { ok: true, machine }
}
if (engine.getMachine(machineId)) {
return { ok: false, status: 403, error: 'Machine access denied' }
}
return { ok: false, status: 404, error: 'Machine not found' }
}
function clearErrorStatus(code: string): 403 | 404 | 409 | 500 {
return code === 'access_denied' ? 403
: code === 'session_not_found' ? 404
: code === 'clear_unavailable' ? 409
: 500
}
export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono<CliEnv> {
const app = new Hono<CliEnv>()
app.use('*', async (c, next) => {
c.header('X-Hapi-Protocol-Version', String(PROTOCOL_VERSION))
const raw = c.req.header('authorization')
if (!raw) {
return c.json({ error: 'Missing Authorization header' }, 401)
}
const parsed = bearerSchema.safeParse(raw)
if (!parsed.success) {
return c.json({ error: 'Invalid Authorization header' }, 401)
}
const token = parsed.data.replace(/^Bearer\s+/i, '')
const configuration = getConfiguration()
const parsedToken = parseAccessToken(token)
if (!parsedToken || !constantTimeEquals(parsedToken.baseToken, configuration.cliApiToken)) {
return c.json({ error: 'Invalid token' }, 401)
}
c.set('namespace', parsedToken.namespace)
return await next()
})
app.post('/sessions', async (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const json = await c.req.json().catch(() => null)
const parsed = CreateOrLoadSessionRequestSchema.safeParse(json)
if (!parsed.success) {
return c.json({ error: 'Invalid body' }, 400)
}
const namespace = c.get('namespace')
const machineInput = parsed.data.machine
if (machineInput) {
const existingMachine = engine.getMachine(machineInput.id)
if (existingMachine && existingMachine.namespace !== namespace) {
return c.json({ error: 'Machine access denied' }, 403)
}
engine.getOrCreateMachine(
machineInput.id,
machineInput.metadata,
machineInput.runnerState ?? null,
namespace
)
}
try {
const session = engine.getOrCreateSession(
parsed.data.tag,
parsed.data.metadata,
parsed.data.agentState ?? null,
namespace,
parsed.data.model,
parsed.data.effort,
parsed.data.modelReasoningEffort,
parsed.data.id
)
return c.json({ session })
} catch (error) {
if (error instanceof SessionIdentityConflictError) {
return c.json({ error: error.message }, 409)
}
throw error
}
})
app.get('/sessions/resumable', (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const namespace = c.get('namespace')
const machineId = c.req.query('machineId') || undefined
const sessions = engine.listLocalResumableSessions(namespace, { machineId })
return c.json({ sessions })
})
app.get('/sessions/:id/resume-target', (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const namespace = c.get('namespace')
const result = engine.resolveLocalResumeTarget(c.req.param('id'), namespace)
if (result.type === 'error') {
const status = result.code === 'access_denied' ? 403
: result.code === 'session_not_found' ? 404
: 409
return c.json({ error: result.message, code: result.code }, status)
}
return c.json({ target: result.target })
})
app.post('/sessions/:id/handoff-local', async (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const namespace = c.get('namespace')
const result = await engine.handoffSessionToLocal(c.req.param('id'), namespace)
if (result.type === 'error') {
const status = result.code === 'access_denied' ? 403
: result.code === 'session_not_found' ? 404
: result.code === 'already_local' ? 409
: 500
return c.json({ error: result.message, code: result.code }, status)
}
return c.json({ ok: true })
})
app.post('/sessions/:id/clear-opencode', async (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const result = await engine.clearOpenCodeSession(c.req.param('id'), c.get('namespace'))
if (result.type === 'error') {
const status = result.code === 'access_denied' ? 403
: result.code === 'session_not_found' ? 404
: result.code === 'clear_unavailable' ? 409
: 500
return c.json({ error: result.message, code: result.code }, status)
}
return c.json({ ok: true, sessionId: result.sessionId })
})
app.post('/sessions/:id/clear-opencode/reserve', (c) => {
const engine = getSyncEngine()
if (!engine) return c.json({ error: 'Not ready' }, 503)
const result = engine.reserveOpenCodeClearSession(c.req.param('id'), c.get('namespace'))
if (result.type === 'error') {
const status = result.code === 'access_denied' ? 403 : result.code === 'session_not_found' ? 404 : result.code === 'clear_unavailable' ? 409 : 500
return c.json({ error: result.message, code: result.code }, status)
}
return c.json({ ok: true, sessionId: result.sessionId })
})
app.post('/sessions/:id/clear-opencode/abort', async (c) => {
const engine = getSyncEngine()
if (!engine) return c.json({ error: 'Not ready' }, 503)
const parsed = ClearOpencodeSessionCallbackRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return c.json({ error: 'Invalid clear callback request' }, 400)
const result = engine.abortOpenCodeClearSession(c.req.param('id'), c.get('namespace'), parsed.data.replacementSessionId)
if (result.type === 'error') return c.json({ error: result.message, code: result.code }, clearErrorStatus(result.code))
return c.json({ ok: true, sessionId: result.sessionId })
})
app.post('/sessions/:id/clear-opencode/confirm-cleanup', async (c) => {
const engine = getSyncEngine()
if (!engine) return c.json({ error: 'Not ready' }, 503)
const parsed = ClearOpencodeSessionCallbackRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return c.json({ error: 'Invalid clear callback request' }, 400)
const result = engine.confirmOpenCodeClearCleanup(c.req.param('id'), c.get('namespace'), parsed.data.replacementSessionId)
if (result.type === 'error') return c.json({ error: result.message, code: result.code }, clearErrorStatus(result.code))
return c.json({ ok: true, sessionId: result.sessionId })
})
app.get('/sessions/:id', (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const sessionId = c.req.param('id')
const namespace = c.get('namespace')
const resolved = resolveSessionForNamespace(engine, sessionId, namespace)
if (!resolved.ok) {
return c.json({ error: resolved.error }, resolved.status)
}
return c.json({ session: resolved.session })
})
app.get('/sessions/:id/messages', (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const sessionId = c.req.param('id')
const namespace = c.get('namespace')
const resolved = resolveSessionForNamespace(engine, sessionId, namespace)
if (!resolved.ok) {
return c.json({ error: resolved.error }, resolved.status)
}
const parsed = getMessagesQuerySchema.safeParse(c.req.query())
if (!parsed.success) {
return c.json({ error: 'Invalid query' }, 400)
}
const limit = parsed.data.limit ?? 200
// Future-scheduled rows are excluded from CLI backfill — see
// messages.ts:getDeliverableMessagesAfter for the rationale. The
// mature-scan path (releaseMatureScheduledMessages) is the sole
// emit channel for scheduled rows.
const messages = engine.getDeliverableMessagesAfter(resolved.sessionId, {
afterSeq: parsed.data.afterSeq,
limit,
now: Date.now()
})
return c.json({ messages })
})
app.post('/sessions/:id/migrate-to-acp', async (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const sessionId = c.req.param('id')
const namespace = c.get('namespace')
const resolved = resolveSessionForNamespace(engine, sessionId, namespace)
if (!resolved.ok) {
return c.json({ error: resolved.error }, resolved.status)
}
// Codex #34 P2 (round 13): mirror the sessions.ts route hardening —
// distinguish "no body" from "malformed JSON". A silent fallback to
// {} would run the migration with destructive defaults even when
// the operator's intended body was mangled in transit.
const rawBody = await c.req.text()
let body: unknown = {}
if (rawBody.trim().length > 0) {
try {
body = JSON.parse(rawBody)
} catch {
return c.json({ error: 'Invalid JSON body' }, 400)
}
}
const parsed = CursorMigrateToAcpRequestSchema.safeParse(body ?? {})
if (!parsed.success) {
return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400)
}
const outcome = await engine.migrateLegacyCursorSession(resolved.sessionId, namespace, parsed.data)
const status = outcome.ok ? 200
: outcome.reason === 'already_acp' || outcome.reason === 'not_cursor_session' || outcome.reason === 'no_cursor_session_id' ? 409
: outcome.reason === 'running_refused' ? 409
: outcome.reason === 'target_already_exists' ? 409
: outcome.reason === 'no_legacy_store_on_disk' ? 404
: 500
return c.json(outcome, status)
})
app.post('/machines', async (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const json = await c.req.json().catch(() => null)
const parsed = CreateOrLoadMachineRequestSchema.safeParse(json)
if (!parsed.success) {
return c.json({ error: 'Invalid body' }, 400)
}
const namespace = c.get('namespace')
const existing = engine.getMachine(parsed.data.id)
if (existing && existing.namespace !== namespace) {
return c.json({ error: 'Machine access denied' }, 403)
}
const machine = engine.getOrCreateMachine(parsed.data.id, parsed.data.metadata, parsed.data.runnerState ?? null, namespace)
return c.json({ machine })
})
app.get('/machines/:id', (c) => {
const engine = getSyncEngine()
if (!engine) {
return c.json({ error: 'Not ready' }, 503)
}
const machineId = c.req.param('id')
const namespace = c.get('namespace')
const resolved = resolveMachineForNamespace(engine, machineId, namespace)
if (!resolved.ok) {
return c.json({ error: resolved.error }, resolved.status)
}
return c.json({ machine: resolved.machine })
})
return app
}