{zh ? "可沿用已有 Goal 的工作目录。未连接的目录需要先完成项目连接;选择目录不会增加执行权限。" : "Reuse an existing Goal’s working directory. Unconnected directories need project setup first; selecting a directory does not expand execution permissions."}
+
{zh
+ ? <>检查配置时会显示具体目录。要添加其他目录,请用 loopx dashboard --scan-path <目录> 重新启动 Dashboard(每个项目一个)。选择目录不会增加执行权限。>
+ : <>The review names the directory before anything is created. To add another directory, restart the Dashboard with loopx dashboard --scan-path <directory>, once per project. Choosing a directory does not expand execution permissions.>}
> : null}
>}
diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md
index 5c435d8630..21743a18be 100644
--- a/docs/guides/personal-workspace-user-guide.md
+++ b/docs/guides/personal-workspace-user-guide.md
@@ -158,25 +158,56 @@ loopx goal-lifecycle --goal-id --operation resume --actor-kind owner -
---
-### 创建 Goal 时选择已有工作目录 / Reuse a workspace when creating a Goal
+### 创建 Goal 时选择工作目录 / Choose a working directory when creating a Goal
-打开侧边栏的「创建 Goal」,填写目标和完成标准后,在「工作目录」中选择一个已有
-Goal,即可沿用它绑定的目录。检查配置并确认一次后,新的 Goal 在所选目录中创建;
-执行权限仍由表单中的权限选择与后端校验决定。对话草稿也可通过「修改」打开同一个表单。
+打开侧边栏的「创建 Goal」,填写目标和完成标准后,在「工作目录」中选择:
+
+- **Dashboard 工作区**:启动 Dashboard 时配置的目录(见下文),即使还没有 Goal 使用它;
+- **已有 Goal 的工作目录**:沿用该 Goal 绑定的目录;
+- **默认工作区**:由当前会话与连接配置解析。
+
+检查配置时,预览的「执行工作区」会写明目录名称、来源(Dashboard 工作区或与已有 Goal
+共用),以及是否为默认解析结果;确认后的 Goal 一定在预览所示目录中创建,执行权限仍由表单中的
+权限选择与后端校验决定。对话草稿也会显示同样的执行工作区,并可通过「修改」打开同一个表单改选。
如果检查配置失败,目标文本与目录选择会保留供重试;失效或不可用的目录不会自动换成另一目录。
+预览只显示目录名称,不显示本机完整路径;同名目录(包括已有 Goal 的目录)会逐级附带上级目录名,直到可以区分。
+
+要管理多个项目,可在启动时一次配置多个目录,之后每次创建 Goal 时分别选择:
+
+```bash
+loopx dashboard --scan-path ~/projects/app --scan-path ~/projects/docs-site
+```
+
+不在列表中的目录无法在页面上直接输入;请用 `--scan-path` 重新启动 Dashboard,或先通过
+[项目连接流程](../guides/newcomer-command-path.md)接入。选择目录不会增加执行权限。
+
+Open **Create Goal** in the sidebar, enter the objective and completion criteria, and choose a
+**Working directory**:
+
+- a **Dashboard workspace** — a directory the Dashboard was started with (below), even before any
+ Goal uses it;
+- an **existing Goal's directory** — reuse the directory that Goal is bound to;
+- the **default workspace** — resolved from the current conversation and connection routing.
+
+The review's **Workspace** field names the directory, where it comes from (a Dashboard workspace or
+shared with an existing Goal), and whether the default chose it. Confirming creates the Goal in
+exactly that directory, under the selected execution permission and the existing backend checks.
+Conversational drafts show the same field, and **Edit** opens the form to choose another directory.
+A failed preview retains the text and selection for retry; unavailable selections never silently
+redirect to another directory. The review shows directory names, not local paths; two directories
+with the same name, including existing Goals' directories, gain parent directory names until they
+read apart.
+
+To work on several projects, start the Dashboard once with each directory and pick one per Goal:
+
+```bash
+loopx dashboard --scan-path ~/projects/app --scan-path ~/projects/docs-site
+```
-默认工作区继续由现有连接配置解析。这一入口支持沿用**已注册 Goal 的目录**,不提供任意
-本机路径输入或新目录接入;未连接的目录仍需先通过
-[项目连接流程](../guides/newcomer-command-path.md)接入。创建前检查预览中的工作区标识。
-
-Open **Create Goal** in the sidebar, enter the objective and completion criteria, and select an
-existing Goal under **Working directory** to reuse its directory. Review and confirm once; the new
-Goal is created there under the selected execution permission and the existing backend checks.
-Conversational drafts reach the same form through **Edit**. A failed preview retains the text and
-selection for retry; unavailable selections do not silently redirect to another directory. The
-default still follows existing connection routing. This supports registered Goal workspaces, not
-arbitrary path entry or new-directory onboarding; connect a new project first and check the workspace
-reference in the creation preview.
+Directories that are not listed cannot be typed into the page; restart the Dashboard with
+`--scan-path`, or connect the project first through the
+[project connection flow](../guides/newcomer-command-path.md). Choosing a directory does not expand
+execution permissions.
## 🎯 4. Goal 深度工作区
diff --git a/examples/personal-workspace-browser/goal-workspace-backend.py b/examples/personal-workspace-browser/goal-workspace-backend.py
index 71e40e2767..8b4ba4d2e6 100644
--- a/examples/personal-workspace-browser/goal-workspace-backend.py
+++ b/examples/personal-workspace-browser/goal-workspace-backend.py
@@ -8,7 +8,8 @@
import sys
from loopx.chat_action_store import ChatActionStore
-from loopx.chat_actions import ChatActionService
+from loopx.capabilities.native_chat.project_context import ChatProjectContexts
+from loopx.chat_actions import ChatActionService, goal_workspace_ref
from loopx.chat_server import ChatHTTPServer, ChatRequestHandler
from loopx.chat_store import ChatSessionStore
from loopx.control_plane.effect_runtime import restart_effect_runtime
@@ -30,8 +31,12 @@
runtime = Path(data["common_runtime_root"])
store, sessions = ChatActionStore(runtime / "chat/actions"), ChatSessionStore(runtime)
controller = fixture["FakeRuntimeController"](sessions)
+# One Dashboard-configured workspace that no Goal uses yet.
+configured = root / "docs-site"
+configured.mkdir()
+controller.project_contexts = ChatProjectContexts([configured])
service = ChatActionService(store=store, registry_path=registry,
- chat_store=sessions, runtime_controller=controller)
+ chat_store=sessions, runtime_controller=controller, workspace_roots=[configured])
server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler)
server.registry_path, server.runtime_root = registry, runtime
server.action_store, server.action_service = store, service
@@ -39,7 +44,9 @@
server.verbose = False
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
print(json.dumps({"port": server.server_port, "registry": str(registry),
- "first": data["goals"][0]["repo"], "second": other["repo"]}), flush=True)
+ "first": data["goals"][0]["repo"], "second": other["repo"],
+ "configured": str(configured.resolve()),
+ "configured_ref": goal_workspace_ref(configured.resolve())}), flush=True)
try:
server.serve_forever()
finally:
diff --git a/examples/personal-workspace-browser/goal-workspace-selection.mjs b/examples/personal-workspace-browser/goal-workspace-selection.mjs
index 90666e5751..5bd9699d7e 100644
--- a/examples/personal-workspace-browser/goal-workspace-selection.mjs
+++ b/examples/personal-workspace-browser/goal-workspace-selection.mjs
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import {spawn} from "node:child_process";
import {mkdtemp, readFile, rename, rm} from "node:fs/promises";
import {tmpdir} from "node:os";
-import {resolve} from "node:path";
+import {basename, dirname, resolve} from "node:path";
import {createInterface} from "node:readline";
import {testPythonCommand} from "../../scripts/test-python.mjs";
import {outputDir, repoRoot} from "./fixture.mjs";
@@ -33,6 +33,9 @@ runpy.run_path(sys.argv[0], run_name="__main__")
const requests = [], applied = [];
let rejectPreview = true;
context = await openWorkspacePage(browser, url, {collectCoverage, beforeGoto: async (_api, page) => {
+ await page.route("**/api/chat/projects", async route => {
+ await route.fulfill({response: await route.fetch({url: `http://127.0.0.1:${backend.port}/api/chat/projects`})});
+ });
await page.route("**/api/actions{,/**,?*}", async route => {
const request = route.request(), path = new URL(request.url());
if (path.pathname.endsWith("/preview")) {
@@ -78,6 +81,14 @@ runpy.run_path(sys.argv[0], run_name="__main__")
await form.getByRole("button", {name: "检查配置"}).click();
await page.getByText("确认执行", {exact: true}).waitFor();
assert.equal(requests.at(-1).normalized_parameters.workspace_ref, "research-monitor");
+ // The review names the directory the Goal will use, without its path.
+ // Both fixture Goals use a directory with the same name, so the review
+ // adds the parent directory that tells them apart.
+ assert.equal(basename(backend.first), basename(backend.second));
+ const secondLabel = `${basename(dirname(backend.second))}/${basename(backend.second)}`;
+ await page.getByText(`${secondLabel} · 与已有 Goal 共用`, {exact: false}).first().waitFor();
+ assert.equal(await page.getByText(`${basename(dirname(backend.first))}/${basename(backend.first)} · 与已有 Goal 共用`, {exact: false}).count(), 0);
+ assert.equal(await page.getByText(backend.second, {exact: false}).count(), 0);
assert.equal(JSON.parse(await readFile(backend.registry, "utf8")).goals.length, 2, "Preview created a Goal");
const confirm = page.getByRole("button", {name: /创建 Goal.*开始首轮/});
await confirm.click();
@@ -92,8 +103,43 @@ runpy.run_path(sys.argv[0], run_name="__main__")
assert.notEqual(created.repo, backend.first);
const state = await readFile(resolve(created.repo, created.state_file), "utf8");
assert.match(state, /输出带来源的检查结果/);
+
+ // A Dashboard workspace that no Goal uses yet is offered, reviewed by name
+ // and becomes the new Goal's directory.
+ await page.setViewportSize({width: 1280, height: 900});
+ await page.getByRole("button", {name: "创建 Goal", exact: true}).first().click();
+ const next = page.getByRole("dialog", {name: "创建新 Goal"});
+ await next.getByLabel("目标", {exact: true}).fill("整理文档站点的入门指南");
+ await next.getByLabel("完成标准", {exact: true}).fill("列出缺失的入门步骤");
+ await next.getByLabel("执行权限", {exact: true}).selectOption("read_only");
+ await next.getByLabel("工作目录", {exact: true}).selectOption(backend.configured_ref);
+ await next.getByRole("button", {name: "检查配置"}).click();
+ await page.getByText("docs-site · Dashboard 工作区", {exact: false}).first().waitFor();
+ assert.equal(requests.at(-1).normalized_parameters.workspace_ref, backend.configured_ref);
+ await page.screenshot({path: resolve(outputDir, "goal-workspace-selection-review.png"), animations: "disabled"});
+ const configuredGoalId = requests.at(-1).normalized_parameters.goal_id;
+ await page.getByRole("button", {name: /创建 Goal.*开始首轮/}).last().click();
+ for (const deadline = Date.now() + 30000; applied.length < 2 && Date.now() < deadline;) {
+ await new Promise(accept => setTimeout(accept, 200));
+ }
+ assert.equal(applied.length, 2, "Dashboard-workspace creation was not applied");
+ assert.equal(applied[1].proposal.receipt.projection_verified, true);
+ const configuredGoal = JSON.parse(await readFile(backend.registry, "utf8")).goals
+ .find(goal => goal.id === configuredGoalId);
+ assert.equal(configuredGoal.repo, backend.configured, "Confirmed Goal was created in the Dashboard workspace");
+
+ // Leaving the default still names the directory it resolves to before
+ // anything is created.
+ await page.getByRole("button", {name: "创建 Goal", exact: true}).first().click();
+ const fallback = page.getByRole("dialog", {name: "创建新 Goal"});
+ await fallback.getByLabel("目标", {exact: true}).fill("检查默认目录");
+ await fallback.getByLabel("完成标准", {exact: true}).fill("说明默认目录");
+ await fallback.getByRole("button", {name: "检查配置"}).click();
+ await page.getByText("默认:docs-site · Dashboard 工作区", {exact: false}).first().waitFor();
+ assert.equal(requests.at(-1).normalized_parameters.workspace_ref, "current");
+ assert.equal(applied.length, 2, "Reviewing the default created a Goal");
return {coverageEntries: context.coverageEntries, errors: context.errors,
- note: "Packaged directory choice, keyboard/mobile, preview failure retention and real HTTP/bootstrap/registry readback passed. Status fixtures and model dispatch are synthetic; no installed or live-model claim."};
+ note: "Packaged directory choice (existing Goal and Dashboard workspace), review naming, keyboard/mobile, preview failure retention and real HTTP/bootstrap/registry readback passed. Status fixtures and model dispatch are synthetic; no installed or live-model claim."};
} finally {
await context?.close();
lines.close();
diff --git a/loopx/capabilities/native_chat/external_conversations.py b/loopx/capabilities/native_chat/external_conversations.py
index aac124bbdd..e3a591866c 100644
--- a/loopx/capabilities/native_chat/external_conversations.py
+++ b/loopx/capabilities/native_chat/external_conversations.py
@@ -10,9 +10,9 @@
from pathlib import Path
import re
from datetime import datetime, timedelta, timezone
-import hashlib
from typing import Any
+from ...chat_actions import goal_workspace_ref
from ...chat_store import _atomic_write_json, _read_json
from ...chat_attachments import normalize_chat_image_attachments
from ...control_plane.quota.automation_cadence_readback import (
@@ -399,7 +399,7 @@ def _run_steward_action(self, row: dict[str, Any]) -> None:
"objective": argument, "completion_criteria": "按明确委托返回可核验结果;原生执行结束不代表 LoopX 验收。",
"execution_boundary": "只读所选工作区;不继承旧目标,不自动调度,不扩大宿主策略。",
"agent_id": selected["binding"]["executor_endpoint_id"],
- "workspace_ref": "workspace-" + hashlib.sha256(workspace.encode()).hexdigest()[:12],
+ "workspace_ref": goal_workspace_ref(Path(workspace)),
"heartbeat": {"enabled": False},
"native_token_budget": row["command_argument"]["native_token_budget"]}})
row.update(proposal_id=proposal["proposal_id"], response=(
diff --git a/loopx/chat_action_store.py b/loopx/chat_action_store.py
index f63edbb11b..bf2a7f98af 100644
--- a/loopx/chat_action_store.py
+++ b/loopx/chat_action_store.py
@@ -201,6 +201,7 @@ def create_preview(
available_transitions: Sequence[Any],
idempotency_key: str,
canonical_update_basis: Mapping[str, Any] | None = None,
+ workspace_binding: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
selected_kind = str(action_kind or "").strip()
if selected_kind not in ACTION_KINDS:
@@ -224,6 +225,8 @@ def create_preview(
"available_transitions": list(available_transitions),
**({"canonical_update_basis": dict(canonical_update_basis)}
if canonical_update_basis is not None else {}),
+ **({"workspace_binding": dict(workspace_binding)}
+ if workspace_binding is not None else {}),
},
path="preview",
)
diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py
index d2aa3d00ef..e05648fb0f 100644
--- a/loopx/chat_actions.py
+++ b/loopx/chat_actions.py
@@ -118,6 +118,36 @@ def _digest(payload: Any) -> str:
return hashlib.sha256(stable.encode("utf-8")).hexdigest()
+def goal_workspace_ref(root: Path) -> str:
+ """The Goal-creation token for one server-configured workspace root."""
+
+ return f"workspace-{hashlib.sha256(str(root).encode('utf-8')).hexdigest()[:12]}"
+
+
+def goal_workspace_labels(directories: Sequence[Path]) -> dict[Path, str]:
+ """Owner-facing names for workspace directories, without checkout paths.
+
+ A directory is named by its own name; parent names are added, one at a
+ time, only while two different directories would otherwise read the same.
+ The filesystem anchor is never shown.
+ """
+
+ unique = list(dict.fromkeys(directories))
+ parts = {path: path.parts[1:] if path.anchor else path.parts for path in unique}
+ depth = dict.fromkeys(unique, 1)
+ while True:
+ labels = {path: "/".join(parts[path][-depth[path]:]) or str(path) for path in unique}
+ readers: dict[str, list[Path]] = {}
+ for path, label in labels.items():
+ readers.setdefault(label, []).append(path)
+ ambiguous = [path for group in readers.values() if len(group) > 1
+ for path in group if depth[path] < len(parts[path])]
+ if not ambiguous:
+ return labels
+ for path in ambiguous:
+ depth[path] += 1
+
+
def _normalize_cadence(value: Any) -> str:
candidate = str(value or "").strip().lower().replace("-", "_")
aliases = {
@@ -444,7 +474,7 @@ def _project_for_goal_create(
# The native steward freezes its configured workspace. Existing
# Goals and the single-Goal fallback cannot redirect a commission.
candidates = [root for root in self.workspace_roots if root.is_dir() and (root / ".git").exists()
- and workspace_ref == f"workspace-{hashlib.sha256(str(root).encode('utf-8')).hexdigest()[:12]}"]
+ and workspace_ref == goal_workspace_ref(root)]
if len(candidates) != 1:
raise ValueError("the bound steward workspace is unavailable")
return candidates[0], {"id": "", "repo": str(candidates[0]), "domain": "project-goal-control-plane",
@@ -472,10 +502,7 @@ def _project_for_goal_create(
source_goal = goals[0]
if source_goal is None:
workspace_candidates = [root for root in self.workspace_roots if root.is_dir()]
- token_map = {
- f"workspace-{hashlib.sha256(str(root).encode('utf-8')).hexdigest()[:12]}": root
- for root in workspace_candidates
- }
+ token_map = {goal_workspace_ref(root): root for root in workspace_candidates}
selected = (workspace_candidates[0]
if workspace_ref == "current" and len(workspace_candidates) == 1
else token_map.get(workspace_ref))
@@ -487,26 +514,22 @@ def _project_for_goal_create(
"adapter": {"kind": "generic_project_goal_v0"},
}
if source_goal is None:
+ available = [root for root in self.workspace_roots if root.is_dir()]
+ labels = self.workspace_labels()
candidates = [
- {
- "workspace_ref": f"workspace-{hashlib.sha256(str(root).encode('utf-8')).hexdigest()[:12]}",
- "label": f"Workspace {index}",
- }
- for index, root in enumerate(
- (
- root
- for root in self.workspace_roots
- if root.is_dir()
- ),
- start=1,
- )
+ {"workspace_ref": goal_workspace_ref(root), "label": labels[root]}
+ for root in available
]
raise ProtectedActionGate(
"goal.create",
gate={
"kind": "workspace_selection_required",
"summary": "Select one server-configured workspace before creating this Goal.",
- "next_action": "Choose an available workspace or a registered Goal workspace, then review again. Unlisted directories require server workspace setup.",
+ "next_action": (
+ "Choose an available workspace or a registered Goal workspace, then review again. "
+ "To use another directory, restart the Dashboard with "
+ "`loopx dashboard --scan-path `, once per project."
+ ),
"candidates": candidates,
},
)
@@ -524,6 +547,40 @@ def _project_for_goal_create(
)
return project, source_goal
+ def workspace_labels(self) -> dict[Path, str]:
+ """Names for every directory a Goal creation can target.
+
+ Configured roots and registered Goal workspaces are named together, so
+ two choices never read the same in the form, a gate or a review.
+ """
+
+ directories = [root for root in self.workspace_roots if root.is_dir()]
+ for goal in registry_goals(self._registry()):
+ repo = str(goal.get("repo") or "").strip()
+ if repo:
+ directories.append(Path(repo).expanduser().resolve())
+ return goal_workspace_labels(directories)
+
+ def _goal_workspace_binding(
+ self, project: Path, source_goal: Mapping[str, Any], *, requested_ref: str
+ ) -> dict[str, Any]:
+ """The workspace a confirmed creation will use, as the owner reviews it.
+
+ ``workspace_ref`` is the explicit reference apply resolves, so a
+ default request is confirmed against the workspace it resolved to.
+ """
+
+ goal_id = str(source_goal.get("id") or "")
+ label = self.workspace_labels().get(project) or project.name
+ return {
+ "schema_version": "loopx_goal_workspace_binding_v0",
+ "workspace_ref": goal_id or goal_workspace_ref(project),
+ "label": label,
+ "source": "goal_workspace" if goal_id else "configured_workspace",
+ "selection": "default" if requested_ref == "current" else "explicit",
+ **({"goal_id": goal_id} if goal_id else {}),
+ }
+
def _apply_goal_create(
self, proposal_id: str, proposal: dict[str, Any], parameters: dict[str, Any]
) -> dict[str, Any]:
@@ -1121,8 +1178,9 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]:
raise ValueError("normalized_parameters and context must be objects")
normalized = self._normalize(action_kind, parameters)
canonical_update_basis = None
+ workspace_binding = None
if action_kind == "goal.create":
- project, _source_goal = self._project_for_goal_create(
+ project, source_goal = self._project_for_goal_create(
{
"normalized_parameters": normalized,
"context": dict(context),
@@ -1130,6 +1188,10 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]:
)
agent_id = str(normalized.get("agent_id") or "codex")
eligibility = self._agent_eligibility(agent_id, project=project)
+ workspace_binding = self._goal_workspace_binding(
+ project, source_goal, requested_ref=str(normalized.get("workspace_ref") or "current")
+ )
+ normalized["workspace_ref"] = workspace_binding["workspace_ref"]
else:
eligibility = None
if action_kind == "operation.execute":
@@ -1283,6 +1345,7 @@ def preview(self, request: Mapping[str, Any]) -> dict[str, Any]:
request.get("idempotency_key"), field="idempotency_key"
),
canonical_update_basis=canonical_update_basis,
+ workspace_binding=workspace_binding,
)
return (
self.store.arm_operation(str(proposal["proposal_id"]))
diff --git a/loopx/chat_server.py b/loopx/chat_server.py
index 9bfeb40d8d..0e016be5cc 100644
--- a/loopx/chat_server.py
+++ b/loopx/chat_server.py
@@ -27,7 +27,7 @@
normalize_chat_image_attachments,
validate_chat_turn_envelope,
)
-from .chat_actions import ChatActionService, ProtectedActionGate
+from .chat_actions import ChatActionService, ProtectedActionGate, goal_workspace_labels, goal_workspace_ref
from .chat_action_store import ACTION_KINDS, ActionConflictError, ChatActionStore
from .chat_goal_subagent_api import (
GoalSubagentConfigurationRequestMixin,
@@ -1390,10 +1390,25 @@ def do_GET(self) -> None:
if path == DEFAULT_EXTENSION_PROJECTION_PATH:
return self._handle_extension_projection(parse_qs(urlparse(self.path).query))
if path == "/api/chat/projects":
+ contexts = self.server.runtime_controller.project_contexts.available()
+ roots = [Path(context["workspace_path"]) for context in contexts]
+ service = getattr(self.server, "action_service", None)
+ labels = goal_workspace_labels(roots)
+ if service is not None:
+ try:
+ labels = service.workspace_labels()
+ except (OSError, ValueError):
+ # Granted project workspaces never depend on the Goal
+ # registry. An unreadable registry only loses naming that
+ # tells these roots apart from registered Goal directories;
+ # Goal preview and apply still refuse it on their own.
+ pass
+ # The same roots admit Goal creation; their creation reference lets
+ # the Goal form offer them by the name a review will show.
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()
+ {"project_ref": context["project_ref"], "title": labels.get(root, root.name),
+ "grant": context["grant"], "goal_workspace_ref": goal_workspace_ref(root)}
+ for context, root in zip(contexts, roots)
]})
return
if path == "/healthz":
diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json
index f909e8d80e..8c3e18588a 100644
--- a/loopx/semantics/project_registry_io_manifest_v1.json
+++ b/loopx/semantics/project_registry_io_manifest_v1.json
@@ -447,7 +447,7 @@
},
{
"site": "loopx/chat_actions.py::.ChatActionService._registry::codec_read:load_registry#1",
- "line": 249,
+ "line": 279,
"column": 16,
"kind": "codec_read",
"api": "load_registry",
@@ -535,7 +535,7 @@
},
{
"site": "loopx/chat_server.py::.serve_chat::codec_read:load_registry#1",
- "line": 1592,
+ "line": 1607,
"column": 16,
"kind": "codec_read",
"api": "load_registry",
@@ -543,7 +543,7 @@
},
{
"site": "loopx/chat_server.py::.serve_chat._wake_goal_context::codec_read:load_registry#1",
- "line": 1703,
+ "line": 1718,
"column": 20,
"kind": "codec_read",
"api": "load_registry",
diff --git a/tests/test_chat_goal_workspace_selection.py b/tests/test_chat_goal_workspace_selection.py
index 34d92fc5eb..b7b8fec54d 100644
--- a/tests/test_chat_goal_workspace_selection.py
+++ b/tests/test_chat_goal_workspace_selection.py
@@ -79,3 +79,164 @@ def test_bound_steward_rejects_goal_workspace_redirect(workspace_service):
"normalized_parameters": {"workspace_ref": "second-goal"},
"context": {"kind": "manager", "goal_id": "first-goal", "binding_id": "steward-binding"},
})
+
+
+def preview(service, workspace_ref, context_goal=None, key="create"):
+ return service.preview({
+ "action_kind": "goal.create", "summary": "Create a Goal", "idempotency_key": key,
+ "context": {"kind": "manager", "goal_id": context_goal},
+ "normalized_parameters": {"goal_id": f"goal-{key}", "title": "New Goal",
+ "objective": "Ship it", "workspace_ref": workspace_ref},
+ })
+
+
+@pytest.mark.parametrize("route", ["context_goal", "single_goal", "single_root"])
+def test_default_preview_binds_the_workspace_it_resolved(workspace_service, route):
+ # "Default workspace" is resolved once, shown to the owner by name, and the
+ # confirmed proposal carries the explicit reference apply will resolve, so
+ # confirmation cannot land in a different directory than the one reviewed.
+ service, registry, first, _ = workspace_service
+ if route != "context_goal":
+ data = json.loads(registry.read_text())
+ data["goals"] = data["goals"][:1] if route == "single_goal" else []
+ registry.write_text(json.dumps(data))
+ if route == "single_root":
+ service.workspace_roots = (first,)
+ proposal = preview(service, "current", "first-goal" if route == "context_goal" else None)
+
+ binding = proposal["workspace_binding"]
+ assert binding["label"] == "first"
+ assert binding["selection"] == "default"
+ assert proposal["normalized_parameters"]["workspace_ref"] == binding["workspace_ref"]
+ if route == "single_root":
+ assert binding["source"] == "configured_workspace" and "goal_id" not in binding
+ assert binding["workspace_ref"] == "workspace-" + hashlib.sha256(str(first).encode()).hexdigest()[:12]
+ else:
+ assert binding["source"] == "goal_workspace"
+ assert binding["workspace_ref"] == binding["goal_id"] == "first-goal"
+ assert resolve(service, binding["workspace_ref"])[0] == first
+ assert str(first) not in json.dumps(binding)
+
+
+def test_explicit_configured_workspace_preview_names_that_workspace(workspace_service):
+ service, _, _, second = workspace_service
+ token = "workspace-" + hashlib.sha256(str(second).encode()).hexdigest()[:12]
+ binding = preview(service, token, "first-goal")["workspace_binding"]
+ assert binding == {
+ "schema_version": "loopx_goal_workspace_binding_v0",
+ "workspace_ref": token, "label": "second",
+ "source": "configured_workspace", "selection": "explicit",
+ }
+
+
+def test_workspace_choices_are_named_by_directory_not_token(tmp_path):
+ roots = [tmp_path / "team-a" / "app", tmp_path / "team-b" / "app", tmp_path / "docs"]
+ for root in roots:
+ root.mkdir(parents=True)
+ registry = tmp_path / "registry.json"
+ registry.write_text(json.dumps({"schema_version": 1, "goals": []}))
+ service = ChatActionService(store=ChatActionStore(tmp_path / "actions"),
+ registry_path=registry, workspace_roots=roots)
+ with pytest.raises(ProtectedActionGate) as error:
+ preview(service, "current")
+ gate = error.value.gate
+ assert [candidate["label"] for candidate in gate["candidates"]] == ["team-a/app", "team-b/app", "docs"]
+ assert "--scan-path" in gate["next_action"]
+
+
+def test_same_named_goal_directories_review_apart(tmp_path):
+ # Two existing Goals in .../first/project and .../second/project, plus a
+ # configured workspace: each review names its own directory distinctly.
+ first, second, docs = tmp_path / "first" / "project", tmp_path / "second" / "project", tmp_path / "docs-site"
+ for directory in (first, second, docs):
+ directory.mkdir(parents=True)
+ registry = tmp_path / "registry.json"
+ registry.write_text(json.dumps({"schema_version": 1, "goals": [
+ {"id": "first-goal", "repo": str(first)}, {"id": "second-goal", "repo": str(second)},
+ ]}))
+ service = ChatActionService(store=ChatActionStore(tmp_path / "actions"),
+ registry_path=registry, workspace_roots=[docs])
+ bindings = [preview(service, goal_id, key=goal_id)["workspace_binding"] for goal_id in ("first-goal", "second-goal")]
+ assert [binding["label"] for binding in bindings] == ["first/project", "second/project"]
+ assert [resolve(service, binding["workspace_ref"])[0] for binding in bindings] == [first, second]
+ assert service.workspace_labels()[docs] == "docs-site"
+
+
+def test_labels_add_parents_only_until_directories_differ(tmp_path):
+ from loopx.chat_actions import goal_workspace_labels
+
+ a, b, c = tmp_path / "a" / "x" / "app", tmp_path / "b" / "x" / "app", tmp_path / "solo"
+ labels = goal_workspace_labels([a, b, c, a])
+ assert labels == {a: "a/x/app", b: "b/x/app", c: "solo"}
+ assert not any(label.startswith(("/", "\\")) for label in labels.values())
+
+
+def test_project_list_survives_unreadable_goal_registry_while_creation_refuses(tmp_path):
+ # The granted project list never depended on the Goal registry. Naming it
+ # alongside Goal directories must not make a corrupt registry disconnect
+ # ordinary project conversations; Goal creation still refuses that source
+ # and works again once the registry is restored.
+ import http.client
+ import runpy
+ import threading
+ from pathlib import Path
+
+ from loopx.capabilities.native_chat.project_context import ChatProjectContexts
+ from loopx.chat_actions import goal_workspace_ref
+ from loopx.chat_server import ChatHTTPServer, ChatRequestHandler
+ from loopx.chat_store import ChatSessionStore
+
+ fixture = runpy.run_path(str(Path(__file__).resolve().parents[1] / "examples/loopx-chat-actions-smoke.py"))
+ registry, _ = fixture["write_registry_fixture"](tmp_path / "source")
+ runtime = Path(json.loads(registry.read_text())["common_runtime_root"])
+ docs = (tmp_path / "docs-site").resolve()
+ docs.mkdir()
+ sessions = ChatSessionStore(runtime)
+ controller = fixture["FakeRuntimeController"](sessions)
+ controller.project_contexts = ChatProjectContexts([docs])
+ server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler)
+ server.registry_path, server.runtime_root = registry, runtime
+ server.action_store = ChatActionStore(runtime / "chat/actions")
+ server.action_service = ChatActionService(store=server.action_store, registry_path=registry,
+ chat_store=sessions, runtime_controller=controller, workspace_roots=[docs])
+ server.chat_store, server.runtime_controller, server.verbose = sessions, controller, False
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ def request(path, body=None):
+ connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=30)
+ try:
+ connection.request("GET" if body is None else "POST", path,
+ None if body is None else json.dumps(body), {"Content-Type": "application/json"})
+ response = connection.getresponse()
+ return response.status, json.loads(response.read())
+ finally:
+ connection.close()
+
+ create = {"action_kind": "goal.create", "summary": "Create a Goal", "idempotency_key": "registry-recovery",
+ "context": {"kind": "manager", "goal_id": None},
+ "normalized_parameters": {"goal_id": "goal-docs", "title": "Docs", "objective": "Check the docs",
+ "agent_id": "codex", "workspace_ref": goal_workspace_ref(docs)}}
+ try:
+ original = registry.read_bytes()
+ registry.write_text("{not json", encoding="utf-8")
+ status, listed = request("/api/chat/projects")
+ assert status == 200
+ assert [(project["title"], project["goal_workspace_ref"]) for project in listed["projects"]] == [
+ ("docs-site", goal_workspace_ref(docs))]
+ status, refused = request("/api/actions/preview", create)
+ assert status == 400 and refused["ok"] is False
+
+ registry.write_bytes(original)
+ status, previewed = request("/api/actions/preview", create)
+ assert status == 201, previewed
+ assert previewed["proposal"]["workspace_binding"]["label"] == "docs-site"
+ proposal_id = previewed["proposal"]["proposal_id"]
+ status, applied = request(f"/api/actions/{proposal_id}/apply", {})
+ assert status in {200, 202}, applied
+ created = next(goal for goal in json.loads(registry.read_text())["goals"] if goal["id"] == "goal-docs")
+ assert Path(created["repo"]).resolve() == docs
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
diff --git a/tests/test_chat_ordinary_project.py b/tests/test_chat_ordinary_project.py
index f931cbce9a..4877aff29e 100644
--- a/tests/test_chat_ordinary_project.py
+++ b/tests/test_chat_ordinary_project.py
@@ -70,6 +70,8 @@ def test_http_ordinary_project_continues_native_session_without_goal_or_portfoli
assert status == 200 and len(projects["projects"]) == 1
project = projects["projects"][0]
assert project["title"] == "notes" and "workspace_path" not in project
+ # The same root is offered to the Goal form by its creation reference.
+ assert project["goal_workspace_ref"].startswith("workspace-")
body = {"context_kind": "project", "project_ref": project["project_ref"]}
status, opened = request("/api/chat/sessions", body)
assert status == 201, opened