style: 完成所有文件的lint

This commit is contained in:
claude-code-best
2026-05-01 21:39:30 +08:00
parent d136872cc9
commit 6182015005
1333 changed files with 68255 additions and 77882 deletions

View File

@@ -1,224 +1,232 @@
import { Hono } from "hono";
import { randomUUID } from "node:crypto";
import type { Context } from "hono";
import type { WSContext, WSMessageReceive } from "hono/ws";
import { upgradeWebSocket } from "../../transport/ws-shared";
import { Hono } from 'hono'
import { randomUUID } from 'node:crypto'
import type { Context } from 'hono'
import type { WSContext, WSMessageReceive } from 'hono/ws'
import { upgradeWebSocket } from '../../transport/ws-shared'
import {
decodeWsPayload,
handleSizedWsPayload,
} from "../../transport/ws-payload";
} from '../../transport/ws-payload'
import {
extractBearerToken,
extractWebSocketAuthToken,
} from "../../auth/middleware";
import { validateApiKey } from "../../auth/api-key";
} from '../../auth/middleware'
import { validateApiKey } from '../../auth/api-key'
import {
handleAcpWsOpen,
handleAcpWsMessage,
handleAcpWsClose,
} from "../../transport/acp-ws-handler";
} from '../../transport/acp-ws-handler'
import {
handleRelayOpen,
handleRelayMessage,
handleRelayClose,
} from "../../transport/acp-relay-handler";
} from '../../transport/acp-relay-handler'
import {
storeListAcpAgents,
storeListAcpAgentsByChannelGroup,
storeGetEnvironment,
} from "../../store";
import { createAcpSSEStream } from "../../transport/acp-sse-writer";
import { log, error as logError } from "../../logger";
} from '../../store'
import { createAcpSSEStream } from '../../transport/acp-sse-writer'
import { log, error as logError } from '../../logger'
const app = new Hono();
const app = new Hono()
type WsMessageEvent = {
data: WSMessageReceive;
};
data: WSMessageReceive
}
type WsCloseEvent = {
code?: number;
reason?: string;
};
code?: number
reason?: string
}
/** Response shape for an ACP agent */
function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
if (!env) return null;
if (!env) return null
return {
id: env.id,
agent_name: env.machineName,
channel_group_id: env.bridgeId,
status: env.status === "active" ? "online" : "offline",
status: env.status === 'active' ? 'online' : 'offline',
max_sessions: env.maxSessions,
last_seen_at: env.lastPollAt ? env.lastPollAt.getTime() / 1000 : null,
created_at: env.createdAt.getTime() / 1000,
};
}
}
function hasAcpReadAuth(c: Context): boolean {
const token = extractBearerToken(c);
return !!token && validateApiKey(token);
const token = extractBearerToken(c)
return !!token && validateApiKey(token)
}
export function hasAcpRelayAuth(c: Context): boolean {
const token = extractWebSocketAuthToken(c);
return !!token && validateApiKey(token);
const token = extractWebSocketAuthToken(c)
return !!token && validateApiKey(token)
}
function acpReadUnauthorized(c: Context) {
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
return c.json(
{ error: { type: 'unauthorized', message: 'Missing auth' } },
401,
)
}
/** GET /acp/agents — List all registered ACP agents (API key auth) */
app.get("/agents", async (c) => {
app.get('/agents', async c => {
if (!hasAcpReadAuth(c)) {
return acpReadUnauthorized(c);
return acpReadUnauthorized(c)
}
const agents = storeListAcpAgents();
return c.json(agents.map((a) => toAcpAgentResponse(a)).filter(Boolean));
});
const agents = storeListAcpAgents()
return c.json(agents.map(a => toAcpAgentResponse(a)).filter(Boolean))
})
/** GET /acp/channel-groups — List all channel groups with member agents (API key auth) */
app.get("/channel-groups", async (c) => {
app.get('/channel-groups', async c => {
if (!hasAcpReadAuth(c)) {
return acpReadUnauthorized(c);
return acpReadUnauthorized(c)
}
const agents = storeListAcpAgents();
const groupMap = new Map<string, typeof agents>();
const agents = storeListAcpAgents()
const groupMap = new Map<string, typeof agents>()
for (const agent of agents) {
const groupId = agent.bridgeId || "default";
const groupId = agent.bridgeId || 'default'
if (!groupMap.has(groupId)) {
groupMap.set(groupId, []);
groupMap.set(groupId, [])
}
groupMap.get(groupId)!.push(agent);
groupMap.get(groupId)!.push(agent)
}
const groups = [...groupMap.entries()].map(([id, members]) => ({
channel_group_id: id,
member_count: members.length,
members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean),
}));
return c.json(groups);
});
members: members.map(m => toAcpAgentResponse(m)).filter(Boolean),
}))
return c.json(groups)
})
/** GET /acp/channel-groups/:id — Specific channel group detail (API key auth) */
app.get("/channel-groups/:id", async (c) => {
app.get('/channel-groups/:id', async c => {
if (!hasAcpReadAuth(c)) {
return acpReadUnauthorized(c);
return acpReadUnauthorized(c)
}
const groupId = c.req.param("id")!;
const members = storeListAcpAgentsByChannelGroup(groupId);
const groupId = c.req.param('id')!
const members = storeListAcpAgentsByChannelGroup(groupId)
if (members.length === 0) {
return c.json({ error: { type: "not_found", message: "Channel group not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Channel group not found' } },
404,
)
}
return c.json({
channel_group_id: groupId,
member_count: members.length,
members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean),
});
});
members: members.map(m => toAcpAgentResponse(m)).filter(Boolean),
})
})
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (API key auth) */
app.get("/channel-groups/:id/events", async (c) => {
app.get('/channel-groups/:id/events', async c => {
if (!hasAcpReadAuth(c)) {
return acpReadUnauthorized(c);
return acpReadUnauthorized(c)
}
const groupId = c.req.param("id")!;
const groupId = c.req.param('id')!
// Support Last-Event-ID / from_sequence_num for reconnection
const lastEventId = c.req.header("Last-Event-ID");
const fromSeq = c.req.query("from_sequence_num");
const fromSeqNum = fromSeq ? parseInt(fromSeq, 10) : lastEventId ? parseInt(lastEventId, 10) : 0;
const lastEventId = c.req.header('Last-Event-ID')
const fromSeq = c.req.query('from_sequence_num')
const fromSeqNum = fromSeq
? parseInt(fromSeq, 10)
: lastEventId
? parseInt(lastEventId, 10)
: 0
return createAcpSSEStream(c, groupId, fromSeqNum);
});
return createAcpSSEStream(c, groupId, fromSeqNum)
})
/** WS /acp/ws — WebSocket endpoint for acp-link connections */
app.get(
"/ws",
upgradeWebSocket(async (c) => {
const token = extractWebSocketAuthToken(c);
'/ws',
upgradeWebSocket(async c => {
const token = extractWebSocketAuthToken(c)
if (!token || !validateApiKey(token)) {
log("[ACP-WS] Upgrade rejected: unauthorized");
log('[ACP-WS] Upgrade rejected: unauthorized')
return {
onOpen(_evt: Event, ws: WSContext) {
ws.close(4003, "unauthorized");
ws.close(4003, 'unauthorized')
},
};
}
}
// Generate unique wsId for this connection
const wsId = `acp_ws_${randomUUID().replace(/-/g, "")}`;
const wsId = `acp_ws_${randomUUID().replace(/-/g, '')}`
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`);
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`)
return {
onOpen(_evt: Event, ws: WSContext) {
handleAcpWsOpen(ws, wsId);
handleAcpWsOpen(ws, wsId)
},
onMessage(evt: WsMessageEvent, ws: WSContext) {
handleAcpWsPayload(
ws,
"[ACP-WS]",
`wsId=${wsId}`,
evt.data,
data => handleAcpWsMessage(ws, wsId, data),
);
handleAcpWsPayload(ws, '[ACP-WS]', `wsId=${wsId}`, evt.data, data =>
handleAcpWsMessage(ws, wsId, data),
)
},
onClose(evt: WsCloseEvent, ws: WSContext) {
handleAcpWsClose(ws, wsId, evt.code, evt.reason);
handleAcpWsClose(ws, wsId, evt.code, evt.reason)
},
onError(evt: Event, ws: WSContext) {
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt);
handleAcpWsClose(ws, wsId, 1006, "websocket error");
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt)
handleAcpWsClose(ws, wsId, 1006, 'websocket error')
},
};
}
}),
);
)
/** WS /acp/relay/:agentId — WebSocket relay for frontend to interact with an agent */
app.get(
"/relay/:agentId",
upgradeWebSocket(async (c) => {
'/relay/:agentId',
upgradeWebSocket(async c => {
if (!hasAcpRelayAuth(c)) {
log("[ACP-Relay] Upgrade rejected: unauthorized");
log('[ACP-Relay] Upgrade rejected: unauthorized')
return {
onOpen(_evt: Event, ws: WSContext) {
ws.close(4003, "unauthorized");
ws.close(4003, 'unauthorized')
},
};
}
}
const agentId = c.req.param("agentId")!;
const relayWsId = `relay_${randomUUID().replace(/-/g, "")}`;
const agentId = c.req.param('agentId')!
const relayWsId = `relay_${randomUUID().replace(/-/g, '')}`
log(`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`);
log(
`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`,
)
return {
onOpen(_evt: Event, ws: WSContext) {
handleRelayOpen(ws, relayWsId, agentId);
handleRelayOpen(ws, relayWsId, agentId)
},
onMessage(evt: WsMessageEvent, ws: WSContext) {
handleAcpWsPayload(
ws,
"[ACP-Relay]",
'[ACP-Relay]',
`relayWsId=${relayWsId}`,
evt.data,
data => handleRelayMessage(ws, relayWsId, data),
);
)
},
onClose(evt: WsCloseEvent, ws: WSContext) {
handleRelayClose(ws, relayWsId, evt.code, evt.reason);
handleRelayClose(ws, relayWsId, evt.code, evt.reason)
},
onError(evt: Event, ws: WSContext) {
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt);
handleRelayClose(ws, relayWsId, 1006, "websocket error");
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt)
handleRelayClose(ws, relayWsId, 1006, 'websocket error')
},
};
}
}),
);
)
export const decodeAcpWsMessageData = decodeWsPayload;
export const decodeAcpWsMessageData = decodeWsPayload
export function handleAcpWsPayload(
ws: WSContext,
@@ -227,7 +235,7 @@ export function handleAcpWsPayload(
payload: unknown,
handleMessage: (data: string) => void,
): boolean {
return handleSizedWsPayload(ws, logPrefix, label, payload, handleMessage);
return handleSizedWsPayload(ws, logPrefix, label, payload, handleMessage)
}
export default app;
export default app

View File

@@ -1,39 +1,45 @@
import { Hono } from "hono";
import { registerEnvironment, deregisterEnvironment, reconnectEnvironment } from "../../services/environment";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
import { storeBindSession } from "../../store";
import { Hono } from 'hono'
import {
registerEnvironment,
deregisterEnvironment,
reconnectEnvironment,
} from '../../services/environment'
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
import { storeBindSession } from '../../store'
const app = new Hono();
const app = new Hono()
/** POST /v1/environments/bridge — Register an environment */
app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
const body = await c.req.json();
const username = c.get("username");
const result = registerEnvironment({ ...body, username });
app.post('/bridge', acceptCliHeaders, apiKeyAuth, async c => {
const body = await c.req.json()
const username = c.get('username')
const result = registerEnvironment({ ...body, username })
// Bind ACP session to the group ID so the web UI can find it by group
if (result.session_id) {
const groupId = body.bridge_id as string | undefined;
const groupId = body.bridge_id as string | undefined
if (groupId) {
storeBindSession(result.session_id, groupId);
storeBindSession(result.session_id, groupId)
}
}
return c.json(result, 200);
});
return c.json(result, 200)
})
/** DELETE /v1/environments/bridge/:id — Deregister */
app.delete("/bridge/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
const envId = c.req.param("id")!;
deregisterEnvironment(envId);
return c.json({ status: "ok" }, 200);
});
app.delete('/bridge/:id', acceptCliHeaders, apiKeyAuth, async c => {
const envId = c.req.param('id')!
deregisterEnvironment(envId)
return c.json({ status: 'ok' }, 200)
})
/** POST /v1/environments/:id/bridge/reconnect — Reconnect */
app.post("/:id/bridge/reconnect", acceptCliHeaders, apiKeyAuth, async (c) => {
const envId = c.req.param("id")!;
reconnectEnvironment(envId);
const { reconnectWorkForEnvironment } = await import("../../services/work-dispatch");
await reconnectWorkForEnvironment(envId);
return c.json({ status: "ok" }, 200);
});
app.post('/:id/bridge/reconnect', acceptCliHeaders, apiKeyAuth, async c => {
const envId = c.req.param('id')!
reconnectEnvironment(envId)
const { reconnectWorkForEnvironment } = await import(
'../../services/work-dispatch'
)
await reconnectWorkForEnvironment(envId)
return c.json({ status: 'ok' }, 200)
})
export default app;
export default app

