feat(api): expose REST-only agent routes
This commit is contained in:
+20
-11
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user