代码提交

This commit is contained in:
董宏凯 committed 2026-07-28 19:17:40 +08:00
1 parent 151ecceb85
commit 816c46d447
6 files changed
+140 -26

No files matched your search

@@ -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<boolean> {
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" ||
/<title>\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({
+9 -3
View File
@@ -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
+8 -1
View File
@@ -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`.
@@ -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)
@@ -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
@@ -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(