View File

@@ -1,41 +1,51 @@
import { Hono } from "hono";
import { pollWork, ackWork, stopWork, heartbeatWork } from "../../services/work-dispatch";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
import { updatePollTime } from "../../services/environment";
import { Hono } from 'hono'
import {
pollWork,
ackWork,
stopWork,
heartbeatWork,
} from '../../services/work-dispatch'
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
import { updatePollTime } from '../../services/environment'
const app = new Hono();
const app = new Hono()
/** GET /v1/environments/:id/work/poll — Long-poll for work */
app.get("/:id/work/poll", acceptCliHeaders, apiKeyAuth, async (c) => {
const envId = c.req.param("id")!;
updatePollTime(envId);
const result = await pollWork(envId);
app.get('/:id/work/poll', acceptCliHeaders, apiKeyAuth, async c => {
const envId = c.req.param('id')!
updatePollTime(envId)
const result = await pollWork(envId)
if (!result) {
// Return 204 No Content so the client's axios parses it as null
return c.body(null, 204);
return c.body(null, 204)
}
return c.json(result, 200);
});
return c.json(result, 200)
})
/** POST /v1/environments/:id/work/:workId/ack — Acknowledge work */
app.post("/:id/work/:workId/ack", acceptCliHeaders, apiKeyAuth, async (c) => {
const workId = c.req.param("workId")!;
ackWork(workId);
return c.json({ status: "ok" }, 200);
});
app.post('/:id/work/:workId/ack', acceptCliHeaders, apiKeyAuth, async c => {
const workId = c.req.param('workId')!
ackWork(workId)
return c.json({ status: 'ok' }, 200)
})
/** POST /v1/environments/:id/work/:workId/stop — Stop work */
app.post("/:id/work/:workId/stop", acceptCliHeaders, apiKeyAuth, async (c) => {
const workId = c.req.param("workId")!;
stopWork(workId);
return c.json({ status: "ok" }, 200);
});
app.post('/:id/work/:workId/stop', acceptCliHeaders, apiKeyAuth, async c => {
const workId = c.req.param('workId')!
stopWork(workId)
return c.json({ status: 'ok' }, 200)
})
/** POST /v1/environments/:id/work/:workId/heartbeat — Heartbeat */
app.post("/:id/work/:workId/heartbeat", acceptCliHeaders, apiKeyAuth, async (c) => {
const workId = c.req.param("workId")!;
const result = heartbeatWork(workId);
return c.json(result, 200);
});
app.post(
'/:id/work/:workId/heartbeat',
acceptCliHeaders,
apiKeyAuth,
async c => {
const workId = c.req.param('workId')!
const result = heartbeatWork(workId)
return c.json(result, 200)
},
)
export default app;
export default app

