import { Hono } from 'hono' import { randomUUID } from 'node:crypto' import { getSession, incrementEpoch, touchSession, updateSessionStatus, } from '../../services/session' import { automationStatesEqual, getAutomationStateEventPayload, } from '../../services/automationState' import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth, } from '../../auth/middleware' import { getEventBus } from '../../transport/event-bus' import { storeGetSessionWorker, storeUpsertSessionWorker } from '../../store' import { updatePollTime } from '../../services/environment' const app = new Hono() /** GET /v1/code/sessions/:id/worker — Read worker state */ app.get('/:id/worker', acceptCliHeaders, sessionIngressAuth, async c => { const sessionId = c.req.param('id')! const session = getSession(sessionId) if (!session) { return c.json( { error: { type: 'not_found', message: 'Session not found' } }, 404, ) } const worker = storeGetSessionWorker(sessionId) return c.json( { worker: { worker_status: worker?.workerStatus ?? session.status, external_metadata: worker?.externalMetadata ?? null, requires_action_details: worker?.requiresActionDetails ?? null, last_heartbeat_at: worker?.lastHeartbeatAt?.toISOString() ?? null, }, }, 200, ) }) /** PUT /v1/code/sessions/:id/worker — Update worker state */ app.put('/:id/worker', acceptCliHeaders, sessionIngressAuth, async c => { const sessionId = c.req.param('id')! const session = getSession(sessionId) if (!session) { return c.json( { error: { type: 'not_found', message: 'Session not found' } }, 404, ) } const body = await c.req.json() const prevAutomationState = getAutomationStateEventPayload( storeGetSessionWorker(sessionId)?.externalMetadata, ) if (body.worker_status) { updateSessionStatus(sessionId, body.worker_status) } else { touchSession(sessionId) } const worker = storeUpsertSessionWorker(sessionId, { workerStatus: body.worker_status, externalMetadata: body.external_metadata, requiresActionDetails: body.requires_action_details, }) const nextAutomationState = getAutomationStateEventPayload( worker.externalMetadata, ) if (!automationStatesEqual(prevAutomationState, nextAutomationState)) { getEventBus(sessionId).publish({ id: randomUUID(), sessionId, type: 'automation_state', payload: nextAutomationState, direction: 'inbound', }) } return c.json( { status: 'ok', worker: { worker_status: worker.workerStatus ?? session.status, external_metadata: worker.externalMetadata, requires_action_details: worker.requiresActionDetails, last_heartbeat_at: worker.lastHeartbeatAt?.toISOString() ?? null, }, }, 200, ) }) /** POST /v1/code/sessions/:id/worker/heartbeat — Keep worker alive */ app.post( '/:id/worker/heartbeat', acceptCliHeaders, sessionIngressAuth, async c => { const sessionId = c.req.param('id')! const session = getSession(sessionId) if (!session) { return c.json( { error: { type: 'not_found', message: 'Session not found' } }, 404, ) } const now = new Date() storeUpsertSessionWorker(sessionId, { lastHeartbeatAt: now }) touchSession(sessionId) // V2 path doesn't go through /v1/environments/:id/work/poll, so // disconnect-monitor never sees lastPollAt updates. Refresh the parent // environment's poll time here — otherwise the env gets marked // disconnected and disappears from /web/environments even though the // worker is actively heartbeating. if (session.environment_id) { updatePollTime(session.environment_id) } return c.json({ status: 'ok', last_heartbeat_at: now.toISOString() }, 200) }, ) /** POST /v1/code/sessions/:id/worker/register — Register worker */ app.post('/:id/worker/register', acceptCliHeaders, apiKeyAuth, async c => { const sessionId = c.req.param('id')! const session = getSession(sessionId) if (!session) { return c.json( { error: { type: 'not_found', message: 'Session not found' } }, 404, ) } const epoch = incrementEpoch(sessionId) // Same lastPollAt rationale as heartbeat — V2 path skips v1 poll. if (session.environment_id) { updatePollTime(session.environment_id) } return c.json({ worker_epoch: epoch }, 200) }) export default app