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

Add project routing and package orchestrator flows

parent 4c113250
......@@ -20,8 +20,11 @@ import { SkillClient } from "./services/skill-client.js";
import { SkillStoreService } from "./services/skill-store.js";
import { ProjectStoreService } from "./services/project-store.js";
import { ProjectBundleService } from "./services/project-bundle.js";
import { ProjectChatTargetResolverService } from "./services/project-chat-target-resolver.js";
import { ProjectContextService } from "./services/project-context.js";
import { ProjectExecutionRouter } from "./services/project-execution-router.js";
import { ProjectIntentRouterService } from "./services/project-intent-router.js";
import { ProjectSkillRouterService } from "./services/project-skill-router.js";
import { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
interface RendererSmokeState {
......@@ -606,6 +609,9 @@ async function bootstrap(): Promise<void> {
}
};
const projectContextService = new ProjectContextService(projectStore);
const projectIntentRouter = new ProjectIntentRouterService(projectStore);
const projectSkillRouter = new ProjectSkillRouterService(projectStore);
const projectChatTargetResolver = new ProjectChatTargetResolverService(projectStore, projectIntentRouter);
const projectExecutionRouter = new ProjectExecutionRouter();
runtimeCloudClient.onPayloadUpdated(async ({ config: payloadConfig, skills }) => {
await skillStore.reconcile(skills, payloadConfig.configVersion);
......@@ -754,6 +760,8 @@ async function bootstrap(): Promise<void> {
runtimeSkillBridge,
projectStore,
projectContextService,
projectChatTargetResolver,
projectSkillRouter,
projectExecutionRouter,
projectWorkspaceExecutor,
systemSummary,
......
This diff is collapsed.
import { createHash } from "node:crypto";
import { createHash } from "node:crypto";
import { promisify } from "node:util";
import { execFile } from "node:child_process";
import http from "node:http";
......@@ -42,6 +42,7 @@ interface ProjectJsonShape {
name?: string;
version?: string;
description?: string;
[key: string]: unknown;
}
interface BundleReplacementOperation {
......@@ -463,7 +464,8 @@ export class ProjectBundleService {
const stagingRoot = path.join(tempRoot, "materialized");
const stagedProjectRoot = path.join(stagingRoot, "projects", metadata.projectId);
await this.stageProjectDirectory(metadata, stagedProjectRoot);
const existingProjectRoot = path.join(projectsRoot, metadata.projectId);
await this.stageProjectDirectory(metadata, stagedProjectRoot, existingProjectRoot);
const stagedSkills = await this.stageSharedDirectories(
this.resolveSharedSubdir(metadata, "skills"),
......@@ -541,7 +543,11 @@ export class ProjectBundleService {
return projectDir;
}
private async stageProjectDirectory(metadata: BundleProjectMetadata, stagedProjectRoot: string): Promise<void> {
private async stageProjectDirectory(
metadata: BundleProjectMetadata,
stagedProjectRoot: string,
existingProjectRoot?: string
): Promise<void> {
await rm(stagedProjectRoot, { recursive: true, force: true }).catch(() => undefined);
await mkdir(path.dirname(stagedProjectRoot), { recursive: true });
await cp(metadata.projectSourceRoot, stagedProjectRoot, { recursive: true, force: true });
......@@ -549,6 +555,7 @@ export class ProjectBundleService {
const projectJsonPath = path.join(stagedProjectRoot, "project.json");
const existing = await this.readProjectJson(projectJsonPath);
await writeJsonFile(projectJsonPath, {
...(existing ?? {}),
id: metadata.projectId,
name: metadata.projectName,
version: metadata.version ?? existing?.version,
......@@ -558,6 +565,24 @@ export class ProjectBundleService {
boundSkillIds: []
});
await mkdir(path.join(stagedProjectRoot, "memory"), { recursive: true });
if (existingProjectRoot && await pathExists(existingProjectRoot)) {
await this.preserveLocalProjectState(existingProjectRoot, stagedProjectRoot);
}
}
private async preserveLocalProjectState(existingProjectRoot: string, stagedProjectRoot: string): Promise<void> {
for (const entryName of ["sessions.json", "session-messages", "memory"] as const) {
const sourcePath = path.join(existingProjectRoot, entryName);
if (!(await pathExists(sourcePath))) {
continue;
}
const targetPath = path.join(stagedProjectRoot, entryName);
await rm(targetPath, { recursive: true, force: true }).catch(() => undefined);
await mkdir(path.dirname(targetPath), { recursive: true });
await cp(sourcePath, targetPath, { recursive: true, force: true });
}
}
private async stageSharedDirectories(sourceDir: string | null, stagingDir: string, targetRoot: string): Promise<StagedSharedEntries> {
......@@ -734,3 +759,4 @@ export class ProjectBundleService {
import type { ProjectSessionState } from "@qjclaw/shared-types";
import type { ProjectIntentRoute, ProjectIntentRouterService } from "./project-intent-router.js";
import type { ProjectStoreService } from "./project-store.js";
export interface ResolvedProjectChatTarget {
sessionState: ProjectSessionState;
route: ProjectIntentRoute | null;
autoRouted: boolean;
previousProjectId: string;
}
export class ProjectChatTargetResolverService {
private readonly projectStore: ProjectStoreService;
private readonly projectIntentRouter: ProjectIntentRouterService;
constructor(projectStore: ProjectStoreService, projectIntentRouter: ProjectIntentRouterService) {
this.projectStore = projectStore;
this.projectIntentRouter = projectIntentRouter;
}
async resolve(sessionId: string, prompt: string, selectedSkillId?: string | null): Promise<ResolvedProjectChatTarget> {
const sessionState = await this.projectStore.getSessionState(sessionId);
const selectedSkill = selectedSkillId?.trim() || null;
if (selectedSkill) {
return {
sessionState,
route: null,
autoRouted: false,
previousProjectId: sessionState.projectId
};
}
const route = await this.projectIntentRouter.resolve(prompt, sessionState.projectId);
if (!route || route.projectId === sessionState.projectId) {
return {
sessionState,
route,
autoRouted: false,
previousProjectId: sessionState.projectId
};
}
await this.projectStore.setActiveProject(route.projectId);
const sessions = await this.projectStore.listSessions(route.projectId);
const targetSession = sessions[0] ?? await this.projectStore.createSession(undefined, route.projectId);
return {
sessionState: await this.projectStore.getSessionState(targetSession.id),
route,
autoRouted: true,
previousProjectId: sessionState.projectId
};
}
}
import { readFile, stat } from "node:fs/promises";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import type {
ProjectContextSnapshot,
ProjectExecutionDecision,
ProjectExecutionRequest
ProjectExecutionRequest,
ProjectPackageConfig
} from "@qjclaw/shared-types";
const WORKSPACE_ENTRY_MARKERS = ["AGENT", "AGENT.md", "AGENTS.md"];
......@@ -55,6 +56,14 @@ function buildPreparedPrompt(snapshot: ProjectContextSnapshot, userPrompt: strin
].join("\n\n");
}
function resolveDeclaredWorkspaceEntry(projectConfig?: ProjectPackageConfig | null): string | null {
if (projectConfig?.defaultEntry?.type === "workspace-entry") {
return `project default entry ${projectConfig.defaultEntry.id}`;
}
const explicitWorkspaceEntry = projectConfig?.entries.find((entry) => entry.type === "workspace-entry");
return explicitWorkspaceEntry ? `project entry ${explicitWorkspaceEntry.id}` : null;
}
export class ProjectExecutionRouter {
async decide(request: ProjectExecutionRequest): Promise<ProjectExecutionDecision> {
const preparedPrompt = buildPreparedPrompt(request.context, request.userPrompt);
......@@ -66,6 +75,16 @@ export class ProjectExecutionRouter {
};
}
const declaredWorkspaceEntryReason = resolveDeclaredWorkspaceEntry(request.projectConfig);
if (declaredWorkspaceEntryReason) {
return {
kind: "workspace-entry",
projectRoot: request.projectRoot,
preparedPrompt,
reason: declaredWorkspaceEntryReason
};
}
const workspaceEntryReason = await this.detectWorkspaceEntry(request.projectRoot);
if (workspaceEntryReason) {
return {
......
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { ProjectSummary } from "@qjclaw/shared-types";
import type { ProjectStoreService } from "./project-store.js";
const MAX_README_BYTES = 4096;
const MIN_CLEAR_ROUTE_SCORE = 4;
const MIN_ROUTE_MARGIN = 2;
const GENERIC_CJK_SUFFIXES = [
"全链路",
"自动化",
"工作流",
"项目",
"技能",
"助手",
"运营",
"交付",
"发布",
"剪辑"
] as const;
const GENERIC_ASCII_TOKENS = new Set([
"project",
"workflow",
"automation",
"assistant",
"skill",
"skills",
"delivery",
"content",
"campaign",
"general"
]);
const PLATFORM_ALIAS_MAP: Record<string, string[]> = {
xiaohongshu: ["\u5c0f\u7ea2\u4e66"],
rednote: ["\u5c0f\u7ea2\u4e66"],
xhs: ["\u5c0f\u7ea2\u4e66"],
douyin: ["\u6296\u97f3"],
tiktok: ["\u6296\u97f3"]
};
export interface ProjectIntentRoute {
projectId: string;
score: number;
confidence: "low" | "medium" | "high";
reason: string;
matchedAliases: string[];
}
interface ProjectCandidate {
project: ProjectSummary;
nameAliases: string[];
descriptionAliases: string[];
skillAliases: string[];
readmeAliases: string[];
}
function normalizeText(value: string): string {
return value
.normalize("NFKC")
.toLowerCase()
.replace(/\r\n/g, "\n")
.replace(/[^\p{L}\p{N}\n]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function normalizeAlias(value: string): string {
return normalizeText(value).replace(/\s+/g, "");
}
function uniqueStrings(values: Iterable<string>): string[] {
return [...new Set([...values].map((value) => value.trim()).filter(Boolean))];
}
function extractAsciiTokens(value: string): string[] {
return value
.split(/[^a-z0-9]+/i)
.map((token) => token.trim().toLowerCase())
.filter((token) => token.length >= 3 && !GENERIC_ASCII_TOKENS.has(token));
}
function extractCjkPhrases(value: string): string[] {
const matches = value.match(/[\p{Script=Han}]{2,16}/gu) ?? [];
const phrases = new Set<string>();
for (const match of matches) {
phrases.add(match);
for (const suffix of GENERIC_CJK_SUFFIXES) {
if (match.endsWith(suffix) && match.length > suffix.length + 1) {
phrases.add(match.slice(0, match.length - suffix.length));
}
}
}
return [...phrases];
}
function buildAliasSet(...inputs: Array<string | null | undefined>): string[] {
const aliases = new Set<string>();
for (const input of inputs) {
const raw = input?.trim();
if (!raw) {
continue;
}
const compact = normalizeAlias(raw);
if (compact.length >= 2) {
aliases.add(compact);
}
for (const line of raw.split(/\r?\n/)) {
const normalizedLine = normalizeAlias(line);
if (normalizedLine.length >= 2) {
aliases.add(normalizedLine);
}
}
for (const token of extractAsciiTokens(raw)) {
aliases.add(normalizeAlias(token));
const expansions = PLATFORM_ALIAS_MAP[token.toLowerCase()];
if (expansions) {
for (const expansion of expansions) {
aliases.add(normalizeAlias(expansion));
}
}
}
for (const phrase of extractCjkPhrases(raw)) {
aliases.add(normalizeAlias(phrase));
}
}
return [...aliases].filter((alias) => alias.length >= 2);
}
async function readProjectReadme(projectRoot: string): Promise<string> {
try {
const raw = await readFile(path.join(projectRoot, "README.md"), "utf8");
return raw.slice(0, MAX_README_BYTES);
} catch {
return "";
}
}
function scoreAliases(
prompt: string,
aliases: string[],
weight: number,
matchedAliases: Set<string>
): number {
let score = 0;
for (const alias of aliases) {
if (!alias || !prompt.includes(alias)) {
continue;
}
matchedAliases.add(alias);
score += weight;
}
return score;
}
function toConfidence(score: number): "low" | "medium" | "high" {
if (score >= 14) {
return "high";
}
if (score >= 9) {
return "medium";
}
return "low";
}
export class ProjectIntentRouterService {
private readonly projectStore: ProjectStoreService;
constructor(projectStore: ProjectStoreService) {
this.projectStore = projectStore;
}
async resolve(prompt: string, currentProjectId?: string | null): Promise<ProjectIntentRoute | null> {
const normalizedPrompt = normalizeAlias(prompt);
if (!normalizedPrompt) {
return null;
}
const projects = await this.projectStore.listProjects();
if (projects.length <= 1) {
return null;
}
const candidates = await Promise.all(projects.map((project) => this.buildCandidate(project)));
const ranked = candidates
.map((candidate) => {
const matchedAliases = new Set<string>();
let score = 0;
score += scoreAliases(normalizedPrompt, candidate.nameAliases, 6, matchedAliases);
score += scoreAliases(normalizedPrompt, buildAliasSet(candidate.project.id), 5, matchedAliases);
score += scoreAliases(normalizedPrompt, candidate.skillAliases, 4, matchedAliases);
score += scoreAliases(normalizedPrompt, candidate.descriptionAliases, 3, matchedAliases);
score += scoreAliases(normalizedPrompt, candidate.readmeAliases, 2, matchedAliases);
if (candidate.project.id === currentProjectId) {
score += 1;
}
return {
candidate,
score,
matchedAliases: uniqueStrings(matchedAliases)
};
})
.sort((left, right) => right.score - left.score || left.candidate.project.name.localeCompare(right.candidate.project.name, "zh-CN"));
const best = ranked[0];
const second = ranked[1];
if (!best || best.score < MIN_CLEAR_ROUTE_SCORE) {
return null;
}
if (second && best.score < second.score + MIN_ROUTE_MARGIN) {
return null;
}
return {
projectId: best.candidate.project.id,
score: best.score,
confidence: toConfidence(best.score),
reason: best.matchedAliases.length > 0
? `matched ${best.matchedAliases.join(", ")}`
: `matched ${best.candidate.project.name}`,
matchedAliases: best.matchedAliases
};
}
private async buildCandidate(project: ProjectSummary): Promise<ProjectCandidate> {
const [skills, projectRoot] = await Promise.all([
this.projectStore.listProjectSkills(project.id),
this.projectStore.getProjectRoot(project.id)
]);
const readme = await readProjectReadme(projectRoot);
return {
project,
nameAliases: buildAliasSet(project.name),
descriptionAliases: buildAliasSet(project.description),
skillAliases: buildAliasSet(...skills.map((skill) => skill.name)),
readmeAliases: buildAliasSet(...readme
.split(/\r?\n/)
.map((line) => line.replace(/^#+\s*/, "").trim())
.filter((line) => line.length >= 2)
.slice(0, 12))
};
}
}
This diff is collapsed.
......@@ -136,6 +136,7 @@ export class ProjectWorkspaceExecutorService {
OPENCLAW_HOME: paths.runtimeDataDir,
OPENCLAW_STATE_DIR: paths.runtimeStateDir,
OPENCLAW_CONFIG_PATH: paths.managedConfigPath,
PLAYWRIGHT_BROWSERS_PATH: paths.playwrightBrowsersPath,
OPENCLAW_HIDE_BANNER: "1",
OPENCLAW_SUPPRESS_NOTES: "1",
NODE_NO_WARNINGS: process.env.NODE_NO_WARNINGS || "1"
......
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { RuntimeManager } from "@qjclaw/runtime-manager";
import type { ProjectStoreService } from "./project-store.js";
......@@ -77,13 +77,19 @@ export class RuntimeSkillBridgeService {
}
async preparePrompt(prompt: string, skillId: string, projectId?: string): Promise<PreparedSkillExecution> {
const target = (projectId ? await this.projectStore.getProjectSkillTarget(projectId, skillId) : await this.projectStore.getCurrentProjectSkillTarget(skillId)) ?? await this.skillStore.getExecutionTarget(skillId);
const target = (projectId
? await this.projectStore.getProjectSkillTarget(projectId, skillId)
: await this.projectStore.getCurrentProjectSkillTarget(skillId))
?? await this.skillStore.getExecutionTarget(skillId);
if (!target) {
throw new Error("The selected skill is not ready locally yet.");
}
const dependencyTargets = projectId
? await this.projectStore.getProjectSkillDependencyTargets(projectId, skillId)
: [];
const runtimeStatus = await this.runtimeManager.status();
const activation = await this.activateRuntimeSkill(target, runtimeStatus.runtimeDataDir);
const activation = await this.activateRuntimeSkills(target, dependencyTargets, runtimeStatus.runtimeDataDir);
return {
prompt: this.buildPrompt(prompt, target.name, activation.runtimeSkillName),
......@@ -100,23 +106,52 @@ export class RuntimeSkillBridgeService {
await this.removeManagedSkillDirs(skillsRoot);
}
private async activateRuntimeSkill(target: SkillExecutionTarget, runtimeDataDir: string): Promise<{ runtimeSkillName: string; runtimeSkillDir: string }> {
private async activateRuntimeSkills(
target: SkillExecutionTarget,
dependencyTargets: SkillExecutionTarget[],
runtimeDataDir: string
): Promise<{ runtimeSkillName: string; runtimeSkillDir: string }> {
const skillsRoot = await this.resolveManagedSkillsRoot(runtimeDataDir);
await mkdir(skillsRoot, { recursive: true });
await this.removeManagedSkillDirs(skillsRoot);
const mainActivation = await this.materializeRuntimeSkill(target, skillsRoot, true);
const seenLocalPaths = new Set<string>([path.resolve(target.localPath)]);
for (const dependencyTarget of dependencyTargets) {
const resolvedPath = path.resolve(dependencyTarget.localPath);
if (seenLocalPaths.has(resolvedPath)) {
continue;
}
seenLocalPaths.add(resolvedPath);
await this.materializeRuntimeSkill(dependencyTarget, skillsRoot, false);
}
return mainActivation;
}
private async materializeRuntimeSkill(
target: SkillExecutionTarget,
skillsRoot: string,
selected: boolean
): Promise<{ runtimeSkillName: string; runtimeSkillDir: string }> {
const content = await readFile(target.localPath, "utf8");
const sourceName = extractFrontmatterName(content)
?? (target.fileName ? path.parse(target.fileName).name : undefined)
?? target.name
?? target.skillId;
const runtimeSkillName = `${MANAGED_SKILL_PREFIX}${slugify(sourceName)}`;
const skillsRoot = await this.resolveManagedSkillsRoot(runtimeDataDir);
const runtimeSkillDir = path.join(skillsRoot, runtimeSkillName);
const runtimeSkillName = selected
? `${MANAGED_SKILL_PREFIX}${slugify(sourceName)}`
: sourceName;
const runtimeSkillDir = path.join(skillsRoot, `${MANAGED_SKILL_PREFIX}${slugify(target.skillId)}`);
const materializedContent = applyRuntimeSkillName(content, runtimeSkillName);
await mkdir(skillsRoot, { recursive: true });
await this.removeManagedSkillDirs(skillsRoot);
await rm(runtimeSkillDir, { recursive: true, force: true }).catch(() => undefined);
await mkdir(runtimeSkillDir, { recursive: true });
await writeFile(path.join(runtimeSkillDir, "SKILL.md"), materializedContent, "utf8");
await writeFile(path.join(runtimeSkillDir, ".qjclaw-skill.json"), JSON.stringify({
source: "cloud",
selected,
selectedSkillName: target.name,
runtimeSkillName,
localPath: target.localPath,
......
......@@ -17,5 +17,5 @@
- `project-bundle-reconcile-smoke.ps1` compiles the targeted `project-bundle-reconcile-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies stale bundle project removal, shared `skills/` cleanup, shared `cron/` cleanup, manifest pruning, and empty-inventory cleanup without recreating a local fallback project; `pnpm smoke:bundle-reconcile`
- `project-bundle-freshness-smoke.ps1` compiles the targeted `project-bundle-freshness-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies that the same bundle URL plus unchanged `configVersion` still re-syncs when remote `ETag` / `Last-Modified` freshness metadata changes; `pnpm smoke:bundle-freshness`
- `project-bundle-replacement-smoke.ps1` compiles the targeted `project-bundle-replacement-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies same-project replacement, shared `skills/` and `cron/` ownership cleanup, rollback on an injected post-commit failure, and successful recovery on the next sync; `pnpm smoke:bundle-replacement`
- `project-isolation-smoke.ps1` runs the project-isolation regression smokes back to back, including the targeted default-chat, project-context refresh, and empty-project-inventory smokes; `pnpm smoke:project-isolation`
- `project-bundle-churn-smoke.ps1` compiles the targeted `project-bundle-churn-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies multi-project churn with stable survivors, same-project replacement, project removal, project addition, active-project fallback, and session survival inside unaffected projects; `pnpm smoke:bundle-churn`
- `project-isolation-smoke.ps1` runs the main project-isolation regression gate end to end, including workspace-entry, default-chat, cloud-bundle Electron lifecycle coverage, project-context refresh, empty-project inventory, bundle reconcile, bundle freshness, bundle replacement, and multi-project churn; `pnpm smoke:project-isolation`
param(
param(
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token',
[int]$SmokePort = 4318,
......@@ -16,6 +16,27 @@ function Write-Utf8File {
[System.IO.File]::WriteAllText($filePath, $content, $encoding)
}
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\cloud-bundle-smoke'
......@@ -48,6 +69,7 @@ $bundleSharedSkillB = 'cloud-bundle-shared-skill-b'
$bundleSharedCronA = 'cloud-bundle-task-a.txt'
$bundleSharedCronB = 'cloud-bundle-task-b.txt'
$expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileName"
$electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1'
if (Test-Path $BaseOutputDir) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
......@@ -126,29 +148,27 @@ $env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersionA
try {
Write-Host 'Running cloud bundle smoke phase 1 (payload sync path)'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') `
-SmokeOutput $phase1Output `
-SmokePort $SmokePort `
-SmokeToken $SmokeToken `
-UserDataPath $userDataPath `
-LogsPath $phase1LogsPath `
-RuntimeMode 'bundled-runtime' `
-ExpectBundledRuntime `
-ExpectWorkspaceEntry `
-ExpectRemoteBundle `
-WorkspaceProjectId $bundleProjectId `
-WorkspaceProjectName $bundleProjectName `
-SmokePrompt 'Describe the current project root and confirm cloud bundle workspace execution.' `
-SmokeSkillId '__cloud_bundle_workspace_entry_disabled__' `
-ExpectedBundleSourceUrl $expectedBundleSourceUrl `
-ExpectedBundleConfigVersion $bundleConfigVersionA `
-ExpectedBundleFileName $bundleFileName `
-ExpectedBundleSkillId $bundleSkillId `
-ExpectedReadmeMarker $bundleReadmeMarkerA `
-TimeoutSeconds $TimeoutSeconds
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'cloud-bundle phase 1' -ArgumentList @(
'-SmokeOutput', $phase1Output,
'-SmokePort', $SmokePort,
'-SmokeToken', $SmokeToken,
'-UserDataPath', $userDataPath,
'-LogsPath', $phase1LogsPath,
'-RuntimeMode', 'bundled-runtime',
'-ExpectBundledRuntime',
'-ExpectWorkspaceEntry',
'-ExpectRemoteBundle',
'-WorkspaceProjectId', $bundleProjectId,
'-WorkspaceProjectName', $bundleProjectName,
'-SmokePrompt', 'Describe the current project root and confirm cloud bundle workspace execution.',
'-SmokeSkillId', '__cloud_bundle_workspace_entry_disabled__',
'-ExpectedBundleSourceUrl', $expectedBundleSourceUrl,
'-ExpectedBundleConfigVersion', $bundleConfigVersionA,
'-ExpectedBundleFileName', $bundleFileName,
'-ExpectedBundleSkillId', $bundleSkillId,
'-ExpectedReadmeMarker', $bundleReadmeMarkerA,
'-TimeoutSeconds', $TimeoutSeconds
)
$projectPath = Join-Path $userDataPath (Join-Path 'projects' $bundleProjectId)
$bundleManifestPath = Join-Path $userDataPath 'manifests\project-bundles.json'
......@@ -164,60 +184,56 @@ try {
}
Write-Host 'Running cloud bundle smoke phase 2 (cached init path)'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') `
-SmokeOutput $phase2Output `
-SmokePort $SmokePort `
-SmokeToken $SmokeToken `
-UserDataPath $userDataPath `
-LogsPath $phase2LogsPath `
-RuntimeMode 'bundled-runtime' `
-ExpectBundledRuntime `
-PreserveUserData `
-ExpectWorkspaceEntry `
-ExpectRemoteBundle `
-WorkspaceProjectId $bundleProjectId `
-WorkspaceProjectName $bundleProjectName `
-SmokePrompt 'Describe the current project root and confirm cached cloud bundle workspace execution.' `
-SmokeSkillId '__cloud_bundle_workspace_entry_disabled__' `
-ExpectedBundleSourceUrl $expectedBundleSourceUrl `
-ExpectedBundleConfigVersion $bundleConfigVersionA `
-ExpectedBundleFileName $bundleFileName `
-ExpectedBundleSkillId $bundleSkillId `
-ExpectedReadmeMarker $bundleReadmeMarkerA `
-TimeoutSeconds $TimeoutSeconds
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'cloud-bundle phase 2' -ArgumentList @(
'-SmokeOutput', $phase2Output,
'-SmokePort', $SmokePort,
'-SmokeToken', $SmokeToken,
'-UserDataPath', $userDataPath,
'-LogsPath', $phase2LogsPath,
'-RuntimeMode', 'bundled-runtime',
'-ExpectBundledRuntime',
'-PreserveUserData',
'-ExpectWorkspaceEntry',
'-ExpectRemoteBundle',
'-WorkspaceProjectId', $bundleProjectId,
'-WorkspaceProjectName', $bundleProjectName,
'-SmokePrompt', 'Describe the current project root and confirm cached cloud bundle workspace execution.',
'-SmokeSkillId', '__cloud_bundle_workspace_entry_disabled__',
'-ExpectedBundleSourceUrl', $expectedBundleSourceUrl,
'-ExpectedBundleConfigVersion', $bundleConfigVersionA,
'-ExpectedBundleFileName', $bundleFileName,
'-ExpectedBundleSkillId', $bundleSkillId,
'-ExpectedReadmeMarker', $bundleReadmeMarkerA,
'-TimeoutSeconds', $TimeoutSeconds
)
$env:QJCLAW_SMOKE_BUNDLE_ZIP_PATH = $bundleZipPathB
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersionB
Write-Host 'Running cloud bundle smoke phase 3 (same-project replacement path)'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') `
-SmokeOutput $phase3Output `
-SmokePort $SmokePort `
-SmokeToken $SmokeToken `
-UserDataPath $userDataPath `
-LogsPath $phase3LogsPath `
-RuntimeMode 'bundled-runtime' `
-ExpectBundledRuntime `
-PreserveUserData `
-ExpectWorkspaceEntry `
-ExpectRemoteBundle `
-WorkspaceProjectId $bundleProjectId `
-WorkspaceProjectName $bundleProjectName `
-SmokePrompt 'Quote the current README marker line and confirm same-project replacement workspace execution.' `
-SmokeSkillId '__cloud_bundle_workspace_entry_disabled__' `
-ExpectedBundleSourceUrl $expectedBundleSourceUrl `
-ExpectedBundleConfigVersion $bundleConfigVersionB `
-ExpectedBundleFileName $bundleFileName `
-ExpectedBundleSkillId $bundleSkillId `
-ExpectedReadmeMarker $bundleReadmeMarkerB `
-UnexpectedReadmeMarker $bundleReadmeMarkerA `
-TimeoutSeconds $TimeoutSeconds
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'cloud-bundle phase 3' -ArgumentList @(
'-SmokeOutput', $phase3Output,
'-SmokePort', $SmokePort,
'-SmokeToken', $SmokeToken,
'-UserDataPath', $userDataPath,
'-LogsPath', $phase3LogsPath,
'-RuntimeMode', 'bundled-runtime',
'-ExpectBundledRuntime',
'-PreserveUserData',
'-ExpectWorkspaceEntry',
'-ExpectRemoteBundle',
'-WorkspaceProjectId', $bundleProjectId,
'-WorkspaceProjectName', $bundleProjectName,
'-SmokePrompt', 'Quote the current README marker line and confirm same-project replacement workspace execution.',
'-SmokeSkillId', '__cloud_bundle_workspace_entry_disabled__',
'-ExpectedBundleSourceUrl', $expectedBundleSourceUrl,
'-ExpectedBundleConfigVersion', $bundleConfigVersionB,
'-ExpectedBundleFileName', $bundleFileName,
'-ExpectedBundleSkillId', $bundleSkillId,
'-ExpectedReadmeMarker', $bundleReadmeMarkerB,
'-UnexpectedReadmeMarker', $bundleReadmeMarkerA,
'-TimeoutSeconds', $TimeoutSeconds
)
$summary = & node -e @"
const fs = require('fs');
......
......@@ -273,9 +273,18 @@ if (expectBundled === 'true') {
if (!runtimeStatus.pythonReady) {
throw new Error('Bundled runtime did not report a ready Python payload.');
}
if (!Array.isArray(runtimeStatus.installedPythonPackages) || runtimeStatus.installedPythonPackages.length < 9) {
if (!Array.isArray(runtimeStatus.installedPythonPackages) || runtimeStatus.installedPythonPackages.length < 12) {
throw new Error('Bundled runtime did not report the expected Python package set.');
}
if (!runtimeStatus.installedPythonPackages.some((value) => { const normalized = String(value || '').toLowerCase(); return normalized === 'playwright' || normalized.startsWith('playwright=='); })) {
throw new Error('Bundled runtime did not report Playwright in the Python package set.');
}
if (!runtimeStatus.installedPythonPackages.some((value) => { const normalized = String(value || '').toLowerCase(); return normalized === 'python-dotenv' || normalized.startsWith('python-dotenv=='); })) {
throw new Error('Bundled runtime did not report python-dotenv in the Python package set.');
}
if (!runtimeStatus.installedPythonPackages.some((value) => { const normalized = String(value || '').toLowerCase(); return normalized === 'pillow' || normalized.startsWith('pillow=='); })) {
throw new Error('Bundled runtime did not report Pillow in the Python package set.');
}
if (!sendResult.status || sendResult.status.state !== 'connected') {
throw new Error('Gateway did not reconnect after bundled runtime startup: ' + (sendResult.status && sendResult.status.state));
}
......@@ -290,8 +299,10 @@ if (expectWorkspaceEntry === 'true') {
const expectedProjectRoot = path.join(expectedUserData, 'projects', workspaceProjectId);
const expectedSessionPrefix = 'project:' + workspaceProjectId + ':';
const selectedSkillId = String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || '');
if (!statusLabels.some((label) => label.toLowerCase().includes('workspace'))) {
throw new Error('Workspace-entry smoke did not report a workspace status label. history=' + JSON.stringify(statusLabels) + ' latest=' + latestStatusLabel);
const reportedWorkspaceLabel = statusLabels.some((label) => label.toLowerCase().includes('workspace'));
const reportedSkillRouteLabel = statusLabels.some((label) => label.toLowerCase().includes('routing to skill '));
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')) {
throw new Error('Workspace-entry smoke did not echo the injected isolated workspace context.');
......@@ -305,7 +316,7 @@ if (expectWorkspaceEntry === 'true') {
if (!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) {
if (selectedSkillId && expectRemoteBundle !== 'true') {
throw new Error('Workspace-entry smoke unexpectedly selected a skill: ' + selectedSkillId);
}
if (!String(sendResult.sessionId || streamSmoke.sessionId || '').startsWith(expectedSessionPrefix)) {
......
......@@ -48,6 +48,10 @@ if (-not $LogsPath) {
$installParent = Split-Path $InstallDir -Parent
New-Item -ItemType Directory -Force -Path $installParent | Out-Null
if (Test-Path $InstallDir) {
Write-Host "Removing existing install directory at $InstallDir"
Remove-Item -Path $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
}
function Stop-SmokeAppProcesses {
$appProcesses = Get-Process -Name 'QianjiangClaw' -ErrorAction SilentlyContinue
......@@ -123,29 +127,18 @@ while ((Get-Date) -lt $installDeadline) {
$lastSnapshot = $null
}
if ($setupProcess.HasExited -and -not $requiredPathsReady) {
break
}
# Do not break early when the launcher process exits — NSIS may spawn a
# child installer process and the parent wrapper exits quickly. Keep polling
# until all required paths are stable or the deadline is reached.
Start-Sleep -Milliseconds 500
}
if (-not $installReady) {
if ($setupProcess.HasExited) {
if ($setupProcess.ExitCode -ne 0) {
throw "Installer exited with code $($setupProcess.ExitCode)"
}
if (-not $requiredPathsReady) {
throw "Installer exited before packaged files were fully materialized under $InstallDir"
}
}
if (-not $requiredPathsReady) {
Stop-Process -Id $setupProcess.Id -Force -ErrorAction SilentlyContinue
if (-not $requiredPathsReady) {
throw "Installer did not materialize the packaged files within $InstallTimeoutSeconds seconds."
}
Stop-Process -Id $setupProcess.Id -Force -ErrorAction SilentlyContinue
throw "Installer did not reach a stable packaged file state within $InstallTimeoutSeconds seconds."
}
......@@ -173,7 +166,7 @@ if (-not (Test-Path $packagedPythonManifest)) {
throw "Bundled Python manifest not found at $packagedPythonManifest"
}
$pythonImportProbe = & $packagedPythonExe -c "import openpyxl, pandas, requests, bs4, lxml, pypdf, docx, charset_normalizer, yaml; print('ok')"
$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') {
throw 'Bundled Python import probe failed for the packaged runtime payload.'
}
......
......@@ -64,6 +64,7 @@ function Test-RuntimePayloadReady {
(Join-Path $RuntimeDir 'python\python.exe'),
(Join-Path $RuntimeDir 'python\python-manifest.json'),
(Join-Path $RuntimeDir 'python\runtime-requirements.lock.txt'),
(Join-Path $RuntimeDir 'playwright-browsers'),
(Join-Path $RuntimeDir 'runtime-manifest.json'),
(Join-Path $RuntimeDir 'README.md')
)
......@@ -99,6 +100,7 @@ function New-RuntimeSummary {
pythonExecutable = (Join-Path $RuntimeDir 'python\python.exe')
pythonManifestPath = (Join-Path $RuntimeDir 'python\python-manifest.json')
requirementsPath = (Join-Path $RuntimeDir 'python\runtime-requirements.lock.txt')
playwrightBrowsersPath = (Join-Path $RuntimeDir 'playwright-browsers')
gatewayPort = $Manifest.gatewayPort
gatewayToken = $Manifest.gatewayToken
installedPythonPackages = $Manifest.installedPythonPackages
......@@ -207,6 +209,7 @@ $openclawDir = Join-Path $stagingDir 'openclaw'
$openclawPackageDir = Join-Path $openclawDir 'package'
$configDir = Join-Path $stagingDir 'config'
$pythonDir = Join-Path $stagingDir 'python'
$playwrightBrowsersDir = Join-Path $stagingDir 'playwright-browsers'
$manifestPath = Join-Path $stagingDir 'runtime-manifest.json'
$wrapperPath = Join-Path $openclawDir 'index.js'
$configPath = Join-Path $configDir 'openclaw.json'
......@@ -221,7 +224,7 @@ if (Test-Path $stagingDir) {
}
try {
New-Item -ItemType Directory -Force -Path $nodeDir, $openclawDir, $configDir | Out-Null
New-Item -ItemType Directory -Force -Path $nodeDir, $openclawDir, $configDir, $playwrightBrowsersDir | Out-Null
Copy-Item -Path $SourceNodeExe -Destination (Join-Path $nodeDir 'node.exe') -Force
Write-Host "Copying OpenClaw package from $SourceOpenClawDir"
......@@ -259,6 +262,13 @@ try {
throw 'Failed to install bundled Python dependencies.'
}
$env:PLAYWRIGHT_BROWSERS_PATH = $playwrightBrowsersDir
Write-Host "Installing bundled Playwright Chromium into $playwrightBrowsersDir"
& $payloadPythonExe -m playwright install chromium
if ($LASTEXITCODE -ne 0) {
throw 'Failed to install bundled Playwright Chromium.'
}
Copy-Item -Path $RequirementsPath -Destination $pythonRequirementsCopy -Force
$manifestScript = @"
......@@ -325,6 +335,7 @@ print(json.dumps(payload))
pythonExecutable = 'python/python.exe'
pythonManifestPath = 'python/python-manifest.json'
requirementsPath = 'python/runtime-requirements.lock.txt'
playwrightBrowsersPath = 'playwright-browsers'
gatewayPort = $GatewayPort
gatewayToken = $GatewayToken
materializedAt = $materializedAt
......@@ -375,6 +386,7 @@ Immutable packaged payload under `vendor/openclaw-runtime/` includes:
- `python/python.exe`
- `python/python-manifest.json`
- `python/runtime-requirements.lock.txt`
- `playwright-browsers/`
Mutable runtime data lives outside the installer payload and should be created under Electron `userData/runtime/`.
......
param(
[string]$SmokeOutput
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$sourcePath = Join-Path $repoRoot 'build\scripts\project-bundle-churn-smoke.ts'
$tempRoot = Join-Path $repoRoot '.tmp\project-bundle-churn-smoke'
$compileRoot = Join-Path $tempRoot 'compiled'
$entryPath = Join-Path $compileRoot 'build\scripts\project-bundle-churn-smoke.js'
$compilePackagePath = Join-Path $compileRoot 'package.json'
$alphaRoot = Join-Path $tempRoot 'alpha-src'
$betaRootV1 = Join-Path $tempRoot 'beta-src-v1'
$betaRootV2 = Join-Path $tempRoot 'beta-src-v2'
$gammaRoot = Join-Path $tempRoot 'gamma-src'
$deltaRoot = Join-Path $tempRoot 'delta-src'
$alphaZipPath = Join-Path $tempRoot 'alpha.zip'
$betaZipPathV1 = Join-Path $tempRoot 'beta-v1.zip'
$betaZipPathV2 = Join-Path $tempRoot 'beta-v2.zip'
$gammaZipPath = Join-Path $tempRoot 'gamma.zip'
$deltaZipPath = Join-Path $tempRoot 'delta.zip'
$resolvedResultPath = if ($SmokeOutput) { [System.IO.Path]::GetFullPath($SmokeOutput) } else { Join-Path $tempRoot 'result.json' }
function Write-Utf8File {
param([string]$FilePath, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($FilePath)) | Out-Null
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
}
function New-BundleFixture {
param(
[string]$Root,
[string]$ProjectId,
[string]$ProjectName,
[string]$ReadmeMarker,
[string]$SkillEntry,
[string]$CronEntry
)
if (Test-Path $Root) {
Remove-Item $Root -Recurse -Force
}
New-Item -ItemType Directory -Path (Join-Path (Join-Path $Root 'skills') $SkillEntry) -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $Root 'memory') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $Root 'cron') -Force | Out-Null
Write-Utf8File -FilePath (Join-Path $Root 'project.json') -Content (@{
id = $ProjectId
name = $ProjectName
version = '1.0.0'
description = 'Verifies multi-project bundle churn and inventory cleanup.'
} | ConvertTo-Json -Depth 5)
Write-Utf8File -FilePath (Join-Path $Root 'README.md') -Content ("# $ProjectName`n`n$ReadmeMarker")
Write-Utf8File -FilePath (Join-Path $Root 'memory\summary.md') -Content ("$ProjectId memory")
Write-Utf8File -FilePath (Join-Path (Join-Path (Join-Path $Root 'skills') $SkillEntry) 'SKILL.md') -Content ("# $ProjectName Skill")
Write-Utf8File -FilePath (Join-Path (Join-Path $Root 'cron') $CronEntry) -Content ("$ProjectId cron")
}
if (-not (Test-Path $sourcePath)) {
throw "Project bundle churn smoke source was not found: $sourcePath"
}
if (Test-Path $compileRoot) {
Remove-Item $compileRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $compileRoot -Force | Out-Null
$compileArgs = @(
'pnpm',
'--dir', (Join-Path $repoRoot 'apps\desktop'),
'exec',
'tsc',
'--module', 'ES2022',
'--moduleResolution', 'node',
'--target', 'ES2022',
'--lib', 'ES2022',
'--types', 'node',
'--esModuleInterop',
'--allowSyntheticDefaultImports',
'--skipLibCheck',
'--outDir', $compileRoot,
$sourcePath
)
Write-Host 'Compiling project-bundle churn smoke with local TypeScript'
corepack @compileArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $entryPath)) {
throw "Project bundle churn smoke entry was not emitted: $entryPath"
}
foreach ($path in @($alphaRoot, $betaRootV1, $betaRootV2, $gammaRoot, $deltaRoot)) {
if (Test-Path $path) {
Remove-Item $path -Recurse -Force
}
}
foreach ($path in @($alphaZipPath, $betaZipPathV1, $betaZipPathV2, $gammaZipPath, $deltaZipPath)) {
if (Test-Path $path) {
Remove-Item $path -Force
}
}
New-BundleFixture -Root $alphaRoot -ProjectId 'bundle-churn-alpha' -ProjectName 'Bundle Churn Alpha' -ReadmeMarker 'Alpha stable variant.' -SkillEntry 'bundle-churn-alpha-skill' -CronEntry 'bundle-churn-alpha-task.txt'
New-BundleFixture -Root $betaRootV1 -ProjectId 'bundle-churn-beta' -ProjectName 'Bundle Churn Beta' -ReadmeMarker 'Beta variant V1' -SkillEntry 'bundle-churn-beta-skill-v1' -CronEntry 'bundle-churn-beta-task-v1.txt'
New-BundleFixture -Root $betaRootV2 -ProjectId 'bundle-churn-beta' -ProjectName 'Bundle Churn Beta' -ReadmeMarker 'Beta variant V2' -SkillEntry 'bundle-churn-beta-skill-v2' -CronEntry 'bundle-churn-beta-task-v2.txt'
New-BundleFixture -Root $gammaRoot -ProjectId 'bundle-churn-gamma' -ProjectName 'Bundle Churn Gamma' -ReadmeMarker 'Gamma removable variant.' -SkillEntry 'bundle-churn-gamma-skill' -CronEntry 'bundle-churn-gamma-task.txt'
New-BundleFixture -Root $deltaRoot -ProjectId 'bundle-churn-delta' -ProjectName 'Bundle Churn Delta' -ReadmeMarker 'Delta newly added variant.' -SkillEntry 'bundle-churn-delta-skill' -CronEntry 'bundle-churn-delta-task.txt'
Compress-Archive -Path (Join-Path $alphaRoot '*') -DestinationPath $alphaZipPath -Force
Compress-Archive -Path (Join-Path $betaRootV1 '*') -DestinationPath $betaZipPathV1 -Force
Compress-Archive -Path (Join-Path $betaRootV2 '*') -DestinationPath $betaZipPathV2 -Force
Compress-Archive -Path (Join-Path $gammaRoot '*') -DestinationPath $gammaZipPath -Force
Compress-Archive -Path (Join-Path $deltaRoot '*') -DestinationPath $deltaZipPath -Force
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
Write-Host 'Running project-bundle churn smoke'
node $entryPath $resolvedResultPath $alphaZipPath $betaZipPathV1 $betaZipPathV2 $gammaZipPath $deltaZipPath
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $resolvedResultPath)) {
throw "Project bundle churn smoke did not produce a result file: $resolvedResultPath"
}
This diff is collapsed.
param(
param(
[int]$TimeoutSeconds = 180,
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token'
......@@ -26,6 +26,12 @@ if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host 'Running project-routing isolation smoke'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\project-routing-smoke.ps1')
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host 'Running cloud-bundle isolation smoke'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\cloud-bundle-smoke.ps1') -TimeoutSeconds $TimeoutSeconds -GatewayPort $GatewayPort -GatewayToken $GatewayToken -SkipMaterializeRuntime
if ($LASTEXITCODE -ne 0) {
......@@ -43,3 +49,28 @@ powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\pro
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host 'Running project-bundle reconcile smoke'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\project-bundle-reconcile-smoke.ps1')
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host 'Running project-bundle freshness smoke'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\project-bundle-freshness-smoke.ps1')
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host 'Running project-bundle replacement smoke'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\project-bundle-replacement-smoke.ps1')
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host 'Running project-bundle churn smoke'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\project-bundle-churn-smoke.ps1')
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
param(
[string]$SmokeOutput
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$sourcePath = Join-Path $repoRoot 'build\scripts\project-package-orchestrator-smoke.ts'
$tempRoot = Join-Path $repoRoot '.tmp\project-package-orchestrator-smoke'
$compileRoot = Join-Path $tempRoot 'compiled'
$entryPath = Join-Path $compileRoot 'build\scripts\project-package-orchestrator-smoke.js'
$compilePackagePath = Join-Path $compileRoot 'package.json'
$resolvedResultPath = if ($SmokeOutput) { [System.IO.Path]::GetFullPath($SmokeOutput) } else { Join-Path $tempRoot 'result.json' }
function Write-Utf8File {
param([string]$FilePath, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($FilePath)) | Out-Null
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
}
if (-not (Test-Path $sourcePath)) {
throw "Project package orchestrator smoke source was not found: $sourcePath"
}
if (Test-Path $compileRoot) {
Remove-Item $compileRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $compileRoot -Force | Out-Null
$compileArgs = @(
'pnpm',
'--dir', (Join-Path $repoRoot 'apps\desktop'),
'exec',
'tsc',
'--module', 'ES2022',
'--moduleResolution', 'node',
'--target', 'ES2022',
'--lib', 'ES2022',
'--types', 'node',
'--esModuleInterop',
'--allowSyntheticDefaultImports',
'--skipLibCheck',
'--outDir', $compileRoot,
$sourcePath
)
Write-Host 'Compiling project-package-orchestrator smoke with local TypeScript'
corepack @compileArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $entryPath)) {
throw "Project package orchestrator smoke entry was not emitted: $entryPath"
}
$runtimeManagerDistSource = Join-Path $repoRoot 'packages\runtime-manager\dist'
$runtimeManagerDistTarget = Join-Path $compileRoot 'packages\runtime-manager\dist'
if (-not (Test-Path $runtimeManagerDistSource)) {
throw "Runtime manager dist was not found: $runtimeManagerDistSource"
}
New-Item -ItemType Directory -Path (Split-Path $runtimeManagerDistTarget -Parent) -Force | Out-Null
Copy-Item -Path $runtimeManagerDistSource -Destination $runtimeManagerDistTarget -Recurse -Force
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
Write-Host 'Running project-package-orchestrator smoke'
node $entryPath $resolvedResultPath
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $resolvedResultPath)) {
throw "Project package orchestrator smoke did not produce a result file: $resolvedResultPath"
}
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { AppConfigService } from "../../apps/desktop/src/main/services/app-config.js";
import { ProjectSkillRouterService } from "../../apps/desktop/src/main/services/project-skill-router.js";
import { ProjectStoreService } from "../../apps/desktop/src/main/services/project-store.js";
import { RuntimeSkillBridgeService } from "../../apps/desktop/src/main/services/runtime-skill-bridge.js";
import { SkillStoreService } from "../../apps/desktop/src/main/services/skill-store.js";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
async function writeSkill(workspaceRoot: string, skillId: string, body: string): Promise<void> {
const skillPath = path.join(workspaceRoot, "skills", skillId, "SKILL.md");
await mkdir(path.dirname(skillPath), { recursive: true });
await writeFile(skillPath, body, "utf8");
}
function createRuntimeManagerStub(vendorRuntimeDir: string, runtimeDataDir: string) {
return {
async status() {
return {
runtimeDataDir
};
},
resolveBundledPaths() {
return {
runtimeDir: vendorRuntimeDir,
runtimeManifestPath: path.join(vendorRuntimeDir, "runtime-manifest.json"),
runtimeDataDir
};
}
};
}
async function resolveManagedSkillsRoot(runtimeManager: ReturnType<typeof createRuntimeManagerStub>): Promise<string> {
const paths = runtimeManager.resolveBundledPaths();
const manifestRaw = await readFile(paths.runtimeManifestPath, "utf8");
const manifest = JSON.parse(manifestRaw.replace(/^\uFEFF/, "")) as {
packagedSkillsRoot?: string;
packagedOpenClawEntry?: string;
sourceOpenClawEntry?: string;
};
if (typeof manifest.packagedSkillsRoot === "string" && manifest.packagedSkillsRoot.trim()) {
return path.resolve(paths.runtimeDir, manifest.packagedSkillsRoot.trim());
}
if (typeof manifest.packagedOpenClawEntry === "string" && manifest.packagedOpenClawEntry.trim()) {
return path.join(path.dirname(path.resolve(paths.runtimeDir, manifest.packagedOpenClawEntry.trim())), "skills");
}
if (typeof manifest.sourceOpenClawEntry === "string" && manifest.sourceOpenClawEntry.trim()) {
return path.join(path.dirname(manifest.sourceOpenClawEntry.trim()), "skills");
}
return path.join(paths.runtimeDataDir, "skills");
}
async function main(): Promise<void> {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = process.cwd();
const resultPath = path.resolve(process.argv[2] ?? path.join(repoRoot, ".tmp", "project-package-orchestrator-smoke", "result.json"));
const tempRoot = path.dirname(resultPath);
const userDataPath = path.join(tempRoot, "user-data");
const vendorRuntimeDir = path.join(repoRoot, "vendor", "openclaw-runtime");
const runtimeDataDir = path.join(userDataPath, "runtime");
await rm(tempRoot, { recursive: true, force: true });
await mkdir(userDataPath, { recursive: true });
const configService = new AppConfigService(userDataPath);
await configService.load();
const projectStore = new ProjectStoreService(configService);
await projectStore.initialize();
const workspaceRoot = await projectStore.getWorkspaceRoot();
await writeSkill(workspaceRoot, "xiaohongshu-pipeline", `---
name: xiaohongshu-pipeline
description: xiaohongshu end to end publish orchestrator
---
`);
await writeSkill(workspaceRoot, "xiaohongshu-writer", `---
name: xiaohongshu-writer
description: xiaohongshu draft writer
---
`);
await writeSkill(workspaceRoot, "xiaohongshu-publisher", `---
name: xiaohongshu-publisher
description: xiaohongshu publisher
---
`);
const project = await projectStore.upsertProject({
id: "xiaohongshu-workspace",
name: "Xiaohongshu Workspace",
description: "\u5c0f\u7ea2\u4e66\u7f8e\u5986\u53d1\u5e16\u9879\u76ee",
projectType: "workspace-package",
platform: "xiaohongshu",
capabilities: ["publish", "write", "workflow"],
requires: ["playwright"],
boundSkillIds: ["xiaohongshu-pipeline", "xiaohongshu-writer", "xiaohongshu-publisher"],
defaultEntry: {
id: "xiaohongshu-pipeline",
type: "skill",
capabilities: ["publish", "workflow"],
intentAliases: ["\u5c0f\u7ea2\u4e66", "\u7f8e\u5986", "\u53d1\u5e16"],
requiresSkills: ["xiaohongshu-writer", "xiaohongshu-publisher"]
},
entries: [
{
id: "xiaohongshu-pipeline",
type: "skill",
capabilities: ["publish", "workflow"],
intentAliases: ["\u5c0f\u7ea2\u4e66", "\u7f8e\u5986", "\u53d1\u5e16"],
requiresSkills: ["xiaohongshu-writer", "xiaohongshu-publisher"]
},
{
id: "xiaohongshu-writer",
type: "skill",
capabilities: ["write", "draft"],
intentAliases: ["\u6587\u6848", "\u7b14\u8bb0", "\u8349\u7a3f"]
},
{
id: "xiaohongshu-publisher",
type: "skill",
capabilities: ["publish"],
intentAliases: ["\u53d1\u5e03", "\u53d1\u5e16"]
}
],
ready: true
});
assert(project.platform === "xiaohongshu", "Project summary did not surface platform metadata.");
assert(project.defaultEntryId === "xiaohongshu-pipeline", "Project summary did not surface default entry metadata.");
const projectConfig = await projectStore.getProjectPackageConfig(project.id);
assert(projectConfig?.defaultEntry?.id === "xiaohongshu-pipeline", "Project package config did not retain default entry.");
const skillRouter = new ProjectSkillRouterService(projectStore);
const publishPrompt = "\u53d1\u4e00\u4e2a\u7f8e\u5986\u7c7b\u7684\u5c0f\u7ea2\u4e66\u5e16\u5b50";
const route = await skillRouter.resolve(project.id, publishPrompt);
assert(route?.skillId === "xiaohongshu-pipeline", "Project skill router did not choose the declared orchestrator skill.");
const skillStore = new SkillStoreService(userDataPath);
const runtimeManager = createRuntimeManagerStub(vendorRuntimeDir, runtimeDataDir);
const bridge = new RuntimeSkillBridgeService(
skillStore,
projectStore,
runtimeManager as unknown as ConstructorParameters<typeof RuntimeSkillBridgeService>[2]
);
const prepared = await bridge.preparePrompt(publishPrompt, "xiaohongshu-pipeline", project.id);
const dependencyIds = await projectStore.getProjectSkillDependencyIds(project.id, "xiaohongshu-pipeline");
const managedSkillsRoot = await resolveManagedSkillsRoot(runtimeManager);
const managedEntries = (await readdir(managedSkillsRoot, { withFileTypes: true }).catch(() => []))
.filter((entry) => entry.isDirectory() && entry.name.startsWith("qjclaw-cloud-"))
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right, "en"));
assert(managedEntries.length === 3, `Expected 3 managed runtime skill directories, received ${managedEntries.length}.`);
const materializedSkills = await Promise.all(managedEntries.map(async (entryName) => {
const skillBody = await readFile(path.join(managedSkillsRoot, entryName, "SKILL.md"), "utf8");
const metaRaw = await readFile(path.join(managedSkillsRoot, entryName, ".qjclaw-skill.json"), "utf8");
const metadata = JSON.parse(metaRaw) as { runtimeSkillName?: string; selected?: boolean; selectedSkillName?: string };
return {
entryName,
skillBody,
metadata
};
}));
assert(materializedSkills.some((item) => item.metadata.selected === true && item.metadata.runtimeSkillName === prepared.runtimeSkillName), "Main orchestrator skill was not materialized as the selected runtime skill.");
assert(materializedSkills.some((item) => item.skillBody.includes("name: xiaohongshu-writer")), "Writer dependency skill was not materialized with its original skill name.");
assert(materializedSkills.some((item) => item.skillBody.includes("name: xiaohongshu-publisher")), "Publisher dependency skill was not materialized with its original skill name.");
const summary = {
ok: true,
workspaceRoot,
projectId: project.id,
platform: project.platform,
defaultEntryId: project.defaultEntryId,
routeSkillId: route?.skillId ?? null,
routeReason: route?.reason ?? null,
preparedRuntimeSkillName: prepared.runtimeSkillName,
dependencyIds,
managedSkillsRoot,
managedEntries,
prompt: prepared.prompt
};
await mkdir(path.dirname(resultPath), { recursive: true });
await writeFile(resultPath, JSON.stringify(summary, null, 2), "utf8");
console.log(JSON.stringify(summary, null, 2));
}
main().catch(async (error) => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = process.cwd();
const resultPath = path.resolve(process.argv[2] ?? path.join(repoRoot, ".tmp", "project-package-orchestrator-smoke", "result.json"));
const failure = {
ok: false,
error: error instanceof Error ? error.stack ?? error.message : String(error)
};
await mkdir(path.dirname(resultPath), { recursive: true });
await writeFile(resultPath, JSON.stringify(failure, null, 2), "utf8");
console.error(failure.error);
process.exitCode = 1;
});
param(
[string]$SmokeOutput
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$sourcePath = Join-Path $repoRoot 'build\scripts\project-routing-smoke.ts'
$tempRoot = Join-Path $repoRoot '.tmp\project-routing-smoke'
$compileRoot = Join-Path $tempRoot 'compiled'
$entryPath = Join-Path $compileRoot 'build\scripts\project-routing-smoke.js'
$compilePackagePath = Join-Path $compileRoot 'package.json'
$resolvedResultPath = if ($SmokeOutput) { [System.IO.Path]::GetFullPath($SmokeOutput) } else { Join-Path $tempRoot 'result.json' }
function Write-Utf8File {
param([string]$FilePath, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($FilePath)) | Out-Null
[System.IO.File]::WriteAllText($FilePath, $Content, $encoding)
}
if (-not (Test-Path $sourcePath)) {
throw "Project routing smoke source was not found: $sourcePath"
}
if (Test-Path $compileRoot) {
Remove-Item $compileRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $compileRoot -Force | Out-Null
$compileArgs = @(
'pnpm',
'--dir', (Join-Path $repoRoot 'apps\desktop'),
'exec',
'tsc',
'--module', 'ES2022',
'--moduleResolution', 'node',
'--target', 'ES2022',
'--lib', 'ES2022',
'--types', 'node',
'--esModuleInterop',
'--allowSyntheticDefaultImports',
'--skipLibCheck',
'--outDir', $compileRoot,
$sourcePath
)
Write-Host 'Compiling project-routing smoke with local TypeScript'
corepack @compileArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $entryPath)) {
throw "Project routing smoke entry was not emitted: $entryPath"
}
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
Write-Host 'Running project-routing smoke'
node $entryPath $resolvedResultPath
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $resolvedResultPath)) {
throw "Project routing smoke did not produce a result file: $resolvedResultPath"
}
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { AppConfigService } from "../../apps/desktop/src/main/services/app-config.js";
import { ProjectChatTargetResolverService } from "../../apps/desktop/src/main/services/project-chat-target-resolver.js";
import { ProjectIntentRouterService } from "../../apps/desktop/src/main/services/project-intent-router.js";
import { ProjectSkillRouterService } from "../../apps/desktop/src/main/services/project-skill-router.js";
import { ProjectStoreService } from "../../apps/desktop/src/main/services/project-store.js";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
async function writeSkill(workspaceRoot: string, skillId: string, body: string): Promise<void> {
const skillPath = path.join(workspaceRoot, "skills", skillId, "SKILL.md");
await mkdir(path.dirname(skillPath), { recursive: true });
await writeFile(skillPath, body, "utf8");
}
async function main(): Promise<void> {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..", "..");
const resultPath = path.resolve(process.argv[2] ?? path.join(repoRoot, ".tmp", "project-routing-smoke", "result.json"));
const tempRoot = path.dirname(resultPath);
const userDataPath = path.join(tempRoot, "user-data");
await rm(tempRoot, { recursive: true, force: true });
await mkdir(userDataPath, { recursive: true });
const configService = new AppConfigService(userDataPath);
await configService.load();
const projectStore = new ProjectStoreService(configService);
await projectStore.initialize();
const workspaceRoot = await projectStore.getWorkspaceRoot();
await writeSkill(workspaceRoot, "xiaohongshu-pipeline", `---
name: xiaohongshu-pipeline
description: xiaohongshu end to end publish skill
---
`);
await writeSkill(workspaceRoot, "xiaohongshu-writer", `---
name: xiaohongshu-writer
description: xiaohongshu publish draft skill
---
`);
await writeSkill(workspaceRoot, "xiaohongshu-publisher", `---
name: xiaohongshu-publisher
description: xiaohongshu publish draft skill
---
`);
await writeSkill(workspaceRoot, "douyin-script-writer", `---
name: douyin-script-writer
description: douyin script writing skill
---
`);
const xiaohongshu = await projectStore.upsertProject({
id: "xiaohongshu-workspace",
name: "Xiaohongshu Workspace",
description: "\u5c0f\u7ea2\u4e66\u7f8e\u5986\u53d1\u5e16\u9879\u76ee",
boundSkillIds: ["xiaohongshu-pipeline", "xiaohongshu-writer", "xiaohongshu-publisher"],
ready: true
});
const douyin = await projectStore.upsertProject({
id: "douyin-workspace",
name: "Douyin Workspace",
description: "\u6296\u97f3\u526a\u8f91\u811a\u672c\u9879\u76ee",
boundSkillIds: ["douyin-script-writer"],
ready: true
});
const intentRouter = new ProjectIntentRouterService(projectStore);
const skillRouter = new ProjectSkillRouterService(projectStore);
const chatTargetResolver = new ProjectChatTargetResolverService(projectStore, intentRouter);
await projectStore.setActiveProject(douyin.id);
const seedSession = await projectStore.createSession("Douyin Session", douyin.id);
const publishPrompt = "\u53d1\u4e00\u4e2a\u7f8e\u5986\u7c7b\u7684\u5c0f\u7ea2\u4e66\u5e16\u5b50";
const writePrompt = "\u5e2e\u6211\u5199\u4e00\u4e2a\u5c0f\u7ea2\u4e66\u62a4\u80a4\u6587\u6848";
const publishExistingPrompt = "\u628a\u8fd9\u4e2a markdown \u8349\u7a3f\u53d1\u5e03\u5230\u5c0f\u7ea2\u4e66";
const projectRoute = await intentRouter.resolve(publishPrompt, douyin.id);
assert(projectRoute?.projectId === xiaohongshu.id, "Default chat project routing did not choose the Xiaohongshu workspace.");
const resolvedTarget = await chatTargetResolver.resolve(seedSession.id, publishPrompt, null);
assert(resolvedTarget.autoRouted, "Chat target resolver did not auto-route the default chat request.");
assert(resolvedTarget.sessionState.projectId === xiaohongshu.id, "Chat target resolver did not rebind the request into the Xiaohongshu workspace session.");
assert(resolvedTarget.sessionState.sessionId !== seedSession.id, "Chat target resolver should not reuse the original Douyin session for a Xiaohongshu request.");
const pipelineRoute = await skillRouter.resolve(xiaohongshu.id, publishPrompt);
assert(pipelineRoute?.skillId === "xiaohongshu-pipeline", "Skill router did not choose the Xiaohongshu pipeline skill for the publish-style request.");
const writerRoute = await skillRouter.resolve(xiaohongshu.id, writePrompt);
assert(writerRoute?.skillId === "xiaohongshu-writer", "Skill router did not choose the Xiaohongshu writer skill for the writing-style request.");
const publisherRoute = await skillRouter.resolve(xiaohongshu.id, publishExistingPrompt);
assert(publisherRoute?.skillId === "xiaohongshu-publisher", "Skill router did not choose the Xiaohongshu publisher skill for the existing-draft publish request.");
const summary = {
ok: true,
workspaceRoot,
publishPrompt,
writePrompt,
publishExistingPrompt,
initialProjectId: douyin.id,
routedProjectId: projectRoute?.projectId ?? null,
routedProjectReason: projectRoute?.reason ?? null,
routedSessionId: resolvedTarget.sessionState.sessionId,
routedSessionProjectId: resolvedTarget.sessionState.projectId,
pipelineSkillId: pipelineRoute?.skillId ?? null,
pipelineRouteReason: pipelineRoute?.reason ?? null,
writerSkillId: writerRoute?.skillId ?? null,
publisherSkillId: publisherRoute?.skillId ?? null
};
await mkdir(path.dirname(resultPath), { recursive: true });
await writeFile(resultPath, JSON.stringify(summary, null, 2), "utf8");
console.log(JSON.stringify(summary, null, 2));
}
main().catch(async (error) => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..", "..");
const resultPath = path.resolve(process.argv[2] ?? path.join(repoRoot, ".tmp", "project-routing-smoke", "result.json"));
const failure = {
ok: false,
error: error instanceof Error ? error.stack ?? error.message : String(error)
};
await mkdir(path.dirname(resultPath), { recursive: true });
await writeFile(resultPath, JSON.stringify(failure, null, 2), "utf8");
console.error(failure.error);
process.exitCode = 1;
});
param(
param(
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token',
[string]$SmokeOutput,
......@@ -10,6 +10,27 @@ param(
$ErrorActionPreference = 'Stop'
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 $SmokeOutput) {
$SmokeOutput = Join-Path $repoRoot '.tmp\workspace-entry-smoke\result.json'
......@@ -29,17 +50,18 @@ if (-not $SkipMaterializeRuntime) {
}
}
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') `
-SmokeOutput $SmokeOutput `
-UserDataPath $UserDataPath `
-LogsPath $LogsPath `
-RuntimeMode 'bundled-runtime' `
-ExpectBundledRuntime `
-PrepareWorkspaceEntryFixture `
-ExpectWorkspaceEntry `
-WorkspaceProjectId 'workspace-entry-smoke' `
-WorkspaceProjectName 'Workspace Entry Smoke' `
-SmokePrompt 'Describe the current project root and confirm workspace execution.' `
-SmokeSkillId '__workspace_entry_disabled__' `
-TimeoutSeconds $TimeoutSeconds
exit $LASTEXITCODE
Invoke-ElectronSmokeWithRetry -ScriptPath (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') -Label 'workspace-entry smoke' -ArgumentList @(
'-SmokeOutput', $SmokeOutput,
'-UserDataPath', $UserDataPath,
'-LogsPath', $LogsPath,
'-RuntimeMode', 'bundled-runtime',
'-ExpectBundledRuntime',
'-PrepareWorkspaceEntryFixture',
'-ExpectWorkspaceEntry',
'-WorkspaceProjectId', 'workspace-entry-smoke',
'-WorkspaceProjectName', 'Workspace Entry Smoke',
'-SmokePrompt', 'Describe the current project root and confirm workspace execution.',
'-SmokeSkillId', '__workspace_entry_disabled__',
'-TimeoutSeconds', $TimeoutSeconds
)
exit 0
# Project Bundle Isolation Plan
Date: 2026-04-01
Status: Bundle ingestion, cloud-owned inventory enforcement, freshness hardening, replacement/rollback hardening, and lifecycle smoke are implemented
Status: Bundle ingestion, cloud-owned inventory enforcement, freshness hardening, replacement/rollback hardening, broader churn coverage, and lifecycle smoke are implemented
## 1. Product constraints
This plan follows the confirmed product constraints:
......@@ -9,6 +9,10 @@ This plan follows the confirmed product constraints:
- The active bundle source is `skills[].skill.download_url`.
- `project_bundle_url` is not part of the current implementation.
- Project inventory is fixed by backend configuration.
- The current protocol returns a `skills` array.
- Each `skills[]` object uses one `skill.download_url` string to point at one project zip.
- Multiple projects are currently expressed by multiple `skills[]` objects.
- If the backend later changes to multi-value download URLs, that will be a separate protocol and implementation task.
## 2. What is already implemented
### Cloud bundle ingestion
......@@ -63,6 +67,7 @@ This plan follows the confirmed product constraints:
- `project-bundle-reconcile-smoke`
- `project-bundle-freshness-smoke`
- `project-bundle-replacement-smoke`
- `project-bundle-churn-smoke`
- `project-isolation-smoke`
Additional lifecycle coverage now verified:
......@@ -70,18 +75,22 @@ Additional lifecycle coverage now verified:
- cloud bundle smoke covers cached `init`
- cloud bundle smoke covers same-`projectId` replacement through Electron UI/main flow
- replacement smoke covers service-level rollback injection and recovery on the next sync
- churn smoke covers multi-project survivor/replacement/removal/addition paths
- `project-isolation-smoke.ps1` now aggregates reconcile, freshness, replacement, and churn service-level coverage
## 3. What is no longer pending
The previously pending items are now implemented:
- full cloud-owned project inventory enforcement
- strong rollback guarantees for partially failed bundle replacement
- broader service-level regression coverage for multi-project churn scenarios
- end-to-end lifecycle smoke for replacement scenarios
## 4. Remaining follow-up work
The main work left is follow-up hardening rather than missing core behavior:
- broaden replacement coverage beyond the single-project happy path to larger multi-project churn scenarios
- decide whether replacement lifecycle smoke should also be aggregated into higher-level CI or release gates
- decide whether `project-isolation-smoke.ps1` and `cloud-bundle-smoke.ps1` should be wired into broader external CI or release gates
- expand deterministic churn coverage to larger or randomized stress matrices only when product changes raise the risk level
- keep the design docs aligned as the project-isolation surface expands
- if backend protocol evolves beyond single `skills[].skill.download_url`, implement that as a separate compatibility task
## 5. File-by-file status
### `apps/desktop/src/main/services/project-bundle.ts`
......@@ -90,15 +99,16 @@ The main work left is follow-up hardening rather than missing core behavior:
### `apps/desktop/src/main/services/project-store.ts`
- Owns active-project state without recreating a local fallback project when inventory is empty.
- Keeps active-project transitions consistent during bundle arrival, removal, and replacement.
- Keeps active-project transitions consistent during bundle arrival, removal, replacement, and churn.
### `apps/desktop/src/main/ipc.ts`
- Keeps send and stream paths aligned on post-execution refresh behavior.
- Preserves background refresh semantics without adding reply latency.
### `build/scripts/*.ps1`
- Current smokes cover empty inventory, bundle reconcile, freshness, replacement rollback, and Electron lifecycle replacement.
- `cloud-bundle-smoke.ps1` is now the main Electron lifecycle regression for bundle replacement.
- Current smokes cover empty inventory, bundle reconcile, freshness, replacement rollback, multi-project churn, and Electron lifecycle replacement.
- `cloud-bundle-smoke.ps1` is the main Electron lifecycle regression for bundle replacement.
- `project-isolation-smoke.ps1` is the main service-level aggregation gate for project-isolation coverage.
## 6. Practical status summary
Current phase:
......@@ -109,9 +119,11 @@ Current phase:
- stale bundle cleanup works
- bundle freshness hardening works
- replacement / rollback hardening works
- multi-project churn coverage exists at the service level
- lifecycle smoke for replacement is in place
Recommended immediate focus:
- keep the current smoke set green
- sync higher-level docs and rollout notes
- wire the current gate pair into broader rollout notes or external CI if needed
- expand regression breadth only if upcoming product changes need it
- keep `download_url` protocol evolution explicitly out of unrelated hardening work until backend is ready
# Project Isolation Execution Design
Date: 2026-04-01
Status: Core execution chain, cloud-owned inventory, bundle freshness hardening, replacement/rollback hardening, and lifecycle smoke are implemented
Status: Core execution chain, cloud-owned inventory, bundle freshness hardening, replacement/rollback hardening, broader churn coverage, and lifecycle smoke are implemented
## 1. Scope and product constraints
This document describes the current desktop implementation for project-isolated execution.
......@@ -10,6 +10,10 @@ Confirmed product constraints:
- The active bundle source is `skills[].skill.download_url`.
- `project_bundle_url` is not part of the current implementation.
- Local project CRUD is not part of the target product shape.
- The current backend contract returns a `skills` array.
- Each `skills[]` object currently treats `skill.download_url` as one project zip URL.
- Multiple projects are represented by multiple `skills[]` objects.
- Any future multi-value download URL contract must be handled as a separate compatibility change.
## 2. Implemented architecture
The implemented chain is:
......@@ -28,6 +32,7 @@ The implemented chain is:
10. Bundle reconciliation removes stale bundle-managed projects, shared `skills/` entries, shared `cron/` entries, and stale manifest records when the expected cloud bundle set changes.
11. Bundle freshness probes the remote bundle with HTTP metadata before deciding whether an existing local materialization can be reused.
12. Same-`projectId` replacement now stages project/shared assets, commits them in order, and either finalizes or rolls back the whole replacement set.
13. Broader churn coverage now verifies survivor, replacement, removal, and addition flows across multiple projects without changing the current cloud protocol.
## 3. Code-truth execution behavior
Current behavior verified from code:
......@@ -97,8 +102,15 @@ Current smoke coverage in the repo:
- validates same-project replacement at the service layer
- validates rollback after injected post-commit failure
- validates successful recovery on the next sync
- `project-bundle-churn-smoke.ps1`
- validates multi-project survivor behavior
- validates same-project replacement during broader churn
- validates project removal and project addition in the same sync window
- validates active-project fallback when the active project is removed
- validates session survival inside unaffected projects
- `project-isolation-smoke.ps1`
- aggregates the main project-isolation regression smokes
- now includes reconcile, freshness, replacement, and churn service-level coverage
Electron smoke validation is also stronger now:
- workspace-entry validation no longer assumes the final status label must contain `workspace`
......@@ -108,14 +120,16 @@ Electron smoke validation is also stronger now:
## 5. What is still incomplete
The earlier core gaps are closed.
The main follow-up areas now are:
- broader multi-project churn coverage beyond the current targeted replacement path
- deciding whether the new replacement lifecycle smoke should be promoted into broader CI / release gating
- deciding whether the `project-isolation-smoke.ps1` and `cloud-bundle-smoke.ps1` gate pair should be wired into broader external CI / release coverage
- expanding deterministic churn coverage into larger or randomized stress matrices only when product changes raise the risk level
- keeping related product and rollout docs aligned with the implementation
- implementing any future protocol migration away from single `skills[].skill.download_url` only when backend is ready
## 6. Recommended next implementation order
1. Keep the current project-isolation smoke set green.
2. Promote lifecycle replacement smoke into higher-level release coverage if upcoming changes raise the risk level.
3. Expand breadth only when new isolation surfaces or multi-project behaviors are introduced.
2. Use `project-isolation-smoke.ps1` plus `cloud-bundle-smoke.ps1` as the default higher-level regression gate pair.
3. Expand breadth only when new isolation surfaces or larger multi-project behaviors are introduced.
4. Handle any future `download_url` protocol evolution as a dedicated compatibility task.
## 7. Bottom line
Current status:
......@@ -128,4 +142,5 @@ Current status:
- Stale bundle reconciliation is implemented for bundle-managed cleanup.
- Bundle freshness hardening is implemented and smoke-verified.
- Replacement / rollback hardening is implemented and smoke-verified.
- Remaining work is mostly broader regression breadth and routine follow-up maintenance.
\ No newline at end of file
- Multi-project churn coverage is implemented at the service level.
- Remaining work is mostly broader regression breadth, gate wiring, and future protocol evolution.
# Single Instance + Task Isolation
Date: 2026-04-01
Status: Foundation, cloud-owned inventory, freshness hardening, replacement/rollback hardening, and lifecycle smoke are implemented
Status: Foundation, cloud-owned inventory, freshness hardening, replacement/rollback hardening, broader bundle regression coverage, and lifecycle smoke are implemented
## 1. Intent
The goal is a single desktop app instance with isolated project execution.
......@@ -47,6 +47,13 @@ All three routes are project-aware.
- Same-`projectId` bundle replacement now uses explicit stage/commit/finalize-or-rollback handling.
- If replacement fails after commit but before metadata sync completes, the previous project root and shared assets are restored.
### Current protocol boundary
- The current backend payload returns a `skills` array.
- Each `skills[]` object carries one project zip through its single `skill.download_url` string field.
- Multi-project inventory is currently expressed by multiple `skills[]` objects, one project download URL per object.
- If the backend later changes this field to an array or set, that must be treated as a separate protocol and implementation change.
- That protocol upgrade has not started yet.
## 3. Current default chat behavior
Default chat is no longer a special weak path.
......@@ -69,20 +76,23 @@ Current repo smoke coverage includes:
- `project-bundle-reconcile-smoke.ps1`
- `project-bundle-freshness-smoke.ps1`
- `project-bundle-replacement-smoke.ps1`
- `project-bundle-churn-smoke.ps1`
- `project-isolation-smoke.ps1`
Additional verified points:
- cloud bundle smoke passes with freshness probing enabled
- cloud bundle smoke now validates same-`projectId` replacement through the Electron UI/main chain
- service-level replacement smoke validates rollback injection and recovery on the next sync
- service-level churn smoke validates multi-project survivor/replacement/removal/addition behavior
- `project-isolation-smoke.ps1` now aggregates reconcile, freshness, replacement, and multi-project churn coverage into the broader project-isolation gate
- Electron smoke validates workspace-agent status history instead of relying on the final status label only
## 5. Important current limitations
### Broad UI regression breadth is still selective
The targeted Electron lifecycle smoke for cloud bundle replacement now exists, but the full UI regression matrix is still intentionally selective rather than exhaustive.
### Follow-up hardening can still expand
The current replacement lifecycle coverage is strong for the implemented path, but future changes may still need wider multi-project churn and stress coverage.
### Stress breadth can still expand
The current replacement and churn coverage is strong for deterministic paths, but future changes may still need larger randomized multi-project stress coverage.
## 6. What has been completed so far
Completed enough to count as real implementation:
......@@ -95,17 +105,19 @@ Completed enough to count as real implementation:
- stale bundle-managed cleanup for project/skill/cron/manifest state
- bundle freshness hardening using remote metadata probe
- replacement / rollback hardening for same-project bundle updates
- smoke coverage for empty inventory, removal, freshness, replacement, and Electron lifecycle validation
- broader service-level churn coverage for multi-project inventory transitions
- smoke coverage for empty inventory, removal, freshness, replacement, churn, and Electron lifecycle validation
This project is no longer at the design-only stage.
## 7. What should happen next
Recommended next work:
1. Keep the current smoke set green as adjacent runtime work lands.
2. Fold the new lifecycle replacement smoke into any broader release gate if needed.
3. Expand coverage only when upcoming product changes introduce new isolation surfaces.
2. Use `project-isolation-smoke.ps1` plus `cloud-bundle-smoke.ps1` as the main higher-level regression gate pair.
3. Expand breadth only when upcoming product changes introduce new isolation surfaces.
4. If backend protocol changes from single `download_url` to a multi-value form, handle it as a separate compatibility task instead of folding it into unrelated hardening work.
## 8. Final summary
The single-instance plus task-isolation foundation is implemented.
The earlier main gaps around cloud-owned inventory, replacement/rollback hardening, and lifecycle smoke have been closed.
The remaining work is mostly broader regression breadth and routine maintenance.
\ No newline at end of file
The remaining work is mostly broader regression breadth and future protocol evolution, not missing core behavior.
......@@ -17,6 +17,8 @@
"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: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",
"smoke:project-isolation": "powershell -ExecutionPolicy Bypass -File build/scripts/project-isolation-smoke.ps1",
"smoke:materialize-cache": "powershell -ExecutionPolicy Bypass -File build/scripts/materialize-runtime-cache-smoke.ps1",
"smoke:project-context-refresh": "powershell -ExecutionPolicy Bypass -File build/scripts/project-context-refresh-smoke.ps1",
......@@ -24,6 +26,7 @@
"smoke:bundle-reconcile": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-reconcile-smoke.ps1",
"smoke:bundle-freshness": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-freshness-smoke.ps1",
"smoke:bundle-replacement": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-replacement-smoke.ps1",
"smoke:bundle-churn": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-churn-smoke.ps1",
"smoke:installer:bundled-runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/installer-smoke.ps1 -RuntimeMode bundled-runtime -ExpectBundledRuntime"
},
"pnpm": {
......@@ -32,3 +35,5 @@
]
}
}
......@@ -302,6 +302,28 @@ export interface SessionSummary {
updatedAt: string;
}
export type ProjectPackageEntryType = "skill" | "workspace-entry";
export type ProjectConfirmationPolicy = "always" | "never" | "publish-only";
export interface ProjectPackageEntry {
id: string;
type: ProjectPackageEntryType;
requiresSkills: string[];
capabilities: string[];
intentAliases: string[];
confirmationPolicy?: ProjectConfirmationPolicy;
}
export interface ProjectPackageConfig {
projectType?: string;
platform?: string;
capabilities: string[];
requires: string[];
confirmationPolicy?: ProjectConfirmationPolicy;
defaultEntry?: ProjectPackageEntry;
entries: ProjectPackageEntry[];
}
export interface ProjectSummary {
id: string;
name: string;
......@@ -310,6 +332,10 @@ export interface ProjectSummary {
updatedAt: string;
skillCount: number;
ready: boolean;
projectType?: string;
platform?: string;
defaultEntryId?: string;
defaultEntryType?: ProjectPackageEntryType;
}
export interface ProjectSessionSummary extends SessionSummary {
......@@ -356,6 +382,7 @@ export interface ProjectExecutionRequest {
userPrompt: string;
context: ProjectContextSnapshot;
selectedSkillId: string | null;
projectConfig?: ProjectPackageConfig | null;
}
export type ProjectExecutionDecision =
......
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