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

feat(desktop): harden bundled runtime startup and workspace launch state

- Extract workspace-startup.ts with buildChatSummary, shouldRetryBootstrapWarmup,
  isTransientLocalGatewayError and toStartupErrorMessage for unified startup state
- Converge index.ts and ipc.ts to use runtimeManager.getGatewayConnection() as
  truth source for bundled-runtime mode, preventing fallback to persisted 18789
- Add WINDOWS_POWERSHELL_PATH / WINDOWS_TASKKILL_PATH absolute paths in
  runtime-manager to fix Git Bash path hijacking on Windows
- Buffer last 50 stderr lines and surface name-conflict / port-in-use hints in
  lastError via buildStderrHint()
- Add smoke:workspace-startup script and workspace-startup-smoke test
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent e47643bd
......@@ -3,15 +3,20 @@ import { appendFile, readFile, writeFile } from "node:fs/promises";
import { BrowserWindow, app } from "electron";
import { GatewayClient } from "@qjclaw/gateway-client";
import { RuntimeManager } from "@qjclaw/runtime-manager";
import type { AppConfig, RuntimeModePreference, SystemSummary } from "@qjclaw/shared-types";
import type { AppConfig, RuntimeCloudFetchAction, RuntimeModePreference, SystemSummary } from "@qjclaw/shared-types";
import { createMainWindow } from "./create-window.js";
import { registerDesktopIpc } from "./ipc.js";
import { registerDesktopIpc, type RegisteredDesktopIpc } from "./ipc.js";
import { AppConfigService } from "./services/app-config.js";
import { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ProfileClient } from "./services/cloud-api.js";
import { DeviceIdentityService } from "./services/device-identity.js";
import { DiagnosticsService } from "./services/diagnostics.js";
import { DailyReportService } from "./services/daily-report-service.js";
import { loadLocalOpenClawGatewayConfig, resolveEffectiveGatewayUrl } from "./services/openclaw-local-config.js";
import {
loadLocalOpenClawGatewayConfig,
resolveEffectiveGatewayToken,
resolveEffectiveGatewayUrl,
shouldUseLocalOpenClawGateway
} from "./services/openclaw-local-config.js";
import { SecretManager } from "./services/secrets.js";
import { startSmokeCloudApiServer } from "./services/smoke-cloud-api.js";
import { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js";
......@@ -391,6 +396,41 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
lastError
};
};
const waitForWorkspaceReady = async () => {
let workspace = await api.workspace.getSummary();
const deadline = Date.now() + 45000;
let warmupQueued = false;
while (Date.now() < deadline) {
const hasProject = Boolean(
workspace.chatReady
&& workspace.projectReady
&& workspace.currentProjectId
&& Array.isArray(workspace.projects)
&& workspace.projects.length > 0
);
if (hasProject) {
return workspace;
}
if (!warmupQueued) {
await api.workspace.warmup().catch(() => undefined);
warmupQueued = true;
}
await sleep(1000);
workspace = await api.workspace.getSummary();
}
throw new Error("Workspace did not become ready for smoke chat. lastState=" + JSON.stringify({
chatReady: workspace.chatReady,
projectReady: workspace.projectReady,
currentProjectId: workspace.currentProjectId,
projectCount: workspace.projectCount,
sessionCount: Array.isArray(workspace.sessions) ? workspace.sessions.length : 0,
startupPhase: workspace.startupPhase,
startupMessage: workspace.startupMessage,
chatStatusMessage: workspace.chatStatusMessage,
runtimeCloudState: workspace.runtimeCloudState,
runtimeState: workspace.runtimeState
}));
};
const runtimeCloudStatus = await api.runtimeCloud.getStatus();
const runtimeCloudFetch = runtimeCloudStatus.apiKeyConfigured ? await api.runtimeCloud.fetchConfig("init") : runtimeCloudStatus;
const runtimeStatus = await api.runtime.getStatus();
......@@ -405,7 +445,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const profile = session.state === "authenticated" ? await api.profile.getSummary() : null;
const credits = session.state === "authenticated" ? await api.credits.getSummary() : null;
const skills = session.state === "authenticated" ? await api.skills.list() : [];
const workspace = await api.workspace.getSummary();
const workspace = await waitForWorkspaceReady();
const readyWorkspaceSkills = workspace.skills.filter((skill) => skill.ready);
const readySkills = skills.filter((skill) => skill.ready);
const selectedSkillId = preferredSkillId
......@@ -618,11 +658,7 @@ async function bootstrap(): Promise<void> {
await syncProjectBundles(skills, payloadConfig.configVersion, "sync");
});
const cachedRuntimeCloudStatus = await runtimeCloudClient.getStatus();
if (cachedRuntimeCloudStatus.config) {
const cachedRemoteSkills = runtimeCloudClient.getRemoteSkillAssets();
await skillStore.reconcile(cachedRemoteSkills, cachedRuntimeCloudStatus.config.configVersion).catch(() => undefined);
await syncProjectBundles(cachedRemoteSkills, cachedRuntimeCloudStatus.config.configVersion, "init");
}
const cachedRuntimeCloudConfig = cachedRuntimeCloudStatus.config;
const runtimeManager = new RuntimeManager({
vendorRuntimeDir: resolveVendorRuntimeDir(systemSummary),
......@@ -647,13 +683,27 @@ async function bootstrap(): Promise<void> {
await traceBootstrap("runtime-configure-done");
const projectWorkspaceExecutor = new ProjectWorkspaceExecutorService(runtimeManager);
const runtimeStatus = await runtimeManager.status();
const runtimeGatewayConnection = await runtimeManager.getGatewayConnection();
if (systemSummary.isPackaged && runtimeStatus.payloadState !== "ready") {
throw new Error(`Packaged app bundled runtime is not ready: ${runtimeStatus.payloadState}`);
}
const shouldUseBundledRuntimeGateway = config.runtimeMode !== "external-gateway"
&& runtimeStatus.selectedMode === "bundled-runtime"
&& typeof runtimeGatewayConnection.url === "string";
const gatewayClient = new GatewayClient({
url: resolveEffectiveGatewayUrl(config.gatewayUrl, localOpenClawConfig?.gatewayUrl),
token: (await secretManager.getGatewayToken()) ?? localOpenClawConfig?.gatewayToken,
url: shouldUseBundledRuntimeGateway
? runtimeGatewayConnection.url!
: resolveEffectiveGatewayUrl(
config.gatewayUrl,
shouldUseLocalOpenClawGateway(systemSummary.isPackaged, config.runtimeMode) ? localOpenClawConfig?.gatewayUrl : undefined
),
token: shouldUseBundledRuntimeGateway
? runtimeGatewayConnection.token
: resolveEffectiveGatewayToken(
await secretManager.getGatewayToken(),
shouldUseLocalOpenClawGateway(systemSummary.isPackaged, config.runtimeMode) ? localOpenClawConfig?.gatewayToken : undefined
),
deviceToken: await secretManager.getDeviceToken(),
deviceIdentity: deviceIdentityService,
onDeviceToken: async (deviceToken) => {
......@@ -686,62 +736,8 @@ async function bootstrap(): Promise<void> {
dailyReportService.handleActivity(event);
});
const scheduleRuntimeCloudRefresh = (reason: string) => {
void (async () => {
const previousConfigVersion = (await runtimeCloudClient.getStatus()).config?.configVersion;
try {
const status = await runtimeCloudClient.fetchConfig(previousConfigVersion ? "sync" : "init");
const nextConfigVersion = status.config?.configVersion;
if (previousConfigVersion && nextConfigVersion && previousConfigVersion !== nextConfigVersion) {
await runtimeManager.syncManagedConfig("sync");
}
} catch (error) {
console.warn(`${reason} runtime cloud refresh skipped:`, error instanceof Error ? error.message : String(error));
}
})();
};
if (resolveRequestedRuntimeMode(config.runtimeMode) !== "external-gateway" && (await secretManager.getApiKey())) {
await traceBootstrap("runtime-bootstrap-start");
try {
const shouldUseRuntimeCloud = config.setupMode === "employee-key";
const usingCachedRuntimeCloudConfig = shouldUseRuntimeCloud && runtimeCloudClient.hasCachedPayload();
if (shouldUseRuntimeCloud && !usingCachedRuntimeCloudConfig) {
await runtimeCloudClient.fetchConfig("init");
}
await traceBootstrap("runtime-start");
await runtimeManager.start();
await traceBootstrap("runtime-started");
const runtimeGatewayConnection = await runtimeManager.getGatewayConnection();
if (runtimeGatewayConnection.url) {
await gatewayClient.reconfigure(
runtimeGatewayConnection.url,
runtimeGatewayConnection.token,
(await secretManager.getDeviceToken()) ?? undefined
);
}
await traceBootstrap("gateway-connect");
await gatewayClient.connect().catch(() => undefined);
await traceBootstrap("gateway-connect-done");
if (config.setupMode === "employee-key") {
await traceBootstrap("runtime-cloud-supervisor-start");
await runtimeCloudSupervisor.start();
await traceBootstrap("runtime-cloud-supervisor-started");
if (usingCachedRuntimeCloudConfig) {
scheduleRuntimeCloudRefresh("bootstrap");
}
} else {
await runtimeCloudSupervisor.stop("bootstrap");
}
} catch (error) {
console.error("Bundled runtime bootstrap skipped:", error instanceof Error ? error.message : String(error));
}
} else if (resolveRequestedRuntimeMode(config.runtimeMode) === "external-gateway") {
void gatewayClient.connect().catch(() => undefined);
}
await traceBootstrap("register-ipc");
registerDesktopIpc({
const desktopApi: RegisteredDesktopIpc = registerDesktopIpc({
appVersion: app.getVersion(),
configService,
diagnosticsService,
......@@ -792,6 +788,24 @@ async function bootstrap(): Promise<void> {
await traceBootstrap("create-window");
const window = createMainWindow(smokeEnabled);
await traceBootstrap("window-created");
if (cachedRuntimeCloudConfig) {
void (async () => {
const cachedRemoteSkills = runtimeCloudClient.getRemoteSkillAssets();
await skillStore.reconcile(cachedRemoteSkills, cachedRuntimeCloudConfig.configVersion).catch(() => undefined);
await syncProjectBundles(cachedRemoteSkills, cachedRuntimeCloudConfig.configVersion, "init");
})().catch((error) => {
console.error("Cached runtime cloud bootstrap sync skipped:", error instanceof Error ? error.message : String(error));
});
}
void (async () => {
await traceBootstrap("bootstrap-warmup-scheduled");
await desktopApi.__internal.queueWorkspaceWarmup("bootstrap", { action: "init" });
})().catch((error) => {
console.error("Bundled runtime bootstrap skipped:", error instanceof Error ? error.message : String(error));
});
if (smokeEnabled && smokeOutputPath) {
await traceBootstrap("run-smoke-test-start");
void runSmokeTest(window, smokeOutputPath);
......@@ -820,11 +834,3 @@ void bootstrap().catch(async (error) => {
}
app.quit();
});
......@@ -26,7 +26,12 @@ import type { DiagnosticsService } from "./services/diagnostics.js";
import type { DailyReportService } from "./services/daily-report-service.js";
import type { SkillClient } from "./services/skill-client.js";
import type { SkillStoreService } from "./services/skill-store.js";
import { resolveEffectiveGatewayUrl, type LocalOpenClawGatewayConfig } from "./services/openclaw-local-config.js";
import {
resolveEffectiveGatewayToken,
resolveEffectiveGatewayUrl,
shouldUseLocalOpenClawGateway,
type LocalOpenClawGatewayConfig
} from "./services/openclaw-local-config.js";
import type { SecretManager } from "./services/secrets.js";
import type { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js";
import type { RuntimeSkillBridgeService } from "./services/runtime-skill-bridge.js";
......@@ -46,6 +51,11 @@ import {
refreshProjectContextAfterExecution,
shouldRefreshProjectContextAfterExecution
} from "./services/project-context-lifecycle.js";
import {
buildChatSummary,
shouldRetryBootstrapWarmup,
shouldRetryManagedRuntimeStartup
} from "./workspace-startup.js";
interface MainServices {
configService: AppConfigService;
......@@ -74,6 +84,20 @@ interface MainServices {
localOpenClawConfig?: LocalOpenClawGatewayConfig | null;
}
export interface RegisteredDesktopIpc extends DesktopApi {
__internal: {
queueWorkspaceWarmup: (
reason: string,
options?: {
action?: RuntimeCloudFetchAction;
restart?: boolean;
config?: AppConfig;
inputToken?: string;
}
) => Promise<WorkspaceWarmupResult>;
};
}
function toControlUiUrl(gatewayUrl: string): string {
const url = new URL(gatewayUrl);
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
......@@ -161,166 +185,16 @@ function buildPluginSummaries(runtimeStatus: RuntimeStatus): PluginSummary[] {
const MANAGED_RUNTIME_START_RETRY_LIMIT = 2;
const MANAGED_RUNTIME_START_RETRY_DELAY_MS = 1500;
const GATEWAY_CONNECT_RETRY_LIMIT = 2;
const GATEWAY_CONNECT_RETRY_LIMIT = 10;
const GATEWAY_CONNECT_RETRY_DELAY_MS = 1000;
const BOOTSTRAP_RECOVERY_RETRY_LIMIT = 2;
const BOOTSTRAP_RECOVERY_RETRY_DELAY_MS = 2000;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isTransientLocalGatewayError(message?: string): boolean {
if (!message) {
return false;
}
const normalized = message.toLowerCase();
return normalized.includes("econnrefused")
|| normalized.includes("failed to connect to ws://127.0.0.1")
|| normalized.includes("failed to connect to ws://localhost")
|| normalized.includes("gateway readiness")
|| normalized.includes("gateway became ready")
|| normalized.includes("gateway closed during readiness probe")
|| normalized.includes("gateway closed before readiness probe completed")
|| normalized.includes("bundled runtime exited before gateway became ready");
}
function toStartupErrorMessage(message: string | undefined, fallback: string): string {
if (isTransientLocalGatewayError(message)) {
return "\u672c\u5730\u52a9\u624b\u6682\u65f6\u6ca1\u6709\u51c6\u5907\u597d\uff0c\u8bf7\u91cd\u65b0\u51c6\u5907\u3002";
}
return message ?? fallback;
}
function shouldRetryManagedRuntimeStartup(config: AppConfig, status: RuntimeStatus): boolean {
if (config.runtimeMode === "external-gateway" || status.processState !== "error") {
return false;
}
const bundledRuntimeSelected = status.selectedMode === "bundled-runtime" || status.activeMode === "bundled-runtime";
if (!bundledRuntimeSelected) {
return false;
}
return isTransientLocalGatewayError(status.lastError ?? status.message);
}
function buildChatSummary(
config: AppConfig,
runtimeStatus: RuntimeStatus,
runtimeCloudStatus: RuntimeCloudStatus,
gatewayStatus: GatewayStatus | null,
warmupInFlight: boolean
): Pick<WorkspaceSummary, "chatReady" | "chatLaunchState" | "chatStatusMessage" | "startupPhase" | "startupMessage"> {
if (!config.apiKeyConfigured) {
const setupMessage = config.setupMode === "direct-provider"
? "\u8bf7\u5148\u5b8c\u6210\u5382\u5546\u4e0e API Key \u914d\u7f6e\u3002"
: "\u8bf7\u5148\u7ed1\u5b9a\u5458\u5de5\u5bc6\u94a5\u3002";
return {
chatReady: false,
chatLaunchState: "unbound",
chatStatusMessage: setupMessage,
startupPhase: "idle",
startupMessage: setupMessage
};
}
if (config.setupMode === "employee-key" && runtimeCloudStatus.state === "error") {
const runtimeCloudError = runtimeCloudStatus.lastError ?? "\u5458\u5de5\u914d\u7f6e\u540c\u6b65\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u5bc6\u94a5\u6216\u7f51\u7edc\u8fde\u63a5\u3002";
return {
chatReady: false,
chatLaunchState: "error",
chatStatusMessage: runtimeCloudError,
startupPhase: "error",
startupMessage: runtimeCloudError
};
}
const runtimeError = runtimeStatus.lastError ?? runtimeStatus.message;
if (warmupInFlight && runtimeStatus.processState === "error" && isTransientLocalGatewayError(runtimeError)) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: "\u6b63\u5728\u91cd\u65b0\u5524\u8d77\u672c\u5730\u52a9\u624b\uff0c\u8bf7\u7a0d\u5019\u3002",
startupPhase: "starting-runtime",
startupMessage: "\u6b63\u5728\u91cd\u65b0\u5524\u8d77\u672c\u5730\u52a9\u624b\uff0c\u8bf7\u7a0d\u5019\u3002"
};
}
const gatewayError = gatewayStatus?.lastError ?? gatewayStatus?.message;
if (warmupInFlight && gatewayStatus?.state === "error" && isTransientLocalGatewayError(gatewayError)) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: "\u6b63\u5728\u91cd\u65b0\u8fde\u63a5\u804a\u5929\u670d\u52a1\uff0c\u8bf7\u7a0d\u5019\u3002",
startupPhase: "connecting-gateway",
startupMessage: "\u6b63\u5728\u91cd\u65b0\u8fde\u63a5\u804a\u5929\u670d\u52a1\uff0c\u8bf7\u7a0d\u5019\u3002"
};
}
if (runtimeStatus.processState === "error") {
const runtimeErrorMessage = toStartupErrorMessage(runtimeError, "\u672c\u5730\u52a9\u624b\u542f\u52a8\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002");
return {
chatReady: false,
chatLaunchState: "error",
chatStatusMessage: runtimeErrorMessage,
startupPhase: "error",
startupMessage: runtimeErrorMessage
};
}
if (gatewayStatus?.state === "error") {
const gatewayErrorMessage = toStartupErrorMessage(gatewayError, "\u804a\u5929\u670d\u52a1\u8fde\u63a5\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002");
return {
chatReady: false,
chatLaunchState: "error",
chatStatusMessage: gatewayErrorMessage,
startupPhase: "error",
startupMessage: gatewayErrorMessage
};
}
const runtimeCanServeChat = runtimeStatus.activeMode === "external-gateway" || runtimeStatus.processState === "running";
if (runtimeCanServeChat && gatewayStatus?.state === "connected") {
return {
chatReady: true,
chatLaunchState: "ready",
chatStatusMessage: "\u804a\u5929\u670d\u52a1\u5df2\u5c31\u7eea\u3002",
startupPhase: "ready",
startupMessage: "\u804a\u5929\u670d\u52a1\u5df2\u5c31\u7eea\u3002"
};
}
if (config.setupMode === "employee-key" && (runtimeCloudStatus.state === "loading" || runtimeCloudStatus.state === "unconfigured")) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: "\u6b63\u5728\u540c\u6b65\u5458\u5de5\u914d\u7f6e\uff0c\u8bf7\u7a0d\u5019\u3002",
startupPhase: "syncing-config",
startupMessage: "\u6b63\u5728\u540c\u6b65\u5458\u5de5\u914d\u7f6e\uff0c\u8bf7\u7a0d\u5019\u3002"
};
}
if (runtimeStatus.processState === "starting" || (runtimeStatus.selectedMode === "bundled-runtime" && runtimeStatus.processState !== "running")) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: runtimeStatus.message || "\u6b63\u5728\u5524\u8d77\u672c\u5730\u52a9\u624b\uff0c\u8bf7\u7a0d\u5019\u3002",
startupPhase: "starting-runtime",
startupMessage: runtimeStatus.message || "\u6b63\u5728\u5524\u8d77\u672c\u5730\u52a9\u624b\uff0c\u8bf7\u7a0d\u5019\u3002"
};
}
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: gatewayStatus?.message ?? "\u6b63\u5728\u8fde\u63a5\u804a\u5929\u670d\u52a1\uff0c\u8bf7\u7a0d\u5019\u3002",
startupPhase: "connecting-gateway",
startupMessage: gatewayStatus?.message ?? "\u6b63\u5728\u8fde\u63a5\u804a\u5929\u670d\u52a1\uff0c\u8bf7\u7a0d\u5019\u3002"
};
}
export function registerDesktopIpc(services: MainServices): DesktopApi {
export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc {
const {
appVersion,
authClient,
......@@ -347,33 +221,65 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
localOpenClawConfig
} = services;
const getEffectiveGatewayToken = async (inputToken?: string): Promise<string | undefined> => {
return inputToken || (await secretManager.getGatewayToken()) || localOpenClawConfig?.gatewayToken;
const shouldUseDiscoveredGateway = (runtimeMode: AppConfig["runtimeMode"]): boolean => {
return shouldUseLocalOpenClawGateway(systemSummary.isPackaged, runtimeMode);
};
const getDiscoveredGatewayUrl = (runtimeMode: AppConfig["runtimeMode"]): string | undefined => {
return shouldUseDiscoveredGateway(runtimeMode) ? localOpenClawConfig?.gatewayUrl : undefined;
};
const getDiscoveredGatewayToken = (runtimeMode: AppConfig["runtimeMode"]): string | undefined => {
return shouldUseDiscoveredGateway(runtimeMode) ? localOpenClawConfig?.gatewayToken : undefined;
};
const getEffectiveGatewayToken = async (config?: AppConfig, inputToken?: string): Promise<string | undefined> => {
if (inputToken) {
return inputToken;
}
const nextConfig = config ?? await configService.load();
return resolveEffectiveGatewayToken(
await secretManager.getGatewayToken(),
getDiscoveredGatewayToken(nextConfig.runtimeMode)
);
};
const getEffectiveConfig = async () => {
const config = await configService.load();
return {
...config,
gatewayUrl: resolveEffectiveGatewayUrl(config.gatewayUrl, localOpenClawConfig?.gatewayUrl),
gatewayUrl: resolveEffectiveGatewayUrl(config.gatewayUrl, getDiscoveredGatewayUrl(config.runtimeMode)),
apiKeyConfigured: Boolean((await secretManager.getApiKey()) || config.apiKeyConfigured),
gatewayTokenConfigured: Boolean((await getEffectiveGatewayToken()) || config.gatewayTokenConfigured),
gatewayTokenConfigured: Boolean((await getEffectiveGatewayToken(config)) || config.gatewayTokenConfigured),
authTokenConfigured: Boolean((await secretManager.getAuthToken()) || config.authTokenConfigured)
};
};
const reconfigureGatewayClient = async (config?: AppConfig, inputToken?: string): Promise<void> => {
const resolveGatewayClientTarget = async (
config?: AppConfig,
inputToken?: string
): Promise<{ gatewayUrl: string; gatewayToken?: string }> => {
const nextConfig = config ?? await configService.load();
const runtimeStatus = await runtimeManager.status();
const runtimeGatewayConnection = await runtimeManager.getGatewayConnection();
const useBundledRuntime = runtimeStatus.activeMode === "bundled-runtime" && typeof runtimeGatewayConnection.url === "string";
const gatewayUrl = useBundledRuntime
? runtimeGatewayConnection.url ?? nextConfig.gatewayUrl
: resolveEffectiveGatewayUrl(nextConfig.gatewayUrl, localOpenClawConfig?.gatewayUrl);
const gatewayToken = useBundledRuntime
? runtimeGatewayConnection.token
: await getEffectiveGatewayToken(inputToken);
if (useBundledRuntime) {
return {
gatewayUrl: runtimeGatewayConnection.url!,
gatewayToken: runtimeGatewayConnection.token
};
}
return {
gatewayUrl: resolveEffectiveGatewayUrl(nextConfig.gatewayUrl, getDiscoveredGatewayUrl(nextConfig.runtimeMode)),
gatewayToken: await getEffectiveGatewayToken(nextConfig, inputToken)
};
};
const reconfigureGatewayClient = async (config?: AppConfig, inputToken?: string): Promise<void> => {
const { gatewayUrl, gatewayToken } = await resolveGatewayClientTarget(config, inputToken);
await gatewayClient.reconfigure(
gatewayUrl,
......@@ -414,13 +320,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
return true;
}
const nextConfig = config ?? await configService.load();
const runtimeStatus = await runtimeManager.status();
const runtimeGatewayConnection = await runtimeManager.getGatewayConnection();
const useBundledRuntime = runtimeStatus.activeMode === "bundled-runtime" && typeof runtimeGatewayConnection.url === "string";
const targetGatewayUrl = useBundledRuntime
? runtimeGatewayConnection.url ?? nextConfig.gatewayUrl
: resolveEffectiveGatewayUrl(nextConfig.gatewayUrl, localOpenClawConfig?.gatewayUrl);
const { gatewayUrl: targetGatewayUrl } = await resolveGatewayClientTarget(config, inputToken);
const currentStatus = await gatewayClient.status().catch(() => null);
return currentStatus?.state !== "connected" || currentStatus.url !== targetGatewayUrl;
......@@ -501,6 +401,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
let workspaceWarmupTail: Promise<void> = Promise.resolve();
let workspaceWarmupInFlight = false;
let bootstrapRecoveryAttempts = 0;
const queueWorkspaceWarmup = async (
reason: string,
......@@ -529,14 +430,37 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
.then(async () => {
workspaceWarmupInFlight = true;
try {
const bootstrapRecoveryEnabled = reason === "bootstrap";
bootstrapRecoveryAttempts = 0;
while (true) {
await startManagedRuntime(reason, {
...options,
config: nextConfig
});
const runtimeStatus = await runtimeManager.status();
const gatewayStatus = await gatewayClient.status().catch(() => null);
const shouldRetryBootstrap = bootstrapRecoveryEnabled
&& bootstrapRecoveryAttempts < BOOTSTRAP_RECOVERY_RETRY_LIMIT
&& shouldRetryBootstrapWarmup({
config: nextConfig,
runtimeStatus,
gatewayStatus,
isPackaged: systemSummary.isPackaged
});
if (!shouldRetryBootstrap) {
break;
}
bootstrapRecoveryAttempts += 1;
await delay(BOOTSTRAP_RECOVERY_RETRY_DELAY_MS);
}
} catch {
// Workspace summary and runtime status retain the latest failure details.
} finally {
workspaceWarmupInFlight = false;
bootstrapRecoveryAttempts = 0;
}
});
......@@ -565,7 +489,14 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
const gatewayStatus = config.apiKeyConfigured
? await gatewayClient.status().catch(() => null)
: null;
const baseChatSummary = buildChatSummary(config, runtimeStatus, runtimeCloudStatus, gatewayStatus, workspaceWarmupInFlight);
const baseChatSummary = buildChatSummary({
config,
runtimeStatus,
runtimeCloudStatus,
gatewayStatus,
warmupInFlight: workspaceWarmupInFlight,
isPackaged: systemSummary.isPackaged
});
const {
projects,
currentProject,
......@@ -607,7 +538,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
runtimeState: runtimeStatus.processState,
runtimeMessage: runtimeStatus.message,
currentProjectId: currentProject?.id,
currentProjectName: currentProject?.name,
currentProjectName: currentProject?.displayName ?? currentProject?.name,
projectVersion: currentProject?.version,
projectReady: currentProject?.ready ?? false,
projectCount: projects.length,
......@@ -1211,11 +1142,21 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id));
return sessions;
});
ipcMain.handle(IPC_CHANNELS.chatListSessionsByProject, async (_event, projectId: string) => {
const sessions = await projectStore.listSessions(projectId);
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id));
return sessions;
});
ipcMain.handle(IPC_CHANNELS.chatCreateSession, async (_event, title?: string) => {
const session = await createSessionForActiveProject(projectStore, title);
runtimeCloudSupervisor.noteSessions([session.id]);
return session;
});
ipcMain.handle(IPC_CHANNELS.chatCreateSessionForProject, async (_event, projectId: string, title?: string) => {
const session = await projectStore.createSession(title, projectId);
runtimeCloudSupervisor.noteSessions([session.id]);
return session;
});
ipcMain.handle(IPC_CHANNELS.chatCloseSession, async (_event, sessionId: string) => {
const sessions = await projectStore.closeSession(sessionId);
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id));
......@@ -1330,11 +1271,21 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id));
return sessions;
},
listSessionsByProject: async (projectId: string) => {
const sessions = await projectStore.listSessions(projectId);
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id));
return sessions;
},
createSession: async (title?: string) => {
const session = await createSessionForActiveProject(projectStore, title);
runtimeCloudSupervisor.noteSessions([session.id]);
return session;
},
createSessionForProject: async (projectId: string, title?: string) => {
const session = await projectStore.createSession(title, projectId);
runtimeCloudSupervisor.noteSessions([session.id]);
return session;
},
closeSession: async (sessionId: string) => {
const sessions = await projectStore.closeSession(sessionId);
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id));
......@@ -1356,13 +1307,9 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
await shell.openExternal(toControlUiUrl(config.gatewayUrl));
},
exportSnapshot: () => exportDiagnostics()
},
__internal: {
queueWorkspaceWarmup
}
};
}
import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { RuntimeModePreference } from "@qjclaw/shared-types";
const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";
......@@ -46,7 +47,17 @@ export async function loadLocalOpenClawGatewayConfig(): Promise<LocalOpenClawGat
}
}
export function resolveEffectiveGatewayUrl(configuredUrl: string, discoveredUrl?: string): string {
export function shouldUseLocalOpenClawGateway(
isPackaged: boolean,
runtimeMode: RuntimeModePreference
): boolean {
return !isPackaged || runtimeMode === "external-gateway";
}
export function resolveEffectiveGatewayUrl(
configuredUrl: string,
discoveredUrl?: string
): string {
if (!discoveredUrl) {
return configuredUrl;
}
......@@ -58,6 +69,21 @@ export function resolveEffectiveGatewayUrl(configuredUrl: string, discoveredUrl?
return configuredUrl;
}
export function resolveEffectiveGatewayToken(
configuredToken?: string,
discoveredToken?: string
): string | undefined {
if (configuredToken && configuredToken.trim()) {
return configuredToken;
}
if (discoveredToken && discoveredToken.trim()) {
return discoveredToken;
}
return undefined;
}
function buildGatewayUrl(config: OpenClawGatewayConfigShape): string {
const port = typeof config.port === "number" ? config.port : 18789;
const host = resolveHost(config);
......
import type {
AppConfig,
GatewayStatus,
RuntimeCloudStatus,
RuntimeStatus,
WorkspaceSummary
} from "@qjclaw/shared-types";
export function isTransientLocalGatewayError(message?: string): boolean {
if (!message) {
return false;
}
const normalized = message.toLowerCase();
return normalized.includes("gateway closed during connect (1006)")
|| normalized.includes("gateway closed during connect (1005)")
|| normalized.includes("gateway closed during connect (1000)")
|| normalized.includes("econnrefused")
|| normalized.includes("failed to connect to ws://127.0.0.1")
|| normalized.includes("failed to connect to ws://localhost")
|| normalized.includes("gateway readiness")
|| normalized.includes("gateway became ready")
|| normalized.includes("gateway closed during readiness probe")
|| normalized.includes("gateway closed before readiness probe completed")
|| normalized.includes("bundled runtime exited before gateway became ready");
}
function isGatewayPolicyViolationError(message?: string): boolean {
if (!message) {
return false;
}
const normalized = message.toLowerCase();
return normalized.includes("gateway connection closed (1008)")
|| normalized.includes("gateway closed during connect (1008)")
|| normalized.includes("policy violation");
}
function isBundledRuntimeNameConflictError(message?: string): boolean {
if (!message) {
return false;
}
const normalized = message.toLowerCase();
return normalized.includes("gateway name/hostname conflict detected")
|| normalized.includes("name conflict")
|| normalized.includes("hostname conflict")
|| normalized.includes("bonjour");
}
export function toStartupErrorMessage(message: string | undefined, fallback: string): string {
if (isTransientLocalGatewayError(message)) {
return "本地助手暂时没有准备好,请重新准备。";
}
if (isBundledRuntimeNameConflictError(message)) {
return "内置运行时启动失败:检测到本机已有 OpenClaw 实例导致网关名称冲突,请先退出本地 OpenClaw 后重试。";
}
if (isGatewayPolicyViolationError(message)) {
return "检测到本机已有 OpenClaw 网关正在运行,但安装包未能切换到内置运行时,请先退出本地 OpenClaw 后重试。";
}
return message ?? fallback;
}
export function shouldRetryManagedRuntimeStartup(config: AppConfig, status: RuntimeStatus): boolean {
if (config.runtimeMode === "external-gateway" || status.processState !== "error") {
return false;
}
const bundledRuntimeSelected = status.selectedMode === "bundled-runtime" || status.activeMode === "bundled-runtime";
if (!bundledRuntimeSelected) {
return false;
}
return isTransientLocalGatewayError(status.lastError ?? status.message);
}
export function shouldRetryBootstrapWarmup(input: {
config: AppConfig;
runtimeStatus: RuntimeStatus;
gatewayStatus: GatewayStatus | null;
isPackaged: boolean;
}): boolean {
if (!input.isPackaged || input.config.runtimeMode === "external-gateway") {
return false;
}
const bundledRuntimeSelected = input.runtimeStatus.selectedMode === "bundled-runtime"
|| input.runtimeStatus.activeMode === "bundled-runtime";
if (!bundledRuntimeSelected) {
return false;
}
const runtimeError = input.runtimeStatus.lastError ?? input.runtimeStatus.message;
if (input.runtimeStatus.processState === "error" && isTransientLocalGatewayError(runtimeError)) {
return true;
}
const gatewayError = input.gatewayStatus?.lastError ?? input.gatewayStatus?.message;
return input.gatewayStatus?.state === "error" && isTransientLocalGatewayError(gatewayError);
}
export function buildChatSummary(input: {
config: AppConfig;
runtimeStatus: RuntimeStatus;
runtimeCloudStatus: RuntimeCloudStatus;
gatewayStatus: GatewayStatus | null;
warmupInFlight: boolean;
isPackaged: boolean;
}): Pick<WorkspaceSummary, "chatReady" | "chatLaunchState" | "chatStatusMessage" | "startupPhase" | "startupMessage"> {
const {
config,
runtimeStatus,
runtimeCloudStatus,
gatewayStatus,
warmupInFlight,
isPackaged
} = input;
if (!config.apiKeyConfigured) {
const setupMessage = config.setupMode === "direct-provider"
? "请先完成厂商与 API Key 配置。"
: "请先绑定员工密钥。";
return {
chatReady: false,
chatLaunchState: "unbound",
chatStatusMessage: setupMessage,
startupPhase: "idle",
startupMessage: setupMessage
};
}
if (config.setupMode === "employee-key" && runtimeCloudStatus.state === "error") {
const runtimeCloudError = runtimeCloudStatus.lastError ?? "员工配置同步失败,请检查密钥或网络连接。";
return {
chatReady: false,
chatLaunchState: "error",
chatStatusMessage: runtimeCloudError,
startupPhase: "error",
startupMessage: runtimeCloudError
};
}
const packagedBundledRuntime = isPackaged && config.runtimeMode !== "external-gateway";
const runtimeError = runtimeStatus.lastError ?? runtimeStatus.message;
if (
warmupInFlight
&& packagedBundledRuntime
&& runtimeStatus.processState === "error"
&& isTransientLocalGatewayError(runtimeError)
) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: "正在重新唤起本地助手,请稍候。",
startupPhase: "starting-runtime",
startupMessage: "正在重新唤起本地助手,请稍候。"
};
}
const gatewayError = gatewayStatus?.lastError ?? gatewayStatus?.message;
if (
warmupInFlight
&& packagedBundledRuntime
&& gatewayStatus?.state === "error"
&& isTransientLocalGatewayError(gatewayError)
) {
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: "正在重新连接聊天服务,请稍候。",
startupPhase: "connecting-gateway",
startupMessage: "正在重新连接聊天服务,请稍候。"
};
}
if (runtimeStatus.processState === "error") {
const runtimeErrorMessage = toStartupErrorMessage(runtimeError, "本地助手启动失败,请稍后重试。");
return {
chatReady: false,
chatLaunchState: "error",
chatStatusMessage: runtimeErrorMessage,
startupPhase: "error",
startupMessage: runtimeErrorMessage
};
}
if (gatewayStatus?.state === "error") {
const gatewayErrorMessage = toStartupErrorMessage(gatewayError, "聊天服务连接失败,请稍后重试。");
return {
chatReady: false,
chatLaunchState: "error",
chatStatusMessage: gatewayErrorMessage,
startupPhase: "error",
startupMessage: gatewayErrorMessage
};
}
const runtimeCanServeChat = runtimeStatus.activeMode === "external-gateway" || runtimeStatus.processState === "running";
if (runtimeCanServeChat && gatewayStatus?.state === "connected") {
return {
chatReady: true,
chatLaunchState: "ready",
chatStatusMessage: "聊天服务已就绪。",
startupPhase: "ready",
startupMessage: "聊天服务已就绪。"
};
}
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 {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: runtimeStatus.message || "正在唤起本地助手,请稍候。",
startupPhase: "starting-runtime",
startupMessage: runtimeStatus.message || "正在唤起本地助手,请稍候。"
};
}
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: gatewayStatus?.message ?? "正在连接聊天服务,请稍候。",
startupPhase: "connecting-gateway",
startupMessage: gatewayStatus?.message ?? "正在连接聊天服务,请稍候。"
};
}
......@@ -18,4 +18,5 @@
- `project-bundle-freshness-smoke.ps1` compiles the targeted `project-bundle-freshness-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies that the same bundle URL plus unchanged `configVersion` still re-syncs when remote `ETag` / `Last-Modified` freshness metadata changes; `pnpm smoke:bundle-freshness`
- `project-bundle-replacement-smoke.ps1` compiles the targeted `project-bundle-replacement-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies same-project replacement, shared `skills/` and `cron/` ownership cleanup, rollback on an injected post-commit failure, and successful recovery on the next sync; `pnpm smoke:bundle-replacement`
- `project-bundle-churn-smoke.ps1` compiles the targeted `project-bundle-churn-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies multi-project churn with stable survivors, same-project replacement, project removal, project addition, active-project fallback, and session survival inside unaffected projects; `pnpm smoke:bundle-churn`
- `workspace-startup-smoke.ps1` compiles the targeted `workspace-startup-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies packaged startup error classification plus local OpenClaw isolation policy; `pnpm smoke:workspace-startup`
- `project-isolation-smoke.ps1` runs the main project-isolation regression gate end to end, including workspace-entry, default-chat, cloud-bundle Electron lifecycle coverage, project-context refresh, empty-project inventory, bundle reconcile, bundle freshness, bundle replacement, and multi-project churn; `pnpm smoke:project-isolation`
param(
[string]$ResultPath
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$desktopAppRoot = Join-Path $repoRoot 'apps\desktop'
$sourcePath = Join-Path $repoRoot 'build\scripts\workspace-startup-smoke.ts'
$tempRoot = Join-Path $repoRoot '.tmp\workspace-startup-smoke'
$compileRoot = Join-Path $tempRoot 'compiled'
$entryPath = Join-Path $compileRoot 'build\scripts\workspace-startup-smoke.js'
$compilePackagePath = Join-Path $compileRoot 'package.json'
$resolvedResultPath = if ($ResultPath) { $ResultPath } else { Join-Path $tempRoot 'result.json' }
function Write-Utf8File {
param([string]$FilePath, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($FilePath)) | Out-Null
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
}
if (-not (Test-Path $sourcePath)) {
throw "Workspace startup smoke source was not found: $sourcePath"
}
if (Test-Path $compileRoot) {
Remove-Item $compileRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $compileRoot -Force | Out-Null
$compileArgs = @(
'pnpm',
'--dir', $desktopAppRoot,
'exec',
'tsc',
'--module', 'ES2022',
'--moduleResolution', 'node',
'--target', 'ES2022',
'--lib', 'ES2022',
'--types', 'node',
'--esModuleInterop',
'--allowSyntheticDefaultImports',
'--skipLibCheck',
'--outDir', $compileRoot,
$sourcePath
)
Write-Host 'Compiling workspace startup smoke with local TypeScript'
corepack @compileArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $entryPath)) {
throw "Workspace startup smoke entry was not emitted: $entryPath"
}
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
Write-Host 'Running workspace startup smoke'
node $entryPath $resolvedResultPath
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $resolvedResultPath)) {
throw "Workspace startup smoke did not produce a result file: $resolvedResultPath"
}
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AppConfig, GatewayStatus, RuntimeCloudStatus, RuntimeStatus } from "../../packages/shared-types/src/index.js";
import {
resolveEffectiveGatewayToken,
resolveEffectiveGatewayUrl,
shouldUseLocalOpenClawGateway
} from "../../apps/desktop/src/main/services/openclaw-local-config.js";
import {
buildChatSummary,
isTransientLocalGatewayError,
shouldRetryBootstrapWarmup
} from "../../apps/desktop/src/main/workspace-startup.js";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function createConfig(overrides: Partial<AppConfig> = {}): AppConfig {
return {
setupMode: "employee-key",
provider: "openai",
baseUrl: "https://api.example.com",
apiKeyConfigured: true,
gatewayTokenConfigured: true,
authTokenConfigured: true,
defaultModel: "gpt-test",
workspacePath: "D:/qjclaw/.tmp/workspace",
gatewayUrl: "ws://127.0.0.1:18789",
cloudApiBaseUrl: "https://cloud.example.com",
runtimeCloudApiBaseUrl: "https://cloud.example.com",
runtimeMode: "bundled-runtime",
...overrides
};
}
function createRuntimeStatus(overrides: Partial<RuntimeStatus> = {}): RuntimeStatus {
return {
requestedMode: "bundled-runtime",
selectedMode: "bundled-runtime",
activeMode: "bundled-runtime",
payloadState: "ready",
processState: "running",
runtimeDir: "D:/runtime",
nodeExecutable: "D:/runtime/node/node.exe",
openClawEntry: "D:/runtime/openclaw/index.js",
defaultConfigPath: "D:/runtime/config/openclaw.json",
pythonExecutable: "D:/runtime/python/python.exe",
pythonManifestPath: "D:/runtime/python/python-manifest.json",
pythonReady: true,
pythonVersion: "3.11.0",
installedPythonPackages: [],
pythonMissingModules: [],
runtimeDataDir: "D:/runtime-data",
runtimeStateDir: "D:/runtime-data/state",
runtimeLogsDir: "D:/runtime-data/logs",
logFilePath: "D:/runtime-data/logs/runtime-manager.log",
gatewayUrl: "ws://127.0.0.1:18889",
gatewayTokenConfigured: true,
pid: 1234,
startedAt: new Date().toISOString(),
stoppedAt: undefined,
lastExitCode: undefined,
lastError: undefined,
message: "Managed bundled runtime process is running and Gateway is ready.",
modeReason: "Bundled runtime is selected.",
detectedFiles: [],
missingFiles: [],
checkedAt: new Date().toISOString(),
...overrides
};
}
function createRuntimeCloudStatus(overrides: Partial<RuntimeCloudStatus> = {}): RuntimeCloudStatus {
return {
state: "ready",
baseUrl: "https://cloud.example.com",
apiKeyConfigured: true,
lastFetchedAt: new Date().toISOString(),
lastError: undefined,
config: undefined,
...overrides
};
}
function createGatewayStatus(overrides: Partial<GatewayStatus> = {}): GatewayStatus {
return {
state: "connected",
url: "ws://127.0.0.1:18889",
host: "127.0.0.1",
port: 18889,
version: "test",
transport: "websocket",
lastConnectedAt: new Date().toISOString(),
lastError: undefined,
availableMethods: [],
message: "Gateway connected.",
...overrides
};
}
async function main(): Promise<void> {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..", "..");
const resultPath = path.resolve(process.argv[2] ?? path.join(repoRoot, ".tmp", "workspace-startup-smoke", "result.json"));
const tempRoot = path.dirname(resultPath);
await rm(tempRoot, { recursive: true, force: true });
await mkdir(tempRoot, { recursive: true });
const transientCodes = [
"Gateway closed during connect (1006).",
"Gateway closed during connect (1005).",
"Gateway closed during connect (1000).",
"connect ECONNREFUSED 127.0.0.1:18889"
];
for (const message of transientCodes) {
assert(isTransientLocalGatewayError(message), `Expected transient startup classification for: ${message}`);
}
const config = createConfig();
const runtimeStatus = createRuntimeStatus({
processState: "error",
lastError: "Gateway closed during connect (1006)."
});
const gatewayStatus = createGatewayStatus({
state: "error",
lastError: "Gateway closed during connect (1006).",
message: "Gateway closed during connect (1006)."
});
const startupSummary = buildChatSummary({
config,
runtimeStatus,
runtimeCloudStatus: createRuntimeCloudStatus(),
gatewayStatus,
warmupInFlight: true,
isPackaged: true
});
assert(startupSummary.chatLaunchState === "starting", "Transient packaged startup failures should remain in starting state.");
assert(startupSummary.startupPhase === "starting-runtime", "Transient runtime failures should map to starting-runtime.");
const gatewayOnlySummary = buildChatSummary({
config,
runtimeStatus: createRuntimeStatus(),
runtimeCloudStatus: createRuntimeCloudStatus(),
gatewayStatus,
warmupInFlight: true,
isPackaged: true
});
assert(gatewayOnlySummary.chatLaunchState === "starting", "Transient gateway failures should remain in starting state during packaged warmup.");
assert(gatewayOnlySummary.startupPhase === "connecting-gateway", "Transient gateway failures should map to connecting-gateway.");
assert(shouldRetryBootstrapWarmup({
config,
runtimeStatus,
gatewayStatus,
isPackaged: true
}), "Packaged bundled-runtime bootstrap should retry transient startup failures.");
assert(!shouldUseLocalOpenClawGateway(true, "bundled-runtime"), "Packaged bundled-runtime mode should ignore local OpenClaw.");
assert(shouldUseLocalOpenClawGateway(true, "external-gateway"), "Packaged external-gateway mode should allow local OpenClaw.");
assert(shouldUseLocalOpenClawGateway(false, "bundled-runtime"), "Dev mode should allow local OpenClaw.");
const ignoredGatewayUrl = resolveEffectiveGatewayUrl("ws://127.0.0.1:18789", undefined);
assert(ignoredGatewayUrl === "ws://127.0.0.1:18789", "Configured gateway URL should remain unchanged when no local override is used.");
const discoveredGatewayUrl = resolveEffectiveGatewayUrl("ws://127.0.0.1:18789", "ws://127.0.0.1:29999");
assert(discoveredGatewayUrl === "ws://127.0.0.1:29999", "Default gateway URL should yield to the discovered local OpenClaw URL when allowed.");
const resolvedGatewayToken = resolveEffectiveGatewayToken("", "local-token");
assert(resolvedGatewayToken === "local-token", "Local OpenClaw token should be used only when no configured token exists.");
const summary = {
ok: true,
transientCodes,
startupSummary,
gatewayOnlySummary,
shouldRetryBootstrap: true,
localOpenClawPolicy: {
packagedBundledRuntime: shouldUseLocalOpenClawGateway(true, "bundled-runtime"),
packagedExternalGateway: shouldUseLocalOpenClawGateway(true, "external-gateway"),
devBundledRuntime: shouldUseLocalOpenClawGateway(false, "bundled-runtime")
},
resolvedGatewayUrl: discoveredGatewayUrl,
resolvedGatewayToken
};
await writeFile(resultPath, JSON.stringify(summary, null, 2), "utf8");
console.log(JSON.stringify(summary, null, 2));
}
main().catch(async (error) => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..", "..");
const resultPath = path.resolve(process.argv[2] ?? path.join(repoRoot, ".tmp", "workspace-startup-smoke", "result.json"));
const failure = {
ok: false,
error: error instanceof Error ? error.stack ?? error.message : String(error)
};
await mkdir(path.dirname(resultPath), { recursive: true });
await writeFile(resultPath, JSON.stringify(failure, null, 2), "utf8");
console.error(failure.error);
process.exitCode = 1;
});
......@@ -27,6 +27,7 @@
"smoke:bundle-freshness": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-freshness-smoke.ps1",
"smoke:bundle-replacement": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-replacement-smoke.ps1",
"smoke:bundle-churn": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-churn-smoke.ps1",
"smoke:workspace-startup": "powershell -ExecutionPolicy Bypass -File build/scripts/workspace-startup-smoke.ps1",
"smoke:installer:bundled-runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/installer-smoke.ps1 -RuntimeMode bundled-runtime -ExpectBundledRuntime"
},
"pnpm": {
......
......@@ -182,6 +182,21 @@ function escapePowerShellSingleQuoted(value: string): string {
return value.replace(/'/g, "''");
}
function resolveWindowsSystemExecutable(relativePath: string, fallback: string): string {
if (process.platform !== "win32") {
return fallback;
}
const systemRoot = process.env.SYSTEMROOT ?? process.env.WINDIR ?? "C:\\Windows";
return path.join(systemRoot, "System32", relativePath);
}
const WINDOWS_POWERSHELL_PATH = resolveWindowsSystemExecutable(
path.join("WindowsPowerShell", "v1.0", "powershell.exe"),
"powershell.exe"
);
const WINDOWS_TASKKILL_PATH = resolveWindowsSystemExecutable("taskkill.exe", "taskkill");
async function execPythonInlineScript(pythonExecutable: string, inlineScript: string): Promise<string> {
try {
const { stdout } = await execFileAsync(pythonExecutable, ["-c", inlineScript]);
......@@ -200,7 +215,7 @@ async function execPythonInlineScript(pythonExecutable: string, inlineScript: st
"'@",
"& '" + escapePowerShellSingleQuoted(pythonExecutable) + "' -c $script"
].join("\n");
const { stdout } = await execFileAsync("powershell.exe", [
const { stdout } = await execFileAsync(WINDOWS_POWERSHELL_PATH, [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
......@@ -536,6 +551,7 @@ export class RuntimeManager extends EventEmitter {
private lastStartedAt?: string;
private lastExitCode?: number | null;
private lastError?: string;
private lastStderrLines: string[] = [];
private startPromise?: Promise<RuntimeStatus>;
constructor(options: RuntimeManagerOptions) {
super();
......@@ -851,7 +867,7 @@ export class RuntimeManager extends EventEmitter {
this.appendLog("warn", "Bundled runtime direct spawn was blocked with EPERM; retrying via PowerShell wrapper.");
const wrapperScript = this.buildWindowsChildWrapperScript(paths, childArgs, childStdoutLogPath, childStderrLogPath, childEnv);
try {
child = spawn("powershell.exe", [
child = spawn(WINDOWS_POWERSHELL_PATH, [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
......@@ -875,10 +891,18 @@ export class RuntimeManager extends EventEmitter {
}
this.child = child;
this.lastStderrLines = [];
child.stdout?.on("data", (chunk: Buffer) => {
this.appendChunk("info", chunk, childStdoutLogPath);
});
child.stderr?.on("data", (chunk: Buffer) => this.appendChunk("warn", chunk, childStderrLogPath));
child.stderr?.on("data", (chunk: Buffer) => {
this.appendChunk("warn", chunk, childStderrLogPath);
const lines = chunk.toString().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
this.lastStderrLines.push(...lines);
if (this.lastStderrLines.length > 50) {
this.lastStderrLines = this.lastStderrLines.slice(-50);
}
});
child.once("error", (error) => {
this.lastError = `Bundled runtime failed to start: ${error.message}`;
this.lastStoppedAt = new Date().toISOString();
......@@ -894,7 +918,8 @@ export class RuntimeManager extends EventEmitter {
this.child = undefined;
this.managedChildPid = undefined;
if (!wasStopping && code !== 0) {
this.lastError = `Bundled runtime exited unexpectedly with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}.`;
const stderrHint = this.buildStderrHint();
this.lastError = `Bundled runtime exited unexpectedly with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}${stderrHint ? `: ${stderrHint}` : ""}.`;
this.appendLog("error", this.lastError);
this.refreshStatus("error");
return;
......@@ -943,7 +968,7 @@ export class RuntimeManager extends EventEmitter {
const failures: string[] = [];
for (const pid of pids) {
try {
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"]);
await execFileAsync(WINDOWS_TASKKILL_PATH, ["/PID", String(pid), "/T", "/F"]);
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error));
}
......@@ -1193,6 +1218,18 @@ export class RuntimeManager extends EventEmitter {
};
}
private buildStderrHint(): string | undefined {
const recent = this.lastStderrLines.join(" ").toLowerCase();
if (recent.includes("name conflict") || recent.includes("hostname conflict") || recent.includes("bonjour")) {
return "gateway name/hostname conflict detected (another OpenClaw instance is running)";
}
if (recent.includes("eaddrinuse") || recent.includes("address already in use")) {
return "port already in use";
}
const lastLine = this.lastStderrLines[this.lastStderrLines.length - 1];
return lastLine ?? undefined;
}
private inferProcessState(): RuntimeProcessState {
if (this.runtimeStatus.processState === "stopping" && this.child) {
return "stopping";
......
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