Commit 71353fc5 authored by AI-甘富林's avatar AI-甘富林

fix(desktop): stabilize employee-key binding startup

Keep runtime-cloud target resolution out of persisted config and hold employee-key startup in syncing/starting states during transient reconnects so binding no longer flashes the wrong environment or recoverable gateway 1006 errors.
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent a9534530
......@@ -360,6 +360,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
};
const scheduleRuntimeCloudRefresh = (reason: string) => {
runtimeCloudRefreshInFlight = true;
void (async () => {
const previousConfigVersion = (await runtimeCloudClient.getStatus()).config?.configVersion;
try {
......@@ -371,6 +372,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
await syncRuntimeCloudSupervisor(`${reason}-runtime-cloud-refresh`);
} catch {
// Keep cached startup available even if the immediate cloud refresh fails.
} finally {
runtimeCloudRefreshInFlight = false;
}
})();
};
......@@ -437,6 +440,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
let workspaceWarmupTail: Promise<void> = Promise.resolve();
let workspaceWarmupInFlight = false;
let runtimeCloudRefreshInFlight = false;
let bootstrapRecoveryAttempts = 0;
let lastWorkspaceSummaryLogKey = "";
......@@ -528,12 +532,15 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
const gatewayStatus = config.apiKeyConfigured || (config.setupMode === "employee-key" && config.runtimeMode !== "external-gateway")
? await gatewayClient.status().catch(() => null)
: null;
const runtimeTelemetryStatus = runtimeCloudSupervisor.getStatus();
const baseChatSummary = buildChatSummary({
config,
runtimeStatus,
runtimeCloudStatus,
gatewayStatus,
warmupInFlight: workspaceWarmupInFlight,
runtimeCloudRefreshInFlight,
runtimeCloudConfigSyncInFlight: runtimeTelemetryStatus.configSyncInFlight,
isPackaged: systemSummary.isPackaged
});
const {
......@@ -549,10 +556,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
runtimeStatus,
gatewayStatus
});
const shouldWaitForProjectSync = projects.length === 0
&& config.apiKeyConfigured
const shouldWaitForProjectSync = config.apiKeyConfigured
&& config.setupMode === "employee-key"
&& runtimeCloudStatus.state === "ready"
&& bundleSyncStatus.state === "syncing"
&& 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.", {
......@@ -563,19 +570,17 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
bundleSyncState: bundleSyncStatus.state
});
}
const chatSummary = projects.length > 0
? baseChatSummary
: bundleSyncFailed
? {
chatReady: false,
chatLaunchState: "error" as const,
chatStatusMessage: bundleSyncStatus.lastError ?? "Workspace project sync failed. Check network access and retry.",
startupPhase: "error" as const,
startupMessage: bundleSyncStatus.lastError ?? "Workspace project sync failed. Check network access and retry."
}
: shouldWaitForProjectSync
? buildProjectSyncSummary(bundleSyncStatus.lastError ?? EMPTY_PROJECT_INVENTORY_MESSAGE)
: baseChatSummary;
const chatSummary = bundleSyncFailed
? {
chatReady: false,
chatLaunchState: "error" as const,
chatStatusMessage: bundleSyncStatus.lastError ?? "Workspace project sync failed. Check network access and retry.",
startupPhase: "error" as const,
startupMessage: bundleSyncStatus.lastError ?? "Workspace project sync failed. Check network access and retry."
}
: shouldWaitForProjectSync
? buildProjectSyncSummary(bundleSyncStatus.lastError ?? EMPTY_PROJECT_INVENTORY_MESSAGE)
: baseChatSummary;
const workspaceSummary: WorkspaceSummary = {
shellReady,
......@@ -1190,7 +1195,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
if (typeof input.authToken === "string") {
await secretManager.setAuthToken(input.authToken || undefined);
}
if (config.setupMode === "direct-provider" || previousConfig.setupMode !== config.setupMode) {
if (
config.setupMode === "direct-provider"
|| previousConfig.setupMode !== config.setupMode
|| previousConfig.runtimeCloudApiBaseUrl !== config.runtimeCloudApiBaseUrl
|| (config.setupMode === "employee-key"
&& typeof input.apiKey === "string"
&& input.apiKey.trim().length > 0)
) {
await runtimeCloudClient.clearCache().catch(() => undefined);
}
await runtimeManager.setRequestedMode(config.runtimeMode);
......
......@@ -2,9 +2,16 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { AppConfig, RuntimeModePreference, SaveConfigInput, SetupMode } from "@qjclaw/shared-types";
export type RuntimeCloudApiBaseUrlSource = "config" | "env" | "default";
export interface RuntimeCloudApiTarget {
baseUrl: string;
source: RuntimeCloudApiBaseUrlSource;
}
const CONFIG_DIR = "config";
const CONFIG_FILE = "app-config.json";
const DEFAULT_RUNTIME_CLOUD_API_BASE_URL = "https://xuphfkscoptnjoaecbvn.supabase.co/functions/v1";
const DEFAULT_RUNTIME_CLOUD_API_BASE_URL = "https://spb-bp1wv2oe0hvfvi98.supabase.opentrust.net/functions/v1";
const UI_ROUTE_NAMES = new Set([
"chat",
"control",
......@@ -84,14 +91,26 @@ function normalizeSetupMode(raw?: string): SetupMode {
return raw === "direct-provider" ? raw : "employee-key";
}
function resolveRuntimeCloudApiBaseUrl(raw?: string): string {
const normalized = normalizeCloudApiBaseUrl(raw ?? "");
function normalizeRuntimeCloudApiBaseUrl(raw?: string): string {
return normalizeCloudApiBaseUrl(raw ?? "");
}
function resolveRuntimeCloudApiTarget(raw?: string): RuntimeCloudApiTarget {
const normalized = normalizeRuntimeCloudApiBaseUrl(raw);
if (normalized) {
return normalized;
return { baseUrl: normalized, source: "config" };
}
const envValue = normalizeCloudApiBaseUrl(process.env.QJCLAW_RUNTIME_CLOUD_API_BASE_URL ?? "");
return envValue || DEFAULT_RUNTIME_CLOUD_API_BASE_URL;
if (envValue) {
return { baseUrl: envValue, source: "env" };
}
return { baseUrl: DEFAULT_RUNTIME_CLOUD_API_BASE_URL, source: "default" };
}
export function getRuntimeCloudApiTarget(config: Pick<AppConfig, "runtimeCloudApiBaseUrl">): RuntimeCloudApiTarget {
return resolveRuntimeCloudApiTarget(config.runtimeCloudApiBaseUrl);
}
export class AppConfigService {
......@@ -120,7 +139,7 @@ export class AppConfigService {
workspacePath: input.workspacePath,
gatewayUrl: normalizeGatewayUrl(input.gatewayUrl),
cloudApiBaseUrl: normalizeCloudApiBaseUrl(input.cloudApiBaseUrl),
runtimeCloudApiBaseUrl: resolveRuntimeCloudApiBaseUrl(input.runtimeCloudApiBaseUrl),
runtimeCloudApiBaseUrl: normalizeRuntimeCloudApiBaseUrl(input.runtimeCloudApiBaseUrl),
runtimeMode: normalizeRuntimeMode(input.runtimeMode)
};
......@@ -149,7 +168,7 @@ export class AppConfigService {
workspacePath: this.userDataPath,
gatewayUrl: "ws://127.0.0.1:18789",
cloudApiBaseUrl: normalizeCloudApiBaseUrl(process.env.QJCLAW_CLOUD_API_BASE_URL ?? ""),
runtimeCloudApiBaseUrl: resolveRuntimeCloudApiBaseUrl(undefined),
runtimeCloudApiBaseUrl: "",
runtimeMode: normalizeRuntimeMode(process.env.QJCLAW_RUNTIME_MODE)
};
}
......@@ -166,7 +185,7 @@ export class AppConfigService {
workspacePath: config.workspacePath ?? this.userDataPath,
gatewayUrl: normalizeGatewayUrl(config.gatewayUrl ?? `ws://127.0.0.1:${config.gatewayPort ?? 18789}`),
cloudApiBaseUrl: normalizeCloudApiBaseUrl(config.cloudApiBaseUrl ?? process.env.QJCLAW_CLOUD_API_BASE_URL ?? ""),
runtimeCloudApiBaseUrl: resolveRuntimeCloudApiBaseUrl(config.runtimeCloudApiBaseUrl),
runtimeCloudApiBaseUrl: normalizeRuntimeCloudApiBaseUrl(config.runtimeCloudApiBaseUrl),
runtimeMode: normalizeRuntimeMode(config.runtimeMode ?? process.env.QJCLAW_RUNTIME_MODE)
};
}
......
......@@ -20,7 +20,8 @@ import type {
SkillSummary,
UserProfileSummary
} from "@qjclaw/shared-types";
import type { AppConfigService } from "./app-config.js";
import { getRuntimeCloudApiTarget } from "./app-config.js";
import type { AppConfigService, RuntimeCloudApiBaseUrlSource } from "./app-config.js";
import type { RemoteSkillAsset } from "./skill-store.js";
import type { SecretManager } from "./secrets.js";
import type { StartupLogger } from "./startup-logger.js";
......@@ -211,6 +212,35 @@ function buildApiKeyFingerprint(apiKey: string): string {
return createHash("sha256").update(apiKey).digest("hex");
}
function toDiagnosticKeyFingerprint(apiKey?: string): string | undefined {
if (!apiKey) {
return undefined;
}
return buildApiKeyFingerprint(apiKey).slice(0, 12);
}
function toBaseHost(baseUrl?: string): string | undefined {
if (!baseUrl) {
return undefined;
}
try {
return new URL(baseUrl).host;
} catch {
return undefined;
}
}
function classifyRuntimeCloudError(message: string): string {
const normalized = message.toLowerCase();
if (normalized.includes("invalid api_key or employee not found")) {
return "员工密钥无效,或当前客户端正连接到未收录该密钥的绑定环境。";
}
return message;
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
}
......@@ -516,6 +546,7 @@ export class OpenClawConfigClient {
private statusCache: RuntimeCloudStatus = {
state: "unconfigured",
baseUrl: "",
baseUrlSource: "default",
apiKeyConfigured: false
};
private cacheLoaded = false;
......@@ -584,11 +615,15 @@ export class OpenClawConfigClient {
async getStatus(): Promise<RuntimeCloudStatus> {
await this.hydrateCache();
const config = await this.configService.load();
const apiKey = await this.secretManager.getApiKey();
const apiKey = (await this.secretManager.getApiKey())?.trim();
const { baseUrl, source } = getRuntimeCloudApiTarget(config);
return {
...this.statusCache,
baseUrl: config.runtimeCloudApiBaseUrl.trim().replace(/\/$/, ""),
apiKeyConfigured: Boolean(apiKey)
baseUrl,
baseUrlSource: source,
baseHost: toBaseHost(baseUrl),
apiKeyConfigured: Boolean(apiKey),
apiKeyFingerprint: toDiagnosticKeyFingerprint(apiKey)
};
}
......@@ -631,22 +666,27 @@ export class OpenClawConfigClient {
await this.hydrateCache();
const config = await this.configService.load();
const startedAt = Date.now();
const baseUrl = config.runtimeCloudApiBaseUrl.trim().replace(/\/$/, "");
const { baseUrl, source } = getRuntimeCloudApiTarget(config);
const baseHost = toBaseHost(baseUrl);
const apiKey = (await this.secretManager.getApiKey())?.trim();
const apiKeyFingerprint = toDiagnosticKeyFingerprint(apiKey);
if (!baseUrl) {
return this.fail(baseUrl, Boolean(apiKey), "\u004f\u0070\u0065\u006e\u0043\u006c\u0061\u0077\u0020\u8fd0\u884c\u65f6\u4e91\u7aef\u5730\u5740\u672a\u914d\u7f6e\u3002");
return this.fail(baseUrl, source, Boolean(apiKey), apiKeyFingerprint, "\u004f\u0070\u0065\u006e\u0043\u006c\u0061\u0077\u0020\u8fd0\u884c\u65f6\u4e91\u7aef\u5730\u5740\u672a\u914d\u7f6e\u3002");
}
if (!apiKey) {
return this.fail(baseUrl, false, "\u8bf7\u5148\u7ed1\u5b9a\u0020\u004f\u0070\u0065\u006e\u0043\u006c\u0061\u0077\u0020\u0065\u006d\u0070\u006c\u006f\u0079\u0065\u0065\u0020\u0041\u0050\u0049\u0020\u004b\u0065\u0079\u3002");
return this.fail(baseUrl, source, false, undefined, "\u8bf7\u5148\u7ed1\u5b9a\u0020\u004f\u0070\u0065\u006e\u0043\u006c\u0061\u0077\u0020\u0065\u006d\u0070\u006c\u006f\u0079\u0065\u0065\u0020\u0041\u0050\u0049\u0020\u004b\u0065\u0079\u3002");
}
this.statusCache = {
...this.statusCache,
state: "loading",
baseUrl,
baseUrlSource: source,
baseHost,
apiKeyConfigured: true,
apiKeyFingerprint,
lastError: undefined
};
......@@ -678,7 +718,10 @@ export class OpenClawConfigClient {
this.statusCache = {
state: "ready",
baseUrl,
baseUrlSource: source,
baseHost,
apiKeyConfigured: true,
apiKeyFingerprint,
lastFetchedAt: fetchedAt,
config: summary,
lastError: undefined
......@@ -695,7 +738,10 @@ export class OpenClawConfigClient {
this.statusCache = {
state: "ready",
baseUrl,
baseUrlSource: source,
baseHost,
apiKeyConfigured: true,
apiKeyFingerprint,
lastFetchedAt: summary.fetchedAt,
config: summary,
lastError: undefined
......@@ -704,13 +750,17 @@ export class OpenClawConfigClient {
await this.notifyPayloadUpdated(action, summary);
return payload;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const rawMessage = error instanceof Error ? error.message : String(error);
const message = classifyRuntimeCloudError(rawMessage);
if (this.payloadCache && this.statusCache.config) {
this.statusCache = {
...this.statusCache,
state: "ready",
baseUrl,
baseUrlSource: source,
baseHost,
apiKeyConfigured: true,
apiKeyFingerprint,
lastError: message
};
throw new Error(message);
......@@ -718,10 +768,13 @@ export class OpenClawConfigClient {
await this.startupLogger?.error("runtime-cloud", "fetch.error", "Runtime cloud fetch failed without cache fallback.", {
action,
baseUrl,
baseUrlSource: source,
baseHost,
apiKeyFingerprint,
elapsedMs: Date.now() - startedAt,
error: message
});
return this.fail(baseUrl, true, message);
return this.fail(baseUrl, source, true, apiKeyFingerprint, message);
}
}
......@@ -746,12 +799,21 @@ export class OpenClawConfigClient {
}
}
private fail(baseUrl: string, apiKeyConfigured: boolean, message: string): never {
private fail(
baseUrl: string,
baseUrlSource: RuntimeCloudApiBaseUrlSource,
apiKeyConfigured: boolean,
apiKeyFingerprint: string | undefined,
message: string
): never {
this.statusCache = {
...this.statusCache,
state: apiKeyConfigured ? "error" : "unconfigured",
baseUrl,
baseUrlSource,
baseHost: toBaseHost(baseUrl),
apiKeyConfigured,
apiKeyFingerprint,
lastError: message
};
throw new Error(message);
......@@ -944,7 +1006,7 @@ export class OpenClawDailyReportClient {
async submit(payload: Omit<OpenClawDailyReportPayload, "api_key">): Promise<OpenClawDailyReportResponse> {
const config = await this.configService.load();
const baseUrl = config.runtimeCloudApiBaseUrl.trim().replace(/\/$/, "");
const { baseUrl } = getRuntimeCloudApiTarget(config);
const apiKey = (await this.secretManager.getApiKey())?.trim();
if (!baseUrl) {
......
......@@ -18,6 +18,7 @@ import type {
UserProfileSummary,
WorkspaceSummary
} from "@qjclaw/shared-types";
import { getRuntimeCloudApiTarget } from "./app-config.js";
import type { LocalOpenClawGatewayConfig } from "./openclaw-local-config.js";
import type { ProjectBundleSyncStatus } from "./project-bundle.js";
import type { StartupLogger } from "./startup-logger.js";
......@@ -64,6 +65,7 @@ export class DiagnosticsService {
const createdAt = new Date().toISOString();
const diagnosticsDir = path.join(this.userDataPath, "diagnostics");
const filePath = path.join(diagnosticsDir, `snapshot-${toSafeStamp(createdAt)}.json`);
const runtimeCloudTarget = getRuntimeCloudApiTarget(input.config);
const payload = {
createdAt,
......@@ -95,6 +97,8 @@ export class DiagnosticsService {
gatewayUrl: input.config.gatewayUrl,
cloudApiBaseUrl: input.config.cloudApiBaseUrl,
runtimeCloudApiBaseUrl: input.config.runtimeCloudApiBaseUrl,
runtimeCloudApiResolvedBaseUrl: runtimeCloudTarget.baseUrl,
runtimeCloudApiBaseUrlSource: runtimeCloudTarget.source,
runtimeMode: input.config.runtimeMode,
apiKeyConfigured: input.config.apiKeyConfigured,
gatewayTokenConfigured: input.config.gatewayTokenConfigured,
......@@ -114,6 +118,8 @@ export class DiagnosticsService {
? {
state: input.runtimeCloudStatus.state,
baseUrl: input.runtimeCloudStatus.baseUrl,
baseUrlSource: input.runtimeCloudStatus.baseUrlSource,
baseHost: input.runtimeCloudStatus.baseHost,
apiKeyConfigured: input.runtimeCloudStatus.apiKeyConfigured,
lastFetchedAt: input.runtimeCloudStatus.lastFetchedAt,
lastError: input.runtimeCloudStatus.lastError,
......
......@@ -7,7 +7,7 @@ import type {
RuntimeTelemetryStatus
} from "@qjclaw/shared-types";
import type { RuntimeManager } from "@qjclaw/runtime-manager";
import type { AppConfigService } from "./app-config.js";
import { getRuntimeCloudApiTarget, type AppConfigService } from "./app-config.js";
import type { OpenClawConfigClient } from "./cloud-api.js";
import type { SecretManager } from "./secrets.js";
......@@ -156,7 +156,7 @@ class RuntimeCloudApiBase {
protected async resolveRequestContext(endpoint: "heartbeat" | "events"): Promise<RuntimeCloudRequestContext> {
const config = await this.configService.load();
const baseUrl = config.runtimeCloudApiBaseUrl.trim().replace(/\/$/, "");
const { baseUrl } = getRuntimeCloudApiTarget(config);
const apiKey = (await this.secretManager.getApiKey())?.trim();
const runtimeCloudStatus = await this.runtimeCloudClient.getStatus();
......@@ -270,6 +270,7 @@ export class RuntimeCloudSupervisor {
messageCount: 0,
activeConversationCount: 0,
errorCount: 0,
configSyncInFlight: false,
configSyncSuccessCount: 0,
heartbeatSuccessCount: 0,
lastEventTypes: [],
......@@ -533,6 +534,7 @@ export class RuntimeCloudSupervisor {
}
this.configSyncInFlight = true;
this.telemetry.configSyncInFlight = true;
const startedAt = new Date().toISOString();
const previousVersion = this.telemetry.currentConfigVersion ?? (await this.runtimeCloudClient.getStatus()).config?.configVersion;
......@@ -563,6 +565,7 @@ export class RuntimeCloudSupervisor {
this.noteError("config_sync_failed", message, { emitEvent: true });
} finally {
this.configSyncInFlight = false;
this.telemetry.configSyncInFlight = false;
}
}
......
......@@ -14,8 +14,11 @@ export function isTransientLocalGatewayError(message?: string): boolean {
const normalized = message.toLowerCase();
return normalized.includes("gateway websocket is not open")
|| normalized.includes("gateway closed during connect (1006)")
|| normalized.includes("gateway connection closed (1006)")
|| normalized.includes("gateway closed during connect (1005)")
|| normalized.includes("gateway connection closed (1005)")
|| normalized.includes("gateway closed during connect (1000)")
|| normalized.includes("gateway connection closed (1000)")
|| normalized.includes("econnrefused")
|| normalized.includes("failed to connect to ws://127.0.0.1")
|| normalized.includes("failed to connect to ws://localhost")
......@@ -179,12 +182,52 @@ function buildGatewayStartingSummary(gatewayStatus: GatewayStatus | null): Pick<
};
}
function shouldWaitForRuntimeCloudStabilization(input: {
config: AppConfig;
runtimeStatus: RuntimeStatus;
runtimeCloudStatus: RuntimeCloudStatus;
warmupInFlight: boolean;
runtimeCloudRefreshInFlight: boolean;
runtimeCloudConfigSyncInFlight: boolean;
isPackaged: boolean;
}): boolean {
const {
config,
runtimeStatus,
runtimeCloudStatus,
warmupInFlight,
runtimeCloudRefreshInFlight,
runtimeCloudConfigSyncInFlight,
isPackaged
} = input;
if (config.setupMode !== "employee-key" || config.runtimeMode === "external-gateway") {
return false;
}
if (runtimeCloudStatus.state === "loading" || runtimeCloudStatus.state === "unconfigured") {
return true;
}
if (runtimeCloudRefreshInFlight || runtimeCloudConfigSyncInFlight) {
return runtimeStatus.processState === "running";
}
const packagedBundledRuntime = isPackaged;
return packagedBundledRuntime
&& warmupInFlight
&& runtimeStatus.processState === "running"
&& runtimeCloudStatus.state === "ready";
}
export function buildChatSummary(input: {
config: AppConfig;
runtimeStatus: RuntimeStatus;
runtimeCloudStatus: RuntimeCloudStatus;
gatewayStatus: GatewayStatus | null;
warmupInFlight: boolean;
runtimeCloudRefreshInFlight: boolean;
runtimeCloudConfigSyncInFlight: boolean;
isPackaged: boolean;
}): Pick<WorkspaceSummary, "chatReady" | "chatLaunchState" | "chatStatusMessage" | "startupPhase" | "startupMessage"> {
const {
......@@ -193,6 +236,8 @@ export function buildChatSummary(input: {
runtimeCloudStatus,
gatewayStatus,
warmupInFlight,
runtimeCloudRefreshInFlight,
runtimeCloudConfigSyncInFlight,
isPackaged
} = input;
......@@ -201,7 +246,7 @@ export function buildChatSummary(input: {
runtimeStatus,
gatewayStatus
});
const packagedBundledRuntime = isPackaged && config.runtimeMode !== "external-gateway";
const packagedBundledRuntime = isPackaged;
const runtimeError = runtimeStatus.lastError ?? runtimeStatus.message;
const gatewayError = gatewayStatus?.lastError ?? gatewayStatus?.message;
......@@ -270,7 +315,10 @@ export function buildChatSummary(input: {
}
if (config.setupMode === "employee-key" && runtimeCloudStatus.state === "error") {
const runtimeCloudError = runtimeCloudStatus.lastError ?? "员工配置同步失败,请检查密钥或网络连接。";
const baseHostSuffix = runtimeCloudStatus.baseHost ? `(当前绑定环境:${runtimeCloudStatus.baseHost})` : "";
const runtimeCloudError = runtimeCloudStatus.lastError
? `${runtimeCloudStatus.lastError}${baseHostSuffix}`
: `员工配置同步失败,请检查密钥或网络连接。${baseHostSuffix}`;
return {
chatReady: false,
chatLaunchState: "error",
......@@ -310,6 +358,24 @@ export function buildChatSummary(input: {
};
}
if (shouldWaitForRuntimeCloudStabilization({
config,
runtimeStatus,
runtimeCloudStatus,
warmupInFlight,
runtimeCloudRefreshInFlight,
runtimeCloudConfigSyncInFlight,
isPackaged
})) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: "正在同步员工配置,请稍候。",
startupPhase: "syncing-config",
startupMessage: "正在同步员工配置,请稍候。"
};
}
if (runtimeStatus.processState === "error") {
const runtimeErrorMessage = toStartupErrorMessage(runtimeError, "本地助手启动失败,请稍后重试。");
return {
......@@ -343,16 +409,6 @@ export function buildChatSummary(input: {
};
}
if (config.setupMode === "employee-key" && (runtimeCloudStatus.state === "loading" || runtimeCloudStatus.state === "unconfigured")) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: "正在同步员工配置,请稍候。",
startupPhase: "syncing-config",
startupMessage: "正在同步员工配置,请稍候。"
};
}
if (runtimeStatus.processState === "starting" || (runtimeStatus.selectedMode === "bundled-runtime" && runtimeStatus.processState !== "running")) {
return buildRuntimeStartingSummary(runtimeStatus);
}
......
......@@ -676,8 +676,8 @@ function getExpertGuide(project: ExpertProject | undefined): ExpertGuideContent
}
}
function canExchangeMessages(runtimeStatus: RuntimeStatus | null, gatewayStatus: GatewayStatus | null) {
if (!runtimeStatus || gatewayStatus?.state !== "connected") {
function canExchangeMessages(workspace: WorkspaceSummary | null, runtimeStatus: RuntimeStatus | null, gatewayStatus: GatewayStatus | null) {
if (!workspace?.chatReady || !runtimeStatus || gatewayStatus?.state !== "connected") {
return false;
}
return runtimeStatus.activeMode === "external-gateway" || runtimeStatus.processState === "running";
......@@ -879,7 +879,7 @@ export default function App() {
setSelectedSkillId(nextSkills[0].id);
}
const canReadMessages = nextWorkspace.chatReady && canExchangeMessages(nextRuntime, statusResult);
const canReadMessages = canExchangeMessages(nextWorkspace, nextRuntime, statusResult);
if (canReadMessages) {
setGatewayHealth(await desktopApi.gateway.health().catch(() => null));
} else {
......@@ -1048,7 +1048,7 @@ export default function App() {
}, [config?.setupMode, config?.provider, config?.baseUrl, config?.defaultModel]);
useEffect(() => {
if (!isBound || !resolvedActiveSessionId || !workspace?.chatReady || !canExchangeMessages(runtimeStatus, gatewayStatus)) {
if (!isBound || !resolvedActiveSessionId || !workspace?.chatReady || !canExchangeMessages(workspace, runtimeStatus, gatewayStatus)) {
return;
}
......@@ -1238,7 +1238,7 @@ export default function App() {
await switchExpert(requestedProjectId);
const projectSessions = await desktopApi.chat.listSessionsByProject(requestedProjectId);
resolvedSessionId = resolvedSessionId
|| projectSessions.find((session) => session.id === activeSessionId)?.id
|| projectSessions.find((session) => session.id === activeSessionId && session.projectId === requestedProjectId)?.id
|| projectSessions[0]?.id
|| (await desktopApi.chat.createSessionForProject(requestedProjectId, "Smoke Test")).id;
} else if (resolvedSessionId) {
......@@ -1252,7 +1252,7 @@ export default function App() {
setPrompt(nextPrompt);
window.setTimeout(() => {
void submitPrompt(nextPrompt, resolvedSkillId, resolvedSessionId || undefined);
void submitPrompt(nextPrompt, resolvedSkillId, resolvedSessionId || undefined, requestedProjectId || undefined);
}, 0);
return {
......@@ -1631,6 +1631,7 @@ export default function App() {
const resolvedProvider = resolvedSetupMode === "direct-provider" ? providerDraft : config.provider;
const resolvedBaseUrl = (resolvedSetupMode === "direct-provider" ? baseUrlDraft : config.baseUrl).trim();
const resolvedDefaultModel = (resolvedSetupMode === "direct-provider" ? defaultModelDraft : config.defaultModel).trim() || config.defaultModel;
const persistedRuntimeCloudApiBaseUrl = config.runtimeCloudApiBaseUrl.trim();
if (!trimmedApiKey) {
setErrorText(resolvedSetupMode === "direct-provider" ? "\u8bf7\u8f93\u5165 API Key" : ui.bindFirstError);
......@@ -1655,7 +1656,7 @@ export default function App() {
workspacePath: workspacePathDraft.trim() || config.workspacePath,
gatewayUrl: config.gatewayUrl,
cloudApiBaseUrl: config.cloudApiBaseUrl,
runtimeCloudApiBaseUrl: config.runtimeCloudApiBaseUrl,
runtimeCloudApiBaseUrl: persistedRuntimeCloudApiBaseUrl,
runtimeMode: "bundled-runtime",
apiKey: trimmedApiKey
};
......@@ -1706,8 +1707,8 @@ export default function App() {
if (!latestWorkspace.apiKeyConfigured) {
throw new Error(ui.bindFirstError);
}
if (latestWorkspace.chatReady && Boolean(latestWorkspace.projectReady && latestWorkspace.currentProjectId) && canExchangeMessages(latestRuntime, latestGateway)) {
return;
if (latestWorkspace.chatReady && Boolean(latestWorkspace.projectReady && latestWorkspace.currentProjectId) && canExchangeMessages(latestWorkspace, latestRuntime, latestGateway)) {
return latestWorkspace;
}
let nextRuntime = latestRuntime;
......@@ -1739,14 +1740,15 @@ export default function App() {
}
setGatewayStatus(confirmedGateway);
if (confirmedWorkspace.chatReady && Boolean(confirmedWorkspace.projectReady && confirmedWorkspace.currentProjectId) && canExchangeMessages(confirmedRuntime, confirmedGateway)) {
return;
if (confirmedWorkspace.chatReady && Boolean(confirmedWorkspace.projectReady && confirmedWorkspace.currentProjectId) && canExchangeMessages(confirmedWorkspace, confirmedRuntime, confirmedGateway)) {
return confirmedWorkspace;
}
throw new Error(confirmedWorkspace.chatStatusMessage ?? ui.chatNotReadyError);
}
async function submitPrompt(promptText: string, requestedSkillId?: string, forcedSessionId?: string) {
async function submitPrompt(promptText: string, requestedSkillId?: string, forcedSessionId?: string, forcedProjectId?: string) {
const trimmedPrompt = promptText.trim();
if (!trimmedPrompt || sending || saving) {
return;
......@@ -1771,16 +1773,22 @@ export default function App() {
let sessionId = forcedSessionId ?? resolvedActiveSessionId;
try {
const chatReadyAtSend = Boolean(workspace?.chatReady) && canExchangeMessages(runtimeStatus, gatewayStatus);
if (!chatReadyAtSend) {
await ensureChatAvailable(assistantMessage.id);
const confirmedWorkspace = await ensureChatAvailable(assistantMessage.id);
const effectiveProjectId = forcedProjectId ?? confirmedWorkspace.currentProjectId ?? sessionScopeProjectId;
if (!effectiveProjectId) {
throw new Error(ui.chatNotReadyError);
}
if (!sessionId) {
if (!sessionScopeProjectId) {
throw new Error(ui.chatNotReadyError);
if (sessionId) {
const projectSessions = await desktopApi.chat.listSessionsByProject(effectiveProjectId).catch(() => []);
const matchedSession = projectSessions.find((session) => session.id === sessionId);
if (!matchedSession) {
sessionId = "";
}
const createdSession = await desktopApi.chat.createSessionForProject(sessionScopeProjectId);
}
if (!sessionId) {
const createdSession = await desktopApi.chat.createSessionForProject(effectiveProjectId);
sessionId = createdSession.id;
setActiveSessionId(createdSession.id);
setSessions((current) => [createdSession, ...current.filter((session) => session.id !== createdSession.id)]);
......@@ -2225,6 +2233,13 @@ export default function App() {
<button disabled={saving} onClick={() => void saveConfig(apiKeyDraft, setupModeDraft)}>{saving ? ui.saving : ui.save}</button>
</div>
{showSettingsStatusHint ? <div className={"inline-hint" + (chatLaunchState === "error" ? " error" : "")}>{startupMessage}</div> : null}
{runtimeCloudStatus ? (
<div className="form-grid single">
<div className="mini-info"><span>Runtime Cloud Target</span><strong>{runtimeCloudStatus.baseUrl || ui.none}</strong></div>
<div className="mini-info"><span>Target Source</span><strong>{runtimeCloudStatus.baseUrlSource ?? ui.none}</strong></div>
<div className="mini-info"><span>Binding Host</span><strong>{runtimeCloudStatus.baseHost ?? ui.none}</strong></div>
</div>
) : null}
<div className="form-grid single">
<label>
{ui.workspacePath}
......
......@@ -150,6 +150,8 @@ async function main(): Promise<void> {
runtimeCloudStatus: createRuntimeCloudStatus(),
gatewayStatus,
warmupInFlight: true,
runtimeCloudRefreshInFlight: false,
runtimeCloudConfigSyncInFlight: false,
isPackaged: true
});
assert(startupSummary.chatLaunchState === "starting", "Transient packaged startup failures should remain in starting state.");
......@@ -161,6 +163,8 @@ async function main(): Promise<void> {
runtimeCloudStatus: createRuntimeCloudStatus(),
gatewayStatus,
warmupInFlight: true,
runtimeCloudRefreshInFlight: false,
runtimeCloudConfigSyncInFlight: false,
isPackaged: true
});
assert(gatewayOnlySummary.chatLaunchState === "starting", "Transient gateway failures should remain in starting state during packaged warmup.");
......@@ -174,6 +178,24 @@ async function main(): Promise<void> {
}), "Packaged bundled-runtime bootstrap should retry transient startup failures.");
assert(shouldRetryManagedRuntimeStartup(config, runtimeStatus), "Packaged bundled-runtime startup should retry transient runtime failures.");
const syncingConfigSummary = buildChatSummary({
config,
runtimeStatus: createRuntimeStatus({
processState: "running",
activeMode: "bundled-runtime"
}),
runtimeCloudStatus: createRuntimeCloudStatus(),
gatewayStatus: createGatewayStatus({
state: "connected"
}),
warmupInFlight: false,
runtimeCloudRefreshInFlight: true,
runtimeCloudConfigSyncInFlight: false,
isPackaged: false
});
assert(syncingConfigSummary.chatLaunchState === "starting", "Runtime cloud refresh should keep chat unavailable while config is syncing.");
assert(syncingConfigSummary.startupPhase === "syncing-config", "Runtime cloud refresh should map to syncing-config.");
const policyViolationRuntimeStatus = createRuntimeStatus({
processState: "error",
lastError: "Gateway connection closed (1008)."
......@@ -245,6 +267,8 @@ async function main(): Promise<void> {
message: "Gateway is connecting."
}),
warmupInFlight: true,
runtimeCloudRefreshInFlight: false,
runtimeCloudConfigSyncInFlight: false,
isPackaged: true
});
assert(unboundStartingSummary.chatLaunchState === "starting", "Unbound packaged startup should remain in starting before shell prewarm completes.");
......@@ -263,6 +287,8 @@ async function main(): Promise<void> {
state: "connected"
}),
warmupInFlight: false,
runtimeCloudRefreshInFlight: false,
runtimeCloudConfigSyncInFlight: false,
isPackaged: true
});
assert(unboundReadyForBindSummary.chatLaunchState === "unbound", "Unbound packaged startup should expose binding only after shell prewarm completes.");
......
......@@ -58,6 +58,7 @@ export type ModelRecommendation = "default" | "chat" | "skills" | "analysis";
export type SkillModelBindingMode = "default" | "forced" | "selectable";
export type RuntimeCloudFetchAction = "init" | "sync";
export type RuntimeCloudState = "unconfigured" | "loading" | "ready" | "error";
export type RuntimeCloudApiBaseUrlSource = "config" | "env" | "default";
export type RuntimeTelemetryState = "idle" | "running" | "stopped" | "error";
export type RuntimeCloudEventType = "startup" | "shutdown" | "message_sent" | "message_received" | "error" | "config_updated";
export type PluginStatus = "included" | "extension" | "unavailable";
......@@ -166,7 +167,10 @@ export interface RuntimeCloudConfigSummary {
export interface RuntimeCloudStatus {
state: RuntimeCloudState;
baseUrl: string;
baseUrlSource?: RuntimeCloudApiBaseUrlSource;
baseHost?: string;
apiKeyConfigured: boolean;
apiKeyFingerprint?: string;
lastFetchedAt?: string;
lastError?: string;
config?: RuntimeCloudConfigSummary;
......@@ -218,6 +222,7 @@ export interface RuntimeTelemetryStatus {
messageCount: number;
activeConversationCount: number;
errorCount: number;
configSyncInFlight: boolean;
lastError?: string;
currentConfigVersion?: string;
lastHeartbeatAt?: string;
......
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