diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 5703207efd..c4696eee69 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -80,7 +80,8 @@ "smoke:task-inspector-route": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/task-inspector-route-smoke.mjs", "smoke:bound-conversation": "vite build --ssr smoke/bound-conversation-smoke.ts --outDir node_modules/.cache/loopx-bound-conversation --emptyOutDir && node node_modules/.cache/loopx-bound-conversation/bound-conversation-smoke.js", "smoke:delegation-search": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/delegation-search-browser-smoke.mjs", -"smoke:workspace-command-palette": "node --experimental-strip-types --test src/features/personal-workspace/workspace-command-entries.test.mjs && node --experimental-strip-types smoke/workspace-command-palette-browser-smoke.mjs && node --experimental-strip-types smoke/workspace-navigation-browser-smoke.mjs" + "smoke:workspace-command-palette": "node --experimental-strip-types --test src/features/personal-workspace/workspace-command-entries.test.mjs && node --experimental-strip-types smoke/workspace-command-palette-browser-smoke.mjs && node --experimental-strip-types smoke/workspace-navigation-browser-smoke.mjs", + "smoke:app-steward": "vite build --ssr smoke/app-steward-smoke.ts --outDir node_modules/.cache/loopx-app-steward --emptyOutDir && node node_modules/.cache/loopx-app-steward/app-steward-smoke.js" }, "dependencies": { "@fontsource-variable/geist": "^5.3.0", diff --git a/apps/presentation/dashboard/smoke/app-steward-smoke.ts b/apps/presentation/dashboard/smoke/app-steward-smoke.ts new file mode 100644 index 0000000000..fe2e48c9bd --- /dev/null +++ b/apps/presentation/dashboard/smoke/app-steward-smoke.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {resolve} from "node:path"; +import {createInterface} from "node:readline"; +import {resolveTestPython} from "../../../../scripts/test-python.mjs"; +import {fetchAppStewardTarget, fetchPrivateConversations, selectAppSteward, + fetchChatHistory, resolveBoundChatSession} from "../src/data/chat"; + +const repoRoot = resolve(process.cwd(), "../../.."); +const child = spawn(resolveTestPython({repoRoot}), ["-u", "apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py", "--steward"], + {cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"]}); +const exited = once(child, "exit"); +const output = createInterface({input: child.stdout}); +const lines = output[Symbol.asyncIterator](); +let stderr = ""; +child.stderr.on("data", value => {stderr += String(value);}); +async function next(): Promise { + for (;;) { + let timer: ReturnType; + const line = await Promise.race([lines.next(), new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Fixture timed out: ${stderr}`)), 20_000); + })]).finally(() => clearTimeout(timer)); + assert.equal(line.done, false, stderr); + if (line.value!.startsWith("{")) return JSON.parse(line.value!); + } +} +const originalFetch = globalThis.fetch; +try { + const {origin, bindingId, sessionId} = await next(); + globalThis.fetch = (input, init) => originalFetch(new URL(String(input), origin), init); + let connections = await fetchPrivateConversations(); + const inspect = async () => {child.stdin.write("inspect\n"); return next();}; + const before = await inspect(); + assert.equal(before.sessions, 3); + assert.equal(await fetchAppStewardTarget(), null, "No implicit selection between two original stewards"); + let peer = connections.connections.find(row => row.app_ref === "synthetic-peer")!; + assert.ok(peer && peer.binding_id !== bindingId); + connections = await selectAppSteward(bindingId, connections.revision); + const target = {bindingId, sessionId}; + assert.deepEqual(await fetchAppStewardTarget(), target); + await assert.rejects(() => selectAppSteward(peer.binding_id, connections.revision - 1), /selection could not/); + assert.deepEqual(await fetchAppStewardTarget(), target, "Stale selection cannot retarget the App"); + const history = await fetchChatHistory({bound: target}); + assert.ok(history.messages.length === 2 && history.messages.every(row => !row.text.includes("Different private steward"))); + connections = await selectAppSteward(peer.binding_id, connections.revision); + const peerTarget = await fetchAppStewardTarget(); + assert.equal(peerTarget?.bindingId, peer.binding_id); + assert.notEqual(peerTarget?.sessionId, sessionId); + assert.equal((await resolveBoundChatSession(target)).session_id, sessionId, + "An already opened original Session stays fixed after the entry preference changes"); + connections = await selectAppSteward(bindingId, connections.revision); + child.stdin.write("deny\n"); await next(); + await assert.rejects(() => fetchAppStewardTarget(), /selected original steward is unavailable/); + await assert.rejects(() => selectAppSteward(bindingId, connections.revision), /selection could not/); + child.stdin.write("recover\n"); await next(); + assert.deepEqual(await fetchAppStewardTarget(), target); + const after = await inspect(); + assert.equal(after.sessions, before.sessions); + assert.deepEqual(after.messages, before.messages); + assert.equal(after.native_requests, before.native_requests, "Entry lookup and preference never invoke a model or replay a Turn"); + child.stdin.write("replace-peer\n"); + const replaced = await next(); + connections = await fetchPrivateConversations(); + const futurePeer = connections.connections.find(row => row.app_ref === "synthetic-peer")!; + assert.notEqual(futurePeer.binding_id, peer.binding_id); + assert.equal(futurePeer.binding_id, replaced.new_peer_binding_id); + assert.deepEqual(await fetchAppStewardTarget(), target, "A newly connected steward cannot capture the selected entry"); + peer = futurePeer; + child.stdin.write("close\n"); await next(); + await assert.rejects(() => fetchAppStewardTarget(), /selected original steward is unavailable/); + child.stdin.write("revoke\n"); await next(); + connections = await fetchPrivateConversations(); + assert.equal(connections.app_steward_binding_id, bindingId); + await assert.rejects(() => fetchAppStewardTarget(), /selected original steward is unavailable/); + assert.equal((await fetchPrivateConversations()).connections[0].binding_id, peer.binding_id); + connections = await selectAppSteward(null, connections.revision); + assert.equal(await fetchAppStewardTarget(), null, "Only an explicit clear restores the ordinary entry"); + const routedFetch = globalThis.fetch; + for (const malformed of [{ok: true}, {ok: true, binding_id: null, session_id: sessionId}, + {ok: true, binding_id: bindingId, session_id: "../other"}]) { + globalThis.fetch = async () => new Response(JSON.stringify(malformed)); + await assert.rejects(() => fetchAppStewardTarget()); + } + globalThis.fetch = routedFetch; + child.stdin.write("stop\n"); + const [code] = await exited; + assert.equal(code, 0, stderr); + console.log("app-steward: passed (real HTTP/Core, explicit choice between sources, original Session, stale/identity/revocation refusal, no replay or fallback)"); +} finally { + globalThis.fetch = originalFetch; + output.close(); + if (child.exitCode === null) {child.kill("SIGINT"); await exited;} +} diff --git a/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py b/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py index 9c1e417ae3..2b24d60aa9 100644 --- a/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py +++ b/apps/presentation/dashboard/smoke/bound-conversation-http-fixture.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import os from pathlib import Path import runpy import socket @@ -16,7 +17,8 @@ import pytest REPO = Path(__file__).resolve().parents[4] -sys.path[:0] = [str(REPO), str(REPO / "tests/control_plane")] +BACKEND = Path(os.environ.get("LOOPX_CHAT_FIXTURE_BACKEND_ROOT", REPO)).resolve() +sys.path[:0] = [str(BACKEND), str(REPO), str(REPO / "tests/control_plane")] from canonical_authority_fixture import isolate_sqlite_runtime, promoted_create_fixture from loopx.chat_server import serve_chat @@ -25,12 +27,16 @@ class SyntheticPrivateSource: transport_ref = "synthetic-private" cancelled = staticmethod(lambda: False) - def __init__(self, root): + def __init__(self, root, *, steward=False, peer=False): self.root, self.verified = root, True + self.steward, self.peer = steward, peer + self.peer_provider = None + if peer: + self.transport_ref = "synthetic-peer" def observe(self): - return {"transport_ref": self.transport_ref, "provider_ref": "c" * 24, - "operator_ref": "d" * 24, "verified": self.verified} + return {"transport_ref": self.transport_ref, "provider_ref": ("e" if self.peer else "c") * 24, + "operator_ref": ("f" if self.peer else "d") * 24, "verified": self.verified} def health_snapshot(self): return {"status": "listening"} @@ -45,12 +51,16 @@ def start(self, server): bindings = runtime.project_contexts.conversation_bindings binding = bindings.configure(transport_ref=self.transport_ref, project_ref=runtime.project_contexts.available()[0]["project_ref"], - executor_endpoint_id="codex", context_kind="project", project_grant="workspace_read") - source = {"source_ref": "a" * 24, "sender_ref": "d" * 24, "private_human_message": True} + executor_endpoint_id="codex", context_kind="steward" if self.steward else "project", project_grant="workspace_read") + source = {"source_ref": ("b" if self.peer else "a") * 24, + "sender_ref": ("f" if self.peer else "d") * 24, "private_human_message": True} original, _ = runtime.open_session(goal_id=None, agent_id="codex", objective="", work_dir=self.root / "project", mode="resume_latest", conversation_binding_id=binding["binding_id"], source_context=source) sid = original["session_id"] + if self.peer: + server.chat_store.append_message(sid, role="agent", text="Different private steward: keep this source separate.") + return server.chat_store.append_message(sid, role="user", text="Synthetic private conversation: review the research notes.") server.chat_store.append_message(sid, role="agent", text="The original conversation and its research notes are ready.") other, _ = runtime.open_session(goal_id=None, agent_id="codex", objective="", mode="new", @@ -73,10 +83,28 @@ def control(): elif command == "close": server.chat_store.update_session(sid, status="closed") result = {"closed": True} + elif command == "replace-peer" and self.peer_provider is not None: + old = next(row for row in bindings.read()["bindings"] if row["transport_ref"] == self.peer_provider.transport_ref) + bindings.disconnect(old["binding_id"], expected_revision=bindings.read()["revision"]) + self.peer_provider.start(server) + fresh = next(row for row in bindings.read()["bindings"] if row["transport_ref"] == self.peer_provider.transport_ref) + result = {"old_peer_binding_id": old["binding_id"], "new_peer_binding_id": fresh["binding_id"]} + elif command == "revoke": + bindings.disconnect(binding["binding_id"], expected_revision=bindings.read()["revision"]) + result = {"revoked": binding["binding_id"]} + elif command == "populate": + for index in range(6): + server.chat_store.append_message(sid, role="user", text=f"Synthetic source request {index + 1}: 保留原任务与尚未确认的结果。") + server.chat_store.append_message(sid, role="agent", text=f"### 合成报告 {index + 1} / Synthetic report\n\n" + + "\n\n".join(f"**工作 {part + 1}**:已验证的结果保留来源;仍在处理的事项继续保留,不把监听状态当作完成。 " + "Verified results retain their source. Unconfirmed returns remain open for recovery." + for part in range(6))) + result = {"populated": True, "messages": len(server.chat_store.messages(sid))} elif command == "inspect": saved = server.chat_store.load_session(sid) result = {"sessions": len(server.chat_store.list_sessions()), "original_thread": saved["upstream_thread_id"], - "messages": server.chat_store.messages(sid)} + "messages": server.chat_store.messages(sid), + "native_requests": len((self.root / "synthetic-requests.jsonl").read_text().splitlines())} elif command == "stop": server.shutdown() return @@ -93,15 +121,22 @@ def main(): patch.setenv("CODEX_HOME", str(root / "synthetic-model-home")) registry, _, _ = promoted_create_fixture(root, provider="sqlite") fake = root / "synthetic-codex" - fake.write_text(runpy.run_path(str(REPO / "examples/loopx-chat-runtime-smoke.py"))["FAKE_CODEX"]) + model = runpy.run_path(str(REPO / "examples/loopx-chat-runtime-smoke.py"))["FAKE_CODEX"] + model = model.replace(' method = request.get("method")', + f' with open({str(root / "synthetic-requests.jsonl")!r}, "a") as output:\n' + ' output.write(json.dumps(request) + "\\n")\n method = request.get("method")') + fake.write_text(model) fake.chmod(0o700) - source = SyntheticPrivateSource(root) + steward = "--steward" in sys.argv + source = SyntheticPrivateSource(root, steward=steward) + peer = SyntheticPrivateSource(root, steward=True, peer=True) if steward else None + source.peer_provider = peer with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) port = sock.getsockname()[1] serve_chat(registry_path=registry, runtime_root_override=root / "runtime", scan_roots=[root / "project"], codex_bin=str(fake), lark_cli_bin="missing-synthetic-lark", port=port, - external_conversation_factories=(lambda _: source,)) + external_conversation_factories=((lambda _: source, lambda _: peer) if peer else (lambda _: source,))) if __name__ == "__main__": diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 7f64dc1063..46cc8dad34 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -2691,11 +2691,28 @@ const privateConversationStopRecoverySchema = z.object({binding_id: z.string(), project_title: z.string(), pending_count: z.number().int().positive()}); export type PrivateConversationStopRecovery = z.infer; const privateConversationsSchema = z.object({ok: z.literal(true), revision: z.number().int(), + app_steward_binding_id: z.string().regex(/^[a-f0-9]{24}$/).nullable().default(null), connections: z.array(privateConversationSchema), stop_recoveries: z.array(privateConversationStopRecoverySchema).default([])}); export type PrivateConversation = z.infer; export async function fetchPrivateConversations(signal?: AbortSignal) { return privateConversationsSchema.parse(await requestJson("/api/chat/lark/private-conversations", { signal })); } +const appStewardTargetSchema = z.union([ + z.object({ok: z.literal(true), binding_id: z.null(), session_id: z.null()}), + z.object({ok: z.literal(true), binding_id: z.string().regex(/^[a-f0-9]{24}$/), + session_id: z.string().regex(/^[A-Za-z0-9._-]{1,160}$/)}), +]); +export async function fetchAppStewardTarget(signal?: AbortSignal): Promise { + const target = appStewardTargetSchema.parse(await requestJson("/api/chat/app-steward", {signal})); + return target.binding_id === null ? null : {bindingId: target.binding_id, sessionId: target.session_id}; +} +export async function selectAppSteward(bindingId: string | null, revision: number) { + const result = privateConversationsSchema.parse(await requestJson("/api/chat/app-steward", { + method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({binding_id: bindingId, revision}), + })); + if (typeof window !== "undefined") window.dispatchEvent(new Event("loopx:app-steward-changed")); + return result; +} export async function connectPrivateConversation(appRef: string, projectRef: string, executor: string, contextKind: "project" | "steward" = "project", projectGrant: "workspace_read" | "workspace_write" = "workspace_write", groupIds?: string[]) { return privateConversationsSchema.parse(await requestJson("/api/chat/lark/private-conversations", { method: "POST", headers: {"Content-Type": "application/json"}, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/app-steward-entry.tsx b/apps/presentation/dashboard/src/features/personal-workspace/app-steward-entry.tsx new file mode 100644 index 0000000000..89cc014020 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/app-steward-entry.tsx @@ -0,0 +1,48 @@ +import {useEffect, useRef, useState, type ReactNode} from "react"; +import {Button} from "../../components/ui/button"; +import {fetchAppStewardTarget, type BoundConversationTarget} from "../../data/chat"; +import {useWorkspaceI18n} from "./i18n"; + +/** Only mount at the local, unbound manager Chat entry. Children must not mount + * until discovery finishes: they otherwise expose the ordinary manager queue. + * Once opened, the route freezes the exact Session and this boundary unmounts. + */ +export function AppStewardEntry({children, onOpen}: {children: ReactNode; onOpen: (target: BoundConversationTarget) => void}) { + const {locale} = useWorkspaceI18n(); + const zh = locale === "zh-CN"; + const [state, setState] = useState<"loading" | "ordinary" | "unavailable">("loading"); + const [revision, setRevision] = useState(0); + const open = useRef(onOpen); + open.current = onOpen; + useEffect(() => { + const changed = () => {setState("loading"); setRevision(value => value + 1);}; + window.addEventListener("loopx:app-steward-changed", changed); + return () => window.removeEventListener("loopx:app-steward-changed", changed); + }, []); + useEffect(() => { + let active = true; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + void fetchAppStewardTarget(controller.signal).then(target => { + if (!active) return; + if (target) open.current(target); + else setState("ordinary"); + }).catch(() => {if (active) setState("unavailable");}).finally(() => clearTimeout(timeout)); + return () => {active = false; controller.abort(); clearTimeout(timeout);}; + }, [revision]); + if (state === "ordinary") return children; + const settings = new URL(window.location.href); + settings.searchParams.set("view", "overview"); + settings.searchParams.set("settings_scope", "machine"); + settings.searchParams.set("settings_tab", "lark"); + return
+

{zh ? "LoopX 管家" : "LoopX steward"}

+ {state === "loading" ?

{zh ? "正在打开管家对话…" : "Opening the steward conversation…"}

: <> +

{zh ? "原管家会话暂不可用。请检查所选连接后重试。" : "The original steward conversation is unavailable. Check the selected connection and retry."}

+
+ + {zh ? "打开会话设置" : "Open conversation settings"} +
+ } +
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx b/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx index 2657f92f07..c06846ef17 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx @@ -3,7 +3,7 @@ import {ContextHelp} from "./context-help"; import {useEffect, useRef, useState} from "react"; import {connectPrivateConversation, disconnectPrivateConversation, fetchPrivateConversations, changePrivateAgentTarget, changeOwnerNotifications, prepareManagedSteward, configureStewardMonitor, configureStewardReport, fetchChatProjects, fetchChatCapabilities, fetchLarkGroupChats, type PrivateConversation, - discoverBoundChatSession, type BoundConversationTarget, + discoverBoundChatSession, selectAppSteward, type BoundConversationTarget, type ChatProject, type LarkApp, type PrivateConversationStopRecovery} from "../../data/chat"; import {useWorkspaceI18n} from "./i18n"; import {StewardManagedMode} from "./steward-managed-mode"; @@ -18,6 +18,7 @@ export function PrivateConversationPanel({apps, onOpenOriginal}: {apps: LarkApp[ const [connectionsLoading, setConnectionsLoading] = useState(true); const [recoveries, setRecoveries] = useState([]); const [revision, setRevision] = useState(0); + const [appStewardBindingId, setAppStewardBindingId] = useState(null); const [app, setApp] = useState(""); const [project, setProject] = useState(""); const [executor, setExecutor] = useState(""); @@ -49,6 +50,7 @@ export function PrivateConversationPanel({apps, onOpenOriginal}: {apps: LarkApp[ async function refresh() { const state = await fetchPrivateConversations(); setRows(state.connections); setRecoveries(state.stop_recoveries); setRevision(state.revision); + setAppStewardBindingId(state.app_steward_binding_id); } useEffect(() => { let active = true; @@ -66,7 +68,8 @@ export function PrivateConversationPanel({apps, onOpenOriginal}: {apps: LarkApp[ async function readConnections() { try { const state = await fetchPrivateConversations(AbortSignal.timeout(5000)); - if (active) {setRows(state.connections); setRecoveries(state.stop_recoveries); setRevision(state.revision);} + if (active) {setRows(state.connections); setRecoveries(state.stop_recoveries); setRevision(state.revision); + setAppStewardBindingId(state.app_steward_binding_id);} } catch (error) {failed(error);} finally {if (active) {setConnectionsLoading(false); timer = setTimeout(() => void readConnections(), 5000);}} } @@ -126,12 +129,21 @@ export function PrivateConversationPanel({apps, onOpenOriginal}: {apps: LarkApp[ + {appStewardBindingId !== null && !rows.some(row => row.binding_id === appStewardBindingId) ?
+

{zh ? "已选的 App 对话来源已失效。" : "The selected App Conversation source is unavailable."}

+ +
: null} {rows.map(row =>
{row.app_ref} · {row.context_available ? row.project_title : (zh ? "工作区不可用" : "Workspace unavailable")} {onOpenOriginal && row.audience === "owner" ? : null} + {row.context_kind === "steward" && row.audience === "owner" ? : null}

{row.context_kind === "steward" ? (row.goal_scope === "all_registered" ? (zh ? `LoopX 管家 · 全部已注册工作 · ${row.goal_count} 个 Goal` : `LoopX steward · All registered work · ${row.goal_count} Goals`) diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 1f3c9dd530..4cfbe2549f 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -6,6 +6,7 @@ import { readConversationReturns, readTaskSessions } from "../data/conversation- import { currentChannelSession, useConversationHistory } from "../data/use-conversation-history"; import { useChatProjects } from "../data/use-chat-projects"; import { useBoundConversation } from "../data/use-bound-conversation"; +import {AppStewardEntry} from "../features/personal-workspace/app-steward-entry"; import { goalSubtree } from "../features/personal-workspace/goal-hierarchy"; import {compactWorkspaceText as compactShareText, workspaceAgentTodoFromItem} from "../features/personal-workspace/personal-workspace-model"; import type { GoalAcceptanceObservation } from "../data/goal-acceptance-observation"; @@ -3681,7 +3682,7 @@ export function DashboardPage() { useEffect(() => { // A source transition can still expose the previous source's Goal rows. // Reconcile only its committed URL, including during browser Back/Forward. - if (isLoading || (source.kind === "url" && search.statusUrl && search.statusUrl !== source.label)) return; + if (statusRequestActive || isLoading || (source.kind === "url" && search.statusUrl && search.statusUrl !== source.label)) return; if (search.statusUrl && source.kind === "example") { return; } @@ -3708,7 +3709,7 @@ export function DashboardPage() { }), }); } - }, [goalArchiveLoadState.phase, goalRows, isLoading, navigate, search.goalId, search.rootGoalId, search.statusUrl, source.kind, source.label]); + }, [goalArchiveLoadState.phase, goalRows, isLoading, navigate, search.goalId, search.rootGoalId, search.statusUrl, source.kind, source.label, statusRequestActive]); useEffect(() => { if (!progress || isLoading || !search.goalId || source.kind !== "url") return; @@ -3751,7 +3752,7 @@ export function DashboardPage() { ); } - return ( + const home = ( ); + const defaultStewardEntry = search.view === "conversation" && !search.goalId && !search.rootGoalId + && search.workspace === undefined && search.workAgent === undefined && search.workSession === undefined + && search.conversationBinding === undefined && search.conversationSession === undefined + && activeStatusSource.kind === "local" && !activeStatusSource.readOnly; + return defaultStewardEntry ? void navigate({replace: true, + search: current => "goalId" in current && current.statusUrl === search.statusUrl && current.view === "conversation" + && !current.goalId && !current.rootGoalId && current.workspace === undefined && current.workAgent === undefined + && current.workSession === undefined && current.conversationBinding === undefined && current.conversationSession === undefined + ? {...current, conversationBinding: target.bindingId, conversationSession: target.sessionId} : current})}> + {home} + : home; } diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index 70f1bea4cc..ceefe28a68 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -140,6 +140,37 @@ remain unavailable. There is no fallback to ordinary steward/project history, Session creation, executor change or model reconfiguration on this entry. Group audiences retain their existing boundary. +An owner-private steward connection also offers **Use for App Conversation**. Core +stores this explicit entry preference alongside the binding set, without +changing any audience, managed identity or execution grant. Multiple stewards +never compete by list order or recent activity. The local App steward Chat +entry resolves only that source's existing current Session, then freezes its +exact binding and Session in the URL. Discovery blocks the ordinary composer +until it completes and performs no model work or Session creation. Existing +unselected installations retain their ordinary entry. Once selected, a missing, +revoked or replaced source stays unavailable until recovery or explicit clear; +it cannot silently fall back to a peer or ordinary conversation. Goal-scoped, +project, explicit original, managed-Agent and remote/read-only views retain their +own navigation. A newly selected preference does not retarget an already frozen +original conversation. +Settings retain an explicit clear action even after the selected binding is +removed or its workspace becomes unavailable; clearing changes only the entry +preference and retains all conversation history and execution grants. + +Cold Goal links wait for the current status directory before invalidating a +missing selection; the bundled placeholder is not proof that a valid Goal was +removed. This keeps an initial Goal route out of default-steward discovery. + +![Choose an existing synthetic source](../../assets/personal-workspace/app-steward-entry/choose-source.png) +![Continue its original conversation](../../assets/personal-workspace/app-steward-entry/original-conversation.png) + +Public-safe packaged evidence also includes the +[narrow selection](../../assets/personal-workspace/app-steward-entry/selected-source-narrow.png), +[unavailable entry](../../assets/personal-workspace/app-steward-entry/unavailable-narrow.png), +and [complete narrow report](../../assets/personal-workspace/app-steward-entry/full-report-narrow.png). +These use disposable Core/HTTP state and synthetic provider/model data; they do +not certify installed external delivery or the complete synchronization goal. + The App reuses its conversation history, single composer, stream recovery and queued-input owners. Quiet source refresh reads the verified compact index; only changed snapshots are read. Messages arriving from the original source @@ -169,8 +200,8 @@ a started write without a provider locator stays unconfirmed after restart. Revocation or changed identity prevents further delivery. Disable the existing connection to revoke it; rollback must preserve its private delivery journals. -Use **Continue original conversation** above to reach this path. Ordinary App -steward history is still a separate entry; it is not silently merged or retargeted. +Use **Continue original conversation** or the explicitly selected App steward +entry above to reach this path. Old ordinary history is retained separately. This checkpoint covers text projection, not reply/thread relation mirroring, attachment transfer, retroactive insertion into Lark chronology or all-channel broadcast. An App attachment produces an explicit unsynchronized notice and a diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md index 58196fedea..edfc07f956 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md @@ -99,6 +99,30 @@ Lark listener 观测。该本地 hook 只读取缓存,不发起网络请求。 不回退到普通管家/项目历史,不新建替代 Session,也不在这里切换执行器或模型。 群受众保留原有边界。 +已有本人私聊管家连接还提供 **用于 App「对话」**。Core 在原 binding 集合中保存 +明确的入口选择,不改变受众、受管身份或执行授权。多个管家不按列表顺序或最近 +活跃时间竞争。点击本机 App 管家聊天时,仅在所选来源中发现当前已有 Session, +随后将确切 binding 与 Session 固定在 URL。发现完成前阻止普通输入框出现; +发现不调用模型、不创建会话。未选择的旧安装保留普通入口。选定来源缺失、撤权 +或被替换时保持不可用,等待恢复或明确取消选择,不悄悄回退到另一管家或普通会话。 +Goal 范围、项目、明确原会话、受管 Agent 以及远端/只读视图保留各自导航。 +后来修改默认选择,不挪走已固定的原会话。 +所选 binding 已移除或工作区不可用时,设置仍保留明确取消选择的入口;取消只改变 +默认入口,保留全部会话历史与执行授权。 + +冷启动 Goal 深链接时,先等待当前状态目录,再判断所选 Goal 是否已失效;内置 +占位数据不能证明有效 Goal 已被删除,初始 Goal 路由因此不会误入默认管家发现。 + +![选择已有合成来源](../../assets/personal-workspace/app-steward-entry/choose-source.png) +![继续其原会话](../../assets/personal-workspace/app-steward-entry/original-conversation.png) + +公开安全的打包证据另含 +[窄屏选择](../../assets/personal-workspace/app-steward-entry/selected-source-narrow.png)、 +[入口不可用](../../assets/personal-workspace/app-steward-entry/unavailable-narrow.png)和 +[窄屏完整报告](../../assets/personal-workspace/app-steward-entry/full-report-narrow.png)。 +以上使用临时 Core/HTTP 状态及合成 provider/模型数据,不代替安装态外部送达或 +完整同步目标的验收。 + App 复用原对话历史、单输入框、流恢复和排队输入 owner。安静刷新读取经核验的 紧凑索引,仅在变化时读取快照;原来源新增消息无需导航即可显示,Web 输入保留 Web 来源,通过已有 Core 队列及新鲜来源准入。连接列表独立于 App/凭证目录加载, @@ -119,8 +143,8 @@ Core 重查 Session、当前绑定与受众,provider 核验原来源。消息 没有 provider locator 的写入,重启后仍保留未确认。撤权或身份变化阻止后续投递。 关闭既有连接即可撤销授权;回滚须保留私有投递 journal。 -通过上述 **继续原会话** 使用该路径。普通 App 管家历史仍是独立入口,不会被静默 -合并或重定向。本检查点覆盖文本投影,不覆盖回复/话题关系镜像、附件传输、向 Lark +通过上述 **继续原会话** 或明确选定的 App 管家入口使用该路径;旧普通历史继续 +独立保留。本检查点覆盖文本投影,不覆盖回复/话题关系镜像、附件传输、向 Lark 历史回插旧时间或全渠道广播。App 附件会产生明确的未同步提示,并保留 partial outbox,不声明完整送达。真实 HTTP/Core 配合合成 provider 的旅程覆盖重复输入、 长正文、重启、回执缺失/未知、撤权及 App 身份替换;安装态 provider 读回另行验收。 diff --git a/docs/assets/personal-workspace/app-steward-entry/choose-source.png b/docs/assets/personal-workspace/app-steward-entry/choose-source.png new file mode 100644 index 0000000000..16951928e5 Binary files /dev/null and b/docs/assets/personal-workspace/app-steward-entry/choose-source.png differ diff --git a/docs/assets/personal-workspace/app-steward-entry/full-report-narrow.png b/docs/assets/personal-workspace/app-steward-entry/full-report-narrow.png new file mode 100644 index 0000000000..a076b3c1e6 Binary files /dev/null and b/docs/assets/personal-workspace/app-steward-entry/full-report-narrow.png differ diff --git a/docs/assets/personal-workspace/app-steward-entry/goal-stays-scoped.png b/docs/assets/personal-workspace/app-steward-entry/goal-stays-scoped.png new file mode 100644 index 0000000000..8ff78afb33 Binary files /dev/null and b/docs/assets/personal-workspace/app-steward-entry/goal-stays-scoped.png differ diff --git a/docs/assets/personal-workspace/app-steward-entry/original-conversation.png b/docs/assets/personal-workspace/app-steward-entry/original-conversation.png new file mode 100644 index 0000000000..740fcb0e93 Binary files /dev/null and b/docs/assets/personal-workspace/app-steward-entry/original-conversation.png differ diff --git a/docs/assets/personal-workspace/app-steward-entry/populated-conversation.png b/docs/assets/personal-workspace/app-steward-entry/populated-conversation.png new file mode 100644 index 0000000000..1798e38323 Binary files /dev/null and b/docs/assets/personal-workspace/app-steward-entry/populated-conversation.png differ diff --git a/docs/assets/personal-workspace/app-steward-entry/populated-narrow.png b/docs/assets/personal-workspace/app-steward-entry/populated-narrow.png new file mode 100644 index 0000000000..16f607cb6e Binary files /dev/null and b/docs/assets/personal-workspace/app-steward-entry/populated-narrow.png differ diff --git a/docs/assets/personal-workspace/app-steward-entry/revoked-selection-narrow.png b/docs/assets/personal-workspace/app-steward-entry/revoked-selection-narrow.png new file mode 100644 index 0000000000..4cf9755ef5 Binary files /dev/null and b/docs/assets/personal-workspace/app-steward-entry/revoked-selection-narrow.png differ diff --git a/docs/assets/personal-workspace/app-steward-entry/selected-source-narrow.png b/docs/assets/personal-workspace/app-steward-entry/selected-source-narrow.png new file mode 100644 index 0000000000..cfe7c0b1e6 Binary files /dev/null and b/docs/assets/personal-workspace/app-steward-entry/selected-source-narrow.png differ diff --git a/docs/assets/personal-workspace/app-steward-entry/unavailable-narrow.png b/docs/assets/personal-workspace/app-steward-entry/unavailable-narrow.png new file mode 100644 index 0000000000..0822519566 Binary files /dev/null and b/docs/assets/personal-workspace/app-steward-entry/unavailable-narrow.png differ diff --git a/docs/assets/personal-workspace/app-steward-entry/unselected-ordinary.png b/docs/assets/personal-workspace/app-steward-entry/unselected-ordinary.png new file mode 100644 index 0000000000..2b7bd353e3 Binary files /dev/null and b/docs/assets/personal-workspace/app-steward-entry/unselected-ordinary.png differ diff --git a/loopx/capabilities/native_chat/conversation_bindings.py b/loopx/capabilities/native_chat/conversation_bindings.py index e2d20562e0..2168754917 100644 --- a/loopx/capabilities/native_chat/conversation_bindings.py +++ b/loopx/capabilities/native_chat/conversation_bindings.py @@ -147,6 +147,41 @@ def disconnect(self, binding_id: str, *, expected_revision: int) -> dict[str, An self._stop_revoked_binding(binding_id) return dict(result["state"]) + def select_app_steward(self, binding_id: str | None, *, expected_revision: int) -> dict[str, Any]: + """Select one existing source for the App entry without changing grants.""" + row = next((item for item in self.read()["bindings"] if item["binding_id"] == binding_id), None) + observation = self.observe(row["transport_ref"]) if row is not None else None + with exclusive_file_lock(self.path, operation="select_app_steward"): + result = self._core("collaboration.conversation.binding", { + "current": self.read(), "expected_revision": expected_revision, + "operation": "select_app_steward", "binding_id": binding_id, + "observation": observation, "available_projects": self.projects.available() if binding_id is not None else [], + }) + if result["changed"]: + _atomic_write_json(self.path, result["state"]) + if self.read() != result["state"]: + raise OSError("App steward selection did not verify") + return dict(result["state"]) + + def app_steward_target(self) -> dict[str, Any]: + """Discover within the selected source; opening creates no execution.""" + current = self.read() + selected = current.get("app_steward_binding_id") + if selected is None: + # Validate legacy state through the same pure owner plan. An + # unreadable grant must not masquerade as an unselected entry. + self._core("collaboration.conversation.binding", {"current": current, + "expected_revision": current.get("revision"), "operation": "select_app_steward", "binding_id": None}) + return {"binding_id": None, "session_id": None} + sessions = self.local_sessions(selected, app_steward_entry=True) + if not sessions: + raise ValueError("the original steward Session is unavailable") + session = max(sessions, key=lambda item: (item.get("last_activity_at") or "", item["session_id"])) + with exclusive_file_lock(self.path, operation="read_app_steward_target"): + if self.read().get("app_steward_binding_id") != selected: + raise ValueError("App steward selection changed during discovery") + return {"binding_id": selected, "session_id": session["session_id"]} + def _revoke_delivery_sources(self, binding_id: str) -> None: if self.controller is not None: from ..manager_context import revoke_conversation_sources @@ -265,7 +300,7 @@ def _session_observation(session: dict[str, Any]) -> dict[str, Any]: "session_id", "goal_id", "agent_id", "executor_endpoint_id", "channel_id", "session_mode", "status", "project_context", "steward_context")} - def local_sessions(self, binding_id: str) -> list[dict[str, Any]]: + def local_sessions(self, binding_id: str, *, app_steward_entry: bool = False) -> list[dict[str, Any]]: """Read original targets; metadata hints never replace Core's grant. Provider IO and store inventory stay outside the publication fence. @@ -295,6 +330,7 @@ def local_sessions(self, binding_id: str) -> list[dict[str, Any]]: "current": current, "binding_id": binding_id, "observation": observation, "available_projects": self.projects.available(), "sessions": [self._session_observation(session) for session in sessions], + **({"app_steward_entry": True} if app_steward_entry else {}), **({"available_goal_ids": self.goal_scope_ids(row)} if row.get("goal_scope") == "all_registered" else {}), }) # Build the lookup only after Core has rejected duplicate identity; diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 2ad8e3e08f..736cdfec0a 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1668,6 +1668,7 @@ def do_GET(self) -> None: CHAT_LARK_CHATS_PATH: self._lark_chats, CHAT_LARK_CONNECTIONS_PATH: self._lark_connections, PRIVATE_CONVERSATIONS_PATH: self._private_conversations, + "/api/chat/app-steward": self._app_steward, CHAT_GOAL_CHANNEL_TARGETS_PATH: self._goal_channel_targets, **self._configuration_get_routes(), DEFAULT_CHAT_STATUS_PATH: self._status, @@ -1727,6 +1728,7 @@ def do_POST(self) -> None: CHAT_LARK_APP_SETUPS_PATH: self._lark_setup_start, CHAT_LARK_CONNECTIONS_PATH: self._lark_connect, PRIVATE_CONVERSATIONS_PATH: self._private_conversation_connect, + "/api/chat/app-steward": self._select_app_steward, PRIVATE_CONVERSATIONS_PATH + "/agent-targets": self._private_conversation_agent_target, PRIVATE_CONVERSATIONS_PATH + "/owner-notifications": self._private_conversation_owner_notifications, PRIVATE_CONVERSATIONS_PATH + "/managed-steward": self._private_conversation_managed_steward, diff --git a/loopx/control_plane/collaboration/conversation_binding.ts b/loopx/control_plane/collaboration/conversation_binding.ts index b0b103210a..0b0757f1c2 100644 --- a/loopx/control_plane/collaboration/conversation_binding.ts +++ b/loopx/control_plane/collaboration/conversation_binding.ts @@ -175,7 +175,7 @@ function binding(value: unknown): JsonObject { }; } -function state(value: unknown): {schema_version: string; revision: number; bindings: JsonObject[]} { +function state(value: unknown): {schema_version: string; revision: number; bindings: JsonObject[]; app_steward_binding_id?: string | null} { const row = requireJsonObject(value, "conversation bindings"); if (row.schema_version !== SET_SCHEMA || !Number.isSafeInteger(row.revision) || Number(row.revision) < 0 || !Array.isArray(row.bindings)) { @@ -198,7 +198,11 @@ function state(value: unknown): {schema_version: string; revision: number; bindi if (new Set(sessions).size !== sessions.length) { throw new EffectRuntimeRequestError("an attached Session cannot share two App audiences"); } - return {schema_version: SET_SCHEMA, revision: Number(row.revision), bindings}; + // This is an explicit local entry preference, not an audience grant. Retain a + // removed identity so disconnect/replacement cannot silently select a peer. + const preferred = row.app_steward_binding_id; + return {schema_version: SET_SCHEMA, revision: Number(row.revision), bindings, + ...(preferred !== undefined ? {app_steward_binding_id: preferred === null ? null : ref(preferred, "App steward binding")} : {})}; } function observed(row: JsonObject, value: unknown): void { @@ -213,7 +217,19 @@ export function planConversationBinding(params: JsonObject): JsonObject { const current = state(params.current); if (params.expected_revision !== current.revision) throw new EffectRuntimeRequestError("conversation binding revision changed"); let rows: JsonObject[]; - if (params.operation === "configure") { + if (params.operation === "select_app_steward") { + const id = params.binding_id === null ? null : ref(params.binding_id, "App steward binding"); + if (id !== null) { + const selected = current.bindings.find(row => row.binding_id === id); + if (!selected || selected.context_kind !== "steward" || selected.audience === "group") { + throw new EffectRuntimeRequestError("select an existing private owner steward"); + } + observed(selected, params.observation); + boundProject(params, selected); + } + if ((current.app_steward_binding_id ?? null) === id) return {changed: false, state: current}; + return {changed: true, state: state({...current, revision: current.revision + 1, app_steward_binding_id: id})}; + } else if (params.operation === "configure") { const candidate = binding(params.binding); observed(candidate, params.observation); if (candidate.audience === "group") { @@ -381,6 +397,10 @@ export function listLocalBoundSessions(params: JsonObject): JsonObject { const id = ref(params.binding_id, "binding identity"); const row = current.bindings.find(item => item.binding_id === id); if (!row) throw new EffectRuntimeRequestError("conversation binding is no longer authorized"); + if (params.app_steward_entry === true + && (current.app_steward_binding_id !== id || row.context_kind !== "steward" || row.audience === "group")) { + throw new EffectRuntimeRequestError("the selected original steward is unavailable"); + } observed(row, params.observation); if (row.audience === "group") throw new EffectRuntimeRequestError("local discovery requires a private binding"); boundProject(params, row); diff --git a/loopx/extensions/lark/private_conversation_api.py b/loopx/extensions/lark/private_conversation_api.py index c3da95e15c..782922be69 100644 --- a/loopx/extensions/lark/private_conversation_api.py +++ b/loopx/extensions/lark/private_conversation_api.py @@ -40,7 +40,8 @@ def _private_conversations(self) -> None: composed = getattr(self.server, "conversation_transports", None) lark_profiles = set(health) - set(composed.transports if composed is not None else {}) deliveries = self.server.lark_private_conversations.health() - self._send_json({"ok": True, "revision": current["revision"], "connections": [ + self._send_json({"ok": True, "revision": current["revision"], + "app_steward_binding_id": current.get("app_steward_binding_id"), "connections": [ {"binding_id": row["binding_id"], "app_ref": row["transport_ref"], "context_kind": row["context_kind"], "project_ref": row["project_ref"], "context_available": row["project_ref"] in titles, "project_title": titles.get(row["project_ref"], "Unavailable workspace"), "executor_endpoint_id": row["executor_endpoint_id"], "grant": row["grant"], @@ -67,6 +68,27 @@ def _private_conversations(self) -> None: "project_title": titles.get(row["project_ref"], "Unavailable workspace")} for row in bindings.stop_recoveries(current)]}) + def _app_steward(self) -> None: + try: + target = self.server.runtime_controller.project_contexts.conversation_bindings.app_steward_target() + except (ValueError, KeyError, OSError, RuntimeError): + self._send_error("The selected original steward is unavailable. Check its connection in Settings and retry.", status=409) + return + self._send_json({"ok": True, **target}) + + def _select_app_steward(self) -> None: + try: + body = self._read_json() + if (set(body) != {"binding_id", "revision"} or type(body["revision"]) is not int + or (body["binding_id"] is not None and not isinstance(body["binding_id"], str))): + raise ValueError("select an existing owner steward at the current revision") + self.server.runtime_controller.project_contexts.conversation_bindings.select_app_steward( + body["binding_id"], expected_revision=body["revision"]) + except (ValueError, KeyError, OSError, RuntimeError): + self._send_error("App steward selection could not be changed. Refresh the connection and retry.", status=409) + return + self._private_conversations() + def _private_conversation_connect(self) -> None: from .goal_topic_runtime import _active_profile_configs from ...chat_lark_api import build_lark_goal_topic_runtime_snapshot diff --git a/tests/control_plane_ts/conversation_binding.test.ts b/tests/control_plane_ts/conversation_binding.test.ts index b609ff34a6..6f93cb2fe9 100644 --- a/tests/control_plane_ts/conversation_binding.test.ts +++ b/tests/control_plane_ts/conversation_binding.test.ts @@ -17,6 +17,37 @@ const current = {schema_version: "loopx_chat_conversation_bindings_v0", revision const request = {current, expected_revision: 0, operation: "configure", binding: row, observation, available_projects: [project]}; +test("the App steward is an explicit source preference, never a new grant or peer fallback", () => { + const steward = {...row, context_kind: "steward", grant: "portfolio_read", goal_ids: []}; + const configured = planConversationBinding({...request, binding: steward}).state; + const choose = {current: configured, expected_revision: 1, operation: "select_app_steward", + binding_id: row.binding_id, observation, available_projects: [project]}; + const selected = planConversationBinding(choose).state; + assert.equal(selected.app_steward_binding_id, row.binding_id); + assert.deepEqual(selected.bindings, configured.bindings, "Selection must not revise any grant or managed actor"); + assert.equal(planConversationBinding({...choose, current: selected, expected_revision: 2}).changed, false); + for (const denied of [{expected_revision: 0}, {binding_id: "f".repeat(24)}, + {observation: {...observation, provider_ref: "f".repeat(24)}}, + {observation: {...observation, operator_ref: "f".repeat(24)}}, + {observation: {...observation, verified: false}}, {available_projects: []}, + {current: {...configured, bindings: [row]}}]) { + assert.throws(() => planConversationBinding({...choose, ...denied})); + } + const disconnected = planConversationBinding({current: selected, expected_revision: 2, + operation: "disconnect", binding_id: row.binding_id}).state; + assert.equal(disconnected.app_steward_binding_id, row.binding_id, "Revocation retains the unavailable original selection"); + assert.throws(() => listLocalBoundSessions({current: disconnected, binding_id: row.binding_id, + app_steward_entry: true, observation, available_projects: [project], sessions: []}), /no longer authorized/); + const cleared = planConversationBinding({current: disconnected, expected_revision: 3, + operation: "select_app_steward", binding_id: null}).state; + assert.equal(cleared.app_steward_binding_id, null); + assert.deepEqual(cleared.bindings, []); + // A different steward may still be inspected normally, but cannot claim to + // be the explicitly selected App entry, including with an empty inventory. + assert.throws(() => listLocalBoundSessions({current: configured, binding_id: row.binding_id, + app_steward_entry: true, observation, available_projects: [project], sessions: []}), /selected original steward/); +}); + for (const kind of ["project", "steward"]) for (const retryKind of ["routing", "quota"]) { test(`${kind} ${retryKind} recovery is bounded, source-bound and independent of context migrations`, () => { const selected = kind === "steward" ? {...row, context_kind: kind, grant: "portfolio_read", goal_ids: []} : row; diff --git a/tests/test_chat_conversation_bindings.py b/tests/test_chat_conversation_bindings.py index f3b8a54266..20d938dc6b 100644 --- a/tests/test_chat_conversation_bindings.py +++ b/tests/test_chat_conversation_bindings.py @@ -10,6 +10,24 @@ from loopx.extensions.lark.conversation_identity import identity_ref +def test_app_steward_corrupt_legacy_configuration_never_falls_back(tmp_path): + bindings = ChatConversationBindings(root=tmp_path, project_contexts=None, + observe=lambda _: pytest.fail("an unselected entry must not probe a provider")) + assert bindings.app_steward_target() == {"binding_id": None, "session_id": None} + valid = bindings.read() + for change in [{"schema_version": "unrecognized"}, {"revision": True}, + {"bindings": None}, {"app_steward_binding_id": "invalid"}]: + bindings.path.write_text(json.dumps({**valid, **change})) + with pytest.raises(ValueError): + bindings.app_steward_target() + bindings.path.write_text(json.dumps(valid)) + before = bindings.path.read_bytes() + assert bindings.app_steward_target() == {"binding_id": None, "session_id": None} + assert bindings.path.read_bytes() == before + assert bindings.select_app_steward(None, expected_revision=valid["revision"])["bindings"] == [] + assert bindings.path.read_bytes() == before + + def _request_for_event(transport, event): binding = transport._binding("notes-app") return transport.core.read_request(