View File

@@ -1,131 +1,143 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono";
import type { Context } from "hono";
import type { WSContext, WSMessageReceive } from "hono/ws";
import { upgradeWebSocket, websocket } from "../../transport/ws-shared";
import { log, error as logError } from '../../logger'
import { Hono } from 'hono'
import type { Context } from 'hono'
import type { WSContext, WSMessageReceive } from 'hono/ws'
import { upgradeWebSocket, websocket } from '../../transport/ws-shared'
import {
decodeWsPayload,
handleSizedWsPayload,
} from "../../transport/ws-payload";
import { validateApiKey } from "../../auth/api-key";
import { verifyWorkerJwt } from "../../auth/jwt";
import { extractWebSocketAuthToken } from "../../auth/middleware";
} from '../../transport/ws-payload'
import { validateApiKey } from '../../auth/api-key'
import { verifyWorkerJwt } from '../../auth/jwt'
import { extractWebSocketAuthToken } from '../../auth/middleware'
import {
handleWebSocketOpen,
handleWebSocketMessage,
handleWebSocketClose,
ingestBridgeMessage,
} from "../../transport/ws-handler";
import { getSession, resolveExistingSessionId } from "../../services/session";
} from '../../transport/ws-handler'
import { getSession, resolveExistingSessionId } from '../../services/session'
const app = new Hono();
const app = new Hono()
type WsMessageEvent = {
data: WSMessageReceive;
};
data: WSMessageReceive
}
type WsCloseEvent = {
code?: number;
reason?: string;
};
code?: number
reason?: string
}
/** Authenticate via API key or worker JWT without accepting URL query secrets. */
function authenticateRequest(c: Context, label: string, expectedSessionId?: string): boolean {
const token = extractWebSocketAuthToken(c);
function authenticateRequest(
c: Context,
label: string,
expectedSessionId?: string,
): boolean {
const token = extractWebSocketAuthToken(c)
// Try API key first
if (validateApiKey(token)) {
return true;
return true
}
// Try JWT verification — validate session_id matches if provided
if (token) {
const payload = verifyWorkerJwt(token);
const payload = verifyWorkerJwt(token)
if (payload) {
if (expectedSessionId && payload.session_id !== expectedSessionId) {
log(`[Auth] ${label}: FAILED — JWT session_id mismatch`);
return false;
log(`[Auth] ${label}: FAILED — JWT session_id mismatch`)
return false
}
return true;
return true
}
}
log(`[Auth] ${label}: FAILED — no valid API key or JWT`);
return false;
log(`[Auth] ${label}: FAILED — no valid API key or JWT`)
return false
}
/** POST /v2/session_ingress/session/:sessionId/events — HTTP POST (HybridTransport writes) */
app.post("/session/:sessionId/events", async (c) => {
const requestedSessionId = c.req.param("sessionId")!;
const sessionId = resolveExistingSessionId(requestedSessionId) ?? requestedSessionId;
app.post('/session/:sessionId/events', async c => {
const requestedSessionId = c.req.param('sessionId')!
const sessionId =
resolveExistingSessionId(requestedSessionId) ?? requestedSessionId
if (!authenticateRequest(c, `POST session/${sessionId}`, sessionId)) {
return c.json({ error: { type: "unauthorized", message: "Invalid auth" } }, 401);
return c.json(
{ error: { type: 'unauthorized', message: 'Invalid auth' } },
401,
)
}
const session = getSession(sessionId);
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const body = await c.req.json();
const events = Array.isArray(body.events) ? body.events : [body];
const body = await c.req.json()
const events = Array.isArray(body.events) ? body.events : [body]
let count = 0;
let count = 0
for (const msg of events) {
if (!msg || typeof msg !== "object") continue;
ingestBridgeMessage(sessionId, msg as Record<string, unknown>);
count++;
if (!msg || typeof msg !== 'object') continue
ingestBridgeMessage(sessionId, msg as Record<string, unknown>)
count++
}
return c.json({ status: "ok" }, 200);
});
return c.json({ status: 'ok' }, 200)
})
/** WS /v2/session_ingress/ws/:sessionId — WebSocket transport */
app.get(
"/ws/:sessionId",
upgradeWebSocket(async (c) => {
const requestedSessionId = c.req.param("sessionId")!;
const sessionId = resolveExistingSessionId(requestedSessionId) ?? requestedSessionId;
'/ws/:sessionId',
upgradeWebSocket(async c => {
const requestedSessionId = c.req.param('sessionId')!
const sessionId =
resolveExistingSessionId(requestedSessionId) ?? requestedSessionId
if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) {
return {
onOpen(_evt: Event, ws: WSContext) {
ws.close(4003, "unauthorized");
ws.close(4003, 'unauthorized')
},
};
}
}
const session = getSession(sessionId);
const session = getSession(sessionId)
if (!session) {
log(`[WS] Upgrade rejected: session ${sessionId} not found`);
log(`[WS] Upgrade rejected: session ${sessionId} not found`)
return {
onOpen(_evt: Event, ws: WSContext) {
ws.close(4001, "session not found");
ws.close(4001, 'session not found')
},
};
}
}
log(`[WS] Upgrade accepted: session=${sessionId}`);
log(`[WS] Upgrade accepted: session=${sessionId}`)
return {
onOpen(_evt: Event, ws: WSContext) {
handleWebSocketOpen(ws, sessionId);
handleWebSocketOpen(ws, sessionId)
},
onMessage(evt: WsMessageEvent, ws: WSContext) {
handleSessionIngressWsPayload(ws, sessionId, evt.data);
handleSessionIngressWsPayload(ws, sessionId, evt.data)
},
onClose(evt: WsCloseEvent, ws: WSContext) {
handleWebSocketClose(ws, sessionId, evt.code, evt.reason);
handleWebSocketClose(ws, sessionId, evt.code, evt.reason)
},
onError(evt: Event, ws: WSContext) {
logError(`[WS] Error on session=${sessionId}:`, evt);
handleWebSocketClose(ws, sessionId, 1006, "websocket error");
logError(`[WS] Error on session=${sessionId}:`, evt)
handleWebSocketClose(ws, sessionId, 1006, 'websocket error')
},
};
}
}),
);
)
export const decodeSessionIngressWsMessage = decodeWsPayload;
export const decodeSessionIngressWsMessage = decodeWsPayload
export function handleSessionIngressWsPayload(
ws: WSContext,
@@ -134,12 +146,12 @@ export function handleSessionIngressWsPayload(
): boolean {
return handleSizedWsPayload(
ws,
"[WS]",
'[WS]',
`session=${sessionId}`,
payload,
data => handleWebSocketMessage(ws, sessionId, data),
);
)
}
export { websocket };
export default app;
export { websocket }
export default app

