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
1 change: 1 addition & 0 deletions apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"smoke:team-plan-proposal": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --ignoreDeprecations 6.0 --jsx react-jsx --types node --skipLibCheck --strict --rootDir ../../.. --outDir node_modules/.cache/loopx-team-plan-smoke smoke/team-plan-proposal-smoke.ts src/data/chat.ts src/features/personal-workspace/team-plan-preview.ts src/vite-env.d.ts && node node_modules/.cache/loopx-team-plan-smoke/apps/presentation/dashboard/smoke/team-plan-proposal-smoke.js",
"smoke:status-source-switch-browser": "node ../../../examples/status-source-switch-browser-smoke.mjs",
"smoke:status-source-switch-packaged": "LOOPX_STATUS_SOURCE_SWITCH_PACKAGED=1 LOOPX_STATUS_SOURCE_SWITCH_PORT=5198 node ../../../examples/status-source-switch-browser-smoke.mjs",
"smoke:runtime-source-workspace": "node ../../../examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs",
"smoke:status-sources": "rm -rf /tmp/loopx-status-source-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-status-source-smoke smoke/status-source-catalog-smoke.ts src/data/ssh-host-catalog.ts src/data/status-source-catalog.ts src/data/local-status-query.ts src/data/status.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-status-source-smoke/apps/presentation/dashboard/smoke/status-source-catalog-smoke.js",
"smoke:task-board-scroll": "node smoke/task-board-scroll-smoke.mjs",
"smoke:usage-progress": "tsc --ignoreConfig --target ES2022 --module ESNext --moduleResolution Bundler --allowImportingTsExtensions --types vite/client --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --noEmit smoke/usage-progress-smoke.ts src/features/personal-workspace/personal-workspace-model.ts && vite build --ssr smoke/usage-progress-smoke.ts --outDir node_modules/.cache/loopx-usage-progress-smoke --emptyOutDir && node node_modules/.cache/loopx-usage-progress-smoke/usage-progress-smoke.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
localStatusSource,
projectedStatusSourceForUrl,
removeStatusSource,
runtimeStatusSourceCatalog,
saveStatusSourceCatalog,
statusSourceCatalogStorageKey,
statusSourceForUrl,
Expand Down Expand Up @@ -124,4 +125,21 @@ equal(configuredDraft.command, "ssh -N -L 8876:127.0.0.1:8766 remote-lab", "the
equal(configuredDraft.statusUrl, "http://127.0.0.1:8876/status.json", "the selected Host maps to the loopback-only status source");
assert("error" in configuredSshTunnelDraft("remote-lab", "22"), "privileged local ports fail closed");

const runtimeUrl = "http://127.0.0.1:19801/status.json";
const runtimeEntries = [
{id: "retained", label: "Retained", status_url: runtimeUrl},
{id: "fresh", label: "Fresh", status_url: "http://127.0.0.1:19802/status.json"},
];
const runtimes = runtimeStatusSourceCatalog(runtimeEntries, runtimeUrl, baseHref);
assert(runtimes, "an owner-prepared catalog recognizes its current source");
assert(runtimes.sources.every((s) => s.readOnly && s.kind === "runtime" && !s.hostAlias), "runtime readers never acquire SSH or execution authority");
equal(activeStatusSourceForUrl(runtimes, runtimeEntries[1].status_url, runtimeUrl, baseHref).id, "fresh", "a failed or pending same-Goal source selection does not bounce to the loaded runtime");
equal(runtimeStatusSourceCatalog(undefined, runtimeUrl, baseHref), null, "existing services need no runtime catalog");
equal(runtimeStatusSourceCatalog(runtimeEntries, "http://127.0.0.1:19803/status.json", baseHref), null, "an unrelated service cannot replace source choices");
equal(runtimeStatusSourceCatalog([...runtimeEntries, runtimeEntries[0]], runtimeUrl, baseHref), null, "duplicate runtime ids or URLs fail closed");
equal(runtimeStatusSourceCatalog([{...runtimeEntries[0], status_url: "https://external.example/status.json"}], runtimeUrl, baseHref), null, "runtime readers remain loopback-only");
const runtimeStorage = new MemoryStorage();
saveStatusSourceCatalog(runtimeStorage, runtimes);
equal(loadStatusSourceCatalog(runtimeStorage, baseHref).sources.length, 1, "ephemeral runtime choices are not persisted as execution or SSH configuration");

console.log("status source catalog smoke: ok");
20 changes: 19 additions & 1 deletion apps/presentation/dashboard/src/data/status-source-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const statusSourceCatalogStorageKey = "loopx-status-source-catalog-v1";
export type StatusSource = {
hostAlias?: string;
id: string;
kind: "local" | "ssh_tunnel";
kind: "local" | "ssh_tunnel" | "runtime";
label: string;
readOnly: boolean;
statusUrl: string;
Expand All @@ -20,6 +20,24 @@ export type StatusSourceCatalog = {

export type StatusSourceStorage = Pick<Storage, "getItem" | "setItem">;

/** Runtime choices describe physical readers, not new execution Goals. */
export function runtimeStatusSourceCatalog(
entries: {id: string; label: string; status_url: string}[] | undefined,
currentUrl: string,
baseHref: string,
): StatusSourceCatalog | null {
if (!entries?.length || entries.length > 12) return null;
const sources: StatusSource[] = [];
for (const entry of entries) {
const resolved = normalizedTunnelUrl(entry.status_url, baseHref);
if (!("url" in resolved) || sources.some((s) => s.id === entry.id || s.statusUrl === resolved.url)) return null;
sources.push({id: entry.id, label: entry.label, kind: "runtime", readOnly: true, statusUrl: resolved.url});
}
const current = new URL(currentUrl, baseHref).toString();
// A source cannot replace the selected origin with an unrelated catalog.
return sources.some((s) => s.statusUrl === current) ? {schemaVersion: 1, sources} : null;
}

export const localStatusSource: StatusSource = {
id: "local",
kind: "local",
Expand Down
6 changes: 6 additions & 0 deletions apps/presentation/dashboard/src/data/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,12 @@ export const localDashboardApiSchema = z.object({
configure_goal_dry_run_url: z.string().optional().nullable(),
configure_goal_apply_url: z.string().optional().nullable(),
control_plane_write_enabled: z.boolean().optional().default(false),
// Ephemeral, owner-prepared read sources; never execution registry entries.
read_only_sources: z.array(z.object({
id: z.string().min(1).max(96),
label: z.string().min(1).max(48),
status_url: z.string().min(1),
})).max(12).optional(),
}).optional().nullable();

// Core keeps the presentation status contract provider-neutral. A ready/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ function relativeLookLabel(iso: string, locale: string): string {
return new Intl.DateTimeFormat(locale === "zh-CN" ? "zh-CN" : "en", { month: "short", day: "numeric" }).format(then);
}

export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenDetails, onSelect, onView, selectedTodoId, board, results, proposals, onPreview, historyRefreshRevision, review, digest, railOpen, onCloseRail, onOpenAgentConversation, sinceYouLooked, onMarkSeen }: {
export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenDetails, onSelect, onView, selectedTodoId, board, results, proposals, onPreview, historyRefreshRevision, readSourceUrl, review, digest, railOpen, onCloseRail, onOpenAgentConversation, sinceYouLooked, onMarkSeen }: {
readSourceUrl?: string;
onOpenAgentConversation?: (agentId: string) => void;
/** Material changes since the owner's last acknowledged look; null when none. */
sinceYouLooked?: SinceYouLookedDelta | null; onMarkSeen?: () => void;
Expand Down Expand Up @@ -104,7 +105,7 @@ export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenD
{moreAttention ? <button className="goal-overview-more" type="button" onClick={() => onView("tasks")}>{copy.tasks}{totalAttention !== null ? ` (${totalAttention})` : ""}<ArrowRight aria-hidden size={14} /></button> : null}
</section> : null}
</div>
{!readOnly ? <GoalResearchHistory key={goal.goalId} goalId={goal.goalId} active={active} zh={locale === "zh-CN"} refreshRevision={historyRefreshRevision}/> : null}
{!readOnly || readSourceUrl ? <GoalResearchHistory key={goal.goalId} goalId={goal.goalId} readSourceUrl={readSourceUrl} active={active} zh={locale === "zh-CN"} refreshRevision={historyRefreshRevision}/> : null}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 在手机主页面显示当前来源的刷新失败

390px,关闭 Goal 导航,先加载 Fresh 的完整 92/92 历史,再断开该实际来源并点击刷新状态。 API 已返回503,但主页面继续显示正常 Goal 和92/92完整历史;唯一失败提示0x0不可见,必须打开隐藏导航才知道刷新失败。没有发现来源替换;缺口是失败与陈旧状态不可见。

Propagate active runtime source read/refresh failure into the always-visible workspace header or history region, retaining old rows only with explicit stale/unavailable feedback and a usable retry. Add a 390px closed-navigation disconnect/recover regression.

<GoalWorkspaceWork onOpenAgentConversation={onOpenAgentConversation} goal={goal} digest={digest} items={items} userTodos={userTodos} selectedTodoId={selectedTodoId} onSelect={onSelect}
readOnly={readOnly} active={active} railOpen={railOpen} onCloseRail={onCloseRail} historyRefreshRevision={historyRefreshRevision} proposals={proposals} onPreview={onPreview} board={board} results={results}
map={readOnly ? <p className="goal-work-scope">{copy.remote}</p> : <DeliveryWorkMap goal={goal} userTodos={userTodos} review={review} onSelect={onSelect} selectedTodoId={selectedTodoId}/>}/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ type Pack = ResearchProgressPack & {source_count: number; analysis_key: string;
objective?: string; episodes?: TrajectoryEpisode[]; job?: Job | null};
type Snapshot = {ok: true; catalog: {availability: string; reason_code: string | null; default_pack_id: string | null; packs: Pack[]}; pack: Pack | null};
type Citation = AssessmentCitation & {artifact: string; text: string; applicability: string};
const endpoint = "/api/chat/research-analysis";
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
const locationFor = (goal: string) => {
const query = new URLSearchParams(window.location.search);
const owner = query.get("research_analysis") ?? query.get("research_analysis_goal");
return {open: query.get("research_analysis") === goal, packId: owner === goal ? query.get("research_pack") : null};
};

export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean) {
export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean, readSourceUrl?: string) {
const endpoint = readSourceUrl ? new URL("/api/chat/research-analysis", readSourceUrl).toString() : "/api/chat/research-analysis";
const [citation, setCitation] = useState<Citation | null>(null);
const [citationError, setCitationError] = useState("");
const [citationBusy, setCitationBusy] = useState(false);
Expand Down Expand Up @@ -58,7 +58,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean
}
if (current === generation.current && sequence === readSequence.current && scope.current.goalId === goalId && scope.current.packId === packId && !signal?.aborted) {setSnapshot(value); setReadError(""); if (value.pack?.job?.status === "succeeded" || value.pack?.job?.status === "cancelled") setError("");}
return value;
}, [goalId, location.packId, zh]);
}, [goalId, location.packId, zh, endpoint]);
const applyLocation = useCallback((next: typeof location) => {
// Selection and browser history share one source transition. Invalidate
// pending reads/actions before a new scope can display the old snapshot.
Expand Down Expand Up @@ -116,6 +116,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean
requestAnimationFrame(() => {panel.current?.focus({preventScroll: true}); panel.current?.scrollIntoView({block: "start"});});
};
const execute = async (retry = false) => {
if (readSourceUrl) return;
if (!pack || pack.availability !== "available" || submitting.current) return;
show(); submitting.current = true; setPending(true); setError("");
const current = generation.current;
Expand All @@ -126,6 +127,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean
finally {if (current === generation.current) {submitting.current = false; setPending(false);}}
};
const cancel = async () => {
if (readSourceUrl) return;
if (!pack || cancelPending) return;
const current = generation.current;
setCancelPending(true);
Expand All @@ -147,7 +149,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean
finally {if (current === citeGeneration.current) setCitationBusy(false);}
};
const closeCitation = () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); requestAnimationFrame(() => sourceTrigger.current?.focus());};
return {citation, citationBusy, citationError, sourcePanel, readCitation, closeCitation, goalId, zh, location, snapshot, pack, readError, error: error || readError, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel,
return {readOnly: Boolean(readSourceUrl), citation, citationBusy, citationError, sourcePanel, readCitation, closeCitation, goalId, zh, location, snapshot, pack, readError, error: error || readError, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel,
close: () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); navigate(false); requestAnimationFrame(() => trigger.current?.focus());}, select: (id: string) => navigate(location.open, id)};
}
type Controller = ReturnType<typeof useResearchAnalysis>;
Expand All @@ -164,7 +166,7 @@ const reasonCopy = (reason: string | null | undefined, zh: boolean) => {
};

