mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 00:05:51 +00:00
style: 完成所有文件的lint
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user