View File

@@ -1,104 +1,129 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono";
import { log, error as logError } from '../../logger'
import { Hono } from 'hono'
import {
createSession,
getSession,
updateSessionTitle,
archiveSession,
resolveExistingSessionId,
} from "../../services/session";
import { createWorkItem } from "../../services/work-dispatch";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
import { publishSessionEvent } from "../../services/transport";
} from '../../services/session'
import { createWorkItem } from '../../services/work-dispatch'
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
import { publishSessionEvent } from '../../services/transport'
const app = new Hono();
const app = new Hono()
/** POST /v1/sessions — Create session */
app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
const body = await c.req.json();
const username = c.get("username");
const session = createSession({ ...body, username });
app.post('/', acceptCliHeaders, apiKeyAuth, async c => {
const body = await c.req.json()
const username = c.get('username')
const session = createSession({ ...body, username })
// Create work item if environment is specified
if (body.environment_id) {
try {
await createWorkItem(body.environment_id, session.id);
await createWorkItem(body.environment_id, session.id)
} catch (err) {
logError(`[RCS] Failed to create work item: ${(err as Error).message}`);
logError(`[RCS] Failed to create work item: ${(err as Error).message}`)
}
}
// Publish initial events if provided
if (body.events && Array.isArray(body.events)) {
for (const evt of body.events) {
publishSessionEvent(session.id, evt.type || "init", evt, "outbound");
publishSessionEvent(session.id, evt.type || 'init', evt, 'outbound')
}
}
return c.json(session, 200);
});
return c.json(session, 200)
})
/** GET /v1/sessions/:id — Get session */
app.get("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
const session = getSession(sessionId);
app.get('/:id', acceptCliHeaders, apiKeyAuth, async c => {
const sessionId =
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
return c.json(session, 200);
});
return c.json(session, 200)
})
/** PATCH /v1/sessions/:id — Update session title */
app.patch("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
const existing = getSession(sessionId);
app.patch('/:id', acceptCliHeaders, apiKeyAuth, async c => {
const sessionId =
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
const existing = getSession(sessionId)
if (!existing) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const body = await c.req.json();
const body = await c.req.json()
if (body.title) {
updateSessionTitle(sessionId, body.title);
updateSessionTitle(sessionId, body.title)
}
const session = getSession(sessionId);
return c.json(session, 200);
});
const session = getSession(sessionId)
return c.json(session, 200)
})
/** POST /v1/sessions/:id/archive — Archive session */
app.post("/:id/archive", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
const session = getSession(sessionId);
app.post('/:id/archive', acceptCliHeaders, apiKeyAuth, async c => {
const sessionId =
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
try {
archiveSession(sessionId);
archiveSession(sessionId)
} catch {
return c.json({ status: "ok" }, 409);
return c.json({ status: 'ok' }, 409)
}
return c.json({ status: "ok" }, 200);
});
return c.json({ status: 'ok' }, 200)
})
/** POST /v1/sessions/:id/events — Send event to session */
app.post("/:id/events", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
const session = getSession(sessionId);
app.post('/:id/events', acceptCliHeaders, apiKeyAuth, async c => {
const sessionId =
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const body = await c.req.json();
const body = await c.req.json()
const events = body.events
? Array.isArray(body.events) ? body.events : [body.events]
: Array.isArray(body) ? body : [body];
const published = [];
? Array.isArray(body.events)
? body.events
: [body.events]
: Array.isArray(body)
? body
: [body]
const published = []
for (const evt of events) {
const result = publishSessionEvent(sessionId, evt.type || "message", evt, "inbound");
published.push(result);
const result = publishSessionEvent(
sessionId,
evt.type || 'message',
evt,
'inbound',
)
published.push(result)
}
return c.json({ status: "ok", events: published.length }, 200);
});
return c.json({ status: 'ok', events: published.length }, 200)
})
export default app;
export default app

