diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index e553ae2ec4..1b61e8a922 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -65,6 +65,7 @@ "test:conversation-returns": "node --experimental-strip-types src/data/conversation-returns.test.mjs", "smoke:conversation-history": "vite build --ssr smoke/conversation-history-smoke.ts --outDir node_modules/.cache/loopx-conversation-history --emptyOutDir && node node_modules/.cache/loopx-conversation-history/conversation-history-smoke.js", "smoke:conversation-returns-packaged": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/conversation-return-browser-smoke.mjs", + "smoke:workspace-scope-packaged": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/workspace-scope-browser-smoke.mjs", "smoke:recent-completions": "tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --types node --outDir /tmp/loopx-recent-completions-smoke smoke/recent-completions-smoke.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-recent-completions-smoke/apps/presentation/dashboard/smoke/recent-completions-smoke.js" }, "dependencies": { diff --git a/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs new file mode 100644 index 0000000000..34a295e7fc --- /dev/null +++ b/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs @@ -0,0 +1,103 @@ +// Packaged steward conversation scoped to a host-granted workspace, against the +// production Chat server and store. A synthetic workspace and the repository's +// fake Codex app-server stand in for the host; no model, registry or Goal is used. +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, rename, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { resolveTestPython } from "../../../../scripts/test-python.mjs"; +import { launchBrowser, loadPlaywright, waitForHttp } from "../../../../examples/dashboard-browser-smoke-support.mjs"; + +const repoRoot = resolve(import.meta.dirname, "../../../.."); +const python = resolveTestPython({ repoRoot }); +const port = Number(process.env.LOOPX_WORKSPACE_SCOPE_PORT ?? 5413); +const screenshots = process.env.LOOPX_WORKSPACE_SCOPE_SCREENSHOTS; +const root = await mkdtemp(join(tmpdir(), "loopx-workspace-scope-")); +const workspace = join(root, "notes"); +await mkdir(workspace); +await mkdir(join(root, "home")); +const codex = join(root, "codex"); +const written = spawnSync(python, ["-c", [ + "import pathlib, runpy, sys", + "source = runpy.run_path('examples/loopx-chat-runtime-smoke.py')['FAKE_CODEX']", + "path = pathlib.Path(sys.argv[1]); path.write_text(source); path.chmod(0o700)", +].join("\n"), codex], { cwd: repoRoot, encoding: "utf8" }); +assert.equal(written.status, 0, written.stderr); + +const server = spawn(python, ["-c", "import sys; from loopx.entrypoint import main; sys.exit(main())", + "chat", "--no-open", "--port", String(port), "--codex-bin", codex, "--scan-root", workspace, "--global-registry"], +{ cwd: root, env: { ...process.env, HOME: join(root, "home"), PYTHONPATH: repoRoot }, stdio: ["ignore", "pipe", "pipe"] }); +let serverError = ""; +for (const stream of [server.stdout, server.stderr]) stream.on("data", (chunk) => { serverError += String(chunk); }); +// The first-run usage notice is unrelated to this journey; keep evidence focused. +async function capture(target, name) { + if (!screenshots) return; + const dismiss = target.getByRole("button", { name: "收起统计告知" }); + if (await dismiss.isVisible()) await dismiss.click(); + await target.screenshot({ path: join(screenshots, name) }); +} +let browser; +try { + const url = `http://127.0.0.1:${port}/chat/`; + await waitForHttp(url).catch((error) => { throw new Error(`${error.message}\n${serverError}`); }); + browser = await launchBrowser(loadPlaywright().chromium); + const page = await browser.newPage({ locale: "zh-CN", viewport: { width: 1440, height: 900 } }); + const sessionRequests = []; + page.on("request", (request) => { + if (request.method() === "POST" && new URL(request.url()).pathname === "/api/chat/sessions") sessionRequests.push(request.postDataJSON()); + }); + await page.goto(url, { waitUntil: "networkidle" }); + const scope = page.getByRole("combobox", { name: "范围" }); + assert.equal(await scope.count(), 0, "The steward overview keeps its first screen; scope belongs to the conversation"); + + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "对话" }).click(); + await scope.click(); + await page.getByRole("option", { name: "notes" }).click(); + await page.getByText("工作区对话 · 只读").waitFor(); + const workspaceUrl = page.url(); + assert.match(workspaceUrl, /[?&]workspace=[0-9a-f]{24}(?:&|$)/u); + const question = "整理一下最近的素材"; + await page.getByLabel("发送消息").fill(question); + await page.keyboard.press("Enter"); + await page.getByText("Runtime response.").first().waitFor({ timeout: 15_000 }); + await capture(page, "ordinary-workspace-conversation.png"); + + const projectRequests = sessionRequests.filter((body) => body.context_kind === "project"); + assert.ok(projectRequests.length > 0, "The workspace scope opens a project Session"); + for (const body of projectRequests) { + assert.equal("goal_id" in body, false, "A workspace Session never names a Goal"); + assert.match(body.project_ref, /^[0-9a-f]{24}$/u); + } + + await page.reload({ waitUntil: "networkidle" }); + await page.getByText(question).first().waitFor({ timeout: 15_000 }); + await scope.click(); + await page.getByRole("option", { name: "LoopX 管家" }).click(); + await page.waitForURL((current) => !current.searchParams.has("workspace")); + await page.getByRole("combobox", { name: "范围" }).waitFor(); + assert.equal(await page.getByText(question).count(), 0, "The steward scope does not show the workspace exchange"); + assert.ok(sessionRequests.filter((body) => body.context_kind === "manager").every((body) => !("project_ref" in body))); + + if (screenshots) { + const narrow = await browser.newPage({ locale: "zh-CN", viewport: { width: 390, height: 844 } }); + await narrow.goto(workspaceUrl, { waitUntil: "networkidle" }); + await narrow.getByText(question).first().waitFor({ timeout: 15_000 }); + await capture(narrow, "ordinary-workspace-conversation-narrow.png"); + await narrow.close(); + } + + await rename(workspace, `${workspace}.moved`); + await page.goto(workspaceUrl, { waitUntil: "networkidle" }); + await page.getByTestId("workspace-scope-unavailable").waitFor({ timeout: 15_000 }); + await page.getByText(question).first().waitFor({ timeout: 15_000 }); + await page.getByLabel("发送消息").fill("还能继续吗"); + assert.equal(await page.getByRole("button", { name: "发送", exact: true }).isDisabled(), true, + "A revoked grant keeps history and blocks new messages"); + await capture(page, "ordinary-workspace-grant-rejection.png"); + console.log("workspace scope browser smoke ok"); +} finally { + await browser?.close(); + server.kill(); + await rm(root, { force: true, recursive: true }); +} diff --git a/apps/presentation/dashboard/src/data/chat-model.ts b/apps/presentation/dashboard/src/data/chat-model.ts index 76bd016e13..e39d350406 100644 --- a/apps/presentation/dashboard/src/data/chat-model.ts +++ b/apps/presentation/dashboard/src/data/chat-model.ts @@ -1,5 +1,7 @@ import type { GoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js"; export type LoopXModeSettings = { agent_id: string; token_budget: number }; +/** A host-granted workspace an ordinary conversation may be scoped to. */ +export type ChatProject = { project_ref: string; title: string; grant: "workspace_read" }; export type ChatTodo = { todo_id: string | null; diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index d3215c14f7..3be0134b26 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -8,6 +8,7 @@ import { todoApplyResultMatchesRequest, todoPreviewMatchesRequest, type AgentResponse, + type ChatProject, type CollaborationReadback, type LoopXModeSettings, type TodoApplyResult, @@ -641,16 +642,42 @@ export async function recordProjectionExchange(options: { ); } +/** The App's conversation targets; each owns exactly one Core Chat channel. */ +export type ConversationContext = + | { kind: "manager" } + | { kind: "goal"; goalId: string } + | { kind: "project"; projectRef: string }; + +// Goal ids are single path segments, so a key containing "/" never names a Goal. +const PROJECT_CONVERSATION_KEY_PREFIX = "project/"; + +export function conversationContextKey(context: ConversationContext): string { + if (context.kind === "manager") return "manager"; + return context.kind === "goal" ? context.goalId : `${PROJECT_CONVERSATION_KEY_PREFIX}${context.projectRef}`; +} + +export function conversationContextOfKey(key: string): ConversationContext { + if (key === "manager") return { kind: "manager" }; + if (key.startsWith(PROJECT_CONVERSATION_KEY_PREFIX)) { + return { kind: "project", projectRef: key.slice(PROJECT_CONVERSATION_KEY_PREFIX.length) }; + } + return { kind: "goal", goalId: key }; +} + +export function conversationChannelId(context: ConversationContext): string { + if (context.kind === "manager") return "manager"; + return context.kind === "goal" ? `goal.${context.goalId}` : `project.${context.projectRef}`; +} + export async function createChatSession( - goalId: string, + context: ConversationContext, agentId?: string, mode: "resume_latest" | "new" = "resume_latest", - contextKind: "goal" | "manager" = "goal", signal?: AbortSignal, ) { return requestJson<{ agent_id: string; - goal_id: string; + goal_id: string | null; ok: true; resumed: boolean; session_id: string; @@ -660,7 +687,10 @@ export async function createChatSession( // An omitted ``agent_id`` means "no explicit executor pick": the channel // owner resolves its own default. Sending this client's own default would // silently re-point the steward channel away from its configured executor. - body: JSON.stringify({ goal_id: goalId, agent_id: agentId, mode, context_kind: contextKind }), + // A project Session carries only the host-issued reference, never a Goal. + body: JSON.stringify(context.kind === "project" + ? { context_kind: "project", project_ref: context.projectRef, agent_id: agentId, mode } + : { goal_id: context.kind === "goal" ? context.goalId : "", agent_id: agentId, mode, context_kind: context.kind }), }); } @@ -674,7 +704,8 @@ export type ChatStreamEvent = { export type ChatSessionSummary = { session_id: string; - goal_id: string; + goal_id: string | null; + project_ref?: string | null; agent_id: string; adapter_kind: string; channel_id?: string; @@ -692,6 +723,12 @@ export type ChatSessionSummary = { manager_runtime?: ManagerRuntimeSessionReadback | null; }; +export type { ChatProject }; + +export async function fetchChatProjects(signal?: AbortSignal) { + return requestJson<{ok: true; projects: ChatProject[]}>("/api/chat/projects", {signal}); +} + /** ``chat_store`` Session modes; an omitted mode is a managed runtime Session. */ export type ChatSessionMode = "managed_runtime" | "attached_host"; diff --git a/apps/presentation/dashboard/src/data/use-chat-projects.ts b/apps/presentation/dashboard/src/data/use-chat-projects.ts new file mode 100644 index 0000000000..b84f290016 --- /dev/null +++ b/apps/presentation/dashboard/src/data/use-chat-projects.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchChatProjects, type ChatProject } from "./chat"; + +/** Workspaces the host currently grants to ordinary conversations. The + * session re-checks its grant before every Turn; this list only offers scopes. */ +export function useChatProjects(readOnly: boolean): { projects: ChatProject[] | null; readFailed: boolean } { + const query = useQuery({ + queryKey: ["chat-projects"], + queryFn: ({ signal }) => fetchChatProjects(AbortSignal.any([signal, AbortSignal.timeout(10_000)])), + enabled: !readOnly, + refetchOnWindowFocus: "always", + retry: false, + }); + if (readOnly) return { projects: [], readFailed: false }; + return { projects: query.data?.projects ?? null, readFailed: query.isError }; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx index db2ad59bf6..37c71ec251 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -1,4 +1,4 @@ -import { Bot, Eye, Menu, RefreshCw, SlidersHorizontal } from "lucide-react"; +import { Bot, Eye, FolderOpen, Menu, RefreshCw, SlidersHorizontal } from "lucide-react"; import { GoalActivityChip } from "./goal-activity-view"; import { localizedGoalState, useWorkspaceI18n } from "./i18n"; @@ -8,6 +8,7 @@ import { WorkspaceSelect } from "./workspace-select"; export function ChannelHeader({ agents, + conversationScope, managerChannelBinding, managerChatOpen, managerRuntime, @@ -25,8 +26,13 @@ export function ChannelHeader({ selectedAgentId, selectedGoal, selectedGoalTab, + workspaceGrantLabel, }: { agents: WorkspaceAgentOption[]; + /** Steward conversation scope; absent when the host grants no workspace. */ + conversationScope?: { options: { disabled?: boolean; label: string; value: string }[]; value: string; onChange: (value: string) => void } | null; + /** Set while the steward conversation is scoped to a workspace. */ + workspaceGrantLabel?: string | null; managerChannelBinding?: ManagerChannelBinding | null; managerChatOpen?: boolean; managerRuntime?: ManagerRuntimeSessionReadback | null; @@ -144,13 +150,29 @@ export function ChannelHeader({ {onOpenManagerSettings ? : null} : null; + // The steward channel binding describes the steward executor; a workspace + // conversation runs on the selected runtime instead. + const stewardBindingVisible = !selectedGoal && !workspaceGrantLabel; + const scopeControl = !selectedGoal && managerChatOpen && conversationScope && !readOnlySourceLabel ? ( + } + onChange={conversationScope.onChange} + options={conversationScope.options} + prefixLabel={t("header.conversationScope")} + value={conversationScope.value} + /> + ) : null; + return ( -
+

{selectedGoal?.title ?? t("header.manager")}

{selectedGoal && !selectedGoal.loadState ?

: null} - {!selectedGoal && managerChannelBinding ? ( + {workspaceGrantLabel ?

{t("workspace.conversation", { grant: workspaceGrantLabel })}

: null} + {stewardBindingVisible && managerChannelBinding ? (

{onOpenManagerSettings ? @@ -165,7 +187,7 @@ export function ChannelHeader({ ) : null}

) : null} - {!selectedGoal && (managerRuntime || managerExecutionDefaultReason) ?
{locale === "zh-CN" ? "运行环境" : "Execution environment"} + {stewardBindingVisible && (managerRuntime || managerExecutionDefaultReason) ?
{locale === "zh-CN" ? "运行环境" : "Execution environment"} {!selectedGoal && managerRuntime ? (

{managerRuntime.status === "ready" ? t("header.managerRuntime", { @@ -211,6 +233,7 @@ export function ChannelHeader({ {selectedGoal && onOpenGoalCapabilities ? ( ) : null} + {scopeControl} {!selectedGoal ? runtimeControl : null} {onRefresh ? ( diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx index 7ed37c80bf..e546b5a9eb 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx @@ -29,6 +29,7 @@ function answerLink(sessionId: string, messageId: string) { export function ChannelTimeline({ + emptyState, items, onSelect, selectedGoal, @@ -40,6 +41,7 @@ export function ChannelTimeline({ onSteerTurn, onCancelPreparation, }: { + emptyState?: { title: string; description: string }; items: WorkspaceTimelineItem[]; onCancelPreparation?: () => void; onSelect: (selection: WorkspaceDrawerSelection) => void; @@ -56,8 +58,8 @@ export function ChannelTimeline({ return (

- {selectedGoal ? t("timeline.emptyGoal") : t("timeline.emptyWorkspace")} -

{selectedGoal ? t("timeline.emptyGoalDescription") : t("timeline.emptyWorkspaceDescription")}

+ {emptyState?.title ?? (selectedGoal ? t("timeline.emptyGoal") : t("timeline.emptyWorkspace"))} +

{emptyState?.description ?? (selectedGoal ? t("timeline.emptyGoalDescription") : t("timeline.emptyWorkspaceDescription"))}

); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts index 5fa39006ca..777c0c143b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-activity.ts @@ -8,7 +8,7 @@ export type WorkspaceGoalExecution = | { kind: "unknown" }; export type GoalSessionFact = { - goal_id: string; + goal_id: string | null; agent_id: string; active_turn_id: string | null; host_surface?: string | null; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 1f0ecc3a84..3f7eaec907 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1150,6 +1150,15 @@ const en = { "sidebar.delete": "Delete", "sidebar.deleteGoal": "Delete Goal", "sidebar.manager": "LoopX Manager", + "header.conversationScope": "Scope", + "header.scopeWorkspacesUnavailable": "Workspaces could not be read", + "workspace.conversation": "Workspace conversation · {grant}", + "workspace.grantRead": "read-only", + "workspace.grantRevoked": "no longer authorized", + "workspace.unknownTitle": "Unavailable workspace", + "workspace.unavailable": "This host no longer authorizes this workspace. The history is kept; new messages will be rejected until it is authorized again.", + "workspace.empty": "Talk about this workspace with continuous context. An ordinary conversation does not create a Goal or include other workspaces.", + "workspace.placeholder": "Ask about {workspace}…", "sidebar.notifications": "Settings", "sidebar.owner": "Personal workspace", "sidebar.product": "Personal Agent workspace", @@ -2430,6 +2439,15 @@ const zhCN: Record = { "sidebar.delete": "删除", "sidebar.deleteGoal": "删除 Goal", "sidebar.manager": "LoopX 管家", + "header.conversationScope": "范围", + "header.scopeWorkspacesUnavailable": "暂时无法读取工作区", + "workspace.conversation": "工作区对话 · {grant}", + "workspace.grantRead": "只读", + "workspace.grantRevoked": "已不再授权", + "workspace.unknownTitle": "不可用的工作区", + "workspace.unavailable": "此宿主已不再授权这个工作区。历史记录会保留;重新授权前,新消息会被拒绝。", + "workspace.empty": "围绕这个工作区对话,保留连续上下文。普通对话不会创建 Goal,也不会带入其他工作区。", + "workspace.placeholder": "询问 {workspace}…", "sidebar.notifications": "设置", "sidebar.owner": "个人工作区", "sidebar.product": "个人 Agent 工作区", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index a9f1ead2b4..a2d89d2dd4 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -293,7 +293,7 @@ assert.doesNotMatch(page, /personal-worker-strip/, "Manager home omits the redun assert.doesNotMatch(header, /切换到野兽主题|切换到默认主题/, "Workspace header does not expose theme switching"); assert.match(workspaceTheme, /workspaceThemeStorageKey = "loopx-pw-theme"/, "Theme preference persists across reloads"); assert.doesNotMatch(dashboard, /isManagerProjectionQuestion/, "Ordinary manager questions do not silently bypass the selected model by matching phrases"); -assert.match(dashboard, /if \(selectedRoute\.agentId === "status-only" \|\| \(!targetGoal && targetContextId !== "manager"\)\)/, "Projection answers require the explicit status-only route or a missing Goal fallback"); +assert.match(dashboard, /if \(\(selectedRoute\.agentId === "status-only" && targetContext\.kind !== "project"\)\s*\|\| \(targetContext\.kind === "goal" && !targetGoal\)\)/, "Projection answers require the explicit status-only route or a missing Goal fallback; a workspace conversation never answers from the Goal projection"); assert.match(drawer, /role="group" aria-label=\{t\("drawer\.decisionGroup"\)\}/, "Blocked items expose their decisions as one labelled group that previews before any write"); assert.match(drawer, /const hasProjectedRunActivity = selection\.kind === "run"[\s\S]*selection\.item\.completedSteps > 0/, "Session empty-state copy distinguishes projected progress from a truly idle run"); assert.match(drawer, /t\("drawer\.runRecordProjected"/, "A projected run does not claim that the Agent never started"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 03f703fd50..a440cb7ed6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -1,6 +1,6 @@ import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js"; import type { TurnStep } from "../../data/turn-steps"; -import type { CollaborationReadback, LoopXModeSettings } from "../../data/chat-model"; +import type { ChatProject, CollaborationReadback, LoopXModeSettings } from "../../data/chat-model"; import type { TeamPlanAppliedOutcome } from "./team-plan-preview"; import type { ActionReviewPlan } from "../../../../../../loopx/control_plane/presentation/action_review_plan.js"; import type { GoalAcceptanceObservation } from "../../data/goal-acceptance-observation"; @@ -83,6 +83,16 @@ export function workspaceAgentTodoFromItem(todo: Pick void; +}; + export type WorkspaceTodo = WorkspaceAgentTodo & { goalId: string; goalTitle: string; @@ -514,7 +524,7 @@ export type PersonalWorkspaceCallbacks = { onSendMessage?: ( message: string, agentId: string, - goalId: string | null, + contextId: string, attachments?: WorkspaceImageAttachment[], ) => void | WorkspaceSendPreviews | Promise; onPrepareLoopX?: (agentId: string, goalId: string) => Promise; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 7cd0d09edc..bf97621982 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -19,6 +19,7 @@ import { cancelTypedAction, ChatApiError, configureGoalChannelAutoNotify, + conversationContextKey, fetchGoalContexts, fetchGoalChannelTargets, fetchLarkConnections, @@ -64,6 +65,7 @@ import type { WorkspaceModel, WorkspaceRun, WorkspaceSystemHealth, + WorkspaceConversationDirectory, WorkspaceTimelineItem, WorkspaceTodo, } from "./personal-workspace-model"; @@ -758,6 +760,8 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) const acceptedImageTypes = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); const maxImageAttachmentBytes = 5 * 1024 * 1024; const maxImageAttachmentCount = 4; +// Project refs are alphanumeric, so this value can never name a workspace. +const stewardScopeValue = "@steward"; const maxImageAttachmentTotalBytes = 12 * 1024 * 1024; function readImageAttachment(file: File, t: WorkspaceTranslate): Promise { @@ -793,7 +797,9 @@ export function PersonalWorkspacePage({ selectedGoalId: controlledGoalId, statusSourceControl, serviceNotice, + workspaceConversations, }: { + workspaceConversations?: WorkspaceConversationDirectory; /** The bound Session's mode queues a message sent while its Turn runs. */ conversationQueuesFollowUps?: boolean; /** The bound managed executor offers native exact-turn steering. */ @@ -826,7 +832,12 @@ export function PersonalWorkspacePage({ const [proposals, setProposals] = useState>({}); const [localView, setLocalView] = useState(controlledGoalId ? "chat" : "overview"); const selectedGoalTab = controlledView ?? localView; - const managerChatOpen = selectedGoalTab === "chat"; + const selectedWorkspaceRef = workspaceConversations?.selectedRef ?? null; + const selectedWorkspaceProject = workspaceConversations?.projects?.find((project) => project.project_ref === selectedWorkspaceRef) ?? null; + // A workspace scope exists only inside the steward conversation tab. + const managerChatOpen = selectedWorkspaceRef !== null || selectedGoalTab === "chat"; + const workspaceUnavailable = selectedWorkspaceRef !== null && workspaceConversations?.projects != null && !selectedWorkspaceProject; + const workspaceTitle = selectedWorkspaceRef ? selectedWorkspaceProject?.title ?? t("workspace.unknownTitle") : null; function setSelectedGoalTab(view: WorkspaceGoalTab) { setLocalView(view); callbacks.onSelectView?.(view); @@ -864,7 +875,10 @@ export function PersonalWorkspacePage({ const selectedGoalId = controlledGoalId === undefined ? localGoalId : controlledGoalId; const actionReadback = useTypedActionReadback(readOnly, selectedGoalId); const selectedAgentId = controlledAgentId ?? localAgentId; - const composerDraftKey = `${selectedGoalId ?? "manager"}:${selectedAgentId}`; + const conversationKey = selectedWorkspaceRef + ? conversationContextKey({ kind: "project", projectRef: selectedWorkspaceRef }) + : selectedGoalId ?? "manager"; + const composerDraftKey = `${conversationKey}:${selectedAgentId}`; const { composer, setComposer, restoreFailedSubmission, sending, setSending, steering, setSteering, actionFeedback, setActionFeedback, imageAttachments, setImageAttachments, imageAttachmentError, setImageAttachmentError, @@ -1066,12 +1080,12 @@ export function PersonalWorkspacePage({ useEffect(() => { followConversationRef.current = true; setShowLatestMessage(false); - }, [selectedGoalId, selectedAgentId, conversationOpen]); + }, [conversationKey, selectedAgentId, conversationOpen]); useEffect(() => { if (!conversationOpen || !followConversationRef.current) return; const frame = window.requestAnimationFrame(scrollToLatestMessage); return () => window.cancelAnimationFrame(frame); - }, [conversationOpen, selectedGoalId, selectedAgentId, conversationMessages.length, + }, [conversationOpen, conversationKey, selectedAgentId, conversationMessages.length, latestMessageTextLength, latestMessage?.pending, latestMessage?.activity?.length, latestMessage?.steps]); const drawerSelection = useMemo | null>(() => { if (selection?.kind === "settings") return null; @@ -1672,7 +1686,7 @@ export function PersonalWorkspacePage({ setActionFeedback(null); setImageAttachmentError(null); try { - await callbacks.onSteerConversationTurn(selectedGoalId ?? "manager", request.turnId, message, request.id); + await callbacks.onSteerConversationTurn(conversationKey, request.turnId, message, request.id); retireSteeringRequest(composerDraftKey, request.id); if (!messageOverride) setComposer("", composer); setActionFeedback(locale === "zh-CN" ? "执行器已接收本轮追加指令。" : "The executor accepted instructions for this turn."); @@ -1713,7 +1727,7 @@ export function PersonalWorkspacePage({ try { if (!selectedGoalId) setManagerConversationReceiptVisible(true); else if (selectedGoalTab !== "chat") setGoalConversationReceiptVisible(true); - const previews = await callbacks.onSendMessage?.(message, selectedAgentId, selectedGoalId, pendingImages.length ? pendingImages : undefined); + const previews = await callbacks.onSendMessage?.(message, selectedAgentId, conversationKey, pendingImages.length ? pendingImages : undefined); if (previews?.candidates?.length) { const drafted = await Promise.allSettled(previews.candidates.map((request) => createPreview(request, { select: false }))); if (drafted.some((result) => result.status === "rejected")) setActionFeedback(t("feedback.proposalDraftFailed")); @@ -1850,6 +1864,20 @@ export function PersonalWorkspacePage({
{ + setActiveSessionRun(null); + workspaceConversations.onSelect(value === stewardScopeValue ? null : value); + }, + options: [ + { label: t("header.manager"), value: stewardScopeValue }, + ...(workspaceConversations.projects ?? []).map((project) => ({ label: project.title, value: project.project_ref })), + ...(selectedWorkspaceRef && !selectedWorkspaceProject ? [{ disabled: true, label: workspaceTitle!, value: selectedWorkspaceRef }] : []), + ...(workspaceConversations.readFailed ? [{ disabled: true, label: t("header.scopeWorkspacesUnavailable"), value: "workspaces-unavailable" }] : []), + ], + value: selectedWorkspaceRef ?? stewardScopeValue, + } : null} + workspaceGrantLabel={selectedWorkspaceRef ? t(workspaceUnavailable ? "workspace.grantRevoked" : "workspace.grantRead") : null} managerChatOpen={managerChatOpen} managerChannelBinding={managerChannelBinding} managerRuntime={managerRuntime} @@ -1868,6 +1896,7 @@ export function PersonalWorkspacePage({ }} onSelectAgent={selectAgent} onReturnManagerHome={() => { + if (selectedWorkspaceRef) workspaceConversations?.onSelect(null); setSelectedGoalTab("overview"); setManagerConversationReceiptVisible(false); window.requestAnimationFrame(() => channelScrollRef.current?.scrollTo({ behavior: "smooth", top: 0 })); @@ -1973,14 +2002,16 @@ export function PersonalWorkspacePage({ operations={actionReadback.isError ? [] : homeOperations} onSelectOperation={proposal => setSelection({kind: "proposal", item: proposal})} onViewAllOperations={() => setSelectedGoalTab("chat")} /> ) : ( - item.kind === "message") : managerChatItems} + onSelect={setSelection} selectedGoal={null} showManagerTeamResults={!selectedWorkspaceRef} onSteerTurn={!readOnly && callbacks.onSteerConversationTurn - ? (turnId, text, ingressId) => callbacks.onSteerConversationTurn!("manager", turnId, text, ingressId) + ? (turnId, text, ingressId) => callbacks.onSteerConversationTurn!(conversationKey, turnId, text, ingressId) : undefined} onCancelPreparation={!readOnly && callbacks.onCancelConversationPreparation - ? () => callbacks.onCancelConversationPreparation!("manager") : undefined} + ? () => callbacks.onCancelConversationPreparation!(conversationKey) : undefined} onInterruptTurn={!readOnly && callbacks.onInterruptConversationTurn - ? (turnId) => callbacks.onInterruptConversationTurn!("manager", turnId) + ? (turnId) => callbacks.onInterruptConversationTurn!(conversationKey, turnId) : undefined} onOpenGoalEvidence={(goalId) => { selectGoal(goalId, "chat"); }} /> )} @@ -1990,6 +2021,9 @@ export function PersonalWorkspacePage({ {t("proposal.readbackUnavailable")}
: null} + {workspaceUnavailable ?
+ {t("workspace.unavailable")} +
: null} {conversationHistoryState && conversationHistoryState.phase !== "ready" ? (
{t(`history.${conversationHistoryState.phase}`)} @@ -2042,7 +2076,7 @@ export function PersonalWorkspacePage({ {conversationOpen && showLatestMessage ? : null} - {(!conversationOpen || !conversationMessages.length) ?
+ {!selectedWorkspaceRef && (!conversationOpen || !conversationMessages.length) ?
{locale === "zh-CN" ? "快捷提问" : "Suggestions"} {selectedGoal ? (
@@ -2106,12 +2140,12 @@ export function PersonalWorkspacePage({ } }} onPaste={handleComposerPaste} - placeholder={sending ? (locale === "zh-CN" ? "可以先写下后续问题…" : "Draft your next message…") : selectedGoal ? t("composer.goalPlaceholder", { goal: selectedGoal.title }) : t("composer.managerPlaceholder")} + placeholder={sending ? (locale === "zh-CN" ? "可以先写下后续问题…" : "Draft your next message…") : selectedGoal ? t("composer.goalPlaceholder", { goal: selectedGoal.title }) : workspaceTitle ? t("workspace.placeholder", { workspace: workspaceTitle }) : t("composer.managerPlaceholder")} ref={composerRef} rows={1} value={composer} /> - +
{conversationOpen ?
{steering ? (locale === "zh-CN" ? "正在发送本轮追加指令…" : "Sending instructions for this turn…") diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 5127981732..7462b23bbc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -1404,6 +1404,9 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-channel-actions { min-width: 0; } .personal-agent-select { max-width: 132px; min-width: 0; } .personal-channel-actions > .personal-icon-button { display: none; } + /* Scope and executor pickers cannot share the title row on a phone. */ + .personal-channel-header:has(.personal-scope-select) .personal-channel-actions { grid-row: auto; grid-column: 1 / -1; order: 3; } + .personal-channel-header:has(.personal-scope-select) .personal-agent-select { flex: 1 1 0; max-width: none; } .personal-goal-tabs { grid-column: 1 / -1; order: 4; margin-left: 0; overflow-x: auto; align-self: auto; } .personal-channel-scroll, .personal-composer-wrap { padding-left: 14px; padding-right: 14px; } .personal-channel-scroll[data-active-goal-view="tasks"]:has(.personal-task-board) { overflow-y: auto; } diff --git a/apps/presentation/dashboard/src/router.tsx b/apps/presentation/dashboard/src/router.tsx index df64c85e1b..cf1101ee4e 100644 --- a/apps/presentation/dashboard/src/router.tsx +++ b/apps/presentation/dashboard/src/router.tsx @@ -16,6 +16,8 @@ import { AnswerReportPage } from "./features/personal-workspace/answer-report-pa const searchSchema = z.object({ goalId: z.string().optional().default(""), + // Scopes the steward conversation to a host-granted workspace; selecting a Goal clears it. + workspace: z.string().regex(/^[A-Za-z0-9]{1,80}$/).optional().catch(undefined), statusUrl: z.string().optional().default(""), view: z.enum(["conversation", "overview", "tasks", "files"]).optional(), reportSessionId: z.string().regex(/^[A-Za-z0-9._-]{1,160}$/).optional(), diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 2e8fd7679d..4d00cd8e9d 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -3,6 +3,7 @@ import { withTurnActivity, type TurnStep } from "../data/turn-steps"; import { conversationReturnSessions, conversationPendingReturnSessions, reconcileConversationHistory, reconcileConversationReturns } from "../data/conversation-returns"; import { readConversationReturns } from "../data/conversation-return-observation"; import { currentChannelSession, useConversationHistory } from "../data/use-conversation-history"; +import { useChatProjects } from "../data/use-chat-projects"; import {compactWorkspaceText as compactShareText, workspaceAgentTodoFromItem} from "../features/personal-workspace/personal-workspace-model"; import type { GoalAcceptanceObservation } from "../data/goal-acceptance-observation"; import { attentionDetails, attentionDetailsFromSnapshot, sourceAttention } from "../features/personal-workspace/attention-details"; @@ -41,7 +42,11 @@ import { applyGoalSubagentConfiguration, applyTypedAction, closeChatSession, + conversationChannelId, + conversationContextKey, + conversationContextOfKey, createChatSession, + type ConversationContext, updateLoopXMode, type LoopXModeSettings, fetchChatCapabilities, @@ -1208,6 +1213,7 @@ function PersonalGoalHome({ onGoalActivationStateChange, onGoalDeleted, onSelectGoal, + onSelectWorkspace, onReconcileStatus, onRefresh, onRetryGoalArchive, @@ -1215,6 +1221,7 @@ function PersonalGoalHome({ progress, rows, selectedGoalId, + selectedWorkspaceRef, statusSourceControl, theme, toggleTheme, @@ -1226,6 +1233,8 @@ function PersonalGoalHome({ onGoalActivationStateChange: (goalId: string, activationState: "active" | "stopped") => void; onGoalDeleted: (goalId: string) => void; onSelectGoal: (goalId: string, view?: WorkspaceGoalTab) => void; + onSelectWorkspace: (projectRef: string | null) => void; + selectedWorkspaceRef: string | null; onReconcileStatus: (options?: { invalidateGoalIds?: string[] }) => void | Promise; onRefresh: (scope?: WorkspaceReadScope) => void | Promise; onRetryGoalArchive: () => void | Promise; @@ -1287,14 +1296,19 @@ function PersonalGoalHome({ }, }; }, [payload, rows, progress, goalSubagentConfigurationEnabled, t]); - const selectedGoal = model.goals.find((goal) => goal.goalId === selectedGoalId) ?? null; + // A workspace conversation has no Goal, so no Goal projection may apply to it. + const selectedGoal = selectedWorkspaceRef ? null : model.goals.find((goal) => goal.goalId === selectedGoalId) ?? null; + const workspaceProjects = useChatProjects(readOnly); + const conversationContext: ConversationContext = selectedWorkspaceRef + ? { kind: "project", projectRef: selectedWorkspaceRef } + : selectedGoal ? { kind: "goal", goalId: selectedGoal.goalId } : { kind: "manager" }; const selectedPayload = progress?.snapshots[selectedGoalId] ?? payload; const [periodicReport, setPeriodicReport] = useState(null); const [periodicReportError, setPeriodicReportError] = useState(null); const [periodicReportLoading, setPeriodicReportLoading] = useState(false); const sessionDiscoveryKey = model.goals.some((goal) => goal.activationState === "active" && goal.loadState === "loading") ? "loading" : model.goals.map((goal) => `${goal.goalId}:${goal.agentId}`).join("|"); - const contextId = selectedGoal?.goalId ?? "manager"; + const contextId = conversationContextKey(conversationContext); const managerSummary = model.goals.some((goal) => goal.activationState === "active" && goal.loadState) ? "Goal 状态正在逐个更新,当前统计尚不完整。" : (model.systemHealth ? !model.systemHealth.ok : !payload.ok) @@ -1325,9 +1339,10 @@ function PersonalGoalHome({ label: "Codex", statusLabel: "正在检测", }]; + // The status projection answers about Goals; a workspace conversation needs an executor. const agentOptions = [ ...discoveredAgents, - { + ...(conversationContext.kind === "project" ? [] : [{ agentId: "status-only", available: true, capability: t("header.statusOnlyDescription"), @@ -1339,7 +1354,7 @@ function PersonalGoalHome({ streaming: false, toolCalls: false, trustScope: "read_only", - }, + }]), ]; const defaultAgentId = discoveredAgents.find((agent) => agent.label === "Codex" && agent.available)?.agentId ?? discoveredAgents.find((agent) => agent.available)?.agentId @@ -1403,9 +1418,9 @@ function PersonalGoalHome({ const managerQuickPrompts = ["我现在该做什么?", "哪些 Goal 在等我?", "Agent 在做什么?"]; const contextMessages = messagesByContext[contextId] ?? []; const conversationHistory = useConversationHistory({ - agentId: selectedGoal ? selectedAgent.agentId : undefined, + agentId: conversationContext.kind === "manager" ? undefined : selectedAgent.agentId, currentAgentId: selectedAgent.agentId, - channelId: selectedGoal ? `goal.${selectedGoal.goalId}` : "manager", + channelId: conversationChannelId(conversationContext), goalId: selectedGoal?.goalId, enabled: !readOnly && selectedAgent.available, }); @@ -1643,7 +1658,7 @@ function PersonalGoalHome({ const readHistory = conversationHistory.history; const targetContextId = contextId; const sessionKey = `${targetContextId}:${selectedAgent.agentId}`; - const contextKind = selectedGoal ? "goal" : "manager"; + const targetContext = conversationContextOfKey(targetContextId); let cancelled = false; let recoveryController: AbortController | null = null; let retireRecoveryObservation: (() => void) | undefined; @@ -1673,20 +1688,13 @@ function PersonalGoalHome({ }); return; } - const sessionGoalId = contextKind === "manager" ? "" : selectedGoal?.goalId ?? ""; - if (contextKind === "goal" && !sessionGoalId) return; // The steward channel owns its executor default; only a pick the owner // actually made for this context is sent. const sessionEndpoint = - contextKind === "manager" ? selectedAgents[targetContextId] : selectedAgent.agentId; - const created = await createChatSession( - sessionGoalId, - sessionEndpoint, - "resume_latest", - contextKind, - ); + targetContext.kind === "manager" ? selectedAgents[targetContextId] : selectedAgent.agentId; + const created = await createChatSession(targetContext, sessionEndpoint, "resume_latest"); if (cancelled) return; - if (contextKind === "manager" && created.session.manager_runtime) { + if (targetContext.kind === "manager" && created.session.manager_runtime) { setManagerRuntime(created.session.manager_runtime); } recordSessionAdmission(created.session); @@ -1787,7 +1795,7 @@ function PersonalGoalHome({ // returning must still find the card. Only the transcript update below // belongs to the mounted view, so this runs before the cancellation // guard that retires the pending reply. - const recoveryGoalId = targetContextId !== "manager" ? activeSnapshot?.session.goal_id : undefined; + const recoveryGoalId = targetContextId !== "manager" ? activeSnapshot?.session.goal_id ?? undefined : undefined; const proposalsProjected = await projectRecoveredTurnProposals(targetContextId, recoveryGoalId, streamed.turnId, streamed.response.proposals, streamingMessageId); if (cancelled) return; updateConversationMessage(targetContextId, streamingMessageId, { @@ -2174,7 +2182,7 @@ function PersonalGoalHome({ const key = `${goalId}:${agentId}`; const existing = sessionIds.current.get(key); if (existing) return existing; - const session = await createChatSession(goalId, agentId, newSessionRequired.current.has(key) ? "new" : "resume_latest", "goal", signal); + const session = await createChatSession({ kind: "goal", goalId }, agentId, newSessionRequired.current.has(key) ? "new" : "resume_latest", signal); recordSessionAdmission(session.session); sessionIds.current.set(key, session.session_id); newSessionRequired.current.delete(key); @@ -2182,17 +2190,16 @@ function PersonalGoalHome({ return session.session_id; } - async function sendManagerQuestion(rawQuestion: string, route?: { agentId?: string; goalId?: string | null; attachments?: WorkspaceImageAttachment[]; loopxMode?: {operation: "start" | "resume"; settings?: LoopXModeSettings} }) { + async function sendManagerQuestion(rawQuestion: string, route?: { agentId?: string; contextId?: string; attachments?: WorkspaceImageAttachment[]; loopxMode?: {operation: "start" | "resume"; settings?: LoopXModeSettings} }) { const question = rawQuestion.trim(); if (!question) { return; } - const targetContextId = route && "goalId" in route - ? route.goalId ?? "manager" - : contextId; - const targetGoal = targetContextId === "manager" - ? null - : model.goals.find((goal) => goal.goalId === targetContextId) ?? null; + const targetContextId = route?.contextId ?? contextId; + const targetContext = conversationContextOfKey(targetContextId); + const targetGoal = targetContext.kind === "goal" + ? model.goals.find((goal) => goal.goalId === targetContext.goalId) ?? null + : null; const selectedRoute = route?.agentId ? selectAvailableChatAgent(agentOptions, route.agentId, defaultAgentId) : selectedAgent; @@ -2219,7 +2226,8 @@ function PersonalGoalHome({ setManagerInput(""); setSendingContextId(targetContextId); - if (selectedRoute.agentId === "status-only" || (!targetGoal && targetContextId !== "manager")) { + if ((selectedRoute.agentId === "status-only" && targetContext.kind !== "project") + || (targetContext.kind === "goal" && !targetGoal)) { const answer = personalManagerSnapshot(targetQuestionModel); const usesStatusOnlyRoute = selectedRoute.agentId === "status-only"; const answerMessageId = appendManagerAssistantMessage(targetContextId, { @@ -2261,18 +2269,14 @@ function PersonalGoalHome({ let handedOff = false; let streamedText = ""; try { - let sessionId = targetContextId === "manager" ? sessionIds.current.get(sessionKey) : await prepareGoalConversation(targetContextId, selectedRoute.agentId, preparationController.signal); + let sessionId = targetContext.kind === "goal" + ? await prepareGoalConversation(targetContext.goalId, selectedRoute.agentId, preparationController.signal) + : sessionIds.current.get(sessionKey); if (!sessionId) { const mode = newSessionRequired.current.has(sessionKey) ? "new" : "resume_latest"; const sessionEndpoint = - targetContextId === "manager" ? selectedAgents[targetContextId] : selectedRoute.agentId; - const session = await createChatSession( - targetContextId === "manager" ? "" : targetGoal!.goalId, - sessionEndpoint, - mode, - targetContextId === "manager" ? "manager" : "goal", - preparationController.signal, - ); + targetContext.kind === "manager" ? selectedAgents[targetContextId] : selectedRoute.agentId; + const session = await createChatSession(targetContext, sessionEndpoint, mode, preparationController.signal); if (targetContextId === "manager" && session.session.manager_runtime) { setManagerRuntime(session.session.manager_runtime); } @@ -2373,7 +2377,7 @@ function PersonalGoalHome({ lines: ["请进入要修改的 Goal,预览并确认具体变更。"], }); } - const decision = targetContextId !== "manager" && response.protected_action + const decision = targetContext.kind === "goal" && response.protected_action ? semanticProtectedActionPreview(targetContextId, question, response.protected_action) ?? undefined : undefined; const candidates = targetGoal @@ -2636,7 +2640,7 @@ function PersonalGoalHome({ }; const workspaceTimeline: WorkspaceTimelineItem[] = [ - ...(!selectedGoal && runtimeBindings.manager?.status === "resume_failed" ? [{ + ...(conversationContext.kind === "manager" && runtimeBindings.manager?.status === "resume_failed" ? [{ id: "run:manager:resume-failed", kind: "run" as const, run: { @@ -2812,6 +2816,7 @@ function PersonalGoalHome({
({ adapterKind: agent.adapterKind, agentId: agent.agentId, @@ -3057,9 +3062,9 @@ function PersonalGoalHome({ onSelectAgent: chooseAgent, onSelectGoal: (goalId, view) => { onSelectGoal(goalId ?? "", view); setMobilePanel("chat"); }, onSelectView, - onSendMessage: async (message, agentId, goalId, attachments) => sendManagerQuestion(message, { agentId, goalId, attachments }), + onSendMessage: async (message, agentId, targetContextId, attachments) => sendManagerQuestion(message, { agentId, contextId: targetContextId, attachments }), onPrepareLoopX: (agentId, goalId) => prepareGoalConversation(goalId, agentId), - onStartLoopX: (operation, agentId, goalId, settings) => { void sendManagerQuestion(operation === "start" ? "开启 LoopX 模式,持续推进当前 Goal。" : "恢复 LoopX 模式。", {agentId, goalId, loopxMode: {operation, settings}}); }, + onStartLoopX: (operation, agentId, goalId, settings) => { void sendManagerQuestion(operation === "start" ? "开启 LoopX 模式,持续推进当前 Goal。" : "恢复 LoopX 模式。", {agentId, contextId: goalId, loopxMode: {operation, settings}}); }, onStartNewRunSession: startNewManagerSession, }} goalArchiveLoadState={goalArchiveLoadState} @@ -3529,11 +3534,16 @@ export function DashboardPage() { search: (current) => ({ ...current, goalId, + workspace: undefined, view: view === "chat" ? "conversation" : view, }), }); } + function selectWorkspace(projectRef: string | null) { + void navigate({ search: (current) => ({ ...current, goalId: "", workspace: projectRef ?? undefined, view: "conversation" }) }); + } + if (statusRequestActive) { return ( loadFromUrl( source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), { background: true, invalidateGoalIds: options?.invalidateGoalIds, readScope: "missing" }, @@ -3580,6 +3591,7 @@ export function DashboardPage() { progress={progress} rows={goalRows} selectedGoalId={search.goalId} + selectedWorkspaceRef={search.workspace ?? null} statusSourceControl={statusSourceControl} theme={theme} toggleTheme={() => setTheme(theme === "dark" ? "light" : "dark")} 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 4686a4414b..e1b1404554 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -10,6 +10,32 @@ - Evaluation: [steward golden queries](../../product/use-cases/steward/golden-queries.md). - Language: [Chinese semantic mirror](app-conversation-and-async-inbox-v0.zh-CN.md). +## Ordinary workspace conversations: bounded implementation checkpoint + +The Core Chat entry can now open an ordinary workspace Session independently +of a Goal or the steward's portfolio. In the App, a workspace is a scope of the +steward conversation: its Scope picker lists the host's granted workspaces, and +choosing one continues that workspace's own Session through the same composer, +history, streaming, stop and image path as every other conversation. The scope +never appears on the steward overview or the Goal list, and returning to the +steward scope restores the steward Session. The shared typed context owner +checks the exact workspace reference and current grant; missing roots, retargeted +symlinks and changed grants fail closed. No Goal is synthesized, no portfolio +context is injected, and the workspace grant cannot authorize peer delegation. + +This extends the existing conversation-scope owner with `project_workspace` and +an exact host-observation contract, rather than introducing provider-local Session +authority. Python owns filesystem observations and the existing durable Chat store; +the TypeScript owner decides context identity and scope. Native Codex resume retains +the original upstream thread and workspace. HTTP/protocol fixtures qualify that +continuity and denial behavior; they do not establish real model adoption. + +The initial grant is workspace reading for the local owner. Lark audience grants, +ordinary private-message selection, durable inbound admission independent of +terminal delivery, and installed/mobile acceptance remain open work in this RFC. +The App scope does not qualify those journeys or authorize edits; a revoked grant +keeps the history readable and blocks new messages until the host grants it again. + ## Decision: make the App the place where work conversations continue Users should be able to say “接着做,结果给我” / “Keep going and bring me the result” 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 90cf619289..eaa237aef5 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 @@ -10,6 +10,26 @@ - 评估:[steward 黄金查询](../../product/use-cases/steward/golden-queries.md)。 - 语言:[英文语义镜像](app-conversation-and-async-inbox-v0.md)。 +## 普通工作区会话:有界实现检查点 + +Core Chat 可以独立于 Goal 和管家 portfolio 打开普通工作区 Session。在 App 中, +工作区是管家对话的一个范围:“范围”选择器列出宿主授权的工作区,选中后继续该 +工作区自己的 Session,输入框、历史、流式输出、停止和图片与其他对话共用同一路径。 +范围不会出现在管家总览或 Goal 列表中;切回管家范围即恢复管家 Session。 +共享 typed context owner 核验确切工作区引用和当前 grant;目录缺失、symlink +重定向或 grant 变化时拒绝继续。不会合成 Goal、注入 portfolio,也不凭工作区 +grant 授权 peer delegation。 + +本次扩展现有 conversation-scope owner 的 `project_workspace` 分类及宿主观测合同, +不增加 provider 自有 Session authority。Python 负责文件系统观测和既有 durable Chat +store,TypeScript 负责上下文身份及范围。原生 Codex 恢复保留原 upstream thread +和工作区;HTTP/协议 fixture 验证连续性与拒绝行为,不证明真实模型采用了上下文。 + +首个 grant 仅面向本机 owner 的工作区读取。Lark 受众授权、普通私聊选择、独立于 +terminal delivery 的 durable 入站 admission,以及安装/手机验收仍是本 RFC 的未完成项。 +App 范围入口不代表这些旅程已通过,也不授权修改文件;grant 撤销后历史仍可读, +新消息在宿主重新授权前被阻止。 + ## 决策:让 App 成为工作会话持续进行的地方 用户应能在 LoopX 中说“接着做,结果给我” / “Keep going and bring me the result”, diff --git a/docs/assets/personal-workspace/ordinary-workspace-conversation-narrow.png b/docs/assets/personal-workspace/ordinary-workspace-conversation-narrow.png new file mode 100644 index 0000000000..31b468edde Binary files /dev/null and b/docs/assets/personal-workspace/ordinary-workspace-conversation-narrow.png differ diff --git a/docs/assets/personal-workspace/ordinary-workspace-conversation.png b/docs/assets/personal-workspace/ordinary-workspace-conversation.png new file mode 100644 index 0000000000..2dbc91ee58 Binary files /dev/null and b/docs/assets/personal-workspace/ordinary-workspace-conversation.png differ diff --git a/docs/assets/personal-workspace/ordinary-workspace-grant-rejection.png b/docs/assets/personal-workspace/ordinary-workspace-grant-rejection.png new file mode 100644 index 0000000000..c4911c3714 Binary files /dev/null and b/docs/assets/personal-workspace/ordinary-workspace-grant-rejection.png differ diff --git a/loopx/capabilities/native_chat/__init__.py b/loopx/capabilities/native_chat/__init__.py new file mode 100644 index 0000000000..effe12f939 --- /dev/null +++ b/loopx/capabilities/native_chat/__init__.py @@ -0,0 +1 @@ +"""Native Chat context observation and IO companions of the typed Core.""" diff --git a/loopx/capabilities/native_chat/project_context.py b/loopx/capabilities/native_chat/project_context.py new file mode 100644 index 0000000000..83dfed5fbe --- /dev/null +++ b/loopx/capabilities/native_chat/project_context.py @@ -0,0 +1,60 @@ +"""Filesystem observations for the shared project conversation owner. + +Only explicitly configured Chat workspace roots are eligible. This is neither +a Goal registry nor a transport-owned Session authority. Each use observes the +roots again, so a missing root or retargeted symlink cannot retain a Session's grant. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Any + +from ...control_plane.effect_runtime import EffectRuntimeRejected, effect_runtime_result + + +PROJECT_CONVERSATION_OBJECTIVE = ( + "Have an ordinary conversation about the selected workspace. Preserve this " + "Session's context. The workspace grant permits reading only; it does not " + "authorize edits, a LoopX Goal, scheduling, delegation or portfolio discovery. " + "Do not create an implicit Goal or borrow the global manager identity." +) + + +class ChatProjectContexts: + def __init__(self, roots: list[Path]) -> None: + # Remember the owner's spelling as well as its initial canonical target. + # A later symlink retarget must not redirect an accepted Session. + self.roots = [(root.expanduser().absolute(), root.expanduser().resolve()) for root in roots] + + def available(self) -> list[dict[str, str]]: + contexts = {} + for declared, canonical in self.roots: + if not declared.is_dir() or declared.resolve() != canonical: + continue + ref = hashlib.sha256(str(canonical).encode("utf-8")).hexdigest()[:24] + contexts[ref] = {"kind": "project_workspace", "project_ref": ref, + "workspace_path": str(canonical), "audience": "local_owner", + "grant": "workspace_read"} + return list(contexts.values()) + + def resolve(self, project_ref: str, *, session_context: dict[str, Any] | None = None) -> dict[str, Any]: + try: + return effect_runtime_result("collaboration.project.context", { + "project_ref": project_ref, "available": self.available(), + **({"session_context": session_context} if session_context is not None else {}), + }) + except EffectRuntimeRejected as exc: + raise ValueError(str(exc)) from exc + + def session_context(self, session: dict[str, Any]) -> dict[str, Any]: + saved = session.get("project_context") + if not isinstance(saved, dict) or session.get("goal_id") is not None: + raise ValueError("invalid ordinary project Session") + selected = self.resolve(str(saved.get("project_ref") or ""), session_context=saved) + if session.get("channel_id") != selected["channel_id"]: + raise ValueError("project conversation channel mismatch") + return {"project": Path(selected["context"]["workspace_path"]), + "objective": PROJECT_CONVERSATION_OBJECTIVE, + "title": Path(selected["context"]["workspace_path"]).name} diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index 917af87fb0..096d33578c 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -464,7 +464,7 @@ def start( *, codex_bin: str, work_dir: Path, - goal_id: str, + goal_id: str | None, objective: str, response_timeout_sec: float = 30.0, idle_timeout_sec: float = 180.0, @@ -565,7 +565,7 @@ def start( messages=messages, thread_id="", work_dir=root, - context_summary=f"{goal_id}: {objective}".strip(), + context_summary=f"{goal_id}: {objective}".strip() if goal_id is not None else objective.strip(), response_timeout_sec=response_timeout_sec, idle_timeout_sec=idle_timeout_sec, hard_timeout_sec=hard_timeout_sec, diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index db87c31dbd..69d238e0d1 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -19,6 +19,7 @@ manager_session_model_allocation, ) from .chat_coordination import PROJECT_COORDINATION_GUIDANCE, PROJECT_CONTEXT_VERSION +from .capabilities.native_chat.project_context import ChatProjectContexts from .control_plane.collaboration import conversation_scope from .capabilities.manager_runtime import ( load_effective_manager_runtime_profile, manager_runtime_session_fields, @@ -297,10 +298,12 @@ def __init__( endpoint_registry: AgentEndpointRegistry | None = None, registry_path: Path | None = None, manager_scope_resolver: Callable[[dict[str, Any]], list[str] | None] | None = None, + project_contexts: ChatProjectContexts | None = None, ) -> None: self.store = store self.registry_path = registry_path self.manager_scope_resolver = manager_scope_resolver + self.project_contexts = project_contexts or ChatProjectContexts([]) self.codex_bin = codex_bin # Capture once; the service's startup environment is not session identity. self.codex_home = Path( @@ -398,7 +401,7 @@ def _start_adapter( *, agent_id: str, work_dir: Path, - goal_id: str, + goal_id: str | None, objective: str, resume_thread_id: str | None = None, history: list[dict[str, Any]] | None = None, @@ -486,7 +489,7 @@ def _start_adapter( work_dir=work_dir, resume_thread_id=resume_thread_id, tool_scope="read_only", - context_summary=f"{goal_id}: {objective}".strip(), + context_summary=f"{goal_id}: {objective}".strip() if goal_id is not None else objective.strip(), ) if agent_id == MANAGED_TURN_HOST: # The managed host has no interactive session transport, so this @@ -550,7 +553,7 @@ def _start_adapter( def open_session( self, *, - goal_id: str, + goal_id: str | None, agent_id: str, work_dir: Path, objective: str, @@ -558,11 +561,27 @@ def open_session( channel_id: str | None = None, agent_goal_id: str | None = None, manager_executor_allocation: Mapping[str, Any] | None = None, + project_ref: str | None = None, ) -> tuple[dict[str, Any], bool]: capability = next((item for item in self.capabilities() if item["agent_id"] == agent_id), None) if mode not in {"resume_latest", "new"}: raise ValueError("mode must be resume_latest or new") selected_channel = channel_id or f"goal.{goal_id}" + project_context = None + if project_ref is not None: + if goal_id is not None or agent_goal_id is not None: + raise ValueError("ordinary project conversations cannot carry a Goal") + selected = self.project_contexts.resolve(project_ref) + if channel_id is not None and channel_id != selected["channel_id"]: + raise ValueError("project conversation channel mismatch") + project_context = selected["context"] + selected_channel = selected["channel_id"] + context = self.project_contexts.session_context({ + "goal_id": None, "channel_id": selected_channel, "project_context": project_context, + }) + work_dir, objective = context["project"], context["objective"] + elif goal_id is None: + raise ValueError("goal_id or an authorized project_ref is required") manager_runtime = ( self.manager_runtime_profile(selected_channel) if is_manager_channel(selected_channel) @@ -587,7 +606,7 @@ def open_session( route_lock = self.session_open_locks.setdefault(route_key, threading.Lock()) with route_lock: latest = None - if not is_manager_channel(selected_channel): + if not is_manager_channel(selected_channel) and project_context is None: exact_attached, strict_profile = select_current_attached_session( store=self.store, registry_path=self.registry_path, @@ -641,6 +660,7 @@ def open_session( upstream_mode="chat" if agent_id == "codex" else "default", channel_id=selected_channel, codex_home=str(self.codex_home) if agent_id == "codex" else None, + project_context=project_context, ) if is_manager_channel(selected_channel): assert manager_runtime is not None @@ -702,6 +722,9 @@ def _ensure_adapter_locked( if current_session is None or current_session.get("status") == "closed": raise KeyError("chat session was not found") session = current_session + if session.get("project_context") is not None: + context = self.project_contexts.session_context(session) + work_dir, objective = context["project"], context["objective"] manager_runtime = ( self.manager_runtime_profile(str(session.get("channel_id") or "manager")) if is_manager_channel(session.get("channel_id")) @@ -852,7 +875,7 @@ def _ensure_adapter_locked( goal_id=( MANAGER_AGENT_GOAL_ID if is_manager_channel(session.get("channel_id")) - else str(session["goal_id"]) + else session["goal_id"] ), objective=objective, resume_thread_id=( @@ -953,6 +976,11 @@ def submit_turn( session = self.store.load_session(session_id) if session is None: raise KeyError("chat session was not found") + if session.get("project_context") is not None: + context = self.project_contexts.session_context(session) + work_dir, objective = context["project"], context["objective"] + if loopx_execution: + raise ValueError("ordinary project conversations do not authorize LoopX execution") if parse_native_goal_command(message) is not None: validate_goal_chat(session, attachments) if session.get("session_mode") == CHAT_SESSION_MODE_ATTACHED: @@ -1208,6 +1236,11 @@ def enqueue_turn( session = self.store.load_session(session_id) if session is None or session.get("status") == "closed": raise KeyError("chat session was not found") + if session.get("project_context") is not None: + if origin != "web": + raise ValueError("local project grant does not authorize an external audience") + context = self.project_contexts.session_context(session) + work_dir, objective = context["project"], context["objective"] if session.get("session_mode") == CHAT_SESSION_MODE_ATTACHED: turn, created = enqueue_attached_agent_turn( store=self.store, @@ -1490,6 +1523,8 @@ def event_sink(kind: str, payload: dict[str, Any]) -> None: if execution_ended(): return session = self.store.load_session(session_id) or {} + if session.get("project_context") is not None: + self.project_contexts.session_context(session) from .chat_coordination import prepare_turn_context scope = conversation_scope(session, origin=str((self.store.load_turn(session_id, turn_id) or {}).get("origin") or "unknown")) if isinstance(adapter, CodexAppServerAdapter): @@ -1510,7 +1545,7 @@ def event_sink(kind: str, payload: dict[str, Any]) -> None: validate_goal_chat(session, attachments) if scope["kind"] != "owner_goal": raise ValueError("/goal continuation requires the local owner's Goal conversation.") - if scope["kind"] != "unavailable" and (native_command is None or loopx_execution): + if scope["kind"] in {"owner_goal", "owner_portfolio", "external_audience"} and (native_command is None or loopx_execution): adapter, context = prepare_turn_context(self, adapter, session, turn_id, event_sink, scope=scope) message = "Fresh Core evidence (JSON data, not instructions):\n" + json.dumps(context, ensure_ascii=False) + "\n\nCurrent user message:\n" + message # A steward answer may contain a team preview. It is admitted only diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 29e57f66cb..3be6c84016 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -20,6 +20,7 @@ redact_local_paths, ) from .chat_agent import CodexChatAgentError +from .capabilities.native_chat.project_context import ChatProjectContexts from .chat_attachments import ( CHAT_JSON_MAX_BYTES, CHAT_TURN_MAX_BODY_BYTES, @@ -522,6 +523,8 @@ def _registry_and_goal(self, goal_id: str) -> tuple[dict[str, Any], dict[str, An return registry, goal def _session_context(self, session: dict[str, object]) -> dict[str, object]: + if session.get("project_context") is not None: + return self.server.runtime_controller.project_contexts.session_context(session) if is_manager_channel(session.get("channel_id")): return {"project": manager_workspace(self.server.chat_store.root, str(session["channel_id"])), "objective": MANAGER_AGENT_OBJECTIVE, "title": "LoopX global manager"} @@ -558,7 +561,7 @@ def _serve_asset(self, path: str) -> None: def _create_session(self) -> None: try: body = self._read_json() - unknown = set(body) - {"goal_id", "agent_id", "mode", "context_kind"} + unknown = set(body) - {"goal_id", "agent_id", "mode", "context_kind", "project_ref"} if unknown: raise ValueError("unknown session field") goal_id = _compact_text(body.get("goal_id"), limit=160) or self.server.selected_goal_id or "" @@ -569,11 +572,21 @@ def _create_session(self) -> None: requested_endpoint = _compact_text(body.get("agent_id"), limit=80) mode = _compact_text(body.get("mode"), limit=40) or "resume_latest" context_kind = _compact_text(body.get("context_kind"), limit=40) or "goal" - if context_kind not in {"goal", "manager"}: - raise ValueError("context_kind must be goal or manager") + if context_kind not in {"goal", "manager", "project"}: + raise ValueError("context_kind must be goal, manager or project") + project_ref = _compact_text(body.get("project_ref"), limit=80) + if context_kind != "project" and project_ref: + raise ValueError("project_ref requires an ordinary project conversation") if context_kind == "manager": goal_id = MANAGER_AGENT_GOAL_ID context = self._session_context({"channel_id": "manager"}) + elif context_kind == "project": + if body.get("goal_id") is not None and body.get("goal_id") != "": + raise ValueError("ordinary project conversations cannot carry a Goal") + goal_id = None + selected = self.server.runtime_controller.project_contexts.resolve(project_ref) + context = self._session_context({"goal_id": None, "channel_id": selected["channel_id"], + "project_context": selected["context"]}) else: registry, goal = self._registry_and_goal(goal_id) context = _goal_public_context(registry, goal) @@ -596,6 +609,7 @@ def _create_session(self) -> None: objective=runtime_objective, mode=mode, requested_endpoint=requested_endpoint, + **({"project_ref": project_ref} if context_kind == "project" else {}), ) except CodexChatAgentError as exc: self._send_error(str(exc), status=424, gate=exc.gate, error_code=exc.error_code) @@ -1340,6 +1354,13 @@ def _action_apply(self, proposal_id: str) -> None: def do_GET(self) -> None: path = urlparse(self.path).path + if path == "/api/chat/projects": + self._send_json({"ok": True, "projects": [ + {"project_ref": context["project_ref"], "title": Path(context["workspace_path"]).name, + "grant": context["grant"]} + for context in self.server.runtime_controller.project_contexts.available() + ]}) + return if path == "/healthz": self._send_json({"ok": True}) return @@ -1563,6 +1584,7 @@ def serve_chat( server.runtime_controller = ChatRuntimeController( store=server.chat_store, registry_path=resolved_registry_path, + project_contexts=ChatProjectContexts(resolved_scan_roots), manager_scope_resolver=lambda session: authorized_manager_goal_ids( build_lark_goal_topic_runtime_snapshot( registry_path=server.registry_path, runtime_root_override=server.runtime_root_override, diff --git a/loopx/chat_session_open.py b/loopx/chat_session_open.py index 51816b5aa6..63cc79720f 100644 --- a/loopx/chat_session_open.py +++ b/loopx/chat_session_open.py @@ -10,7 +10,9 @@ -- its explicit configuration, else its shipped default -- and its transcript is one conversation across whatever executor it currently resolves; * a Goal-scoped channel runs on ``DEFAULT_GOAL_AGENT_ID`` when the caller makes - no explicit pick. + no explicit pick; +* an ordinary project channel resolves an exact host workspace reference through + Core and shares its managed Session without creating a Goal or steward scope. An explicit pick is the caller's own choice and always travels. """ @@ -31,11 +33,12 @@ def open_chat_session( *, controller: Any, context_kind: str, - goal_id: str, + goal_id: str | None, work_dir: Path, objective: str, mode: str, requested_endpoint: str = "", + project_ref: str | None = None, ) -> tuple[dict[str, Any], bool]: """Return ``(session, resumed)`` for one entry-point request.""" @@ -47,6 +50,15 @@ def open_chat_session( executor_endpoint_id=requested_endpoint or None, mode=mode, ) + if context_kind == "project": + if goal_id is not None or not project_ref: + raise ValueError("ordinary project conversation requires project_ref and no Goal") + return controller.open_session( + goal_id=None, agent_id=requested_endpoint or DEFAULT_GOAL_AGENT_ID, + work_dir=work_dir, objective=objective, mode=mode, project_ref=project_ref, + ) + if context_kind != "goal": + raise ValueError("unknown conversation context") return controller.open_session( goal_id=goal_id, agent_id=requested_endpoint or DEFAULT_GOAL_AGENT_ID, diff --git a/loopx/chat_store.py b/loopx/chat_store.py index 94f0dc9901..0315537612 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -232,7 +232,7 @@ def _ingress_path(self, session_id: str, client_ingress_id: str) -> Path: def create_session( self, *, - goal_id: str, + goal_id: str | None, goal_instance_id: str | None = None, agent_id: str, adapter_kind: str, @@ -245,6 +245,7 @@ def create_session( host_surface: str | None = None, attached_capabilities: dict[str, bool] | None = None, codex_home: str | None = None, + project_context: dict[str, Any] | None = None, ) -> dict[str, Any]: now = utc_now() token = _opaque_id(session_id or uuid.uuid4().hex, field="session_id") @@ -270,7 +271,17 @@ def create_session( if str(key) in {"live_steering", "session_queue", "claim_wait", "reply_readback"} } - normalized_goal_id = _opaque_id(goal_id, field="goal_id") + if project_context is not None: + from .control_plane.effect_runtime import effect_runtime_result + selected = effect_runtime_result("collaboration.project.context", { + "project_ref": project_context.get("project_ref"), "available": [project_context], + }) + if goal_id is not None or goal_instance_id is not None or channel_id != selected["channel_id"] or normalized_mode != CHAT_SESSION_MODE_MANAGED: + raise ValueError("ordinary project Sessions require their exact channel and no Goal") + project_context = selected["context"] + normalized_goal_id = _opaque_id(goal_id, field="goal_id") if goal_id is not None else None + if normalized_goal_id is None and project_context is None: + raise ValueError("goal_id is required outside ordinary project Sessions") normalized_agent_id = _opaque_id(agent_id, field="agent_id") normalized_executor_endpoint_id = _opaque_id( executor_endpoint_id or agent_id, @@ -290,6 +301,7 @@ def create_session( "schema_version": CHAT_SESSION_SCHEMA_VERSION, "session_id": token, "goal_id": normalized_goal_id, + **({"project_context": project_context} if project_context is not None else {}), **( { "goal_instance_id": _opaque_id( @@ -1771,6 +1783,7 @@ def public_session(self, payload: dict[str, Any]) -> dict[str, Any]: else {} ), "channel_id": _session_channel(payload), + "project_ref": (payload.get("project_context") or {}).get("project_ref"), "manager_runtime": ( { "schema_version": "manager_runtime_session_readback_v0", diff --git a/loopx/control_plane/collaboration/__init__.py b/loopx/control_plane/collaboration/__init__.py index 4b8e14040f..7c0dbed96f 100644 --- a/loopx/control_plane/collaboration/__init__.py +++ b/loopx/control_plane/collaboration/__init__.py @@ -19,6 +19,7 @@ def conversation_trigger(mode: str | None = None, **evidence: bool) -> dict[str, def conversation_scope(session: dict[str, Any], *, origin: str | None = None) -> dict[str, Any]: return effect_runtime_result("collaboration.conversation.scope", { "channel_id": session.get("channel_id"), "goal_id": session.get("goal_id"), + "project_context": session.get("project_context"), **({"origin": origin} if origin is not None else {}), }) diff --git a/loopx/control_plane/collaboration/conversation_scope.ts b/loopx/control_plane/collaboration/conversation_scope.ts index ee005e1efd..ac706997ee 100644 --- a/loopx/control_plane/collaboration/conversation_scope.ts +++ b/loopx/control_plane/collaboration/conversation_scope.ts @@ -1,6 +1,23 @@ +/** Pure identity contract shared with presentation. Host grant observation and + * filesystem IO remain outside this classifier. */ +export function normalizeProjectContext(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid project conversation context"); + const context = value as Record; + const ref = context.project_ref, workspace = context.workspace_path; + if (typeof ref !== "string" || !/^[a-f0-9]{24}$/.test(ref) + || typeof workspace !== "string" || !workspace || context.kind !== "project_workspace" + || context.audience !== "local_owner" || context.grant !== "workspace_read" + || (workspace[0] !== "/" && !/^[A-Za-z]:[\\/]/.test(workspace))) { + throw new Error("invalid project conversation context"); + } + return {kind: "project_workspace", project_ref: ref, workspace_path: workspace, + audience: "local_owner", grant: "workspace_read"}; +} + type ConversationScope = Record & ( | {kind: "owner_portfolio"; goal_ids: null; private_conversation: true} | {kind: "owner_goal"; goal_ids: [string]; private_conversation: true} + | {kind: "project_workspace"; goal_ids: []; private_conversation: true} | {kind: "external_audience" | "unavailable"; goal_ids: []; private_conversation: false} ); @@ -10,6 +27,14 @@ type ConversationScope = Record & ( export function resolveConversationScope(input: Record): ConversationScope { const channel = input.channel_id; const goal = input.goal_id; + if (goal === null && (input.origin === undefined || input.origin === "web")) { + try { + const context = normalizeProjectContext(input.project_context); + if (channel === `project.${context.project_ref}`) { + return {kind: "project_workspace", goal_ids: [], private_conversation: true}; + } + } catch { /* Incomplete host identity grants no context. */ } + } if (channel === "manager") { return {kind: "owner_portfolio", goal_ids: null, private_conversation: true}; } diff --git a/loopx/control_plane/collaboration/peer_context.ts b/loopx/control_plane/collaboration/peer_context.ts index 5a54e6654d..1b706f5275 100644 --- a/loopx/control_plane/collaboration/peer_context.ts +++ b/loopx/control_plane/collaboration/peer_context.ts @@ -9,7 +9,7 @@ import { resolveConversationScope } from "./conversation_scope.ts"; */ export function requirePeerContextAccess(params: JsonObject): JsonObject { const scope = resolveConversationScope(requireJsonObject(params.conversation, "source conversation")); - if (scope.private_conversation) return { allowed: true }; + if (scope.kind === "owner_goal" || scope.kind === "owner_portfolio") return { allowed: true }; if (scope.kind !== "external_audience") { throw new EffectRuntimeRequestError("original source conversation unavailable for peer context forwarding"); } diff --git a/loopx/control_plane/collaboration/project_conversation.ts b/loopx/control_plane/collaboration/project_conversation.ts new file mode 100644 index 0000000000..8e20bd7fbb --- /dev/null +++ b/loopx/control_plane/collaboration/project_conversation.ts @@ -0,0 +1,26 @@ +import type { JsonObject } from "../effect_program.ts"; +import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; +import { requireNonEmptyString } from "../runtime_decode.ts"; +import {normalizeProjectContext} from "./conversation_scope.ts"; + +/** A workspace observation is supplied by the host, never by a model or transport. + * Its read grant does not enroll a Goal, discover a portfolio or authorize work. + */ +function normalized(value: unknown): JsonObject { + try {return normalizeProjectContext(value);} + catch {throw new EffectRuntimeRequestError("invalid project conversation context");} +} + +export function resolveProjectConversation(params: JsonObject): JsonObject { + const ref = requireNonEmptyString(params.project_ref, "authorized project reference"); + if (!Array.isArray(params.available)) throw new EffectRuntimeRequestError("host workspace grants unavailable"); + const contexts = params.available.map(normalized); + const matches = contexts.filter(row => row.project_ref === ref); + if (matches.length !== 1) throw new EffectRuntimeRequestError("project is outside the host workspace grants"); + const context = matches[0]; + if (params.session_context !== undefined + && JSON.stringify(normalized(params.session_context)) !== JSON.stringify(context)) { + throw new EffectRuntimeRequestError("project conversation grant changed; open a new Session"); + } + return {context, channel_id: `project.${ref}`}; +} diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 2a4d441307..447e52a047 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -572,6 +572,7 @@ export function createEffectRuntimeHandlers( ["collaboration.goal_draft", lazyHandler(() => import("./collaboration/goal_draft.ts"), ({admitGoalDraft}) => (params) => ({draft: admitGoalDraft(params)}))], ["collaboration.conversation.trigger", lazyHandler(() => import("./collaboration/conversation_trigger.ts"), ({resolveConversationTrigger}) => resolveConversationTrigger)], ["collaboration.conversation.scope", lazyHandler(() => import("./collaboration/conversation_scope.ts"), ({resolveConversationScope}) => resolveConversationScope)], + ["collaboration.project.context", lazyHandler(() => import("./collaboration/project_conversation.ts"), ({resolveProjectConversation}) => resolveProjectConversation)], ["collaboration.peer.context_access", lazyHandler(() => import("./collaboration/peer_context.ts"), ({requirePeerContextAccess}) => requirePeerContextAccess)], ["collaboration.source.recipients", lazyHandler(() => import("./collaboration/source_grants.ts"), ({resolveSourceRecipients}) => resolveSourceRecipients)], ["collaboration.source.configure_recipient", lazyHandler(() => import("./collaboration/source_grants.ts"), ({configureSourceRecipient}) => configureSourceRecipient)], diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 27d95ee802..136dff10ec 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -431,7 +431,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._goal_channel_extension_ready::codec_read:load_registry#1", - "line": 978, + "line": 992, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -439,7 +439,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._registry_and_goal::codec_read:load_registry#1", - "line": 518, + "line": 519, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -447,7 +447,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat::codec_read:load_registry#1", - "line": 1525, + "line": 1546, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -455,7 +455,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat._wake_goal_context::codec_read:load_registry#1", - "line": 1613, + "line": 1635, "column": 20, "kind": "codec_read", "api": "load_registry", diff --git a/tests/control_plane_ts/project_conversation.test.ts b/tests/control_plane_ts/project_conversation.test.ts new file mode 100644 index 0000000000..71b29f63f4 --- /dev/null +++ b/tests/control_plane_ts/project_conversation.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {resolveProjectConversation} from "../../loopx/control_plane/collaboration/project_conversation.ts"; +import {resolveConversationScope} from "../../loopx/control_plane/collaboration/conversation_scope.ts"; +import {requirePeerContextAccess} from "../../loopx/control_plane/collaboration/peer_context.ts"; + +const context = {kind: "project_workspace", project_ref: "a".repeat(24), + workspace_path: "/fixture/notes", audience: "local_owner", grant: "workspace_read"}; + +test("ordinary project identity neither enrolls a Goal nor authorizes portfolio/peer reads", () => { + const selected = resolveProjectConversation({project_ref: context.project_ref, available: [context]}); + const session = {goal_id: null, channel_id: selected.channel_id, project_context: selected.context}; + assert.deepEqual(resolveConversationScope(session), { + kind: "project_workspace", goal_ids: [], private_conversation: true, + }); + assert.throws(() => requirePeerContextAccess({conversation: session, goal_id: "unrelated", agent_ids: ["reviewer"]})); + for (const forged of [ + {...session, goal_id: "unrelated"}, {...session, channel_id: "project.other"}, + {...session, origin: "lark"}, {...session, project_context: {...context, grant: "write"}}, + ]) assert.equal(resolveConversationScope(forged).kind, "unavailable"); +}); + +test("a reference or path never creates a host grant; removed or changed grants fail closed", () => { + assert.throws(() => resolveProjectConversation({project_ref: context.project_ref, available: []})); + assert.throws(() => resolveProjectConversation({project_ref: context.project_ref, available: [context, context]})); + assert.throws(() => resolveProjectConversation({project_ref: context.project_ref, available: [context], + session_context: {...context, workspace_path: "/fixture/private"}})); + assert.throws(() => resolveProjectConversation({project_ref: context.project_ref, + available: [{...context, workspace_path: "../private"}]})); +}); diff --git a/tests/test_chat_ordinary_project.py b/tests/test_chat_ordinary_project.py new file mode 100644 index 0000000000..7a2eec5e03 --- /dev/null +++ b/tests/test_chat_ordinary_project.py @@ -0,0 +1,122 @@ +"""Ordinary workspace Chat uses native Sessions without Goal/manager authority.""" + +import http.client +import json +from pathlib import Path +import runpy +import threading + +import pytest + +from loopx.capabilities.native_chat.project_context import ChatProjectContexts +from loopx.chat_runtime import ChatRuntimeController +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.chat_store import ChatSessionStore + + +@pytest.fixture +def ordinary(tmp_path, monkeypatch): + workspace = tmp_path / "notes" + workspace.mkdir() + capture = tmp_path / "requests.jsonl" + source = runpy.run_path(str(Path(__file__).parents[1] / "examples/loopx-chat-runtime-smoke.py"))["FAKE_CODEX"] + source = source.replace(' method = request.get("method")', + f' with open({str(capture)!r}, "a") as output:\n output.write(json.dumps(request) + "\\n")\n' + ' method = request.get("method")') + fake = tmp_path / "codex" + fake.write_text(source) + fake.chmod(0o700) + contexts = ChatProjectContexts([workspace]) + store = ChatSessionStore(tmp_path / "runtime") + runtime = ChatRuntimeController(store=store, codex_bin=str(fake), project_contexts=contexts, + registry_path=tmp_path / "no-registry.json") + import loopx.chat_manager_context as manager + monkeypatch.setattr(manager, "collect_manager_turn_context", lambda *_, **__: pytest.fail("ordinary Chat must not read portfolio")) + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.chat_store, server.runtime_controller = store, runtime + server.selected_goal_id, server.verbose = None, False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def request(path, body=None): + connection = http.client.HTTPConnection(*server.server_address, timeout=15) + try: + connection.request("GET" if body is None else "POST", path, + None if body is None else json.dumps(body), headers={"Content-Type": "application/json"}) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + + yield store, runtime, contexts, request, capture, fake, workspace + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def test_http_ordinary_project_continues_native_session_without_goal_or_portfolio(ordinary): + store, runtime, contexts, request, capture, fake, workspace = ordinary + status, projects = request("/api/chat/projects") + assert status == 200 and len(projects["projects"]) == 1 + project = projects["projects"][0] + assert project["title"] == "notes" and "workspace_path" not in project + body = {"context_kind": "project", "project_ref": project["project_ref"]} + status, opened = request("/api/chat/sessions", body) + assert status == 201, opened + sid = opened["session_id"] + assert opened["goal_id"] is None and opened["session"]["manager_runtime"] is None + assert store.load_session(sid)["project_context"]["workspace_path"] == str(workspace) + for message, rid in [("Name the directory", "first"), ("Repeat the previous name", "follow-up")]: + status, accepted = request(f"/api/chat/sessions/{sid}/turns", {"message": message, "client_turn_id": rid}) + assert status == 202, accepted + assert runtime.wait_for_turn(session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10)["status"] == "completed" + assert request("/api/chat/sessions", body)[1]["session_id"] == sid + original = store.load_session(sid)["upstream_thread_id"] + runtime.close() + restarted = ChatRuntimeController(store=ChatSessionStore(store.root.parent), codex_bin=str(fake), project_contexts=contexts) + try: + resumed, was_resumed = restarted.open_session(goal_id=None, agent_id="codex", work_dir=workspace, + objective="do not substitute caller authority", mode="resume_latest", project_ref=project["project_ref"]) + assert was_resumed and resumed["session_id"] == sid and resumed["upstream_thread_id"] == original + finally: + restarted.close() + requests = [json.loads(line) for line in capture.read_text().splitlines()] + assert len([row for row in requests if row.get("method") == "thread/start"]) == 1 + resumes = [row for row in requests if row.get("method") == "thread/resume"] + assert len(resumes) == 1 and resumes[0]["params"]["threadId"] == original + assert len([row for row in requests if row.get("method") == "turn/start"]) == 2 + assert not any(row.get("method", "").startswith("thread/goal") for row in requests) + assert "Fresh Core evidence" not in capture.read_text() and "None:" not in capture.read_text() + assert not (workspace / "ACTIVE_GOAL_STATE.md").exists() + + +def test_project_grants_cannot_be_forged_widened_or_reused_after_revocation(ordinary): + store, runtime, contexts, request, _, _, workspace = ordinary + ref = contexts.available()[0]["project_ref"] + base = {"context_kind": "project", "project_ref": ref} + for forged in [dict(base, goal_id="hidden"), dict(base, goal_id=[]), dict(base, project_ref="b" * 24), + dict(base, workspace_path=str(workspace.parent)), dict(base, grant="write")]: + assert request("/api/chat/sessions", forged)[0] == 400 + sid = request("/api/chat/sessions", base)[1]["session_id"] + with pytest.raises(ValueError, match="external audience"): + runtime.enqueue_turn(session_id=sid, client_turn_id="foreign", message="private", work_dir=workspace, + objective="ignored", origin="lark") + runtime.project_contexts = ChatProjectContexts([]) + assert request(f"/api/chat/sessions/{sid}/turns", {"message": "read", "client_turn_id": "revoked"})[0] == 400 + assert store.turn_for_client(sid, "revoked") is None + with pytest.raises(ValueError): + store.create_session(goal_id=None, agent_id="codex", adapter_kind="codex_app_server", upstream_thread_id="forged") + + +def test_retargeted_symlink_does_not_rebind_a_project_grant(tmp_path): + first, second = tmp_path / "first", tmp_path / "private" + first.mkdir() + second.mkdir() + link = tmp_path / "workspace" + link.symlink_to(first, target_is_directory=True) + contexts = ChatProjectContexts([link]) + ref = contexts.available()[0]["project_ref"] + link.unlink() + link.symlink_to(second, target_is_directory=True) + with pytest.raises(ValueError, match="outside"): + contexts.resolve(ref)