feat(api): expose REST-only agent routes
Agent CI/CD / docker-image (push) Failing after 35s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s

This commit is contained in:
2026-07-30 20:38:52 +08:00
parent 2415f75841
commit 94529cb141
29 changed files with 3525 additions and 378 deletions
+20 -11
View File
@@ -100,7 +100,7 @@ export const requireAgentAuth = async (
const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS);
try {
const response = await fetch(new URL("/api/v1/agent/auth/context", config.TJWATER_API_BASE_URL), {
const response = await fetch(new URL("/api/v1/agent-auth-context", config.TJWATER_API_BASE_URL), {
method: "GET",
headers: {
Accept: "application/json",
@@ -125,18 +125,27 @@ export const requireAgentAuth = async (
return;
}
const detail = await response.text();
res.status(response.status === 403 ? 403 : 401).json({
message: response.status === 403 ? "forbidden" : "unauthorized",
detail: detail || undefined,
});
const status =
response.status === 400 ||
response.status === 401 ||
response.status === 403 ||
response.status === 404
? response.status
: response.status === 503
? 503
: 502;
const messages: Record<number, string> = {
400: "invalid authentication request",
401: "unauthorized",
403: "forbidden",
404: "authentication context not found",
502: "invalid authentication service response",
503: "authentication service unavailable",
};
res.status(status).json({ message: messages[status] });
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.warn({ err: error }, "agent auth validation failed");
res.status(503).json({
message: "authentication service unavailable",
detail,
});
res.status(503).json({ message: "authentication service unavailable" });
} finally {
clearTimeout(timer);
}
+325
View File
@@ -0,0 +1,325 @@
import {
extendZodWithOpenApi,
OpenAPIRegistry,
OpenApiGeneratorV3,
} from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
extendZodWithOpenApi(z);
const registry = new OpenAPIRegistry();
registry.registerComponent("securitySchemes", "bearerAuth", {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
});
const SessionId = z.object({ session_id: z.string().max(128) });
const RenderRef = z.object({ render_ref: z.string().min(1) });
const RenderReferenceQuery = z.object({
session_id: z.string().max(128).optional(),
});
const SessionCreate = z
.object({
session_id: z.string(),
title: z.string().nullable().optional(),
created_at: z.string(),
updated_at: z.string(),
status: z.string(),
parent_session_id: z.string().nullable().optional(),
})
.openapi("AgentSessionCreate");
const SessionSummary = z
.object({
id: z.string(),
title: z.string(),
created_at: z.string(),
updated_at: z.string(),
status: z.string(),
parent_session_id: z.string().nullable().optional(),
is_streaming: z.boolean(),
run_status: z.string().nullable().optional(),
})
.openapi("AgentSessionSummary");
const SessionDetail = SessionSummary.extend({
session_id: z.string(),
is_title_manually_edited: z.boolean(),
messages: z.array(z.unknown()),
}).openapi("AgentSessionDetail");
const SessionUpdate = z
.object({
id: z.string(),
title: z.string(),
updated_at: z.string(),
})
.openapi("AgentSessionUpdate");
const SessionFork = z
.object({
session_id: z.string(),
})
.openapi("AgentSessionFork");
const Problem = z
.object({
type: z.string(),
title: z.string(),
status: z.number().int(),
detail: z.string(),
instance: z.string(),
code: z.string(),
trace_id: z.string(),
errors: z.array(z.unknown()),
})
.openapi("ProblemDetails");
const JsonObject = z.record(z.unknown());
const errorResponses = {
400: {
description: "Invalid request",
content: { "application/problem+json": { schema: Problem } },
},
401: {
description: "Authentication required",
content: { "application/problem+json": { schema: Problem } },
},
403: {
description: "Insufficient permission",
content: { "application/problem+json": { schema: Problem } },
},
404: {
description: "Resource not found",
content: { "application/problem+json": { schema: Problem } },
},
409: {
description: "Resource conflict",
content: { "application/problem+json": { schema: Problem } },
},
422: {
description: "Validation error",
content: { "application/problem+json": { schema: Problem } },
},
500: {
description: "Internal server error",
content: { "application/problem+json": { schema: Problem } },
},
502: {
description: "Upstream dependency error",
content: { "application/problem+json": { schema: Problem } },
},
503: {
description: "Dependency unavailable",
content: { "application/problem+json": { schema: Problem } },
},
};
const jsonResponse = (schema: z.ZodTypeAny, description = "Successful response") => ({
description,
content: { "application/json": { schema } },
});
type RegisterConfig = {
summary: string;
request?: Record<string, unknown>;
responses: Record<number, unknown>;
};
export const createRouteRegistrar = (targetRegistry: OpenAPIRegistry) => {
const operations = new Set<string>();
return (
path: string,
method: "get" | "post" | "patch" | "delete",
config: RegisterConfig,
) => {
const key = `${method.toUpperCase()} ${path}`;
if (operations.has(key)) {
throw new Error(`Duplicate Agent OpenAPI operation: ${key}`);
}
operations.add(key);
targetRegistry.registerPath({
path,
method,
operationId: `${method}_${path
.replace(/^\/api\/v1\/agent\/?/, "")
.replaceAll(/[{}]/g, "")
.replaceAll(/[^a-zA-Z0-9]+/g, "_")
.replaceAll(/^_|_$/g, "")}`,
tags: ["Agent"],
security: [{ bearerAuth: [] }],
...config,
responses: { ...config.responses, ...errorResponses },
});
};
};
const register = createRouteRegistrar(registry);
register("/api/v1/agent/models", "get", {
summary: "List available agent models",
responses: { 200: jsonResponse(JsonObject) },
});
register("/api/v1/agent/sessions", "get", {
summary: "List agent sessions",
responses: { 200: jsonResponse(z.object({ sessions: z.array(SessionSummary) })) },
});
register("/api/v1/agent/sessions", "post", {
summary: "Create an agent session",
request: {
body: {
content: {
"application/json": {
schema: z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
}),
},
},
},
},
responses: {
200: jsonResponse(SessionCreate, "Existing session returned"),
201: jsonResponse(SessionCreate, "Session created"),
},
});
register("/api/v1/agent/sessions/{session_id}", "get", {
summary: "Get an agent session",
request: { params: SessionId },
responses: { 200: jsonResponse(SessionDetail) },
});
register("/api/v1/agent/sessions/{session_id}", "patch", {
summary: "Update an agent session",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
title: z.string().min(1).max(120),
is_title_manually_edited: z.boolean().optional(),
}),
},
},
},
},
responses: { 200: jsonResponse(SessionUpdate) },
});
register("/api/v1/agent/sessions/{session_id}", "delete", {
summary: "Delete an agent session",
request: { params: SessionId },
responses: { 204: { description: "Session deleted" } },
});
register("/api/v1/agent/sessions/{session_id}/runs", "post", {
summary: "Run an agent session",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
message: z.string().min(1).max(10000),
model: z.string().optional(),
approval_mode: z.enum(["request", "always"]).optional(),
}),
},
},
},
},
responses: {
200: {
description: "Agent event stream",
content: { "text/event-stream": { schema: z.string() } },
},
},
});
register(
"/api/v1/agent/sessions/{session_id}/runs/current/events",
"get",
{
summary: "Resume the current agent event stream",
request: { params: SessionId },
responses: {
200: {
description: "Agent event stream",
content: { "text/event-stream": { schema: z.string() } },
},
},
},
);
register("/api/v1/agent/sessions/{session_id}/runs/current", "delete", {
summary: "Abort the current agent run",
request: { params: SessionId },
responses: { 202: jsonResponse(JsonObject), 204: { description: "No active run" } },
});
register(
"/api/v1/agent/sessions/{session_id}/permission-responses",
"post",
{
summary: "Reply to an agent permission request",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
request_id: z.string(),
reply: z.enum(["once", "always", "reject"]),
message: z.string().max(1000).optional(),
}),
},
},
},
},
responses: { 202: jsonResponse(JsonObject) },
},
);
register(
"/api/v1/agent/sessions/{session_id}/question-responses",
"post",
{
summary: "Reply to or reject an agent question",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
request_id: z.string(),
action: z.enum(["reply", "reject"]).default("reply"),
answers: z.array(z.array(z.string().max(2000))).optional(),
}),
},
},
},
},
responses: { 202: jsonResponse(JsonObject) },
},
);
register("/api/v1/agent/sessions/{session_id}/forks", "post", {
summary: "Fork an agent session",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
keep_message_count: z.number().int().nonnegative(),
}),
},
},
},
},
responses: { 200: jsonResponse(SessionFork) },
});
register("/api/v1/agent/render-references/{render_ref}", "get", {
summary: "Resolve a render reference",
request: { params: RenderRef, query: RenderReferenceQuery },
responses: { 200: jsonResponse(JsonObject) },
});
export const generateAgentOpenApi = () => {
const generator = new OpenApiGeneratorV3(registry.definitions);
return generator.generateDocument({
openapi: "3.0.3",
info: {
title: "TJWater Agent API",
version: "1.0.0",
description: "Public REST API for TJWater Agent sessions and runs",
},
});
};
+38 -30
View File
@@ -131,7 +131,7 @@ export const buildChatRouter = (
});
});
chatRouter.post("/session", async (req, res) => {
chatRouter.post("/sessions", async (req, res) => {
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({
@@ -194,7 +194,7 @@ export const buildChatRouter = (
});
});
chatRouter.get("/session/:session_id", async (req, res) => {
chatRouter.get("/sessions/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
@@ -238,7 +238,7 @@ export const buildChatRouter = (
});
});
chatRouter.get("/session/:session_id/stream", async (req, res) => {
chatRouter.get("/sessions/:session_id/runs/current/events", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
@@ -303,7 +303,7 @@ export const buildChatRouter = (
res.on("close", cleanup);
});
chatRouter.patch("/session/:session_id/title", async (req, res) => {
chatRouter.patch("/sessions/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const title =
typeof req.body?.title === "string" ? req.body.title.trim() : "";
@@ -349,7 +349,7 @@ export const buildChatRouter = (
});
});
chatRouter.delete("/session/:session_id", async (req, res) => {
chatRouter.delete("/sessions/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
@@ -395,8 +395,11 @@ export const buildChatRouter = (
sessionUiStateStore,
});
chatRouter.post("/fork", async (req, res) => {
const parsed = forkPayloadSchema.safeParse(req.body);
chatRouter.post("/sessions/:session_id/forks", async (req, res) => {
const parsed = forkPayloadSchema.safeParse({
...req.body,
session_id: req.params.session_id,
});
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
@@ -425,6 +428,10 @@ export const buildChatRouter = (
sourceSessionId,
)
: null;
if (!sourceSessionId || !sourceSessionRecord) {
res.status(404).json({ message: "source session not found" });
return;
}
const forkSession = await runtime.createSession();
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,
@@ -436,27 +443,25 @@ export const buildChatRouter = (
});
const nextSessionId = targetSessionRecord.sessionId;
if (sourceSessionId) {
await sessionTranscriptStore.cloneThread(
{
actorKey,
clientSessionId: sourceSessionId,
projectKey,
sessionId: sourceSessionId,
},
{
actorKey,
clientSessionId: nextSessionId,
projectKey,
sessionId: nextSessionId,
},
parsed.data.keep_message_count,
);
}
const sourceState = sourceSessionRecord
? await sessionUiStateStore.read(toSessionUiStateContext(sourceSessionRecord))
: null;
const forkTitle = sourceSessionRecord?.title
await sessionTranscriptStore.cloneThread(
{
actorKey,
clientSessionId: sourceSessionId,
projectKey,
sessionId: sourceSessionId,
},
{
actorKey,
clientSessionId: nextSessionId,
projectKey,
sessionId: nextSessionId,
},
parsed.data.keep_message_count,
);
const sourceState = await sessionUiStateStore.read(
toSessionUiStateContext(sourceSessionRecord),
);
const forkTitle = sourceSessionRecord.title
? `${sourceSessionRecord.title} 副本`
: "新对话副本";
const titledTargetSessionRecord = await sessionMetadataStore.touch(
@@ -495,8 +500,11 @@ export const buildChatRouter = (
}
});
chatRouter.post("/stream", async (req, res) => {
const parsed = payloadSchema.safeParse(req.body);
chatRouter.post("/sessions/:session_id/runs", async (req, res) => {
const parsed = payloadSchema.safeParse({
...req.body,
session_id: req.params.session_id,
});
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
+4 -4
View File
@@ -17,7 +17,7 @@ import {
updateLastAssistantMessage,
} from "./chatUiState.js";
const abortPayloadSchema = z.object({
const abortParamsSchema = z.object({
session_id: z.string().max(128),
});
@@ -45,7 +45,7 @@ export const registerChatAuxiliaryRoutes = (
sessionUiStateStore,
}: RegisterAuxiliaryRoutesOptions,
) => {
chatRouter.get("/render-ref/:render_ref", async (req, res) => {
chatRouter.get("/render-references/:render_ref", async (req, res) => {
const renderRef = req.params.render_ref?.trim();
const authContext = getAgentAuthContext(req);
const userId = authContext.userId;
@@ -82,8 +82,8 @@ export const registerChatAuxiliaryRoutes = (
res.json(result);
});
chatRouter.post("/abort", async (req, res) => {
const parsed = abortPayloadSchema.safeParse(req.body);
chatRouter.delete("/sessions/:session_id/runs/current", async (req, res) => {
const parsed = abortParamsSchema.safeParse(req.params);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
+34 -163
View File
@@ -15,20 +15,17 @@ import {
} from "./chatUiState.js";
const permissionReplyPayloadSchema = z.object({
session_id: z.string().max(128),
request_id: z.string().min(1),
reply: z.enum(["once", "always", "reject"]),
message: z.string().max(1000).optional(),
});
const questionReplyPayloadSchema = z.object({
session_id: z.string().max(128),
request_id: z.string().min(1),
action: z.enum(["reply", "reject"]).default("reply"),
answers: z.array(z.array(z.string().max(2000))).default([]),
});
const questionRejectPayloadSchema = z.object({
session_id: z.string().max(128),
});
type RegisterInteractionRoutesOptions = {
activeRuns: Map<string, ActiveRun>;
runtime: OpencodeRuntimeAdapter;
@@ -49,13 +46,8 @@ export const registerChatInteractionRoutes = (
sessionUiStateStore,
}: RegisterInteractionRoutesOptions,
) => {
chatRouter.post("/permission/:request_id/reply", async (req, res) => {
const requestId = req.params.request_id?.trim();
chatRouter.post("/sessions/:session_id/permission-responses", async (req, res) => {
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
return;
}
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
@@ -70,9 +62,10 @@ export const registerChatInteractionRoutes = (
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestId = parsed.data.request_id;
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
req.params.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
@@ -174,13 +167,8 @@ export const registerChatInteractionRoutes = (
}
});
chatRouter.post("/question/:request_id/reply", async (req, res) => {
const requestId = req.params.request_id?.trim();
chatRouter.post("/sessions/:session_id/question-responses", async (req, res) => {
const parsed = questionReplyPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
return;
}
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
@@ -195,9 +183,10 @@ export const registerChatInteractionRoutes = (
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestId = parsed.data.request_id;
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
req.params.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
@@ -227,11 +216,18 @@ export const registerChatInteractionRoutes = (
};
try {
await runtime.replyQuestion({
requestId,
sessionId: sessionRecord.sessionId,
answers: parsed.data.answers,
});
if (parsed.data.action === "reject") {
await runtime.rejectQuestion({
requestId,
sessionId: sessionRecord.sessionId,
});
} else {
await runtime.replyQuestion({
requestId,
sessionId: sessionRecord.sessionId,
answers: parsed.data.answers,
});
}
} catch (error) {
run.messages = updateLastAssistantQuestion(
run.messages,
@@ -242,7 +238,7 @@ export const registerChatInteractionRoutes = (
error:
error instanceof Error
? error.message
: "failed to reply question",
: `failed to ${parsed.data.action} question`,
}),
);
await persistQuestionState().catch((persistError) => {
@@ -252,7 +248,7 @@ export const registerChatInteractionRoutes = (
);
});
res.status(502).json({
message: "question reply failed",
message: `question ${parsed.data.action} failed`,
detail: error instanceof Error ? error.message : String(error),
});
return;
@@ -264,8 +260,9 @@ export const registerChatInteractionRoutes = (
requestId,
(question) => ({
...question,
status: "answered",
answers: parsed.data.answers,
status: parsed.data.action === "reject" ? "rejected" : "answered",
answers:
parsed.data.action === "reject" ? question.answers : parsed.data.answers,
repliedAt: Date.now(),
error: undefined,
}),
@@ -280,7 +277,9 @@ export const registerChatInteractionRoutes = (
subscriber.write("question_response", {
session_id: pendingQuestion.session_id,
request_id: requestId,
answers: parsed.data.answers,
...(parsed.data.action === "reject"
? { rejected: true }
: { answers: parsed.data.answers }),
});
}
if (
@@ -294,143 +293,15 @@ export const registerChatInteractionRoutes = (
res.status(202).json({
session_id: pendingQuestion.session_id,
request_id: requestId,
answers: parsed.data.answers,
...(parsed.data.action === "reject"
? { rejected: true }
: { answers: parsed.data.answers }),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "question reply route failed");
logger.error({ err: error }, "question response route failed");
res.status(500).json({
message: "question reply route failed",
detail,
});
}
});
chatRouter.post("/question/:request_id/reject", async (req, res) => {
const requestId = req.params.request_id?.trim();
const parsed = questionRejectPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
return;
}
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const run = activeRuns.get(sessionRecord.sessionId);
if (!run) {
res.status(409).json({ message: "session is not waiting for questions" });
return;
}
const pendingQuestion = run.pendingQuestions.get(requestId);
if (!pendingQuestion) {
res.status(404).json({ message: "question request not found" });
return;
}
const persistQuestionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
};
try {
await runtime.rejectQuestion({
requestId,
sessionId: sessionRecord.sessionId,
});
} catch (error) {
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reject question",
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question error state",
);
});
res.status(502).json({
message: "question reject failed",
detail: error instanceof Error ? error.message : String(error),
});
return;
}
run.pendingQuestions.delete(requestId);
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: "rejected",
repliedAt: Date.now(),
error: undefined,
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question reject state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("question_response", {
session_id: pendingQuestion.session_id,
request_id: requestId,
rejected: true,
});
}
if (
run.status !== "running" &&
run.pendingPermissions.size === 0 &&
run.pendingQuestions.size === 0
) {
activeRuns.delete(sessionRecord.sessionId);
}
res.status(202).json({
session_id: pendingQuestion.session_id,
request_id: requestId,
rejected: true,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "question reject route failed");
res.status(500).json({
message: "question reject route failed",
message: "question response route failed",
detail,
});
}
+55
View File
@@ -0,0 +1,55 @@
import { randomUUID } from "node:crypto";
import { type Request, type Response, Router } from "express";
const problemDetails = (req: Request, status: number, body: unknown) => {
const record =
typeof body === "object" && body !== null
? (body as Record<string, unknown>)
: {};
const detail =
typeof record.detail === "string"
? record.detail
: typeof record.message === "string"
? record.message
: `Request failed with status ${status}`;
const codeByStatus: Record<number, string> = {
400: "invalid_request",
401: "unauthenticated",
403: "forbidden",
404: "not_found",
409: "conflict",
422: "validation_error",
503: "dependency_unavailable",
};
const code = codeByStatus[status] ?? "request_error";
return {
type: `https://tjwater.example/problems/${code.replaceAll("_", "-")}`,
title: code.replaceAll("_", " "),
status,
detail,
instance: req.originalUrl.split("?")[0],
code,
trace_id: req.header("x-request-id") ?? randomUUID(),
errors: record.detail && typeof record.detail === "object" ? [record.detail] : [],
};
};
export const buildAgentPublicRouter = (chatRouter: Router) => {
const router = Router();
router.use((req, res, next) => {
const json = res.json.bind(res);
res.json = ((body: unknown) => {
if (res.statusCode >= 400) {
res.type("application/problem+json");
return json(problemDetails(req, res.statusCode, body));
}
return json(body);
}) as Response["json"];
next();
});
router.use(chatRouter);
return router;
};
+17 -15
View File
@@ -18,6 +18,7 @@ import {
ResultReferenceStore,
} from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js";
import { buildAgentPublicRouter } from "./routes/publicApi.js";
import { opencodeRuntime } from "./runtime/opencode.js";
import {
getRuntimeSessionContext,
@@ -113,7 +114,6 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
server: config.TJWATER_API_BASE_URL,
access_token: context.accessToken,
project_id: context.projectId,
network: context.network,
});
const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)];
@@ -402,7 +402,7 @@ app.post("/internal/tools/web-search", async (req, res) => {
try {
const response = await callBackendJson(
"/api/v1/web-search",
"/api/v1/web-searches",
context,
payload,
);
@@ -445,7 +445,7 @@ app.post("/internal/tools/geocode", async (req, res) => {
try {
const response = await callBackendJson(
"/api/v1/tianditu/geocode",
"/api/v1/geocoding-requests",
context,
{ keyword },
);
@@ -462,19 +462,21 @@ app.post("/internal/tools/geocode", async (req, res) => {
}
});
const chatRouter = buildChatRouter(
sessionBridge,
opencodeRuntime,
sessionMetadataStore,
sessionUiStateStore,
memoryStore,
sessionTranscriptStore,
learningOrchestrator,
resultReferenceResolver,
);
const authenticatedChatRouter = express.Router();
authenticatedChatRouter.use(requireAgentAuth, chatRouter);
app.use(
"/api/v1/agent/chat",
requireAgentAuth,
buildChatRouter(
sessionBridge,
opencodeRuntime,
sessionMetadataStore,
sessionUiStateStore,
memoryStore,
sessionTranscriptStore,
learningOrchestrator,
resultReferenceResolver,
),
"/api/v1/agent",
buildAgentPublicRouter(authenticatedChatRouter),
);
const bootstrap = async () => {
+59 -14
View File
@@ -8,6 +8,7 @@ import {
readJsonFile,
removeFileIfExists,
slugify,
toStableId,
} from "../utils/fileStore.js";
export type SessionStatus = "active" | "archived";
@@ -37,6 +38,13 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
parentSessionId?: string;
};
export class SessionMetadataOwnershipError extends Error {
constructor(sessionId: string) {
super(`session metadata ownership mismatch: ${sessionId}`);
this.name = "SessionMetadataOwnershipError";
}
}
export class SessionMetadataStore {
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {}
@@ -49,10 +57,13 @@ export class SessionMetadataStore {
if (!sessionId) {
throw new Error("sessionId is required");
}
const existing = await readJsonFile<SessionRecord>(
this.filePath(sessionId),
);
const existing = await this.readRecord(sessionId);
if (existing) {
if (!matchesContext(existing, input)) {
throw new SessionMetadataOwnershipError(sessionId);
}
await atomicWriteJson(this.filePath(sessionId), existing);
await removeFileIfExists(this.legacyFilePath(sessionId));
return { created: false, record: existing };
}
@@ -80,9 +91,8 @@ export class SessionMetadataStore {
if (!normalizedSessionId) {
return null;
}
return await readJsonFile<SessionRecord>(
this.filePath(normalizedSessionId),
);
const record = await this.readRecord(normalizedSessionId);
return record && matchesContext(record, context) ? record : null;
}
async touch(
@@ -98,6 +108,7 @@ export class SessionMetadataStore {
this.filePath(record.sessionId),
next,
);
await removeFileIfExists(this.legacyFilePath(record.sessionId));
return next;
}
@@ -106,27 +117,61 @@ export class SessionMetadataStore {
const records = await Promise.all(
files.map((file) => readJsonFile<SessionRecord>(file)),
);
return records
const uniqueRecords = new Map<string, SessionRecord>();
for (const record of records) {
if (record && matchesContext(record, context)) {
const previous = uniqueRecords.get(record.sessionId);
if (!previous || record.updatedAt > previous.updatedAt) {
uniqueRecords.set(record.sessionId, record);
}
}
}
return [...uniqueRecords.values()]
.filter((record): record is SessionRecord => Boolean(record))
.filter(
(record) =>
record.actorKey === context.actorKey &&
record.projectKey === context.projectKey,
)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
async remove(record: SessionRecord) {
await removeFileIfExists(
this.filePath(record.sessionId),
await Promise.all(
this.filePaths(record.sessionId).map((path) => removeFileIfExists(path)),
);
}
private filePath(sessionId: string) {
return join(
this.baseDir,
`${slugify(sessionId)}-${toStableId(sessionId)}.json`,
);
}
private legacyFilePath(sessionId: string) {
return join(this.baseDir, `${slugify(sessionId)}.json`);
}
private filePaths(sessionId: string) {
return [this.filePath(sessionId), this.legacyFilePath(sessionId)];
}
private async readRecord(sessionId: string) {
for (const path of this.filePaths(sessionId)) {
const record = await readJsonFile<SessionRecord>(path);
if (record?.sessionId === sessionId) {
return record;
}
}
return null;
}
}
const matchesContext = (
record: SessionRecord,
context: SessionMetadataContext,
) =>
record.actorKey === context.actorKey &&
record.projectKey === context.projectKey &&
(!record.ownerUserId || record.ownerUserId === context.userId?.trim()) &&
(!record.projectId || record.projectId === context.projectId);
const normalizeSessionId = (value?: string) => {
const normalized = value?.trim();
return normalized ? normalized.slice(0, 128) : undefined;