View File

@@ -1,36 +1,46 @@
import { Hono } from "hono";
import { createCodeSession, getSession, incrementEpoch } from "../../services/session";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
import { generateWorkerJwt } from "../../auth/jwt";
import { getBaseUrl, config } from "../../config";
import { Hono } from 'hono'
import {
createCodeSession,
getSession,
incrementEpoch,
} from '../../services/session'
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
import { generateWorkerJwt } from '../../auth/jwt'
import { getBaseUrl, config } from '../../config'
const app = new Hono();
const app = new Hono()
/** POST /v1/code/sessions — Create code session (wrapped response for TUI compat) */
app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
const body = await c.req.json();
const session = createCodeSession(body);
return c.json({ session }, 200);
});
app.post('/', acceptCliHeaders, apiKeyAuth, async c => {
const body = await c.req.json()
const session = createCodeSession(body)
return c.json({ session }, 200)
})
/** POST /v1/code/sessions/:id/bridge — Get connection info + worker JWT */
app.post("/:id/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = c.req.param("id")!;
const session = getSession(sessionId);
app.post('/:id/bridge', 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);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const epoch = incrementEpoch(sessionId);
const expiresInSeconds = config.jwtExpiresIn;
const workerJwt = generateWorkerJwt(sessionId, expiresInSeconds);
const epoch = incrementEpoch(sessionId)
const expiresInSeconds = config.jwtExpiresIn
const workerJwt = generateWorkerJwt(sessionId, expiresInSeconds)
return c.json({
api_base_url: getBaseUrl(),
worker_epoch: epoch,
worker_jwt: workerJwt,
expires_in: expiresInSeconds,
}, 200);
});
return c.json(
{
api_base_url: getBaseUrl(),
worker_epoch: epoch,
worker_jwt: workerJwt,
expires_in: expiresInSeconds,
},
200,
)
})
export default app;
export default app

View File

@@ -1,24 +1,36 @@
import { Hono } from "hono";
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
import { createWorkerEventStream } from "../../transport/sse-writer";
import { getSession } from "../../services/session";
import { Hono } from 'hono'
import { sessionIngressAuth, acceptCliHeaders } from '../../auth/middleware'
import { createWorkerEventStream } from '../../transport/sse-writer'
import { getSession } from '../../services/session'
const app = new Hono();
const app = new Hono()
/** SSE /v1/code/sessions/:id/worker/events/stream — SSE event stream */
app.get("/:id/worker/events/stream", 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);
}
app.get(
'/:id/worker/events/stream',
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,
)
}
// Support Last-Event-ID / from_sequence_num for reconnection
const lastEventId = c.req.header("Last-Event-ID");
const fromSeq = c.req.query("from_sequence_num");
const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
// Support Last-Event-ID / from_sequence_num for reconnection
const lastEventId = c.req.header('Last-Event-ID')
const fromSeq = c.req.query('from_sequence_num')
const fromSeqNum = fromSeq
? parseInt(fromSeq, 10)
: lastEventId
? parseInt(lastEventId, 10)
: 0
return createWorkerEventStream(c, sessionId, fromSeqNum);
});
return createWorkerEventStream(c, sessionId, fromSeqNum)
},
)
export default app;
export default app

View File

