Commit d3ac333b authored by AI-甘富林's avatar AI-甘富林

fix(desktop): clarify first-launch startup states and diagnostics

Prevent first-launch startup from being masked by the generic employee-config syncing state.
Differentiate config sync, project sync, runtime startup, and gateway connection states, and improve diagnostics and smoke coverage.
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 5c3ae433
This diff is collapsed.
import { randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import { ipcMain, shell, type WebContents } from "electron";
import {
IPC_CHANNELS,
......@@ -24,6 +24,7 @@ import type { AppConfigService } from "./services/app-config.js";
import type { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ProfileClient } from "./services/cloud-api.js";
import type { DiagnosticsService } from "./services/diagnostics.js";
import type { DailyReportService } from "./services/daily-report-service.js";
import type { SkillCatalogService } from "./services/skill-catalog.js";
import type { SkillClient } from "./services/skill-client.js";
import type { SkillStoreService } from "./services/skill-store.js";
import {
......@@ -33,6 +34,7 @@ import {
type LocalOpenClawGatewayConfig
} from "./services/openclaw-local-config.js";
import type { SecretManager } from "./services/secrets.js";
import type { StartupLogger } from "./services/startup-logger.js";
import type { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js";
import type { RuntimeSkillBridgeService } from "./services/runtime-skill-bridge.js";
import type { ProjectStoreService } from "./services/project-store.js";
......@@ -72,6 +74,7 @@ interface MainServices {
profileClient: ProfileClient;
creditClient: CreditClient;
skillClient: SkillClient;
skillCatalogService: SkillCatalogService;
skillStore: SkillStoreService;
modelConfigClient: ModelConfigClient;
runtimeCloudClient: OpenClawConfigClient;
......@@ -85,6 +88,7 @@ interface MainServices {
projectSkillRouter: ProjectSkillRouterService;
projectExecutionRouter: ProjectExecutionRouter;
projectWorkspaceExecutor: ProjectWorkspaceExecutorService;
startupLogger: StartupLogger;
appVersion: string;
systemSummary: SystemSummary;
localOpenClawConfig?: LocalOpenClawGatewayConfig | null;
......@@ -189,6 +193,16 @@ function buildPluginSummaries(runtimeStatus: RuntimeStatus): PluginSummary[] {
});
}
function buildProjectSyncSummary(message: string): Pick<WorkspaceSummary, "chatReady" | "chatLaunchState" | "chatStatusMessage" | "startupPhase" | "startupMessage"> {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: message,
startupPhase: "syncing-projects",
startupMessage: message
};
}
const MANAGED_RUNTIME_START_RETRY_LIMIT = 2;
const MANAGED_RUNTIME_START_RETRY_DELAY_MS = 1500;
const GATEWAY_CONNECT_RETRY_LIMIT = 10;
......@@ -212,6 +226,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
profileClient,
secretManager,
skillClient,
skillCatalogService,
modelConfigClient,
runtimeCloudClient,
runtimeCloudSupervisor,
......@@ -224,6 +239,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
projectSkillRouter,
projectExecutionRouter,
projectWorkspaceExecutor,
startupLogger,
systemSummary,
localOpenClawConfig
} = services;
......@@ -422,6 +438,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
let workspaceWarmupTail: Promise<void> = Promise.resolve();
let workspaceWarmupInFlight = false;
let bootstrapRecoveryAttempts = 0;
let lastWorkspaceSummaryLogKey = "";
const queueWorkspaceWarmup = async (
reason: string,
......@@ -532,29 +549,35 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
runtimeStatus,
gatewayStatus
});
const shouldWaitForProjectSync = projects.length === 0
&& config.apiKeyConfigured
&& config.setupMode === "employee-key"
&& runtimeCloudStatus.state === "ready"
&& baseChatSummary.chatLaunchState !== "error";
if (shouldWaitForProjectSync && baseChatSummary.startupPhase !== "syncing-projects") {
void startupLogger.warn("workspace-summary", "phase.override", "Project sync phase is overriding the base startup phase because no project inventory is available yet.", {
baseLaunchState: baseChatSummary.chatLaunchState,
basePhase: baseChatSummary.startupPhase,
runtimeCloudState: runtimeCloudStatus.state,
projectCount: projects.length,
bundleSyncState: bundleSyncStatus.state
});
}
const chatSummary = projects.length > 0
? baseChatSummary
: bundleSyncFailed
? {
chatReady: false,
chatLaunchState: "error" as const,
chatStatusMessage: bundleSyncStatus.lastError ?? "工作配置同步失败,请检查网络后重试。",
chatStatusMessage: bundleSyncStatus.lastError ?? "Workspace project sync failed. Check network access and retry.",
startupPhase: "error" as const,
startupMessage: bundleSyncStatus.lastError ?? "工作配置同步失败,请检查网络后重试。"
startupMessage: bundleSyncStatus.lastError ?? "Workspace project sync failed. Check network access and retry."
}
: {
chatReady: false,
chatLaunchState: config.apiKeyConfigured ? "starting" as const : baseChatSummary.chatLaunchState,
chatStatusMessage: config.apiKeyConfigured
? EMPTY_PROJECT_INVENTORY_MESSAGE
: baseChatSummary.chatStatusMessage,
startupPhase: config.apiKeyConfigured ? "syncing-config" as const : baseChatSummary.startupPhase,
startupMessage: config.apiKeyConfigured
? EMPTY_PROJECT_INVENTORY_MESSAGE
: baseChatSummary.startupMessage
};
: shouldWaitForProjectSync
? buildProjectSyncSummary(bundleSyncStatus.lastError ?? EMPTY_PROJECT_INVENTORY_MESSAGE)
: baseChatSummary;
return {
const workspaceSummary: WorkspaceSummary = {
shellReady,
apiKeyConfigured: config.apiKeyConfigured,
bindingRequired: !config.apiKeyConfigured,
......@@ -585,12 +608,36 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
skillCount: skills.length,
skills,
plugins: buildPluginSummaries(runtimeStatus),
lastError: runtimeCloudStatus.lastError ?? runtimeStatus.lastError ?? gatewayStatus?.lastError
lastError: bundleSyncStatus.lastError ?? runtimeCloudStatus.lastError ?? runtimeStatus.lastError ?? gatewayStatus?.lastError
};
const workspaceSummaryLogKey = JSON.stringify({
phase: workspaceSummary.startupPhase,
launchState: workspaceSummary.chatLaunchState,
projectCount: workspaceSummary.projectCount,
runtimeCloudState: workspaceSummary.runtimeCloudState,
runtimeState: workspaceSummary.runtimeState,
lastError: workspaceSummary.lastError ?? ""
});
if (workspaceSummaryLogKey !== lastWorkspaceSummaryLogKey) {
lastWorkspaceSummaryLogKey = workspaceSummaryLogKey;
void startupLogger.info("workspace-summary", "transition", "Workspace startup summary changed.", {
phase: workspaceSummary.startupPhase,
launchState: workspaceSummary.chatLaunchState,
projectCount: workspaceSummary.projectCount,
runtimeCloudState: workspaceSummary.runtimeCloudState,
runtimeState: workspaceSummary.runtimeState,
bundleSyncState: bundleSyncStatus.state,
lastError: workspaceSummary.lastError
});
}
return workspaceSummary;
};
const exportDiagnostics = async () => {
const config = await getEffectiveConfig();
const workspaceSummary = await buildWorkspaceSummary();
const [gatewayStatus, gatewayHealth, logs, authSession, runtimeStatus, runtimeLogs, runtimeCloudStatus, runtimeTelemetryStatus] = await Promise.all([
gatewayClient.status(),
gatewayClient.health(),
......@@ -639,7 +686,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
runtimeStatus,
runtimeLogs,
runtimeCloudStatus,
runtimeTelemetryStatus
runtimeTelemetryStatus,
workspaceSummary,
bundleSyncStatus: projectBundleService.getSyncStatus(),
startupLogPath: startupLogger.getSessionLogPath()
});
};
......@@ -1182,6 +1232,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
ipcMain.handle(IPC_CHANNELS.skillsList, async () => skillClient.list());
ipcMain.handle(IPC_CHANNELS.modelConfigGetSummary, async () => modelConfigClient.getSummary());
ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary);
ipcMain.handle(IPC_CHANNELS.skillCatalogList, async () => skillCatalogService.listForActiveProject());
ipcMain.handle(IPC_CHANNELS.projectsList, async () => projectStore.listProjects());
ipcMain.handle(IPC_CHANNELS.projectsSetActive, async (_event, projectId: string) => {
......@@ -1298,6 +1349,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return buildWorkspaceSummary();
}
},
skillCatalog: {
list: () => skillCatalogService.listForActiveProject()
},
auth: {
getSessionSummary: () => authClient.getSessionSummary(),
signIn: (input: SignInInput) => authClient.signIn(input.accessToken),
......@@ -1366,3 +1420,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}
};
}
......@@ -23,6 +23,7 @@ import type {
import type { AppConfigService } from "./app-config.js";
import type { RemoteSkillAsset } from "./skill-store.js";
import type { SecretManager } from "./secrets.js";
import type { StartupLogger } from "./startup-logger.js";
interface SessionPayload {
user?: { displayName?: string; email?: string };
......@@ -510,6 +511,7 @@ export class OpenClawConfigClient {
private readonly httpClient = new HttpJsonClient();
private readonly payloadListeners = new Set<RuntimeCloudPayloadListener>();
private readonly cachePath: string;
private readonly startupLogger?: StartupLogger;
private payloadCache: OpenClawEmployeeConfigPayload | null = null;
private statusCache: RuntimeCloudStatus = {
state: "unconfigured",
......@@ -518,10 +520,11 @@ export class OpenClawConfigClient {
};
private cacheLoaded = false;
constructor(configService: AppConfigService, secretManager: SecretManager) {
constructor(configService: AppConfigService, secretManager: SecretManager, startupLogger?: StartupLogger) {
this.configService = configService;
this.secretManager = secretManager;
this.cachePath = this.configService.getDataPath("config", "runtime-cloud-cache.json");
this.startupLogger = startupLogger;
}
async hydrateCache(): Promise<void> {
......@@ -627,6 +630,7 @@ export class OpenClawConfigClient {
private async fetchPayload(action: RuntimeCloudFetchAction): Promise<OpenClawEmployeeConfigPayload> {
await this.hydrateCache();
const config = await this.configService.load();
const startedAt = Date.now();
const baseUrl = config.runtimeCloudApiBaseUrl.trim().replace(/\/$/, "");
const apiKey = (await this.secretManager.getApiKey())?.trim();
......@@ -711,6 +715,12 @@ export class OpenClawConfigClient {
};
throw new Error(message);
}
await this.startupLogger?.error("runtime-cloud", "fetch.error", "Runtime cloud fetch failed without cache fallback.", {
action,
baseUrl,
elapsedMs: Date.now() - startedAt,
error: message
});
return this.fail(baseUrl, true, message);
}
}
......@@ -997,3 +1007,9 @@ export class ModelConfigClient {
......@@ -15,9 +15,12 @@ import type {
RuntimeTelemetryStatus,
SkillSummary,
SystemSummary,
UserProfileSummary
UserProfileSummary,
WorkspaceSummary
} from "@qjclaw/shared-types";
import type { LocalOpenClawGatewayConfig } from "./openclaw-local-config.js";
import type { ProjectBundleSyncStatus } from "./project-bundle.js";
import type { StartupLogger } from "./startup-logger.js";
interface DiagnosticsSnapshotInput {
config: AppConfig;
......@@ -38,6 +41,10 @@ interface DiagnosticsSnapshotInput {
runtimeLogs?: LogEntry[];
runtimeCloudStatus?: RuntimeCloudStatus;
runtimeTelemetryStatus?: RuntimeTelemetryStatus;
workspaceSummary?: WorkspaceSummary;
bundleSyncStatus?: ProjectBundleSyncStatus;
startupLogPath?: string;
reason?: string;
}
function toSafeStamp(value: string): string {
......@@ -46,9 +53,11 @@ function toSafeStamp(value: string): string {
export class DiagnosticsService {
private readonly userDataPath: string;
private readonly startupLogger?: StartupLogger;
constructor(userDataPath: string) {
constructor(userDataPath: string, startupLogger?: StartupLogger) {
this.userDataPath = userDataPath;
this.startupLogger = startupLogger;
}
async exportSnapshot(input: DiagnosticsSnapshotInput): Promise<DiagnosticsExportResult> {
......@@ -58,6 +67,8 @@ export class DiagnosticsService {
const payload = {
createdAt,
reason: input.reason,
startupLogPath: input.startupLogPath,
app: {
name: input.systemSummary.appName,
version: input.appVersion,
......@@ -109,6 +120,10 @@ export class DiagnosticsService {
config: input.runtimeCloudStatus.config
}
: null,
workspace: input.workspaceSummary ?? null,
bundleSync: input.bundleSyncStatus
? { ...input.bundleSyncStatus }
: null,
runtimeTelemetry: input.runtimeTelemetryStatus ?? null,
runtime: {
status: input.runtimeStatus,
......@@ -133,10 +148,18 @@ export class DiagnosticsService {
await mkdir(diagnosticsDir, { recursive: true });
await writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
await this.startupLogger?.info("diagnostics", "snapshot.exported", "Diagnostics snapshot exported.", {
diagnosticsPath: filePath,
startupLogPath: input.startupLogPath,
reason: input.reason,
workspacePhase: input.workspaceSummary?.startupPhase,
workspaceLaunchState: input.workspaceSummary?.chatLaunchState
});
return {
filePath,
createdAt
createdAt,
startupLogPath: input.startupLogPath
};
}
}
......@@ -8,6 +8,7 @@ import extractZip from "extract-zip";
import type { AppConfigService } from "./app-config.js";
import type { ProjectStoreService } from "./project-store.js";
import type { RemoteSkillAsset } from "./skill-store.js";
import type { StartupLogger } from "./startup-logger.js";
interface BundleManifestRecord {
sourceUrl: string;
......@@ -180,11 +181,13 @@ function logBundle(event: string, details: Record<string, unknown>): void {
export class ProjectBundleService {
private readonly configService: AppConfigService;
private readonly projectStore: ProjectStoreService;
private readonly startupLogger?: StartupLogger;
private syncStatus: ProjectBundleSyncStatus = { state: "idle" };
constructor(configService: AppConfigService, projectStore: ProjectStoreService) {
constructor(configService: AppConfigService, projectStore: ProjectStoreService, startupLogger?: StartupLogger) {
this.configService = configService;
this.projectStore = projectStore;
this.startupLogger = startupLogger;
}
getSyncStatus(): ProjectBundleSyncStatus {
......@@ -214,6 +217,13 @@ export class ProjectBundleService {
bundleAssetCount: bundleAssets.length
});
const workspaceRoot = await this.projectStore.getWorkspaceRoot();
await this.startupLogger?.info("project-bundle", "sync.start", "Project bundle sync started.", {
action: _action ?? "unknown",
configVersion,
workspaceRoot,
remoteSkillCount: remoteSkills.length,
bundleAssetCount: bundleAssets.length
});
const manifestPath = path.join(workspaceRoot, MANIFESTS_DIR, MANIFEST_FILE);
const currentManifest = (await readJsonFile<Record<string, BundleManifestRecord>>(manifestPath)) ?? {};
const nextManifest: Record<string, BundleManifestRecord> = {};
......@@ -934,3 +944,7 @@ export class ProjectBundleService {
import { appendFile, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
export type StartupLogLevel = "info" | "warn" | "error";
export type StartupLogPhase = "bootstrap" | "runtime-cloud" | "project-bundle" | "workspace-summary" | "diagnostics";
interface StartupLogEntry {
ts: string;
level: StartupLogLevel;
phase: StartupLogPhase;
event: string;
message: string;
context?: Record<string, unknown>;
}
function toSafeStamp(value: string): string {
return value.replace(/[:.]/g, "-");
}
function sanitizeUrlLikeString(value: string): string {
if (!/^https?:\/\//i.test(value)) {
return value;
}
try {
const parsed = new URL(value);
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
} catch {
return value;
}
}
function sanitizeValue(value: unknown, key?: string): unknown {
if (value === null || value === undefined) {
return value;
}
if (typeof value === "string") {
if (key && /(token|secret|api[_-]?key|password)/i.test(key)) {
return "<redacted>";
}
return sanitizeUrlLikeString(value);
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeValue(item));
}
if (typeof value === "object") {
const next: Record<string, unknown> = {};
for (const [entryKey, entryValue] of Object.entries(value as Record<string, unknown>)) {
next[entryKey] = sanitizeValue(entryValue, entryKey);
}
return next;
}
return value;
}
export class StartupLogger {
private readonly sessionLogPath: string;
private readonly latestLogPath: string;
private readonly initPromise: Promise<void>;
private writeChain: Promise<void> = Promise.resolve();
constructor(logsPath: string) {
const startupLogsDir = path.join(logsPath, "startup");
const stamp = toSafeStamp(new Date().toISOString());
this.sessionLogPath = path.join(startupLogsDir, `startup-${stamp}.log`);
this.latestLogPath = path.join(startupLogsDir, "startup-latest.log");
this.initPromise = mkdir(startupLogsDir, { recursive: true })
.then(() => Promise.all([
writeFile(this.sessionLogPath, "", "utf8"),
writeFile(this.latestLogPath, "", "utf8")
]))
.then(() => undefined);
}
getSessionLogPath(): string {
return this.sessionLogPath;
}
info(phase: StartupLogPhase, event: string, message: string, context?: Record<string, unknown>): Promise<void> {
return this.log("info", phase, event, message, context);
}
warn(phase: StartupLogPhase, event: string, message: string, context?: Record<string, unknown>): Promise<void> {
return this.log("warn", phase, event, message, context);
}
error(phase: StartupLogPhase, event: string, message: string, context?: Record<string, unknown>): Promise<void> {
return this.log("error", phase, event, message, context);
}
async log(level: StartupLogLevel, phase: StartupLogPhase, event: string, message: string, context?: Record<string, unknown>): Promise<void> {
const entry: StartupLogEntry = {
ts: new Date().toISOString(),
level,
phase,
event,
message,
...(context ? { context: sanitizeValue(context) as Record<string, unknown> } : {})
};
const line = JSON.stringify(entry) + "`n";
this.writeChain = this.writeChain.then(async () => {
await this.initPromise;
await appendFile(this.sessionLogPath, line, "utf8");
await appendFile(this.latestLogPath, line, "utf8");
}).catch(() => undefined);
await this.writeChain;
}
}
......@@ -44,6 +44,9 @@ const desktopApi: DesktopApi = {
list: () => ipcRenderer.invoke(IPC_CHANNELS.projectsList),
setActive: (projectId: string) => ipcRenderer.invoke(IPC_CHANNELS.projectsSetActive, projectId)
},
skillCatalog: {
list: () => ipcRenderer.invoke(IPC_CHANNELS.skillCatalogList)
},
auth: {
getSessionSummary: () => ipcRenderer.invoke(IPC_CHANNELS.authGetSession),
signIn: (input: SignInInput) => ipcRenderer.invoke(IPC_CHANNELS.authSignIn, input),
......
......@@ -307,6 +307,7 @@ const startupCurtainCopy = {
brandTagline: "START YOUR IDEAS",
loadingLabel: "\u6b63\u5728\u4e3a\u60a8\u51c6\u5907\u5bf9\u8bdd\u73af\u5883",
syncingConfig: "\u6b63\u5728\u540c\u6b65\u5de5\u4f5c\u914d\u7f6e",
syncingProjects: "\u6b63\u5728\u540c\u6b65\u9879\u76ee\u914d\u7f6e",
startingRuntime: "\u6b63\u5728\u5524\u8d77\u672c\u5730\u52a9\u624b",
connectingGateway: "\u6b63\u5728\u5efa\u7acb\u5bf9\u8bdd\u8fde\u63a5",
ready: "\u51c6\u5907\u5b8c\u6210\uff0c\u6b63\u5728\u8fdb\u5165\u5bf9\u8bdd",
......@@ -686,6 +687,8 @@ function getStartupProgress(phase: WorkspaceSummary["startupPhase"] | undefined)
switch (phase) {
case "syncing-config":
return 0.24;
case "syncing-projects":
return 0.4;
case "starting-runtime":
return 0.56;
case "connecting-gateway":
......@@ -711,6 +714,8 @@ function getStartupCurtainStatus(
switch (phase) {
case "syncing-config":
return startupCurtainCopy.syncingConfig;
case "syncing-projects":
return startupCurtainCopy.syncingProjects;
case "starting-runtime":
return startupCurtainCopy.startingRuntime;
case "connecting-gateway":
......@@ -960,7 +965,7 @@ export default function App() {
return;
}
const nextShouldPoll = Boolean(nextWorkspace) && (
const nextShouldPoll = nextWorkspace != null && (
nextWorkspace.chatLaunchState === "starting"
|| (!nextWorkspace.shellReady && nextWorkspace.bindingRequired)
);
......@@ -1905,7 +1910,7 @@ export default function App() {
try {
const result = await desktopApi.diagnostics.exportSnapshot();
setInfoText(ui.exported + result.filePath);
setInfoText(ui.exported + result.filePath + (result.startupLogPath ? " | startup: " + result.startupLogPath : ""));
} catch (error) {
setErrorText(err(error));
}
......@@ -2260,6 +2265,7 @@ export default function App() {
<div className="button-row startup-overlay-actions">
<button type="button" disabled={refreshing} onClick={() => void retryStartup()}>{refreshing ? ui.preparing : ui.startupRetry}</button>
<button type="button" className="secondary" onClick={() => setViewMode("settings")}>{ui.openSettings}</button>
<button type="button" className="secondary" onClick={() => void exportDiagnostics()}>{ui.export}</button>
</div>
) : null}
</div>
......
This diff is collapsed.
......@@ -277,33 +277,47 @@ if (startupOnly === 'true') {
}
const sendResult = result.sendResult || {};
const streamSmoke = sendResult.streamSmoke || {};
const executionPolicySource = String(streamSmoke.executionPolicySource || '');
if (streamSmoke.phase !== 'completed') {
const smokeViewMode = String(sendResult.smokeViewMode || 'chat');
const finalState = result.finalState || {};
if (smokeViewMode === 'skills') {
if (String(finalState.viewMode || '') !== 'skills') {
throw new Error('Skills smoke did not end on skills view: ' + String(finalState.viewMode || ''));
}
if (typeof sendResult.skillsPageCatalogCount !== 'number') {
throw new Error('Skills smoke did not report skillsPageCatalogCount.');
}
if (typeof sendResult.workspaceSkillCount !== 'number') {
throw new Error('Skills smoke did not report workspaceSkillCount.');
}
} else {
const executionPolicySource = String(streamSmoke.executionPolicySource || '');
if (streamSmoke.phase !== 'completed') {
throw new Error('Renderer stream smoke did not complete successfully: ' + streamSmoke.phase);
}
if (streamSmoke.fallbackUsed) {
}
if (streamSmoke.fallbackUsed) {
throw new Error('Renderer stream smoke fell back to non-streaming sendPrompt.');
}
if (!['cloud-default', 'cloud-skill-binding'].includes(executionPolicySource)) {
}
if (!['cloud-default', 'cloud-skill-binding'].includes(executionPolicySource)) {
throw new Error('Unexpected stream execution policy source: ' + executionPolicySource);
}
if (sendResult.selectedSkillId && streamSmoke.selectedSkillId !== sendResult.selectedSkillId) {
}
if (sendResult.selectedSkillId && streamSmoke.selectedSkillId !== sendResult.selectedSkillId) {
throw new Error('Renderer stream selectedSkillId does not match smoke selection.');
}
if (Number(streamSmoke.startedEventCount || 0) < 1) {
}
if (Number(streamSmoke.startedEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a started event.');
}
if (Number(streamSmoke.deltaEventCount || 0) < 1 && !String(streamSmoke.finalContent || '')) {
}
if (Number(streamSmoke.deltaEventCount || 0) < 1 && !String(streamSmoke.finalContent || '')) {
throw new Error('Renderer stream smoke did not observe a delta event or final assistant content.');
}
if (Number(streamSmoke.completedEventCount || 0) < 1) {
}
if (Number(streamSmoke.completedEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a completed event.');
}
if (Number(streamSmoke.errorEventCount || 0) !== 0) {
}
if (Number(streamSmoke.errorEventCount || 0) !== 0) {
throw new Error('Renderer stream smoke observed unexpected error events: ' + streamSmoke.errorEventCount);
}
if (!String(streamSmoke.renderedContent || streamSmoke.finalContent || '')) {
}
if (!String(streamSmoke.renderedContent || streamSmoke.finalContent || '')) {
throw new Error('Renderer stream smoke did not render assistant content.');
}
}
if (String(sendResult.system && sendResult.system.userDataPath) !== expectedUserData) {
throw new Error('Smoke ran against an unexpected userData path: ' + (sendResult.system && sendResult.system.userDataPath));
......@@ -312,25 +326,29 @@ if (String(sendResult.system && sendResult.system.logsPath) !== expectedLogs) {
throw new Error('Smoke ran against an unexpected logs path: ' + (sendResult.system && sendResult.system.logsPath));
}
const diagnosticsPath = String(sendResult.diagnostics && sendResult.diagnostics.filePath || '');
if (!diagnosticsPath || !fs.existsSync(diagnosticsPath)) {
throw new Error('Diagnostics snapshot was not produced by smoke.');
}
const diagnostics = JSON.parse(fs.readFileSync(diagnosticsPath, 'utf8'));
const diagnostics = diagnosticsPath && fs.existsSync(diagnosticsPath)
? JSON.parse(fs.readFileSync(diagnosticsPath, 'utf8'))
: null;
const runtimeTelemetry = sendResult.runtimeTelemetryAfterWait || sendResult.runtimeTelemetryBeforeWait || {};
if (!sendResult.runtimeCloudFetch || sendResult.runtimeCloudFetch.state !== 'ready') {
throw new Error('Runtime cloud config fetch did not succeed.');
}
if (Number(runtimeTelemetry.heartbeatSuccessCount || 0) < 1) {
if (smokeViewMode !== 'skills') {
if (!diagnosticsPath || !fs.existsSync(diagnosticsPath)) {
throw new Error('Diagnostics snapshot was not produced by smoke.');
}
if (Number(runtimeTelemetry.heartbeatSuccessCount || 0) < 1) {
throw new Error('Runtime telemetry did not record a successful heartbeat.');
}
if (Number(runtimeTelemetry.totalAcceptedEventCount || 0) < 3) {
}
if (Number(runtimeTelemetry.totalAcceptedEventCount || 0) < 3) {
throw new Error('Runtime telemetry did not accept the expected event batch count: ' + runtimeTelemetry.totalAcceptedEventCount);
}
if (Number(runtimeTelemetry.configSyncSuccessCount || 0) < 1) {
}
if (Number(runtimeTelemetry.configSyncSuccessCount || 0) < 1) {
throw new Error('Runtime telemetry did not record a successful config sync.');
}
if (!diagnostics.runtimeTelemetry) {
}
if (!diagnostics || !diagnostics.runtimeTelemetry) {
throw new Error('Diagnostics snapshot did not include runtimeTelemetry.');
}
}
if (expectBundled === 'true') {
const runtimeStatus = sendResult.runtimeStatusAfterProbe || {};
......@@ -367,7 +385,7 @@ if (expectBundled === 'true') {
}
}
let workspaceEntryValidated = false;
if (expectWorkspaceEntry === 'true') {
if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') {
const latestStatusLabel = String(streamSmoke.latestStatusLabel || '');
const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || ''))
......
# Desktop Startup Handoff (2026-04-07)
## Goal
排查并优化 Windows 安装包首启卡在“正在同步员工配置”的问题,重点处理两件事:
1. 修正启动状态汇总,避免把真实错误或后续阶段统一显示成“正在同步员工配置”
2. 增加首启结构化日志和更完整的 diagnostics 信息,便于现场机器定位卡点
## Current Status
当前不是完成态,但主体改动已经做了大半。
已完成:
- 主进程接入 startup logger 基础能力
- diagnostics 导出内容增强
- workspace summary 逻辑已改,新增 `syncing-projects` 阶段,避免“无项目”覆盖真实错误
- UI 已做最小配套修改:
- 支持 `syncing-projects`
- 导出 diagnostics 后显示 `startupLogPath`
- 启动错误遮罩上增加“导出诊断”按钮
未完成:
- 当前还没跑通整仓 typecheck
- 还没跑桌面端/安装包首启验证
- 日志点已接入到部分关键服务,但还没有做完整的 installer 现场回归
## Current Blocker
最新一次整仓类型检查:
```powershell
$env:COREPACK_HOME='D:\qjclaw\.corepack'; corepack pnpm typecheck
```
结果:
- `packages/shared-types` 通过
- `packages/gateway-client` 通过
- `apps/ui` 失败
当前唯一已确认报错:
- `apps/ui/src/App.tsx:969`
- `nextWorkspace` is possibly `null`
对应代码附近逻辑是启动轮询里的:
```ts
const nextWorkspace = await refresh(false);
const nextShouldPoll = Boolean(nextWorkspace) && (
nextWorkspace.chatLaunchState === "starting"
|| (!nextWorkspace.shellReady && nextWorkspace.bindingRequired)
);
```
这里需要把 `nextWorkspace` 收窄后再访问字段。
## Files Changed For This Task
确认与本任务直接相关的文件:
- `apps/desktop/src/main/index.ts`
- `apps/desktop/src/main/ipc.ts`
- `apps/desktop/src/main/services/cloud-api.ts`
- `apps/desktop/src/main/services/diagnostics.ts`
- `apps/desktop/src/main/services/project-bundle.ts`
- `apps/desktop/src/main/services/startup-logger.ts`
- `packages/shared-types/src/index.ts`
- `apps/ui/src/App.tsx`
## Important Functional Changes
### 1. New startup logger
新增:
- `apps/desktop/src/main/services/startup-logger.ts`
作用:
-`<logsPath>/startup/` 写 JSONL 风格首启日志
- 生成会话日志和 latest 日志
- 对 token / api key / password / URL 做基本脱敏
### 2. Shared types updated
`packages/shared-types/src/index.ts` 已添加:
- `WorkspaceStartupPhase` 新值:`syncing-projects`
- `DiagnosticsExportResult.startupLogPath?: string`
### 3. Diagnostics export enhanced
`apps/desktop/src/main/services/diagnostics.ts` 当前已支持导出更多现场信息:
- `workspaceSummary`
- `bundleSyncStatus`
- `startupLogPath`
- `reason`
并通过 `startupLogger` 写 diagnostics 导出记录。
### 4. Workspace summary fix
`apps/desktop/src/main/ipc.ts` 是本次最关键的修复点。
当前逻辑已经改成:
- `error` 不再被“无项目”覆盖
- 当 runtime cloud ready 但还没有项目时,进入 `syncing-projects`
- 如果 bundle sync 失败,返回明确错误 summary
- summary 变化会写 startup logger
- diagnostics export 时会附带 startup log path / workspace summary / bundle sync status
这部分是本次修复“首页闪一下又被‘正在同步员工配置’盖回去”的核心。
### 5. Index / service wiring
`apps/desktop/src/main/index.ts` 已做的事情:
- 创建 `startupLogger`
- 注入 `DiagnosticsService`
- 注入 `OpenClawConfigClient`
- 注入 `ProjectBundleService`
- 注入 `registerDesktopIpc`
`cloud-api.ts` / `project-bundle.ts` 已开始接入 logger,但后续仍建议在类型检查通过后再补一次日志点覆盖率检查。
### 6. UI minimal support
`apps/ui/src/App.tsx` 当前的目标性修改只有这些:
- `startupCurtainCopy.syncingProjects`
- `getStartupProgress()` 支持 `syncing-projects`
- `getStartupCurtainStatus()` 支持 `syncing-projects`
- diagnostics 成功提示里包含 `startupLogPath`
- 启动错误遮罩新增 diagnostics 导出按钮
## Other Dirty Files In Worktree
当前 `git diff --name-only` 里还有这些未提交改动:
- `apps/desktop/src/main/services/project-store.ts`
- `apps/desktop/src/preload/index.ts`
- `apps/ui/src/styles.css`
- `build/scripts/electron-smoke.ps1`
这些不在我本次“启动日志/状态机”主线修改的核心列表里,可能是已有改动或其他任务改动。
如果另一个 AI 要继续本任务,不要默认回退这些文件。
## Whether This Affects Another Task
会不会影响,取决于另一个任务改哪些文件。
影响较小的情况:
- 另一个任务不改下列文件:
- `apps/desktop/src/main/index.ts`
- `apps/desktop/src/main/ipc.ts`
- `apps/desktop/src/main/services/cloud-api.ts`
- `apps/desktop/src/main/services/diagnostics.ts`
- `apps/desktop/src/main/services/project-bundle.ts`
- `apps/ui/src/App.tsx`
- `packages/shared-types/src/index.ts`
容易冲突的情况:
- 另一个任务要改 `App.tsx`
- 另一个任务要改 desktop main 启动链路
- 另一个任务要改 diagnostics / preload / shared-types IPC 类型
所以如果你只是临时切去改完全无关模块,通常问题不大;如果另一个任务也碰启动、UI 主壳、共享类型或 preload,冲突概率就高。
## Recommended Pause Strategy
如果要先切走做别的任务,建议:
1. 不要继续在当前改动基础上随手混改启动相关文件
2. 让另一个任务尽量避开本任务相关文件
3. 如果另一个 AI 要继续本任务,先读本文件,再跑一次 typecheck,从 UI 的 nullability 报错开始收尾
## Recommended Next Steps For The Next AI
1. 修复 `apps/ui/src/App.tsx:969` 的空值收窄问题
2. 重新运行:
```powershell
$env:COREPACK_HOME='D:\qjclaw\.corepack'; corepack pnpm typecheck
```
3. 如果 typecheck 继续报主进程相关错误,再逐个修:
- `index.ts`
- `cloud-api.ts`
- `project-bundle.ts`
- `ipc.ts`
4. typecheck 通过后,验证桌面端:
- 启动阶段文案是否出现 `syncing-projects`
- diagnostics 导出是否返回 `startupLogPath`
- 启动错误遮罩是否能导出日志
5. 再做安装包/首启验证,重点看是否能区分:
- 员工配置请求失败
- 项目 bundle 同步失败
- runtime 启动失败
- gateway 连接失败
- summary 被覆盖
## Useful Commands
查看工作区变更:
```powershell
git -c safe.directory=D:/qjclaw diff --name-only
```
重新看 UI 变更:
```powershell
git -c safe.directory=D:/qjclaw diff -- apps/ui/src/App.tsx
```
重新跑类型检查:
```powershell
$env:COREPACK_HOME='D:\qjclaw\.corepack'; corepack pnpm typecheck
```
## Notes
- 这个仓库当前在 Windows 环境下,且 `D:\qjclaw` 需要按 safe.directory 方式使用 git
- 我这轮没有做 destructive revert
- 当前最重要的是先保持工作区稳定,不要在 `App.tsx` 上再叠加大改
\ No newline at end of file
export const IPC_CHANNELS = {
export const IPC_CHANNELS = {
workspaceGetSummary: "workspace:get-summary",
workspaceWarmup: "workspace:warmup",
gatewayStatus: "gateway:status",
......@@ -20,6 +20,7 @@ export const IPC_CHANNELS = {
configSave: "config:save",
projectsList: "projects:list",
projectsSetActive: "projects:set-active",
skillCatalogList: "skill-catalog:list",
chatListSessions: "chat:list-sessions",
chatListSessionsByProject: "chat:list-sessions-by-project",
chatCreateSession: "chat:create-session",
......@@ -62,7 +63,7 @@ export type RuntimeCloudEventType = "startup" | "shutdown" | "message_sent" | "m
export type PluginStatus = "included" | "extension" | "unavailable";
export type SetupMode = "employee-key" | "direct-provider";
export type ChatLaunchState = "unbound" | "starting" | "ready" | "error";
export type WorkspaceStartupPhase = "idle" | "syncing-config" | "starting-runtime" | "connecting-gateway" | "ready" | "error";
export type WorkspaceStartupPhase = "idle" | "syncing-config" | "syncing-projects" | "starting-runtime" | "connecting-gateway" | "ready" | "error";
export type SkillDownloadState = "pending" | "downloading" | "ready" | "failed" | "removed";
export type DailyReportDeliveryState = "draft" | "sent" | "failed";
......@@ -506,6 +507,7 @@ export interface AppConfig {
export interface DiagnosticsExportResult {
filePath: string;
createdAt: string;
startupLogPath?: string;
}
export interface SaveConfigInput {
......@@ -571,6 +573,25 @@ export interface SkillSummary {
requiresCredits?: number;
}
export type SkillCatalogAvailability = "usable" | "info-only";
export type SkillCatalogSource = "project" | "q-skills" | "hybrid";
export interface SkillCatalogItem {
id: string;
name: string;
zhName: string;
description: string;
zhDescription: string;
category: string;
source: SkillCatalogSource;
availability: SkillCatalogAvailability;
selectable: boolean;
isProjectSkill: boolean;
showInSkillsPage: boolean;
searchText: string;
selectionHint?: string;
}
export interface ModelCatalogItemSummary {
id: string;
label: string;
......@@ -656,6 +677,9 @@ export interface DesktopApi {
list(): Promise<ProjectSummary[]>;
setActive(projectId: string): Promise<WorkspaceSummary>;
};
skillCatalog: {
list(): Promise<SkillCatalogItem[]>;
};
auth: {
getSessionSummary(): Promise<AuthSessionSummary>;
signIn(input: SignInInput): Promise<AuthSessionSummary>;
......@@ -692,3 +716,4 @@ export interface DesktopApi {
exportSnapshot(): Promise<DiagnosticsExportResult>;
};
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment