Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function goalCreateContent(input: GoalContent, t: WorkspaceTranslate) {

/** One preview builder for explicit forms and conversational drafts. No effects. */
export function goalCreateRequest(input: GoalContent & {
agentId: string; permission: string; contextGoalId: string | null; operationId?: string;
workspaceRef?: string; agentId: string; permission: string; contextGoalId: string | null; operationId?: string;
}, t: WorkspaceTranslate): WorkspaceActionPreviewRequest {
const content = goalCreateContent(input, t);
return {
Expand All @@ -28,7 +28,7 @@ export function goalCreateRequest(input: GoalContent & {
normalizedParameters: {
...content, goal_id: `goal-${input.operationId ?? crypto.randomUUID()}`,
agent_id: input.agentId, permission: input.permission,
workspace_ref: "current", heartbeat: { enabled: false, cadence: "1d", timezone: "Asia/Shanghai" },
workspace_ref: input.workspaceRef ?? "current", heartbeat: { enabled: false, cadence: "1d", timezone: "Asia/Shanghai" },
stop_condition: "goal_complete",
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,13 @@ for (const contextGoalId of [null, "existing-research-goal"]) {
assert.equal(request.normalizedParameters.goal_id, "goal-new-research");
assert.notEqual(request.normalizedParameters.goal_id, contextGoalId);
}
const explicitWorkspace = goalCreateRequest({
objective: "Inspect the second project", completion: "A checked report", boundary: "Read only",
agentId: "research-agent", permission: "read_only", contextGoalId: "first-project",
workspaceRef: "second-project", operationId: "explicit-workspace",
}, (key) => key);
assert.equal(explicitWorkspace.normalizedParameters.workspace_ref, "second-project");
assert.equal(explicitWorkspace.context.goal_id, "first-project", "Conversation and workspace remain separate");
assert.match(page, /t\("proposal\.workspace\.current"\)/, "Create Goal localizes its execution workspace explanation");
assert.match(i18n, /Current local workspace \(no Repository bound\)/, "English workspace copy explains that no repository is bound");
assert.match(i18n, /当前本地工作区(未绑定 Repository)/, "Chinese workspace copy explains that no repository is bound");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2243,7 +2243,14 @@ export function PersonalWorkspacePage({
{composerSuggestions}
</div>
{!composerCollapsed ? <>
{actionDraft ? <WorkspaceActionForm draft={actionDraft} onClose={() => setActionDraft(null)} onPreview={(request) => createPreview(request)} /> : null}
{actionDraft ? <WorkspaceActionForm draft={actionDraft} workspaceGoals={workspaceGoals} onClose={() => setActionDraft(null)} onPreview={async (request) => {
const proposal = await createPreview(request, { select: false });
if (proposal.gate?.kind === "workspace_unavailable") {
throw new Error(proposal.gate.summary);
}
if (isCurrentConversation()) setSelection({ item: proposal, kind: "proposal" });
return proposal;
}} /> : null}
{imageAttachments.length ? <div className="personal-composer-images" aria-label={t("composer.imagesPending")}>{imageAttachments.map((attachment) => (
<figure key={attachment.id}>
<img alt={attachment.name} src={attachment.dataUrl} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { goalCreateContent, goalCreateRequest } from "./goal-create-request";
import { useEffect, useId, useRef, useState } from "react";
import { useWorkspaceI18n } from "./i18n";
import type { WorkspaceActionPreviewRequest } from "./personal-workspace-model";
import type { WorkspaceActionPreviewRequest, WorkspaceGoal } from "./personal-workspace-model";

// Action identity comes from an explicit control, never from the text being edited.
export type WorkspaceActionDraft = {
Expand All @@ -22,8 +22,9 @@ function boundedLength(value: string) {
return Array.from(value.split(/\s+/u).filter(Boolean).join(" ")).length;
}

export function WorkspaceActionForm({ draft, onClose, onPreview }: {
export function WorkspaceActionForm({ draft, workspaceGoals = [], onClose, onPreview }: {
draft: WorkspaceActionDraft;
workspaceGoals?: WorkspaceGoal[];
onClose: () => void;
onPreview: (request: WorkspaceActionPreviewRequest) => Promise<unknown>;
}) {
Expand All @@ -37,6 +38,7 @@ export function WorkspaceActionForm({ draft, onClose, onPreview }: {
const [objective, setObjective] = useState(draft.text ?? "");
const [completion, setCompletion] = useState(draft.completionCriteria ?? "");
const [boundary, setBoundary] = useState(draft.executionBoundary ?? "");
const [workspaceRef, setWorkspaceRef] = useState("current");
const [permission, setPermission] = useState<string>(draft.permission ?? "workspace_write_on_confirmation");
const [interval, setInterval] = useState(draft.kind === "heartbeat" ? "1" : "2");
const [unit, setUnit] = useState(draft.kind === "heartbeat" ? "d" : "h");
Expand Down Expand Up @@ -65,7 +67,7 @@ export function WorkspaceActionForm({ draft, onClose, onPreview }: {
setError("");
try {
if (draft.kind === "goal") {
await onPreview(goalCreateRequest({ objective, completion, boundary, permission,
await onPreview(goalCreateRequest({ objective, completion, boundary, permission, workspaceRef,
agentId: draft.agentId, contextGoalId: draft.goalId }, t));
onClose();
return;
Expand Down Expand Up @@ -103,6 +105,11 @@ export function WorkspaceActionForm({ draft, onClose, onPreview }: {
{draft.kind === "goal" ? <>
<label>{zh ? "完成标准" : "Completion criteria"}<textarea aria-label={zh ? "完成标准" : "Completion criteria"} required value={completion} onChange={(event) => setCompletion(event.target.value)} /></label>
<label>{zh ? "执行边界(可选)" : "Execution boundary (optional)"}<textarea aria-label={zh ? "执行边界(可选)" : "Execution boundary (optional)"} value={boundary} onChange={(event) => setBoundary(event.target.value)} /></label>
<label>{zh ? "工作目录" : "Working directory"}<select aria-label={zh ? "工作目录" : "Working directory"} value={workspaceRef} onChange={(event) => setWorkspaceRef(event.target.value)}>
<option value="current">{zh ? "默认工作区(确认前检查)" : "Default workspace (check before confirming)"}</option>
{workspaceGoals.map((goal) => <option key={goal.goalId} value={goal.goalId}>{goal.title} · {goal.repository?.label ?? goal.goalId}</option>)}
</select></label>
<p>{zh ? "可沿用已有 Goal 的工作目录。未连接的目录需要先完成项目连接;选择目录不会增加执行权限。" : "Reuse an existing Goal’s working directory. Unconnected directories need project setup first; selecting a directory does not expand execution permissions."}</p>
<label>{zh ? "执行权限" : "Execution permission"}<select aria-label={zh ? "执行权限" : "Execution permission"} value={permission} onChange={(event) => setPermission(event.target.value)}><option value="read_only">{zh ? "只读" : "Read only"}</option><option value="workspace_write_on_confirmation">{zh ? "确认后允许修改工作区" : "Allow workspace changes after confirmation"}</option></select></label>
</> : null}
</>}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions docs/guides/personal-workspace-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,26 @@ loopx goal-lifecycle --goal-id <goal-id> --operation resume --actor-kind owner -

---

### 创建 Goal 时选择已有工作目录 / Reuse a workspace when creating a Goal

打开侧边栏的「创建 Goal」,填写目标和完成标准后,在「工作目录」中选择一个已有
Goal,即可沿用它绑定的目录。检查配置并确认一次后,新的 Goal 在所选目录中创建;
执行权限仍由表单中的权限选择与后端校验决定。对话草稿也可通过「修改」打开同一个表单。
如果检查配置失败,目标文本与目录选择会保留供重试;失效或不可用的目录不会自动换成另一目录。

默认工作区继续由现有连接配置解析。这一入口支持沿用**已注册 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.

## 🎯 4. Goal 深度工作区

在侧边栏点击具体的 Goal(例如 `Apollo Spacecraft Telemetry Pipeline`),进入该 Goal 的独立工作台。
Expand Down
2 changes: 2 additions & 0 deletions examples/personal-workspace-browser-smoke.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import {goalWorkspaceSelectionScenario} from "./personal-workspace-browser/goal-workspace-selection.mjs";
import { replanCadenceScenario } from "./personal-workspace-browser/replan-cadence.mjs";
import {nativeChildActivityScenario} from "./personal-workspace-browser/native-child-activity.mjs";
import {privateStewardScopeScenario} from "./personal-workspace-browser/private-steward-scope.mjs";
Expand Down Expand Up @@ -97,6 +98,7 @@ scenarioCatalog.push(researchResultsScenario);
scenarioCatalog.push(prReviewAgentOrderScenario);
scenarioCatalog.push(taskInspectorReturnScenario);
scenarioCatalog.push(replanCadenceScenario);
scenarioCatalog.push(goalWorkspaceSelectionScenario);
const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO;
const scenarios = requestedScenario
? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario)
Expand Down
47 changes: 47 additions & 0 deletions examples/personal-workspace-browser/goal-workspace-backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Disposable real action/registry backend; only model dispatch is synthetic."""
import json
import os
import tempfile
from pathlib import Path
import runpy
import signal
import sys

from loopx.chat_action_store import ChatActionStore
from loopx.chat_actions import ChatActionService
from loopx.chat_server import ChatHTTPServer, ChatRequestHandler
from loopx.chat_store import ChatSessionStore
from loopx.control_plane.effect_runtime import restart_effect_runtime


root = Path(sys.argv[1]).resolve()
tempfile.tempdir = str(root)
for variable in ("TMPDIR", "TEMP", "TMP"):
os.environ[variable] = str(root)
fixture = runpy.run_path(str(Path(__file__).resolve().parents[1] / "loopx-chat-actions-smoke.py"))
registry, _ = fixture["write_registry_fixture"](root / "first")
other_registry, _ = fixture["write_registry_fixture"](root / "second")
data = json.loads(registry.read_text())
other = json.loads(other_registry.read_text())["goals"][0]
data["goals"][0]["id"] = "product-release"
other["id"] = "research-monitor"
data["goals"].append(other)
registry.write_text(json.dumps(data), encoding="utf-8")
runtime = Path(data["common_runtime_root"])
store, sessions = ChatActionStore(runtime / "chat/actions"), ChatSessionStore(runtime)
controller = fixture["FakeRuntimeController"](sessions)
service = ChatActionService(store=store, registry_path=registry,
chat_store=sessions, runtime_controller=controller)
server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler)
server.registry_path, server.runtime_root = registry, runtime
server.action_store, server.action_service = store, service
server.chat_store, server.runtime_controller = sessions, controller
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)
try:
server.serve_forever()
finally:
server.server_close()
restart_effect_runtime()
103 changes: 103 additions & 0 deletions examples/personal-workspace-browser/goal-workspace-selection.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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 {createInterface} from "node:readline";
import {resolveTestPython} from "../../scripts/test-python.mjs";
import {outputDir, repoRoot} from "./fixture.mjs";
import {openWorkspacePage} from "./scenario-context.mjs";

export const goalWorkspaceSelectionScenario = {
id: "goal-workspace-selection",
async run({browser, collectCoverage, url}) {
const root = await mkdtemp(resolve(tmpdir(), "loopx-goal-workspace-"));
const child = spawn(resolveTestPython(), ["-u", resolve(repoRoot, "examples/personal-workspace-browser/goal-workspace-backend.py"), root],
{cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"]});
const lines = createInterface({input: child.stdout});
let diagnostics = "", context;
child.stderr.on("data", chunk => {diagnostics = (diagnostics + chunk).slice(-6000);});
try {
const backend = await new Promise((accept, reject) => {
const timer = setTimeout(() => reject(new Error(`Workspace backend startup: ${diagnostics}`)), 30000);
child.once("error", error => {clearTimeout(timer); reject(error);});
child.once("exit", () => {clearTimeout(timer); reject(new Error(`Workspace backend exited: ${diagnostics}`));});
lines.once("line", line => {clearTimeout(timer); try {accept(JSON.parse(line));} catch (error) {reject(error);}});
});
const requests = [], applied = [];
let rejectPreview = true;
context = await openWorkspacePage(browser, url, {collectCoverage, beforeGoto: async (_api, page) => {
await page.route("**/api/actions{,/**,?*}", async route => {
const request = route.request(), path = new URL(request.url());
if (path.pathname.endsWith("/preview")) {
requests.push(request.postDataJSON());
if (rejectPreview) {
await route.fulfill({status: 503, json: {ok: false, error: "Workspace service unavailable; retry."}});
return;
}
}
const response = await route.fetch({url: `http://127.0.0.1:${backend.port}${path.pathname}${path.search}`});
if (path.pathname.endsWith("/apply")) applied.push(await response.json());
await route.fulfill({response});
});
}});
const {page} = context;
await page.getByRole("button", {name: "创建 Goal", exact: true}).first().click();
const form = page.getByRole("dialog", {name: "创建新 Goal"});
await form.getByLabel("目标", {exact: true}).fill("核对第二个项目的公开文档");
await form.getByLabel("完成标准", {exact: true}).fill("输出带来源的检查结果");
await form.getByLabel("执行权限", {exact: true}).selectOption("read_only");
const directory = form.getByLabel("工作目录", {exact: true});
await directory.selectOption("research-monitor");
await directory.focus();
assert.equal(await directory.evaluate(node => node === document.activeElement), true);
await page.screenshot({path: resolve(outputDir, "goal-workspace-selection-desktop.png"), animations: "disabled"});
await form.getByRole("button", {name: "检查配置"}).click();
await form.getByRole("alert").waitFor();
assert.equal(await directory.inputValue(), "research-monitor");
assert.equal(await form.getByLabel("目标", {exact: true}).inputValue(), "核对第二个项目的公开文档");
assert.equal(JSON.parse(await readFile(backend.registry, "utf8")).goals.length, 2);
await page.setViewportSize({width: 390, height: 844});
await directory.scrollIntoViewIfNeeded();
const bounds = await form.boundingBox();
assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= 390);
await page.screenshot({path: resolve(outputDir, "goal-workspace-selection-mobile.png"), animations: "disabled"});
rejectPreview = false;
await rename(backend.second, `${backend.second}-unavailable`);
await form.getByRole("button", {name: "检查配置"}).click();
await form.getByRole("alert").filter({hasText: "workspace is unavailable"}).waitFor();
assert.equal(await directory.inputValue(), "research-monitor");
assert.equal(JSON.parse(await readFile(backend.registry, "utf8")).goals.length, 2);
await rename(`${backend.second}-unavailable`, backend.second);
await form.getByRole("button", {name: "检查配置"}).click();
await page.getByText("确认执行", {exact: true}).waitFor();
assert.equal(requests.at(-1).normalized_parameters.workspace_ref, "research-monitor");
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();
await page.waitForFunction(() => document.body.innerText.includes("进入新 Goal"));
assert.equal(applied.length, 1);
assert.equal(applied[0].proposal.receipt.projection_verified, true);
const goalId = requests.at(-1).normalized_parameters.goal_id;
const registry = JSON.parse(await readFile(backend.registry, "utf8"));
const created = registry.goals.find(goal => goal.id === goalId);
assert.equal(created.repo, backend.second, "Confirmed Goal was created in the other workspace");
assert.equal(created.parent_goal_id, "research-monitor");
assert.notEqual(created.repo, backend.first);
const state = await readFile(resolve(created.repo, created.state_file), "utf8");
assert.match(state, /输出带来源的检查结果/);
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."};
} finally {
await context?.close();
lines.close();
if (child.exitCode === null && child.signalCode === null) {
const exited = new Promise(accept => child.once("exit", accept));
child.kill("SIGTERM");
const timer = setTimeout(() => child.kill("SIGKILL"), 5000);
try {await exited;} finally {clearTimeout(timer);}
}
await rm(root, {recursive: true, force: true});
}
},
};
Loading
Loading