@@ -1,99 +1,144 @@
import { Hono } from "hono";
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
import { publishSessionEvent } from "../../services/transport";
import { getSession, touchSession, updateSessionStatus } from "../../services/session";
import { Hono } from 'hono'
import { sessionIngressAuth, acceptCliHeaders } from '../../auth/middleware'
import { publishSessionEvent } from '../../services/transport'
import {
getSession,
touchSession,
updateSessionStatus,
} from '../../services/session'
const app = new Hono();
const app = new Hono()
function extractWorkerEvents(body: unknown): Array<Record<string, unknown>> {
if (!body || typeof body !== "object") {
return [];
if (!body || typeof body !== 'object') {
return []
}
const payload = body as Record<string, unknown>;
const payload = body as Record<string, unknown>
const rawEvents = Array.isArray(payload.events)
? payload.events
: Array.isArray(body)
? body
: [body];
: [body]
return rawEvents
.filter((evt): evt is Record<string, unknown> => !!evt && typeof evt === "object")
.map((evt) => {
const wrappedPayload = evt.payload;
if (wrappedPayload && typeof wrappedPayload === "object" && !Array.isArray(wrappedPayload)) {
return wrappedPayload as Record<string, unknown>;
.filter(
(evt): evt is Record<string, unknown> => !!evt && typeof evt === 'object',
)
.map(evt => {
const wrappedPayload = evt.payload
if (
wrappedPayload &&
typeof wrappedPayload === 'object' &&
!Array.isArray(wrappedPayload)
) {
return wrappedPayload as Record<string, unknown>
}
return evt;
});
return evt
})
}
/** POST /v1/code/sessions/:id/worker/events — Write events */
app.post("/:id/worker/events", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const body = await c.req.json();
app.post(
'/:id/worker/events',
acceptCliHeaders,
sessionIngressAuth,
async c => {
const sessionId = c.req.param('id')!
if (!getSession(sessionId)) {
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const body = await c.req.json()
const events = extractWorkerEvents(body);
const published = [];
for (const evt of events) {
const eventType = typeof evt.type === "string" ? evt.type : "message";
const result = publishSessionEvent(sessionId, eventType, evt, "inbound");
published.push(result);
}
const events = extractWorkerEvents(body)
const published = []
for (const evt of events) {
const eventType = typeof evt.type === 'string' ? evt.type : 'message'
const result = publishSessionEvent(sessionId, eventType, evt, 'inbound')
published.push(result)
}
touchSession(sessionId);
touchSession(sessionId)
return c.json({ status: "ok", count: published.length }, 200);
});
return c.json({ status: 'ok', count: published.length }, 200)
},
)
/** PUT /v1/code/sessions/:id/worker/state — Report worker state */
app.put("/:id/worker/state", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
app.put('/:id/worker/state', acceptCliHeaders, sessionIngressAuth, async c => {
const sessionId = c.req.param('id')!
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const body = await c.req.json();
const body = await c.req.json()
if (body.status) {
updateSessionStatus(sessionId, body.status);
updateSessionStatus(sessionId, body.status)
} else {
touchSession(sessionId);
touchSession(sessionId)
}
return c.json({ status: "ok" }, 200);
});
return c.json({ status: 'ok' }, 200)
})
/** PUT /v1/code/sessions/:id/worker/external_metadata — Report worker metadata (no-op) */
app.put("/:id/worker/external_metadata", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
// TUI's CCRClient calls this for metadata reporting. Accept and discard.
return c.json({ status: "ok" }, 200);
});
app.put(
'/:id/worker/external_metadata',
acceptCliHeaders,
sessionIngressAuth,
async c => {
const sessionId = c.req.param('id')!
if (!getSession(sessionId)) {
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
// TUI's CCRClient calls this for metadata reporting. Accept and discard.
return c.json({ status: 'ok' }, 200)
},
)
/** POST /v1/code/sessions/:id/worker/events/delivery — Batch delivery tracking (no-op) */
app.post("/:id/worker/events/delivery", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
return c.json({ status: "ok" }, 200);
});
app.post(
'/:id/worker/events/delivery',
acceptCliHeaders,
sessionIngressAuth,
async c => {
const sessionId = c.req.param('id')!
if (!getSession(sessionId)) {
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
return c.json({ status: 'ok' }, 200)
},
)
/** POST /v1/code/sessions/:id/worker/events/:eventId/delivery — Delivery tracking (no-op) */
app.post("/:id/worker/events/:eventId/delivery", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
// TUI's CCRClient reports event delivery status (received/processing/processed).
// Accept and discard — event bus doesn't track per-event delivery.
return c.json({ status: "ok" }, 200);
});
app.post(
'/:id/worker/events/:eventId/delivery',
acceptCliHeaders,
sessionIngressAuth,
async c => {
const sessionId = c.req.param('id')!
if (!getSession(sessionId)) {
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
// TUI's CCRClient reports event delivery status (received/processing/processed).
// Accept and discard — event bus doesn't track per-event delivery.
return c.json({ status: 'ok' }, 200)
},
)
export default app;
export default app

View File

@@ -1,105 +1,139 @@
import { Hono } from "hono";
import { randomUUID } from "node:crypto";
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
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";
} from '../../services/automationState'
import {
apiKeyAuth,
acceptCliHeaders,
sessionIngressAuth,
} from '../../auth/middleware'
import { getEventBus } from '../../transport/event-bus'
import { storeGetSessionWorker, storeUpsertSessionWorker } from '../../store'
const app = new Hono();
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);
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);
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,
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);
});
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);
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);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const body = await c.req.json();
const body = await c.req.json()
const prevAutomationState = getAutomationStateEventPayload(
storeGetSessionWorker(sessionId)?.externalMetadata,
);
)
if (body.worker_status) {
updateSessionStatus(sessionId, body.worker_status);
updateSessionStatus(sessionId, body.worker_status)
} else {
touchSession(sessionId);
touchSession(sessionId)
}
const worker = storeUpsertSessionWorker(sessionId, {
workerStatus: body.worker_status,
externalMetadata: body.external_metadata,
requiresActionDetails: body.requires_action_details,
});
const nextAutomationState = getAutomationStateEventPayload(worker.externalMetadata);
})
const nextAutomationState = getAutomationStateEventPayload(
worker.externalMetadata,
)
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
getEventBus(sessionId).publish({
id: randomUUID(),
sessionId,
type: "automation_state",
type: 'automation_state',
payload: nextAutomationState,
direction: "inbound",
});
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,
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);
});
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);
}
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);
return c.json({ status: "ok", last_heartbeat_at: now.toISOString() }, 200);
});
const now = new Date()
storeUpsertSessionWorker(sessionId, { lastHeartbeatAt: now })
touchSession(sessionId)
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);
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);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const epoch = incrementEpoch(sessionId);
return c.json({ worker_epoch: epoch }, 200);
});
const epoch = incrementEpoch(sessionId)
return c.json({ worker_epoch: epoch }, 200)
})
export default app;
export default app

View File

@@ -1,27 +1,30 @@
import { Hono } from "hono";
import { storeBindSession } from "../../store";
import { resolveExistingWebSessionId, toWebSessionId } from "../../services/session";
import { Hono } from 'hono'
import { storeBindSession } from '../../store'
import {
resolveExistingWebSessionId,
toWebSessionId,
} from '../../services/session'
const app = new Hono();
const app = new Hono()
/** POST /web/bind — Bind a session to a UUID (no-login auth) */
app.post("/bind", async (c) => {
const body = await c.req.json();
const sessionId = body.sessionId;
app.post('/bind', async c => {
const body = await c.req.json()
const sessionId = body.sessionId
// UUID can come from query param (api.js sends it in URL) or body
const uuid = c.req.query("uuid") || body.uuid;
const uuid = c.req.query('uuid') || body.uuid
if (!sessionId || !uuid) {
return c.json({ error: "sessionId and uuid are required" }, 400);
return c.json({ error: 'sessionId and uuid are required' }, 400)
}
const resolvedSessionId = resolveExistingWebSessionId(sessionId);
const resolvedSessionId = resolveExistingWebSessionId(sessionId)
if (!resolvedSessionId) {
return c.json({ error: "Session not found" }, 404);
return c.json({ error: 'Session not found' }, 404)
}
storeBindSession(resolvedSessionId, uuid);
return c.json({ ok: true, sessionId: toWebSessionId(resolvedSessionId) });
});
storeBindSession(resolvedSessionId, uuid)
return c.json({ ok: true, sessionId: toWebSessionId(resolvedSessionId) })
})
export default app;
export default app

