Fix standalone workspace discovery and expose USER/TASKS docs

This commit is contained in:
OpenClaw Local
2026-03-14 19:22:12 +01:00
parent 06e88e50b3
commit a4a215ccac
8 changed files with 179 additions and 9 deletions
+3
View File
@@ -3,6 +3,9 @@
GATEWAY_URL=ws://127.0.0.1:18789
# Optional path overrides when your OpenClaw/Codex data is not in the default home locations.
# OPENCLAW_HOME=/path/to/.openclaw
# OPENCLAW_CONFIG_PATH=/path/to/openclaw.json
# OPENCLAW_WORKSPACE_ROOT=/path/to/workspace
# OPENCLAW_AGENT_ROOT=/path/to/one/agent/workspace
# CODEX_HOME=/path/to/.codex
# OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH=/path/to/subscription.json
READONLY_MODE=true
+3
View File
@@ -67,6 +67,9 @@ Phase 3: apply safe first-run settings
15. Only change these when the machine actually requires it:
- GATEWAY_URL
- OPENCLAW_HOME
- OPENCLAW_CONFIG_PATH
- OPENCLAW_WORKSPACE_ROOT
- OPENCLAW_AGENT_ROOT
- CODEX_HOME
- OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH
- UI_PORT
+3
View File
@@ -67,6 +67,9 @@
15. 只有在本机环境确实不同的时候,才修改:
- GATEWAY_URL
- OPENCLAW_HOME
- OPENCLAW_CONFIG_PATH
- OPENCLAW_WORKSPACE_ROOT
- OPENCLAW_AGENT_ROOT
- CODEX_HOME
- OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH
- UI_PORT
+7 -1
View File
@@ -314,6 +314,9 @@ UI_PORT=4310
# Optional only when your paths differ from the defaults:
# OPENCLAW_HOME=/path/to/.openclaw
# OPENCLAW_CONFIG_PATH=/path/to/openclaw.json
# OPENCLAW_WORKSPACE_ROOT=/path/to/workspace
# OPENCLAW_AGENT_ROOT=/path/to/one/agent/workspace
# CODEX_HOME=/path/to/.codex
# OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH=/path/to/subscription.json
```
@@ -321,6 +324,9 @@ UI_PORT=4310
Change only these values if your environment needs it:
- `GATEWAY_URL`: when your OpenClaw Gateway is not on the default local socket
- `OPENCLAW_HOME`: when OpenClaw is not stored in `~/.openclaw`
- `OPENCLAW_CONFIG_PATH`: when `openclaw.json` lives somewhere custom
- `OPENCLAW_WORKSPACE_ROOT`: when control-center is installed outside the OpenClaw workspace tree and you want to pin the true workspace root
- `OPENCLAW_AGENT_ROOT`: when control-center is not inside an agent workspace but you still want legacy agent-local memory/mission reads to point at one specific agent workspace
- `CODEX_HOME`: when Codex data is not stored in `~/.codex`
- `OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH`: when your billing/subscription snapshot lives somewhere custom
- `UI_PORT`: when `4310` is already in use
@@ -359,7 +365,7 @@ On your first launch, check these pages in order:
### 8. If something looks wrong
- Empty live activity usually means `GATEWAY_URL` is wrong or the OpenClaw Gateway is not running.
- Missing `Documents / Memory` agents usually means `OPENCLAW_HOME` points to the wrong OpenClaw root or `openclaw.json` is missing.
- Missing `Documents / Memory` agents usually means `OPENCLAW_HOME`, `OPENCLAW_CONFIG_PATH`, or `OPENCLAW_WORKSPACE_ROOT` points to the wrong place, or `openclaw.json` is missing.
- Missing usage/subscription data usually means `CODEX_HOME` or `OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH` needs to be set.
- If you only want a safe read-only dashboard, do not change the mutation defaults.
+7 -1
View File
@@ -300,6 +300,9 @@ UI_PORT=4310
# 只有路径不是默认值时才需要设置:
# OPENCLAW_HOME=/path/to/.openclaw
# OPENCLAW_CONFIG_PATH=/path/to/openclaw.json
# OPENCLAW_WORKSPACE_ROOT=/path/to/workspace
# OPENCLAW_AGENT_ROOT=/path/to/one/agent/workspace
# CODEX_HOME=/path/to/.codex
# OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH=/path/to/subscription.json
```
@@ -307,6 +310,9 @@ UI_PORT=4310
一般只需要在这些情况下修改:
- `GATEWAY_URL`:你的 Gateway 不在默认本地地址
- `OPENCLAW_HOME`OpenClaw 不在 `~/.openclaw`
- `OPENCLAW_CONFIG_PATH``openclaw.json` 不在默认位置
- `OPENCLAW_WORKSPACE_ROOT`control-center 装在工作区树外,需要显式指定工作区根目录
- `OPENCLAW_AGENT_ROOT`control-center 不在某个 agent 工作区内,但你仍希望旧版记忆/说明读取链指向指定 agent
- `CODEX_HOME`Codex 数据不在 `~/.codex`
- `OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH`:订阅或账单快照文件在自定义位置
- `UI_PORT``4310` 已被占用
@@ -344,7 +350,7 @@ UI_MODE=true npm run dev
### 8. 如果看起来不对
- 实时活动全空,通常是 `GATEWAY_URL` 错了,或者 OpenClaw Gateway 没启动
- `文档 / 记忆` 范围不对,通常是 `OPENCLAW_HOME` 指错了,或者 `openclaw.json` 不可读
- `文档 / 记忆` 范围不对,通常是 `OPENCLAW_HOME` / `OPENCLAW_CONFIG_PATH` / `OPENCLAW_WORKSPACE_ROOT` 指错了,或者 `openclaw.json` 不可读
- `用量 / 订阅` 没数据,通常是 `CODEX_HOME``OPENCLAW_SUBSCRIPTION_SNAPSHOT_PATH` 没配对
- 如果你只是想先安全观察,不要改默认的只读和 mutation 开关
+29
View File
@@ -1,5 +1,34 @@
# Progress
## Phase 154 (Workspace root overrides and USER/TASKS doc discovery) — Completed
- Scope:
- Fix document and memory file discovery when control-center is installed outside the OpenClaw workspace tree.
- Expose `USER.md` and `TASKS.md` in the workspace file workbench instead of silently omitting them.
- Changed files:
- `src/ui/server.ts`
- `.env.example`
- `README.md`
- `README.en.md`
- `INSTALL_PROMPT.md`
- `INSTALL_PROMPT.en.md`
- `test/ui-render-smoke.test.ts`
- `docs/PROGRESS.md`
- Implementation:
- Added explicit support for `OPENCLAW_CONFIG_PATH`, `OPENCLAW_WORKSPACE_ROOT`, and `OPENCLAW_AGENT_ROOT` in the UI server path resolution layer.
- Replaced the hardcoded `process.cwd()`-relative workspace-root assumption with a safer resolution order:
- explicit `OPENCLAW_WORKSPACE_ROOT`
- inferred workspace root from `openclaw.json`
- fallback to `OPENCLAW_HOME/workspace`
- Resolved configured agent workspace paths relative to the OpenClaw config directory so relative `workspace` entries no longer break when control-center is installed elsewhere.
- Added `USER.md` and `TASKS.md` to both shared and per-agent workspace document candidates, and raised their sort priority so they surface naturally in the workbench.
- Extended install docs and copy-paste install prompts so standalone installs outside the workspace tree now point users to the correct override envs instead of leaving discovery to fail silently.
- Verification:
- `npm run build`
- `npm test`
- `npm run smoke:ui`
- Remaining gap:
- The legacy agent-local memory summary helpers still use `OPENCLAW_AGENT_ROOT` for a few non-workbench fallbacks. Workbench discovery is fixed, but fully decoupling every legacy fallback from repo location can be done later if standalone users report it.
## Phase 153 (CLI insight cards for connection, memory, context, security, and update state) — Completed
- Scope:
- Add high-value OpenClaw CLI visibility surfaces without changing the existing information architecture, visual style, or operator workflows.
+94 -7
View File
@@ -1,5 +1,6 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, extname, join, relative, resolve } from "node:path";
@@ -127,13 +128,15 @@ import type {
const SNAPSHOT_PATH = join(process.cwd(), "runtime", "last-snapshot.json");
const OPENCLAW_HOME_DIR = process.env.OPENCLAW_HOME?.trim() || join(homedir(), ".openclaw");
const OPENCLAW_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH?.trim() || join(OPENCLAW_HOME_DIR, "openclaw.json");
const OPENCLAW_CONFIG_DIR = dirname(OPENCLAW_CONFIG_PATH);
const OPENCLAW_CRON_JOBS_CANDIDATES = [
join(OPENCLAW_HOME_DIR, "cron", "jobs.json"),
join(process.cwd(), "..", "..", "..", "..", "cron", "jobs.json"),
];
const DOCS_DIR = join(process.cwd(), "docs");
const README_PATH = join(process.cwd(), "README.md");
const AGENT_ROOT_DIR = join(process.cwd(), "..");
const AGENT_ROOT_DIR = process.env.OPENCLAW_AGENT_ROOT?.trim() || join(process.cwd(), "..");
const MEMORY_DIR_CANDIDATES = [
join(AGENT_ROOT_DIR, "memory"),
join(process.cwd(), "runtime", "digests"),
@@ -163,7 +166,11 @@ const EDITABLE_TEXT_CONTENT_MAX_CHARS = 240_000;
const SEARCH_LIMIT_MAX = 200;
const TASK_RUNTIME_ACTIVITY_WINDOW_MS = 6 * 60 * 60 * 1000;
const STALLED_RUNNING_SESSION_WINDOW_MS = 2 * 60 * 60 * 1000;
const OPENCLAW_WORKSPACE_ROOT = resolve(process.cwd(), "..", "..", "..");
const OPENCLAW_WORKSPACE_ROOT = resolveOpenClawWorkspaceRoot({
explicitWorkspaceRoot: process.env.OPENCLAW_WORKSPACE_ROOT?.trim(),
openclawHomeDir: OPENCLAW_HOME_DIR,
configPath: OPENCLAW_CONFIG_PATH,
});
const WORKSPACE_EDITABLE_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "coverage"]);
const WORKSPACE_EDITABLE_EXTENSIONS = new Set([".md", ".markdown"]);
const MEMORY_EDITABLE_EXTENSIONS = new Set([".md", ".markdown", ".txt"]);
@@ -171,6 +178,8 @@ const SHARED_DOCUMENT_FILE_CANDIDATES = [
"AGENTS.md",
"IDENTITY.md",
"SOUL.md",
"USER.md",
"TASKS.md",
"BOOTSTRAP.md",
"HEARTBEAT.md",
"TOOLS.md",
@@ -181,6 +190,8 @@ const AGENT_DOCUMENT_FILE_CANDIDATES = [
"AGENTS.md",
"IDENTITY.md",
"SOUL.md",
"USER.md",
"TASKS.md",
"HEARTBEAT.md",
"TOOLS.md",
"README.md",
@@ -214,7 +225,6 @@ const DASHBOARD_SECTIONS = [
"settings",
] as const;
const CONTROL_CENTER_MAPPING_TASK_IDS = new Set(["due-fast", "todo-second", "already-running", "unassigned"]);
const OPENCLAW_CONFIG_PATH = join(OPENCLAW_HOME_DIR, "openclaw.json");
const LEGACY_DASHBOARD_ROUTE_SECTION = {
"/calendar": "projects-tasks",
"/heartbeat": "overview",
@@ -225,6 +235,74 @@ const LEGACY_DASHBOARD_ROUTE_ANCHOR = {
"/heartbeat": "heartbeat-health",
"/tools": "tool-connectors",
} as const;
function resolveOpenClawWorkspaceRoot(input: {
explicitWorkspaceRoot?: string;
openclawHomeDir: string;
configPath: string;
}): string {
const explicit = input.explicitWorkspaceRoot?.trim();
if (explicit) return resolve(explicit);
const configText = safeReadTextFileSync(input.configPath);
const inferred = inferWorkspaceRootFromConfigText(configText, dirname(input.configPath));
if (inferred) return inferred;
return join(input.openclawHomeDir, "workspace");
}
function safeReadTextFileSync(path: string): string | undefined {
try {
return readFileSync(path, "utf8");
} catch {
return undefined;
}
}
function inferWorkspaceRootFromConfigText(raw: string | undefined, configDir: string): string | undefined {
if (!raw?.trim()) return undefined;
try {
return inferWorkspaceRootFromConfigObject(JSON.parse(raw) as unknown, configDir);
} catch {
return undefined;
}
}
function inferWorkspaceRootFromConfigObject(input: unknown, configDir: string): string | undefined {
const root = asObject(input);
const agents = asObject(root?.agents);
const list = asArray(agents?.list);
const mainRow = list
.map((item) => asObject(item))
.find((row) => normalizeLookupKey(asString(row?.id)?.trim() ?? asString(row?.name)?.trim() ?? "") === "main");
const explicitMainWorkspace = asString(mainRow?.workspace)?.trim();
if (explicitMainWorkspace) return resolve(configDir, explicitMainWorkspace);
const inferredRoots = new Set<string>();
for (const item of list) {
const row = asObject(item);
const rawWorkspace = asString(row?.workspace)?.trim();
if (!rawWorkspace) continue;
const workspacePath = resolve(configDir, rawWorkspace);
const parentDir = dirname(workspacePath);
if (basename(parentDir).toLowerCase() !== "agents") continue;
inferredRoots.add(dirname(parentDir));
}
if (inferredRoots.size === 1) return [...inferredRoots][0];
return undefined;
}
export function resolveOpenClawWorkspaceRootForSmoke(input: {
explicitWorkspaceRoot?: string;
openclawHomeDir: string;
configText?: string;
configPath?: string;
}): string {
const explicit = input.explicitWorkspaceRoot?.trim();
if (explicit) return resolve(explicit);
const configDir = dirname(input.configPath?.trim() || join(input.openclawHomeDir, "openclaw.json"));
const inferred = inferWorkspaceRootFromConfigText(input.configText, configDir);
if (inferred) return inferred;
return join(input.openclawHomeDir, "workspace");
}
const TASK_STATES: TaskState[] = ["todo", "in_progress", "blocked", "done"];
const SESSION_STATES: AgentRunState[] = ["idle", "running", "blocked", "waiting_approval", "error"];
const DOC_LINKS = [
@@ -8663,7 +8741,9 @@ function resolveStaffWorkspaceRoot(member: TeamMemberSnapshot): string {
const key = normalizeLookupKey(member.agentId);
if (key === "main") return OPENCLAW_WORKSPACE_ROOT;
const workspace = member.workspace.trim();
if (workspace && workspace !== "未标注" && workspace !== "unlisted") return workspace;
if (workspace && workspace !== "未标注" && workspace !== "unlisted") {
return resolve(OPENCLAW_CONFIG_DIR, workspace);
}
return join(OPENCLAW_WORKSPACE_ROOT, "agents", member.agentId);
}
@@ -8834,13 +8914,12 @@ async function buildEditableFileEntry(input: {
if (!meta.isFile() || meta.size > EDITABLE_TEXT_FILE_MAX_BYTES) return undefined;
const raw = await safeReadTextFile(input.sourcePath);
if (raw === undefined) return undefined;
const ext = extname(input.sourcePath).toLowerCase();
const relativePath = input.relativeBase
? relative(input.relativeBase, input.sourcePath) || basename(input.sourcePath)
: basename(input.sourcePath);
return {
scope: input.scope,
title: extractMarkdownHeading(raw) || basename(input.sourcePath, ext) || basename(input.sourcePath),
title: basename(input.sourcePath) || relativePath,
excerpt: toPlainSummary(raw, 160),
category: input.category,
sourcePath: input.sourcePath,
@@ -9038,6 +9117,8 @@ function documentFilePriority(relativePath: string): number {
"agents.md",
"identity.md",
"soul.md",
"user.md",
"tasks.md",
"bootstrap.md",
"heartbeat.md",
"tools.md",
@@ -9127,7 +9208,7 @@ function resolveEditableAgentScopesFromConfig(input: unknown): EditableAgentScop
const workspaceRoot =
facetKey === "main"
? OPENCLAW_WORKSPACE_ROOT
: resolve(asString(row.workspace)?.trim() || join(OPENCLAW_WORKSPACE_ROOT, "agents", rawId));
: resolveConfiguredWorkspaceRoot(asString(row.workspace)?.trim(), rawId);
output.push({
agentId: rawId,
facetKey,
@@ -9211,6 +9292,12 @@ function resolveEditableAgentScopesFromWorkspaceAgentIds(agentIds: string[]): Ed
return scopes.sort(compareEditableAgentScopes);
}
function resolveConfiguredWorkspaceRoot(rawWorkspace: string | undefined, agentId: string): string {
const workspace = rawWorkspace?.trim();
if (workspace) return resolve(OPENCLAW_CONFIG_DIR, workspace);
return join(OPENCLAW_WORKSPACE_ROOT, "agents", agentId);
}
async function resolveEditableFileEntry(
scope: EditableFileScope,
sourcePath: string,
+33
View File
@@ -425,11 +425,14 @@ test("memory and workspace sections expose editable file workbenches", async ()
assert(source.includes('const AGENT_DOCUMENT_FILE_CANDIDATES = ['));
assert(source.includes('"IDENTITY.md"'));
assert(source.includes('"SOUL.md"'));
assert(source.includes('"USER.md"'));
assert(source.includes('"TASKS.md"'));
assert(source.includes('"BOOTSTRAP.md"'));
assert(source.includes("listMemoryFacetOptions()"));
assert(source.includes("listWorkspaceFacetOptions()"));
assert(source.includes("facetOptions: memoryFacetOptions"));
assert(source.includes("facetOptions: workspaceFacetOptions"));
assert(source.includes('title: basename(input.sourcePath) || relativePath,'));
assert(source.includes("defaultFacetKey: \"main\""));
assert(source.includes("defaultFacetKey: \"main\""));
assert(source.includes("includeAllFacet: false"));
@@ -504,10 +507,40 @@ test("memory and workspace sections expose editable file workbenches", async ()
test("editable agent scopes follow configured agents before workspace folders", async () => {
const {
resolveOpenClawWorkspaceRootForSmoke,
resolveEditableAgentScopesFromConfigForSmoke,
resolveEditableAgentScopesWithFallbackForSmoke,
} = await import("../src/ui/server");
assert.equal(
resolveOpenClawWorkspaceRootForSmoke({
openclawHomeDir: "/home/test/.openclaw",
configPath: "/home/test/.openclaw/openclaw.json",
configText: JSON.stringify({
agents: {
list: [
{ id: "main" },
{ id: "pandas", workspace: "/srv/openclaw/workspace/agents/pandas" },
],
},
}),
}),
"/srv/openclaw/workspace",
);
assert.equal(
resolveOpenClawWorkspaceRootForSmoke({
explicitWorkspaceRoot: "/data/openclaw/workspace",
openclawHomeDir: "/home/test/.openclaw",
}),
"/data/openclaw/workspace",
);
assert.equal(
resolveOpenClawWorkspaceRootForSmoke({
openclawHomeDir: "/home/test/.openclaw",
}),
"/home/test/.openclaw/workspace",
);
const scopes = resolveEditableAgentScopesFromConfigForSmoke({
agents: {
list: [