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

feat: add project bundle smoke workflows

parent 0a92cbb9
......@@ -397,6 +397,50 @@ function resolveSmokeWaitForPathsTimeoutMs(): number {
return 120_000;
}
function resolveSmokeAttachments(): Array<{
kind: "image";
name: string;
mimeType: string;
localPath: string;
}> {
const raw = process.env.QJCLAW_SMOKE_ATTACHMENTS_JSON ?? "";
if (!raw.trim()) {
return [];
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.flatMap((attachment) => {
if (!attachment || typeof attachment !== "object") {
return [];
}
const typed = attachment as {
kind?: unknown;
name?: unknown;
mimeType?: unknown;
localPath?: unknown;
};
const kind = typed.kind === "image" ? "image" : null;
const localPath = typeof typed.localPath === "string" ? typed.localPath.trim() : "";
if (!kind || !localPath) {
return [];
}
return [{
kind,
name: typeof typed.name === "string" && typed.name.trim() ? typed.name.trim() : path.basename(localPath),
mimeType: typeof typed.mimeType === "string" && typed.mimeType.trim() ? typed.mimeType.trim() : "application/octet-stream",
localPath
}];
});
} catch {
return [];
}
}
async function waitForSmokePaths(pathsToCheck: string[], timeoutMs: number): Promise<void> {
if (pathsToCheck.length === 0) {
return;
......@@ -429,6 +473,63 @@ async function waitForSmokePaths(pathsToCheck: string[], timeoutMs: number): Pro
throw new Error("Workspace launch was accepted, but expected artifacts were not created in time: " + missingPaths.filter(Boolean).join(", "));
}
async function waitForWorkspaceVideoGeneration(
userDataPath: string,
projectId: string,
timeoutMs: number
): Promise<void> {
const outputRoot = path.join(userDataPath, "projects", projectId, "memory", "output");
const latestProjectAliasPath = path.join(outputRoot, "_latest_project_path.txt");
const started = Date.now();
let lastStatus = "missing";
while (Date.now() - started < timeoutMs) {
try {
await access(latestProjectAliasPath);
const latestProjectPath = (await readFile(latestProjectAliasPath, "utf8")).trim();
if (latestProjectPath) {
const videoStatusPath = path.join(latestProjectPath, "video_generation_status.json");
try {
const videoStatusRaw = await readFile(videoStatusPath, "utf8");
const videoStatus = JSON.parse(videoStatusRaw) as { status?: unknown; error?: unknown; message?: unknown };
lastStatus = String(videoStatus.status ?? "").trim() || "unknown";
if (lastStatus === "success") {
const finalVideoPath = path.join(latestProjectPath, "latest_seedance_split.mp4");
await access(finalVideoPath);
return;
}
if (["error", "failed"].includes(lastStatus)) {
const detail = String(videoStatus.error ?? videoStatus.message ?? "").trim();
throw new Error("Workspace video generation failed" + (detail ? ": " + detail : "."));
}
} catch (error) {
if (error instanceof Error && error.message.startsWith("Workspace video generation failed")) {
throw error;
}
}
}
} catch {
// Keep polling until the video artifacts are ready or timeout.
}
await delay(1000);
}
throw new Error("Workspace video generation did not finish in time. lastStatus=" + lastStatus);
}
async function waitForRendererTerminalStreamSmoke(window: BrowserWindow, timeoutMs = 40000): Promise<RendererSmokeState | null> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const state = await waitForRendererSmokeState(window, 2000);
const streamSmoke = state?.streamSmoke;
if (streamSmoke && ["completed", "fallback", "error"].includes(String(streamSmoke.phase ?? ""))) {
return state;
}
await delay(250);
}
return null;
}
async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<void> {
const result: Record<string, unknown> = {
startedAt: new Date().toISOString()
......@@ -549,6 +650,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
? "skills"
: "chat";
const smokeProjectId = process.env.QJCLAW_SMOKE_PROJECT_ID?.trim() || "";
const smokeAttachments = resolveSmokeAttachments();
await trace("runSmokeTest:before-send-script");
const sendResult = await window.webContents.executeJavaScript(`(async () => {
const api = window.qjcDesktop;
......@@ -565,6 +667,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const smokeRuntimeApiKey = ${JSON.stringify(process.env.QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY ?? "smoke-runtime-api-key")};
const preferredSkillId = ${JSON.stringify(process.env.QJCLAW_SMOKE_SKILL_ID?.trim() ?? "")};
const smokeViewMode = ${JSON.stringify(smokeViewMode)};
const smokeAttachments = ${JSON.stringify(smokeAttachments)};
if (smokeBaseUrl) {
const current = await api.config.load();
await api.config.save({
......@@ -707,12 +810,14 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
: await actions.sendConversationPrompt(${JSON.stringify(prompt)}, {
mode: ${JSON.stringify(smokeViewMode)},
projectId: ${JSON.stringify(smokeProjectId)},
skillId: selectedSkillId || undefined
skillId: selectedSkillId || undefined,
attachments: smokeAttachments.length ? smokeAttachments : undefined
});
return {
prompt: ${JSON.stringify(prompt)},
smokeViewMode: ${JSON.stringify(smokeViewMode)},
smokeProjectId: ${JSON.stringify(smokeProjectId)},
smokeAttachments,
runtimeCloudStatus,
runtimeCloudFetch,
runtimeCloudFetchError,
......@@ -786,9 +891,25 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
await waitForSmokePaths(smokeWaitForPaths, smokeWaitForPathsTimeoutMs);
await trace("runSmokeTest:artifact-wait-complete");
}
if (
process.env.QJCLAW_SMOKE_WAIT_FOR_EXPECTED_STAGE === "video_generated"
&& process.env.QJCLAW_SMOKE_PROJECT_ID
) {
await trace("runSmokeTest:video-artifact-wait-start");
await waitForWorkspaceVideoGeneration(
app.getPath("userData"),
process.env.QJCLAW_SMOKE_PROJECT_ID,
smokeWaitForPathsTimeoutMs
);
await trace("runSmokeTest:video-artifact-wait-complete");
}
}
await delay(1500);
const finalState = await waitForRendererSmokeState(window, 5000);
const finalState = (
process.env.QJCLAW_SMOKE_WAIT_FOR_EXPECTED_STAGE === "video_generated"
? await waitForRendererTerminalStreamSmoke(window, 30000)
: null
) ?? await waitForRendererSmokeState(window, 5000);
const streamSmoke = finalState?.streamSmoke ?? streamState.streamSmoke;
await trace("runSmokeTest:before-post-stream-script");
const postStreamResult = await window.webContents.executeJavaScript(`(async () => {
......
......@@ -3,12 +3,14 @@ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import type { ProjectResolvedAttachment } from "@qjclaw/shared-types";
interface RunnerInput {
vendorPackageDir: string;
projectRoot: string;
sessionId: string;
prompt: string;
attachments?: ProjectResolvedAttachment[];
runId?: string;
}
......@@ -150,6 +152,23 @@ function createRuntimeSessionIdentity(sessionId: string): { sessionId: string; s
};
}
function renderAttachmentPrelude(projectRoot: string, attachments: ProjectResolvedAttachment[] | undefined): string {
if (!attachments?.length) {
return "";
}
const lines = [
"",
"Project attachments available for this request:",
...attachments.map((attachment, index) => {
const relativePath = attachment.relativeProjectPath || path.relative(projectRoot, attachment.projectPath);
return `${index + 1}. ${attachment.kind} | ${attachment.name} | ${relativePath}`;
}),
""
];
return lines.join("\n");
}
async function main(): Promise<void> {
const inputRaw = await readStdin();
const input = JSON.parse(inputRaw) as RunnerInput;
......@@ -219,9 +238,10 @@ async function main(): Promise<void> {
};
const runtimeSession = createRuntimeSessionIdentity(input.sessionId);
const message = `${input.prompt}${renderAttachmentPrelude(input.projectRoot, input.attachments)}`.trim();
try {
const result = await agentModule.t({
message: input.prompt,
message,
sessionId: runtimeSession.sessionId,
sessionKey: runtimeSession.sessionKey,
workspaceDir: input.projectRoot,
......
......@@ -12,6 +12,9 @@ export interface RuntimeCloudApiTarget {
const CONFIG_DIR = "config";
const CONFIG_FILE = "app-config.json";
const DEFAULT_RUNTIME_CLOUD_API_BASE_URL = "https://spb-bp1wv2oe0hvfvi98.supabase.opentrust.net/functions/v1";
const DEPRECATED_RUNTIME_CLOUD_API_BASE_URLS = new Set([
"https://xuphfkscoptnjoaecbvn.supabase.co/functions/v1"
]);
const UI_ROUTE_NAMES = new Set([
"chat",
"control",
......@@ -95,6 +98,14 @@ function normalizeRuntimeCloudApiBaseUrl(raw?: string): string {
return normalizeCloudApiBaseUrl(raw ?? "");
}
function migrateDeprecatedRuntimeCloudApiBaseUrl(raw?: string): string {
const normalized = normalizeRuntimeCloudApiBaseUrl(raw);
if (normalized && DEPRECATED_RUNTIME_CLOUD_API_BASE_URLS.has(normalized)) {
return DEFAULT_RUNTIME_CLOUD_API_BASE_URL;
}
return normalized;
}
function resolveRuntimeCloudApiTarget(raw?: string): RuntimeCloudApiTarget {
const normalized = normalizeRuntimeCloudApiBaseUrl(raw);
if (normalized) {
......@@ -139,7 +150,7 @@ export class AppConfigService {
workspacePath: input.workspacePath,
gatewayUrl: normalizeGatewayUrl(input.gatewayUrl),
cloudApiBaseUrl: normalizeCloudApiBaseUrl(input.cloudApiBaseUrl),
runtimeCloudApiBaseUrl: normalizeRuntimeCloudApiBaseUrl(input.runtimeCloudApiBaseUrl),
runtimeCloudApiBaseUrl: migrateDeprecatedRuntimeCloudApiBaseUrl(input.runtimeCloudApiBaseUrl),
runtimeMode: normalizeRuntimeMode(input.runtimeMode)
};
......@@ -185,7 +196,7 @@ export class AppConfigService {
workspacePath: config.workspacePath ?? this.userDataPath,
gatewayUrl: normalizeGatewayUrl(config.gatewayUrl ?? `ws://127.0.0.1:${config.gatewayPort ?? 18789}`),
cloudApiBaseUrl: normalizeCloudApiBaseUrl(config.cloudApiBaseUrl ?? process.env.QJCLAW_CLOUD_API_BASE_URL ?? ""),
runtimeCloudApiBaseUrl: normalizeRuntimeCloudApiBaseUrl(config.runtimeCloudApiBaseUrl),
runtimeCloudApiBaseUrl: migrateDeprecatedRuntimeCloudApiBaseUrl(config.runtimeCloudApiBaseUrl),
runtimeMode: normalizeRuntimeMode(config.runtimeMode ?? process.env.QJCLAW_RUNTIME_MODE)
};
}
......
......@@ -242,6 +242,49 @@ function classifyRuntimeCloudError(message: string): string {
return message;
}
function mergeAdditionalModelProvidersFromEnv(config: Record<string, unknown>): Record<string, unknown> {
const rawProviders = process.env.QJCLAW_EXTRA_MODEL_PROVIDERS_JSON?.trim();
if (!rawProviders) {
return config;
}
let parsed: unknown;
try {
parsed = JSON.parse(rawProviders);
} catch (error) {
console.warn("[runtime-cloud]", "failed to parse QJCLAW_EXTRA_MODEL_PROVIDERS_JSON", error);
return config;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return config;
}
const nextConfig = cloneJson(config);
const modelsSection = asRecord(nextConfig.models);
const providers = asRecord(modelsSection.providers);
for (const [providerKey, providerValue] of Object.entries(parsed)) {
if (providerKey.trim().length === 0) {
continue;
}
const incomingProvider = asRecord(providerValue);
if (Object.keys(incomingProvider).length === 0) {
continue;
}
const existingProvider = asRecord(providers[providerKey]);
providers[providerKey] = {
...existingProvider,
...incomingProvider
};
}
modelsSection.mode = "merge";
modelsSection.providers = providers;
nextConfig.models = modelsSection;
return nextConfig;
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
}
......@@ -961,7 +1004,7 @@ export class OpenClawConfigClient {
agentsSection.defaults = agentDefaults;
nextConfig.agents = agentsSection;
return nextConfig;
return mergeAdditionalModelProvidersFromEnv(nextConfig);
}
}
export class AuthClient {
......
......@@ -25,6 +25,10 @@ export interface LocalOpenClawGatewayConfig {
}
export async function loadLocalOpenClawGatewayConfig(): Promise<LocalOpenClawGatewayConfig | null> {
if (process.env.QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY === "1") {
return null;
}
const sourcePath = path.join(os.homedir(), ".openclaw", "openclaw.json");
try {
......
......@@ -4,7 +4,7 @@ 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 { createRequire } from "node:module";
import type { AppConfigService } from "./app-config.js";
import type { ProjectStoreService } from "./project-store.js";
import type { RemoteSkillAsset } from "./skill-store.js";
......@@ -31,6 +31,7 @@ interface BundleProjectMetadata {
projectName: string;
version?: string;
description?: string;
declaredBoundSkillIds: string[];
projectSourceRoot: string;
sharedSourceRoot: string;
}
......@@ -62,6 +63,10 @@ interface RetryableFsOperationLogContext {
projectId?: string;
}
interface RemoveReplacementPathOptions {
requireMissing?: boolean;
}
interface MaterializedBundleTransaction {
skillIds: string[];
sharedSkillEntries: string[];
......@@ -97,6 +102,15 @@ const MAX_REDIRECTS = 5;
const BUNDLE_REQUEST_IDLE_TIMEOUT_MS = 30_000;
const WINDOWS_RETRYABLE_FS_ERROR_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]);
const WINDOWS_FS_RETRY_DELAYS_MS = [50, 120, 250, 500, 1000] as const;
const requireFromHere = createRequire(
typeof __filename === "string"
? __filename
: path.join(process.cwd(), "apps", "desktop", "src", "main", "services", "project-bundle.ts")
);
type ExtractZipFn = (zipPath: string, options: { dir: string }) => Promise<void>;
let extractZipLoaderPromise: Promise<ExtractZipFn> | null = null;
function nowIso(): string {
return new Date().toISOString();
......@@ -106,20 +120,6 @@ function normalizeBundleName(fileName: string): string {
return fileName.replace(/\.zip$/i, "").trim() || "project-bundle";
}
function slugify(value: string): string {
const ascii = value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
if (ascii) {
return ascii;
}
return `project-${createHash("sha1").update(value).digest("hex").slice(0, 10)}`;
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
......@@ -165,6 +165,52 @@ function uniqueStrings(values: Iterable<string>): string[] {
return [...new Set([...values].map((value) => value.trim()).filter(Boolean))].sort((left, right) => left.localeCompare(right, "en"));
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return uniqueStrings(value.map((entry) => typeof entry === "string" ? entry : ""));
}
function normalizeExtractZipModule(moduleValue: unknown): ExtractZipFn | null {
if (typeof moduleValue === "function") {
return moduleValue as ExtractZipFn;
}
if (moduleValue && typeof moduleValue === "object" && typeof (moduleValue as { default?: unknown }).default === "function") {
return (moduleValue as { default: ExtractZipFn }).default;
}
return null;
}
async function loadExtractZip(): Promise<ExtractZipFn> {
if (extractZipLoaderPromise) {
return extractZipLoaderPromise;
}
extractZipLoaderPromise = Promise.resolve().then(() => {
const fallbackCandidates = [
"extract-zip",
path.resolve(process.cwd(), "apps", "desktop", "node_modules", "extract-zip"),
path.resolve(process.cwd(), "node_modules", "extract-zip")
];
for (const candidate of fallbackCandidates) {
try {
const loaded = normalizeExtractZipModule(requireFromHere(candidate));
if (loaded) {
return loaded;
}
} catch {
// Keep trying fallback resolution candidates.
}
}
throw new Error("Unable to resolve extract-zip for project bundle extraction.");
});
return extractZipLoaderPromise;
}
function normalizeHeaderValue(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) {
return value[0]?.trim() || undefined;
......@@ -200,6 +246,20 @@ function isRetryableWindowsFsError(error: unknown): boolean {
return process.platform === "win32" && WINDOWS_RETRYABLE_FS_ERROR_CODES.has(getFsErrorCode(error) ?? "");
}
function isRecoverableRemoteBundleAssetError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const message = error.message;
return message.startsWith("Project bundle freshness probe failed")
|| message.startsWith("Project bundle freshness probe timed out")
|| message.startsWith("Project bundle download failed")
|| message.startsWith("Project bundle download timed out")
|| message === "Project bundle probe redirected too many times."
|| message === "Project bundle download redirected too many times.";
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
......@@ -256,6 +316,7 @@ export class ProjectBundleService {
const currentManifest = (await readJsonFile<Record<string, BundleManifestRecord>>(manifestPath)) ?? {};
const nextManifest: Record<string, BundleManifestRecord> = {};
const seenBundleKeys = new Set<string>();
let skippedAssetCount = 0;
logBundle("bundle.asset_filter.result", {
remoteSkillCount: remoteSkills.length,
bundleAssetCount: bundleAssets.length
......@@ -282,7 +343,16 @@ export class ProjectBundleService {
bundleKey,
source: sanitizeUrl(new URL(asset.downloadUrl))
});
const nextRecord = await this.resolveNextManifestRecord(workspaceRoot, asset, configVersion, currentRecord);
const nextRecord = await this.resolveNextManifestRecordOrFallback(
workspaceRoot,
asset,
configVersion,
currentRecord
);
if (!nextRecord) {
skippedAssetCount += 1;
continue;
}
if (nextManifest[nextRecord.projectId]) {
throw new Error(`Project bundle sync resolved duplicate projectId ${nextRecord.projectId}.`);
......@@ -301,6 +371,7 @@ export class ProjectBundleService {
action: _action ?? "unknown",
configVersion,
projectCount: Object.keys(nextManifest).length,
skippedAssetCount,
elapsedMs: Date.now() - startedAt
});
};
......@@ -344,7 +415,7 @@ export class ProjectBundleService {
return await this.downloadAndInstallBundle(workspaceRoot, asset, configVersion);
} catch (error) {
const hasLocalProjectCache = await this.hasUsableLocalProjectCache(workspaceRoot, currentRecord);
if (!hasLocalProjectCache) {
if (!hasLocalProjectCache || !currentRecord) {
throw error;
}
logBundle("bundle.reuse.check", {
......@@ -362,7 +433,7 @@ export class ProjectBundleService {
freshnessProbe = await this.probeRemoteBundle(new URL(asset.downloadUrl!));
} catch (error) {
const hasLocalProjectCache = await this.hasUsableLocalProjectCache(workspaceRoot, currentRecord);
if (!hasLocalProjectCache) {
if (!hasLocalProjectCache || !currentRecord) {
throw error;
}
logBundle("bundle.reuse.check", {
......@@ -390,6 +461,52 @@ export class ProjectBundleService {
return this.updateManifestRecordFromProbe(currentRecord, asset, configVersion, freshnessProbe);
}
private async resolveNextManifestRecordOrFallback(
workspaceRoot: string,
asset: RemoteSkillAsset,
configVersion: string | undefined,
currentRecord: BundleManifestRecord | undefined
): Promise<BundleManifestRecord | null> {
try {
return await this.resolveNextManifestRecord(workspaceRoot, asset, configVersion, currentRecord);
} catch (error) {
if (!isRecoverableRemoteBundleAssetError(error)) {
throw error;
}
const hasLocalProjectCache = await this.hasUsableLocalProjectCache(workspaceRoot, currentRecord);
const decision = hasLocalProjectCache && currentRecord ? "reuse-local-cache" : "skip-asset";
const errorMessage = error instanceof Error ? error.message : String(error);
logBundle("bundle.asset.recoverable_error", {
skillId: asset.skillId,
source: sanitizeUrl(new URL(asset.downloadUrl!)),
decision,
projectId: currentRecord?.projectId,
error: errorMessage
});
await this.startupLogger?.warn(
"project-bundle",
"asset.recoverable_error",
"Recoverable remote project bundle error encountered; continuing startup.",
{
skillId: asset.skillId,
source: sanitizeUrl(new URL(asset.downloadUrl!)),
decision,
projectId: currentRecord?.projectId,
error: errorMessage
}
);
if (hasLocalProjectCache && currentRecord) {
return {
...currentRecord,
updatedAt: nowIso()
};
}
return null;
}
}
private async hasUsableLocalProjectCache(
workspaceRoot: string,
record: BundleManifestRecord | undefined
......@@ -561,6 +678,7 @@ export class ProjectBundleService {
projectName: metadata.projectName,
version: metadata.version ?? configVersion,
boundSkillIds: materialized.skillIds,
declaredBoundSkillIds: metadata.declaredBoundSkillIds,
description: metadata.description ?? asset.description,
ready: true
});
......@@ -605,6 +723,7 @@ export class ProjectBundleService {
private async resolveBundleMetadata(sourceRoot: string, asset: RemoteSkillAsset, configVersion?: string): Promise<BundleProjectMetadata> {
const sourceProjectsDir = path.join(sourceRoot, "projects");
const bundleName = normalizeBundleName(asset.fileName ?? asset.name);
const bundleLabel = asset.fileName ?? asset.skillId;
if (await pathExists(sourceProjectsDir)) {
const projectEntries = (await readdir(sourceProjectsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory());
if (projectEntries.length !== 1) {
......@@ -612,27 +731,41 @@ export class ProjectBundleService {
}
const projectEntry = projectEntries[0];
const projectSourceRoot = path.join(sourceProjectsDir, projectEntry.name);
const projectJson = await this.readProjectJson(path.join(projectSourceRoot, "project.json"));
const projectId = projectJson?.id?.trim() || projectEntry.name.trim() || slugify(bundleName);
const projectName = projectJson?.name?.trim() || projectEntry.name.trim() || bundleName;
const projectJson = await this.readRequiredProjectJson(path.join(projectSourceRoot, "project.json"), bundleLabel);
const projectId = projectJson.id?.trim();
if (!projectId) {
throw new Error(`Project bundle ${bundleLabel} is missing required project id in project.json.`);
}
const projectName = projectJson.name?.trim();
if (!projectName) {
throw new Error(`Project bundle ${bundleLabel} is missing required project name in project.json.`);
}
return {
projectId,
projectName,
version: projectJson?.version?.trim() || configVersion,
description: projectJson?.description?.trim() || asset.description,
declaredBoundSkillIds: normalizeStringArray(projectJson.boundSkillIds),
projectSourceRoot,
sharedSourceRoot: sourceRoot
};
}
const rootProjectJson = await this.readProjectJson(path.join(sourceRoot, "project.json"));
const projectId = rootProjectJson?.id?.trim() || slugify(bundleName);
const projectName = rootProjectJson?.name?.trim() || bundleName;
const rootProjectJson = await this.readRequiredProjectJson(path.join(sourceRoot, "project.json"), bundleLabel);
const projectId = rootProjectJson.id?.trim();
if (!projectId) {
throw new Error(`Project bundle ${bundleLabel} is missing required project id in project.json.`);
}
const projectName = rootProjectJson.name?.trim();
if (!projectName) {
throw new Error(`Project bundle ${bundleLabel} is missing required project name in project.json.`);
}
return {
projectId,
projectName,
version: rootProjectJson?.version?.trim() || configVersion,
description: rootProjectJson?.description?.trim() || asset.description,
declaredBoundSkillIds: normalizeStringArray(rootProjectJson.boundSkillIds),
projectSourceRoot: sourceRoot,
sharedSourceRoot: sourceRoot
};
......@@ -673,7 +806,11 @@ export class ProjectBundleService {
}
}
private async removeReplacementPath(targetPath: string, projectId?: string): Promise<void> {
private async removeReplacementPath(
targetPath: string,
projectId?: string,
options?: RemoveReplacementPathOptions
): Promise<void> {
await this.withWindowsFsRetry(
{
action: "remove-path",
......@@ -684,20 +821,51 @@ export class ProjectBundleService {
await rm(targetPath, { recursive: true, force: true });
}
).catch(() => undefined);
if (options?.requireMissing && await pathExists(targetPath)) {
throw new Error(`Project bundle replacement target could not be cleared: ${targetPath}`);
}
}
private async moveReplacementPath(sourcePath: string, targetPath: string, action: string, projectId?: string): Promise<void> {
await this.withWindowsFsRetry(
{
try {
await this.withWindowsFsRetry(
{
action,
path: sourcePath,
backupPath: targetPath,
projectId
},
async () => {
await rename(sourcePath, targetPath);
}
);
} catch (error) {
if (action === "backup-rename" || !isRetryableWindowsFsError(error)) {
throw error;
}
const errorCode = getFsErrorCode(error);
logBundle("bundle.fs.apply_fallback", {
action,
path: sourcePath,
backupPath: targetPath,
projectId
},
async () => {
await rename(sourcePath, targetPath);
}
);
projectId,
errorCode,
error: error instanceof Error ? error.message : String(error)
});
await this.startupLogger?.warn("project-bundle", "apply.fallback", "Falling back to copy-based project bundle replacement.", {
action,
path: sourcePath,
backupPath: targetPath,
projectId,
errorCode,
error: error instanceof Error ? error.message : String(error)
});
await this.removeReplacementPath(targetPath, projectId, { requireMissing: true });
await cp(sourcePath, targetPath, { recursive: true, force: true });
await this.removeReplacementPath(sourcePath, projectId, { requireMissing: true });
}
}
private async backupReplacementTarget(
......@@ -738,7 +906,7 @@ export class ProjectBundleService {
error: error instanceof Error ? error.message : String(error)
});
await cp(operation.targetPath, operation.backupPath, { recursive: true, force: true });
await this.removeReplacementPath(operation.targetPath, projectId);
await this.removeReplacementPath(operation.targetPath, projectId, { requireMissing: true });
operation.backupMode = "copy";
}
}
......@@ -883,7 +1051,7 @@ export class ProjectBundleService {
description: metadata.description ?? existing?.description,
updatedAt: nowIso(),
ready: true,
boundSkillIds: []
boundSkillIds: metadata.declaredBoundSkillIds
});
await mkdir(path.join(stagedProjectRoot, "memory"), { recursive: true });
......@@ -979,6 +1147,25 @@ export class ProjectBundleService {
return readJsonFile<ProjectJsonShape>(filePath);
}
private async readRequiredProjectJson(filePath: string, bundleLabel: string): Promise<ProjectJsonShape> {
let raw: string;
try {
raw = await readFile(filePath, "utf8");
} catch {
throw new Error(`Project bundle ${bundleLabel} is missing required project.json at ${filePath}.`);
}
try {
const parsed = JSON.parse(raw.replace(/^\uFEFF/, "")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("project.json must contain a JSON object.");
}
return parsed as ProjectJsonShape;
} catch (error) {
throw new Error(`Project bundle ${bundleLabel} has invalid project.json at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async resolveArchiveContentRoot(extractPath: string): Promise<string> {
const entries = await readdir(extractPath, { withFileTypes: true });
const directories = entries.filter((entry) => entry.isDirectory());
......@@ -995,6 +1182,7 @@ export class ProjectBundleService {
private async extractZip(zipPath: string, destinationPath: string): Promise<void> {
await mkdir(destinationPath, { recursive: true });
const extractZip = await loadExtractZip();
await extractZip(zipPath, { dir: destinationPath });
}
......
......@@ -704,19 +704,11 @@ export class ProjectStoreService {
const project = await this.getProjectById(projectId);
const projectRecord = await this.readProjectRecord(project.id);
const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []);
const merged = new Map<string, WorkspaceSkillSummary>();
for (const skill of await this.listWorkspaceSkills(project.name, project.updatedAt, boundSkillIds)) {
merged.set(skill.id, skill);
if (project.isBuiltinHome) {
return (await this.listCuratedGenericSkills(project.updatedAt)).sort(compareSkills);
}
for (const skill of await this.listCuratedGenericSkills(project.updatedAt)) {
if (!merged.has(skill.id)) {
merged.set(skill.id, skill);
}
}
return [...merged.values()].sort(compareSkills);
return (await this.listWorkspaceSkills(project.name, project.updatedAt, boundSkillIds)).sort(compareSkills);
}
async getCurrentProjectSkillTarget(skillId: string): Promise<SkillExecutionTarget | undefined> {
......@@ -729,6 +721,9 @@ export class ProjectStoreService {
const projectRecord = await this.readProjectRecord(projectId);
const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []);
if (CURATED_GENERIC_SKILL_IDS.has(normalizedSkillId)) {
if (!isBuiltinHomeProjectId(projectId)) {
return undefined;
}
return this.resolveCuratedGenericSkillTarget(normalizedSkillId);
}
......@@ -766,17 +761,50 @@ export class ProjectStoreService {
projectName: string;
version?: string;
boundSkillIds?: string[];
declaredBoundSkillIds?: string[];
description?: string;
ready?: boolean;
}): Promise<ProjectSummary> {
const projectId = input.projectId?.trim() || slugify(input.projectName);
const bundleProjectRecord = await this.readProjectRecord(projectId);
const availableWorkspaceSkillIds = await this.readWorkspaceSkillIds();
const materializedBoundSkillIds = uniqueSkillIds(input.boundSkillIds ?? []);
const declaredBoundSkillIds = uniqueSkillIds(input.declaredBoundSkillIds ?? bundleProjectRecord?.boundSkillIds ?? []);
const requestedBoundSkillIds = declaredBoundSkillIds.length > 0
? declaredBoundSkillIds
: materializedBoundSkillIds;
const validBoundSkillIds = requestedBoundSkillIds.filter((skillId) => availableWorkspaceSkillIds.has(skillId));
const missingBoundSkillIds = requestedBoundSkillIds.filter((skillId) => !availableWorkspaceSkillIds.has(skillId));
const undeclaredMaterializedSkillIds = declaredBoundSkillIds.length > 0
? materializedBoundSkillIds.filter((skillId) => !declaredBoundSkillIds.includes(skillId))
: [];
if (missingBoundSkillIds.length > 0) {
console.warn("[project-store] bundle project declared missing bound skills", {
projectId,
missingBoundSkillIds
});
}
if (undeclaredMaterializedSkillIds.length > 0) {
console.warn("[project-store] bundle project materialized undeclared skills", {
projectId,
undeclaredMaterializedSkillIds
});
}
const lastError = missingBoundSkillIds.length > 0
? `Missing bound skills: ${missingBoundSkillIds.join(", ")}`
: undefined;
const nextReady = (input.ready ?? bundleProjectRecord?.ready ?? true)
&& (requestedBoundSkillIds.length === 0 || validBoundSkillIds.length > 0);
const project = await this.upsertProject({
...(bundleProjectRecord ?? {}),
id: projectId,
name: input.projectName,
description: input.description,
version: input.version,
boundSkillIds: input.boundSkillIds,
ready: input.ready ?? true
description: input.description ?? bundleProjectRecord?.description,
version: input.version ?? bundleProjectRecord?.version,
boundSkillIds: validBoundSkillIds,
ready: nextReady,
lastError
});
const activeProject = await this.getActiveProject().catch(() => null);
if (!activeProject || activeProject.isBuiltinHome) {
......@@ -1045,6 +1073,17 @@ export class ProjectStoreService {
return skills;
}
private async readWorkspaceSkillIds(): Promise<Set<string>> {
const workspaceRoot = await this.getWorkspaceRoot();
const skillsRoot = path.join(workspaceRoot, SKILLS_DIR);
const dirEntries = await readdir(skillsRoot, { withFileTypes: true }).catch(() => []);
return new Set(
dirEntries
.filter((entry) => entry.isDirectory())
.map((entry) => sanitizeSkillId(entry.name))
);
}
private async listCuratedGenericSkills(projectUpdatedAt: string): Promise<WorkspaceSkillSummary[]> {
const genericRoot = this.resolveCuratedSkillsRoot();
if (!genericRoot) {
......
......@@ -3,13 +3,14 @@ import { spawn } from "node:child_process";
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";
import type { ChatMessage, ProjectResolvedAttachment } from "@qjclaw/shared-types";
interface ProjectWorkspaceExecutionInput {
sessionId: string;
projectRoot: string;
prompt: string;
userPrompt?: string;
attachments?: ProjectResolvedAttachment[];
runId?: string;
}
......@@ -184,7 +185,9 @@ async function resolveProjectAutomationCommand(
prompt: input.userPrompt?.trim() || input.prompt,
preparedPrompt: input.prompt,
projectRoot: input.projectRoot,
sessionId: input.sessionId
sessionId: input.sessionId,
firstAttachmentPath: input.attachments?.[0]?.projectPath ?? "",
attachmentPathsJson: JSON.stringify(input.attachments?.map((attachment) => attachment.projectPath) ?? [])
};
const scriptPath = path.resolve(projectRoot, renderTemplate(script, variables));
......@@ -271,6 +274,8 @@ export class ProjectWorkspaceExecutorService {
path.dirname(paths.pythonExecutable),
process.env.PATH ?? ""
].filter(Boolean).join(path.delimiter),
QJC_PROJECT_ATTACHMENTS_JSON: JSON.stringify(input.attachments ?? []),
QJC_PROJECT_MAIN_IMAGE: input.attachments?.find((attachment) => attachment.kind === "image")?.projectPath ?? "",
...(automationCommand?.env ?? {})
};
const spawnOptions = {
......@@ -404,6 +409,7 @@ export class ProjectWorkspaceExecutorService {
projectRoot: input.projectRoot,
sessionId: input.sessionId,
prompt: input.prompt,
attachments: input.attachments ?? [],
runId
});
if (!automationCommand) {
......
......@@ -152,7 +152,8 @@ export class RuntimeSkillBridgeService {
const runtimeSkillName = selected
? `${MANAGED_SKILL_PREFIX}${slugify(sourceName)}`
: sourceName;
const runtimeSkillDir = path.join(skillsRoot, sanitizeRuntimeDirName(target.skillId || target.name || sourceName));
const runtimeSkillDirName = `${MANAGED_SKILL_PREFIX}${slugify(target.skillId || target.name || sourceName)}`;
const runtimeSkillDir = path.join(skillsRoot, sanitizeRuntimeDirName(runtimeSkillDirName));
const materializedContent = applyRuntimeSkillName(content, runtimeSkillName);
const sourceSkillDir = path.dirname(target.localPath);
......
......@@ -7,4 +7,10 @@ lxml==5.3.0
pypdf==5.4.0
python-docx==1.1.2
charset-normalizer==3.4.1
pyyaml==6.0.2
\ No newline at end of file
pyyaml==6.0.2
pillow==11.3.0
python-dotenv==1.2.2
greenlet==3.1.1
playwright==1.50.0
edge-tts==7.2.3
imageio-ffmpeg==0.6.0
......@@ -10,4 +10,8 @@ charset-normalizer==3.4.1
pyyaml==6.0.2
pillow==11.3.0
python-dotenv==1.2.2
greenlet==3.1.1
playwright==1.50.0
edge-tts==7.2.3
imageio-ffmpeg==0.6.0
qiniu==7.17.0
......@@ -10,8 +10,12 @@
- `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`
- `xhs-expert-cloud-bundle-smoke.ps1` packages `workspace/xhs` as a zip-backed employee-config bundle, injects the fixed `volces` image provider into the managed runtime config for XHS generation, 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`
- `douyin-expert-cloud-bundle-smoke.ps1` packages `workspace/douyin` as a zip-backed employee-config bundle, preserves two extra fixture experts so the experts rail exceeds two items, switches to the Douyin expert, and sends `帮我做一个关于防晒喷雾的抖音视频文案` through the experts view; `pnpm smoke:douyin-expert-cloud-bundle`
- `local-project-package-smoke.ps1` copies the current local `workspace/xhs` and `workspace/douyin` sources with `bundlePackaging.excludePaths` applied, runs package-level workspace-entry smoke checks from the copied package roots, verifies the XHS path/publish behavior, verifies injected XHS image-provider resolution plus topic extraction cleanup, and verifies the Douyin multi-turn intake flow; `pnpm smoke:local-project-package`
- `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`, injects the fixed `volces` image provider plus `XHS_IMAGE_PROVIDER/XHS_IMAGE_MODEL`, 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`
- `douyin-expert-manual-launch.ps1` packages `workspace/douyin` into a local zip bundle with `bundlePackaging.excludePaths` applied, 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/douyin-expert-manual-launch.ps1`
- `douyin-expert-live-run.ps1` packages `workspace/douyin` into a zip-backed expert bundle, sends an experts-page prompt with a test image attachment, and validates that Douyin writes fresh preview artifacts such as `_latest_workflow_summary.json`, `video_request.json`, `storyboard.json`, and `omnihuman_prompt.txt` inside the synced project; `powershell -ExecutionPolicy Bypass -File build/scripts/douyin-expert-live-run.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`
......
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 Write-Base64File {
param(
[string]$FilePath,
[string]$Base64
)
$parent = Split-Path -Parent $FilePath
if ($parent) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
[System.IO.File]::WriteAllBytes($FilePath, [System.Convert]::FromBase64String($Base64))
}
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
}
if ($entry.PSIsContainer -and $entry.Name -match '^tmp[a-z0-9]{6,}$') {
Write-Warning "Skipping transient temp directory from bundle source: $($entry.FullName)"
continue
}
try {
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationRoot $entry.Name) -Recurse -Force
}
catch [System.UnauthorizedAccessException] {
Write-Warning "Skipping inaccessible bundle source entry: $($entry.FullName)"
continue
}
}
}
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\douyin-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 'douyin-expert-cloud-bundle.zip'
$bundleFileName = 'douyin-expert-cloud-bundle.zip'
$bundleProjectId = 'douyin'
$bundleProjectName = 'Douyin Automation'
$bundleSkillId = 'douyin-project-bundle'
$bundleConfigVersion = '2026-04-10T00:00:00.000Z'
$expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileName"
$expertPrompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('5biu5oiR5YGa5LiA5Liq5YWz5LqO6Ziy5pmS5Za36Zu+55qE5oqW6Z+z6KeG6aKR5paH5qGI'))
$expectedExpertIds = @('browser-expert-smoke', 'douyin', 'xhs-expert-smoke')
$electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1'
$attachmentFixturePath = Join-Path $BaseOutputDir 'fixtures\smoke-image.png'
$expectedAttachmentDir = Join-Path $userDataPath 'projects\douyin\inputs\images\main'
$attachmentFixtureName = 'smoke-image.png'
$attachmentPayload = @(
@{
kind = 'image'
name = $attachmentFixtureName
mimeType = 'image/png'
localPath = $attachmentFixturePath
}
)
$douyinSourceCandidates = @(
(Join-Path $repoRoot 'workspace\douyin')
)
$douyinSourceRoot = $douyinSourceCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
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 $douyinSourceRoot) {
throw "Douyin workspace source was not found in any expected location: $($douyinSourceCandidates -join ', ')"
}
Write-Base64File -FilePath $attachmentFixturePath -Base64 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACOSURBVHhe7dAxAQAwDITA+tf4XlIDOIDhFkbetjNrAEWTBlA0aQBFkwZQNGkARZMGUDRpAEWTBlA0aQBFkwZQNGkARZMGUDRpAEWTBlA0aQBFkwZQNGkARZMGUDRpAEWTBlA0aQBFkwZQNGkARZMGUDRpAEWTBlA0aQBFkwZQNGkARZMGUDRpAEUT+YDdB6OSM1gxG4BEAAAAAElFTkSuQmCC'
Copy-ProjectBundleSource -SourceRoot $douyinSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'douyin')
if (Test-Path $bundleZipPath) {
Remove-Item $bundleZipPath -Force
}
Compress-Archive -Path (Join-Path $bundleSourceRoot 'douyin') -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 'xhs-expert-smoke' -ProjectName 'XHS Expert Fixture' -Platform 'xiaohongshu' -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 = 'Douyin Project Bundle'
$env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Zip-backed Douyin project bundle for expert-page smoke validation.'
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion
$env:QJCLAW_SMOKE_ATTACHMENTS_JSON = (ConvertTo-Json -InputObject @($attachmentPayload) -Depth 5 -Compress)
$env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH = '1'
$env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY = '1'
try {
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'douyin 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.');
}
function sanitizeAttachmentFileComponent(value) {
const trimmed = String(value || '').trim();
const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
return sanitized || 'attachment';
}
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.douyin;
if (!manifestRecord || typeof manifestRecord !== 'object') {
throw new Error('Bundle manifest does not contain douyin.');
}
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 || '') !== 'douyin') {
throw new Error('Smoke request did not target the douyin expert.');
}
if (String(sendResult.prompt || '') !== expectedPrompt) {
throw new Error('Smoke prompt mismatch.');
}
if (String(workspaceSummary.currentProjectId || '') !== 'douyin') {
throw new Error('Final active project was not douyin: ' + 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:douyin:')) {
throw new Error('Expert smoke session did not bind to douyin: ' + String(sendResult.sessionId || ''));
}
const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || ''))
: [];
const workspaceLaunchAccepted = statusLabels.some((label) => label.includes('Launching project workspace'));
if (String(streamSmoke.phase || '') !== 'completed' && !workspaceLaunchAccepted) {
throw new Error('Expert smoke stream did not complete: ' + String(streamSmoke.phase || ''));
}
if (String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || '')) {
throw new Error('Expert smoke unexpectedly selected a skill instead of workspace-entry: ' + String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || ''));
}
if (statusLabels.some((label) => label.includes('Routing to skill'))) {
throw new Error('Expert smoke still routed through a skill: ' + JSON.stringify(statusLabels));
}
if (!workspaceLaunchAccepted) {
throw new Error('Expert smoke did not expose a workspace-entry launch status: ' + JSON.stringify(statusLabels));
}
const smokeAttachments = Array.isArray(sendResult.smokeAttachments) ? sendResult.smokeAttachments : [];
if (smokeAttachments.length !== 1) {
throw new Error('Expected exactly one smoke attachment. actual=' + smokeAttachments.length);
}
const attachmentSessionId = String(sendResult.sessionId || '');
const sessionSlug = sanitizeAttachmentFileComponent(attachmentSessionId.replace(/[:]/g, '-'));
const expectedAttachmentRelativePath = 'inputs/images/main/' + sessionSlug + '-01.png';
const expectedAttachmentPath = path.join(userDataPath, 'projects', 'douyin', ...expectedAttachmentRelativePath.split('/'));
if (!fs.existsSync(expectedAttachmentPath)) {
throw new Error('Materialized attachment file was not found: ' + expectedAttachmentPath);
}
const attachmentStat = fs.statSync(expectedAttachmentPath);
if (!attachmentStat.isFile() || attachmentStat.size < 1) {
throw new Error('Materialized attachment file is empty: ' + expectedAttachmentPath);
}
const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || '');
if (!assistantContent.includes('Project attachments:')) {
throw new Error('Assistant content did not echo project attachments.');
}
if (!assistantContent.includes(expectedAttachmentRelativePath)) {
throw new Error('Assistant content did not reference the materialized attachment path: ' + expectedAttachmentRelativePath);
}
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,
selectedSkillId: sendResult.selectedSkillId || streamSmoke.selectedSkillId || null,
attachmentCount: smokeAttachments.length,
attachmentPath: expectedAttachmentPath,
attachmentRelativePath: expectedAttachmentRelativePath,
statusLabels,
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_SMOKE_ATTACHMENTS_JSON -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY -ErrorAction SilentlyContinue
}
param(
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token',
[int]$SmokePort = 4318,
[string]$SmokeToken = 'smoke-token',
[string]$BaseOutputDir,
[int]$TimeoutSeconds = 900,
[int]$StreamTimeoutSeconds = 720,
[string]$Prompt,
[string]$PromptBase64,
[string]$ExpectedVideoEngine,
[string]$ExpectedStage,
[switch]$SkipMaterializeRuntime,
[switch]$UseExistingCloudConfig,
[string]$EmployeeApiKey,
[string]$RuntimeCloudApiBaseUrl = 'https://spb-bp1wv2oe0hvfvi98.supabase.opentrust.net/functions/v1'
)
$ErrorActionPreference = 'Stop'
$script:Utf8NoBom = New-Object System.Text.UTF8Encoding $false
if ($PSBoundParameters.ContainsKey('PromptBase64') -and -not [string]::IsNullOrWhiteSpace($PromptBase64)) {
$Prompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($PromptBase64))
}
elseif (-not $PSBoundParameters.ContainsKey('Prompt') -or [string]::IsNullOrWhiteSpace($Prompt)) {
$Prompt = [System.Text.Encoding]::UTF8.GetString(
[System.Convert]::FromBase64String('5biu5oiR5YGa5LiA5Liq5YWz5LqO6Ziy5pmS5Za36Zu+55qE5oqW6Z+z6KeG6aKR5paH5qGI77yM5Y+X5LyX5piv6YCa5Yuk5aWz55Sf77yM5YGa5oiQ5pWw5a2X5Lq65Y+j5pKt77yM6aOO5qC857K+6Ie05bm/5ZGK5oSf77yM5pe26ZW/MTXnp5LjgII=')
)
}
function Resolve-ExpectedVideoEngine {
param(
[string]$PromptText,
[string]$ExplicitValue
)
if (-not [string]::IsNullOrWhiteSpace($ExplicitValue)) {
return $ExplicitValue.Trim().ToLowerInvariant()
}
return 'auto'
}
function Resolve-ExpectedStage {
param(
[string]$PromptText,
[string]$ExplicitValue
)
if (-not [string]::IsNullOrWhiteSpace($ExplicitValue)) {
return $ExplicitValue.Trim()
}
return 'preview_ready'
}
$ExpectedVideoEngine = Resolve-ExpectedVideoEngine -PromptText $Prompt -ExplicitValue $ExpectedVideoEngine
$ExpectedStage = Resolve-ExpectedStage -PromptText $Prompt -ExplicitValue $ExpectedStage
$shouldAttachImage = $ExpectedVideoEngine -eq 'omnihuman'
function Write-Utf8File {
param([string]$filePath, [string]$content)
[System.IO.File]::WriteAllText($filePath, $content, $script:Utf8NoBom)
}
function Write-Base64File {
param(
[string]$FilePath,
[string]$Base64
)
$parent = Split-Path -Parent $FilePath
if ($parent) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
[System.IO.File]::WriteAllBytes($FilePath, [System.Convert]::FromBase64String($Base64))
}
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
}
if ($entry.PSIsContainer -and $entry.Name -match '^tmp[a-z0-9]{6,}$') {
Write-Warning "Skipping transient temp directory from bundle source: $($entry.FullName)"
continue
}
try {
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationRoot $entry.Name) -Recurse -Force
}
catch [System.UnauthorizedAccessException] {
Write-Warning "Skipping inaccessible bundle source entry: $($entry.FullName)"
continue
}
}
}
function Reset-DouyinLiveRunBundleState {
param(
[string]$ProjectRoot
)
$outputRoot = Join-Path $ProjectRoot 'memory\output'
if (Test-Path $outputRoot) {
Remove-Item -LiteralPath $outputRoot -Recurse -Force -ErrorAction SilentlyContinue
}
$pendingRoot = Join-Path $ProjectRoot 'memory\output\pending_intake'
if (Test-Path $pendingRoot) {
Remove-Item -LiteralPath $pendingRoot -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null
}
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 Initialize-LiveRunCloudConfig {
param(
[string]$UserDataPath,
[string]$EmployeeApiKey,
[string]$RuntimeCloudApiBaseUrl
)
if ([string]::IsNullOrWhiteSpace($EmployeeApiKey)) {
return
}
$configRoot = Join-Path $UserDataPath 'config'
New-Item -ItemType Directory -Force -Path $configRoot | Out-Null
$appConfigPath = Join-Path $configRoot 'app-config.json'
$secretsPath = Join-Path $configRoot 'secrets.dev.json'
$appConfig = [ordered]@{
setupMode = 'employee-key'
provider = 'openai'
baseUrl = 'https://api.openai.com/v1'
apiKeyConfigured = $true
gatewayTokenConfigured = $false
authTokenConfigured = $false
defaultModel = 'gpt-5.4-mini'
workspacePath = $UserDataPath
gatewayUrl = 'ws://127.0.0.1:18789'
cloudApiBaseUrl = ''
runtimeCloudApiBaseUrl = $RuntimeCloudApiBaseUrl
runtimeMode = 'bundled-runtime'
}
Write-Utf8File $appConfigPath ($appConfig | ConvertTo-Json -Depth 8)
$secretPayload = [ordered]@{
note = 'Development fallback only. Replace this file-based secret store with keytar before shipping.'
apiKey = $EmployeeApiKey
}
Write-Utf8File $secretsPath ($secretPayload | ConvertTo-Json -Depth 6)
}
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\douyin-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 'douyin-expert-live-run.zip'
$bundleFileName = 'douyin-expert-live-run.zip'
$bundleProjectId = 'douyin'
$bundleProjectName = 'Douyin Automation'
$bundleSkillId = 'douyin-project-bundle'
$bundleConfigVersion = '2026-04-13T16:00:00.000Z'
$expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileName"
$expectedExpertIds = @('browser-expert-smoke', 'douyin', 'xhs-expert-smoke')
$electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1'
$attachmentFixturePath = Join-Path $BaseOutputDir 'fixtures\portrait.png'
$expectedAttachmentDir = Join-Path $userDataPath 'projects\douyin\inputs\images\main'
$attachmentFixtureName = 'portrait.png'
$attachmentPayload = @()
if ($shouldAttachImage) {
$attachmentPayload = @(
@{
kind = 'image'
name = $attachmentFixtureName
mimeType = 'image/png'
localPath = $attachmentFixturePath
}
)
}
$latestProjectAliasPath = Join-Path $userDataPath 'projects\douyin\memory\output\_latest_project_path.txt'
$latestSummaryAliasPath = Join-Path $userDataPath 'projects\douyin\memory\output\_latest_workflow_summary.json'
$waitPaths = @($latestProjectAliasPath, $latestSummaryAliasPath)
$douyinSourceCandidates = @(
(Join-Path $repoRoot 'workspace\douyin')
)
$douyinSourceRoot = $douyinSourceCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (Test-Path $BaseOutputDir) {
if ($UseExistingCloudConfig) {
Remove-Item $bundleSourceRoot -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $logsPath -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $smokeOutput -Force -ErrorAction SilentlyContinue
Remove-Item $bundleZipPath -Force -ErrorAction SilentlyContinue
Remove-Item $attachmentFixturePath -Force -ErrorAction SilentlyContinue
}
else {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir, $bundleSourceRoot, $userDataPath, $logsPath | Out-Null
if (-not $douyinSourceRoot) {
throw "Douyin workspace source was not found in any expected location: $($douyinSourceCandidates -join ', ')"
}
if ($shouldAttachImage) {
Write-Base64File -FilePath $attachmentFixturePath -Base64 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACOSURBVHhe7dAxAQAwDITA+tf4XlIDOIDhFkbetjNrAEWTBlA0aQBFkwZQNGkARZMGUDRpAEWTBlA0aQBFkwZQNGkARZMGUDRpAEWTBlA0aQBFkwZQNGkARZMGUDRpAEWTBlA0aQBFkwZQNGkARZMGUDRpAEWTBlA0aQBFkwZQNGkARZMGUDRpAEUT+YDdB6OSM1gxG4BEAAAAAElFTkSuQmCC'
}
Copy-ProjectBundleSource -SourceRoot $douyinSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'douyin')
Reset-DouyinLiveRunBundleState -ProjectRoot (Join-Path $bundleSourceRoot 'douyin')
if (Test-Path $bundleZipPath) {
Remove-Item $bundleZipPath -Force
}
Compress-Archive -Path (Join-Path $bundleSourceRoot 'douyin') -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 'xhs-expert-smoke' -ProjectName 'XHS Expert Fixture' -Platform 'xiaohongshu' -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)
Initialize-LiveRunCloudConfig -UserDataPath $userDataPath -EmployeeApiKey $EmployeeApiKey -RuntimeCloudApiBaseUrl $RuntimeCloudApiBaseUrl
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 = 'Douyin Project Bundle'
$env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Zip-backed Douyin project bundle for expert-page live-run validation.'
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersion
if ($attachmentPayload.Count -gt 0) {
$env:QJCLAW_SMOKE_ATTACHMENTS_JSON = (ConvertTo-Json -InputObject @($attachmentPayload) -Depth 5 -Compress)
}
else {
Remove-Item Env:QJCLAW_SMOKE_ATTACHMENTS_JSON -ErrorAction SilentlyContinue
}
$env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS = ([int]$StreamTimeoutSeconds * 1000).ToString()
$env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH = '1'
$env:QJCLAW_SMOKE_WAIT_FOR_PATHS = [string]::Join([System.IO.Path]::PathSeparator, $waitPaths)
$env:QJCLAW_SMOKE_WAIT_FOR_PATHS_TIMEOUT_MS = ([int]$StreamTimeoutSeconds * 1000).ToString()
$env:QJCLAW_SMOKE_WAIT_FOR_EXPECTED_STAGE = $ExpectedStage
$env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY = '1'
try {
$electronSmokeArguments = @(
'-SmokeOutput', $smokeOutput,
'-SmokePort', $SmokePort,
'-SmokeToken', $SmokeToken,
'-UserDataPath', $userDataPath,
'-LogsPath', $logsPath,
'-RuntimeMode', 'bundled-runtime',
'-ExpectBundledRuntime',
'-ExpectWorkspaceEntry',
'-PreserveUserData',
'-WorkspaceProjectId', $bundleProjectId,
'-WorkspaceProjectName', $bundleProjectName,
'-WorkspaceMarkerFile', 'AGENTS.md',
'-SmokePrompt', $Prompt,
'-SmokeViewMode', 'experts',
'-SmokeProjectId', $bundleProjectId,
'-TimeoutSeconds', $TimeoutSeconds
)
if ($UseExistingCloudConfig) {
$electronSmokeArguments += '-UseExistingCloudConfig'
}
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'douyin expert live run' -ArgumentList $electronSmokeArguments
$summary = & node -e @"
const fs = require('fs');
const path = require('path');
const [
smokeOutput,
userDataPath,
expectedBundleSourceUrl,
expectedBundleConfigVersion,
expectedBundleFileName,
expectedBundleSkillId,
expectedPrompt,
expectedExpertIdsCsv,
expectedVideoEngine,
expectedStage,
expectAttachmentFlag
] = process.argv.slice(1);
const result = JSON.parse(fs.readFileSync(smokeOutput, 'utf8'));
if (!result.ok) {
throw new Error(result.error || 'Live run failed.');
}
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 expectedExpertIds = expectedExpertIdsCsv.split(',').map((value) => value.trim()).filter(Boolean).sort();
const expectAttachment = String(expectAttachmentFlag || '') === '1';
const assistantContent = String(
(sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content)
|| streamSmoke.finalContent
|| streamSmoke.renderedContent
|| ''
);
const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || ''))
: [];
function requireFile(filePath, message) {
if (!fs.existsSync(filePath)) {
throw new Error(message + ': ' + filePath);
}
return filePath;
}
function requirePathInside(rootPath, candidatePath, label) {
const normalizedRoot = path.normalize(rootPath + path.sep);
const normalizedCandidate = path.normalize(String(candidatePath || ''));
if (!normalizedCandidate.startsWith(normalizedRoot)) {
throw new Error(label + ' escaped expected root: ' + normalizedCandidate);
}
return normalizedCandidate;
}
const bundleManifestPath = path.join(userDataPath, 'manifests', 'project-bundles.json');
requireFile(bundleManifestPath, 'Bundle manifest was not produced');
const bundleManifest = JSON.parse(fs.readFileSync(bundleManifestPath, 'utf8'));
const manifestRecord = bundleManifest.douyin;
if (!manifestRecord || typeof manifestRecord !== 'object') {
throw new Error('Bundle manifest does not contain douyin.');
}
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 || '') !== 'douyin') {
throw new Error('Live run request did not target the douyin expert.');
}
if (String(sendResult.prompt || '') !== expectedPrompt) {
throw new Error('Live run prompt mismatch.');
}
if (String(workspaceSummary.currentProjectId || '') !== 'douyin') {
throw new Error('Final active project was not douyin: ' + String(workspaceSummary.currentProjectId || ''));
}
if (expertProjectIds.length !== expectedExpertIds.length || expertProjectIds.some((value, index) => value !== expectedExpertIds[index])) {
throw new Error('Expert list mismatch. 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:douyin:')) {
throw new Error('Live run session did not bind to douyin: ' + String(sendResult.sessionId || ''));
}
const workspaceLaunchAccepted = statusLabels.some((label) => label.includes('Launching project workspace'));
if (String(streamSmoke.phase || '') !== 'completed' && !workspaceLaunchAccepted) {
throw new Error('Live run stream did not complete: ' + String(streamSmoke.phase || ''));
}
if (String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || '')) {
throw new Error('Live run unexpectedly selected a skill instead of workspace-entry: ' + String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || ''));
}
if (statusLabels.some((label) => label.includes('Routing to skill'))) {
throw new Error('Live run still routed through a skill: ' + JSON.stringify(statusLabels));
}
if (!workspaceLaunchAccepted) {
throw new Error('Live run did not expose a workspace-entry launch status: ' + JSON.stringify(statusLabels));
}
if (!assistantContent.includes(String(expectedStage || ''))) {
throw new Error('Assistant content did not report expected stage: ' + String(expectedStage || ''));
}
for (const requiredHint of ['video_request.json', 'storyboard.md', '_latest_workflow_summary.json']) {
if (!assistantContent.includes(requiredHint)) {
throw new Error('Assistant content did not expose required artifact hint: ' + requiredHint);
}
}
const outputRoot = path.join(userDataPath, 'projects', 'douyin', 'memory', 'output');
const latestProjectAliasPath = path.join(outputRoot, '_latest_project_path.txt');
const latestSummaryAliasPath = path.join(outputRoot, '_latest_workflow_summary.json');
requireFile(latestProjectAliasPath, 'Live run did not write _latest_project_path.txt');
requireFile(latestSummaryAliasPath, 'Live run did not write _latest_workflow_summary.json');
const latestProjectPath = String(fs.readFileSync(latestProjectAliasPath, 'utf8')).trim();
if (!latestProjectPath) {
throw new Error('Live run wrote an empty latest project path.');
}
const normalizedLatestProjectPath = requirePathInside(outputRoot, latestProjectPath, 'Latest project path');
if (!fs.existsSync(normalizedLatestProjectPath) || !fs.statSync(normalizedLatestProjectPath).isDirectory()) {
throw new Error('Latest project path is not a directory: ' + normalizedLatestProjectPath);
}
const workflowSummaryPath = requireFile(path.join(normalizedLatestProjectPath, 'workflow_summary.json'), 'Missing workflow summary');
const videoRequestPath = requireFile(path.join(normalizedLatestProjectPath, 'video_request.json'), 'Missing video request');
const workflowSummary = JSON.parse(fs.readFileSync(workflowSummaryPath, 'utf8'));
const latestWorkflowSummary = JSON.parse(fs.readFileSync(latestSummaryAliasPath, 'utf8'));
if (String(workflowSummary.stage || '') !== String(expectedStage || '')) {
throw new Error('Expected workflow summary stage ' + String(expectedStage || '') + ', received: ' + String(workflowSummary.stage || ''));
}
if (String(latestWorkflowSummary.stage || '') !== String(expectedStage || '')) {
throw new Error('Latest workflow summary alias did not reach expected stage ' + String(expectedStage || '') + '.');
}
const startedAtValue = Date.parse(String(result.startedAt || ''));
const summaryTimestampValue = Date.parse(String(workflowSummary.timestamp || latestWorkflowSummary.timestamp || ''));
if (Number.isFinite(startedAtValue) && Number.isFinite(summaryTimestampValue) && summaryTimestampValue + 1000 < startedAtValue) {
throw new Error('Workflow summary timestamp predates smoke start. startedAt=' + String(result.startedAt || '') + ' summary=' + String(workflowSummary.timestamp || latestWorkflowSummary.timestamp || ''));
}
const videoRequest = JSON.parse(fs.readFileSync(videoRequestPath, 'utf8'));
const selectedVideoEngine = String(videoRequest.selected_video_engine || '');
if (String(expectedVideoEngine || '') !== 'auto' && selectedVideoEngine !== String(expectedVideoEngine || '')) {
throw new Error('Unexpected selected_video_engine. actual=' + selectedVideoEngine + ' expected=' + String(expectedVideoEngine || ''));
}
let finalVideoPath = null;
let videoStatusPath = null;
let videoSummaryPath = null;
if (String(expectedStage || '') === 'video_generated') {
videoStatusPath = requireFile(path.join(normalizedLatestProjectPath, 'video_generation_status.json'), 'Missing video generation status');
const videoStatus = JSON.parse(fs.readFileSync(videoStatusPath, 'utf8'));
if (String(videoStatus.status || '') !== 'success') {
throw new Error('Video generation status is not success: ' + JSON.stringify(videoStatus));
}
if (selectedVideoEngine === 'seedance') {
videoSummaryPath = requireFile(path.join(normalizedLatestProjectPath, 'latest_seedance_split_summary.json'), 'Missing seedance split summary');
const splitSummary = JSON.parse(fs.readFileSync(videoSummaryPath, 'utf8'));
finalVideoPath = requirePathInside(normalizedLatestProjectPath, String(splitSummary.latest_project_video || splitSummary.final_video_path || ''), 'latest seedance split video');
requireFile(finalVideoPath, 'Missing final generated video');
}
}
const commonArtifacts = [
['storyboard_file', videoRequest.storyboard_file],
['storyboard_markdown_file', videoRequest.storyboard_markdown_file],
['copywriter_text_file', videoRequest.copywriter_text_file],
['storyboard_text_file', videoRequest.storyboard_text_file],
['script_file', videoRequest.script_file],
['structured_script_file', videoRequest.structured_script_file]
];
const engineSpecificArtifacts = [];
if (selectedVideoEngine === 'omnihuman') {
engineSpecificArtifacts.push(['omnihuman_prompt_file', videoRequest.omnihuman_prompt_file]);
}
if (selectedVideoEngine === 'seedance') {
engineSpecificArtifacts.push(['seedance_prompt_file', videoRequest.seedance_prompt_file]);
}
const validatedArtifacts = [];
for (const [label, artifactPathValue] of commonArtifacts.concat(engineSpecificArtifacts)) {
const artifactPath = requirePathInside(normalizedLatestProjectPath, String(artifactPathValue || ''), label);
requireFile(artifactPath, 'Missing artifact for ' + label);
validatedArtifacts.push({ label, path: artifactPath });
}
const smokeAttachments = Array.isArray(sendResult.smokeAttachments) ? sendResult.smokeAttachments : [];
let workspaceAttachmentPath = null;
let workspaceAttachmentRelativePath = null;
let projectMainImagePath = null;
if (expectAttachment) {
if (smokeAttachments.length !== 1) {
throw new Error('Expected exactly one smoke attachment. actual=' + smokeAttachments.length);
}
if (!assistantContent.includes('Project attachments:')) {
throw new Error('Assistant content did not echo project attachments.');
}
const assistantAttachmentMatch = assistantContent.match(/inputs\/images\/main\/[^\r\n]+/);
if (!assistantAttachmentMatch) {
throw new Error('Assistant content did not expose a materialized attachment path.');
}
workspaceAttachmentRelativePath = assistantAttachmentMatch[0].trim();
workspaceAttachmentPath = path.join(userDataPath, 'projects', 'douyin', ...workspaceAttachmentRelativePath.split('/'));
requireFile(workspaceAttachmentPath, 'Workspace attachment file was not found');
const workspaceAttachmentStat = fs.statSync(workspaceAttachmentPath);
if (!workspaceAttachmentStat.isFile() || workspaceAttachmentStat.size < 1) {
throw new Error('Workspace attachment file is empty: ' + workspaceAttachmentPath);
}
if (!assistantContent.includes(workspaceAttachmentRelativePath)) {
throw new Error('Assistant content did not reference the materialized workspace attachment path: ' + workspaceAttachmentRelativePath);
}
const projectInputs = videoRequest.project_inputs || {};
projectMainImagePath = requirePathInside(normalizedLatestProjectPath, String(projectInputs.main_image || ''), 'project_inputs.main_image');
requireFile(projectMainImagePath, 'project_inputs.main_image was not created');
const latestProjectImagePathHint = path.join(path.dirname(projectMainImagePath), '_latest_path.txt');
requireFile(latestProjectImagePathHint, 'Project inputs main image directory is missing _latest_path.txt');
const latestProjectImagePath = path.normalize(String(fs.readFileSync(latestProjectImagePathHint, 'utf8')).trim());
if (latestProjectImagePath !== projectMainImagePath) {
throw new Error('Project inputs latest image pointer mismatch. actual=' + latestProjectImagePath + ' expected=' + projectMainImagePath);
}
} else if (smokeAttachments.length !== 0) {
throw new Error('Did not expect smoke attachments. actual=' + smokeAttachments.length);
}
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,
latestProjectPath: normalizedLatestProjectPath,
workflowSummaryPath,
selectedVideoEngine,
expectedVideoEngine,
expectedStage,
finalVideoPath,
videoStatusPath,
videoSummaryPath,
smokeAttachmentCount: smokeAttachments.length,
workspaceAttachmentPath,
workspaceAttachmentRelativePath,
projectMainImagePath,
artifactCount: validatedArtifacts.length,
statusLabels,
bundleManifestPath
}, null, 2));
"@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $Prompt ($expectedExpertIds -join ',') $ExpectedVideoEngine $ExpectedStage ($(if ($shouldAttachImage) { '1' } else { '0' }))
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_ATTACHMENTS_JSON -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_WAIT_FOR_PATHS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_WAIT_FOR_PATHS_TIMEOUT_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_WAIT_FOR_EXPECTED_STAGE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY -ErrorAction SilentlyContinue
}
param(
[int]$SmokePort = 4319,
[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 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
}
if ($entry.PSIsContainer -and $entry.Name -match '^tmp[a-z0-9]{6,}$') {
Write-Warning "Skipping transient temp directory from bundle source: $($entry.FullName)"
continue
}
try {
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationRoot $entry.Name) -Recurse -Force
}
catch [System.UnauthorizedAccessException] {
Write-Warning "Skipping inaccessible bundle source entry: $($entry.FullName)"
continue
}
}
}
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 Douyin 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\douyin-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)
$douyinSourceRoot = Join-Path $repoRoot 'workspace\douyin'
$userDataPath = Join-Path $BaseOutputDir 'user-data'
$logsPath = Join-Path $BaseOutputDir 'logs'
$bundleSourceRoot = Join-Path $BaseOutputDir 'bundle-src'
$bundleZipPath = Join-Path $BaseOutputDir 'douyin-expert-manual-launch.zip'
$bundleFileName = 'douyin-expert-manual-launch.zip'
$bundleSkillId = 'douyin-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 $douyinSourceRoot)) {
throw "Douyin workspace source was not found: $douyinSourceRoot"
}
$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-ProjectBundleSource -SourceRoot $douyinSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'douyin')
if (Test-Path $bundleZipPath) {
Remove-Item -LiteralPath $bundleZipPath -Force
}
Compress-Archive -Path (Join-Path $bundleSourceRoot 'douyin') -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 'xhs-expert-smoke' -ProjectName 'XHS Expert Fixture' -Platform 'xiaohongshu' -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 = 'Douyin Project Bundle'
$env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Local mock employee-config bundle for manual Douyin 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 douyin, and send your manual prompt."
......@@ -390,6 +390,7 @@ const diagnostics = diagnosticsPath && fs.existsSync(diagnosticsPath)
? JSON.parse(fs.readFileSync(diagnosticsPath, 'utf8'))
: null;
const runtimeTelemetry = sendResult.runtimeTelemetryAfterWait || sendResult.runtimeTelemetryBeforeWait || {};
const acceptWorkspaceLaunch = process.env.QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH === '1';
if (!sendResult.runtimeCloudFetch || sendResult.runtimeCloudFetch.state !== 'ready') {
throw new Error('Runtime cloud config fetch did not succeed.');
}
......@@ -400,10 +401,10 @@ if (smokeViewMode !== 'skills') {
if (Number(runtimeTelemetry.heartbeatSuccessCount || 0) < 1) {
throw new Error('Runtime telemetry did not record a successful heartbeat.');
}
if (Number(runtimeTelemetry.totalAcceptedEventCount || 0) < 3) {
if (!acceptWorkspaceLaunch && Number(runtimeTelemetry.totalAcceptedEventCount || 0) < 3) {
throw new Error('Runtime telemetry did not accept the expected event batch count: ' + runtimeTelemetry.totalAcceptedEventCount);
}
if (Number(runtimeTelemetry.configSyncSuccessCount || 0) < 1) {
if (!acceptWorkspaceLaunch && Number(runtimeTelemetry.configSyncSuccessCount || 0) < 1) {
throw new Error('Runtime telemetry did not record a successful config sync.');
}
if (!diagnostics || !diagnostics.runtimeTelemetry) {
......@@ -453,7 +454,7 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') {
const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || ''))
: [];
const workspaceLaunchAccepted = process.env.QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH === '1'
const workspaceLaunchAccepted = acceptWorkspaceLaunch
&& statusLabels.some((label) => label.includes('Launching project workspace'));
const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || '');
const expectedProjectRoot = path.join(expectedUserData, 'projects', workspaceProjectId);
......@@ -464,16 +465,16 @@ if (expectWorkspaceEntry === 'true' && smokeViewMode !== 'skills') {
if (!reportedWorkspaceLabel && !(expectRemoteBundle === 'true' && reportedSkillRouteLabel)) {
throw new Error('Workspace-entry smoke did not report a workspace or routed-skill status label. history=' + JSON.stringify(statusLabels) + ' latest=' + latestStatusLabel);
}
if (!assistantContent.includes('desktop project-isolated workspace')) {
if (!workspaceLaunchAccepted && !assistantContent.includes('desktop project-isolated workspace')) {
throw new Error('Workspace-entry smoke did not echo the injected isolated workspace context.');
}
if (!assistantContent.includes('Current project: ' + workspaceProjectName + ' (' + workspaceProjectId + ')')) {
if (!workspaceLaunchAccepted && !assistantContent.includes('Current project: ' + workspaceProjectName + ' (' + workspaceProjectId + ')')) {
throw new Error('Workspace-entry smoke did not reference the expected project identity.');
}
if (!assistantContent.includes('Project root: ' + expectedProjectRoot)) {
if (!workspaceLaunchAccepted && !assistantContent.includes('Project root: ' + expectedProjectRoot)) {
throw new Error('Workspace-entry smoke did not reference the expected project root.');
}
if (!assistantContent.includes('Keep project context isolated to this project and session.')) {
if (!workspaceLaunchAccepted && !assistantContent.includes('Keep project context isolated to this project and session.')) {
throw new Error('Workspace-entry smoke did not preserve the project isolation instruction.');
}
if (selectedSkillId && expectRemoteBundle !== 'true') {
......
......@@ -919,26 +919,97 @@ try {
throw $failureError
}
$pythonImportProbe = & $packagedPythonExe -c "import openpyxl, pandas, requests, bs4, lxml, pypdf, docx, charset_normalizer, yaml, PIL, dotenv, playwright; print('ok')"
if ($LASTEXITCODE -ne 0 -or $pythonImportProbe -notmatch 'ok') {
$pythonImportProbeScript = @'
import json
result = {
"ok": True,
"moduleSet": False,
"greenletImport": False,
"playwrightAsyncImport": False,
"edgeTtsImport": False
}
try:
import openpyxl, pandas, requests, bs4, lxml, pypdf, docx, charset_normalizer, yaml, PIL, dotenv, playwright, edge_tts
result["moduleSet"] = True
import greenlet
result["greenletImport"] = True
from playwright.async_api import async_playwright
assert async_playwright is not None
result["playwrightAsyncImport"] = True
result["edgeTtsImport"] = True
except Exception as exc:
result["ok"] = False
result["error"] = f"{type(exc).__name__}: {exc}"
print(json.dumps(result))
'@
$pythonImportProbePath = Join-Path (Split-Path $SmokeOutput -Parent) 'installer-smoke-python-import-probe.py'
Write-Utf8File -FilePath $pythonImportProbePath -Content $pythonImportProbeScript
$pythonImportProbeRaw = ''
$pythonImportProbeExitCode = $null
try {
$pythonImportProbeRaw = (& $packagedPythonExe $pythonImportProbePath 2>&1 | Out-String).Trim()
$pythonImportProbeExitCode = $LASTEXITCODE
} finally {
if (Test-Path $pythonImportProbePath) {
Remove-Item -LiteralPath $pythonImportProbePath -Force -ErrorAction SilentlyContinue
}
}
$pythonImportProbeJson = $null
try {
if ($pythonImportProbeRaw) {
$pythonImportProbeJson = $pythonImportProbeRaw | ConvertFrom-Json
}
} catch {
$pythonImportProbeJson = $null
}
$pythonImportProbeOk = (
$pythonImportProbeExitCode -eq 0 `
-and $null -ne $pythonImportProbeJson `
-and [bool]$pythonImportProbeJson.ok
)
$installerStageSummary.pythonImportProbe = [ordered]@{
ok = $pythonImportProbeOk
exitCode = $pythonImportProbeExitCode
output = $pythonImportProbeRaw
moduleSet = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.moduleSet)
greenletImport = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.greenletImport)
playwrightAsyncImport = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.playwrightAsyncImport)
edgeTtsImport = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.edgeTtsImport)
error = if ($pythonImportProbeJson) { [string]$pythonImportProbeJson.error } else { '' }
}
$installerStageSummary.greenletImportProbe = [ordered]@{
ok = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.greenletImport)
output = $pythonImportProbeRaw
error = if ($pythonImportProbeJson) { [string]$pythonImportProbeJson.error } else { '' }
}
$installerStageSummary.playwrightAsyncImportProbe = [ordered]@{
ok = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.playwrightAsyncImport)
output = $pythonImportProbeRaw
error = if ($pythonImportProbeJson) { [string]$pythonImportProbeJson.error } else { '' }
}
$installerStageSummary.edgeTtsImportProbe = [ordered]@{
ok = [bool]($pythonImportProbeJson -and $pythonImportProbeJson.edgeTtsImport)
output = $pythonImportProbeRaw
error = if ($pythonImportProbeJson) { [string]$pythonImportProbeJson.error } else { '' }
}
if (-not $pythonImportProbeOk) {
$failureStage = 'installer-materialization'
$failureClassification = 'payload-validation-failure'
$failureError = 'Bundled Python import probe failed for the packaged runtime payload.'
$installerStageSummary.failureStage = $failureStage
$installerStageSummary.failureClassification = $failureClassification
$installerStageSummary.ok = $false
$installerStageSummary.pythonImportProbe = [ordered]@{
ok = $false
output = [string]$pythonImportProbe
}
throw $failureError
}
$installerStageSummary.pythonImportProbe = [ordered]@{
ok = $true
output = [string]$pythonImportProbe
}
$installerStageSummary.packagedWorkspaceTemplate = [ordered]@{
path = $packagedWorkspaceTemplate
exists = (Test-Path $packagedWorkspaceTemplate)
......
param(
[ValidateSet('all', 'xhs', 'douyin')]
[string]$ProjectId = 'all',
[string]$SmokeOutput
)
$ErrorActionPreference = 'Stop'
function Assert-Condition {
param(
[bool]$Condition,
[string]$Message
)
if (-not $Condition) {
throw $Message
}
}
function Write-Utf8File {
param(
[string]$FilePath,
[string]$Content
)
$parent = Split-Path -Parent $FilePath
if ($parent) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
}
function Decode-Utf8Base64 {
param([string]$Value)
return [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Value))
}
function Get-XhsVolcesImageProviderConfig {
$modelId = 'doubao-seedream-5-0-260128'
return [ordered]@{
providerName = 'volces'
modelId = $modelId
providers = [ordered]@{
volces = [ordered]@{
baseUrl = 'https://ark.cn-beijing.volces.com/api/v3/images/generations'
apiKey = 'smoke-key'
api = 'openai-completions'
models = @(
[ordered]@{
id = $modelId
name = 'volces-gen-image'
reasoning = $false
input = @('text', 'image')
cost = [ordered]@{
input = 0
output = 0
cacheRead = 0
cacheWrite = 0
}
contextWindow = 128000
maxTokens = 81920
}
)
}
}
}
}
function Get-XhsTextProviderFixtureConfig {
$modelId = 'qwen3.5-plus'
return [ordered]@{
providerName = 'qwen'
modelId = $modelId
providers = [ordered]@{
qwen = [ordered]@{
baseUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
apiKey = 'smoke-key'
api = 'openai-completions'
models = @(
[ordered]@{
id = $modelId
input = @('text')
}
)
}
modelstudio = [ordered]@{
baseUrl = 'https://coding.dashscope.aliyuncs.com/v1'
api = 'openai-completions'
models = @(
[ordered]@{
id = 'qwen3.5-plus'
input = @('text', 'image')
}
)
}
'openclaw-cloud' = [ordered]@{
baseUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
apiKey = 'smoke-key'
api = 'openai-completions'
models = @(
[ordered]@{
id = 'kimi-k2.5'
input = @('text', 'image')
}
)
}
}
primaryModel = 'openclaw-cloud/kimi-k2.5'
}
}
function Get-NormalizedRelativePath {
param(
[string]$BasePath,
[string]$TargetPath
)
$pathType = [System.IO.Path]
if ($pathType.GetMethod('GetRelativePath', [type[]]@([string], [string]))) {
$relative = [System.IO.Path]::GetRelativePath($BasePath, $TargetPath)
} else {
$baseUri = New-Object System.Uri(((Resolve-Path $BasePath).Path.TrimEnd('\') + '\'))
$targetUri = New-Object System.Uri((Resolve-Path $TargetPath).Path)
$relative = [System.Uri]::UnescapeDataString($baseUri.MakeRelativeUri($targetUri).ToString())
}
return ($relative -replace '\\', '/').TrimStart('./')
}
function Test-ExcludedRelativePath {
param(
[string]$RelativePath,
[string[]]$ExcludePatterns,
[bool]$IsDirectory
)
$normalized = ($RelativePath -replace '\\', '/').Trim('/')
$candidateValues = @($normalized)
if ($IsDirectory -and $normalized) {
$candidateValues += "$normalized/"
}
foreach ($pattern in $ExcludePatterns) {
$rawPattern = [string]$pattern
if (-not $rawPattern) {
continue
}
$normalizedPattern = ($rawPattern -replace '\\', '/').Trim()
if (-not $normalizedPattern) {
continue
}
$wildcard = New-Object System.Management.Automation.WildcardPattern($normalizedPattern, [System.Management.Automation.WildcardOptions]::IgnoreCase)
foreach ($candidate in $candidateValues) {
if ($candidate -and $wildcard.IsMatch($candidate)) {
return $true
}
}
if ($normalized -and -not $normalizedPattern.Contains('/')) {
$leaf = Split-Path -Leaf $normalized
if ($leaf -and $wildcard.IsMatch($leaf)) {
return $true
}
}
}
return $false
}
function Copy-ProjectPackageSource {
param(
[string]$SourceRoot,
[string]$DestinationRoot
)
$projectJsonPath = Join-Path $SourceRoot 'project.json'
$excludePatterns = @()
if (Test-Path $projectJsonPath) {
$projectConfig = Get-Content -Path $projectJsonPath -Raw | ConvertFrom-Json
if ($projectConfig.bundlePackaging -and $projectConfig.bundlePackaging.excludePaths) {
$excludePatterns = @($projectConfig.bundlePackaging.excludePaths | ForEach-Object { [string]$_ })
}
}
function Copy-ProjectEntry {
param(
[string]$CurrentSource,
[string]$CurrentDestination
)
New-Item -ItemType Directory -Force -Path $CurrentDestination | Out-Null
foreach ($entry in Get-ChildItem -LiteralPath $CurrentSource -Force) {
$relativePath = Get-NormalizedRelativePath -BasePath $SourceRoot -TargetPath $entry.FullName
if (Test-ExcludedRelativePath -RelativePath $relativePath -ExcludePatterns $excludePatterns -IsDirectory $entry.PSIsContainer) {
continue
}
$targetPath = Join-Path $CurrentDestination $entry.Name
if ($entry.PSIsContainer) {
Copy-ProjectEntry -CurrentSource $entry.FullName -CurrentDestination $targetPath
} else {
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $targetPath) | Out-Null
Copy-Item -LiteralPath $entry.FullName -Destination $targetPath -Force
}
}
}
if (Test-Path $DestinationRoot) {
Remove-Item -LiteralPath $DestinationRoot -Recurse -Force
}
Copy-ProjectEntry -CurrentSource $SourceRoot -CurrentDestination $DestinationRoot
}
function Invoke-PythonCommand {
param(
[string[]]$Arguments,
[string]$WorkingDirectory,
[hashtable]$EnvironmentOverrides = @{}
)
$previousValues = @{}
foreach ($key in $EnvironmentOverrides.Keys) {
$previousValues[$key] = [Environment]::GetEnvironmentVariable($key, 'Process')
[Environment]::SetEnvironmentVariable($key, [string]$EnvironmentOverrides[$key], 'Process')
}
try {
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
Push-Location $WorkingDirectory
$rawOutput = & python @Arguments 2>&1
$exitCode = $LASTEXITCODE
Pop-Location
} finally {
$ErrorActionPreference = $previousErrorActionPreference
foreach ($key in $EnvironmentOverrides.Keys) {
[Environment]::SetEnvironmentVariable($key, $previousValues[$key], 'Process')
}
}
$outputText = if ($rawOutput -is [System.Array]) {
($rawOutput | ForEach-Object { [string]$_ }) -join [Environment]::NewLine
} elseif ($null -eq $rawOutput) {
''
} else {
[string]$rawOutput
}
return [pscustomobject]@{
ExitCode = $exitCode
Output = $outputText
}
}
function Parse-WorkspaceEvents {
param([string]$OutputText)
$events = @()
$prefix = "QJC_WORKSPACE_EVENT`t"
foreach ($line in ($OutputText -split "`r?`n")) {
if (-not $line.StartsWith($prefix)) {
continue
}
$jsonText = $line.Substring($prefix.Length)
$events += ($jsonText | ConvertFrom-Json)
}
return $events
}
function Invoke-WorkspaceEntry {
param(
[string]$ProjectRoot,
[string]$Prompt,
[string]$SessionId = '',
[hashtable]$EnvironmentOverrides = @{}
)
$arguments = @(
'-X', 'utf8',
(Join-Path $ProjectRoot 'workspace_entry.py'),
'--prompt', $Prompt
)
if ($ProjectRoot) {
$arguments += @('--project-root', $ProjectRoot)
}
if ($SessionId) {
$arguments += @('--session-id', $SessionId)
}
$result = Invoke-PythonCommand -Arguments $arguments -WorkingDirectory $ProjectRoot -EnvironmentOverrides $EnvironmentOverrides
$events = Parse-WorkspaceEvents -OutputText $result.Output
Assert-Condition ($events.Count -gt 0) "Workspace entry emitted no structured events. output=$($result.Output)"
$terminalEvent = $events[-1]
$terminalType = [string]$terminalEvent.type
Assert-Condition (($terminalType -eq 'completed') -or ($terminalType -eq 'error')) "Workspace entry did not emit a terminal completed/error event. output=$($result.Output)"
return [pscustomobject]@{
ExitCode = $result.ExitCode
Output = $result.Output
Events = $events
TerminalType = $terminalType
TerminalEvent = $terminalEvent
}
}
function Write-DouyinCoordinatorSmokeStub {
param(
[string]$ProjectRoot
)
$stubPath = Join-Path $ProjectRoot 'skills\douyin-master\scripts\coordinator.py'
$stub = @'
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat()
def write_json(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def write_text(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def update_latest(output_root: Path, project_dir: Path, summary: dict, preview_paths: dict | None = None) -> None:
(output_root / "_latest_project_path.txt").write_text(str(project_dir), encoding="utf-8")
write_json(output_root / "_latest_workflow_summary.json", summary)
if preview_paths is not None:
write_json(output_root / "_latest_preview_paths.json", preview_paths)
def main() -> int:
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--product", default="")
parser.add_argument("--audience", default="")
parser.add_argument("--type", dest="video_type", default="")
parser.add_argument("--style", default="")
parser.add_argument("--output-dir", default="")
parser.add_argument("--continue-from", default="")
parser.add_argument("--video-engine", default="auto")
parser.add_argument("--duration", default="")
parser.add_argument("--seedance-duration", type=int, default=None)
args, _ = parser.parse_known_args()
output_root = Path(args.output_dir).resolve()
project_dir = Path(args.continue_from).resolve() if args.continue_from else (output_root / (args.product or "douyin-project"))
project_dir.mkdir(parents=True, exist_ok=True)
copywriter_text_file = project_dir / "01_文案脚本.txt"
storyboard_text_file = project_dir / "02_分镜对照.txt"
storyboard_markdown_file = project_dir / "storyboard.md"
video_request_file = project_dir / "video_request.json"
selected_video_engine = "seedance" if args.video_engine in {"", "auto"} else args.video_engine
duration_text = args.duration or (f"{args.seedance_duration}s" if args.seedance_duration else "15s")
write_text(copywriter_text_file, f"topic: {project_dir.name}\naudience: {args.audience or 'tourist'}\nduration: {duration_text}\n")
write_text(storyboard_text_file, "shot1: intro\nshot2: body\nshot3: outro\n")
write_text(storyboard_markdown_file, "# Storyboard\n\n1. intro\n2. body\n3. outro\n")
video_request = {
"product": project_dir.name,
"audience": args.audience or "tourist",
"selected_video_engine": selected_video_engine,
"copywriter_text_file": str(copywriter_text_file),
"storyboard_text_file": str(storyboard_text_file),
"storyboard_markdown_file": str(storyboard_markdown_file),
"seedance_duration": args.seedance_duration or 15,
}
write_json(video_request_file, video_request)
preview_paths = {
"stage": "preview_ready",
"project_dir": str(project_dir),
"copywriter_text_file": str(copywriter_text_file),
"storyboard_text_file": str(storyboard_text_file),
"video_request_file": str(video_request_file),
}
if not args.continue_from:
summary = {
"generated_at": now_iso(),
"stage": "preview_ready",
"config": {
"video_engine": selected_video_engine,
"effective_video_engine": selected_video_engine,
"output_dir": str(project_dir),
},
"results": {
"video_request": video_request,
},
}
write_json(project_dir / "workflow_summary.json", summary)
update_latest(output_root, project_dir, summary, preview_paths)
return 0
run_dir = project_dir / "seedance-split-runs" / "smoke-run"
run_dir.mkdir(parents=True, exist_ok=True)
final_video_path = run_dir / "final_video.mp4"
latest_project_video = project_dir / "latest_seedance_split.mp4"
final_video_path.write_bytes(b"FAKE_MP4")
latest_project_video.write_bytes(b"FAKE_MP4")
split_summary = {
"project_dir": str(project_dir),
"run_dir": str(run_dir),
"status": "success",
"final_video_path": str(final_video_path),
"latest_project_video": str(latest_project_video),
"duration_seconds": args.seedance_duration or 15,
"segment_count": 3,
}
write_json(project_dir / "latest_seedance_split_summary.json", split_summary)
write_json(
project_dir / "video_generation_status.json",
{
"generated_at": now_iso(),
"status": "success",
"project_dir": str(project_dir),
"video_engine": selected_video_engine,
"current_engine": selected_video_engine,
"estimated_wait": "",
"message": "video generated",
},
)
summary = {
"generated_at": now_iso(),
"stage": "video_generated",
"config": {
"video_engine": selected_video_engine,
"effective_video_engine": selected_video_engine,
"output_dir": str(project_dir),
},
"results": {
"video_request": video_request,
"video_seedance": {
"engine": "seedance",
"mode": "split",
"status": "success",
"output_dir": str(run_dir),
"manifest_path": str(project_dir / "latest_seedance_split_summary.json"),
"video_path": str(latest_project_video),
"video_url": "",
"failure": "",
},
},
}
write_json(project_dir / "workflow_summary.json", summary)
update_latest(output_root, project_dir, summary, preview_paths)
return 0
if __name__ == "__main__":
raise SystemExit(main())
'@
Write-Utf8File -FilePath $stubPath -Content $stub
}
function Invoke-JsonHarness {
param(
[string]$HarnessPath,
[string[]]$Arguments,
[string]$WorkingDirectory,
[hashtable]$EnvironmentOverrides = @{}
)
$commandArguments = @('-X', 'utf8', $HarnessPath) + $Arguments
$result = Invoke-PythonCommand -Arguments $commandArguments -WorkingDirectory $WorkingDirectory -EnvironmentOverrides $EnvironmentOverrides
Assert-Condition ($result.ExitCode -eq 0) "Harness failed: $($result.Output)"
try {
return $result.Output | ConvertFrom-Json
} catch {
throw "Harness did not emit JSON. output=$($result.Output)"
}
}
function Run-XhsSmoke {
param(
[string]$RepoRoot,
[string]$TempRoot
)
$xhsTopic = Decode-Utf8Base64 '5LiK5rW36L+q5aOr5bC85peF5ri45pS755Wl'
$xhsTitle = Decode-Utf8Base64 '5LiK5rW36L+q5aOr5bC85LiA5pel5ri45pS755Wl'
$xhsTag = Decode-Utf8Base64 '5LiK5rW36L+q5aOr5bC8'
$xhsLocatePrompt = Decode-Utf8Base64 '55Sf5oiQ55qE56yU6K6w5Zyo5ZOq5Liq55uu5b2V5LiL5ZGi'
$xhsLocateNote = Decode-Utf8Base64 '5paH5qGI6K6w5b2V77yaQzpcdG1wXHhoc19ub3RlLmpzb24='
$xhsLocateMarkdown = Decode-Utf8Base64 'TWFya2Rvd24g5paH5qGj77yaQzpcdG1wXHhoc19ub3RlLm1k'
$xhsLocateText = Decode-Utf8Base64 '5paH5pys5paH5qGj77yaQzpcdG1wXHhoc19ub3RlLnR4dA=='
$xhsLocateImageDir = Decode-Utf8Base64 '5Zu+54mH55uu5b2V77yaQzpcdG1wXGdlbmVyYXRlZF9pbWFnZXM='
$xhsLocateTopic = Decode-Utf8Base64 '5pyA5paw5Li76aKY77ya5LiK5rW36L+q5aOr5bC85peF5ri45pS755Wl'
$xhsGeneratePrompt = Decode-Utf8Base64 '57uZ5oiR55Sf5oiQ5LiA5Liq5LiK5rW36L+q5aOr5bC855qE5peF5ri45pS755Wl'
$xhsExpectedTopic = Decode-Utf8Base64 '5LiK5rW36L+q5aOr5bC855qE5peF5ri45pS755Wl'
$xhsRetryExpectedTitle = Decode-Utf8Base64 '5aSn5a2m55Sf5omL5py65o6o6I2Q5oCO5LmI6YCJ'
$xhsAnalysisFallbackLog = Decode-Utf8Base64 'W+WIhuaekF0g5b2T5YmN5qih5Z6L5LiN5pSv5oyB5Zu+54mH6L6T5YWl77yM5pS55Li657qv5paH5pys5YiG5p6Q'
$xhsRetryTopicBase64 = '5aSn5a2m55Sf5omL5py65o6o6I2Q'
$xhsRetryTitleBase64 = '5aSn5a2m55Sf5omL5py65o6o6I2Q5oCO5LmI6YCJ'
$xhsRetryDraftBase64 = '5YWI6K+057uT6K6677yM5aSn5a2m55Sf5Lmw5omL5py65pyA5oCV5LiA5q2l5Yiw5L2N5Y+Y5oiQ5LiA5q2l5Yiw5Z2R44CC6aKE566X5Zyo5Lik5Y2D5Yiw5LiJ5Y2D5LqU5LmL6Ze077yM5LyY5YWI55yL57ut6Iiq44CB5bGP5bmV44CB5ouN54Wn5ZKM5pel5bi45rWB55WF5bqm77yM5LiN6KaB6KKr57q46Z2i5Y+C5pWw5bim5YGP44CC5aaC5p6c5L2g5bmz5pe25Li76KaB5piv5LiK6K++6K6w56yU6K6w44CB5Yi36KeG6aKR44CB5ouN5pel5bi477yM5aSE55CG5Zmo5aSf56iz44CB57O757uf5bm/5ZGK5bCR44CB5YWF55S16YCf5bqm5b+r77yM5q+U5p6B6ZmQ6LeR5YiG5pu06YeN6KaB44CC6aKE566X5pu057Sn5byg5Y+v5Lul5LyY5YWI6YCJ5aSn55S15rGg5ZKM6ICQ55So5py65Z6L77yb5aaC5p6c5L2g5Zac5qyi5ouN54Wn5ZKM56S+5Lqk5YiG5Lqr77yM5bCx5oqK5Li75pGE5ZKM5aSc5pmv6KGo546w5b6A5YmN5o6S44CC5pyA5ZCO5LiA5a6a6KaB5Y6757q/5LiL5pG455yf5py677yM55yL5omL5oSf44CB5Lqu5bqm5ZKM5Y+R54Ot77yM5YaN5Yaz5a6a54mI5pys5ZKM5a656YeP44CC'
$xhsRetryTagOneBase64 = '5qCH562+MQ=='
$xhsRetryTagTwoBase64 = '6auY5oCn5Lu35q+U5omL5py6'
$xhsRetryTagThreeBase64 = '5omL5py66YG/5Z2R'
$xhsRetryTagFourBase64 = '5pWw56CB6YCJ6LSt'
$xhsRetryTagFiveBase64 = '5byA5a2m6KOF5aSH'
$xhsPublishPrompt = Decode-Utf8Base64 '546w5Zyo5Y+R5biD'
$xhsPublishError = Decode-Utf8Base64 '5bey5om+5Yiw5pyA5paw5paH5qGI77yM5L2G5b2T5YmN5rKh5pyJ5Y+v5Y+R5biD55qE5Zu+54mH'
$xhsPublishHint = Decode-Utf8Base64 '5pyA6L+R5LiA5qyh6YWN5Zu+5o+Q56S677yaTm8gdXNhYmxlIGltYWdlIGdlbmVyYXRpb24gcHJvdmlkZXIgaXMgY29uZmlndXJlZC4='
$sourceRoot = Join-Path $RepoRoot 'workspace\xhs'
$packageRoot = Join-Path $TempRoot 'package'
Copy-ProjectPackageSource -SourceRoot $sourceRoot -DestinationRoot $packageRoot
$memoryRoot = Join-Path $packageRoot 'memory'
$runsRoot = Join-Path $memoryRoot 'openclaw_runs'
$generatedRoot = Join-Path $memoryRoot 'generated_images'
New-Item -ItemType Directory -Force -Path $runsRoot, $generatedRoot | Out-Null
$latestNote = [ordered]@{
saved_at = '2026-04-13T18:00:00'
topic = $xhsTopic
title = $xhsTitle
draft = 'body'
tags = @($xhsTag)
images = @()
image_count = 0
image_status = 'error'
image_errors = @('No usable image generation provider is configured.')
references_file = 'C:\tmp\xhs_run.json'
run_record_path = 'C:\tmp\xhs_run.json'
generated_images_dir = 'C:\tmp\generated_images'
note_record_path = 'C:\tmp\xhs_note.json'
note_markdown_path = 'C:\tmp\xhs_note.md'
note_text_path = 'C:\tmp\xhs_note.txt'
}
Write-Utf8File -FilePath (Join-Path $runsRoot 'xhs_last_note.json') -Content (($latestNote | ConvertTo-Json -Depth 8))
$locateResult = Invoke-WorkspaceEntry -ProjectRoot $packageRoot -Prompt $xhsLocatePrompt
Assert-Condition ($locateResult.TerminalType -eq 'completed') "XHS locate smoke did not complete. output=$($locateResult.Output)"
$locateContent = [string]$locateResult.TerminalEvent.content
Assert-Condition ($locateContent.Contains($xhsLocateNote)) 'XHS locate smoke did not surface the note record path.'
Assert-Condition ($locateContent.Contains($xhsLocateMarkdown)) 'XHS locate smoke did not surface the markdown note path.'
Assert-Condition ($locateContent.Contains($xhsLocateText)) 'XHS locate smoke did not surface the text note path.'
Assert-Condition ($locateContent.Contains($xhsLocateImageDir)) 'XHS locate smoke did not surface the image directory.'
Assert-Condition ($locateContent.Contains($xhsLocateTopic)) 'XHS locate smoke did not preserve the latest topic.'
$publishResult = Invoke-WorkspaceEntry -ProjectRoot $packageRoot -Prompt $xhsPublishPrompt
Assert-Condition ($publishResult.TerminalType -eq 'error') "XHS publish smoke should have failed without images. output=$($publishResult.Output)"
$publishMessage = [string]$publishResult.TerminalEvent.message
Assert-Condition ($publishMessage.Contains($xhsPublishError)) 'XHS publish smoke did not surface the no-images publish error.'
Assert-Condition ($publishMessage.Contains($xhsPublishHint)) 'XHS publish smoke did not surface the latest image error.'
$configPath = Join-Path $TempRoot 'fixtures\xhs-openclaw.runtime.json'
$xhsImageProviderConfig = Get-XhsVolcesImageProviderConfig
$xhsTextProviderConfig = Get-XhsTextProviderFixtureConfig
$configPayload = [ordered]@{
models = [ordered]@{
providers = [ordered]@{
qwen = $xhsTextProviderConfig.providers.qwen
modelstudio = $xhsTextProviderConfig.providers.modelstudio
'openclaw-cloud' = $xhsTextProviderConfig.providers.'openclaw-cloud'
volces = $xhsImageProviderConfig.providers.volces
}
}
agents = [ordered]@{
defaults = [ordered]@{
model = [ordered]@{
primary = $xhsTextProviderConfig.primaryModel
}
}
}
}
Write-Utf8File -FilePath $configPath -Content (($configPayload | ConvertTo-Json -Depth 10))
$harnessPath = Join-Path $TempRoot 'fixtures\xhs-provider-diagnostic.py'
Write-Utf8File -FilePath $harnessPath -Content @'
import json
import os
import sys
import types
project_root = sys.argv[1]
config_path = sys.argv[2]
os.environ["OPENCLAW_CONFIG_PATH"] = config_path
sys.path.insert(0, os.path.join(project_root, "scripts"))
browser = types.ModuleType("browser")
browser.BrowserController = object
sys.modules["browser"] = browser
requests = types.ModuleType("requests")
def _blocked(*args, **kwargs):
raise RuntimeError("requests should not be called during smoke diagnostics")
requests.post = _blocked
requests.get = _blocked
sys.modules["requests"] = requests
import run
payload = {
"provider": run._IMAGE_PROVIDER_NAME,
"model": run.IMAGE_MODEL,
"api_url": run.IMAGE_API_URL,
}
try:
run.ensure_image_api_config()
payload["ok"] = True
except Exception as exc:
payload["ok"] = False
payload["error"] = str(exc)
print(json.dumps(payload, ensure_ascii=False))
'@
$diagnostic = Invoke-JsonHarness -HarnessPath $harnessPath -Arguments @($packageRoot, $configPath) -WorkingDirectory $packageRoot
Assert-Condition ([bool]$diagnostic.ok) 'XHS provider diagnostic smoke did not resolve the injected image provider.'
Assert-Condition ([string]$diagnostic.provider -eq $xhsImageProviderConfig.providerName) 'XHS provider diagnostic smoke did not pick the configured provider.'
Assert-Condition ([string]$diagnostic.model -eq $xhsImageProviderConfig.modelId) 'XHS provider diagnostic smoke did not pick the configured model.'
Assert-Condition ([string]$diagnostic.api_url -eq 'https://ark.cn-beijing.volces.com/api/v3/images/generations') 'XHS provider diagnostic smoke produced an unexpected image API URL.'
$textProviderHarnessPath = Join-Path $TempRoot 'fixtures\xhs-text-provider.py'
Write-Utf8File -FilePath $textProviderHarnessPath -Content @'
import json
import os
import sys
project_root = sys.argv[1]
config_path = sys.argv[2]
os.environ["OPENCLAW_CONFIG_PATH"] = config_path
sys.path.insert(0, os.path.join(project_root, "scripts"))
import qwen
print(json.dumps({
"provider": qwen._QWEN_PROVIDER_NAME,
"text_model": qwen.ARK_TEXT_MODEL,
"vision_model": qwen.ARK_VISION_MODEL,
}, ensure_ascii=False))
'@
$textProviderResult = Invoke-JsonHarness -HarnessPath $textProviderHarnessPath -Arguments @($packageRoot, $configPath) -WorkingDirectory $packageRoot
Assert-Condition ([string]$textProviderResult.provider -eq $xhsTextProviderConfig.providerName) 'XHS text-provider smoke did not prefer the configured local text provider over openclaw-cloud.'
Assert-Condition ([string]$textProviderResult.text_model -eq $xhsTextProviderConfig.modelId) 'XHS text-provider smoke did not pick qwen3.5-plus as text model.'
$topicHarnessPath = Join-Path $TempRoot 'fixtures\xhs-topic-extract.py'
Write-Utf8File -FilePath $topicHarnessPath -Content @'
import json
import sys
project_root = sys.argv[1]
prompt = sys.argv[2]
sys.path.insert(0, project_root)
import workspace_entry
print(json.dumps({"topic": workspace_entry.extract_topic(prompt)}, ensure_ascii=False))
'@
$topicResult = Invoke-JsonHarness -HarnessPath $topicHarnessPath -Arguments @($packageRoot, $xhsGeneratePrompt) -WorkingDirectory $packageRoot
Assert-Condition ([string]$topicResult.topic -eq $xhsExpectedTopic) 'XHS topic extraction smoke did not strip the colloquial prefix.'
$envProviderHarnessPath = Join-Path $TempRoot 'fixtures\xhs-env-provider.py'
Write-Utf8File -FilePath $envProviderHarnessPath -Content @'
import json
import os
import sys
import types
project_root = sys.argv[1]
sys.path.insert(0, os.path.join(project_root, "scripts"))
browser = types.ModuleType("browser")
browser.BrowserController = object
sys.modules["browser"] = browser
requests = types.ModuleType("requests")
def _blocked(*args, **kwargs):
raise RuntimeError("requests should not be called during smoke diagnostics")
requests.post = _blocked
requests.get = _blocked
sys.modules["requests"] = requests
import run
import qwen
run.ensure_image_api_config()
print(json.dumps({
"text_base_url": qwen.ARK_BASE_URL,
"text_model": qwen.ARK_TEXT_MODEL,
"image_provider": run._IMAGE_PROVIDER_NAME,
"image_base_url": run.IMAGE_API_BASE_URL,
"image_model": run.IMAGE_MODEL,
}, ensure_ascii=False))
'@
$envProviderResult = Invoke-JsonHarness -HarnessPath $envProviderHarnessPath -Arguments @($packageRoot) -WorkingDirectory $packageRoot -EnvironmentOverrides @{
QWEN_BASE_URL = [string]$xhsTextProviderConfig.providers.qwen.baseUrl
QWEN_API_KEY = 'smoke-key'
QWEN_MODEL = [string]$xhsTextProviderConfig.modelId
XHS_IMAGE_PROVIDER = [string]$xhsImageProviderConfig.providerName
XHS_IMAGE_BASE_URL = [string]$xhsImageProviderConfig.providers.volces.baseUrl
XHS_IMAGE_API_KEY = 'smoke-key'
XHS_IMAGE_MODEL = [string]$xhsImageProviderConfig.modelId
}
Assert-Condition ([string]$envProviderResult.text_base_url -eq [string]$xhsTextProviderConfig.providers.qwen.baseUrl) 'XHS env-provider smoke did not wire QWEN_BASE_URL.'
Assert-Condition ([string]$envProviderResult.text_model -eq $xhsTextProviderConfig.modelId) 'XHS env-provider smoke did not wire QWEN_MODEL.'
Assert-Condition ([string]$envProviderResult.image_provider -eq $xhsImageProviderConfig.providerName) 'XHS env-provider smoke did not prefer the direct image env provider.'
Assert-Condition ([string]$envProviderResult.image_base_url -eq [string]$xhsImageProviderConfig.providers.volces.baseUrl) 'XHS env-provider smoke did not wire XHS_IMAGE_BASE_URL.'
Assert-Condition ([string]$envProviderResult.image_model -eq $xhsImageProviderConfig.modelId) 'XHS env-provider smoke did not wire XHS_IMAGE_MODEL.'
$noteRetryHarnessPath = Join-Path $TempRoot 'fixtures\xhs-note-retry.py'
Write-Utf8File -FilePath $noteRetryHarnessPath -Content @'
import base64
import contextlib
import io
import json
import os
import sys
import types
project_root = sys.argv[1]
sys.path.insert(0, os.path.join(project_root, "scripts"))
browser = types.ModuleType("browser")
browser.BrowserController = object
sys.modules["browser"] = browser
requests = types.ModuleType("requests")
def _blocked(*args, **kwargs):
raise RuntimeError("requests should not be called during smoke note retry")
requests.post = _blocked
requests.get = _blocked
sys.modules["requests"] = requests
import run
def dec(value):
return base64.b64decode(value).decode("utf-8")
responses = [
json.dumps({"title": "...", "draft": "...", "tags": [dec(sys.argv[5])]}, ensure_ascii=False),
json.dumps({
"title": dec(sys.argv[3]),
"draft": dec(sys.argv[4]),
"tags": [dec(sys.argv[2]), dec(sys.argv[6]), dec(sys.argv[7]), dec(sys.argv[8]), dec(sys.argv[9])]
}, ensure_ascii=False),
]
state = {"calls": 0}
def fake_call(messages, **kwargs):
idx = state["calls"]
state["calls"] += 1
return responses[min(idx, len(responses) - 1)]
run._call_qwen = fake_call
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
result = run.generate_note(dec(sys.argv[2]), analysis={})
print(json.dumps({
"calls": state["calls"],
"title": result.get("title", ""),
"draft_length": len(result.get("draft", "")),
"tags_count": len(result.get("tags", [])),
}, ensure_ascii=False))
'@
$noteRetryResult = Invoke-JsonHarness -HarnessPath $noteRetryHarnessPath -Arguments @(
$packageRoot,
$xhsRetryTopicBase64,
$xhsRetryTitleBase64,
$xhsRetryDraftBase64,
$xhsRetryTagOneBase64,
$xhsRetryTagTwoBase64,
$xhsRetryTagThreeBase64,
$xhsRetryTagFourBase64,
$xhsRetryTagFiveBase64
) -WorkingDirectory $packageRoot
Assert-Condition ([int]$noteRetryResult.calls -eq 2) 'XHS note retry smoke did not retry after placeholder content.'
Assert-Condition ([string]$noteRetryResult.title -eq $xhsRetryExpectedTitle) 'XHS note retry smoke did not return the retried real title.'
Assert-Condition ([int]$noteRetryResult.draft_length -ge 60) 'XHS note retry smoke did not return a usable draft.'
Assert-Condition ([int]$noteRetryResult.tags_count -ge 3) 'XHS note retry smoke did not return enough tags.'
$noteExportHarnessPath = Join-Path $TempRoot 'fixtures\xhs-note-export.py'
Write-Utf8File -FilePath $noteExportHarnessPath -Content @'
import json
import os
import sys
from pathlib import Path
project_root = Path(sys.argv[1])
sys.path.insert(0, str(project_root))
import workspace_entry
workspace_entry.ensure_runtime_dirs()
payload = {
"title": "smoke title",
"draft": """first line
second line""",
"tags": ["tag-a", "tag-b", "tag-c"],
"images": [str(project_root / "memory" / "generated_images" / "img1.jpg")],
"image_status": "success",
"image_errors": [],
"generated_images_dir": str(project_root / "memory" / "generated_images"),
"notes_file": str(project_root / "memory" / "openclaw_runs" / "refs.json"),
"analysis": {"写作风格": "real"},
}
run_record_path = project_root / "memory" / "openclaw_runs" / "xhs_test.json"
record = workspace_entry.save_latest_note(payload, topic="smoke topic", run_record_path=run_record_path)
markdown_path = Path(record["note_markdown_path"])
text_path = Path(record["note_text_path"])
print(json.dumps({
"markdown_exists": markdown_path.exists(),
"text_exists": text_path.exists(),
"markdown_path": str(markdown_path),
"text_path": str(text_path),
"markdown_has_title": "# smoke title" in markdown_path.read_text(encoding="utf-8"),
"text_has_draft": "first line" in text_path.read_text(encoding="utf-8"),
"latest_has_markdown_path": json.loads(workspace_entry.LATEST_NOTE_PATH.read_text(encoding="utf-8")).get("note_markdown_path", ""),
"latest_has_text_path": json.loads(workspace_entry.LATEST_NOTE_PATH.read_text(encoding="utf-8")).get("note_text_path", ""),
}, ensure_ascii=False))
'@
$noteExportResult = Invoke-JsonHarness -HarnessPath $noteExportHarnessPath -Arguments @($packageRoot) -WorkingDirectory $packageRoot
Assert-Condition ([bool]$noteExportResult.markdown_exists) 'XHS note export smoke did not create the markdown note.'
Assert-Condition ([bool]$noteExportResult.text_exists) 'XHS note export smoke did not create the text note.'
Assert-Condition ([bool]$noteExportResult.markdown_has_title) 'XHS note export smoke did not render markdown content.'
Assert-Condition ([bool]$noteExportResult.text_has_draft) 'XHS note export smoke did not render text content.'
Assert-Condition ([string]$noteExportResult.latest_has_markdown_path -eq [string]$noteExportResult.markdown_path) 'XHS note export smoke did not persist markdown path to latest note.'
Assert-Condition ([string]$noteExportResult.latest_has_text_path -eq [string]$noteExportResult.text_path) 'XHS note export smoke did not persist text path to latest note.'
$runScriptText = Get-Content -Path (Join-Path $packageRoot 'scripts\run.py') -Raw -Encoding utf8
Assert-Condition ($runScriptText.Contains($xhsAnalysisFallbackLog)) 'XHS local package smoke did not preserve the readable pure-text analysis fallback log.'
return [ordered]@{
ok = $true
projectId = 'xhs'
packageRoot = $packageRoot
locateContent = $locateContent
publishMessage = $publishMessage
providerName = [string]$diagnostic.provider
providerModel = [string]$diagnostic.model
textProviderName = [string]$textProviderResult.provider
textProviderModel = [string]$textProviderResult.text_model
extractedTopic = [string]$topicResult.topic
envImageProvider = [string]$envProviderResult.image_provider
envImageModel = [string]$envProviderResult.image_model
noteRetryCalls = [int]$noteRetryResult.calls
noteMarkdownPath = [string]$noteExportResult.markdown_path
noteTextPath = [string]$noteExportResult.text_path
}
}
function Run-DouyinSmoke {
param(
[string]$RepoRoot,
[string]$TempRoot
)
$firstPrompt = Decode-Utf8Base64 '5biu5oiR5YGa5LiA5Liq5oqW6Z+z5a6j5Lyg54yV54y25qGD55qE6KeG6aKR77yM57qv55S76Z2i5bCx6KGM44CC5YiG6ZWc5aS05bCR5LiA54K577yM6KeG6aKR5bCP5LiA54K5'
$askAudience = Decode-Utf8Base64 'Mi4g6L+Z5p2h6KeG6aKR5Li76KaB57uZ6LCB55yL'
$askStyle = Decode-Utf8Base64 'NC4g5L2g5oOz6KaB5LuA5LmI6aOO5qC8'
$askTopic = Decode-Utf8Base64 'MS4g6L+Z5qyh6KaB5YGa55qE5Li76aKY5oiW5Lqn5ZOB5piv5LuA5LmI'
$askVideoType = Decode-Utf8Base64 'My4g5L2g5YWI5piO56Gu6YCJ5LiA56eN'
$secondPrompt = Decode-Utf8Base64 '57uZ5ri45a6i55yL77yM5oiR5oOz6KaB57K+6Ie05bm/5ZGK5oSf'
$completedPrefix = Decode-Utf8Base64 '5bey5a6M5oiQ5oqW6Z+z6aG555uu5omn6KGM'
$projectDirLabel = Decode-Utf8Base64 '6aG555uu55uu5b2V77ya'
$videoRequestLabel = Decode-Utf8Base64 '6KeG6aKR6K+35rGC77ya'
$storyboardMarkdownLabel = Decode-Utf8Base64 '5YiG6ZWcIE1hcmtkb3du77ya'
$numberedPrompt = Decode-Utf8Base64 'Mua4uOWuoiA057K+6Ie05bm/5ZGK5oSf'
$fullySpecifiedPrompt = Decode-Utf8Base64 '5biu5oiR5YGa5LiA5Liq5pmv54K55a6j5Lyg55qE5oqW6Z+z6KeG6aKR77yM57uZ5ri45a6i55yL77yM57K+6Ie05bm/5ZGK5oSf77yM57qv55S76Z2i5Yqg5peB55m977yM5YiG6ZWcM+S4quS7peWGhe+8jOinhumikTE156eS77yM5Y+q5piv5Li65LqG5rWL6K+V'
$generateVideoPrompt = Decode-Utf8Base64 '5biu5oiR5YGa5LiA5Liq5o6o6ZSA54yV54y05qGD55qE6KeG6aKR77yM57uZ5ri45a6i55yL77yM57qv55S76Z2i57K+6Ie05bm/5ZGK5oSf77yMMTXnp5LvvIznlJ/miJDop4bpopE='
$kiwiTopic = Decode-Utf8Base64 '54yV54y05qGD'
$videoGeneratedStage = 'video_generated'
$finalVideoMarker = 'latest_seedance_split.mp4'
$sourceRoot = Join-Path $RepoRoot 'workspace\douyin'
$packageRoot = Join-Path $TempRoot 'package'
Copy-ProjectPackageSource -SourceRoot $sourceRoot -DestinationRoot $packageRoot
Write-DouyinCoordinatorSmokeStub -ProjectRoot $packageRoot
$sessionOne = 'local-smoke-session-1'
$firstResult = Invoke-WorkspaceEntry -ProjectRoot $packageRoot -Prompt $firstPrompt -SessionId $sessionOne
Assert-Condition ($firstResult.TerminalType -eq 'completed') "Douyin first preview smoke did not complete. output=$($firstResult.Output)"
$firstContent = [string]$firstResult.TerminalEvent.content
Assert-Condition ($firstContent.Contains($askAudience)) 'Douyin first preview smoke did not ask for audience.'
Assert-Condition ($firstContent.Contains($askStyle)) 'Douyin first preview smoke did not ask for style.'
Assert-Condition (-not $firstContent.Contains($askTopic)) 'Douyin first preview smoke still asked for topic.'
Assert-Condition (-not $firstContent.Contains($askVideoType)) 'Douyin first preview smoke still asked for video type.'
$secondResult = Invoke-WorkspaceEntry -ProjectRoot $packageRoot -Prompt $secondPrompt -SessionId $sessionOne
Assert-Condition ($secondResult.TerminalType -eq 'completed') "Douyin second preview smoke did not complete. output=$($secondResult.Output)"
$secondContent = [string]$secondResult.TerminalEvent.content
Assert-Condition ($secondContent.Contains($completedPrefix)) 'Douyin second preview smoke did not continue into execution.'
Assert-Condition ($secondContent.Contains($projectDirLabel)) 'Douyin second preview smoke did not surface the project directory.'
$pendingRoot = Join-Path $packageRoot 'memory\output\pending_intake'
$pendingFilesAfterSecond = @(Get-ChildItem -LiteralPath $pendingRoot -File -ErrorAction SilentlyContinue)
Assert-Condition ($pendingFilesAfterSecond.Count -eq 0) 'Douyin second preview smoke did not clear the pending intake state.'
$sessionTwo = 'local-smoke-session-2'
[void](Invoke-WorkspaceEntry -ProjectRoot $packageRoot -Prompt $firstPrompt -SessionId $sessionTwo)
$numberedResult = Invoke-WorkspaceEntry -ProjectRoot $packageRoot -Prompt $numberedPrompt -SessionId $sessionTwo
Assert-Condition ($numberedResult.TerminalType -eq 'completed') "Douyin numbered preview smoke did not complete. output=$($numberedResult.Output)"
$numberedContent = [string]$numberedResult.TerminalEvent.content
Assert-Condition ($numberedContent.Contains($completedPrefix)) 'Douyin numbered preview smoke did not complete execution.'
$sessionThree = 'local-smoke-session-3'
$fullPromptResult = Invoke-WorkspaceEntry -ProjectRoot $packageRoot -Prompt $fullySpecifiedPrompt -SessionId $sessionThree
Assert-Condition ($fullPromptResult.TerminalType -eq 'completed') "Douyin fully specified preview smoke did not complete. output=$($fullPromptResult.Output)"
$fullPromptContent = [string]$fullPromptResult.TerminalEvent.content
Assert-Condition ($fullPromptContent.Contains($completedPrefix)) 'Douyin fully specified preview smoke did not execute immediately.'
Assert-Condition (-not $fullPromptContent.Contains($askVideoType)) 'Douyin fully specified preview smoke still asked for video type.'
Assert-Condition ($fullPromptContent.Contains($projectDirLabel)) 'Douyin fully specified preview smoke did not surface the project directory.'
Assert-Condition ($fullPromptContent.Contains($videoRequestLabel)) 'Douyin fully specified preview smoke did not surface the video request file.'
Assert-Condition ($fullPromptContent.Contains($storyboardMarkdownLabel)) 'Douyin fully specified preview smoke did not surface the storyboard markdown file.'
$sessionFour = 'local-smoke-session-4'
$generateVideoResult = Invoke-WorkspaceEntry -ProjectRoot $packageRoot -Prompt $generateVideoPrompt -SessionId $sessionFour
Assert-Condition ($generateVideoResult.TerminalType -eq 'completed') "Douyin generate-video prompt smoke did not complete. output=$($generateVideoResult.Output)"
$generateVideoContent = [string]$generateVideoResult.TerminalEvent.content
Assert-Condition ($generateVideoContent.Contains($completedPrefix)) 'Douyin generate-video prompt smoke did not execute immediately.'
Assert-Condition (-not $generateVideoContent.Contains($askVideoType)) 'Douyin generate-video prompt smoke still asked for video type.'
Assert-Condition ($generateVideoContent.Contains($projectDirLabel)) 'Douyin generate-video prompt smoke did not surface the project directory.'
Assert-Condition ($generateVideoContent.Contains($videoGeneratedStage)) 'Douyin generate-video prompt smoke did not complete the final video stage.'
Assert-Condition ($generateVideoContent.Contains($finalVideoMarker)) 'Douyin generate-video prompt smoke did not surface the final video path.'
$latestProjectPath = Get-Content -LiteralPath (Join-Path $packageRoot 'memory\output\_latest_project_path.txt') -Raw -Encoding utf8
$latestProjectLeaf = Split-Path -Leaf $latestProjectPath.Trim()
Assert-Condition ($latestProjectLeaf.Contains($kiwiTopic)) "Douyin generate-video prompt smoke routed to the wrong topic directory. latest=$latestProjectLeaf"
$latestProjectVideo = Join-Path $latestProjectPath.Trim() 'latest_seedance_split.mp4'
Assert-Condition (Test-Path $latestProjectVideo) "Douyin generate-video prompt smoke did not create the final project video. path=$latestProjectVideo"
return [ordered]@{
ok = $true
projectId = 'douyin'
packageRoot = $packageRoot
firstContent = $firstContent
secondContent = $secondContent
numberedContent = $numberedContent
fullPromptContent = $fullPromptContent
generateVideoContent = $generateVideoContent
generateVideoProjectLeaf = $latestProjectLeaf
generateVideoPath = $latestProjectVideo
}
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$tempRoot = Join-Path $repoRoot '.tmp\local-project-package-smoke'
$resolvedResultPath = if ($SmokeOutput) { [System.IO.Path]::GetFullPath($SmokeOutput) } else { Join-Path $tempRoot 'result.json' }
if (Test-Path $tempRoot) {
Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null
$summary = [ordered]@{
ok = $true
projectId = $ProjectId
executedAt = [DateTime]::UtcNow.ToString('o')
results = [ordered]@{}
}
try {
if ($ProjectId -in @('all', 'xhs')) {
$summary.results.xhs = Run-XhsSmoke -RepoRoot $repoRoot -TempRoot (Join-Path $tempRoot 'xhs')
}
if ($ProjectId -in @('all', 'douyin')) {
$summary.results.douyin = Run-DouyinSmoke -RepoRoot $repoRoot -TempRoot (Join-Path $tempRoot 'douyin')
}
Write-Utf8File -FilePath $resolvedResultPath -Content (($summary | ConvertTo-Json -Depth 8))
Write-Host (($summary | ConvertTo-Json -Depth 8))
} catch {
$failure = [ordered]@{
ok = $false
projectId = $ProjectId
error = $_.Exception.Message
}
Write-Utf8File -FilePath $resolvedResultPath -Content (($failure | ConvertTo-Json -Depth 8))
throw
}
......@@ -16,6 +16,50 @@ function Write-Utf8File {
[System.IO.File]::WriteAllText($filePath, $content, $encoding)
}
function Get-XhsVolcesImageProviderConfig {
$modelId = 'doubao-seedream-5-0-260128'
return [ordered]@{
providerName = 'volces'
modelId = $modelId
providers = [ordered]@{
volces = [ordered]@{
baseUrl = 'https://ark.cn-beijing.volces.com/api/v3/images/generations'
apiKey = 'c434d7a4-ac38-48c4-9d50-ce0e3c9aaf6b'
api = 'openai-completions'
models = @(
[ordered]@{
id = $modelId
name = 'volces-gen-image'
reasoning = $false
input = @('text', 'image')
cost = [ordered]@{
input = 0
output = 0
cacheRead = 0
cacheWrite = 0
}
contextWindow = 128000
maxTokens = 81920
}
)
}
}
}
}
function Write-Base64File {
param(
[string]$FilePath,
[string]$Base64
)
$parent = Split-Path -Parent $FilePath
if ($parent) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
[System.IO.File]::WriteAllBytes($FilePath, [System.Convert]::FromBase64String($Base64))
}
function Copy-ProjectBundleSource {
param(
[string]$SourceRoot,
......@@ -43,7 +87,17 @@ function Copy-ProjectBundleSource {
if ($excludeSet.Contains($entry.Name)) {
continue
}
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationRoot $entry.Name) -Recurse -Force
if ($entry.PSIsContainer -and $entry.Name -match '^tmp[a-z0-9]{6,}$') {
Write-Warning "Skipping transient temp directory from bundle source: $($entry.FullName)"
continue
}
try {
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationRoot $entry.Name) -Recurse -Force
}
catch [System.UnauthorizedAccessException] {
Write-Warning "Skipping inaccessible bundle source entry: $($entry.FullName)"
continue
}
}
}
......@@ -115,6 +169,17 @@ $expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileNam
$expertPrompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('5Y+R6YCB5LiA5Liq56+u55CD5oqA5ben5biW5a2Q'))
$expectedExpertIds = @('browser-expert-smoke', 'douyin-expert-smoke', 'xhs')
$electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1'
$attachmentFixturePath = Join-Path $BaseOutputDir 'fixtures\smoke-image.png'
$expectedAttachmentDir = Join-Path $userDataPath 'projects\xhs\inputs\images\main'
$attachmentFixtureName = 'smoke-image.png'
$attachmentPayload = @(
@{
kind = 'image'
name = $attachmentFixtureName
mimeType = 'image/png'
localPath = $attachmentFixturePath
}
)
$xhsSourceCandidates = @(
(Join-Path $repoRoot 'workspace\xhs'),
(Join-Path $repoRoot '.tmp\real-api-bundle-check-2\bundle-src\xhs'),
......@@ -131,6 +196,7 @@ if (-not $xhsSourceRoot) {
throw "XHS workspace source was not found in any expected location: $($xhsSourceCandidates -join ', ')"
}
Write-Base64File -FilePath $attachmentFixturePath -Base64 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aKJwAAAAASUVORK5CYII='
Copy-ProjectBundleSource -SourceRoot $xhsSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'xhs')
if (Test-Path $bundleZipPath) {
Remove-Item $bundleZipPath -Force
......@@ -158,8 +224,14 @@ $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_SMOKE_ATTACHMENTS_JSON = (ConvertTo-Json -InputObject @($attachmentPayload) -Depth 5 -Compress)
$xhsImageProviderConfig = Get-XhsVolcesImageProviderConfig
$env:QJCLAW_EXTRA_MODEL_PROVIDERS_JSON = ($xhsImageProviderConfig.providers | ConvertTo-Json -Depth 10 -Compress)
$env:XHS_IMAGE_PROVIDER = $xhsImageProviderConfig.providerName
$env:XHS_IMAGE_MODEL = $xhsImageProviderConfig.modelId
$env:QJCLAW_XHS_SMOKE_MODE = '1'
$env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH = '1'
$env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY = '1'
try {
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert cloud-bundle smoke' -ArgumentList @(
......@@ -189,6 +261,11 @@ const result = JSON.parse(fs.readFileSync(smokeOutput, 'utf8'));
if (!result.ok) {
throw new Error(result.error || 'Smoke failed.');
}
function sanitizeAttachmentFileComponent(value) {
const trimmed = String(value || '').trim();
const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
return sanitized || 'attachment';
}
const expectedExpertIds = expectedExpertIdsCsv.split(',').map((value) => value.trim()).filter(Boolean).sort();
const finalState = result.finalState || {};
const sendResult = result.sendResult || {};
......@@ -261,6 +338,21 @@ if (statusLabels.some((label) => label.includes('Routing to skill'))) {
if (!workspaceLaunchAccepted) {
throw new Error('Expert smoke did not expose a workspace-entry launch status: ' + JSON.stringify(statusLabels));
}
const smokeAttachments = Array.isArray(sendResult.smokeAttachments) ? sendResult.smokeAttachments : [];
if (smokeAttachments.length !== 1) {
throw new Error('Expected exactly one smoke attachment. actual=' + smokeAttachments.length);
}
const attachmentSessionId = String(sendResult.sessionId || '');
const sessionSlug = sanitizeAttachmentFileComponent(attachmentSessionId.replace(/[:]/g, '-'));
const expectedAttachmentRelativePath = 'inputs/images/main/' + sessionSlug + '-01.png';
const expectedAttachmentPath = path.join(userDataPath, 'projects', 'xhs', ...expectedAttachmentRelativePath.split('/'));
if (!fs.existsSync(expectedAttachmentPath)) {
throw new Error('Materialized attachment file was not found: ' + expectedAttachmentPath);
}
const attachmentStat = fs.statSync(expectedAttachmentPath);
if (!attachmentStat.isFile() || attachmentStat.size < 1) {
throw new Error('Materialized attachment file is empty: ' + expectedAttachmentPath);
}
console.log(JSON.stringify({
ok: true,
smokeOutput,
......@@ -272,6 +364,9 @@ console.log(JSON.stringify({
executionPolicySource: streamSmoke.executionPolicySource || null,
sessionId: sendResult.sessionId || null,
selectedSkillId: sendResult.selectedSkillId || streamSmoke.selectedSkillId || null,
attachmentCount: smokeAttachments.length,
attachmentPath: expectedAttachmentPath,
attachmentRelativePath: expectedAttachmentRelativePath,
statusLabels,
bundleManifestPath
}, null, 2));
......@@ -288,6 +383,11 @@ finally {
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_ATTACHMENTS_JSON -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_EXTRA_MODEL_PROVIDERS_JSON -ErrorAction SilentlyContinue
Remove-Item Env:XHS_IMAGE_PROVIDER -ErrorAction SilentlyContinue
Remove-Item Env:XHS_IMAGE_MODEL -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_XHS_SMOKE_MODE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY -ErrorAction SilentlyContinue
}
......@@ -17,7 +17,7 @@ $ErrorActionPreference = 'Stop'
if (-not $PSBoundParameters.ContainsKey('Prompt') -or [string]::IsNullOrWhiteSpace($Prompt)) {
$Prompt = [System.Text.Encoding]::UTF8.GetString(
[System.Convert]::FromBase64String('5Y+R6YCB5LiA5Liq56+u55CD5oqA5ben5biW5a2Q')
[System.Convert]::FromBase64String('5biu5oiR5YGa5LiA56+H5YWz5LqO6Ziy5pmS5Za36Zu+55qE5bCP57qi5Lmm5Zu+5paH56yU6K6w5paH5qGI77yM5Y+X5LyX5piv6YCa5Yuk5aWz55Sf77yM6aOO5qC85bm85a6e55So77yM5YGa5oiQ5Zu+5paH56yU6K6w')
)
}
......@@ -27,6 +27,108 @@ function Write-Utf8File {
[System.IO.File]::WriteAllText($filePath, $content, $encoding)
}
function Get-XhsVolcesImageProviderConfig {
$modelId = 'doubao-seedream-5-0-260128'
return [ordered]@{
providerName = 'volces'
modelId = $modelId
providers = [ordered]@{
volces = [ordered]@{
baseUrl = 'https://ark.cn-beijing.volces.com/api/v3/images/generations'
apiKey = 'c434d7a4-ac38-48c4-9d50-ce0e3c9aaf6b'
api = 'openai-completions'
models = @(
[ordered]@{
id = $modelId
name = 'volces-gen-image'
reasoning = $false
input = @('text', 'image')
cost = [ordered]@{
input = 0
output = 0
cacheRead = 0
cacheWrite = 0
}
contextWindow = 128000
maxTokens = 81920
}
)
}
}
}
}
function Write-Base64File {
param(
[string]$FilePath,
[string]$Base64
)
$parent = Split-Path -Parent $FilePath
if ($parent) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
[System.IO.File]::WriteAllBytes($FilePath, [System.Convert]::FromBase64String($Base64))
}
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
}
if ($entry.PSIsContainer -and $entry.Name -match '^tmp[a-z0-9]{6,}$') {
Write-Warning "Skipping transient temp directory from bundle source: $($entry.FullName)"
continue
}
try {
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationRoot $entry.Name) -Recurse -Force
}
catch [System.UnauthorizedAccessException] {
Write-Warning "Skipping inaccessible bundle source entry: $($entry.FullName)"
continue
}
}
}
function Reset-XhsLiveRunBundleState {
param([string]$ProjectRoot)
$memoryRoot = Join-Path $ProjectRoot 'memory'
foreach ($relativePath in @(
'openclaw_runs',
'generated_images',
'xhs_profile',
'project.env'
)) {
$fullPath = Join-Path $memoryRoot $relativePath
if (Test-Path $fullPath) {
Remove-Item -LiteralPath $fullPath -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
function New-ExpertFixtureProject {
param(
[string]$ProjectsRoot,
......@@ -128,15 +230,27 @@ $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'
$bundleProjectName = 'xiaohongshu-writer'
$bundleSkillId = 'xhs-project-bundle'
$bundleConfigVersion = '2026-04-03T18:00:00.000Z'
$bundleConfigVersion = '2026-04-13T18:30: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'
$attachmentFixturePath = Join-Path $BaseOutputDir 'fixtures\portrait.png'
$attachmentFixtureName = 'portrait.png'
$attachmentPayload = @(
@{
kind = 'image'
name = $attachmentFixtureName
mimeType = 'image/png'
localPath = $attachmentFixturePath
}
)
$latestNotePath = Join-Path $userDataPath 'projects\xhs\memory\openclaw_runs\xhs_last_note.json'
$xhsProfilePath = Join-Path $userDataPath 'projects\xhs\memory\xhs_profile'
$waitPaths = @($latestNotePath, $xhsProfilePath)
$xhsSourceCandidates = @(
(Join-Path $repoRoot 'workspace\xhs'),
(Join-Path $repoRoot '.tmp\real-api-bundle-check\user-data\projects\xhs')
(Join-Path $repoRoot 'workspace\xhs')
)
$xhsSourceRoot = $xhsSourceCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
......@@ -146,6 +260,7 @@ if (Test-Path $BaseOutputDir) {
Remove-Item $logsPath -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $smokeOutput -Force -ErrorAction SilentlyContinue
Remove-Item $bundleZipPath -Force -ErrorAction SilentlyContinue
Remove-Item $attachmentFixturePath -Force -ErrorAction SilentlyContinue
}
else {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
......@@ -157,7 +272,9 @@ if (-not $xhsSourceRoot) {
throw "XHS workspace source was not found in any expected location: $($xhsSourceCandidates -join ', ')"
}
Copy-Item -LiteralPath $xhsSourceRoot -Destination $bundleSourceRoot -Recurse -Force
Write-Base64File -FilePath $attachmentFixturePath -Base64 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aKJwAAAAASUVORK5CYII='
Copy-ProjectBundleSource -SourceRoot $xhsSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'xhs')
Reset-XhsLiveRunBundleState -ProjectRoot (Join-Path $bundleSourceRoot 'xhs')
if (Test-Path $bundleZipPath) {
Remove-Item $bundleZipPath -Force
}
......@@ -185,15 +302,16 @@ $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_ATTACHMENTS_JSON = (ConvertTo-Json -InputObject @($attachmentPayload) -Depth 5 -Compress)
$xhsImageProviderConfig = Get-XhsVolcesImageProviderConfig
$env:QJCLAW_EXTRA_MODEL_PROVIDERS_JSON = ($xhsImageProviderConfig.providers | ConvertTo-Json -Depth 10 -Compress)
$env:XHS_IMAGE_PROVIDER = $xhsImageProviderConfig.providerName
$env:XHS_IMAGE_MODEL = $xhsImageProviderConfig.modelId
$env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS = ([int]$StreamTimeoutSeconds * 1000).ToString()
$env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH = '1'
$expectedArtifactPaths = @(
(Join-Path $userDataPath 'projects\xhs\basketball_draft.json'),
(Join-Path $userDataPath 'projects\xhs\publish_basketball_manual.py'),
(Join-Path $userDataPath 'projects\xhs\xhs_profile')
)
$env:QJCLAW_SMOKE_WAIT_FOR_PATHS = [string]::Join([System.IO.Path]::PathSeparator, $expectedArtifactPaths)
$env:QJCLAW_SMOKE_WAIT_FOR_PATHS = [string]::Join([System.IO.Path]::PathSeparator, $waitPaths)
$env:QJCLAW_SMOKE_WAIT_FOR_PATHS_TIMEOUT_MS = ([int]$StreamTimeoutSeconds * 1000).ToString()
$env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY = '1'
try {
$electronSmokeArguments = @(
......@@ -204,7 +322,11 @@ try {
'-LogsPath', $logsPath,
'-RuntimeMode', 'bundled-runtime',
'-ExpectBundledRuntime',
'-ExpectWorkspaceEntry',
'-PreserveUserData',
'-WorkspaceProjectId', $bundleProjectId,
'-WorkspaceProjectName', $bundleProjectName,
'-WorkspaceMarkerFile', 'AGENTS.md',
'-SmokePrompt', $Prompt,
'-SmokeViewMode', 'experts',
'-SmokeProjectId', $bundleProjectId,
......@@ -214,7 +336,7 @@ try {
$electronSmokeArguments += '-UseExistingCloudConfig'
}
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert live run' -ArgumentList $electronSmokeArguments
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'xhs expert live run' -ArgumentList $electronSmokeArguments -MaxAttempts 1
$summary = & node -e @"
const fs = require('fs');
......@@ -277,6 +399,9 @@ if (expertProjectIds.length !== expectedExpertIds.length || expertProjectIds.som
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('Live run session did not bind to xhs: ' + String(sendResult.sessionId || ''));
}
const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || ''))
: [];
......@@ -293,25 +418,120 @@ if (statusLabels.some((label) => label.includes('Routing to skill'))) {
if (!workspaceLaunchAccepted) {
throw new Error('Live run did not expose a workspace-entry launch status: ' + JSON.stringify(statusLabels));
}
const projectRoot = path.join(userDataPath, 'projects', 'xhs');
const smokeAttachments = Array.isArray(sendResult.smokeAttachments) ? sendResult.smokeAttachments : [];
if (smokeAttachments.length !== 1) {
throw new Error('Expected exactly one smoke attachment. actual=' + smokeAttachments.length);
}
const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || '');
const basketballDraftPath = path.join(projectRoot, 'basketball_draft.json');
const manualPublishPath = path.join(projectRoot, 'publish_basketball_manual.py');
const xhsProfilePath = path.join(projectRoot, 'xhs_profile');
const artifactState = {
basketballDraft: fs.existsSync(basketballDraftPath),
manualPublishScript: fs.existsSync(manualPublishPath),
xhsProfile: fs.existsSync(xhsProfilePath)
};
if (!artifactState.basketballDraft) {
throw new Error('Live run did not materialize basketball_draft.json.');
}
if (!artifactState.manualPublishScript) {
throw new Error('Live run did not materialize publish_basketball_manual.py.');
}
if (!artifactState.xhsProfile) {
throw new Error('Live run did not create the Xiaohongshu browser profile directory.');
if (!assistantContent.includes('Project attachments:')) {
throw new Error('Assistant content did not echo project attachments.');
}
const assistantAttachmentMatch = assistantContent.match(/inputs\/images\/main\/[^\r\n]+/);
if (!assistantAttachmentMatch) {
throw new Error('Assistant content did not expose a materialized attachment path.');
}
const workspaceAttachmentRelativePath = assistantAttachmentMatch[0].trim();
const workspaceAttachmentPath = path.join(userDataPath, 'projects', 'xhs', ...workspaceAttachmentRelativePath.split('/'));
if (!fs.existsSync(workspaceAttachmentPath)) {
throw new Error('Workspace attachment file was not found: ' + workspaceAttachmentPath);
}
const workspaceAttachmentStat = fs.statSync(workspaceAttachmentPath);
if (!workspaceAttachmentStat.isFile() || workspaceAttachmentStat.size < 1) {
throw new Error('Workspace attachment file is empty: ' + workspaceAttachmentPath);
}
const projectRoot = path.join(userDataPath, 'projects', 'xhs');
const latestNotePath = path.join(projectRoot, 'memory', 'openclaw_runs', 'xhs_last_note.json');
const generatedRoot = path.join(projectRoot, 'memory', 'generated_images');
const xhsProfilePath = path.join(projectRoot, 'memory', 'xhs_profile');
if (!fs.existsSync(latestNotePath)) {
throw new Error('Live run did not write xhs_last_note.json.');
}
if (!fs.existsSync(generatedRoot) || !fs.statSync(generatedRoot).isDirectory()) {
throw new Error('Live run did not create memory/generated_images.');
}
if (!fs.existsSync(xhsProfilePath) || !fs.statSync(xhsProfilePath).isDirectory()) {
throw new Error('Live run did not create memory/xhs_profile.');
}
const latestNote = JSON.parse(fs.readFileSync(latestNotePath, 'utf8'));
if (!latestNote || typeof latestNote !== 'object') {
throw new Error('xhs_last_note.json is not an object.');
}
const title = String(latestNote.title || '').trim();
const draft = String(latestNote.draft || '').trim();
const tags = Array.isArray(latestNote.tags) ? latestNote.tags.map((value) => String(value || '').trim()).filter(Boolean) : [];
const images = Array.isArray(latestNote.images) ? latestNote.images.map((value) => String(value || '').trim()).filter(Boolean) : [];
const noteMarkdownPath = String(latestNote.note_markdown_path || '').trim();
const noteTextPath = String(latestNote.note_text_path || '').trim();
/*
function looksLikePlaceholder(value) {
const text = String(value || '').trim().replace(/^["'“”‘’]+|["'“”‘’]+$/g, '');
if (!text) {
return true;
}
const compact = text.replace(/\s+/g, '').toLowerCase();
if (['...', '…', '。。。', 'placeholder', 'todo', 'tbd', '示例', '样例', '待补充', '待填写'].includes(compact)) {
return true;
}
return /^[.。…·_-]{2,}$/.test(text) || /^(真实标题|真实正文|标签[a-z0-9一二三四五六七八九十]*)$/i.test(text);
}
*/
function looksLikePlaceholder(value) {
const text = String(value || '').trim().replace(/^["'\s]+|["'\s]+$/g, '');
if (!text) {
return true;
}
const compact = text.replace(/\s+/g, '').toLowerCase();
if (['...', 'placeholder', 'todo', 'tbd', 'example', 'sample', 'draft', 'title'].includes(compact)) {
return true;
}
return /^[._-]{2,}$/.test(text);
}
if (!title) {
throw new Error('xhs_last_note.json is missing title.');
}
if (!draft) {
throw new Error('xhs_last_note.json is missing draft.');
}
if (looksLikePlaceholder(title)) {
throw new Error('xhs_last_note.json title is still placeholder text: ' + title);
}
if (looksLikePlaceholder(draft) || draft.replace(/\s+/g, '').length < 60) {
throw new Error('xhs_last_note.json draft is still placeholder text or too short.');
}
if (tags.length < 3 || tags.some((value) => looksLikePlaceholder(value))) {
throw new Error('xhs_last_note.json tags are still placeholder text: ' + JSON.stringify(tags));
}
if (images.length < 1) {
throw new Error('xhs_last_note.json does not contain generated images.');
}
if (!noteMarkdownPath) {
throw new Error('xhs_last_note.json is missing note_markdown_path.');
}
if (!noteTextPath) {
throw new Error('xhs_last_note.json is missing note_text_path.');
}
if (!fs.existsSync(noteMarkdownPath) || !fs.statSync(noteMarkdownPath).isFile()) {
throw new Error('Markdown note file is missing: ' + noteMarkdownPath);
}
if (!fs.existsSync(noteTextPath) || !fs.statSync(noteTextPath).isFile()) {
throw new Error('Text note file is missing: ' + noteTextPath);
}
const normalizedGeneratedRoot = path.normalize(generatedRoot + path.sep);
const resolvedImages = images.map((imagePath) => {
const resolvedPath = path.isAbsolute(imagePath) ? imagePath : path.join(projectRoot, imagePath);
const normalizedPath = path.normalize(resolvedPath);
if (!normalizedPath.startsWith(normalizedGeneratedRoot)) {
throw new Error('Generated image escaped memory/generated_images: ' + normalizedPath);
}
if (!fs.existsSync(normalizedPath)) {
throw new Error('Generated image file is missing: ' + normalizedPath);
}
const imageStat = fs.statSync(normalizedPath);
if (!imageStat.isFile() || imageStat.size < 1) {
throw new Error('Generated image file is empty: ' + normalizedPath);
}
return normalizedPath;
});
console.log(JSON.stringify({
ok: true,
smokeOutput,
......@@ -321,12 +541,16 @@ console.log(JSON.stringify({
expertProjectIds,
nonHomeProjectCount: nonHomeProjects.length,
executionPolicySource: streamSmoke.executionPolicySource || null,
sessionId: streamSmoke.sessionId || sendResult.sessionId || null,
latestStatusLabel: streamSmoke.latestStatusLabel || null,
selectedSkillId: sendResult.selectedSkillId || streamSmoke.selectedSkillId || null,
sessionId: sendResult.sessionId || null,
latestNotePath,
noteMarkdownPath,
noteTextPath,
generatedImageCount: resolvedImages.length,
generatedImages: resolvedImages,
workspaceAttachmentPath,
workspaceAttachmentRelativePath,
xhsProfilePath,
statusLabels,
assistantContent,
artifactState,
bundleManifestPath
}, null, 2));
"@ $smokeOutput $userDataPath $expectedBundleSourceUrl $bundleConfigVersion $bundleFileName $bundleSkillId $Prompt ($expectedExpertIds -join ',')
......@@ -342,8 +566,13 @@ finally {
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_ATTACHMENTS_JSON -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_EXTRA_MODEL_PROVIDERS_JSON -ErrorAction SilentlyContinue
Remove-Item Env:XHS_IMAGE_PROVIDER -ErrorAction SilentlyContinue
Remove-Item Env:XHS_IMAGE_MODEL -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_STREAM_TIMEOUT_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_ACCEPT_WORKSPACE_LAUNCH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_WAIT_FOR_PATHS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_WAIT_FOR_PATHS_TIMEOUT_MS -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_DISABLE_LOCAL_OPENCLAW_GATEWAY -ErrorAction SilentlyContinue
}
......@@ -13,6 +13,109 @@ function Write-Utf8File {
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
}
function Get-XhsVolcesImageProviderConfig {
$modelId = 'doubao-seedream-5-0-260128'
return [ordered]@{
providerName = 'volces'
modelId = $modelId
providers = [ordered]@{
volces = [ordered]@{
baseUrl = 'https://ark.cn-beijing.volces.com/api/v3/images/generations'
apiKey = 'c434d7a4-ac38-48c4-9d50-ce0e3c9aaf6b'
api = 'openai-completions'
models = @(
[ordered]@{
id = $modelId
name = 'volces-gen-image'
reasoning = $false
input = @('text', 'image')
cost = [ordered]@{
input = 0
output = 0
cacheRead = 0
cacheWrite = 0
}
contextWindow = 128000
maxTokens = 81920
}
)
}
}
}
}
function Get-XhsQwenTextProviderConfig {
$modelId = 'qwen3.5-plus'
return [ordered]@{
providerName = 'qwen'
modelId = $modelId
providers = [ordered]@{
qwen = [ordered]@{
baseUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
apiKey = 'sk-cfd27311f00649438753fac04851585b'
api = 'openai-completions'
models = @(
[ordered]@{
id = $modelId
name = 'qwen-chat'
reasoning = $false
input = @('text')
cost = [ordered]@{
input = 0
output = 0
cacheRead = 0
cacheWrite = 0
}
contextWindow = 128000
maxTokens = 8192
}
)
}
}
}
}
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
}
if ($entry.PSIsContainer -and $entry.Name -match '^tmp[a-z0-9]{6,}$') {
Write-Warning "Skipping transient temp directory from bundle source: $($entry.FullName)"
continue
}
try {
Copy-Item -LiteralPath $entry.FullName -Destination (Join-Path $DestinationRoot $entry.Name) -Recurse -Force
}
catch [System.UnauthorizedAccessException] {
Write-Warning "Skipping inaccessible bundle source entry: $($entry.FullName)"
continue
}
}
}
function New-ExpertFixtureProject {
param(
[string]$ProjectsRoot,
......@@ -83,7 +186,7 @@ if (Test-Path $BaseOutputDir) {
New-Item -ItemType Directory -Force -Path $BaseOutputDir, $userDataPath, $logsPath, $bundleSourceRoot | Out-Null
Copy-Item -LiteralPath $xhsSourceRoot -Destination $bundleSourceRoot -Recurse -Force
Copy-ProjectBundleSource -SourceRoot $xhsSourceRoot -DestinationRoot (Join-Path $bundleSourceRoot 'xhs')
if (Test-Path $bundleZipPath) {
Remove-Item -LiteralPath $bundleZipPath -Force
}
......@@ -109,10 +212,30 @@ $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
$xhsImageProviderConfig = Get-XhsVolcesImageProviderConfig
$xhsTextProviderConfig = Get-XhsQwenTextProviderConfig
$combinedProviders = [ordered]@{}
foreach ($entry in $xhsTextProviderConfig.providers.GetEnumerator()) {
$combinedProviders[$entry.Key] = $entry.Value
}
foreach ($entry in $xhsImageProviderConfig.providers.GetEnumerator()) {
$combinedProviders[$entry.Key] = $entry.Value
}
$env:QJCLAW_EXTRA_MODEL_PROVIDERS_JSON = ($combinedProviders | ConvertTo-Json -Depth 10 -Compress)
$env:XHS_LLM_PROVIDER = $xhsTextProviderConfig.providerName
$env:QWEN_BASE_URL = [string]$xhsTextProviderConfig.providers.qwen.baseUrl
$env:QWEN_API_KEY = [string]$xhsTextProviderConfig.providers.qwen.apiKey
$env:QWEN_MODEL = $xhsTextProviderConfig.modelId
$env:XHS_IMAGE_PROVIDER = $xhsImageProviderConfig.providerName
$env:XHS_IMAGE_BASE_URL = [string]$xhsImageProviderConfig.providers.volces.baseUrl
$env:XHS_IMAGE_API_KEY = [string]$xhsImageProviderConfig.providers.volces.apiKey
$env:XHS_IMAGE_MODEL = $xhsImageProviderConfig.modelId
$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 "Configured XHS text provider: $($xhsTextProviderConfig.providerName)/$($xhsTextProviderConfig.modelId)"
Write-Host "Configured XHS image provider: $($xhsImageProviderConfig.providerName)/$($xhsImageProviderConfig.modelId)"
Write-Host "Open the Experts view, select xhs, and send your manual prompt."
......@@ -17,7 +17,12 @@
"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",
"smoke:douyin-expert-cloud-bundle": "powershell -ExecutionPolicy Bypass -File build/scripts/douyin-expert-cloud-bundle-smoke.ps1",
"smoke:local-project-package": "powershell -ExecutionPolicy Bypass -File build/scripts/local-project-package-smoke.ps1",
"smoke:xhs-local-project-package": "powershell -ExecutionPolicy Bypass -File build/scripts/local-project-package-smoke.ps1 -ProjectId xhs",
"smoke:douyin-local-project-package": "powershell -ExecutionPolicy Bypass -File build/scripts/local-project-package-smoke.ps1 -ProjectId douyin",
"launch:xhs-local-manual": "powershell -ExecutionPolicy Bypass -File build/scripts/xhs-expert-manual-launch.ps1",
"launch:douyin-local-manual": "powershell -ExecutionPolicy Bypass -File build/scripts/douyin-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",
......
......@@ -96,6 +96,7 @@ interface PythonPayloadProbeResult {
pythonVersion?: string;
installedPackages: string[];
missingModules: string[];
importErrors?: Record<string, string>;
error?: string;
}
......@@ -155,9 +156,23 @@ const PYTHON_RUNTIME_IMPORTS = [
["pyyaml", "yaml"],
["pillow", "PIL"],
["python-dotenv", "dotenv"],
["playwright", "playwright"]
["greenlet", "greenlet"],
["playwright", "playwright"],
["edge-tts", "edge_tts"]
] as const;
const PYTHON_RUNTIME_DIRECT_IMPORT_PROBES = [
["greenlet", "import greenlet"],
["playwright.async_api", "from playwright.async_api import async_playwright"]
] as const;
const PYTHON_RUNTIME_PROBE_NAMES = [
...new Set([
...PYTHON_RUNTIME_IMPORTS.map(([packageName]) => packageName),
...PYTHON_RUNTIME_DIRECT_IMPORT_PROBES.map(([probeName]) => probeName)
])
];
function formatExecError(error: unknown): string {
if (error instanceof Error) {
const typedError = error as Error & { code?: number | string; stderr?: string };
......@@ -365,6 +380,13 @@ function formatPayloadIssue(
}
if (pythonProbe.missingModules.length > 0) {
const importErrors = Object.entries(pythonProbe.importErrors ?? {});
if (importErrors.length > 0) {
const formattedImportErrors = importErrors
.map(([probeName, message]) => `${probeName} (${message})`)
.join("; ");
return `the bundled Python dependency probe failed. Missing or broken imports: ${pythonProbe.missingModules.join(", ")}. Import errors: ${formattedImportErrors}`;
}
return `the bundled Python dependency probe failed. Missing modules: ${pythonProbe.missingModules.join(", ")}`;
}
......@@ -375,19 +397,30 @@ async function probePythonPayload(pythonExecutable: string): Promise<PythonPaylo
const inlineScript = [
"import importlib.util, json, sys",
`modules = ${JSON.stringify(PYTHON_RUNTIME_IMPORTS)}`,
`direct_imports = ${JSON.stringify(PYTHON_RUNTIME_DIRECT_IMPORT_PROBES)}`,
"installed = []",
"missing = []",
"import_errors = {}",
"for package_name, module_name in modules:",
" spec = importlib.util.find_spec(module_name)",
" if spec is None:",
" missing.append(package_name)",
" if package_name not in missing:",
" missing.append(package_name)",
" else:",
" installed.append(package_name)",
"for probe_name, statement in direct_imports:",
" try:",
" exec(statement, {})",
" except Exception as exc:",
" if probe_name not in missing:",
" missing.append(probe_name)",
" import_errors[probe_name] = f\"{type(exc).__name__}: {exc}\"",
"print(json.dumps({",
" 'ready': len(missing) == 0,",
" 'pythonVersion': sys.version.split()[0],",
" 'installedPackages': installed,",
" 'missingModules': missing",
" 'missingModules': missing,",
" 'importErrors': import_errors",
"}))"
].join("\n");
......@@ -399,13 +432,15 @@ async function probePythonPayload(pythonExecutable: string): Promise<PythonPaylo
pythonVersion: parsed.pythonVersion,
installedPackages: parsed.installedPackages ?? [],
missingModules: parsed.missingModules ?? [],
importErrors: parsed.importErrors ?? {},
error: parsed.error
};
} catch (error) {
return {
ready: false,
installedPackages: [],
missingModules: PYTHON_RUNTIME_IMPORTS.map(([packageName]) => packageName),
missingModules: [...PYTHON_RUNTIME_PROBE_NAMES],
importErrors: {},
error: formatExecError(error)
};
}
......@@ -461,6 +496,7 @@ async function probePythonPayloadFromManifest(pythonManifestPath: string): Promi
pythonVersion: typeof parsed.pythonVersion === "string" ? parsed.pythonVersion : undefined,
installedPackages,
missingModules,
importErrors: {},
error: undefined
};
} catch {
......@@ -578,7 +614,7 @@ export class RuntimeManager extends EventEmitter {
pythonReady: false,
pythonVersion: undefined,
installedPythonPackages: [],
pythonMissingModules: [...PYTHON_RUNTIME_IMPORTS.map(([packageName]) => packageName)],
pythonMissingModules: [...PYTHON_RUNTIME_PROBE_NAMES],
runtimeDataDir: resolved.runtimeDataDir,
runtimeStateDir: resolved.runtimeStateDir,
runtimeLogsDir: resolved.runtimeLogsDir,
......@@ -662,7 +698,8 @@ export class RuntimeManager extends EventEmitter {
: {
ready: false,
installedPackages: [],
missingModules: PYTHON_RUNTIME_IMPORTS.map(([packageName]) => packageName),
missingModules: [...PYTHON_RUNTIME_PROBE_NAMES],
importErrors: {},
error: undefined
};
......
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