diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index eddda1027d..e3f6022e76 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1397,6 +1397,28 @@ const en = { "brief.completedEmpty": "No completed work yet", "brief.completedTotal": "{count} completed in total", "home.allGoals": "All Goals", + "home.firstGoal.title": "No Goals yet", + "home.firstGoal.lead": "A Goal is one outcome you want; the Agent keeps working toward it until you stop it. Describe the outcome in your own words. The LoopX Manager drafts the Goal and nothing is created until you confirm.", + "home.firstGoal.greeting": "Let’s start with your first Goal.", + "home.firstGoal.create": "Create a Goal", + "home.firstGoal.examplesTitle": "Or start from an example. It only fills the message box; nothing is sent until you press send.", + "home.firstGoal.example.research": "Help me shape a goal to research Microsoft’s cash flow over the last three fiscal years from its official reports.", + "home.firstGoal.example.issue": "Help me shape a goal to turn one open issue in a repository I choose into a reviewed patch.", + "home.firstGoal.example.monitor": "Help me shape a goal to watch a public web page every weekday and report only when its content changes.", + "home.firstGoal.diagnostics": "Status source details", + "home.health.payloadNotOk": "The status source reports that it is not healthy.", + "home.health.contractFailed": "Control-plane contract checks failed: {errors} error(s), {warnings} warning(s).", + "home.health.contractFailedDetail": "Control-plane contract checks failed: {detail}", + "home.health.contractFailedUnknown": "Control-plane contract checks failed.", + "home.health.registryHigh": "Registry checks found {count} high-severity finding(s).", + "home.health.diagnostics": "Diagnostics", + "home.health.sourceUnreadable": "The status source could not be read. What is shown is the last successful read.", + "home.health.sourceUnreadableDetail": "Latest read failed: {error}", + "home.health.retry": "Retry", + "home.health.allClear": "All control-plane contract and registry checks passed.", + "home.health.checked": "Status checks completed.", + "home.health.concerns": "{count} concern(s) found.", + "home.health.freshness": "{count} decision state(s) are stale.", "state.stopped": "Stopped", "state.waiting": "Waiting", "tasks.blocked": "Blocked", @@ -2881,6 +2903,28 @@ const zhCN: Record = { "brief.completedEmpty": "还没有完成的工作", "brief.completedTotal": "累计完成 {count} 项", "home.allGoals": "全部 Goal", + "home.firstGoal.title": "还没有 Goal", + "home.firstGoal.lead": "一个 Goal 就是你想要的一个结果,Agent 会持续推进它直到你叫停。用你自己的话描述想要的结果,LoopX 管家会先整理成 Goal 草稿,你确认之前不会创建任何东西。", + "home.firstGoal.greeting": "我们从第一个 Goal 开始。", + "home.firstGoal.create": "创建 Goal", + "home.firstGoal.examplesTitle": "也可以从一个例子开始。点选只会填入输入框,按发送前不会发出任何内容。", + "home.firstGoal.example.research": "帮我整理一个目标:依据官方财报研究微软最近三个财年的现金流。", + "home.firstGoal.example.issue": "帮我整理一个目标:把我指定仓库里的一个未关闭 issue 做成一个经过评审的补丁。", + "home.firstGoal.example.monitor": "帮我整理一个目标:每个工作日查看一个公开网页,只有内容变化时才向我汇报。", + "home.firstGoal.diagnostics": "状态来源详情", + "home.health.payloadNotOk": "状态来源报告自身不健康。", + "home.health.contractFailed": "控制面契约检查未通过:{errors} 项错误,{warnings} 项警告。", + "home.health.contractFailedDetail": "控制面契约检查未通过:{detail}", + "home.health.contractFailedUnknown": "控制面契约检查未通过。", + "home.health.registryHigh": "注册表检查发现 {count} 项高危问题。", + "home.health.diagnostics": "诊断信息", + "home.health.sourceUnreadable": "状态来源读取失败,当前显示的是上一次成功读取的内容。", + "home.health.sourceUnreadableDetail": "最近一次读取失败:{error}", + "home.health.retry": "重试", + "home.health.allClear": "所有控制面契约与注册表检查均正常。", + "home.health.checked": "状态检查已完成。", + "home.health.concerns": "发现 {count} 项需要关注。", + "home.health.freshness": "{count} 项决策状态已过期。", "state.stopped": "已停止", "state.waiting": "等待条件", "tasks.blocked": "受阻", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index 7fc03c2055..2efa09d6c0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -585,4 +585,19 @@ assert.match( "The Goal header prefers the current projected Todo over stale recommended_action strings", ); +// First-Goal home: a healthy source with no registered Goal is a designed +// state, not empty lanes, and health issues are owner-language sentences. +assert.match(model, /kind: "no_goals"/, "The home carries a typed first-Goal classification"); +assert.match(dashboard, /const onboarding: WorkspaceHomeOnboarding \| undefined = isHealthy/, "Only a healthy source classifies as a first-Goal workspace"); +assert.match(dashboard, /if \(!sourceError\) return loadedModel;[\s\S]*?onboarding: undefined,[\s\S]*?retryable: true,/, "A failed current read withdraws the first-Goal state and shows a retryable failure"); +assert.match(dashboard, /onRefresh=\{async \(readScope = "all"\) => \{[\s\S]*?rejectOnError: true[\s\S]*?return \{ ok: false, error: formatStatusError\(error\) \}/, "A refresh reports its failure to the page instead of swallowing it"); +assert.match(page, /if \(result && result\.ok === false\) \{\n\s*setRefreshState\("error"\);/, "The header reports a failed refresh and never an update"); +assert.doesNotMatch(dashboard, /payload\.ok === false|契约检查未通过|系统健康关注点/, "Health issues are localized owner language, not protocol fragments"); +assert.match(page, /data-testid="personal-first-goal"/, "The home renders the first-Goal state"); +assert.match(page, /onSuggestRequest\(t\(`home\.firstGoal\.example\./, "Example requests only fill the composer"); +assert.match(page, /personal-first-goal-diagnostics/, "Status source details stay in a collapsed disclosure"); +for (const key of ["home.firstGoal.title", "home.firstGoal.lead", "home.firstGoal.create", "home.health.contractFailed", "home.health.concerns"]) { + assert.equal((i18n.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) ?? []).length, 2, `${key} exists in both locales`); +} + console.log("personal workspace drawer contract smoke passed"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 3611611929..5ceca40824 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -434,12 +434,31 @@ export type WorkspaceGoalNotification = { }; export type WorkspaceSystemHealth = { + /** Status-source contract lines for a collapsed disclosure; never primary copy. */ + diagnostics?: string[]; freshnessWarning?: string | null; issues: string[]; ok: boolean; + /** The current read of the source failed; a retry can clear it. */ + retryable?: boolean; summary: string; }; +/** Outcome of a workspace refresh; a failed read never rejects, it reports. */ +export type WorkspaceRefreshResult = { ok: true } | { ok: false; error: string }; + +/** + * Typed first-Goal classification of the manager home. `no_goals` means the + * status source reports zero registered Goals and no execution history, so + * the home shows its first-Goal state instead of empty lanes. `diagnostics` + * carries the status source's own contract lines for a collapsed disclosure; + * it is never primary copy. + */ +export type WorkspaceHomeOnboarding = { + kind: "no_goals"; + diagnostics: string[]; +}; + export type WorkspaceModel = { blockingTodoCount: number; goalNotifications?: WorkspaceGoalNotification[]; @@ -449,6 +468,7 @@ export type WorkspaceModel = { error?: string | null; loading: boolean; }; + onboarding?: WorkspaceHomeOnboarding; systemHealth?: WorkspaceSystemHealth; timeline?: WorkspaceTimelineItem[]; attentionHistory?: WorkspaceAttention[]; @@ -490,6 +510,7 @@ export type PersonalHomeCompatibleModel = { goalNotifications?: WorkspaceGoalNotification[]; goals: WorkspaceGoal[]; openUserTodoCount: number; + onboarding?: WorkspaceHomeOnboarding; systemHealth?: WorkspaceSystemHealth; attentionHistory?: WorkspaceAttention[]; userTodos: WorkspaceAttention[]; @@ -545,7 +566,7 @@ export type PersonalWorkspaceCallbacks = { * names the Goals the action touched, so a peer's snapshot is not re-read for it. */ onReconcileStatus?: (options?: { invalidateGoalIds?: string[] }) => void | Promise; /** Full refresh by default; error recovery can explicitly read missing Goals only. */ - onRefresh?: (scope?: WorkspaceReadScope) => void | Promise; + onRefresh?: (scope?: WorkspaceReadScope) => void | WorkspaceRefreshResult | Promise; onRetryGoalArchive?: () => void | Promise; onPreviewAction?: (request: WorkspaceActionPreviewRequest) => WorkspaceActionPreview | Promise; onRequestGoalCreate?: () => WorkspaceActionPreview | Promise | void; @@ -596,6 +617,7 @@ export function normalizePersonalHomeModel(model: PersonalHomeCompatibleModel): goalNotifications: model.goalNotifications, goals: model.goals, openUserTodoCount: model.openUserTodoCount, + onboarding: model.onboarding, systemHealth: model.systemHealth, attentionHistory: model.attentionHistory, userTodos: model.userTodos, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 85df233936..dd6b7d4dcd 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -73,6 +73,7 @@ import type { WorkspaceImageAttachment, WorkspaceModel, WorkspaceRun, + WorkspaceHomeOnboarding, WorkspaceSystemHealth, WorkspaceConversationDirectory, WorkspaceTimelineItem, @@ -120,6 +121,10 @@ function ManagerHomeBoard({ goals, onSelectGoal, onRetry, + onRetrySource, + onboarding, + onRequestGoalCreate, + onSuggestRequest, systemHealth, operations, onSelectOperation, @@ -128,6 +133,11 @@ function ManagerHomeBoard({ goals: WorkspaceGoal[]; onSelectGoal: (goalId: string) => void; onRetry?: () => void; + /** Re-read the whole source after its latest read failed. */ + onRetrySource?: () => void; + onboarding?: WorkspaceHomeOnboarding; + onRequestGoalCreate?: () => void; + onSuggestRequest?: (text: string) => void; systemHealth?: WorkspaceSystemHealth; operations: WorkspaceActionPreview[]; onSelectOperation: (proposal: WorkspaceActionPreview) => void; @@ -176,8 +186,44 @@ function ManagerHomeBoard({ ))} ) : null} + {systemHealth.diagnostics?.length ? ( +
+ {t("home.health.diagnostics")} +
    {systemHealth.diagnostics.map((line, idx) =>
  • {line}
  • )}
+
+ ) : null} + {systemHealth.retryable && onRetrySource ? ( + + ) : null} ) : null} + {onboarding?.kind === "no_goals" && goals.length === 0 ? ( +
+

{t("home.firstGoal.title")}

+

{t("home.firstGoal.lead")}

+ {onRequestGoalCreate ? ( +
+ +
+ ) : null} + {onSuggestRequest ? ( +
    +
  • {t("home.firstGoal.examplesTitle")}
  • + {(["research", "issue", "monitor"] as const).map((example) => ( +
  • + +
  • + ))} +
+ ) : null} + {onboarding.diagnostics.length ? ( +
+ {t("home.firstGoal.diagnostics")} +
    {onboarding.diagnostics.map((line, idx) =>
  • {line}
  • )}
+
+ ) : null} +
+ ) : null} {currentGoals.some((goal) => goal.loadState) ?
{t("startup.progress", { loaded: currentGoals.filter((goal) => !goal.loadState).length, total: currentGoals.length })}
{failedCount ?
{t("startup.failedCount", { count: failedCount })} @@ -186,7 +232,7 @@ function ManagerHomeBoard({
: null} {currentGoals.some((goal) => !goal.loadState) ? : null} -

{t("home.allGoals")}

+ {onboarding?.kind === "no_goals" && goals.length === 0 ? null :

{t("home.allGoals")}

}
{activeHomeLanes.filter((lane) => active[lane.key].length > 0).map((lane) => (
@@ -2010,9 +2056,13 @@ export function PersonalWorkspacePage({ } setRefreshState("loading"); try { - await callbacks.onRefresh(); - setHistoryRefreshRevision((revision) => revision + 1); - setRefreshState("done"); + const result = await callbacks.onRefresh(); + if (result && result.ok === false) { + setRefreshState("error"); + } else { + setHistoryRefreshRevision((revision) => revision + 1); + setRefreshState("done"); + } } catch { setRefreshState("error"); } @@ -2210,7 +2260,7 @@ export function PersonalWorkspacePage({ {!selectedGoal && !managerChatOpen ? (
-
{t("brief.title")} · {new Intl.DateTimeFormat(locale, { month: "long", day: "numeric", weekday: "short" }).format(new Date())}{t("home.greeting")}

{model.goals.some((goal) => goal.activationState === "active" && goal.loadState) ? t("startup.partial") : <>{t("home.waitingCount", { count: managerNeedsYouCount })} {managerBlockingCount > 0 ? t("home.blockingSummary", { count: managerBlockingCount }) : null} {!actionReadback.isError && homeOperations.length ? t("home.operationSummary", {count: homeOperations.length}) : null}}

+
{t("brief.title")} · {new Intl.DateTimeFormat(locale, { month: "long", day: "numeric", weekday: "short" }).format(new Date())}{t("home.greeting")}

{model.goals.some((goal) => goal.activationState === "active" && goal.loadState) ? t("startup.partial") : model.onboarding?.kind === "no_goals" && model.goals.length === 0 ? t("home.firstGoal.greeting") : <>{t("home.waitingCount", { count: managerNeedsYouCount })} {managerBlockingCount > 0 ? t("home.blockingSummary", { count: managerBlockingCount }) : null} {!actionReadback.isError && homeOperations.length ? t("home.operationSummary", {count: homeOperations.length}) : null}}

) : null} {selectedGoal && !selectedGoal.loadState && readOnly && routedTodoId @@ -2253,7 +2303,8 @@ export function PersonalWorkspacePage({ ), }} /> ) : !managerChatOpen ? ( - void callbacks.onRefresh?.("missing")} onSelectGoal={selectGoal} systemHealth={model.systemHealth} + void callbacks.onRefresh?.("missing")} onRetrySource={() => void refreshWorkspace()} onSelectGoal={selectGoal} systemHealth={model.systemHealth} + onboarding={model.onboarding} onRequestGoalCreate={readOnly ? undefined : requestGoalCreate} onSuggestRequest={readOnly ? undefined : suggestReply} operations={actionReadback.isError ? [] : homeOperations} onSelectOperation={proposal => setSelection({kind: "proposal", item: proposal})} onViewAllOperations={() => setSelectedGoalTab("chat")} /> ) : ( diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 22181b0d3e..d4172394a9 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -312,6 +312,26 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-system-health-header { display: flex; align-items: center; gap: 8px; font-size: var(--text-body); line-height: var(--leading-body); font-weight: 600; } .personal-system-health-header small { color: #b91c1c; font-size: var(--text-caption); line-height: var(--leading-caption); font-weight: 400; } .personal-system-health-issues { margin: 6px 0 0 24px; padding: 0; font-size: var(--text-caption); line-height: var(--leading-caption); color: #b91c1c; } +.personal-first-goal { display: grid; gap: 16px; max-width: 640px; min-width: 0; padding: 8px 0 24px; border-bottom: 1px solid var(--pw-line); } +.personal-first-goal h2 { margin: 0; color: var(--pw-text); font-size: var(--text-heading); line-height: var(--leading-heading); letter-spacing: -0.01em; } +.personal-first-goal > p { margin: 0; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-body); } +.personal-first-goal-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } +.personal-first-goal-create { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 10px; background: var(--pw-blue); color: var(--pw-card); font-size: var(--text-body); font-weight: 600; cursor: pointer; } +.personal-first-goal-create:hover { background: var(--pw-blue-ink); } +.personal-first-goal-create:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; } +.personal-first-goal-examples { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; } +.personal-first-goal-examples > li:first-child { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } +.personal-first-goal-examples button { display: block; width: 100%; min-height: 44px; padding: 10px 0; border: 0; border-top: 1px solid var(--pw-line); background: transparent; color: var(--pw-text); font-size: var(--text-body); line-height: var(--leading-body); text-align: left; cursor: pointer; } +.personal-first-goal-examples button:hover { color: var(--pw-blue-ink); } +.personal-first-goal-examples button:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: -2px; } +.personal-first-goal-diagnostics { color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } +.personal-first-goal-diagnostics summary { cursor: pointer; } +.personal-first-goal-diagnostics ul { margin: 6px 0 0 16px; padding: 0; } +.personal-system-health-retry { margin-top: 10px; min-height: 36px; padding: 0 14px; border: 1px solid var(--pw-red); border-radius: 8px; background: var(--pw-card); color: var(--pw-red); font-size: var(--text-caption); font-weight: 600; cursor: pointer; } +.personal-system-health-retry:focus-visible { outline: 2px solid var(--pw-red); outline-offset: 2px; } +.personal-system-health-diagnostics { margin-top: 6px; font-size: var(--text-caption); line-height: var(--leading-caption); } +.personal-system-health-diagnostics summary { cursor: pointer; } +.personal-system-health-diagnostics ul { margin: 4px 0 0 16px; padding: 0; } .personal-home-lane { display: flex; flex-direction: column; min-width: 0; min-height: 100%; border: 1px solid var(--pw-line); border-radius: 16px; background: #fff; box-shadow: 0 1px 4px rgb(30 28 20 / 4%); } .personal-manager-greeting > span { display: grid; flex: none; place-items: center; width: 44px; height: 44px; border-radius: 12px; background: var(--pw-blue-soft); color: var(--pw-blue); } .personal-manager-greeting div { display: grid; gap: 4px; min-width: 0; } @@ -1857,6 +1877,9 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-workspace-shell[data-pw-theme="brutal"] .personal-manager-conversation-messages article.is-user { background: #8fdcff; } .personal-workspace-shell[data-pw-theme="brutal"] .personal-quick-prompts button { border: 2px solid #141414; background: #fff; color: #141414; box-shadow: 2px 2px 0 #141414; } .personal-workspace-shell[data-pw-theme="brutal"] .personal-quick-prompts button:hover { background: #ffe23f; border-color: #141414; color: #141414; } +.personal-workspace-shell[data-pw-theme="brutal"] .personal-first-goal-create { border: 2px solid #141414; border-radius: 4px; background: #ffe23f; color: #141414; box-shadow: 3px 3px 0 #141414; } +.personal-workspace-shell[data-pw-theme="brutal"] .personal-first-goal-create:hover { background: #ffd91a; } +.personal-workspace-shell[data-pw-theme="brutal"] .personal-first-goal-examples button { border-top: 1.5px solid #141414; } .personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer { border: 2px solid #141414; border-radius: 6px; box-shadow: 3px 3px 0 #141414; } .personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer:focus-within { border-color: #141414; box-shadow: 5px 5px 0 #141414; } .personal-workspace-shell[data-pw-theme="brutal"] .personal-channel-composer textarea { border-left: 2px solid #141414; } diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 832b6a55a9..7142d99c58 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -108,6 +108,8 @@ import { type WorkspaceGoalTab, type WorkspaceGoalUsage, type WorkspaceImageAttachment, + type WorkspaceHomeOnboarding, + type WorkspaceRefreshResult, type WorkspaceSystemHealth, type WorkspaceTimelineItem, type WorkspaceWorker, @@ -504,6 +506,7 @@ type PersonalHomeModel = { goalNotifications?: WorkspaceGoalNotification[]; goals: PersonalGoalItem[]; openUserTodoCount: number; + onboarding?: WorkspaceHomeOnboarding; systemHealth?: WorkspaceSystemHealth; attentionHistory?: PersonalNeedsYouItem[]; userTodos: PersonalNeedsYouItem[]; @@ -1105,20 +1108,23 @@ function buildPersonalHomeModel( }]; }); + // Owner language first: each issue is a localized sentence. The status + // source's own contract lines stay available to a collapsed disclosure. const systemHealthIssues: string[] = []; if (!payload.ok) { - systemHealthIssues.push("状态载荷未标记为正常 (payload.ok === false)"); + systemHealthIssues.push(t("home.health.payloadNotOk")); } if (payload.contract && !payload.contract.ok) { const summary = payload.contract.summary; - const detail = summary - ? `${summary.errors} 项错误 / ${summary.warnings} 项警告` - : (payload.contract.errors?.[0] || "请检查控制面契约"); - systemHealthIssues.push(`契约检查未通过: ${detail}`); + systemHealthIssues.push(summary + ? t("home.health.contractFailed", { errors: summary.errors, warnings: summary.warnings }) + : payload.contract.errors?.[0] + ? t("home.health.contractFailedDetail", { detail: payload.contract.errors[0] }) + : t("home.health.contractFailedUnknown")); } if (payload.global_registry) { if (!payload.global_registry.ok) { - systemHealthIssues.push(`注册表状态异常: ${payload.global_registry.summary.high} 项高危`); + systemHealthIssues.push(t("home.health.registryHigh", { count: payload.global_registry.summary.high })); } for (const finding of payload.global_registry.findings || []) { if (finding.severity === "high") { @@ -1127,17 +1133,24 @@ function buildPersonalHomeModel( } } const freshnessWarning = payload.decision_freshness_summary?.summary?.stale_count - ? `${payload.decision_freshness_summary.summary.stale_count} 项决策状态已过期` + ? t("home.health.freshness", { count: payload.decision_freshness_summary.summary.stale_count }) : null; const isHealthy = systemHealthIssues.length === 0 && !freshnessWarning; const systemHealth: WorkspaceSystemHealth = { ok: isHealthy, summary: isHealthy - ? "所有控制面契约与注册表检查均正常" - : `发现 ${systemHealthIssues.length + (freshnessWarning ? 1 : 0)} 项系统健康关注点`, + ? t("home.health.allClear") + : t("home.health.concerns", { count: systemHealthIssues.length + (freshnessWarning ? 1 : 0) }), issues: systemHealthIssues, freshnessWarning, + diagnostics: [...(payload.contract?.errors ?? []), ...(payload.contract?.warnings ?? [])], }; + // A healthy source with no registered Goal and no execution history is a + // first-Goal workspace, not a failure. Any health issue keeps its banner. + const onboarding: WorkspaceHomeOnboarding | undefined = isHealthy + && payload.goal_count === 0 && goals.length === 0 && (payload.run_history?.goals ?? []).length === 0 + ? { kind: "no_goals", diagnostics: [...(payload.contract?.warnings ?? []), ...(payload.contract?.checks ?? [])] } + : undefined; return { blockingTodoCount: allUserTodos.filter((todo) => todo.blocking).length, @@ -1159,6 +1172,7 @@ function buildPersonalHomeModel( })), goals, openUserTodoCount: allUserTodos.length, + onboarding, systemHealth, attentionHistory: [...attentionHistory, ...pendingOperatorGates], userTodos: allUserTodos, @@ -1174,6 +1188,7 @@ function buildPersonalHomeModel( } function PersonalGoalHome({ goalArchiveLoadState, + sourceError, selectedView, selectedTodoId, onSelectTodo, @@ -1194,6 +1209,8 @@ function PersonalGoalHome({ theme, }: { goalArchiveLoadState: WorkspaceGoalArchiveLoadState; + /** The current source's latest read failed; the retained payload is stale. */ + sourceError: string | null; selectedView?: WorkspaceGoalTab; selectedTodoId?: string | null; onSelectTodo: (todoId: string | null) => void; @@ -1204,7 +1221,7 @@ function PersonalGoalHome({ onSelectWorkspace: (projectRef: string | null) => void; selectedWorkspaceRef: string | null; onReconcileStatus: (options?: { invalidateGoalIds?: string[] }) => void | Promise; - onRefresh: (scope?: WorkspaceReadScope) => void | Promise; + onRefresh: (scope?: WorkspaceReadScope) => Promise; onRetryGoalArchive: () => void | Promise; payload: StatusPayload; progress: WorkspaceProgress | null; @@ -1235,7 +1252,7 @@ function PersonalGoalHome({ const [managerRuntime, setManagerRuntime] = useState(null); const [managerChannelBinding, setManagerChannelBinding] = useState(null); const [capabilityRevision, setCapabilityRevision] = useState(0); - const model = useMemo(() => { + const loadedModel = useMemo(() => { const base = buildPersonalHomeModel(payload, rows, t, goalSubagentConfigurationEnabled); if (!progress) return base; const models = Object.values(progress.snapshots).map((snapshot) => buildPersonalHomeModel( @@ -1256,13 +1273,32 @@ function PersonalGoalHome({ openUserTodoCount: userTodos.length, blockingTodoCount: userTodos.filter((todo) => todo.blocking).length, workers: [...new Map(models.flatMap((item) => item.workers ?? []).map((worker) => [worker.agentId, worker])).values()], goalNotifications: models.flatMap((item) => item.goalNotifications ?? []), + onboarding: incomplete ? undefined : base.onboarding, systemHealth: incomplete || models.length === 0 ? undefined : { ok: models.every((item) => item.systemHealth?.ok), issues, - summary: issues.length ? `发现 ${issues.length} 项系统健康关注点` : "状态检查已完成", + summary: issues.length ? t("home.health.concerns", { count: issues.length }) : t("home.health.checked"), freshnessWarning: models.map((item) => item.systemHealth?.freshnessWarning).filter(Boolean).join(";") || null, + diagnostics: [...new Set(models.flatMap((item) => item.systemHealth?.diagnostics ?? []))], }, }; }, [payload, rows, progress, goalSubagentConfigurationEnabled, t]); + // A retained payload is not a current fact once the source's latest read + // failed: the first-Goal invitation is withdrawn and the failure is shown + // with a retry, instead of an empty workspace that claims to be updated. + const model = useMemo(() => { + if (!sourceError) return loadedModel; + return { + ...loadedModel, + onboarding: undefined, + systemHealth: { + ok: false, retryable: true, + summary: t("home.health.sourceUnreadable"), + issues: [t("home.health.sourceUnreadableDetail", { error: sourceError })], + freshnessWarning: null, + diagnostics: loadedModel.systemHealth?.diagnostics ?? [], + }, + }; + }, [sourceError, loadedModel, t]); // A workspace conversation has no Goal, so no Goal projection may apply to it. const selectedGoal = selectedWorkspaceRef ? null : model.goals.find((goal) => goal.goalId === selectedGoalId) ?? null; const workspaceProjects = useChatProjects(readOnly); @@ -2930,8 +2966,9 @@ function PersonalGoalHome({ URL.revokeObjectURL(url); }, onRefresh: async (scope) => { - await onRefresh(scope); + const result = await onRefresh(scope); setCapabilityRevision((revision) => revision + 1); + return result; }, onRetryResumeRun: retryManagerSession, onSelectAgent: chooseAgent, @@ -3448,6 +3485,7 @@ export function DashboardPage() { return ( { const goalId = search.goalId, statusUrl = search.statusUrl; @@ -3479,7 +3517,14 @@ export function DashboardPage() { { rejectOnError: true, background: true, invalidateGoalIds: options?.invalidateGoalIds, readScope: "missing" }, )} onRetryGoalArchive={retryGoalArchive} - onRefresh={(readScope = "all") => loadFromUrl(source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), { readScope })} + onRefresh={async (readScope = "all") => { + try { + await loadFromUrl(source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), { readScope, rejectOnError: true }); + return { ok: true } as const; + } catch (error) { + return { ok: false, error: formatStatusError(error) } as const; + } + }} payload={payload} progress={progress} rows={goalRows} diff --git a/docs/assets/personal-workspace/first-goal-home-desktop.png b/docs/assets/personal-workspace/first-goal-home-desktop.png new file mode 100644 index 0000000000..8a159bffc9 Binary files /dev/null and b/docs/assets/personal-workspace/first-goal-home-desktop.png differ diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index 0fefa252cd..f0f95c9fbf 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -69,6 +69,23 @@ graph TD ![LoopX 管家总览与 4 泳道流](../assets/personal-workspace/guide_manager_overview.png) +### 首次打开 / First open + +![还没有 Goal 时的管家总览](../assets/personal-workspace/first-goal-home-desktop.png) + +还没有任何 Goal 时,总览不显示空泳道或健康告警,而是一个「还没有 Goal」状态:一句话说明 +Goal 是什么、一个 **创建 Goal** 按钮(打开与侧边栏 `+` 相同的创建表单),以及三条示例 +请求。点选示例只会填入输入框,按发送前不会发出任何内容。状态来源自身的检查行(例如 +本机尚未建立注册表)折叠在「状态来源详情」里。只有状态来源真的不健康(例如注册表损坏) +时才显示健康告警,并以可读句子描述问题,原始检查行同样折叠在「诊断信息」里。 + +When no Goal exists yet, the overview shows a first-Goal state instead of empty lanes or a +health banner: what a Goal is, a **Create a Goal** button (the same form as the sidebar `+`), +and three example requests that only fill the composer. The status source's own check +lines (such as "no registry yet on this machine") stay collapsed under "Status source +details". A health banner appears only when the source is actually unhealthy, in plain +sentences, with the raw check lines collapsed under "Diagnostics". + ### 核心功能区 1. **「你不在的时候」离线概览**: - 聚合展示你离开期间所有 Agent 的运行结果:`已完成` 数量、`异常/失败` 数量以及当前 **`等你确认`** 的阻塞项。 diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 14348a3e96..4c5efde221 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -5,6 +5,7 @@ import {goalWorkspaceCreateScenario} from "./personal-workspace-browser/goal-wor import {agentModelSelectionScenario} from "./personal-workspace-browser/agent-model-selection.mjs"; import {goalWorkspaceM2Scenario} from "./personal-workspace-browser/goal-workspace-m2.mjs"; import {goalWorkspaceSelectionScenario} from "./personal-workspace-browser/goal-workspace-selection.mjs"; +import {firstGoalHomeScenario} from "./personal-workspace-browser/first-goal-home.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"; @@ -109,6 +110,7 @@ scenarioCatalog.push(zcodeGoalScenario); scenarioCatalog.push(goalWorkspaceM2Scenario); scenarioCatalog.push(goalWorkspaceCreateScenario); scenarioCatalog.push(goalWorkspaceSelectionScenario); +scenarioCatalog.push(firstGoalHomeScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; scenarioCatalog.push(agentModelSelectionScenario); const scenarios = requestedScenario diff --git a/examples/personal-workspace-browser/first-goal-home.mjs b/examples/personal-workspace-browser/first-goal-home.mjs new file mode 100644 index 0000000000..4d621688c6 --- /dev/null +++ b/examples/personal-workspace-browser/first-goal-home.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { outputDir } from "./fixture.mjs"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +// A fresh install opens to the manager home with zero registered Goals. The +// status source is healthy (a missing default registry is a warning, not an +// error), so the home must show its first-Goal state instead of empty lanes or +// a health banner, and example requests may only fill the composer. A source +// that is actually unhealthy keeps its banner in owner language and does not +// invite the first Goal. + +function emptyWorkspace(fixture, { healthy = true } = {}) { + fixture.ok = healthy; + fixture.goal_count = 0; + fixture.run_count = 0; + fixture.run_history.goals = []; + fixture.run_history.recent_runs = []; + fixture.run_history.goal_count = 0; + fixture.run_history.run_count = 0; + fixture.attention_queue.items = []; + fixture.attention_queue.item_count = 0; + fixture.attention_queue.needs_user_or_controller = 0; + fixture.attention_queue.needs_codex = 0; + fixture.attention_queue.watching_external_evidence = 0; + if (fixture.usage_summary) fixture.usage_summary.goals = []; + fixture.contract = healthy + ? { ok: true, summary: { errors: 0, warnings: 1, checks: 3 }, errors: [], + warnings: ["registry file does not exist: [local-path]"], + checks: ["runtime root resolved: [local-path]", "run-history goals=0 runs=0", "public boundary scan deferred for status hot path"] } + : { ok: false, summary: { errors: 1, warnings: 0, checks: 2 }, errors: ["registry file is not valid JSON"], warnings: [], + checks: ["runtime root resolved: [local-path]", "run-history goals=0 runs=0"] }; +} + +export const firstGoalHomeScenario = { + id: "first-goal-home", + async run({ browser, collectCoverage, url }) { + const coverageEntries = []; + const session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, + beforeGoto: (api) => { api.transformStatus = (fixture) => emptyWorkspace(fixture); } }); + const { api, page } = session; + // "Sends nothing" means no Turn is created. Session admission on focus is + // not a send, so only turn creation and steering count. + const turnRequests = []; + page.on("request", (request) => { + if (request.method() === "POST" && /\/api\/chat\/sessions\/[^/]+\/turns/.test(request.url())) turnRequests.push(request.url()); + }); + try { + const firstGoal = page.getByTestId("personal-first-goal"); + await firstGoal.waitFor({ state: "visible", timeout: 15_000 }); + assert.equal(await page.locator(".personal-system-health-banner").count(), 0, "A missing default registry is not a health concern"); + assert.equal(await page.locator(".personal-home-section-title").count(), 0, "No empty 'All Goals' heading in the first-Goal state"); + assert.match(await firstGoal.locator("h2").innerText(), /还没有 Goal/); + assert.match(await page.locator(".personal-manager-greeting p").innerText(), /我们从第一个 Goal 开始/); + // Status source details stay collapsed and carry the source's own warning. + const details = firstGoal.locator("details.personal-first-goal-diagnostics"); + assert.equal(await details.getAttribute("open"), null, "Diagnostics are collapsed by default"); + assert.match((await details.locator("li").first().textContent()) ?? "", /registry file does not exist/); + await page.screenshot({ path: resolve(outputDir, "first-goal-home-desktop.png"), animations: "disabled" }); + + // An example request only fills the composer; nothing is sent. + const composer = page.locator(".personal-channel-composer textarea"); + const example = firstGoal.locator(".personal-first-goal-examples button").first(); + const exampleText = (await example.innerText()).trim(); + await example.click(); + assert.equal((await composer.inputValue()).trim(), exampleText, "The example fills the composer verbatim"); + assert.equal(turnRequests.length, 0, `Selecting an example sends nothing: ${turnRequests.join(", ")}`); + await page.keyboard.press("Escape"); + + // Create a Goal opens the existing typed Goal form in place. + await firstGoal.locator(".personal-first-goal-create").click(); + const form = page.locator("dialog.personal-action-form"); + await form.waitFor({ state: "visible", timeout: 10_000 }); + await page.screenshot({ path: resolve(outputDir, "first-goal-home-create.png"), animations: "disabled" }); + await page.keyboard.press("Escape"); + await form.waitFor({ state: "hidden", timeout: 10_000 }); + + // A warm refresh that fails is not an empty workspace: both status reads + // answer HTTP 500, the invitation is withdrawn, the failure is shown with + // a retry, and the header never reports an update. Repairing the source + // and retrying restores the first-Goal state. + api.statusFailure = 500; + const refresh = page.getByRole("button", { name: "刷新状态", exact: true }); + await refresh.click(); + const sourceRetry = page.getByTestId("personal-source-retry"); + await sourceRetry.waitFor({ state: "visible", timeout: 15_000 }); + assert.equal(await firstGoal.count(), 0, "A failed read withdraws the first-Goal invitation"); + const failedBanner = page.locator(".personal-system-health-banner"); + assert.match(await failedBanner.innerText(), /状态来源读取失败/, "The failure is shown in owner language"); + assert.match(await failedBanner.innerText(), /最近一次读取失败:/); + const headerStatus = page.locator(".personal-channel-title, header").first(); + await page.locator("small", { hasText: "刷新失败" }).first().waitFor({ state: "visible", timeout: 10_000 }); + assert.equal(await page.locator("small", { hasText: "刚刚更新" }).count(), 0, "A failed refresh never reports an update"); + void headerStatus; + await page.screenshot({ path: resolve(outputDir, "first-goal-home-source-failed.png"), animations: "disabled" }); + api.statusFailure = null; + await sourceRetry.click(); + await firstGoal.waitFor({ state: "visible", timeout: 15_000 }); + assert.equal(await page.locator(".personal-system-health-banner").count(), 0, "A repaired source clears the failure"); + await page.locator("small", { hasText: "刚刚更新" }).first().waitFor({ state: "visible", timeout: 10_000 }); + + // English copy and the phone composition. + await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "en")); + await page.reload({ waitUntil: "networkidle" }); + await firstGoal.waitFor({ state: "visible", timeout: 15_000 }); + assert.match(await firstGoal.locator("h2").innerText(), /No Goals yet/); + await page.screenshot({ path: resolve(outputDir, "first-goal-home-en.png"), animations: "disabled" }); + await page.evaluate(() => localStorage.removeItem("loopx-pw-locale")); + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload({ waitUntil: "networkidle" }); + await firstGoal.waitFor({ state: "visible", timeout: 15_000 }); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, "No horizontal overflow on a phone"); + await page.screenshot({ path: resolve(outputDir, "first-goal-home-phone.png"), animations: "disabled" }); + coverageEntries.push(...await session.close()); + } catch (error) { + await session.close().catch(() => {}); + throw error; + } + + // Negative: an unhealthy source with zero Goals keeps its banner and does + // not present the first-Goal invitation. + const unhealthy = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, + beforeGoto: (api) => { api.transformStatus = (fixture) => emptyWorkspace(fixture, { healthy: false }); } }); + try { + const banner = unhealthy.page.locator(".personal-system-health-banner"); + await banner.waitFor({ state: "visible", timeout: 15_000 }); + const bannerText = await banner.innerText(); + assert.match(bannerText, /控制面契约检查未通过:1 项错误,0 项警告/, "Health issues read as owner language"); + assert.doesNotMatch(bannerText, /payload\.ok/, "No protocol fragment in primary copy"); + assert.equal(await banner.locator("details.personal-system-health-diagnostics").getAttribute("open"), null, "Source lines stay collapsed"); + assert.match((await banner.locator("details li").first().textContent()) ?? "", /registry file is not valid JSON/); + assert.equal(await unhealthy.page.getByTestId("personal-first-goal").count(), 0, "An unhealthy source does not invite a first Goal"); + await unhealthy.page.screenshot({ path: resolve(outputDir, "first-goal-home-unhealthy.png"), animations: "disabled" }); + coverageEntries.push(...await unhealthy.close()); + } catch (error) { + await unhealthy.close().catch(() => {}); + throw error; + } + return { coverageEntries }; + }, +}; diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 4968b5416e..117aa6a8b8 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -446,6 +446,8 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu failNextLifecyclePreview: false, failNextActionPreview: false, failNextStatusRequest: false, + /** When set, every status read answers this HTTP status until cleared: a source that cannot be projected. */ + statusFailure: null, freezeGoalSubagentStatusProjection: false, goalSubagentConfigurationEnabled, goalActivationStates: new Map([ @@ -503,6 +505,10 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu }; await page.route(`http://127.0.0.1:${fixturePort}/status.json*`, async (route) => { state.statusRequestCount += 1; + if (state.statusFailure) { + await route.fulfill({ contentType: "application/json", json: { ok: false, error: "LoopX status could not be projected for the workspace." }, status: state.statusFailure }); + return; + } const requestUrl = new URL(route.request().url()); const requestedGoalId = requestUrl.searchParams.get("goal_id"); if (progressiveWorkspace && requestUrl.searchParams.get("view") === "workspace-directory") { diff --git a/loopx/chat_status_api.py b/loopx/chat_status_api.py index 861eec2d0c..9f7dbb3276 100644 --- a/loopx/chat_status_api.py +++ b/loopx/chat_status_api.py @@ -5,6 +5,7 @@ import errno import json from datetime import datetime, timezone +from pathlib import Path from typing import Any from urllib.parse import parse_qs, urlparse @@ -73,6 +74,21 @@ def _goal_task_map(server: Any, goal_id: str) -> dict[str, Any] | None: ) +def _discovery_registry(registry_path: Path) -> dict[str, Any]: + """Read the registry for the workspace directory; a missing file is an empty workspace. + + A fresh install has no registry until the first Goal is created. Discovery + reports that as zero registered Goals rather than a projection failure, so + the home page can show its first-Goal state. Unreadable or incompatible + registries still raise. + """ + + try: + return load_project_registry(registry_path) + except FileNotFoundError: + return {} + + def _status_access_denied(error: BaseException) -> bool: """Recognize permission causes without reclassifying unrelated I/O failures.""" current: BaseException | None = error @@ -158,7 +174,7 @@ def _status(self, *, delivery_review: bool = False) -> None: if views is not None or requested_goals is not None or delivery_review: # Discovery reads only metadata. Full status/runtime reads keep # their existing lifecycle-only profile refusal. - registry = (load_project_registry(self.server.registry_path) if views == ["workspace-directory"] + registry = (_discovery_registry(self.server.registry_path) if views == ["workspace-directory"] else load_registry(self.server.registry_path)) if goal_id and goal_id not in { str(g.get("id")) for g in registry_goals(registry) @@ -173,6 +189,10 @@ def _status(self, *, delivery_review: bool = False) -> None: return projection = collect_status( registry_path=self.server.registry_path, + # The Chat service already starts on an absent registry as an + # empty workspace (serve_chat); its status reports the same + # fact as a warning, so a fresh install is not a failed source. + allow_missing_registry=True, runtime_root_override=self.server.runtime_root_override, scan_roots=self.server.scan_roots, limit=self.server.limit, diff --git a/loopx/control_plane/status/collection.py b/loopx/control_plane/status/collection.py index e745db37ca..89df438f2d 100644 --- a/loopx/control_plane/status/collection.py +++ b/loopx/control_plane/status/collection.py @@ -85,6 +85,7 @@ def collect_status( include_zcode_goal_eligibility: bool = False, activation_state_filter: GoalActivationState | str | None = None, agent_lane_id: str | None = None, + allow_missing_registry: bool = False, ) -> dict[str, Any]: display_limit = max(0, limit) control_plane_limit = max( @@ -134,6 +135,7 @@ def collect_status( runtime_root_override=str(runtime_root), scan_roots=scan_roots, limit=limit, + allow_missing_registry=allow_missing_registry, goal_id_filter=goal_filter, include_public_boundary_scan=include_public_boundary_scan, activation_state_filter=activation_filter, diff --git a/loopx/status.py b/loopx/status.py index d5642c0e49..c505bbc574 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -1362,10 +1362,12 @@ def collect_status( include_zcode_goal_eligibility: bool = False, activation_state_filter: str | None = None, agent_lane_id: str | None = None, + allow_missing_registry: bool = False, ) -> dict[str, Any]: with effect_runtime_request_scope(): return _collect_status_read_model( registry_path=registry_path, + allow_missing_registry=allow_missing_registry, runtime_root_override=runtime_root_override, scan_roots=scan_roots, limit=limit, diff --git a/tests/test_chat_server_cors.py b/tests/test_chat_server_cors.py index 970c8a4ff8..9a13d17088 100644 --- a/tests/test_chat_server_cors.py +++ b/tests/test_chat_server_cors.py @@ -487,6 +487,43 @@ def test_chat_status_rejects_invalid_goal_activation_scope(monkeypatch) -> None: server.server_close() +def test_workspace_directory_reports_a_missing_registry_as_an_empty_workspace(tmp_path) -> None: + # A fresh install has no registry until the first Goal is created. The + # discovery read must answer with zero registered Goals, not a 500, so the + # home page can render its first-Goal state instead of a service failure. + server, thread = _start_server() + server.registry_path = tmp_path / ".loopx" / "registry.json" + try: + response = _request(server.server_address[1], method="GET", origin=None, path="/status.json?view=workspace-directory") + payload = json.loads(response.read().decode("utf-8")) + assert response.status == 200 + assert payload["ok"] is True + assert payload["schema_version"] == "loopx_workspace_directory_v1" + assert payload["goals"] == [] + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +def test_workspace_directory_still_fails_on_an_unreadable_registry(tmp_path) -> None: + # Only absence is an empty workspace; a corrupt registry stays a failure. + server, thread = _start_server() + registry_path = tmp_path / ".loopx" / "registry.json" + registry_path.parent.mkdir(parents=True) + registry_path.write_text("{not json", encoding="utf-8") + server.registry_path = registry_path + try: + response = _request(server.server_address[1], method="GET", origin=None, path="/status.json?view=workspace-directory") + payload = json.loads(response.read().decode("utf-8")) + assert response.status == 500 + assert payload["ok"] is False + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + def test_workspace_directory_skips_expensive_projection_and_respects_scope(monkeypatch) -> None: registry = {"goals": [{"id": "alpha", "display_name": "Alpha"}, {"id": "beta"}]} monkeypatch.setattr("loopx.chat_status_api.load_project_registry", lambda _: registry) @@ -631,6 +668,7 @@ def fail(*_args, **_kwargs): raise PermissionError("synthetic confidential diagnostic") monkeypatch.setattr("loopx.chat_status_api.load_registry", fail) + monkeypatch.setattr("loopx.chat_status_api.load_project_registry", fail) monkeypatch.setattr("loopx.chat_status_api.collect_status", fail) server, thread = _start_server() try: diff --git a/tests/test_status_missing_registry.py b/tests/test_status_missing_registry.py new file mode 100644 index 0000000000..937bb9a1d0 --- /dev/null +++ b/tests/test_status_missing_registry.py @@ -0,0 +1,35 @@ +"""A missing registry is an empty workspace only where the caller says so.""" + +from __future__ import annotations + +from pathlib import Path + +from loopx.status import collect_status + + +def _collect(tmp_path: Path, **overrides): + return collect_status( + registry_path=tmp_path / ".loopx" / "registry.json", + runtime_root_override=str(tmp_path / "runtime"), + scan_roots=[], + limit=5, + include_public_boundary_scan=False, + **overrides, + ) + + +def test_missing_registry_is_a_contract_error_by_default(tmp_path: Path) -> None: + payload = _collect(tmp_path) + assert payload["ok"] is False + assert payload["goal_count"] == 0 + assert "registry file does not exist" in payload["contract"]["errors"] + + +def test_missing_registry_is_a_warning_when_the_caller_allows_it(tmp_path: Path) -> None: + payload = _collect(tmp_path, allow_missing_registry=True) + assert payload["ok"] is True, payload["contract"] + assert payload["contract"]["ok"] is True + assert payload["contract"]["errors"] == [] + assert any(line.startswith("registry file does not exist") for line in payload["contract"]["warnings"]) + assert payload["goal_count"] == 0 + assert payload["run_history"]["goals"] == []