Commit 0a6752f5 authored by AI-甘富林's avatar AI-甘富林

fix: 优化 bundled runtime 启动恢复与项目包同步状态

parent f8e4ee3a
......@@ -114,6 +114,7 @@ interface RendererSmokeState {
const forcedUserDataPath = process.env.QJCLAW_USER_DATA_PATH?.trim();
const forcedLogsPath = process.env.QJCLAW_LOGS_PATH?.trim();
const PROJECT_BUNDLE_BOOTSTRAP_TIMEOUT_MS = 45_000;
if (forcedUserDataPath) {
app.setPath("userData", forcedUserDataPath);
......@@ -129,6 +130,24 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function withTimeout<T>(operation: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(message));
}, timeoutMs);
operation.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
}
);
});
}
function buildSystemSummary(): SystemSummary {
const userDataPath = process.env.QJCLAW_USER_DATA_PATH?.trim() || app.getPath("userData");
const logsPath = process.env.QJCLAW_LOGS_PATH?.trim() || app.getPath("logs");
......@@ -633,19 +652,37 @@ async function bootstrap(): Promise<void> {
configVersion: string | undefined,
action: RuntimeCloudFetchAction
) => {
console.info("[bundle-bootstrap]", "bundle.sync.start", {
action,
configVersion,
skillCount: skills.length,
timeoutMs: PROJECT_BUNDLE_BOOTSTRAP_TIMEOUT_MS
});
await traceBootstrap("project-bundle-sync:" + action + ":start:" + skills.length);
try {
await projectBundleService.syncRemoteBundles(skills, configVersion, action);
await withTimeout(
projectBundleService.syncRemoteBundles(skills, configVersion, action),
PROJECT_BUNDLE_BOOTSTRAP_TIMEOUT_MS,
`Project bundle sync timed out after ${Math.round(PROJECT_BUNDLE_BOOTSTRAP_TIMEOUT_MS / 1000)}s.`
);
console.info("[bundle-bootstrap]", "bundle.sync.done", {
action,
configVersion,
skillCount: skills.length
});
await traceBootstrap("project-bundle-sync:" + action + ":done");
} catch (error) {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
console.error("Project bundle sync failed", {
const rawMessage = error instanceof Error ? (error.stack ?? error.message) : String(error);
const userMessage = error instanceof Error ? error.message : String(error);
projectBundleService.setSyncError(userMessage);
console.error("[bundle-bootstrap]", "bundle.sync.error", {
action,
configVersion,
skillIds: skills.map((skill) => skill.skillId),
error: message
error: rawMessage,
timedOut: userMessage.includes("timed out")
});
await traceBootstrap("project-bundle-sync:" + action + ":error:" + message.replace(/\r?\n/g, " | "));
await traceBootstrap("project-bundle-sync:" + action + ":error:" + rawMessage.replace(/\r?\n/g, " | "));
}
};
const projectContextService = new ProjectContextService(projectStore);
......@@ -755,6 +792,7 @@ async function bootstrap(): Promise<void> {
dailyReportService,
runtimeSkillBridge,
projectStore,
projectBundleService,
projectContextService,
projectChatTargetResolver,
projectSkillRouter,
......
......@@ -36,6 +36,7 @@ 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";
import type { ProjectStoreService } from "./services/project-store.js";
import type { ProjectBundleService } from "./services/project-bundle.js";
import {
EMPTY_PROJECT_INVENTORY_MESSAGE,
createSessionForActiveProject,
......@@ -77,6 +78,7 @@ interface MainServices {
dailyReportService: DailyReportService;
runtimeSkillBridge: RuntimeSkillBridgeService;
projectStore: ProjectStoreService;
projectBundleService: ProjectBundleService;
projectContextService: ProjectContextService;
projectChatTargetResolver: ProjectChatTargetResolverService;
projectSkillRouter: ProjectSkillRouterService;
......@@ -215,6 +217,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
dailyReportService,
runtimeSkillBridge,
projectStore,
projectBundleService,
projectContextService,
projectChatTargetResolver,
projectSkillRouter,
......@@ -516,19 +519,29 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
sessions,
skills
} = await loadActiveProjectWorkspaceState(projectStore);
const bundleSyncStatus = projectBundleService.getSyncStatus();
const bundleSyncFailed = bundleSyncStatus.state === "error";
const chatSummary = projects.length > 0
? baseChatSummary
: {
chatReady: false,
chatLaunchState: config.apiKeyConfigured ? "starting" as const : baseChatSummary.chatLaunchState,
chatStatusMessage: config.apiKeyConfigured
? EMPTY_PROJECT_INVENTORY_MESSAGE
: baseChatSummary.chatStatusMessage,
startupPhase: config.apiKeyConfigured ? "syncing-config" as const : baseChatSummary.startupPhase,
startupMessage: config.apiKeyConfigured
? EMPTY_PROJECT_INVENTORY_MESSAGE
: baseChatSummary.startupMessage
};
: bundleSyncFailed
? {
chatReady: false,
chatLaunchState: "error" as const,
chatStatusMessage: bundleSyncStatus.lastError ?? "工作配置同步失败,请检查网络后重试。",
startupPhase: "error" as const,
startupMessage: bundleSyncStatus.lastError ?? "工作配置同步失败,请检查网络后重试。"
}
: {
chatReady: false,
chatLaunchState: config.apiKeyConfigured ? "starting" as const : baseChatSummary.chatLaunchState,
chatStatusMessage: config.apiKeyConfigured
? EMPTY_PROJECT_INVENTORY_MESSAGE
: baseChatSummary.chatStatusMessage,
startupPhase: config.apiKeyConfigured ? "syncing-config" as const : baseChatSummary.startupPhase,
startupMessage: config.apiKeyConfigured
? EMPTY_PROJECT_INVENTORY_MESSAGE
: baseChatSummary.startupMessage
};
return {
apiKeyConfigured: config.apiKeyConfigured,
......
......@@ -85,12 +85,19 @@ interface RemoteBundleProbeResult {
contentLength?: number;
}
export interface ProjectBundleSyncStatus {
state: "idle" | "syncing" | "ready" | "error";
lastError?: string;
lastSyncedAt?: string;
}
const MANIFEST_FILE = "project-bundles.json";
const MANIFESTS_DIR = "manifests";
const TEMP_DIR = ".bundle-tmp";
const REDIRECT_STATUS_CODES = new Set([301, 302, 307, 308]);
const HEAD_UNSUPPORTED_STATUS_CODES = new Set([403, 404, 405, 501]);
const MAX_REDIRECTS = 5;
const BUNDLE_REQUEST_IDLE_TIMEOUT_MS = 30_000;
function nowIso(): string {
return new Date().toISOString();
......@@ -174,22 +181,59 @@ function normalizeContentLength(value: string | undefined): number | undefined {
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
}
function sanitizeUrl(url: URL): string {
return `${url.protocol}//${url.host}${url.pathname}`;
}
function logBundle(event: string, details: Record<string, unknown>): void {
console.info("[bundle]", event, { ...details, ts: new Date().toISOString() });
}
export class ProjectBundleService {
private readonly configService: AppConfigService;
private readonly projectStore: ProjectStoreService;
private syncStatus: ProjectBundleSyncStatus = { state: "idle" };
constructor(configService: AppConfigService, projectStore: ProjectStoreService) {
this.configService = configService;
this.projectStore = projectStore;
}
getSyncStatus(): ProjectBundleSyncStatus {
return { ...this.syncStatus };
}
setSyncError(message: string): void {
this.syncStatus = {
...this.syncStatus,
state: "error",
lastError: message
};
}
async syncRemoteBundles(remoteSkills: RemoteSkillAsset[], configVersion?: string, _action?: RuntimeCloudFetchAction): Promise<void> {
const startedAt = Date.now();
this.syncStatus = {
...this.syncStatus,
state: "syncing",
lastError: undefined
};
const bundleAssets = remoteSkills.filter((asset) => asset.downloadUrl && asset.fileName && /\.zip$/i.test(asset.fileName));
logBundle("bundle.sync.start", {
action: _action ?? "unknown",
configVersion,
remoteSkillCount: remoteSkills.length,
bundleAssetCount: bundleAssets.length
});
const workspaceRoot = await this.projectStore.getWorkspaceRoot();
const manifestPath = path.join(workspaceRoot, MANIFESTS_DIR, MANIFEST_FILE);
const currentManifest = (await readJsonFile<Record<string, BundleManifestRecord>>(manifestPath)) ?? {};
const nextManifest: Record<string, BundleManifestRecord> = {};
const seenBundleKeys = new Set<string>();
logBundle("bundle.asset_filter.result", {
remoteSkillCount: remoteSkills.length,
bundleAssetCount: bundleAssets.length
});
for (const asset of bundleAssets) {
if (!asset.downloadUrl || !asset.fileName) {
......@@ -198,11 +242,20 @@ export class ProjectBundleService {
const bundleKey = this.getBundleAssetKey(asset);
if (seenBundleKeys.has(bundleKey)) {
logBundle("bundle.duplicate.detected", {
skillId: asset.skillId,
bundleKey
});
continue;
}
seenBundleKeys.add(bundleKey);
const currentRecord = this.findManifestRecordForAsset(currentManifest, asset);
logBundle("bundle.reuse.check", {
skillId: asset.skillId,
bundleKey,
source: sanitizeUrl(new URL(asset.downloadUrl))
});
const nextRecord = await this.resolveNextManifestRecord(workspaceRoot, asset, configVersion, currentRecord);
if (nextManifest[nextRecord.projectId]) {
......@@ -213,6 +266,17 @@ export class ProjectBundleService {
await this.cleanupRemovedBundleState(workspaceRoot, currentManifest, nextManifest);
await writeJsonFile(manifestPath, nextManifest);
this.syncStatus = {
state: "ready",
lastError: undefined,
lastSyncedAt: nowIso()
};
logBundle("bundle.sync.done", {
action: _action ?? "unknown",
configVersion,
projectCount: Object.keys(nextManifest).length,
elapsedMs: Date.now() - startedAt
});
}
private getBundleAssetKey(asset: Pick<RemoteSkillAsset, "skillId" | "downloadUrl">): string {
......@@ -240,14 +304,29 @@ export class ProjectBundleService {
currentRecord: BundleManifestRecord | undefined
): Promise<BundleManifestRecord> {
if (!this.canReuseManifestRecord(currentRecord, asset, configVersion)) {
logBundle("bundle.reuse.check", {
skillId: asset.skillId,
decision: "redownload",
reason: "manifest-not-reusable"
});
return this.downloadAndInstallBundle(workspaceRoot, asset, configVersion);
}
const freshnessProbe = await this.probeRemoteBundle(new URL(asset.downloadUrl!));
if (this.shouldRedownloadBundle(currentRecord, asset, freshnessProbe)) {
logBundle("bundle.reuse.check", {
skillId: asset.skillId,
decision: "redownload",
reason: "freshness-mismatch"
});
return this.downloadAndInstallBundle(workspaceRoot, asset, configVersion, freshnessProbe);
}
logBundle("bundle.reuse.check", {
skillId: asset.skillId,
decision: "reuse",
reason: "freshness-match"
});
return this.updateManifestRecordFromProbe(currentRecord, asset, configVersion, freshnessProbe);
}
......@@ -333,6 +412,11 @@ export class ProjectBundleService {
}
const nextRecords = Object.values(nextManifest);
logBundle("bundle.cleanup.removed_state", {
removedProjectCount: currentRecords.length - nextRecords.length,
currentProjectCount: currentRecords.length,
nextProjectCount: nextRecords.length
});
const expectedProjectIds = new Set(nextRecords.map((record) => record.projectId));
const expectedSkillEntries = new Set(nextRecords.flatMap((record) => record.sharedSkillEntries ?? []));
const expectedCronEntries = new Set(nextRecords.flatMap((record) => record.sharedCronEntries ?? []));
......@@ -371,10 +455,17 @@ export class ProjectBundleService {
configVersion?: string,
freshnessProbe?: RemoteBundleProbeResult | null
): Promise<BundleManifestRecord> {
const startedAt = Date.now();
const tempRoot = path.join(workspaceRoot, TEMP_DIR, `${Date.now()}-${asset.skillId}`);
const zipPath = path.join(tempRoot, asset.fileName ?? `${asset.skillId}.zip`);
const extractPath = path.join(tempRoot, "unzipped");
await mkdir(extractPath, { recursive: true });
logBundle("bundle.download.start", {
skillId: asset.skillId,
source: sanitizeUrl(new URL(asset.downloadUrl!)),
fileName: asset.fileName,
configVersion
});
try {
const resolvedFreshnessProbe = freshnessProbe === undefined
......@@ -386,6 +477,11 @@ export class ProjectBundleService {
await this.extractZip(zipPath, extractPath);
const contentRoot = await this.resolveArchiveContentRoot(extractPath);
const metadata = await this.resolveBundleMetadata(contentRoot, asset, configVersion);
logBundle("bundle.install.start", {
skillId: asset.skillId,
projectId: metadata.projectId,
projectName: metadata.projectName
});
const materialized = await this.materializeBundle(workspaceRoot, tempRoot, metadata);
try {
await this.projectStore.syncBundleProject({
......@@ -401,6 +497,11 @@ export class ProjectBundleService {
throw error;
}
await materialized.finalize().catch(() => undefined);
logBundle("bundle.install.success", {
skillId: asset.skillId,
projectId: metadata.projectId,
elapsedMs: Date.now() - startedAt
});
return {
sourceUrl: asset.downloadUrl!,
fileName: asset.fileName ?? `${asset.skillId}.zip`,
......@@ -416,6 +517,14 @@ export class ProjectBundleService {
remoteEtag: resolvedFreshnessProbe?.etag,
remoteLastModified: resolvedFreshnessProbe?.lastModified
};
} catch (error) {
logBundle("bundle.sync.error", {
skillId: asset.skillId,
source: sanitizeUrl(new URL(asset.downloadUrl!)),
error: error instanceof Error ? error.message : String(error),
elapsedMs: Date.now() - startedAt
});
throw error;
} finally {
await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined);
}
......@@ -691,29 +800,64 @@ export class ProjectBundleService {
}
private async probeRemoteBundle(url: URL, redirectCount = 0): Promise<RemoteBundleProbeResult | null> {
const startedAt = Date.now();
logBundle("bundle.probe.start", {
source: sanitizeUrl(url),
redirectCount
});
const client = url.protocol === "https:" ? https : http;
return new Promise<RemoteBundleProbeResult | null>((resolve, reject) => {
let settled = false;
const finishReject = (error: Error) => {
if (settled) {
return;
}
settled = true;
logBundle("bundle.probe.result", {
source: sanitizeUrl(url),
redirectCount,
outcome: "error",
error: error.message,
elapsedMs: Date.now() - startedAt
});
reject(error);
};
const finishResolve = (result: RemoteBundleProbeResult | null) => {
if (settled) {
return;
}
settled = true;
logBundle("bundle.probe.result", {
source: sanitizeUrl(url),
redirectCount,
outcome: result ? "success" : "unsupported-head",
contentLength: result?.contentLength,
elapsedMs: Date.now() - startedAt
});
resolve(result);
};
const request = client.request(url, { method: "HEAD" }, (response) => {
const status = response.statusCode ?? 500;
const location = response.headers.location;
if (location && REDIRECT_STATUS_CODES.has(status)) {
if (redirectCount >= MAX_REDIRECTS) {
reject(new Error("Project bundle probe redirected too many times."));
finishReject(new Error("Project bundle probe redirected too many times."));
response.resume();
return;
}
const redirectUrl = new URL(location, url);
response.resume();
void this.probeRemoteBundle(redirectUrl, redirectCount + 1).then(resolve, reject);
void this.probeRemoteBundle(redirectUrl, redirectCount + 1).then(finishResolve, finishReject);
return;
}
if (HEAD_UNSUPPORTED_STATUS_CODES.has(status)) {
response.resume();
resolve(null);
finishResolve(null);
return;
}
if (status < 200 || status >= 300) {
reject(new Error(`Project bundle freshness probe failed with HTTP status ${status}.`));
finishReject(new Error(`Project bundle freshness probe failed with HTTP status ${status}.`));
response.resume();
return;
}
......@@ -722,36 +866,72 @@ export class ProjectBundleService {
const lastModified = normalizeHeaderValue(response.headers["last-modified"]);
const contentLength = normalizeContentLength(normalizeHeaderValue(response.headers["content-length"]));
response.resume();
resolve({
finishResolve({
etag,
lastModified,
contentLength
});
});
request.on("error", (error) => reject(new Error(`Project bundle freshness probe failed: ${error.message}`)));
request.setTimeout(BUNDLE_REQUEST_IDLE_TIMEOUT_MS, () => {
request.destroy(new Error(`Project bundle freshness probe timed out after ${Math.round(BUNDLE_REQUEST_IDLE_TIMEOUT_MS / 1000)}s.`));
});
request.on("error", (error) => finishReject(new Error(`Project bundle freshness probe failed: ${error.message}`)));
request.end();
});
}
private async downloadToBuffer(url: URL, redirectCount = 0): Promise<Buffer> {
const startedAt = Date.now();
logBundle("bundle.download.start", {
source: sanitizeUrl(url),
redirectCount
});
const client = url.protocol === "https:" ? https : http;
return new Promise<Buffer>((resolve, reject) => {
let settled = false;
const finishReject = (error: Error) => {
if (settled) {
return;
}
settled = true;
logBundle("bundle.download.error", {
source: sanitizeUrl(url),
redirectCount,
error: error.message,
elapsedMs: Date.now() - startedAt
});
reject(error);
};
const finishResolve = (payload: Buffer) => {
if (settled) {
return;
}
settled = true;
logBundle("bundle.download.success", {
source: sanitizeUrl(url),
redirectCount,
byteLength: payload.length,
elapsedMs: Date.now() - startedAt
});
resolve(payload);
};
const request = client.get(url, (response) => {
const status = response.statusCode ?? 500;
const location = response.headers.location;
if (location && REDIRECT_STATUS_CODES.has(status)) {
if (redirectCount >= MAX_REDIRECTS) {
reject(new Error("Project bundle download redirected too many times."));
finishReject(new Error("Project bundle download redirected too many times."));
response.resume();
return;
}
const redirectUrl = new URL(location, url);
response.resume();
void this.downloadToBuffer(redirectUrl, redirectCount + 1).then(resolve, reject);
void this.downloadToBuffer(redirectUrl, redirectCount + 1).then(finishResolve, finishReject);
return;
}
if (status < 200 || status >= 300) {
reject(new Error(`Project bundle download failed with HTTP status ${status}.`));
finishReject(new Error(`Project bundle download failed with HTTP status ${status}.`));
response.resume();
return;
}
......@@ -759,9 +939,12 @@ export class ProjectBundleService {
response.on("data", (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on("end", () => resolve(Buffer.concat(chunks)));
response.on("end", () => finishResolve(Buffer.concat(chunks)));
});
request.setTimeout(BUNDLE_REQUEST_IDLE_TIMEOUT_MS, () => {
request.destroy(new Error(`Project bundle download timed out after ${Math.round(BUNDLE_REQUEST_IDLE_TIMEOUT_MS / 1000)}s.`));
});
request.on("error", (error) => reject(new Error(`Project bundle download failed: ${error.message}`)));
request.on("error", (error) => finishReject(new Error(`Project bundle download failed: ${error.message}`)));
});
}
}
......
......@@ -26,7 +26,7 @@ export function isTransientLocalGatewayError(message?: string): boolean {
|| normalized.includes("bundled runtime exited before gateway became ready");
}
function isGatewayPolicyViolationError(message?: string): boolean {
export function isGatewayPolicyViolationError(message?: string): boolean {
if (!message) {
return false;
}
......@@ -37,7 +37,7 @@ function isGatewayPolicyViolationError(message?: string): boolean {
|| normalized.includes("policy violation");
}
function isBundledRuntimeNameConflictError(message?: string): boolean {
export function isBundledRuntimeNameConflictError(message?: string): boolean {
if (!message) {
return false;
}
......@@ -59,7 +59,7 @@ export function toStartupErrorMessage(message: string | undefined, fallback: str
}
if (isGatewayPolicyViolationError(message)) {
return "检测到本机已有 OpenClaw 网关正在运行,但安装包未能切换到内置运行时,请先退出本地 OpenClaw 后重试。";
return "内置运行时网关连接被拒绝,如本机正在运行 OpenClaw,请先退出后重试;否则请重启应用。";
}
return message ?? fallback;
......@@ -75,7 +75,10 @@ export function shouldRetryManagedRuntimeStartup(config: AppConfig, status: Runt
return false;
}
return isTransientLocalGatewayError(status.lastError ?? status.message);
const runtimeError = status.lastError ?? status.message;
return isTransientLocalGatewayError(runtimeError)
|| isGatewayPolicyViolationError(runtimeError)
|| isBundledRuntimeNameConflictError(runtimeError);
}
export function shouldRetryBootstrapWarmup(input: {
......@@ -95,12 +98,20 @@ export function shouldRetryBootstrapWarmup(input: {
}
const runtimeError = input.runtimeStatus.lastError ?? input.runtimeStatus.message;
if (input.runtimeStatus.processState === "error" && isTransientLocalGatewayError(runtimeError)) {
if (input.runtimeStatus.processState === "error" && (
isTransientLocalGatewayError(runtimeError)
|| isGatewayPolicyViolationError(runtimeError)
|| isBundledRuntimeNameConflictError(runtimeError)
)) {
return true;
}
const gatewayError = input.gatewayStatus?.lastError ?? input.gatewayStatus?.message;
return input.gatewayStatus?.state === "error" && isTransientLocalGatewayError(gatewayError);
return input.gatewayStatus?.state === "error" && (
isTransientLocalGatewayError(gatewayError)
|| isGatewayPolicyViolationError(gatewayError)
|| isBundledRuntimeNameConflictError(gatewayError)
);
}
export function buildChatSummary(input: {
......@@ -166,7 +177,7 @@ export function buildChatSummary(input: {
warmupInFlight
&& packagedBundledRuntime
&& gatewayStatus?.state === "error"
&& isTransientLocalGatewayError(gatewayError)
&& (isTransientLocalGatewayError(gatewayError) || isGatewayPolicyViolationError(gatewayError) || isBundledRuntimeNameConflictError(gatewayError))
) {
return {
chatReady: false,
......
param(
param(
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token',
[string]$SmokeOutput,
[string]$UserDataPath,
[string]$LogsPath,
[int]$TimeoutSeconds = 90
[int]$TimeoutSeconds = 90,
[switch]$SkipMaterializeRuntime
)
$ErrorActionPreference = 'Stop'
function Write-Utf8File {
param([string]$filePath, [string]$content)
$encoding = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($filePath, $content, $encoding)
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $SmokeOutput) {
$SmokeOutput = Join-Path $repoRoot '.tmp\bundled-runtime-smoke\result.json'
......@@ -20,11 +27,77 @@ if (-not $LogsPath) {
$LogsPath = Join-Path $repoRoot '.tmp\bundled-runtime-smoke\logs'
}
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
$BaseOutputDir = [System.IO.Path]::GetFullPath((Split-Path $SmokeOutput -Parent))
$userDataPath = [System.IO.Path]::GetFullPath($UserDataPath)
$logsPath = [System.IO.Path]::GetFullPath($LogsPath)
$bundleSourceRoot = Join-Path $BaseOutputDir 'bundle-src'
$bundleRoot = Join-Path $bundleSourceRoot 'bundled-runtime-smoke'
$bundleZipPath = Join-Path $BaseOutputDir 'bundled-runtime-smoke.zip'
$bundleFileName = 'bundled-runtime-smoke.zip'
$bundleProjectId = 'bundled-runtime-smoke'
$bundleProjectName = ''
$bundleSkillId = 'bundled-runtime-smoke-skill'
$bundleConfigVersion = '2026-04-03T03:55:00.000Z'
$bundleReadmeMarker = 'Bundled runtime smoke bundle marker.'
if (Test-Path $BaseOutputDir) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir, $bundleRoot, $userDataPath, $logsPath | Out-Null
$bundleProjectJson = [ordered]@{
id = $bundleProjectId
name = $bundleProjectName
description = 'Remote bundle fixture for bundled runtime smoke.'
version = '1.0.0'
}
$bundleReadme = @(
'# Bundled Runtime Smoke',
'',
'This bundle validates that bundled runtime startup can sync a zip-backed project bundle.',
$bundleReadmeMarker
) -join [Environment]::NewLine
$bundleAgent = @(
'# Bundled Runtime Smoke',
'',
'This project is used to validate remote zip bundle sync during packaged desktop startup.'
) -join [Environment]::NewLine
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
Write-Utf8File (Join-Path $bundleRoot 'project.json') ($bundleProjectJson | ConvertTo-Json -Depth 5)
Write-Utf8File (Join-Path $bundleRoot 'README.md') $bundleReadme
Write-Utf8File (Join-Path $bundleRoot 'AGENT.md') $bundleAgent
New-Item -ItemType Directory -Force -Path (Join-Path $bundleRoot 'memory') | Out-Null
Write-Utf8File (Join-Path $bundleRoot 'memory\summary.md') 'Bundled runtime smoke memory marker.'
if (Test-Path $bundleZipPath) {
Remove-Item $bundleZipPath -Force -ErrorAction SilentlyContinue
}
Compress-Archive -Path (Join-Path $bundleSourceRoot '*') -DestinationPath $bundleZipPath -Force
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
}
}
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') -SmokeOutput $SmokeOutput -UserDataPath $UserDataPath -LogsPath $LogsPath -RuntimeMode 'bundled-runtime' -ExpectBundledRuntime -TimeoutSeconds $TimeoutSeconds
exit $LASTEXITCODE
$env:QJCLAW_SMOKE_BUNDLE_ZIP_PATH = $bundleZipPath
$env:QJCLAW_SMOKE_BUNDLE_FILE_NAME = $bundleFileName
$env:QJCLAW_SMOKE_BUNDLE_SKILL_ID = $bundleSkillId
$env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'Bundled Runtime Smoke Skill'
$env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Remote zip-backed bundle for bundled runtime smoke validation.'
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion
try {
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') -SmokeOutput $SmokeOutput -UserDataPath $UserDataPath -LogsPath $LogsPath -RuntimeMode 'bundled-runtime' -ExpectBundledRuntime -ExpectRemoteBundle -WorkspaceProjectId $bundleProjectId -SmokePrompt 'Describe the current project root and confirm bundled runtime smoke bundle execution.' -SmokeSkillId '__bundled_runtime_smoke_disabled__' -ExpectedBundleSourceUrl "http://127.0.0.1:$GatewayPort/downloads/$bundleFileName" -ExpectedBundleConfigVersion $bundleConfigVersion -ExpectedBundleFileName $bundleFileName -ExpectedBundleSkillId $bundleSkillId -ExpectedReadmeMarker $bundleReadmeMarker -TimeoutSeconds $TimeoutSeconds
exit $LASTEXITCODE
} finally {
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_ZIP_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_FILE_NAME -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_ID -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue
}
......@@ -361,7 +361,7 @@ if (expectRemoteBundle === 'true') {
if (currentProjectId !== workspaceProjectId) {
throw new Error('Remote bundle smoke did not activate the expected project: ' + currentProjectId);
}
if (currentProjectName !== workspaceProjectName) {
if (workspaceProjectName && currentProjectName !== workspaceProjectName) {
throw new Error('Remote bundle smoke did not expose the expected project name: ' + currentProjectName);
}
if (!fs.existsSync(bundleManifestPath)) {
......
......@@ -9,8 +9,12 @@ import {
} from "../../apps/desktop/src/main/services/openclaw-local-config.js";
import {
buildChatSummary,
isBundledRuntimeNameConflictError,
isGatewayPolicyViolationError,
isTransientLocalGatewayError,
shouldRetryBootstrapWarmup
shouldRetryBootstrapWarmup,
shouldRetryManagedRuntimeStartup,
toStartupErrorMessage
} from "../../apps/desktop/src/main/workspace-startup.js";
function assert(condition: unknown, message: string): asserts condition {
......@@ -122,6 +126,10 @@ async function main(): Promise<void> {
for (const message of transientCodes) {
assert(isTransientLocalGatewayError(message), `Expected transient startup classification for: ${message}`);
}
assert(isGatewayPolicyViolationError("Gateway connection closed (1008)."), "Expected 1008 to be classified as gateway policy violation.");
assert(isBundledRuntimeNameConflictError("gateway name/hostname conflict detected via bonjour"), "Expected bonjour name conflict to be classified correctly.");
const policyViolationMessage = toStartupErrorMessage("Gateway connection closed (1008).", "fallback");
assert(!policyViolationMessage.includes("本机已有 OpenClaw 网关正在运行"), "Policy violation message should no longer hard-assert a local OpenClaw gateway conflict.");
const config = createConfig();
const runtimeStatus = createRuntimeStatus({
......@@ -162,6 +170,36 @@ async function main(): Promise<void> {
gatewayStatus,
isPackaged: true
}), "Packaged bundled-runtime bootstrap should retry transient startup failures.");
assert(shouldRetryManagedRuntimeStartup(config, runtimeStatus), "Packaged bundled-runtime startup should retry transient runtime failures.");
const policyViolationRuntimeStatus = createRuntimeStatus({
processState: "error",
lastError: "Gateway connection closed (1008)."
});
const policyViolationGatewayStatus = createGatewayStatus({
state: "error",
lastError: "Gateway connection closed (1008).",
message: "Gateway connection closed (1008)."
});
assert(shouldRetryBootstrapWarmup({
config,
runtimeStatus: createRuntimeStatus(),
gatewayStatus: policyViolationGatewayStatus,
isPackaged: true
}), "Packaged bundled-runtime bootstrap should retry gateway policy violations.");
assert(shouldRetryManagedRuntimeStartup(config, policyViolationRuntimeStatus), "Packaged bundled-runtime startup should retry runtime policy violations.");
const nameConflictGatewayStatus = createGatewayStatus({
state: "error",
lastError: "Gateway name/hostname conflict detected via bonjour.",
message: "Gateway name/hostname conflict detected via bonjour."
});
assert(shouldRetryBootstrapWarmup({
config,
runtimeStatus: createRuntimeStatus(),
gatewayStatus: nameConflictGatewayStatus,
isPackaged: true
}), "Packaged bundled-runtime bootstrap should retry bundled runtime name conflicts.");
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.");
......@@ -180,6 +218,8 @@ async function main(): Promise<void> {
startupSummary,
gatewayOnlySummary,
shouldRetryBootstrap: true,
shouldRetryManagedRuntime: true,
policyViolationMessage,
localOpenClawPolicy: {
packagedBundledRuntime: shouldUseLocalOpenClawGateway(true, "bundled-runtime"),
packagedExternalGateway: shouldUseLocalOpenClawGateway(true, "external-gateway"),
......
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