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.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
});
child.stdin.write(payload);
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;
}
) => {
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);
}
if (sessionId) {
setActiveSessionId(sessionId);
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
}
param(
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token',
[int]$SmokePort = 4318,
[string]$SmokeToken = 'smoke-token',
[string]$BaseOutputDir,
[int]$TimeoutSeconds = 180,
[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)
}
function Copy-ProjectBundleSource {
param(
[string]$SourceRoot,
[string]$DestinationRoot
)
$projectJsonPath = Join-Path $SourceRoot 'project.json'
$excludePaths = @()
if (Test-Path $projectJsonPath) {
$projectConfig = Get-Content -Path $projectJsonPath -Raw | ConvertFrom-Json
$configuredExcludes = $projectConfig.bundlePackaging.excludePaths
if ($configuredExcludes) {
$excludePaths = @($configuredExcludes | ForEach-Object { [string]$_ })
}
}
$excludeSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($entry in $excludePaths) {
if ($entry) {
[void]$excludeSet.Add($entry)
}
}
New-Item -ItemType Directory -Force -Path $DestinationRoot | Out-Null
foreach ($entry in Get-ChildItem -LiteralPath $SourceRoot -Force) {
if ($excludeSet.Contains($entry.Name)) {
continue
}
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationRoot $entry.Name) -Recurse -Force
}
}
function Invoke-ElectronSmokeWithRetry {
param(
[string]$ScriptPath,
[string]$Label,
[string[]]$ArgumentList,
[int]$MaxAttempts = 2
)
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt += 1) {
powershell -ExecutionPolicy Bypass -File $ScriptPath @ArgumentList
if ($LASTEXITCODE -eq 0) {
return
}
if ($attempt -ge $MaxAttempts) {
exit $LASTEXITCODE
}
Write-Warning "$Label failed on attempt $attempt. Retrying..."
Start-Sleep -Seconds 2
}
}
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 UI smoke coverage."
Write-Utf8File (Join-Path $projectRoot 'AGENTS.md') "# $ProjectName`n`nThis is a passive fixture expert used for desktop UI smoke coverage."
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\xhs-expert-cloud-bundle-smoke'
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
$userDataPath = Join-Path $BaseOutputDir 'user-data'
$logsPath = Join-Path $BaseOutputDir 'logs'
$smokeOutput = Join-Path $BaseOutputDir 'result.json'
$bundleSourceRoot = Join-Path $BaseOutputDir 'bundle-src'
$bundleZipPath = Join-Path $BaseOutputDir 'xhs-expert-cloud-bundle.zip'
$bundleFileName = 'xhs-expert-cloud-bundle.zip'
$bundleProjectId = 'xhs'
$bundleProjectName = 'Xiaohongshu Automation'
$bundleSkillId = 'xhs-project-bundle'
$bundleConfigVersion = '2026-04-03T12:00:00.000Z'
$expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileName"
$expertPrompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('5Y+R5LiA5Liq576O6aOf5o6o6I2Q57G755qE5biW5a2Q'))
$expectedExpertIds = @('browser-expert-smoke', 'douyin-expert-smoke', 'xhs')
$electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1'
$xhsSourceRoot = Join-Path $repoRoot 'workspace\xhs'
if (Test-Path $BaseOutputDir) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir, $bundleSourceRoot, $userDataPath, $logsPath | Out-Null
if (-not (Test-Path $xhsSourceRoot)) {
throw "XHS workspace source was not found: $xhsSourceRoot"
}
Copy-ProjectBundleSource -SourceRoot $xhsSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'xhs')
if (Test-Path $bundleZipPath) {
Remove-Item $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 rail 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 rail 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)
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
}
}
$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 = 'Zip-backed Xiaohongshu project bundle for expert-page smoke validation.'
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion
$env:QJCLAW_XHS_SMOKE_MODE = '1'
try {
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert cloud-bundle smoke' -ArgumentList @(
'-SmokeOutput', $smokeOutput,
'-SmokePort', $SmokePort,
'-SmokeToken', $SmokeToken,
'-UserDataPath', $userDataPath,
'-LogsPath', $logsPath,
'-RuntimeMode', 'bundled-runtime',
'-ExpectBundledRuntime',
'-ExpectWorkspaceEntry',
'-PreserveUserData',
'-WorkspaceProjectId', $bundleProjectId,
'-WorkspaceProjectName', $bundleProjectName,
'-WorkspaceMarkerFile', 'AGENTS.md',
'-SmokePrompt', $expertPrompt,
'-SmokeViewMode', 'experts',
'-SmokeProjectId', $bundleProjectId,
'-TimeoutSeconds', $TimeoutSeconds
)
$summary = & node -e @"
const fs = require('fs');
const path = require('path');
const [smokeOutput, userDataPath, expectedBundleSourceUrl, expectedBundleConfigVersion, expectedBundleFileName, expectedBundleSkillId, expectedPrompt, expectedExpertIdsCsv] = process.argv.slice(1);
const result = JSON.parse(fs.readFileSync(smokeOutput, 'utf8'));
if (!result.ok) {
throw new Error(result.error || 'Smoke failed.');
}
const expectedExpertIds = expectedExpertIdsCsv.split(',').map((value) => value.trim()).filter(Boolean).sort();
const finalState = result.finalState || {};
const sendResult = result.sendResult || {};
const streamSmoke = sendResult.streamSmoke || {};
const workspaceSummary = finalState.workspaceSummary || {};
const nonHomeProjects = Array.isArray(workspaceSummary.projects)
? workspaceSummary.projects.filter((project) => !project.isBuiltinHome)
: [];
const expertProjectIds = Array.isArray(finalState.expertProjectIds)
? finalState.expertProjectIds.map((value) => String(value || '')).sort()
: [];
const bundleManifestPath = path.join(userDataPath, 'manifests', 'project-bundles.json');
if (!fs.existsSync(bundleManifestPath)) {
throw new Error('Bundle manifest was not produced: ' + bundleManifestPath);
}
const bundleManifest = JSON.parse(fs.readFileSync(bundleManifestPath, 'utf8'));
const manifestRecord = bundleManifest.xhs;
if (!manifestRecord || typeof manifestRecord !== 'object') {
throw new Error('Bundle manifest does not contain xhs.');
}
if (String(manifestRecord.sourceUrl || '') !== expectedBundleSourceUrl) {
throw new Error('Unexpected bundle sourceUrl: ' + String(manifestRecord.sourceUrl || ''));
}
if (String(manifestRecord.configVersion || '') !== expectedBundleConfigVersion) {
throw new Error('Unexpected bundle configVersion: ' + String(manifestRecord.configVersion || ''));
}
if (String(manifestRecord.fileName || '') !== expectedBundleFileName) {
throw new Error('Unexpected bundle fileName: ' + String(manifestRecord.fileName || ''));
}
if (String(manifestRecord.sourceSkillId || '') !== expectedBundleSkillId) {
throw new Error('Unexpected bundle sourceSkillId: ' + String(manifestRecord.sourceSkillId || ''));
}
if (String(finalState.viewMode || '') !== 'experts') {
throw new Error('Smoke did not remain on experts view.');
}
if (String(sendResult.smokeViewMode || '') !== 'experts') {
throw new Error('Smoke request was not issued from experts mode.');
}
if (String(sendResult.smokeProjectId || '') !== 'xhs') {
throw new Error('Smoke request did not target the xhs expert.');
}
if (String(sendResult.prompt || '') !== expectedPrompt) {
throw new Error('Smoke prompt mismatch.');
}
if (String(workspaceSummary.currentProjectId || '') !== 'xhs') {
throw new Error('Final active project was not xhs: ' + String(workspaceSummary.currentProjectId || ''));
}
if (expertProjectIds.length !== expectedExpertIds.length || expertProjectIds.some((value, index) => value !== expectedExpertIds[index])) {
throw new Error('Expert list did not expose all expected projects. actual=' + JSON.stringify(expertProjectIds) + ' expected=' + JSON.stringify(expectedExpertIds));
}
if (nonHomeProjects.length < 3) {
throw new Error('Workspace summary did not expose at least three non-home projects.');
}
if (!String(sendResult.sessionId || '').startsWith('project:xhs:')) {
throw new Error('Expert smoke session did not bind to xhs: ' + String(sendResult.sessionId || ''));
}
if (String(streamSmoke.phase || '') !== 'completed') {
throw new Error('Expert smoke stream did not complete: ' + String(streamSmoke.phase || ''));
}
console.log(JSON.stringify({
ok: true,
smokeOutput,
prompt: sendResult.prompt || null,
currentProjectId: workspaceSummary.currentProjectId || null,
currentProjectName: workspaceSummary.currentProjectName || null,
expertProjectIds,
nonHomeProjectCount: nonHomeProjects.length,
executionPolicySource: streamSmoke.executionPolicySource || null,
sessionId: sendResult.sessionId || null,
bundleManifestPath
}, null, 2));
"@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $expertPrompt ($expectedExpertIds -join ',')
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Output $summary
}
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
Remove-Item Env:QJCLAW_XHS_SMOKE_MODE -ErrorAction SilentlyContinue
}
param(
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token',
[int]$SmokePort = 4318,
[string]$SmokeToken = 'smoke-token',
[string]$BaseOutputDir,
[int]$TimeoutSeconds = 1500,
[int]$StreamTimeoutSeconds = 1200,
[string]$Prompt,
[switch]$SkipMaterializeRuntime
)
$ErrorActionPreference = 'Stop'
if (-not $PSBoundParameters.ContainsKey('Prompt') -or [string]::IsNullOrWhiteSpace($Prompt)) {
$Prompt = [System.Text.Encoding]::UTF8.GetString(
[System.Convert]::FromBase64String('5Y+R5Liq576O6aOf57G755qE5YiG5Lqr5biW5a2Q')
)
}
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 UI live-run coverage."
Write-Utf8File (Join-Path $projectRoot 'AGENTS.md') "# $ProjectName`n`nThis is a passive fixture expert used for desktop UI live-run coverage."
}
function Invoke-ElectronSmokeWithRetry {
param(
[string]$ScriptPath,
[string]$Label,
[string[]]$ArgumentList,
[int]$MaxAttempts = 2
)
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt += 1) {
powershell -ExecutionPolicy Bypass -File $ScriptPath @ArgumentList
if ($LASTEXITCODE -eq 0) {
return
}
if ($attempt -ge $MaxAttempts) {
exit $LASTEXITCODE
}
Write-Warning "$Label failed on attempt $attempt. Retrying..."
Start-Sleep -Seconds 2
}
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\xhs-expert-live-run'
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
$userDataPath = Join-Path $BaseOutputDir 'user-data'
$logsPath = Join-Path $BaseOutputDir 'logs'
$smokeOutput = Join-Path $BaseOutputDir 'result.json'
$bundleSourceRoot = Join-Path $BaseOutputDir 'bundle-src'
$bundleZipPath = Join-Path $BaseOutputDir 'xhs-expert-live-run.zip'
$bundleFileName = 'xhs-expert-live-run.zip'
$bundleProjectId = 'xhs'
$bundleProjectName = 'Xiaohongshu Automation'
$bundleSkillId = 'xhs-project-bundle'
$bundleConfigVersion = '2026-04-03T18:00:00.000Z'
$expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileName"
$expectedExpertIds = @('browser-expert-smoke', 'douyin-expert-smoke', 'xhs')
$electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1'
$xhsSourceRoot = Join-Path $repoRoot 'workspace\xhs'
if (Test-Path $BaseOutputDir) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir, $bundleSourceRoot, $userDataPath, $logsPath | Out-Null
if (-not (Test-Path $xhsSourceRoot)) {
throw "XHS workspace source was not found: $xhsSourceRoot"
}
Copy-Item -LiteralPath $xhsSourceRoot -Destination $bundleSourceRoot -Recurse -Force
if (Test-Path $bundleZipPath) {
Remove-Item $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 rail 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 rail 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)
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
}
}
$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 = 'Zip-backed Xiaohongshu project bundle for expert-page live-run validation.'
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion
$env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS = ([int]$StreamTimeoutSeconds * 1000).ToString()
try {
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert live run' -ArgumentList @(
'-SmokeOutput', $smokeOutput,
'-SmokePort', $SmokePort,
'-SmokeToken', $SmokeToken,
'-UserDataPath', $userDataPath,
'-LogsPath', $logsPath,
'-RuntimeMode', 'bundled-runtime',
'-ExpectBundledRuntime',
'-PreserveUserData',
'-SmokePrompt', $Prompt,
'-SmokeViewMode', 'experts',
'-SmokeProjectId', $bundleProjectId,
'-TimeoutSeconds', $TimeoutSeconds
)
$summary = & node -e @"
const fs = require('fs');
const path = require('path');
const [smokeOutput, userDataPath, expectedBundleSourceUrl, expectedBundleConfigVersion, expectedBundleFileName, expectedBundleSkillId, expectedPrompt, expectedExpertIdsCsv] = process.argv.slice(1);
const result = JSON.parse(fs.readFileSync(smokeOutput, 'utf8'));
if (!result.ok) {
throw new Error(result.error || 'Live run failed.');
}
const expectedExpertIds = expectedExpertIdsCsv.split(',').map((value) => value.trim()).filter(Boolean).sort();
const finalState = result.finalState || {};
const sendResult = result.sendResult || {};
const streamSmoke = sendResult.streamSmoke || {};
const workspaceSummary = finalState.workspaceSummary || {};
const nonHomeProjects = Array.isArray(workspaceSummary.projects)
? workspaceSummary.projects.filter((project) => !project.isBuiltinHome)
: [];
const expertProjectIds = Array.isArray(finalState.expertProjectIds)
? finalState.expertProjectIds.map((value) => String(value || '')).sort()
: [];
const bundleManifestPath = path.join(userDataPath, 'manifests', 'project-bundles.json');
if (!fs.existsSync(bundleManifestPath)) {
throw new Error('Bundle manifest was not produced: ' + bundleManifestPath);
}
const bundleManifest = JSON.parse(fs.readFileSync(bundleManifestPath, 'utf8'));
const manifestRecord = bundleManifest.xhs;
if (!manifestRecord || typeof manifestRecord !== 'object') {
throw new Error('Bundle manifest does not contain xhs.');
}
if (String(manifestRecord.sourceUrl || '') !== expectedBundleSourceUrl) {
throw new Error('Unexpected bundle sourceUrl: ' + String(manifestRecord.sourceUrl || ''));
}
if (String(manifestRecord.configVersion || '') !== expectedBundleConfigVersion) {
throw new Error('Unexpected bundle configVersion: ' + String(manifestRecord.configVersion || ''));
}
if (String(manifestRecord.fileName || '') !== expectedBundleFileName) {
throw new Error('Unexpected bundle fileName: ' + String(manifestRecord.fileName || ''));
}
if (String(manifestRecord.sourceSkillId || '') !== expectedBundleSkillId) {
throw new Error('Unexpected bundle sourceSkillId: ' + String(manifestRecord.sourceSkillId || ''));
}
if (String(finalState.viewMode || '') !== 'experts') {
throw new Error('Live run did not remain on experts view.');
}
if (String(sendResult.smokeViewMode || '') !== 'experts') {
throw new Error('Live run request was not issued from experts mode.');
}
if (String(sendResult.smokeProjectId || '') !== 'xhs') {
throw new Error('Live run request did not target the xhs expert.');
}
if (String(sendResult.prompt || '') !== expectedPrompt) {
throw new Error('Live run prompt mismatch.');
}
if (String(workspaceSummary.currentProjectId || '') !== 'xhs') {
throw new Error('Final active project was not xhs: ' + String(workspaceSummary.currentProjectId || ''));
}
if (expertProjectIds.length !== expectedExpertIds.length || expertProjectIds.some((value, index) => value !== expectedExpertIds[index])) {
throw new Error('Expert list did not expose all expected projects. actual=' + JSON.stringify(expertProjectIds) + ' expected=' + JSON.stringify(expectedExpertIds));
}
if (nonHomeProjects.length < 3) {
throw new Error('Workspace summary did not expose at least three non-home projects.');
}
if (String(streamSmoke.phase || '') !== 'completed') {
throw new Error('Live run stream did not complete: ' + String(streamSmoke.phase || ''));
}
const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || '');
if (!assistantContent) {
throw new Error('Live run did not produce assistant content.');
}
if (!assistantContent.includes('XHS automation completed.')) {
throw new Error('Live run did not report a completed automation summary. content=' + assistantContent);
}
if (assistantContent.includes('Pipeline status: error')) {
throw new Error('Live run reported pipeline failure. content=' + assistantContent);
}
if (!assistantContent.includes('Submission status: published')) {
throw new Error('Live run did not report a published submission. content=' + assistantContent);
}
const projectRoot = path.join(userDataPath, 'projects', 'xhs');
const runsDir = path.join(projectRoot, 'openclaw_runs');
const runFiles = fs.existsSync(runsDir)
? fs.readdirSync(runsDir).filter((entry) => /^xhs_\d+\.json$/i.test(entry)).sort()
: [];
if (runFiles.length === 0) {
throw new Error('Live run did not materialize an openclaw_runs result file.');
}
console.log(JSON.stringify({
ok: true,
smokeOutput,
prompt: sendResult.prompt || null,
currentProjectId: workspaceSummary.currentProjectId || null,
currentProjectName: workspaceSummary.currentProjectName || null,
expertProjectIds,
nonHomeProjectCount: nonHomeProjects.length,
executionPolicySource: streamSmoke.executionPolicySource || null,
sessionId: streamSmoke.sessionId || sendResult.sessionId || null,
latestStatusLabel: streamSmoke.latestStatusLabel || null,
assistantContent,
runFiles,
bundleManifestPath
}, null, 2));
"@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $Prompt ($expectedExpertIds -join ',')
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Output $summary
}
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
Remove-Item Env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS -ErrorAction SilentlyContinue
}
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."
# Remote Project Bundle Spec
Status: active
本文定义通过 `/openclaw-employee-config` 下发 zip 项目包,并在客户端专家页直接拉起执行的最小规范。
目标是让后续项目例如抖音、小红书都按同一约定交付:
1. 云端配置返回下载地址。
2. 客户端下载 zip 并解压到本地 workspace。
3. 专家页显示该项目。
4. 用户在专家页发送一句自然语言。
5. 客户端自动把请求路由到该项目的 `workspace-entry`
6. 项目在本地完整执行自己的自动化流程。
## 1. 适用范围
本规范适用于当前桌面客户端的 bundle 路径:
- 启动时或配置同步时,客户端根据员工配置中的远程 bundle 下载地址拉取 zip。
- 客户端将 zip 安装到本地 `user-data/projects/<projectId>`
- 专家页根据 `project.json` 中的声明决定该项目如何被展示和执行。
当前实现里,bundle 来源仍然是员工配置中的 skill 下载地址,不是单独的 `project_bundle_url`
## 2. 服务端需要提供什么
服务端不需要生成本地运行态文件。
服务端只需要保证:
- 员工配置中包含可下载的 zip 地址。
- zip 内容满足本文规定的目录结构和文件契约。
- 同一个项目更新时,继续使用同一个 `projectId`
- 如果 zip 内容变了,下载地址、配置版本或远端 freshness 元数据能够触发客户端重新拉取。
服务端不需要在 zip 内预生成这些内容:
- `updatedAt`
- `ready`
- `boundSkillIds`
- `memory/`
- `sessions.json`
- `session-messages/`
- `manifests/active-project.json`
- `manifests/project-bundles.json`
这些都由客户端本地生成或维护。
## 3. 一个 zip 里只能有一个项目
当前客户端按“一个 zip = 一个项目”处理。
支持两种 zip 根结构:
### 结构 A:zip 根目录直接就是项目目录
```text
douyin/
project.json
workspace_entry.py
README.md
tools/
...
```
### 结构 B:zip 根目录包含 `projects/`
```text
projects/
douyin/
project.json
workspace_entry.py
README.md
tools/
...
```
如果采用 `projects/` 结构,则 `projects/` 下必须且只能有一个项目目录。
## 4. 必须文件
### 4.1 `project.json`
每个项目包必须包含 `project.json`
最小建议字段如下:
```json
{
"id": "douyin",
"name": "Douyin Automation",
"description": "Project-isolated Douyin workflow.",
"version": "0.1.0",
"projectType": "workspace-package",
"platform": "douyin",
"capabilities": ["publish", "workflow"],
"requires": ["playwright"],
"workspaceAutomation": {
"runtime": "python",
"script": "workspace_entry.py",
"args": [
"--prompt",
"{prompt}",
"--prepared-prompt",
"{preparedPrompt}",
"--project-root",
"{projectRoot}"
]
},
"bundlePackaging": {
"excludePaths": [
"__pycache__"
]
},
"defaultEntry": {
"id": "workspace-entry",
"type": "workspace-entry",
"intentAliases": ["douyin", "抖音", "发帖", "发布"],
"capabilities": ["publish", "workflow"]
},
"entries": [
{
"id": "workspace-entry",
"type": "workspace-entry",
"intentAliases": ["douyin", "抖音", "发帖", "发布"],
"capabilities": ["publish", "workflow"]
}
],
"workspaceEntryEnabled": true
}
```
字段说明:
- `id`: 必须稳定。后续同一项目升级时不要改。
- `name`: 专家页展示名的基础来源。
- `platform`: 建议填写,便于展示名和路由归类。
- `workspaceAutomation`: 如果希望专家页一句话直接拉起项目,这是必须的。
- `defaultEntry.type = "workspace-entry"`: 推荐配置,保证默认走项目入口而不是普通聊天。
- `entries`: 推荐显式声明,便于后续扩展 skill entry 或更多 intent alias。
- `bundlePackaging.excludePaths`: 只用于你在本地打 zip 时过滤不该进包的目录。
### 4.2 `workspace_entry.py`
如果项目需要通过专家页直接执行自动化流程,必须提供入口脚本。
当前客户端会把这些参数传给入口脚本:
- `--prompt`
- `--prepared-prompt`
- `--project-root`
入口脚本必须:
1. 在项目根目录内执行。
2. 能从自然语言 prompt 里解析出项目需要的执行参数。
3. 调用项目自身的主流程。
4. 通过 `QJC_WORKSPACE_EVENT\t<json>` 输出 started/status/delta/completed/error 事件。
推荐最小事件协议:
```text
QJC_WORKSPACE_EVENT\t{"type":"started","runId":"..."}
QJC_WORKSPACE_EVENT\t{"type":"status","stage":"...","label":"...","detail":"..."}
QJC_WORKSPACE_EVENT\t{"type":"delta","runId":"...","textDelta":"...","fullText":"..."}
QJC_WORKSPACE_EVENT\t{"type":"completed","runId":"...","content":"..."}
QJC_WORKSPACE_EVENT\t{"type":"error","runId":"...","message":"..."}
```
### 4.3 `README.md`
不是强制必需,但强烈建议有。
原因:
- 客户端会把项目 README 注入到项目上下文。
- 专家页模型在做项目路由和上下文理解时会受益。
## 5. 推荐目录结构
建议以后所有项目都收敛到下面这个结构:
```text
<project-id>/
project.json
workspace_entry.py
README.md
AGENTS.md
tools/
skills/
cron/
memory/
```
说明:
- `tools/`: 项目自己的脚本和模块。
- `skills/`: 可选。需要共享 skill 时放这里。
- `cron/`: 可选。需要共享 cron 时放这里。
- `memory/`: 所有可变运行态都应放这里。
## 6. 运行态文件必须放到 `memory/`
这是当前规范里最重要的一条。
客户端在 bundle 更新时,会整体替换项目目录,只保留以下本地状态:
- `sessions.json`
- `session-messages/`
- `memory/`
这意味着任何不在 `memory/` 下的可变状态,在下一次 bundle 替换时都可能丢失。
因此所有项目都必须遵守:
- 浏览器登录态放在 `memory/`
- Cookie / token / 本地缓存放在 `memory/`
- 调试截图和 HTML dump 放在 `memory/`
- 运行结果文件放在 `memory/`
- 生成图片、临时文件、下载文件放在 `memory/`
不建议把这些运行态写在项目根目录:
- `.env`
- `generated_images/`
- `openclaw_runs/`
- `openclaw_debug/`
- `*_profile/`
如果项目必须写环境文件,建议写成:
```text
memory/project.env
```
并由代码显式从这里读取。
## 7. 当前客户端会自动补哪些内容
bundle 安装完成后,客户端会自动:
- 重写 `project.json` 中的 `id`
- 重写 `project.json` 中的 `name`
- 回写 `updatedAt`
- 回写 `ready = true`
- 回写 `boundSkillIds`
- 创建 `memory/`
- 创建默认 session
- 创建 `sessions.json`
- 创建 `session-messages/`
因此 zip 内不需要预先包含这些运行态内容。
## 8. 专家页能跑起来的前提
如果以后抖音项目也按这个规范做,那么在当前客户端里,拉取后可以在专家页跑起来,但有两个前提。
### 前提 A:项目走 `workspace-entry`
也就是:
- `project.json` 里声明了 `workspaceAutomation`
- `defaultEntry``entries` 明确指向 `workspace-entry`
- `workspace_entry.py` 能处理自然语言 prompt
### 前提 B:项目依赖已经包含在当前 bundled runtime 里
当前 bundled runtime 不会根据 zip 自动安装额外 Python 依赖。
也就是说:
- `project.json.requires` 目前只是元数据,不会自动触发安装。
- 如果项目依赖一个 runtime 里没有的 Python 包,项目仍然会启动失败。
当前已确认的 bundled runtime Python 依赖包括:
- `openpyxl`
- `pandas`
- `requests`
- `beautifulsoup4`
- `lxml`
- `pypdf`
- `python-docx`
- `charset-normalizer`
- `pyyaml`
- `pillow`
- `python-dotenv`
- `playwright`
如果新项目例如抖音项目只依赖这些现有能力,那么它可以直接按本规范打包并在专家页执行。
如果它需要新的 Python 依赖,则必须先扩 runtime,而不是只改项目 zip。
## 9. 对项目作者的约束
最终用户不会修改项目代码。
所以项目包必须满足:
- zip 下载后可直接运行
- 首次初始化逻辑由项目自己完成
- 不依赖用户手工创建目录
- 不依赖用户手工改 `project.json`
- 不依赖用户手工改 Python 代码
可以接受的首次使用动作只有这类:
- 专家页发一句话
- 首次登录某个站点
- 首次授权浏览器
不可以接受的动作:
- 让用户进项目目录改路径
- 让用户自己补脚本
- 让用户手工建状态目录
- 让用户自己 pip install
## 10. 新项目交付检查清单
在交付任何新项目 zip 前,至少检查以下内容:
1. zip 中只有一个项目。
2. `project.json` 存在且 `id` 稳定。
3. `workspaceAutomation` 指向的脚本真实存在。
4. 专家页意图词已经写进 `defaultEntry.intentAliases``entries.intentAliases`
5. 所有可变状态都写入 `memory/`
6. 项目不依赖当前 runtime 中缺失的 Python 包。
7. 项目首次运行所需的浏览器/登录/配置流程已经内置在代码里。
8. 用真实 bundle 路径做过一次 `cloud zip -> 专家页 -> 自动化执行` 验证。
## 11. 推荐的最小模板
```text
douyin/
project.json
workspace_entry.py
README.md
AGENTS.md
tools/
run.py
publish.py
memory/
```
其中:
- `run.py` 是项目自己的完整自动化主流程。
- `workspace_entry.py` 只负责把专家页自然语言转换成 `run.py` 的结构化调用。
- 所有本地状态都进入 `memory/`
## 12. `xhs` 当前状态
`workspace/xhs` 已经完成了“专家页可直接拉起”的主体改造:
-`project.json`
-`workspaceAutomation`
-`workspace_entry.py`
- 真实专家页链路已经验证通过
但它仍然有一部分运行态还写在项目根目录,不完全符合本规范的最终状态。
后续如果要把 `xhs` 作为标准模板,建议再补一轮整理,把这些目录迁移到 `memory/`
- `xhs_profile`
- `generated_images`
- `openclaw_runs`
- `openclaw_debug`
- 项目级环境文件
## 13. 最终结论
以后抖音项目可以按这份规范来做。
只要同时满足下面两点:
- zip 结构和 `workspace-entry` 契约满足本文要求
- 项目依赖不超出当前 bundled runtime 能力
那么客户端在拉取配置并同步 zip 后,就可以在专家页通过对话直接把项目跑起来。
......@@ -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