View File

@@ -1,86 +1,130 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware";
import { getSession, isSessionClosedStatus, resolveOwnedWebSessionId, updateSessionStatus } from "../../services/session";
import { publishSessionEvent } from "../../services/transport";
import { getEventBus } from "../../transport/event-bus";
import { log, error as logError } from '../../logger'
import { Hono } from 'hono'
import { uuidAuth } from '../../auth/middleware'
import {
getSession,
isSessionClosedStatus,
resolveOwnedWebSessionId,
updateSessionStatus,
} from '../../services/session'
import { publishSessionEvent } from '../../services/transport'
import { getEventBus } from '../../transport/event-bus'
const app = new Hono();
const app = new Hono()
type OwnershipCheckResult =
| { error: true }
| { error: true; reason: string }
| { error: false; session: NonNullable<ReturnType<typeof getSession>>; sessionId: string };
| {
error: false
session: NonNullable<ReturnType<typeof getSession>>
sessionId: string
}
function checkOwnership(c: { get: (key: string) => string | undefined }, sessionId: string): OwnershipCheckResult {
const uuid = c.get("uuid")!;
const resolvedSessionId = resolveOwnedWebSessionId(sessionId, uuid);
function checkOwnership(
c: { get: (key: string) => string | undefined },
sessionId: string,
): OwnershipCheckResult {
const uuid = c.get('uuid')!
const resolvedSessionId = resolveOwnedWebSessionId(sessionId, uuid)
if (!resolvedSessionId) {
return { error: true };
return { error: true }
}
const session = getSession(resolvedSessionId);
const session = getSession(resolvedSessionId)
if (!session) {
return { error: true };
return { error: true }
}
if (isSessionClosedStatus(session.status)) {
return { error: true, reason: `Session is ${session.status}` };
return { error: true, reason: `Session is ${session.status}` }
}
return { error: false, session, sessionId: resolvedSessionId };
return { error: false, session, sessionId: resolvedSessionId }
}
function closedSessionResponse(message: string) {
return { error: { type: "session_closed", message } };
return { error: { type: 'session_closed', message } }
}
/** POST /web/sessions/:id/events — Send user message to session */
app.post("/sessions/:id/events", uuidAuth, async (c) => {
const requestedSessionId = c.req.param("id")!;
const ownership = checkOwnership(c, requestedSessionId);
app.post('/sessions/:id/events', uuidAuth, async c => {
const requestedSessionId = c.req.param('id')!
const ownership = checkOwnership(c, requestedSessionId)
if (ownership.error) {
const message = "reason" in ownership ? ownership.reason : "Not your session";
const status = "reason" in ownership ? 409 : 403;
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
const message =
'reason' in ownership ? ownership.reason : 'Not your session'
const status = 'reason' in ownership ? 409 : 403
return c.json(
'reason' in ownership
? closedSessionResponse(message)
: { error: { type: 'forbidden', message } },
status,
)
}
const { sessionId } = ownership;
const { sessionId } = ownership
const body = await c.req.json();
const eventType = body.type || "user";
log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`);
const event = publishSessionEvent(sessionId, eventType, body, "outbound");
log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`);
return c.json({ status: "ok", event }, 200);
});
const body = await c.req.json()
const eventType = body.type || 'user'
log(
`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`,
)
const event = publishSessionEvent(sessionId, eventType, body, 'outbound')
log(
`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`,
)
return c.json({ status: 'ok', event }, 200)
})
/** POST /web/sessions/:id/control — Send control request (permission approval etc) */
app.post("/sessions/:id/control", uuidAuth, async (c) => {
const requestedSessionId = c.req.param("id")!;
const ownership = checkOwnership(c, requestedSessionId);
app.post('/sessions/:id/control', uuidAuth, async c => {
const requestedSessionId = c.req.param('id')!
const ownership = checkOwnership(c, requestedSessionId)
if (ownership.error) {
const message = "reason" in ownership ? ownership.reason : "Not your session";
const status = "reason" in ownership ? 409 : 403;
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
const message =
'reason' in ownership ? ownership.reason : 'Not your session'
const status = 'reason' in ownership ? 409 : 403
return c.json(
'reason' in ownership
? closedSessionResponse(message)
: { error: { type: 'forbidden', message } },
status,
)
}
const { sessionId } = ownership;
const { sessionId } = ownership
const body = await c.req.json();
const event = publishSessionEvent(sessionId, body.type || "control_request", body, "outbound");
return c.json({ status: "ok", event }, 200);
});
const body = await c.req.json()
const event = publishSessionEvent(
sessionId,
body.type || 'control_request',
body,
'outbound',
)
return c.json({ status: 'ok', event }, 200)
})
/** POST /web/sessions/:id/interrupt — Interrupt session */
app.post("/sessions/:id/interrupt", uuidAuth, async (c) => {
const requestedSessionId = c.req.param("id")!;
const ownership = checkOwnership(c, requestedSessionId);
app.post('/sessions/:id/interrupt', uuidAuth, async c => {
const requestedSessionId = c.req.param('id')!
const ownership = checkOwnership(c, requestedSessionId)
if (ownership.error) {
const message = "reason" in ownership ? ownership.reason : "Not your session";
const status = "reason" in ownership ? 409 : 403;
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
const message =
'reason' in ownership ? ownership.reason : 'Not your session'
const status = 'reason' in ownership ? 409 : 403
return c.json(
'reason' in ownership
? closedSessionResponse(message)
: { error: { type: 'forbidden', message } },
status,
)
}
const { sessionId } = ownership;
const { sessionId } = ownership
publishSessionEvent(sessionId, "interrupt", { action: "interrupt" }, "outbound");
updateSessionStatus(sessionId, "idle");
return c.json({ status: "ok" }, 200);
});
publishSessionEvent(
sessionId,
'interrupt',
{ action: 'interrupt' },
'outbound',
)
updateSessionStatus(sessionId, 'idle')
return c.json({ status: 'ok' }, 200)
})
export default app;
export default app

View File

@@ -1,14 +1,14 @@
import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware";
import { listActiveEnvironmentsResponse } from "../../services/environment";
import { Hono } from 'hono'
import { uuidAuth } from '../../auth/middleware'
import { listActiveEnvironmentsResponse } from '../../services/environment'
const app = new Hono();
const app = new Hono()
/** GET /web/environments — List active environments (UUID-based, no user filtering) */
app.get("/environments", uuidAuth, async (c) => {
app.get('/environments', uuidAuth, async c => {
// Environments are shared across all UUIDs for now
const envs = listActiveEnvironmentsResponse();
return c.json(envs, 200);
});
const envs = listActiveEnvironmentsResponse()
return c.json(envs, 200)
})
export default app;
export default app

