From 816c46d447f872109ebaaa735a410d9f0988c847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 28 Jul 2026 19:17:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/position-roundtable/api/sessions.ts | 116 +++++++++++++++--- offline-backend-20260512/backend/CLAUDE.md | 12 +- offline-backend-20260512/backend/README.md | 9 +- .../backend/app/gateway/app.py | 10 +- .../gateway/routers/position_roundtable.py | 4 +- .../test_position_roundtable_sessions.py | 15 +++ 6 files changed, 140 insertions(+), 26 deletions(-) diff --git a/frontend-web/src/position-roundtable/api/sessions.ts b/frontend-web/src/position-roundtable/api/sessions.ts index ed5b7e9f..ac903dd6 100644 --- a/frontend-web/src/position-roundtable/api/sessions.ts +++ b/frontend-web/src/position-roundtable/api/sessions.ts @@ -130,10 +130,86 @@ interface ServerSession { nodes?: ServerNode[]; } -const BASE = () => `${getBackendBaseURL()}/api/position-roundtable`; +const PRIMARY_API_PATH = "/api/position-roundtable"; +const COMPAT_API_PATH = "/api/multi-agent/position-roundtable"; +const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`; + +/** + * Some intranet gateways only forward the long-established `/api/multi-agent` + * namespace. The position workspace was added later under its own top-level + * prefix, so a frontend-only release can work locally but receive FastAPI's + * route-level `{"detail":"Not Found"}` after deployment. + * + * Retry only that exact "route is absent" response against the compatibility + * namespace. A domain 404 such as "Position roundtable session not found" + * remains a real 404 and is never hidden by this routing fallback. + */ +let resolvedApiPath: typeof PRIMARY_API_PATH | typeof COMPAT_API_PATH | null = null; + +function replaceApiPath(url: string, apiPath: string): string { + return url.replace(PRIMARY_API_PATH, apiPath); +} + +async function isMissingRoute(response: Response): Promise { + if (response.status !== 404) return false; + const raw = await response.clone().text().catch(() => ""); + try { + const parsed = JSON.parse(raw) as { detail?: unknown }; + return parsed?.detail === "Not Found"; + } catch { + const normalized = raw.trim(); + return ( + normalized === "Not Found" || + /\s*404 Not Found\s*<\/title>/i.test(normalized) + ); + } +} + +async function positionApiFetch( + input: string, + init?: RequestInit, +): Promise<Response> { + if (resolvedApiPath === COMPAT_API_PATH) { + const compatibilityResponse = await apiFetch( + replaceApiPath(input, COMPAT_API_PATH), + init, + ); + if (!(await isMissingRoute(compatibilityResponse))) { + return compatibilityResponse; + } + // A gateway may be reloaded while the SPA remains open. Re-probe the + // canonical route instead of pinning the tab to a now-stale route choice. + resolvedApiPath = null; + } + + const primaryResponse = await apiFetch(input, init); + if (!(await isMissingRoute(primaryResponse))) { + resolvedApiPath = PRIMARY_API_PATH; + return primaryResponse; + } + + const compatibilityResponse = await apiFetch( + replaceApiPath(input, COMPAT_API_PATH), + init, + ); + if (!(await isMissingRoute(compatibilityResponse))) { + resolvedApiPath = COMPAT_API_PATH; + return compatibilityResponse; + } + + // Preserve the canonical response/error when neither route exists. This + // keeps diagnostics stable and identifies an outdated backend release. + return primaryResponse; +} async function readJson<T>(response: Response, label: string): Promise<T> { if (!response.ok) { + if (await isMissingRoute(response)) { + throw new Error( + `${label} failed (${response.status}): 岗位会商会话接口尚未部署或未被网关转发;` + + `已尝试 ${PRIMARY_API_PATH} 与 ${COMPAT_API_PATH}`, + ); + } const detail = await response.text().catch(() => ""); throw new Error(`${label} failed (${response.status}): ${detail}`); } @@ -219,7 +295,7 @@ function toSession(session: ServerSession): PositionRoundtableSession { export async function listPositionSessions(externalTaskId?: string): Promise<PositionRoundtableSession[]> { const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : ""; const result = await readJson<ServerSession[]>( - await apiFetch(`${BASE()}/sessions${params}`), + await positionApiFetch(`${BASE()}/sessions${params}`), "list position sessions", ); return result.map(toSession); @@ -230,7 +306,7 @@ export async function createPositionSession(input: { taskSnapshot: Record<string, unknown>; }): Promise<PositionRoundtableSession> { const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions`, { + await positionApiFetch(`${BASE()}/sessions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -244,8 +320,8 @@ export async function createPositionSession(input: { } export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> { - const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`); - if (response.status === 404) { + const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`); + if (response.status === 404 && !(await isMissingRoute(response))) { // Consume the body so the connection can be reused, then expose a typed // error that the page can resolve by selecting another history record. await response.text().catch(() => ""); @@ -260,7 +336,7 @@ export async function updatePositionTaskSnapshot( taskSnapshot: Record<string, unknown>, ): Promise<PositionRoundtableSession> { const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ task_snapshot: taskSnapshot }), @@ -279,7 +355,7 @@ export async function savePositionIntent( }, ): Promise<PositionRoundtableSession> { const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -299,7 +375,7 @@ export async function activatePositionSession( ): Promise<PositionRoundtableSession> { // 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。 const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chain_id: chainId }), @@ -311,7 +387,7 @@ export async function activatePositionSession( export async function archivePositionSession(sessionId: string): Promise<PositionRoundtableSession> { const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, { method: "POST", }), "archive position session", @@ -320,12 +396,12 @@ export async function archivePositionSession(sessionId: string): Promise<Positio } export async function deletePositionSession(sessionId: string): Promise<void> { - const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, { + const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE", }); // DELETE is deliberately idempotent from the UI's perspective. A stale // history row that is already gone still needs to be removed locally. - if (response.status === 404) { + if (response.status === 404 && !(await isMissingRoute(response))) { await response.text().catch(() => ""); return; } @@ -338,7 +414,7 @@ export async function bindPositionNodeThread( threadId: string, ): Promise<PositionRoundtableNode> { const result = await readJson<ServerNode>( - await apiFetch( + await positionApiFetch( `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`, { method: "POST", @@ -360,7 +436,7 @@ export async function savePositionNodeConversation( }, ): Promise<PositionRoundtableNode> { const result = await readJson<ServerNode>( - await apiFetch( + await positionApiFetch( `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/state`, { method: "PUT", @@ -386,7 +462,7 @@ export async function savePositionBuiltinConversation( ): Promise<PositionRoundtableSession> { const path = kind === "summary" ? "summary/state" : "action-plan/state"; const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -416,7 +492,7 @@ export async function seedPositionDemoSession( payload: PositionDemoSeedPayload, ): Promise<PositionRoundtableSession> { const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/demo/seed`, { + await positionApiFetch(`${BASE()}/demo/seed`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -437,7 +513,7 @@ export async function getPositionNodeContext( nodeKey: string, ): Promise<Record<string, unknown>> { return readJson<Record<string, unknown>>( - await apiFetch( + await positionApiFetch( `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`, { method: "POST" }, ), @@ -456,7 +532,7 @@ export async function completePositionNodeTurn( ): Promise<PositionRoundtableNode> { // 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。 const result = await readJson<ServerNode>( - await apiFetch( + await positionApiFetch( `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`, { method: "POST", @@ -479,7 +555,7 @@ export async function rejectPositionNode( input: { reason?: string; title?: string }, ): Promise<PositionRoundtableSession> { const result = await readJson<ServerSession>( - await apiFetch( + await positionApiFetch( `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`, { method: "POST", @@ -533,7 +609,7 @@ async function prepareBuiltinRun( // prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。 const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare"; const result = await readJson<ServerBuiltinRunPreparation>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message }), @@ -557,7 +633,7 @@ async function completeBuiltinRun( // complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。 const path = kind === "summary" ? "summary/complete" : "action-plan/complete"; const result = await readJson<ServerSession>( - await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { + await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 5f2a15f5..727c4a65 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -109,8 +109,8 @@ CI runs these regression tests for every pull request via [.github/workflows/bac ### Position roundtable recovery persistence -`/api/position-roundtable` uses LangGraph threads as the live execution source -and stores an additional SQL recovery copy in +`/api/position-roundtable` uses the shared `/api/intent/*` LangGraph flow as +the live Step-1 execution source and stores an additional SQL recovery copy in `position_roundtable_sessions`/`position_roundtable_nodes`. The recovery copy contains complete intent/node/summary/action-plan conversations, unsent composer drafts, workflow snapshots, and bounded full text for generated @@ -118,7 +118,11 @@ Markdown/JSON artifacts. This lets history and downstream closing agents keep working when container-local checkpoint or sandbox volumes are replaced. Revision `20260723_05` adds the conversation columns. Session deletion explicitly removes child nodes so SQLite deployments do not depend on FK -cascade pragmas. +cascade pragmas. The router is mounted canonically below +`/api/position-roundtable` and, for intranet gateway allowlists, below the +schema-hidden compatibility prefix `/api/multi-agent/position-roundtable`. +Clients may retry the compatibility route only for a framework/proxy-level +`404 Not Found`; domain-level missing-session responses must remain real 404s. ### Harness / App Split @@ -411,6 +415,8 @@ integration point is the existing `/api/roundtable-chains` store: each `seats` J may carry an optional `position_id` (validated by `ChainSeatSchema`, no schema migration). The original roundtable only reads its established seat fields and therefore remains unchanged. The separate `/api/position-roundtable` router +(also mounted at `/api/multi-agent/position-roundtable` for intranet gateway +compatibility) persists task/intent/frozen-chain snapshots in `position_roundtable_sessions` and direct-agent node indexes in `position_roundtable_nodes` (migration `20260721_01`). Message history remains in the established LangGraph thread diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 56b715ed..f558cdff 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -124,7 +124,7 @@ FastAPI application providing REST endpoints for frontend integration: | `GET/POST /api/agents` | List visible agents or create a user-owned agent | | `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent | | `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) | -| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. | +| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. The same router is also mounted at `/api/multi-agent/position-roundtable/*` for intranet gateways that only forward the established multi-agent namespace; the compatibility mount is intentionally omitted from OpenAPI. | | `GET /api/memory` | Retrieve memory data | | `POST /api/memory/reload` | Force memory reload | | `GET /api/memory/config` | Memory configuration | @@ -475,3 +475,10 @@ unsent composer drafts, and generated text deliverables. LangGraph checkpoints remain the execution source, but SQL history continues to render after a checkpoint or sandbox-output volume is replaced. Apply Alembic revision `20260723_05` (or simply upgrade to `head`) before deploying this version. + +Its live intent conversation still uses the shared `/api/intent/init` and +`/api/intent/stream` endpoints used by the original multi-agent roundtable. +Only the recovery snapshot layer is position-specific. Deployments must expose +either `/api/position-roundtable/*` or the compatibility mirror +`/api/multi-agent/position-roundtable/*`; updated clients probe both and only +retry when the first response is the framework/proxy-level `404 Not Found`. diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 66a7e928..5f4aded9 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -1474,7 +1474,15 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an app.include_router(roundtable_chains.router) # 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业) - app.include_router(position_roundtable.router) + # Canonical position-workspace persistence API. + app.include_router(position_roundtable.router, prefix="/api") + # Compatibility route for intranet gateways whose allowlist only exposes + # the established `/api/multi-agent/*` namespace. + app.include_router( + position_roundtable.router, + prefix="/api/multi-agent", + include_in_schema=False, + ) # Global business ? agent / chain mapping at /api/business-mapping app.include_router(business_mapping.router) diff --git a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py index fee1a881..7ca43857 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py +++ b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py @@ -21,7 +21,9 @@ from pydantic import BaseModel, Field from app.gateway.path_utils import aresolve_thread_virtual_path from deerflow.runtime.user_context import get_effective_user_id -router = APIRouter(prefix="/api/position-roundtable", tags=["position-roundtable"]) +# The application mounts this router below both `/api` (canonical) and the +# long-established `/api/multi-agent` namespace (intranet compatibility). +router = APIRouter(prefix="/position-roundtable", tags=["position-roundtable"]) NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "rejected", "error"} _HANDOFF_TEXT_BUDGET = 12_000 diff --git a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py index 797295c5..75b486db 100644 --- a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py +++ b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py @@ -9,6 +9,7 @@ import pytest_asyncio from fastapi import HTTPException from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from app.gateway.app import create_app from app.gateway import services as gateway_services from app.gateway.routers import intent as intent_router from app.gateway.routers import position_roundtable as position_roundtable_router @@ -35,6 +36,20 @@ def test_reject_node_request_allows_an_optional_reason(): assert request.reason == "" +def test_position_roundtable_router_has_intranet_compatibility_prefix(): + """The workspace must survive proxies that only expose /api/multi-agent.""" + app = create_app() + registrations = { + (route.include_context.prefix, route.include_context.include_in_schema) + for route in app.routes + if getattr(route, "original_router", None) is position_roundtable_router.router + } + + assert ("/api", True) in registrations + assert ("/api/multi-agent", False) in registrations + assert position_roundtable_router.router.prefix == "/position-roundtable" + + @pytest.mark.asyncio async def test_session_activation_freezes_chain_and_creates_nodes_once(store): created = await store.create_session(