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
13 changes: 11 additions & 2 deletions apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,13 +408,16 @@ fn compose_environment(
arch: &str,
runtime_executable_found: bool,
python3: (bool, Option<String>),
node: (bool, Option<String>),
) -> Value {
json!({
"os_version": os_version,
"arch": arch,
"runtime_executable_found": runtime_executable_found,
"python3_found": python3.0,
"python3_version": python3.1,
"node_found": node.0,
"node_version": node.1,
})
}

Expand Down Expand Up @@ -442,6 +445,7 @@ fn detect_environment() -> Value {
std::env::consts::ARCH,
runtime_executable_found,
crate::services::python3_environment(),
crate::services::node_environment(),
)
}

Expand Down Expand Up @@ -1907,19 +1911,22 @@ mod tests {
"aarch64",
false,
(true, Some("3.9.6".to_string())),
(false, None),
);
assert_eq!(environment["os_version"], "26.5");
assert_eq!(environment["arch"], "aarch64");
assert_eq!(environment["runtime_executable_found"], false);
assert_eq!(environment["python3_found"], true);
assert_eq!(environment["python3_version"], "3.9.6");
assert_eq!(environment["node_found"], false);
assert_eq!(environment["node_version"], Value::Null);
// The fresh-Mac signature: no Homebrew/CLT python3 answers a version
// probe, and the runtime executable has never been installed.
let fresh = compose_environment(None, "aarch64", false, (false, None));
let fresh = compose_environment(None, "aarch64", false, (false, None), (false, None));
assert_eq!(fresh["os_version"], Value::Null);
assert_eq!(fresh["python3_found"], false);
assert_eq!(fresh["python3_version"], Value::Null);
// The block carries exactly the five diagnostic keys — no paths,
// The block carries only coarse diagnostic keys — no paths,
// environment variables or free-form output can join it.
let keys: Vec<&str> = environment
.as_object()
Expand All @@ -1931,6 +1938,8 @@ mod tests {
keys,
[
"arch",
"node_found",
"node_version",
"os_version",
"python3_found",
"python3_version",
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/loopx-control-plane/src-tauri/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,14 @@ fn runtime_search_path(
paths.extend([
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/usr/local/bin"),
// Homebrew's supported LTS Node formulae are keg-only. Finder
// does not inherit the shell PATH that activates those runtimes.
// Use the same bounded search for the private installer and the
// services it starts; Core still qualifies the actual version.
PathBuf::from("/opt/homebrew/opt/node@24/bin"),
PathBuf::from("/usr/local/opt/node@24/bin"),
PathBuf::from("/opt/homebrew/opt/node@22/bin"),
PathBuf::from("/usr/local/opt/node@22/bin"),
]);
}
for path in inherited
Expand Down Expand Up @@ -908,6 +916,24 @@ pub(crate) fn python3_environment() -> (bool, Option<String>) {
(found, version)
}

pub(crate) fn node_environment() -> (bool, Option<String>) {
let path = runtime_search_path(env::var_os("HOME"), env::var_os("PATH"));
let executable = resolve_executable_path("node", Some(path.as_os_str()));
let found = executable.is_some();
let version = executable.and_then(|node| {
let mut probe = Command::new(node);
probe.arg("--version");
timed_output(probe).filter(|output| output.status.success()).and_then(|output| {
let text = std::str::from_utf8(&output.stdout).ok()?.trim().strip_prefix('v')?;
let fields: Vec<_> = text.split('.').collect();
(fields.len() == 3 && fields.iter().all(|field|
!field.is_empty() && field.bytes().all(|byte| byte.is_ascii_digit())))
.then(|| text.to_owned())
})
});
(found, version)
}

const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(1);
// Installation qualification boots Core, checks the TypeScript runtime and
// hashes the installed package. It is not a version-banner query: a valid cold
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ fn finder_runtime_path_includes_tools_without_loading_shell_profiles() {
.unwrap()
);
assert!(paths.contains(&PathBuf::from("/usr/local/bin")));
for prefix in ["/opt/homebrew", "/usr/local"] {
for version in ["24", "22"] {
assert!(paths.contains(&PathBuf::from(format!("{prefix}/opt/node@{version}/bin"))));
}
}
assert!(paths.contains(&PathBuf::from("/sbin")));
let again = runtime_search_path(Some("/fixture/user".into()), Some(path.clone()));
assert_eq!(again, path, "tool search must be idempotent");
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/loopx-control-plane/static/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const pageStarted = performance.now();
let lastTiming = null;
let timingObservedAt = pageStarted;
let bootState = null;
let observedEnvironment = null;
function renderStartup(result) {
const state = result?.state;
bootState = state;
Expand Down Expand Up @@ -125,6 +126,9 @@ function codeText(code, phase) {
return "启动参数 LOOPX_BIN 固定了当前运行时。请通过该安装方式修复它,或移除、修正 LOOPX_BIN 后重新打开 App;清除记住的选择不会改变此参数。";
}
if (typeof code === "string" && /^runtime_install_exit_(\d+|signal)$/.test(code)) {
if (code === "runtime_install_exit_1" && observedEnvironment?.node_found === false) {
return "安装程序退出(1);App 的启动环境未找到 Node.js。请启用受支持的 Node.js 后点击「修复当前版本」。";
}
// Exit 2 from install-local.sh is its "no usable Python 3.11+" gate; the
// same exit can technically be a usage error, so the wording stays
// probabilistic and points at the repair action.
Expand Down Expand Up @@ -168,9 +172,12 @@ function safeEnvironment(result) {
runtime_executable_found: flag(environment.runtime_executable_found),
python3_found: flag(environment.python3_found),
python3_version: text(environment.python3_version),
node_found: flag(environment.node_found),
node_version: text(environment.node_version),
};
}
function renderDiagnostics(result) {
observedEnvironment = safeEnvironment(result);
const failure = result.last_failure ?? result.state;
const text = JSON.stringify({
schema_version: "desktop_recovery_diagnostics_v2",
Expand Down
5 changes: 4 additions & 1 deletion apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,14 +646,17 @@ export async function requestJson<T>(url: string, init?: RequestInit): Promise<T
return parsedPayload as T;
}

export async function readTodoRequest(goalId: string, todoId: string, signal: AbortSignal) {
export async function readTodoRequest(goalId: string, todoId: string, signal: AbortSignal, sourceNamespace?: string) {
const query = new URLSearchParams({goal_id: goalId, todo_id: todoId});
if (sourceNamespace) query.set("source_namespace", sourceNamespace);
const result = z.object({
ok: z.literal(true), goal_id: z.string(), todo_id: z.string(), text: z.string(),
status: z.string(), archive_state: z.string(), updated_at: z.string().nullable(),
review_basis: todoUpdateSourceBasisSchema.optional(),
source_access: z.object({namespace: z.string()}).optional(),
}).parse(await requestJson<unknown>(`/api/chat/todo/detail?${query}`, {signal}));
if (result.goal_id !== goalId || result.todo_id !== todoId) throw new Error("Task source changed");
if (sourceNamespace && result.source_access?.namespace !== sourceNamespace) throw new Error("Task source changed");
return result;
}

Expand Down
1 change: 1 addition & 0 deletions apps/presentation/dashboard/src/data/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ export const runRecordSchema = z.object({
});

export const runGoalSchema = z.object({
source_access: z.object({namespace: z.string(), label: z.string(), read_only: z.literal(true)}).optional(),
parent_goal_id: z.string().nullable().optional().catch(null),
goal_objective: z.string().nullable().optional().catch(null),
zcode_goal_eligible_agent_ids: z.array(z.string()).optional().default([]).catch([]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const directorySchema = z.object({
goals: z.array(z.object({
id: z.string(),
parent_goal_id: z.string().nullable().optional().catch(null),
source_access: z.object({namespace: z.string(), label: z.string(), read_only: z.literal(true)}).optional(),
display_name: z.string(),
activation_state: z.enum(["active", "stopped"]),
registry_member: z.literal(true),
Expand Down Expand Up @@ -68,6 +69,7 @@ export function reusableGoalSnapshots(
const snapshot = previous.snapshots[goal.id];
if (!earlier || !snapshot || invalidated.has(goal.id)) return [];
if (earlier.display_name !== goal.display_name
|| earlier.source_access?.namespace !== goal.source_access?.namespace
|| earlier.parent_goal_id !== goal.parent_goal_id
|| earlier.activation_state !== goal.activation_state) return [];
return [[goal.id, snapshot]];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
: t("drawer.goalDetails");
const goalId = selection.kind === "proposal" ? selection.item.goalId ?? "manager"
: selection.item.goalId;
const taskNamespace = goals.find(goal => goal.goalId === goalId)?.sourceAccess?.namespace;
const taskReadable = !readOnly || Boolean(taskNamespace);
const contextLabel = selection.kind === "attention" ? selection.item.goalTitle ?? t("drawer.currentGoal")
: selection.kind === "todo" ? selection.item.goalTitle
: selection.kind === "run" ? selection.item.goalTitle
Expand Down Expand Up @@ -642,7 +644,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
) : selection.item.detailMode ? (
<section className="personal-task-inspector-summary">
<p className="personal-proposal-explainer">{t(selection.item.detailMode === "missing_current" ? "drawer.taskReadbackUnavailable" : "drawer.taskRequestOnly")}</p>
<TaskRequest todo={selection.item} local={!readOnly} />
<TaskRequest todo={selection.item} local={taskReadable} sourceNamespace={taskNamespace} />
</section>
) : (
<>
Expand All @@ -654,8 +656,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
{selection.item.priority ? <span>{selection.item.priority}</span> : null}
<span>{selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")}</span>
</div>
<TaskRequest key={`${selection.item.goalId}:${selection.item.todoId}`} todo={selection.item} local={!readOnly}
onSave={!todoCompleted ? callbacks.onSaveTodoRequirements : undefined} />
<TaskRequest key={`${taskNamespace}:${selection.item.goalId}:${selection.item.todoId}`} todo={selection.item} local={taskReadable} sourceNamespace={taskNamespace}
onSave={!readOnly && !todoCompleted ? callbacks.onSaveTodoRequirements : undefined} />
{selection.item.status === "blocked" ? <section aria-label={t("drawer.blockedReason")} className="personal-task-blocked-reason" data-recorded={selection.item.blockedReason ? "true" : "false"}>
<h4><AlertCircle size={14} />{t("drawer.blockedReason")}</h4>
<p>{selection.item.blockedReason ?? t("drawer.blockedReasonMissing")}</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,7 @@ const en = {
"header.managerOverview": "Overview",
"header.managerView": "Manager view",
"header.openGoalNavigation": "Open Goal navigation",
"header.readOnlySourceDescription": "{source} is displayed read-only through an SSH tunnel",
"header.readOnlySourceDescription": "Original records from {source}; execution remains on its source",
"header.refresh": "Refresh status",
"header.refreshDone": "Updated just now",
"header.refreshFailed": "Refresh failed",
Expand Down Expand Up @@ -2267,7 +2267,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"header.managerOverview": "总览",
"header.managerView": "管家视图",
"header.openGoalNavigation": "打开 Goal 导航",
"header.readOnlySourceDescription": "{source} 通过 SSH 隧道只读展示",
"header.readOnlySourceDescription": "显示 {source} 的原始记录;执行控制在原运行环境",
"header.refresh": "刷新状态",
"header.refreshDone": "刚刚更新",
"header.refreshFailed": "刷新失败",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export type WorkspaceGoalSubagentConfiguration = {
};

export type WorkspaceGoal = {
sourceAccess?: {namespace: string; label: string; readOnly: true};
/** Registry organization in this status source; not Agent ownership or execution dependency. */
parentGoalId?: string | null;
/** Bounded, safe text from this Goal's declared source document. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -916,7 +916,7 @@ export function PersonalWorkspacePage({
managerRuntime,
model,
researchApi,
readOnly = false,
readOnly: sourceReadOnly = false,
typedActionsRevision = 0,
selectedAgentId: controlledAgentId,
selectedAgentModelId,
Expand Down Expand Up @@ -963,6 +963,8 @@ export function PersonalWorkspacePage({
}) {
const { locale, t } = useWorkspaceI18n();
const [localGoalId, setLocalGoalId] = useState<string | null>(controlledGoalId ?? null);
const sourceGoal = model.goals.find(goal => goal.goalId === (controlledGoalId === undefined ? localGoalId : controlledGoalId));
const readOnly = sourceReadOnly || sourceGoal?.sourceAccess?.readOnly === true;
const [localAgentId, setLocalAgentId] = useState(controlledAgentId ?? agents.find((agent) => agent.available)?.agentId ?? "codex");
const selectionScope = `${statusSourceControl?.activeSource.statusUrl ?? "/status.json"}:${controlledGoalId === undefined ? localGoalId : controlledGoalId}`;
const runtimeSourceUnavailable = statusSourceControl?.activeSource.kind === "runtime"
Expand Down Expand Up @@ -2733,7 +2735,7 @@ export function PersonalWorkspacePage({
selectedAgentModelReadback={selectedAgentModelReadback}
selectedAgentReasoningEffort={selectedAgentReasoningEffort}
refreshState={refreshState}
readOnlySourceLabel={readOnly ? statusSourceControl?.activeSource.label : undefined}
readOnlySourceLabel={readOnly ? sourceGoal?.sourceAccess?.label ?? statusSourceControl?.activeSource.label : undefined}
selectedGoal={runtimeSourceUnavailable ? null : selectedGoal}
selectedGoalTab={selectedGoalTab}
goalLifecycleBusy={selectedGoal ? lifecycleBusyGoalIds.has(selectedGoal.goalId) : false}
Expand Down Expand Up @@ -2804,7 +2806,7 @@ export function PersonalWorkspacePage({
onOpenDetails={() => setSelection({ kind: "goal", item: selectedGoal })} onSelect={setSelection} onView={setSelectedGoalTab}
selectedTodoId={drawerSelection?.kind === "todo" ? drawerSelection.item.todoId : null}
board={taskBoard(!settingsOpen && selectedGoalTab === "overview")} results={goalResults(!settingsOpen && selectedGoalTab === "overview")}
historyRefreshRevision={historyRefreshRevision} readSourceUrl={statusSourceControl?.activeSource.kind === "runtime" && statusSourceControl.connectionState !== "loading" && statusSourceControl.connectionState !== "error" ? statusSourceControl.activeSource.statusUrl : undefined} proposals={proposals} onPreview={createPreview} />,
historyRefreshRevision={historyRefreshRevision} readSourceUrl={sourceGoal?.sourceAccess ? `/status.json?source_namespace=${sourceGoal.sourceAccess.namespace}` : statusSourceControl?.activeSource.kind === "runtime" && statusSourceControl.connectionState !== "loading" && statusSourceControl.connectionState !== "error" ? statusSourceControl.activeSource.statusUrl : undefined} proposals={proposals} onPreview={createPreview} />,
tasks: taskBoard(!settingsOpen && selectedGoalTab === "tasks"),
files: goalResults(selectedGoalTab === "files" && !settingsOpen),
chat: agentWorkActive ? <GoalAgentWorkPage key={`${selectionScope}:${selectedWorkAgentId}`} goal={selectedGoal} agentId={selectedWorkAgentId!} readOnly={readOnly}
Expand Down Expand Up @@ -2874,7 +2876,7 @@ export function PersonalWorkspacePage({
) : null}
{loopxMode?.session_id === conversationSessionId && loopxMode?.enabled && loopxMode.active_turn_id ? <label className="goal-loopx-message-mode">{locale === "zh-CN" ? "消息处理" : "Message delivery"}<select aria-label={locale === "zh-CN" ? "消息处理方式" : "Message delivery mode"} value={loopxDelivery} onChange={event => setLoopxDelivery(event.target.value as typeof loopxDelivery)}><option value="queue">{locale === "zh-CN" ? "下一轮处理" : "Next turn"}</option><option value="inbox">{locale === "zh-CN" ? "放入收件箱" : "Inbox"}</option><option value="steer">{locale === "zh-CN" ? "立即纠偏" : "Steer now"}</option></select><span role="status">{loopxMessageReceipt}</span></label> : null}
{readOnly ? (
<div className="personal-read-only-notice"><strong>{t("source.readOnlyNoticeTitle")}</strong><span>{t("source.readOnlyNoticeDescription")}</span></div>
sourceGoal?.sourceAccess ? null : <div className="personal-read-only-notice"><strong>{t("source.readOnlyNoticeTitle")}</strong><span>{t("source.readOnlyNoticeDescription")}</span></div>
) : <>
{!selectedGoal && !managerChatOpen && managerConversationReceiptVisible && managerMessages.length ? (
<ManagerConversationTray onReviewGoalDraft={reviewGoalDraft} onSuggestReply={suggestReply}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ type RequestRead = {identity: string} & (
| {phase: "ready"; detail: Awaited<ReturnType<typeof readTodoRequest>>}
);

export function TaskRequest({todo, local, onSave}: {todo: WorkspaceTodo; local: boolean;
export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: WorkspaceTodo; local: boolean; sourceNamespace?: string;
onSave?: PersonalWorkspaceCallbacks["onSaveTodoRequirements"]}) {
const {t} = useWorkspaceI18n();
const sourceId = todo.sourceTodoId;
const identity = JSON.stringify([local, todo.goalId, sourceId, todo.requestText, todo.status]);
const identity = JSON.stringify([local, sourceNamespace, todo.goalId, sourceId, todo.requestText, todo.status]);
const [read, setRead] = useState<RequestRead | null>(null);
const [retry, setRetry] = useState(0);
const [draft, setDraft] = useState<{text: string; basis: TodoUpdateSourceBasis} | null>(null);
Expand All @@ -31,7 +31,7 @@ export function TaskRequest({todo, local, onSave}: {todo: WorkspaceTodo; local:
if (!local || !sourceId) return;
const controller = new AbortController();
setRead({identity, phase: "loading"});
void readTodoRequest(todo.goalId, sourceId, controller.signal).then(detail => {
void readTodoRequest(todo.goalId, sourceId, controller.signal, sourceNamespace).then(detail => {
if (controller.signal.aborted) return;
setRead({identity, phase: "ready", detail});
if (rebaseRef.current && detail.review_basis) {
Expand All @@ -45,7 +45,7 @@ export function TaskRequest({todo, local, onSave}: {todo: WorkspaceTodo; local:
if (!controller.signal.aborted) setRead({identity, phase: "error"});
});
return () => controller.abort();
}, [identity, local, retry, todo.goalId, sourceId]);
}, [identity, local, retry, todo.goalId, sourceId, sourceNamespace]);
const current = local && read?.identity === identity ? read : null;
const detail = current?.phase === "ready" ? current.detail : null;
const editable = local && onSave && todo.claimedBy && sourceId && detail?.review_basis
Expand Down
Loading
Loading