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

feat: support expert-page workspace project bundles

parent 31303078
......@@ -16,6 +16,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"extract-zip": "^2.0.1",
"keytar": "^7.9.0"
},
"devDependencies": {
......
import { BrowserWindow, app } from "electron";
import path from "node:path";
import { pathToFileURL } from "node:url";
function resolveRendererEntry(): string {
if (!app.isPackaged) {
......@@ -30,7 +31,7 @@ export function createMainWindow(smokeEnabled = false): BrowserWindow {
if (rendererEntry.startsWith("http://") || rendererEntry.startsWith("https://")) {
void window.loadURL(rendererEntry);
} else {
void window.loadFile(rendererEntry);
void window.loadURL(pathToFileURL(rendererEntry).toString());
}
return window;
......
......@@ -246,7 +246,13 @@ async function waitForRendererSmokeState(window: BrowserWindow, timeoutMs = 2000
throw new Error("Smoke test window was destroyed before renderer state became available.");
}
const state = await window.webContents.executeJavaScript("window.__QJC_SMOKE__ ?? null");
let state: unknown = null;
try {
state = await window.webContents.executeJavaScript("window.__QJC_SMOKE__ ?? null");
} catch {
await delay(250);
continue;
}
if (state && typeof state === "object" && "sessions" in state && "logs" in state) {
return state as RendererSmokeState;
}
......@@ -273,6 +279,14 @@ async function waitForRendererStreamSmoke(window: BrowserWindow, timeoutMs = 400
return null;
}
function resolveSmokeStreamTimeoutMs(): number {
const raw = Number.parseInt(process.env.QJCLAW_SMOKE_STREAM_TIMEOUT_MS ?? "", 10);
if (Number.isFinite(raw) && raw >= 10_000) {
return raw;
}
return 40_000;
}
async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<void> {
const result: Record<string, unknown> = {
startedAt: new Date().toISOString()
......@@ -286,7 +300,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
try {
await trace("runSmokeTest:start");
if (window.webContents.isLoadingMainFrame()) {
await trace("runSmokeTest:waiting-for-load");
await trace("runSmokeTest:renderer-loading");
await new Promise<void>((resolve, reject) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
......@@ -323,13 +337,13 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
fail("Renderer process exited during smoke load: " + details.reason);
};
timer = setTimeout(() => {
fail("Renderer main frame did not finish loading in time.");
fail("Renderer DOM did not become ready in time.");
}, 15000);
window.webContents.once("did-finish-load", finish);
window.webContents.once("dom-ready", finish);
window.webContents.on("did-fail-load", onFailLoad);
window.webContents.on("render-process-gone", onRenderProcessGone);
});
await trace("runSmokeTest:load-finished");
await trace("runSmokeTest:dom-ready");
}
await trace("runSmokeTest:loading-renderer-state");
......@@ -360,6 +374,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
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";
const smokeProjectId = process.env.QJCLAW_SMOKE_PROJECT_ID?.trim() || "";
await trace("runSmokeTest:before-send-script");
const sendResult = await window.webContents.executeJavaScript(`(async () => {
const api = window.qjcDesktop;
......@@ -470,18 +486,17 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const selectedSkillId = preferredSkillId
? (readyWorkspaceSkills.find((skill) => skill.id === preferredSkillId)?.id
?? readySkills.find((skill) => skill.id === preferredSkillId)?.id)
: (readyWorkspaceSkills[0]?.id ?? readySkills[0]?.id);
let sessions = workspace.sessions?.length ? workspace.sessions : await api.chat.listSessions();
let sessionId = sessions.find((session) => session.id === state?.activeSessionId)?.id ?? sessions[0]?.id;
if (!sessionId) {
const createdSession = await api.chat.createSession("Smoke Test");
sessions = [createdSession];
sessionId = createdSession.id;
}
: undefined;
const system = await api.system.getSummary();
await actions.sendChatPrompt(${JSON.stringify(prompt)}, selectedSkillId, sessionId);
const actionResult = await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
mode: ${JSON.stringify(smokeViewMode)},
projectId: ${JSON.stringify(smokeProjectId)},
skillId: selectedSkillId || undefined
});
return {
prompt: ${JSON.stringify(prompt)},
smokeViewMode: ${JSON.stringify(smokeViewMode)},
smokeProjectId: ${JSON.stringify(smokeProjectId)},
runtimeCloudStatus,
runtimeCloudFetch,
runtimeStatus,
......@@ -495,8 +510,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
profile,
credits,
skills,
selectedSkillId,
initialSessionId: sessionId,
selectedSkillId: actionResult.skillId || selectedSkillId,
initialSessionId: actionResult.sessionId,
system,
health: gatewayProbe.health,
status: gatewayProbe.status
......@@ -504,7 +519,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
})()`);
await trace("runSmokeTest:send-script-finished");
const streamState = await waitForRendererStreamSmoke(window, 40000);
const streamState = await waitForRendererStreamSmoke(window, resolveSmokeStreamTimeoutMs());
if (!streamState?.streamSmoke) {
throw new Error("Renderer stream smoke did not reach a terminal state.");
}
......@@ -552,6 +567,10 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const combinedSendResult = {
...sendResult,
...postStreamResult,
initialGatewayHealth: sendResult.health,
initialGatewayStatus: sendResult.status,
finalGatewayHealth: postStreamResult.health,
finalGatewayStatus: postStreamResult.status,
streamSmoke
};
const diagnosticsPath = typeof (combinedSendResult as { diagnostics?: { filePath?: string } }).diagnostics?.filePath === "string"
......@@ -584,6 +603,10 @@ async function bootstrap(): Promise<void> {
const smokeOutputPath = process.env.QJCLAW_SMOKE_OUTPUT;
const smokeEnabled = Boolean(smokeOutputPath);
const smokeCloudBaseUrl = process.env.QJCLAW_SMOKE_CLOUD_API_BASE_URL?.trim();
const smokeAuthToken = process.env.QJCLAW_SMOKE_AUTH_TOKEN?.trim();
const smokeRuntimeApiKey = process.env.QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY ?? "smoke-runtime-api-key";
const smokeCloudBootstrapEnabled = Boolean(smokeCloudBaseUrl && smokeAuthToken);
const traceBootstrap = async (message: string) => {
if (!smokeOutputPath) {
return;
......@@ -601,15 +624,12 @@ async function bootstrap(): Promise<void> {
let stopSmokeCloudApiServer: (() => Promise<void>) | undefined;
if (smokeEnabled) {
if (smokeEnabled || smokeCloudBootstrapEnabled) {
await traceBootstrap("smoke-config-start");
const smokeCloudBaseUrl = process.env.QJCLAW_SMOKE_CLOUD_API_BASE_URL;
const smokeAuthToken = process.env.QJCLAW_SMOKE_AUTH_TOKEN;
const smokeRuntimeApiKey = process.env.QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY ?? "smoke-runtime-api-key";
if (smokeCloudBaseUrl && smokeAuthToken) {
stopSmokeCloudApiServer = await startSmokeCloudApiServer(smokeCloudBaseUrl, smokeAuthToken, smokeRuntimeApiKey);
}
if (smokeCloudBaseUrl || smokeAuthToken || smokeRuntimeApiKey) {
if (smokeCloudBaseUrl && smokeAuthToken) {
await configService.save({
setupMode: config.setupMode,
provider: config.provider,
......@@ -624,9 +644,7 @@ async function bootstrap(): Promise<void> {
gatewayToken: undefined,
apiKey: smokeRuntimeApiKey
});
if (typeof smokeAuthToken === "string") {
await secretManager.setAuthToken(smokeAuthToken);
}
await secretManager.setApiKey(smokeRuntimeApiKey);
}
}
......
......@@ -732,7 +732,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
const target = await projectChatTargetResolver.resolve(sessionId, prompt, requestedSkillId);
const resolvedSessionId = target.sessionState.sessionId;
const projectConfig = await projectStore.getProjectPackageConfig(target.sessionState.projectId);
const preferWorkspaceDefaultEntry = projectConfig?.defaultEntry?.type === "workspace-entry";
const autoSkillRoute = requestedSkillId
|| preferWorkspaceDefaultEntry
? null
: await projectSkillRouter.resolve(target.sessionState.projectId, prompt);
const defaultEntryRoute = (!requestedSkillId && !autoSkillRoute && projectConfig?.defaultEntry?.type === "skill")
......@@ -795,7 +797,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
const result = await projectWorkspaceExecutor.execute({
sessionId: executionSessionId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt: preparedExecution.decision.preparedPrompt
prompt: preparedExecution.decision.preparedPrompt,
userPrompt: prompt
});
await projectStore.appendSessionMessage(executionSessionId, result.reply);
await projectStore.updateSessionLastActive(executionSessionId).catch(() => undefined);
......@@ -932,7 +935,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
const result = await projectWorkspaceExecutor.execute({
sessionId: executionSessionId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt: preparedExecution.decision.preparedPrompt
prompt: preparedExecution.decision.preparedPrompt,
userPrompt: prompt
}, {
onStarted: (runId) => {
queueOrSend({
......
import { createHash } from "node:crypto";
import { promisify } from "node:util";
import { execFile } from "node:child_process";
import http from "node:http";
import https from "node:https";
import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import type { RuntimeCloudFetchAction } from "@qjclaw/shared-types";
import extractZip from "extract-zip";
import type { AppConfigService } from "./app-config.js";
import type { ProjectStoreService } from "./project-store.js";
import type { RemoteSkillAsset } from "./skill-store.js";
const execFileAsync = promisify(execFile);
function getWindowsPowerShellPath(): string {
if (process.platform !== "win32") {
return "powershell.exe";
}
const systemRoot = process.env.SYSTEMROOT ?? process.env.WINDIR ?? "C:\\Windows";
return path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
}
interface BundleManifestRecord {
sourceUrl: string;
fileName: string;
......@@ -790,13 +778,7 @@ export class ProjectBundleService {
private async extractZip(zipPath: string, destinationPath: string): Promise<void> {
await mkdir(destinationPath, { recursive: true });
const escapedZipPath = zipPath.replace(/'/g, "''");
const escapedDestinationPath = destinationPath.replace(/'/g, "''");
await execFileAsync(getWindowsPowerShellPath(), [
"-NoProfile",
"-Command",
`Expand-Archive -LiteralPath '${escapedZipPath}' -DestinationPath '${escapedDestinationPath}' -Force`
]);
await extractZip(zipPath, { dir: destinationPath });
}
private async probeRemoteBundle(url: URL, redirectCount = 0): Promise<RemoteBundleProbeResult | null> {
......
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { stat } from "node:fs/promises";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import type { RuntimeManager } from "@qjclaw/runtime-manager";
import type { ChatMessage } from "@qjclaw/shared-types";
......@@ -9,6 +9,7 @@ interface ProjectWorkspaceExecutionInput {
sessionId: string;
projectRoot: string;
prompt: string;
userPrompt?: string;
runId?: string;
}
......@@ -31,6 +32,21 @@ interface RunnerEvent {
result?: unknown;
}
interface ProjectAutomationCommandConfig {
runtime?: unknown;
script?: unknown;
args?: unknown;
env?: unknown;
}
interface ResolvedProjectAutomationCommand {
runtime: "python";
executablePath: string;
scriptPath: string;
args: string[];
env: Record<string, string>;
}
const EVENT_PREFIX = "QJC_WORKSPACE_EVENT\t";
function escapePowerShellSingleQuoted(value: string): string {
......@@ -106,6 +122,87 @@ function extractReplyText(result: unknown): string {
return parts.join("\n\n").trim();
}
function renderTemplate(
value: string,
variables: Record<string, string>
): string {
return value.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key: string) => variables[key] ?? "");
}
function normalizeAutomationEnv(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value)
.filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[0].trim().length > 0)
.map(([key, envValue]) => [key, envValue])
);
}
async function resolveProjectAutomationCommand(
projectRoot: string,
input: ProjectWorkspaceExecutionInput,
pythonExecutable: string
): Promise<ResolvedProjectAutomationCommand | null> {
const projectJsonPath = path.join(projectRoot, "project.json");
let rawProjectJson: string;
try {
rawProjectJson = await readFile(projectJsonPath, "utf8");
} catch {
return null;
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(rawProjectJson) as Record<string, unknown>;
} catch {
return null;
}
const automation = (parsed.workspaceAutomation ?? parsed.workspace_automation) as ProjectAutomationCommandConfig | undefined;
if (!automation || typeof automation !== "object" || Array.isArray(automation)) {
return null;
}
const runtime = typeof automation.runtime === "string" ? automation.runtime.trim().toLowerCase() : "";
if (runtime !== "python") {
return null;
}
const script = typeof automation.script === "string" ? automation.script.trim() : "";
if (!script) {
return null;
}
const args = Array.isArray(automation.args)
? automation.args.filter((value): value is string => typeof value === "string")
: [];
const env = normalizeAutomationEnv(automation.env);
const variables = {
prompt: input.userPrompt?.trim() || input.prompt,
preparedPrompt: input.prompt,
projectRoot: input.projectRoot,
sessionId: input.sessionId
};
const scriptPath = path.resolve(projectRoot, renderTemplate(script, variables));
if (!(await pathExists(scriptPath))) {
throw new Error(`Project automation script is missing: ${scriptPath}`);
}
return {
runtime: "python",
executablePath: pythonExecutable,
scriptPath,
args: args.map((value) => renderTemplate(value, variables)),
env: Object.fromEntries(
Object.entries(env).map(([key, value]) => [key, renderTemplate(value, variables)])
)
};
}
async function resolveRunnerScriptPath(): Promise<string> {
const directPath = path.resolve(__dirname, "./project-workspace-agent-runner.js");
const candidates = [directPath];
......@@ -141,12 +238,16 @@ export class ProjectWorkspaceExecutorService {
await this.runtimeManager.syncManagedConfig("sync");
const paths = this.runtimeManager.resolveBundledPaths();
const runnerScriptPath = await resolveRunnerScriptPath();
const automationCommand = await resolveProjectAutomationCommand(input.projectRoot, input, paths.pythonExecutable);
const runnerScriptPath = automationCommand ? null : await resolveRunnerScriptPath();
const vendorPackageDir = path.join(paths.runtimeDir, "openclaw", "package");
const runId = input.runId?.trim() || randomUUID();
callbacks.onStatus?.("launch-workspace", "Launching project workspace agent");
callbacks.onStatus?.(
automationCommand ? "launch-workspace-automation" : "launch-workspace",
automationCommand ? "Launching project workspace automation" : "Launching project workspace agent"
);
return await new Promise<{ runId: string; reply: ChatMessage }>((resolve, reject) => {
let settled = false;
......@@ -162,7 +263,15 @@ export class ProjectWorkspaceExecutorService {
PLAYWRIGHT_BROWSERS_PATH: paths.playwrightBrowsersPath,
OPENCLAW_HIDE_BANNER: "1",
OPENCLAW_SUPPRESS_NOTES: "1",
NODE_NO_WARNINGS: process.env.NODE_NO_WARNINGS || "1"
NODE_NO_WARNINGS: process.env.NODE_NO_WARNINGS || "1",
PYTHONUTF8: "1",
PYTHONIOENCODING: "utf-8",
PATH: [
path.join(paths.runtimeDir, "python", "Scripts"),
path.dirname(paths.pythonExecutable),
process.env.PATH ?? ""
].filter(Boolean).join(path.delimiter),
...(automationCommand?.env ?? {})
};
const spawnOptions = {
cwd: input.projectRoot,
......@@ -173,7 +282,9 @@ export class ProjectWorkspaceExecutorService {
let child;
try {
child = spawn(paths.nodeExecutable, [runnerScriptPath], spawnOptions);
child = automationCommand
? spawn(automationCommand.executablePath, [automationCommand.scriptPath, ...automationCommand.args], spawnOptions)
: spawn(paths.nodeExecutable, [runnerScriptPath!], spawnOptions);
} catch (error) {
const errorCode = error instanceof Error
? String((error as Error & { code?: number | string }).code ?? "")
......@@ -184,7 +295,12 @@ export class ProjectWorkspaceExecutorService {
const wrapperScript = [
`Set-Location -LiteralPath '${escapePowerShellSingleQuoted(input.projectRoot)}'`,
`& '${escapePowerShellSingleQuoted(paths.nodeExecutable)}' '${escapePowerShellSingleQuoted(runnerScriptPath)}'`,
...Object.entries(childEnv)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(([key, value]) => `$env:${key}='${escapePowerShellSingleQuoted(value)}'`),
automationCommand
? `& '${escapePowerShellSingleQuoted(automationCommand.executablePath)}' '${escapePowerShellSingleQuoted(automationCommand.scriptPath)}' ${automationCommand.args.map((value) => `'${escapePowerShellSingleQuoted(value)}'`).join(" ")}`
: `& '${escapePowerShellSingleQuoted(paths.nodeExecutable)}' '${escapePowerShellSingleQuoted(runnerScriptPath!)}'`,
"$exitCode = if ($LASTEXITCODE -is [int]) { $LASTEXITCODE } else { 0 }",
"exit $exitCode"
].join("; ");
......@@ -290,7 +406,9 @@ export class ProjectWorkspaceExecutorService {
prompt: input.prompt,
runId
});
if (!automationCommand) {
child.stdin.write(payload);
}
child.stdin.end();
});
}
......
......@@ -506,6 +506,7 @@ declare global {
qjcSmokeEnabled?: boolean;
__QJC_SMOKE__?: {
usingMockApi: boolean;
viewMode: ViewMode;
gatewayStatus: GatewayStatus | null;
gatewayHealth: GatewayHealth | null;
runtimeStatus: RuntimeStatus | null;
......@@ -522,11 +523,25 @@ declare global {
messages: ChatMessage[];
logs: LogEntry[];
activeSessionId: string;
expertProjectIds: string[];
workspaceSummary: WorkspaceSummary | null;
streamSmoke: SmokeStreamSnapshot | null;
};
__QJC_SMOKE_ACTIONS__?: {
sendChatPrompt(prompt: string, skillId?: string, sessionId?: string): Promise<void>;
sendConversationPrompt(
prompt: string,
options?: {
mode?: "chat" | "experts";
projectId?: string;
skillId?: string;
sessionId?: string;
}
): Promise<{
mode: "chat" | "experts";
projectId?: string;
sessionId: string;
skillId?: string;
}>;
};
}
}
......@@ -750,7 +765,7 @@ export default function App() {
const activeProject = useMemo(() => visibleProjects.find((project) => project.id === workspace?.currentProjectId) ?? visibleProjects[0], [visibleProjects, workspace?.currentProjectId]);
const activeExpertName = useMemo(() => getProjectDisplayName(activeProject), [activeProject]);
const activeExpertGuide = useMemo(() => getExpertGuide(activeProject), [activeProject]);
const expertPageProjects = useMemo(() => visibleProjects.slice(0, 2), [visibleProjects]);
const expertPageProjects = useMemo(() => visibleProjects, [visibleProjects]);
const expertCards = useMemo(() => expertPageProjects.map((project) => ({
project,
displayName: getProjectDisplayName(project),
......@@ -1137,6 +1152,7 @@ export default function App() {
window.__QJC_SMOKE__ = {
usingMockApi: isMockDesktopApi,
viewMode,
gatewayStatus,
gatewayHealth,
runtimeStatus,
......@@ -1153,10 +1169,11 @@ export default function App() {
messages: toPlainMessages(messages),
logs: [],
activeSessionId: resolvedActiveSessionId ?? "",
expertProjectIds: expertPageProjects.map((project) => project.id),
workspaceSummary: workspace,
streamSmoke
};
}, [config, gatewayHealth, gatewayStatus, messages, resolvedActiveSessionId, runtimeCloudStatus, runtimeStatus, runtimeTelemetry, sessions, streamSmoke, systemSummary, workspace]);
}, [config, expertPageProjects, gatewayHealth, gatewayStatus, messages, resolvedActiveSessionId, runtimeCloudStatus, runtimeStatus, runtimeTelemetry, sessions, streamSmoke, systemSummary, viewMode, workspace]);
useEffect(() => {
if (!smokeEnabled) {
......@@ -1165,18 +1182,48 @@ export default function App() {
}
window.__QJC_SMOKE_ACTIONS__ = {
sendChatPrompt: async (nextPrompt: string, skillId?: string, sessionId?: string) => {
setViewMode("chat");
if (skillId) {
setSelectedSkillId(skillId);
sendConversationPrompt: async (
nextPrompt: string,
options?: {
mode?: "chat" | "experts";
projectId?: string;
skillId?: string;
sessionId?: string;
}
if (sessionId) {
setActiveSessionId(sessionId);
) => {
const mode = options?.mode === "experts" ? "experts" : "chat";
const requestedProjectId = options?.projectId?.trim();
const resolvedSkillId = options?.skillId?.trim() || undefined;
let resolvedSessionId = options?.sessionId?.trim() || "";
setViewMode(mode);
if (mode === "experts" && requestedProjectId) {
await switchExpert(requestedProjectId);
const projectSessions = await desktopApi.chat.listSessionsByProject(requestedProjectId);
resolvedSessionId = resolvedSessionId
|| projectSessions.find((session) => session.id === activeSessionId)?.id
|| projectSessions[0]?.id
|| (await desktopApi.chat.createSessionForProject(requestedProjectId, "Smoke Test")).id;
} else if (resolvedSessionId) {
setActiveSessionId(resolvedSessionId);
}
setSelectedSkillId(resolvedSkillId ?? DEFAULT_SKILL.id);
if (resolvedSessionId) {
setActiveSessionId(resolvedSessionId);
}
setPrompt(nextPrompt);
window.setTimeout(() => {
void submitPrompt(nextPrompt, skillId, sessionId);
void submitPrompt(nextPrompt, resolvedSkillId, resolvedSessionId || undefined);
}, 0);
return {
mode,
projectId: mode === "experts" ? requestedProjectId : undefined,
sessionId: resolvedSessionId,
skillId: resolvedSkillId
};
}
};
......
......@@ -10,6 +10,9 @@
- `bundled-runtime-smoke.ps1` materializes the local runtime payload, forces bundled-runtime mode, and validates that Electron can launch and use the managed runtime end to end
- `workspace-entry-smoke.ps1` materializes the bundled runtime payload, prepares an isolated active project fixture, and validates the workspace-entry execution path end to end as a formal regression smoke; `pnpm smoke:workspace-entry`
- `cloud-bundle-smoke.ps1` generates real same-project bundle variants, serves them through the smoke cloud API, and validates the full `cloud zip -> bundle sync -> active project -> workspace-entry` chain for payload `sync`, cached `init`, and same-`projectId` replacement with refreshed README/shared-entry materialization; `pnpm smoke:cloud-bundle`
- `xhs-expert-cloud-bundle-smoke.ps1` packages `workspace/xhs` as a zip-backed employee-config bundle, preserves two extra fixture experts so the experts rail exceeds two items, switches to the XHS expert, and sends `发一个美食推荐类的帖子` through the experts view; `pnpm smoke:xhs-expert-cloud-bundle`
- `xhs-expert-manual-launch.ps1` packages `workspace/xhs` into a local zip bundle, boots the packaged desktop app against the built-in mock `/openclaw-employee-config`, preserves two extra fixture experts so the experts rail exceeds two items, and leaves the app open for manual experts-page testing; close any already running `QianjiangClaw.exe` instance first, then run `powershell -ExecutionPolicy Bypass -File build/scripts/xhs-expert-manual-launch.ps1`
- Remote project zip delivery and workspace-entry packaging rules are documented in `docs/remote-project-bundle-spec.zh-CN.md`
- `default-chat-smoke.ps1` compiles the targeted `default-chat-context-smoke.ts` service-level smoke with the local desktop TypeScript toolchain and verifies `chat-fallback` routing, project context injection into the prepared prompt, post-execution snapshot refresh/rebind, and reuse of the refreshed snapshot on the next request; `pnpm smoke:default-chat`
- `installer-smoke.ps1` validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe`
- `installer-smoke.ps1` also validates that the packaged runtime still contains the OpenClaw workspace template fallback file `resources/vendor/openclaw-runtime/openclaw/package/docs/reference/templates/AGENTS.md` before it launches the installed app smoke
......
......@@ -16,6 +16,8 @@ param(
[string]$WorkspaceProjectName = 'Workspace Entry Smoke',
[string]$WorkspaceProjectDescription = 'Workspace-entry smoke fixture for desktop project isolation.',
[string]$WorkspaceMarkerFile = 'AGENT.md',
[string]$SmokeViewMode = 'chat',
[string]$SmokeProjectId,
[string]$ExpectedBundleSourceUrl,
[string]$ExpectedBundleConfigVersion,
[string]$ExpectedBundleFileName,
......@@ -137,6 +139,12 @@ if ($PSBoundParameters.ContainsKey('SmokePrompt')) {
if ($PSBoundParameters.ContainsKey('SmokeSkillId')) {
$env:QJCLAW_SMOKE_SKILL_ID = $SmokeSkillId
}
if ($PSBoundParameters.ContainsKey('SmokeViewMode')) {
$env:QJCLAW_SMOKE_VIEW_MODE = $SmokeViewMode
}
if ($PSBoundParameters.ContainsKey('SmokeProjectId')) {
$env:QJCLAW_SMOKE_PROJECT_ID = $SmokeProjectId
}
try {
Write-Host "Running Electron smoke with isolated userData at $UserDataPath"
......@@ -261,6 +269,9 @@ if (!diagnostics.runtimeTelemetry) {
if (expectBundled === 'true') {
const runtimeStatus = sendResult.runtimeStatusAfterProbe || {};
const runtimeHealth = sendResult.runtimeHealthAfterProbe || {};
const initialGatewayStatus = sendResult.initialGatewayStatus || {};
const finalGatewayStatus = sendResult.finalGatewayStatus || sendResult.status || {};
const gatewayWasConnected = initialGatewayStatus.state === 'connected' || finalGatewayStatus.state === 'connected';
if (runtimeStatus.activeMode !== 'bundled-runtime') {
throw new Error('Bundled runtime did not become active. Active mode: ' + runtimeStatus.activeMode);
}
......@@ -285,8 +296,8 @@ if (expectBundled === 'true') {
if (!runtimeStatus.installedPythonPackages.some((value) => { const normalized = String(value || '').toLowerCase(); return normalized === 'pillow' || normalized.startsWith('pillow=='); })) {
throw new Error('Bundled runtime did not report Pillow in the Python package set.');
}
if (!sendResult.status || sendResult.status.state !== 'connected') {
throw new Error('Gateway did not reconnect after bundled runtime startup: ' + (sendResult.status && sendResult.status.state));
if (!gatewayWasConnected) {
throw new Error('Gateway never reported connected after bundled runtime startup. initial=' + String(initialGatewayStatus.state || '') + ' final=' + String(finalGatewayStatus.state || ''));
}
}
let workspaceEntryValidated = false;
......@@ -458,5 +469,7 @@ finally {
Remove-Item Env:QJCLAW_RUNTIME_MODE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_PROMPT -ErrorAction SilentlyContinue
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
}
This diff is collapsed.
This diff is collapsed.
param(
[int]$SmokePort = 4318,
[string]$SmokeToken = 'smoke-token',
[string]$BaseOutputDir,
[string]$AppExePath
)
$ErrorActionPreference = 'Stop'
function Write-Utf8File {
param([string]$FilePath, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
}
function New-ExpertFixtureProject {
param(
[string]$ProjectsRoot,
[string]$ProjectId,
[string]$ProjectName,
[string]$Platform,
[string]$Description,
[string]$UpdatedAt
)
$projectRoot = Join-Path $ProjectsRoot $ProjectId
New-Item -ItemType Directory -Force -Path $projectRoot, (Join-Path $projectRoot 'memory') | Out-Null
$projectPayload = [ordered]@{
id = $ProjectId
name = $ProjectName
description = $Description
platform = $Platform
ready = $true
updatedAt = $UpdatedAt
boundSkillIds = @()
workspaceEntryEnabled = $true
}
Write-Utf8File (Join-Path $projectRoot 'project.json') ($projectPayload | ConvertTo-Json -Depth 8)
Write-Utf8File (Join-Path $projectRoot 'README.md') "# $ProjectName`n`nFixture expert project for manual XHS validation."
Write-Utf8File (Join-Path $projectRoot 'AGENTS.md') "# $ProjectName`n`nPassive fixture expert used to keep the experts list above two items."
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\xhs-expert-manual-launch'
}
if (-not $AppExePath) {
$AppExePath = Join-Path $repoRoot 'dist\installer\win-unpacked\QianjiangClaw.exe'
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
$AppExePath = [System.IO.Path]::GetFullPath($AppExePath)
$xhsSourceRoot = Join-Path $repoRoot 'workspace\xhs'
$userDataPath = Join-Path $BaseOutputDir 'user-data'
$logsPath = Join-Path $BaseOutputDir 'logs'
$bundleSourceRoot = Join-Path $BaseOutputDir 'bundle-src'
$bundleZipPath = Join-Path $BaseOutputDir 'xhs-expert-manual-launch.zip'
$bundleFileName = 'xhs-expert-manual-launch.zip'
$bundleSkillId = 'xhs-project-bundle'
$bundleConfigVersion = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
if (-not (Test-Path $AppExePath)) {
throw "Packaged desktop executable was not found: $AppExePath"
}
if (-not (Test-Path $xhsSourceRoot)) {
throw "XHS workspace source was not found: $xhsSourceRoot"
}
$runningDesktop = Get-Process -Name 'QianjiangClaw' -ErrorAction SilentlyContinue
if ($runningDesktop) {
$processIds = ($runningDesktop | Select-Object -ExpandProperty Id) -join ', '
throw "QianjiangClaw is already running (PID: $processIds). Close the desktop app first, then rerun this launcher."
}
if (Test-Path $BaseOutputDir) {
Remove-Item -LiteralPath $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
if (Test-Path $BaseOutputDir) {
throw "Failed to reset manual launch directory: $BaseOutputDir"
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir, $userDataPath, $logsPath, $bundleSourceRoot | Out-Null
Copy-Item -LiteralPath $xhsSourceRoot -Destination $bundleSourceRoot -Recurse -Force
if (Test-Path $bundleZipPath) {
Remove-Item -LiteralPath $bundleZipPath -Force
}
Compress-Archive -Path (Join-Path $bundleSourceRoot 'xhs') -DestinationPath $bundleZipPath -Force
$projectsRoot = Join-Path $userDataPath 'projects'
$manifestsRoot = Join-Path $userDataPath 'manifests'
New-Item -ItemType Directory -Force -Path $projectsRoot, $manifestsRoot | Out-Null
New-ExpertFixtureProject -ProjectsRoot $projectsRoot -ProjectId 'douyin-expert-smoke' -ProjectName 'Douyin Expert Fixture' -Platform 'douyin' -Description 'Fixture project that keeps the experts list above two items.' -UpdatedAt '2026-04-03T00:00:00.000Z'
New-ExpertFixtureProject -ProjectsRoot $projectsRoot -ProjectId 'browser-expert-smoke' -ProjectName 'Browser Expert Fixture' -Platform 'browser' -Description 'Fixture project that keeps the experts list above two items.' -UpdatedAt '2026-04-03T00:01:00.000Z'
Write-Utf8File (Join-Path $manifestsRoot 'active-project.json') (@{ projectId = 'browser-expert-smoke' } | ConvertTo-Json -Depth 3)
$env:QJCLAW_USER_DATA_PATH = $userDataPath
$env:QJCLAW_LOGS_PATH = $logsPath
$env:QJCLAW_RUNTIME_MODE = 'bundled-runtime'
$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_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 = 'XHS Project Bundle'
$env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Local mock employee-config bundle for manual Xiaohongshu expert validation.'
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion
$null = Start-Process -FilePath $AppExePath -PassThru
Write-Host "Launched packaged desktop app with local mock employee-config."
Write-Host "User data path: $userDataPath"
Write-Host "Bundle zip path: $bundleZipPath"
Write-Host "Open the Experts view, select xhs, and send your manual prompt."
This diff is collapsed.
......@@ -16,6 +16,8 @@
"smoke:bundled-runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/bundled-runtime-smoke.ps1",
"smoke:workspace-entry": "powershell -ExecutionPolicy Bypass -File build/scripts/workspace-entry-smoke.ps1",
"smoke:cloud-bundle": "powershell -ExecutionPolicy Bypass -File build/scripts/cloud-bundle-smoke.ps1",
"smoke:xhs-expert-cloud-bundle": "powershell -ExecutionPolicy Bypass -File build/scripts/xhs-expert-cloud-bundle-smoke.ps1",
"launch:xhs-local-manual": "powershell -ExecutionPolicy Bypass -File build/scripts/xhs-expert-manual-launch.ps1",
"smoke:default-chat": "powershell -ExecutionPolicy Bypass -File build/scripts/default-chat-smoke.ps1",
"smoke:project-routing": "powershell -ExecutionPolicy Bypass -File build/scripts/project-routing-smoke.ps1",
"smoke:project-package-orchestrator": "powershell -ExecutionPolicy Bypass -File build/scripts/project-package-orchestrator-smoke.ps1",
......
......@@ -10,6 +10,9 @@ importers:
apps/desktop:
dependencies:
extract-zip:
specifier: ^2.0.1
version: 2.0.1
keytar:
specifier: ^7.9.0
version: 7.9.0
......@@ -109,6 +112,9 @@ importers:
packages/runtime-manager:
dependencies:
'@qjclaw/gateway-client':
specifier: workspace:*
version: link:../gateway-client
'@qjclaw/shared-types':
specifier: workspace:*
version: link:../shared-types
......
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