export function ResearchAnalysisButton({analysis: a}: {analysis: Controller}) {
const unavailable = a.pack?.availability !== "available";
const unavailable = a.readOnly || a.pack?.availability !== "available";
// The action keeps its separate-usage consequence visible. Further method
// explanation is optional; cached results and unavailable states are reads.
const readOnly = unavailable || a.pack?.result || a.pending || a.recordedRunning;
Expand All @@ -179,6 +181,7 @@ export function ResearchAnalysisButton({analysis: a}: {analysis: Controller}) {

export function ResearchAnalysisStatus({analysis: a}: {analysis: Controller}) {
const p = a.pack;
if (a.readOnly && !p) return <p className="research-history-note">{a.zh ? "此运行尚未接入深度分析。" : "Deep analysis is not connected for this runtime."}</p>;
if (!p) return <p className="research-history-note">{a.loading ? (a.zh ? "正在核对可分析来源;浏览不调用 Agent。" : "Checking analysis sources. Browsing never invokes an Agent.") : reasonCopy(a.snapshot?.catalog.reason_code, a.zh)}</p>;
return !p.result && p.availability !== "available" ? <p className="research-history-note">{reasonCopy(p.reason_code, a.zh)}</p> : null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ const selectedFromUrl = (goal: string): Selection => {

/** An effect-free history consumer. Its source is Explore, not raw sessions or
* a judge. All recorded updates survive; unsupported analysis stays unavailable. */
export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0}: {goalId: string; active: boolean; zh: boolean; refreshRevision?: number}) {
const analysis = useResearchAnalysis(goalId, active, zh);
export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, readSourceUrl}: {goalId: string; active: boolean; zh: boolean; refreshRevision?: number; readSourceUrl?: string}) {
const analysis = useResearchAnalysis(goalId, active, zh, readSourceUrl);
const [read, setRead] = useState<Read>({events: [], total: null, complete: false});
const [busy, setBusy] = useState(true);
const [error, setError] = useState("");
Expand Down Expand Up @@ -107,7 +107,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0}: {
let cursor = "";
do {
const query = new URLSearchParams({goal_id: goalId, view: "history", ...(cursor ? {cursor} : {})});
const page = parseExploreHistoryPage(await requestJson<unknown>(`/api/chat/explore-results?${query}`, {signal: controller.signal}), goalId);
const page = parseExploreHistoryPage(await requestJson<unknown>(readSourceUrl ? new URL(`/api/chat/explore-results?${query}`, readSourceUrl).toString() : `/api/chat/explore-results?${query}`, {signal: controller.signal}), goalId);
if (total !== -1 && total !== page.total) throw new Error("Explore history snapshot changed");
total = page.total; events.push(...page.items);
if (events.length > total || (page.next_cursor && (!page.items.length || cursors.has(page.next_cursor)))) throw new Error("Invalid Explore history pagination");
Expand All @@ -129,7 +129,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0}: {
} finally {if (current) setBusy(false);}
})();
return () => {current = false; controller.abort();};
}, [goalId, active, revision, refreshRevision]);
}, [goalId, active, revision, refreshRevision, readSourceUrl]);
const history = useMemo(() => {
try {return {value: projectExploreHistory(read.events, goalId), error: ""};}
catch (failure) {return {value: null, error: String(failure)};}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function GoalSidebar({
const [sorting, setSorting] = useState(false);
const tree = useMemo(() => goalHierarchy(goals), [goals]);
const siblings = (from: string, to: string) => tree.parents.get(from) === tree.parents.get(to);
const ordering = useGoalOrder(goals.filter((goal) => goal.activationState !== "stopped"), statusSourceControl?.activeSource.statusUrl ?? "/status.json", siblings);
const ordering = useGoalOrder(goals.filter((goal) => goal.activationState !== "stopped"), statusSourceControl?.activeSource.statusUrl ?? "/status.json", siblings, statusSourceControl?.activeSource.kind === "runtime");
const activeGoals = ordering.sorted;
const stoppedGoals = goals.filter((goal) => goal.activationState === "stopped");
const siblingGroups = new Map<string | null, WorkspaceGoal[]>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,7 @@ const en = {
"source.namePlaceholder": "Remote development machine",
"source.notAvailable": "Unavailable",
"source.readOnly": "SSH tunnel · read only",
"source.runtimeReadOnly": "Runtime · read only",
"source.readOnlyNoticeDescription": "You can view Goals, Tasks, evidence, and run status. Writes, guidance, and Agent sessions remain on the source host.",
"source.readOnlyNoticeTitle": "Remote read-only projection",
"source.readOnlyWriteError": "Remote SSH tunnel sources are read-only projections and cannot perform control-plane writes.",
Expand Down Expand Up @@ -2706,6 +2707,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"source.namePlaceholder": "远程开发机",
"source.notAvailable": "不可用",
"source.readOnly": "SSH 隧道 · 只读",
"source.runtimeReadOnly": "运行来源 · 只读",
"source.readOnlyNoticeDescription": "你可以查看 Goal、Task、证据与运行状态;写入、纠偏和 Agent 会话仍留在来源主机。",
"source.readOnlyNoticeTitle": "远端只读投影",
"source.readOnlyWriteError": "远端 SSH 隧道来源是只读投影,不能执行控制面写入。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2789,7 +2789,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} proposals={proposals} onPreview={createPreview} />,
historyRefreshRevision={historyRefreshRevision} readSourceUrl={statusSourceControl?.activeSource.kind === "runtime" ? 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
Loading
Loading