Commit 5c3ae433 authored by AI-甘富林's avatar AI-甘富林

feat(desktop): prewarm employee startup and refresh curtain

parent 127ab878
......@@ -372,6 +372,76 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
result.initialState = initialState;
await trace("runSmokeTest:initial-state-ready");
const startupOnly = process.env.QJCLAW_SMOKE_STARTUP_ONLY === "1";
if (startupOnly) {
await trace("runSmokeTest:startup-only-begin");
const startupOnlyResult = await window.webContents.executeJavaScript(`(async () => {
const api = window.qjcDesktop;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
if (!api) {
throw new Error("Renderer is using mock desktop API.");
}
const capture = async (label) => {
const state = window.__QJC_SMOKE__;
const workspace = await api.workspace.getSummary();
return {
label,
capturedAt: new Date().toISOString(),
workspace,
ui: state?.ui ?? null,
dom: {
startupOverlayVisible: Boolean(document.querySelector(".startup-overlay")),
bindEntryVisible: Boolean(document.querySelector(".bind-entry")),
expertCardCount: document.querySelectorAll(".expert-card").length
}
};
};
const timeline = [];
let warmupQueued = false;
let lastSnapshot = await capture("initial");
timeline.push(lastSnapshot);
const deadline = Date.now() + 90000;
while (Date.now() < deadline) {
if (!warmupQueued) {
await api.workspace.warmup().catch(() => undefined);
warmupQueued = true;
}
await sleep(1000);
lastSnapshot = await capture("poll");
timeline.push(lastSnapshot);
if (
lastSnapshot.ui?.showBindEntry
&& !lastSnapshot.ui?.showStartupOverlay
&& lastSnapshot.dom.bindEntryVisible
&& !lastSnapshot.dom.startupOverlayVisible
) {
return {
timeline,
finalSnapshot: lastSnapshot
};
}
}
throw new Error("Startup-only smoke did not reach bind-entry after shell warmup. lastState=" + JSON.stringify(lastSnapshot));
})()`);
await trace("runSmokeTest:startup-only-finished");
result.startupOnlyResult = startupOnlyResult;
result.finalState = await waitForRendererSmokeState(window, 5000);
result.ok = true;
await trace("runSmokeTest:startup-only-success");
result.finishedAt = new Date().toISOString();
await trace("runSmokeTest:writing-output");
await writeFile(outputPath, JSON.stringify(result, null, 2), "utf8");
await trace("runSmokeTest:output-written");
app.quit();
return;
}
const prompt = process.env.QJCLAW_SMOKE_PROMPT?.trim() || `qjc smoke stream ${new Date().toISOString()}`;
const preferredSkillId = process.env.QJCLAW_SMOKE_SKILL_ID?.trim();
const smokeViewMode = process.env.QJCLAW_SMOKE_VIEW_MODE?.trim() === "experts" ? "experts" : "chat";
......@@ -729,6 +799,9 @@ async function bootstrap(): Promise<void> {
}
return buildDirectProviderManagedConfig(defaultConfig, latestConfig, apiKey);
}
if (!apiKey) {
return defaultConfig;
}
return runtimeCloudClient.buildManagedConfig(defaultConfig, action);
},
strictBundledRuntime: systemSummary.isPackaged
......
......@@ -57,6 +57,7 @@ import {
} from "./chat-gateway-recovery.js";
import {
buildChatSummary,
isWorkspaceShellReady,
shouldRetryBootstrapWarmup,
shouldRetryManagedRuntimeStartup
} from "./workspace-startup.js";
......@@ -369,7 +370,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
): Promise<RuntimeStatus> => {
const nextConfig = options.config ?? await configService.load();
const apiKey = await secretManager.getApiKey();
if (nextConfig.runtimeMode === "external-gateway" || !apiKey) {
const canPrewarmBundledRuntime = nextConfig.setupMode === "employee-key"
&& nextConfig.runtimeMode !== "external-gateway";
if (nextConfig.runtimeMode === "external-gateway" || (!apiKey && !canPrewarmBundledRuntime)) {
await runtimeCloudSupervisor.stop(reason);
if (await shouldRefreshGatewayClient(nextConfig, options.inputToken)) {
await reconfigureGatewayClient(nextConfig, options.inputToken);
......@@ -379,8 +382,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}
const shouldUseRuntimeCloud = nextConfig.setupMode === "employee-key";
const usingCachedRuntimeCloudConfig = shouldUseRuntimeCloud && (options.action ?? "init") === "init" && runtimeCloudClient.hasCachedPayload();
if (shouldUseRuntimeCloud && !usingCachedRuntimeCloudConfig) {
const canUseRuntimeCloudConfig = shouldUseRuntimeCloud && Boolean(apiKey);
const usingCachedRuntimeCloudConfig = canUseRuntimeCloudConfig && (options.action ?? "init") === "init" && runtimeCloudClient.hasCachedPayload();
if (canUseRuntimeCloudConfig && !usingCachedRuntimeCloudConfig) {
await runtimeCloudClient.fetchConfig(options.action ?? "init");
}
let status = options.restart ? await runtimeManager.restart() : await runtimeManager.start();
......@@ -394,7 +398,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
await reconfigureGatewayClient(nextConfig, options.inputToken);
await connectGatewayClientWithRetry().catch(() => undefined);
}
if (shouldUseRuntimeCloud) {
if (canUseRuntimeCloudConfig) {
await syncRuntimeCloudSupervisor(reason);
if (usingCachedRuntimeCloudConfig) {
scheduleRuntimeCloudRefresh(reason);
......@@ -430,7 +434,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
): Promise<WorkspaceWarmupResult> => {
const nextConfig = options.config ?? await configService.load();
const apiKey = await secretManager.getApiKey();
if (!apiKey) {
const canPrewarmBundledRuntime = nextConfig.setupMode === "employee-key"
&& nextConfig.runtimeMode !== "external-gateway";
if (!apiKey && !canPrewarmBundledRuntime) {
return {
accepted: false,
state: "skipped",
......@@ -502,7 +508,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
config: undefined
};
const gatewayStatus = config.apiKeyConfigured
const gatewayStatus = config.apiKeyConfigured || (config.setupMode === "employee-key" && config.runtimeMode !== "external-gateway")
? await gatewayClient.status().catch(() => null)
: null;
const baseChatSummary = buildChatSummary({
......@@ -521,7 +527,11 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
} = await loadActiveProjectWorkspaceState(projectStore);
const bundleSyncStatus = projectBundleService.getSyncStatus();
const bundleSyncFailed = bundleSyncStatus.state === "error";
const shellReady = !bundleSyncFailed;
const shellReady = !bundleSyncFailed && isWorkspaceShellReady({
config,
runtimeStatus,
gatewayStatus
});
const chatSummary = projects.length > 0
? baseChatSummary
: bundleSyncFailed
......@@ -1140,7 +1150,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
void queueWorkspaceWarmup("config-save", {
action: "init",
config,
inputToken: input.gatewayToken
inputToken: input.gatewayToken,
restart: config.setupMode === "employee-key"
&& typeof input.apiKey === "string"
&& input.apiKey.trim().length > 0
});
} else {
await runtimeCloudSupervisor.stop("config-save");
......
......@@ -51,7 +51,7 @@ export function isBundledRuntimeNameConflictError(message?: string): boolean {
export function toStartupErrorMessage(message: string | undefined, fallback: string): string {
if (isTransientLocalGatewayError(message)) {
return "本地助手暂时没有准备好,请重新准备。";
return "本地助手暂时没有准备好,请重。";
}
if (isBundledRuntimeNameConflictError(message)) {
......@@ -114,6 +114,71 @@ export function shouldRetryBootstrapWarmup(input: {
);
}
export function requiresShellWarmupBeforeBinding(config: AppConfig): boolean {
return !config.apiKeyConfigured
&& config.setupMode === "employee-key"
&& config.runtimeMode !== "external-gateway";
}
export function isWorkspaceShellReady(input: {
config: AppConfig;
runtimeStatus: RuntimeStatus;
gatewayStatus: GatewayStatus | null;
}): boolean {
const {
config,
runtimeStatus,
gatewayStatus
} = input;
if (!requiresShellWarmupBeforeBinding(config)) {
return true;
}
const runtimeReady = runtimeStatus.activeMode === "external-gateway"
|| runtimeStatus.processState === "running";
if (!runtimeReady) {
return false;
}
return gatewayStatus?.state === "connected";
}
function buildSetupSummary(config: AppConfig): Pick<WorkspaceSummary, "chatReady" | "chatLaunchState" | "chatStatusMessage" | "startupPhase" | "startupMessage"> {
const setupMessage = config.setupMode === "direct-provider"
? "请先完成厂商 API Key 配置。"
: "请先绑定员工密钥。";
return {
chatReady: false,
chatLaunchState: "unbound",
chatStatusMessage: setupMessage,
startupPhase: "idle",
startupMessage: setupMessage
};
}
function buildRuntimeStartingSummary(runtimeStatus: RuntimeStatus): Pick<WorkspaceSummary, "chatReady" | "chatLaunchState" | "chatStatusMessage" | "startupPhase" | "startupMessage"> {
const message = runtimeStatus.message || "正在唤起本地助手,请稍候。";
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: message,
startupPhase: "starting-runtime",
startupMessage: message
};
}
function buildGatewayStartingSummary(gatewayStatus: GatewayStatus | null): Pick<WorkspaceSummary, "chatReady" | "chatLaunchState" | "chatStatusMessage" | "startupPhase" | "startupMessage"> {
const message = gatewayStatus?.message ?? "正在连接聊天服务,请稍候。";
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: message,
startupPhase: "connecting-gateway",
startupMessage: message
};
}
export function buildChatSummary(input: {
config: AppConfig;
runtimeStatus: RuntimeStatus;
......@@ -131,19 +196,79 @@ export function buildChatSummary(input: {
isPackaged
} = input;
if (!config.apiKeyConfigured) {
const setupMessage = config.setupMode === "direct-provider"
? "请先完成厂商与 API Key 配置。"
: "请先绑定员工密钥。";
const shellReady = isWorkspaceShellReady({
config,
runtimeStatus,
gatewayStatus
});
const packagedBundledRuntime = isPackaged && config.runtimeMode !== "external-gateway";
const runtimeError = runtimeStatus.lastError ?? runtimeStatus.message;
const gatewayError = gatewayStatus?.lastError ?? gatewayStatus?.message;
if (!config.apiKeyConfigured && !shellReady) {
if (
warmupInFlight
&& packagedBundledRuntime
&& runtimeStatus.processState === "error"
&& isTransientLocalGatewayError(runtimeError)
) {
return {
chatReady: false,
chatLaunchState: "unbound",
chatStatusMessage: setupMessage,
startupPhase: "idle",
startupMessage: setupMessage
chatLaunchState: "starting",
chatStatusMessage: "正在重新唤起本地助手,请稍候。",
startupPhase: "starting-runtime",
startupMessage: "正在重新唤起本地助手,请稍候。"
};
}
if (
warmupInFlight
&& packagedBundledRuntime
&& gatewayStatus?.state === "error"
&& (isTransientLocalGatewayError(gatewayError) || isGatewayPolicyViolationError(gatewayError) || isBundledRuntimeNameConflictError(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
};
}
if (runtimeStatus.processState === "starting" || (runtimeStatus.selectedMode === "bundled-runtime" && runtimeStatus.processState !== "running")) {
return buildRuntimeStartingSummary(runtimeStatus);
}
return buildGatewayStartingSummary(gatewayStatus);
}
if (!config.apiKeyConfigured) {
return buildSetupSummary(config);
}
if (config.setupMode === "employee-key" && runtimeCloudStatus.state === "error") {
const runtimeCloudError = runtimeCloudStatus.lastError ?? "员工配置同步失败,请检查密钥或网络连接。";
return {
......@@ -155,8 +280,6 @@ export function buildChatSummary(input: {
};
}
const packagedBundledRuntime = isPackaged && config.runtimeMode !== "external-gateway";
const runtimeError = runtimeStatus.lastError ?? runtimeStatus.message;
if (
warmupInFlight
&& packagedBundledRuntime
......@@ -172,7 +295,6 @@ export function buildChatSummary(input: {
};
}
const gatewayError = gatewayStatus?.lastError ?? gatewayStatus?.message;
if (
warmupInFlight
&& packagedBundledRuntime
......@@ -232,20 +354,8 @@ export function buildChatSummary(input: {
}
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 buildRuntimeStartingSummary(runtimeStatus);
}
return {
chatReady: false,
chatLaunchState: "starting",
chatStatusMessage: gatewayStatus?.message ?? "正在连接聊天服务,请稍候。",
startupPhase: "connecting-gateway",
startupMessage: gatewayStatus?.message ?? "正在连接聊天服务,请稍候。"
};
return buildGatewayStartingSummary(gatewayStatus);
}
......@@ -81,6 +81,17 @@ interface SmokeStreamSnapshot {
lastError?: string;
}
interface SmokeUiSnapshot {
shellReady: boolean;
bindingRequired: boolean;
isBound: boolean;
showStartupOverlay: boolean;
showBindEntry: boolean;
chatLaunchState: ChatLaunchState;
startupPhase: WorkspaceSummary["startupPhase"];
currentProjectId?: string;
}
const DEFAULT_SESSION_ID = "desktop-main";
const HOME_CHAT_PROJECT_ID = "home-chat";
const SUCCESS_NOTICE_TIMEOUT_MS = 2400;
......@@ -292,10 +303,8 @@ const ui = {
} as const;
const startupCurtainCopy = {
kicker: "Qianjiang Claw",
brandTitle: "\u5343\u5320Claw",
brandSubtitle: "\u60a8\u8eab\u8fb9\u6700\u5f97\u529b\u7684\u5458\u5de5",
brandTagline: "Start Your Ideas.",
brandTitle: "\u5343\u5320\u00b7\u95ee\u5929",
brandTagline: "START YOUR IDEAS",
loadingLabel: "\u6b63\u5728\u4e3a\u60a8\u51c6\u5907\u5bf9\u8bdd\u73af\u5883",
syncingConfig: "\u6b63\u5728\u540c\u6b65\u5de5\u4f5c\u914d\u7f6e",
startingRuntime: "\u6b63\u5728\u5524\u8d77\u672c\u5730\u52a9\u624b",
......@@ -527,6 +536,7 @@ declare global {
expertProjectIds: string[];
workspaceSummary: WorkspaceSummary | null;
streamSmoke: SmokeStreamSnapshot | null;
ui: SmokeUiSnapshot;
};
__QJC_SMOKE_ACTIONS__?: {
sendConversationPrompt(
......@@ -934,7 +944,10 @@ export default function App() {
}, [activeSessionId, bindingRequired, desktopApi.chat, isBound, sessionScopeProjectId, workspace]);
useEffect(() => {
if (viewMode === "settings" || !showStartupOverlay || !isBound || chatLaunchState !== "starting") {
const shouldPollStartupState = viewMode !== "settings"
&& showStartupOverlay
&& (chatLaunchState === "starting" || (!isBound && !shellReady));
if (!shouldPollStartupState) {
return;
}
......@@ -947,7 +960,11 @@ export default function App() {
return;
}
if (nextWorkspace?.chatLaunchState === "starting") {
const nextShouldPoll = Boolean(nextWorkspace) && (
nextWorkspace.chatLaunchState === "starting"
|| (!nextWorkspace.shellReady && nextWorkspace.bindingRequired)
);
if (nextShouldPoll) {
timer = window.setTimeout(() => {
void pollWorkspace();
}, 1000);
......@@ -964,10 +981,14 @@ export default function App() {
window.clearTimeout(timer);
}
};
}, [chatLaunchState, isBound, showStartupOverlay, viewMode]);
}, [chatLaunchState, isBound, shellReady, showStartupOverlay, viewMode]);
useEffect(() => {
if (!showStartupOverlay || !isBound || chatLaunchState !== "starting") {
const shouldRequestStartupWarmup = showStartupOverlay && (
(isBound && chatLaunchState === "starting")
|| (!isBound && !shellReady)
);
if (!shouldRequestStartupWarmup) {
startupWarmupRequestedRef.current = false;
return;
}
......@@ -978,7 +999,7 @@ export default function App() {
startupWarmupRequestedRef.current = true;
void desktopApi.workspace.warmup().catch(() => undefined);
}, [chatLaunchState, isBound, showStartupOverlay]);
}, [chatLaunchState, isBound, shellReady, showStartupOverlay]);
useEffect(() => {
if (!skillMenuOpen) {
......@@ -1172,9 +1193,19 @@ export default function App() {
activeSessionId: resolvedActiveSessionId ?? "",
expertProjectIds: expertPageProjects.map((project) => project.id),
workspaceSummary: workspace,
streamSmoke
streamSmoke,
ui: {
shellReady,
bindingRequired,
isBound,
showStartupOverlay,
showBindEntry,
chatLaunchState,
startupPhase,
currentProjectId: workspace?.currentProjectId
}
};
}, [config, expertPageProjects, gatewayHealth, gatewayStatus, messages, resolvedActiveSessionId, runtimeCloudStatus, runtimeStatus, runtimeTelemetry, sessions, streamSmoke, systemSummary, viewMode, workspace]);
}, [bindingRequired, chatLaunchState, config, expertPageProjects, gatewayHealth, gatewayStatus, isBound, messages, resolvedActiveSessionId, runtimeCloudStatus, runtimeStatus, runtimeTelemetry, sessions, shellReady, showBindEntry, showStartupOverlay, startupPhase, streamSmoke, systemSummary, viewMode, workspace]);
useEffect(() => {
if (!smokeEnabled) {
......@@ -2213,13 +2244,11 @@ export default function App() {
{showStartupOverlay ? (
<div className={"startup-overlay" + (chatLaunchState === "error" ? " error" : "")} role="dialog" aria-modal="true" aria-live="polite">
<div className="startup-overlay-panel">
<span className="startup-overlay-kicker">{startupCurtainCopy.kicker}</span>
<div className="startup-overlay-copy">
<h1>{startupCurtainCopy.brandTitle}</h1>
<p className="startup-overlay-subtitle">{startupCurtainCopy.brandSubtitle}</p>
<p className="startup-overlay-tagline">{startupCurtainCopy.brandTagline}</p>
</div>
<>
<div className="startup-overlay-body">
<div className="startup-overlay-progress" aria-hidden="true">
<span style={{ width: String(Math.round(startupProgress * 100)) + "%" }} />
</div>
......@@ -2233,7 +2262,7 @@ export default function App() {
<button type="button" className="secondary" onClick={() => setViewMode("settings")}>{ui.openSettings}</button>
</div>
) : null}
</>
</div>
</div>
</div>
) : null}
......
......@@ -416,10 +416,13 @@ strong { font-weight: 600; }
position: relative;
z-index: 1;
width: min(560px, 100%);
display: grid;
gap: 22px;
justify-items: center;
padding: 48px 42px;
min-height: min(560px, calc(100vh - 96px));
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0;
padding: clamp(54px, 10vh, 86px) 42px 48px;
text-align: center;
border-radius: 34px;
border: 1px solid rgba(255, 255, 255, 0.72);
......@@ -434,48 +437,45 @@ strong { font-weight: 600; }
box-shadow: 0 24px 72px rgba(53, 90, 135, 0.18);
}
.startup-overlay-kicker {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 0 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.64);
box-shadow: inset 0 0 0 1px rgba(114, 157, 201, 0.14);
color: #4687c9;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.startup-overlay-copy {
width: min(460px, 100%);
display: grid;
gap: 10px;
justify-items: center;
gap: 16px;
margin-top: clamp(10px, 3vh, 26px);
}
.startup-overlay-copy h1 {
font-size: clamp(40px, 8vw, 64px);
line-height: 1.04;
letter-spacing: -0.04em;
color: #16365f;
}
.startup-overlay-subtitle {
font-size: clamp(20px, 3.6vw, 28px);
line-height: 1.35;
color: #29507f;
margin: 0;
font-size: clamp(48px, 9vw, 78px);
line-height: 1.02;
letter-spacing: -0.06em;
font-weight: 800;
background-image: linear-gradient(135deg, #9fd4ff 0%, #bdd7ff 45%, #d8c5ff 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 18px 48px rgba(139, 182, 255, 0.18);
}
.startup-overlay-tagline {
margin: 0;
font-size: 15px;
letter-spacing: 0.18em;
font-weight: 600;
letter-spacing: 0.32em;
text-transform: uppercase;
color: #6094c8;
color: #7ea2d9;
}
.startup-overlay-body {
width: min(420px, 100%);
display: grid;
gap: 16px;
margin-top: clamp(34px, 8vh, 68px);
}
.startup-overlay-progress {
width: min(360px, 100%);
width: 100%;
height: 7px;
overflow: hidden;
border-radius: 999px;
......@@ -492,7 +492,6 @@ strong { font-weight: 600; }
}
.startup-overlay-status {
width: min(420px, 100%);
display: grid;
gap: 8px;
}
......@@ -1119,8 +1118,11 @@ strong { font-weight: 600; }
@media (max-width: 720px) {
.main-shell, .sidebar { padding: 16px; }
.startup-overlay { padding: 18px; }
.startup-overlay-panel { padding: 36px 22px; border-radius: 28px; }
.startup-overlay-kicker { letter-spacing: 0.14em; }
.startup-overlay-panel { min-height: min(500px, calc(100vh - 36px)); padding: 42px 22px 34px; border-radius: 28px; }
.startup-overlay-copy { gap: 14px; margin-top: 6px; }
.startup-overlay-copy h1 { font-size: clamp(42px, 14vw, 60px); }
.startup-overlay-tagline { font-size: 13px; letter-spacing: 0.24em; }
.startup-overlay-body { width: 100%; margin-top: 28px; }
.startup-overlay-status { width: 100%; }
.startup-setup-tabs { grid-template-columns: 1fr; }
.page-topbar,
......
......@@ -25,5 +25,6 @@
- `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`
- `startup-binding-smoke.ps1` launches Electron in bundled-runtime mode without an employee key, asserts the startup overlay appears first, and verifies the bind-entry only appears after shell prewarm completes; `powershell -ExecutionPolicy Bypass -File build/scripts/startup-binding-smoke.ps1`
- `chat-gateway-recovery-smoke.ps1` compiles the targeted `chat-gateway-recovery-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies send-time gateway readiness enforcement plus single-shot reconnect/retry for `Gateway websocket is not open.`; `pnpm smoke:chat-gateway-recovery`
- `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`
......@@ -24,6 +24,7 @@ param(
[string]$ExpectedBundleSkillId,
[string]$ExpectedReadmeMarker,
[string]$UnexpectedReadmeMarker,
[switch]$StartupOnly,
[int]$TimeoutSeconds = 180
)
......@@ -121,13 +122,24 @@ if ($PrepareWorkspaceEntryFixture) {
$env:QJCLAW_RENDERER_URL = $rendererUrl
$env:QJCLAW_SMOKE_OUTPUT = $SmokeOutput
$env:QJCLAW_SMOKE_CLOUD_API_BASE_URL = "http://127.0.0.1:$SmokePort"
$env:QJCLAW_SMOKE_AUTH_TOKEN = $SmokeToken
$env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY = 'smoke-runtime-api-key'
$env:QJCLAW_RUNTIME_CLOUD_HEARTBEAT_INTERVAL_MS = 1000
$env:QJCLAW_RUNTIME_CLOUD_CONFIG_SYNC_INTERVAL_MS = 1500
$env:QJCLAW_RUNTIME_CLOUD_EVENT_FLUSH_INTERVAL_MS = 800
$env:QJCLAW_RUNTIME_CLOUD_EVENT_BATCH_SIZE = 3
$env:QJCLAW_SECRET_BACKEND = 'file-fallback'
if (-not $StartupOnly) {
$env:QJCLAW_SMOKE_CLOUD_API_BASE_URL = "http://127.0.0.1:$SmokePort"
$env:QJCLAW_SMOKE_AUTH_TOKEN = $SmokeToken
$env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY = 'smoke-runtime-api-key'
$env:QJCLAW_RUNTIME_CLOUD_HEARTBEAT_INTERVAL_MS = 1000
$env:QJCLAW_RUNTIME_CLOUD_CONFIG_SYNC_INTERVAL_MS = 1500
$env:QJCLAW_RUNTIME_CLOUD_EVENT_FLUSH_INTERVAL_MS = 800
$env:QJCLAW_RUNTIME_CLOUD_EVENT_BATCH_SIZE = 3
} else {
Remove-Item Env:QJCLAW_SMOKE_CLOUD_API_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_AUTH_TOKEN -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_HEARTBEAT_INTERVAL_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_CONFIG_SYNC_INTERVAL_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_EVENT_FLUSH_INTERVAL_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_CLOUD_EVENT_BATCH_SIZE -ErrorAction SilentlyContinue
}
$env:QJCLAW_USER_DATA_PATH = $UserDataPath
$env:QJCLAW_LOGS_PATH = $LogsPath
if ($RuntimeMode) {
......@@ -145,6 +157,9 @@ if ($PSBoundParameters.ContainsKey('SmokeViewMode')) {
if ($PSBoundParameters.ContainsKey('SmokeProjectId')) {
$env:QJCLAW_SMOKE_PROJECT_ID = $SmokeProjectId
}
if ($StartupOnly) {
$env:QJCLAW_SMOKE_STARTUP_ONLY = '1'
}
try {
Write-Host "Running Electron smoke with isolated userData at $UserDataPath"
......@@ -180,6 +195,7 @@ try {
}
$expectBundledValue = if ($ExpectBundledRuntime) { 'true' } else { 'false' }
$startupOnlyValue = if ($StartupOnly) { 'true' } else { 'false' }
$expectWorkspaceEntryValue = if ($ExpectWorkspaceEntry) { 'true' } else { 'false' }
$expectRemoteBundleValue = if ($ExpectRemoteBundle) { 'true' } else { 'false' }
$validator = @"
......@@ -192,6 +208,7 @@ const [
expectedLogs,
runtimeMode,
expectBundled,
startupOnly,
expectWorkspaceEntry,
expectRemoteBundle,
workspaceProjectId,
......@@ -209,6 +226,55 @@ if (!result.ok) {
const message = result.error || 'Unknown smoke failure.';
throw new Error('Electron smoke failed: ' + message);
}
if (startupOnly === 'true') {
const startupOnlyResult = result.startupOnlyResult || {};
const timeline = Array.isArray(startupOnlyResult.timeline) ? startupOnlyResult.timeline : [];
const finalSnapshot = startupOnlyResult.finalSnapshot || {};
const initialUi = result.initialState && result.initialState.ui ? result.initialState.ui : {};
if (timeline.length < 2) {
throw new Error('Startup-only smoke did not capture enough UI snapshots.');
}
const overlaySnapshot = timeline.find((snapshot) => {
const ui = snapshot && snapshot.ui ? snapshot.ui : {};
const dom = snapshot && snapshot.dom ? snapshot.dom : {};
return ui.showStartupOverlay && dom.startupOverlayVisible && !dom.bindEntryVisible;
});
const fastPathReachedBindEntry = initialUi.showBindEntry && !initialUi.showStartupOverlay;
if (!overlaySnapshot && !fastPathReachedBindEntry) {
throw new Error('Startup-only smoke never observed the startup overlay before binding.');
}
const finalUi = finalSnapshot.ui || {};
const finalDom = finalSnapshot.dom || {};
const finalWorkspace = finalSnapshot.workspace || {};
if (!finalUi.showBindEntry || finalUi.showStartupOverlay) {
throw new Error('Startup-only smoke did not transition to bind-entry after shell warmup.');
}
if (!finalDom.bindEntryVisible || finalDom.startupOverlayVisible) {
throw new Error('Startup-only smoke DOM did not expose the bind-entry-only state.');
}
if (!finalUi.shellReady || !finalWorkspace.shellReady) {
throw new Error('Startup-only smoke reached bind-entry without shellReady=true.');
}
if (!finalWorkspace.bindingRequired || finalWorkspace.apiKeyConfigured) {
throw new Error('Startup-only smoke final workspace unexpectedly reported a bound state.');
}
const summary = {
ok: true,
smokeOutput,
runtimeMode,
expectBundledRuntime: expectBundled === 'true',
overlayObserved: Boolean(overlaySnapshot),
initialOverlaySeenAt: overlaySnapshot ? (overlaySnapshot.capturedAt || null) : null,
finalCapturedAt: finalSnapshot.capturedAt || null,
snapshotCount: timeline.length,
shellReady: Boolean(finalWorkspace.shellReady),
bindingRequired: Boolean(finalWorkspace.bindingRequired),
runtimeState: String(finalWorkspace.runtimeState || ''),
startupPhase: String(finalWorkspace.startupPhase || '')
};
console.log(JSON.stringify(summary, null, 2));
process.exit(0);
}
const sendResult = result.sendResult || {};
const streamSmoke = sendResult.streamSmoke || {};
const executionPolicySource = String(streamSmoke.executionPolicySource || '');
......@@ -448,7 +514,7 @@ const summary = {
};
console.log(JSON.stringify(summary, null, 2));
"@
$summary = & node -e $validator $SmokeOutput $UserDataPath $LogsPath $RuntimeMode $expectBundledValue $expectWorkspaceEntryValue $expectRemoteBundleValue $WorkspaceProjectId $WorkspaceProjectName $WorkspaceMarkerFile $ExpectedBundleSourceUrl $ExpectedBundleConfigVersion $ExpectedBundleFileName $ExpectedBundleSkillId $ExpectedReadmeMarker $UnexpectedReadmeMarker
$summary = & node -e $validator $SmokeOutput $UserDataPath $LogsPath $RuntimeMode $expectBundledValue $startupOnlyValue $expectWorkspaceEntryValue $expectRemoteBundleValue $WorkspaceProjectId $WorkspaceProjectName $WorkspaceMarkerFile $ExpectedBundleSourceUrl $ExpectedBundleConfigVersion $ExpectedBundleFileName $ExpectedBundleSkillId $ExpectedReadmeMarker $UnexpectedReadmeMarker
if ($LASTEXITCODE -ne 0) {
throw 'Electron smoke validation failed.'
}
......@@ -471,5 +537,7 @@ finally {
Remove-Item Env:QJCLAW_SMOKE_SKILL_ID -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_VIEW_MODE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_PROJECT_ID -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_STARTUP_ONLY -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SECRET_BACKEND -ErrorAction SilentlyContinue
}
param(
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token',
[string]$SmokeOutput,
[string]$UserDataPath,
[string]$LogsPath,
[int]$TimeoutSeconds = 180,
[switch]$SkipMaterializeRuntime
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $SmokeOutput) {
$SmokeOutput = Join-Path $repoRoot '.tmp\startup-binding-smoke\result.json'
}
if (-not $UserDataPath) {
$UserDataPath = Join-Path $repoRoot '.tmp\startup-binding-smoke\user-data'
}
if (-not $LogsPath) {
$LogsPath = Join-Path $repoRoot '.tmp\startup-binding-smoke\logs'
}
if (-not $SkipMaterializeRuntime) {
Write-Host "Materializing bundled runtime payload on port $GatewayPort"
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\materialize-runtime-payload.ps1') -GatewayPort $GatewayPort -GatewayToken $GatewayToken
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
Remove-Item Env:QJCLAW_SMOKE_CLOUD_API_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_AUTH_TOKEN -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY -ErrorAction SilentlyContinue
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') `
-SmokeOutput $SmokeOutput `
-UserDataPath $UserDataPath `
-LogsPath $LogsPath `
-RuntimeMode 'bundled-runtime' `
-ExpectBundledRuntime `
-StartupOnly `
-TimeoutSeconds $TimeoutSeconds
exit $LASTEXITCODE
......@@ -9,9 +9,11 @@ import {
} from "../../apps/desktop/src/main/services/openclaw-local-config.js";
import {
buildChatSummary,
isWorkspaceShellReady,
isBundledRuntimeNameConflictError,
isGatewayPolicyViolationError,
isTransientLocalGatewayError,
requiresShellWarmupBeforeBinding,
shouldRetryBootstrapWarmup,
shouldRetryManagedRuntimeStartup,
toStartupErrorMessage
......@@ -201,6 +203,70 @@ async function main(): Promise<void> {
isPackaged: true
}), "Packaged bundled-runtime bootstrap should retry bundled runtime name conflicts.");
const unboundConfig = createConfig({
apiKeyConfigured: false
});
assert(requiresShellWarmupBeforeBinding(unboundConfig), "Unbound employee-key config should require shell warmup before binding.");
assert(!isWorkspaceShellReady({
config: unboundConfig,
runtimeStatus: createRuntimeStatus({
processState: "starting",
activeMode: "bundled-runtime",
message: "Starting managed bundled runtime process."
}),
gatewayStatus: createGatewayStatus({
state: "connecting",
message: "Gateway is connecting."
})
}), "Shell should remain unready while bundled runtime/gateway warmup is incomplete.");
assert(isWorkspaceShellReady({
config: unboundConfig,
runtimeStatus: createRuntimeStatus({
processState: "running",
activeMode: "bundled-runtime"
}),
gatewayStatus: createGatewayStatus({
state: "connected"
})
}), "Shell should become ready only after bundled runtime and gateway are both ready.");
const unboundStartingSummary = buildChatSummary({
config: unboundConfig,
runtimeStatus: createRuntimeStatus({
processState: "starting",
activeMode: "bundled-runtime",
message: "Starting managed bundled runtime process."
}),
runtimeCloudStatus: createRuntimeCloudStatus({
state: "unconfigured",
apiKeyConfigured: false
}),
gatewayStatus: createGatewayStatus({
state: "connecting",
message: "Gateway is connecting."
}),
warmupInFlight: true,
isPackaged: true
});
assert(unboundStartingSummary.chatLaunchState === "starting", "Unbound packaged startup should remain in starting before shell prewarm completes.");
assert(unboundStartingSummary.startupPhase === "starting-runtime", "Unbound packaged startup should report starting-runtime before binding.");
const unboundReadyForBindSummary = buildChatSummary({
config: unboundConfig,
runtimeStatus: createRuntimeStatus({
processState: "running",
activeMode: "bundled-runtime"
}),
runtimeCloudStatus: createRuntimeCloudStatus({
state: "unconfigured",
apiKeyConfigured: false
}),
gatewayStatus: createGatewayStatus({
state: "connected"
}),
warmupInFlight: false,
isPackaged: true
});
assert(unboundReadyForBindSummary.chatLaunchState === "unbound", "Unbound packaged startup should expose binding only after shell prewarm completes.");
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.");
......
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