View File

@@ -1,7 +1,7 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware";
import { getAutomationStateSnapshot } from "../../services/automationState";
import { log, error as logError } from '../../logger'
import { Hono } from 'hono'
import { uuidAuth } from '../../auth/middleware'
import { getAutomationStateSnapshot } from '../../services/automationState'
import {
createSession,
getSession,
@@ -10,109 +10,137 @@ import {
listWebSessionsByOwnerUuid,
resolveOwnedWebSessionId,
toWebSessionResponse,
} from "../../services/session";
import { storeBindSession, storeGetSessionWorker } from "../../store";
import { createWorkItem } from "../../services/work-dispatch";
import { createSSEStream } from "../../transport/sse-writer";
import { getEventBus } from "../../transport/event-bus";
} from '../../services/session'
import { storeBindSession, storeGetSessionWorker } from '../../store'
import { createWorkItem } from '../../services/work-dispatch'
import { createSSEStream } from '../../transport/sse-writer'
import { getEventBus } from '../../transport/event-bus'
const app = new Hono();
const app = new Hono()
/** POST /web/sessions — Create a session from web UI */
app.post("/sessions", uuidAuth, async (c) => {
const uuid = c.get("uuid")!;
const body = await c.req.json();
app.post('/sessions', uuidAuth, async c => {
const uuid = c.get('uuid')!
const body = await c.req.json()
const session = createSession({
environment_id: body.environment_id || null,
title: body.title || "New Session",
source: "web",
permission_mode: body.permission_mode || "default",
});
title: body.title || 'New Session',
source: 'web',
permission_mode: body.permission_mode || 'default',
})
// Auto-bind to creator's UUID
storeBindSession(session.id, uuid);
storeBindSession(session.id, uuid)
// Dispatch work to environment if specified
if (body.environment_id) {
try {
await createWorkItem(body.environment_id, session.id);
await createWorkItem(body.environment_id, session.id)
} catch (err) {
logError(`[RCS] Failed to create work item: ${(err as Error).message}`);
logError(`[RCS] Failed to create work item: ${(err as Error).message}`)
}
}
return c.json(session, 200);
});
return c.json(session, 200)
})
/** GET /web/sessions — List sessions owned by the requesting UUID */
app.get("/sessions", uuidAuth, async (c) => {
const uuid = c.get("uuid")!;
const sessions = listWebSessionsByOwnerUuid(uuid);
return c.json(sessions, 200);
});
app.get('/sessions', uuidAuth, async c => {
const uuid = c.get('uuid')!
const sessions = listWebSessionsByOwnerUuid(uuid)
return c.json(sessions, 200)
})
/** GET /web/sessions/all — List sessions owned by the requesting UUID (unowned sessions excluded) */
app.get("/sessions/all", uuidAuth, async (c) => {
const uuid = c.get("uuid")!;
const sessions = listWebSessionSummariesByOwnerUuid(uuid);
return c.json(sessions, 200);
});
app.get('/sessions/all', uuidAuth, async c => {
const uuid = c.get('uuid')!
const sessions = listWebSessionSummariesByOwnerUuid(uuid)
return c.json(sessions, 200)
})
/** GET /web/sessions/:id — Session detail */
app.get("/sessions/:id", uuidAuth, async (c) => {
const uuid = c.get("uuid")!;
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid);
app.get('/sessions/:id', uuidAuth, async c => {
const uuid = c.get('uuid')!
const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid)
if (!sessionId) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
return c.json(
{ error: { type: 'forbidden', message: 'Not your session' } },
403,
)
}
const session = getSession(sessionId);
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const worker = storeGetSessionWorker(sessionId);
const automationState = getAutomationStateSnapshot(worker?.externalMetadata);
const response = toWebSessionResponse(session);
const worker = storeGetSessionWorker(sessionId)
const automationState = getAutomationStateSnapshot(worker?.externalMetadata)
const response = toWebSessionResponse(session)
return c.json(
automationState === undefined ? response : { ...response, automation_state: automationState },
automationState === undefined
? response
: { ...response, automation_state: automationState },
200,
);
});
)
})
/** GET /web/sessions/:id/history — Historical events for session */
app.get("/sessions/:id/history", uuidAuth, async (c) => {
const uuid = c.get("uuid")!;
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid);
app.get('/sessions/:id/history', uuidAuth, async c => {
const uuid = c.get('uuid')!
const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid)
if (!sessionId) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
return c.json(
{ error: { type: 'forbidden', message: 'Not your session' } },
403,
)
}
const session = getSession(sessionId);
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
const bus = getEventBus(sessionId);
const events = bus.getEventsSince(0);
return c.json({ events }, 200);
});
const bus = getEventBus(sessionId)
const events = bus.getEventsSince(0)
return c.json({ events }, 200)
})
/** SSE /web/sessions/:id/events — Real-time event stream */
app.get("/sessions/:id/events", uuidAuth, async (c) => {
const uuid = c.get("uuid")!;
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid);
app.get('/sessions/:id/events', uuidAuth, async c => {
const uuid = c.get('uuid')!
const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid)
if (!sessionId) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
return c.json(
{ error: { type: 'forbidden', message: 'Not your session' } },
403,
)
}
const session = getSession(sessionId);
const session = getSession(sessionId)
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
return c.json(
{ error: { type: 'not_found', message: 'Session not found' } },
404,
)
}
if (isSessionClosedStatus(session.status)) {
return c.json({ error: { type: "session_closed", message: `Session is ${session.status}` } }, 409);
return c.json(
{
error: {
type: 'session_closed',
message: `Session is ${session.status}`,
},
},
409,
)
}
const lastEventId = c.req.header("Last-Event-ID");
const fromSeqNum = lastEventId ? parseInt(lastEventId) : 0;
return createSSEStream(c, sessionId, fromSeqNum);
});
const lastEventId = c.req.header('Last-Event-ID')
const fromSeqNum = lastEventId ? parseInt(lastEventId, 10) : 0
return createSSEStream(c, sessionId, fromSeqNum)
})
export default app;
export default app