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"; ...@@ -20,8 +20,11 @@ import { SkillClient } from "./services/skill-client.js";
import { SkillStoreService } from "./services/skill-store.js"; import { SkillStoreService } from "./services/skill-store.js";
import { ProjectStoreService } from "./services/project-store.js"; import { ProjectStoreService } from "./services/project-store.js";
import { ProjectBundleService } from "./services/project-bundle.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 { ProjectContextService } from "./services/project-context.js";
import { ProjectExecutionRouter } from "./services/project-execution-router.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"; import { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
interface RendererSmokeState { interface RendererSmokeState {
...@@ -606,6 +609,9 @@ async function bootstrap(): Promise<void> { ...@@ -606,6 +609,9 @@ async function bootstrap(): Promise<void> {
} }
}; };
const projectContextService = new ProjectContextService(projectStore); 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(); const projectExecutionRouter = new ProjectExecutionRouter();
runtimeCloudClient.onPayloadUpdated(async ({ config: payloadConfig, skills }) => { runtimeCloudClient.onPayloadUpdated(async ({ config: payloadConfig, skills }) => {
await skillStore.reconcile(skills, payloadConfig.configVersion); await skillStore.reconcile(skills, payloadConfig.configVersion);
...@@ -754,6 +760,8 @@ async function bootstrap(): Promise<void> { ...@@ -754,6 +760,8 @@ async function bootstrap(): Promise<void> {
runtimeSkillBridge, runtimeSkillBridge,
projectStore, projectStore,
projectContextService, projectContextService,
projectChatTargetResolver,
projectSkillRouter,
projectExecutionRouter, projectExecutionRouter,
projectWorkspaceExecutor, projectWorkspaceExecutor,
systemSummary, systemSummary,
......
This diff is collapsed.
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import http from "node:http"; import http from "node:http";
...@@ -42,6 +42,7 @@ interface ProjectJsonShape { ...@@ -42,6 +42,7 @@ interface ProjectJsonShape {
name?: string; name?: string;
version?: string; version?: string;
description?: string; description?: string;
[key: string]: unknown;
} }
interface BundleReplacementOperation { interface BundleReplacementOperation {
...@@ -463,7 +464,8 @@ export class ProjectBundleService { ...@@ -463,7 +464,8 @@ export class ProjectBundleService {
const stagingRoot = path.join(tempRoot, "materialized"); const stagingRoot = path.join(tempRoot, "materialized");
const stagedProjectRoot = path.join(stagingRoot, "projects", metadata.projectId); 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( const stagedSkills = await this.stageSharedDirectories(
this.resolveSharedSubdir(metadata, "skills"), this.resolveSharedSubdir(metadata, "skills"),
...@@ -541,7 +543,11 @@ export class ProjectBundleService { ...@@ -541,7 +543,11 @@ export class ProjectBundleService {
return projectDir; 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 rm(stagedProjectRoot, { recursive: true, force: true }).catch(() => undefined);
await mkdir(path.dirname(stagedProjectRoot), { recursive: true }); await mkdir(path.dirname(stagedProjectRoot), { recursive: true });
await cp(metadata.projectSourceRoot, stagedProjectRoot, { recursive: true, force: true }); await cp(metadata.projectSourceRoot, stagedProjectRoot, { recursive: true, force: true });
...@@ -549,6 +555,7 @@ export class ProjectBundleService { ...@@ -549,6 +555,7 @@ export class ProjectBundleService {
const projectJsonPath = path.join(stagedProjectRoot, "project.json"); const projectJsonPath = path.join(stagedProjectRoot, "project.json");
const existing = await this.readProjectJson(projectJsonPath); const existing = await this.readProjectJson(projectJsonPath);
await writeJsonFile(projectJsonPath, { await writeJsonFile(projectJsonPath, {
...(existing ?? {}),
id: metadata.projectId, id: metadata.projectId,
name: metadata.projectName, name: metadata.projectName,
version: metadata.version ?? existing?.version, version: metadata.version ?? existing?.version,
...@@ -558,6 +565,24 @@ export class ProjectBundleService { ...@@ -558,6 +565,24 @@ export class ProjectBundleService {
boundSkillIds: [] boundSkillIds: []
}); });
await mkdir(path.join(stagedProjectRoot, "memory"), { recursive: true }); 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> { private async stageSharedDirectories(sourceDir: string | null, stagingDir: string, targetRoot: string): Promise<StagedSharedEntries> {
...@@ -734,3 +759,4 @@ export class ProjectBundleService { ...@@ -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 path from "node:path";
import type { import type {
ProjectContextSnapshot, ProjectContextSnapshot,
ProjectExecutionDecision, ProjectExecutionDecision,
ProjectExecutionRequest ProjectExecutionRequest,
ProjectPackageConfig
} from "@qjclaw/shared-types"; } from "@qjclaw/shared-types";
const WORKSPACE_ENTRY_MARKERS = ["AGENT", "AGENT.md", "AGENTS.md"]; const WORKSPACE_ENTRY_MARKERS = ["AGENT", "AGENT.md", "AGENTS.md"];
...@@ -55,6 +56,14 @@ function buildPreparedPrompt(snapshot: ProjectContextSnapshot, userPrompt: strin ...@@ -55,6 +56,14 @@ function buildPreparedPrompt(snapshot: ProjectContextSnapshot, userPrompt: strin
].join("\n\n"); ].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 { export class ProjectExecutionRouter {
async decide(request: ProjectExecutionRequest): Promise<ProjectExecutionDecision> { async decide(request: ProjectExecutionRequest): Promise<ProjectExecutionDecision> {
const preparedPrompt = buildPreparedPrompt(request.context, request.userPrompt); const preparedPrompt = buildPreparedPrompt(request.context, request.userPrompt);
...@@ -66,6 +75,16 @@ export class ProjectExecutionRouter { ...@@ -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); const workspaceEntryReason = await this.detectWorkspaceEntry(request.projectRoot);
if (workspaceEntryReason) { if (workspaceEntryReason) {
return { 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 { ...@@ -136,6 +136,7 @@ export class ProjectWorkspaceExecutorService {
OPENCLAW_HOME: paths.runtimeDataDir, OPENCLAW_HOME: paths.runtimeDataDir,
OPENCLAW_STATE_DIR: paths.runtimeStateDir, OPENCLAW_STATE_DIR: paths.runtimeStateDir,
OPENCLAW_CONFIG_PATH: paths.managedConfigPath, OPENCLAW_CONFIG_PATH: paths.managedConfigPath,
PLAYWRIGHT_BROWSERS_PATH: paths.playwrightBrowsersPath,
OPENCLAW_HIDE_BANNER: "1", OPENCLAW_HIDE_BANNER: "1",
OPENCLAW_SUPPRESS_NOTES: "1", OPENCLAW_SUPPRESS_NOTES: "1",
NODE_NO_WARNINGS: process.env.NODE_NO_WARNINGS || "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 path from "node:path";
import type { RuntimeManager } from "@qjclaw/runtime-manager"; import type { RuntimeManager } from "@qjclaw/runtime-manager";
import type { ProjectStoreService } from "./project-store.js"; import type { ProjectStoreService } from "./project-store.js";
...@@ -77,13 +77,19 @@ export class RuntimeSkillBridgeService { ...@@ -77,13 +77,19 @@ export class RuntimeSkillBridgeService {
} }
async preparePrompt(prompt: string, skillId: string, projectId?: string): Promise<PreparedSkillExecution> { 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) { if (!target) {
throw new Error("The selected skill is not ready locally yet."); 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 runtimeStatus = await this.runtimeManager.status();
const activation = await this.activateRuntimeSkill(target, runtimeStatus.runtimeDataDir); const activation = await this.activateRuntimeSkills(target, dependencyTargets, runtimeStatus.runtimeDataDir);
return { return {
prompt: this.buildPrompt(prompt, target.name, activation.runtimeSkillName), prompt: this.buildPrompt(prompt, target.name, activation.runtimeSkillName),
...@@ -100,23 +106,52 @@ export class RuntimeSkillBridgeService { ...@@ -100,23 +106,52 @@ export class RuntimeSkillBridgeService {
await this.removeManagedSkillDirs(skillsRoot); 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 content = await readFile(target.localPath, "utf8");
const sourceName = extractFrontmatterName(content) const sourceName = extractFrontmatterName(content)
?? (target.fileName ? path.parse(target.fileName).name : undefined) ?? (target.fileName ? path.parse(target.fileName).name : undefined)
?? target.name ?? target.name
?? target.skillId; ?? target.skillId;
const runtimeSkillName = `${MANAGED_SKILL_PREFIX}${slugify(sourceName)}`; const runtimeSkillName = selected
const skillsRoot = await this.resolveManagedSkillsRoot(runtimeDataDir); ? `${MANAGED_SKILL_PREFIX}${slugify(sourceName)}`
const runtimeSkillDir = path.join(skillsRoot, runtimeSkillName); : sourceName;
const runtimeSkillDir = path.join(skillsRoot, `${MANAGED_SKILL_PREFIX}${slugify(target.skillId)}`);
const materializedContent = applyRuntimeSkillName(content, runtimeSkillName); const materializedContent = applyRuntimeSkillName(content, runtimeSkillName);
await mkdir(skillsRoot, { recursive: true }); await rm(runtimeSkillDir, { recursive: true, force: true }).catch(() => undefined);
await this.removeManagedSkillDirs(skillsRoot);
await mkdir(runtimeSkillDir, { recursive: true }); await mkdir(runtimeSkillDir, { recursive: true });
await writeFile(path.join(runtimeSkillDir, "SKILL.md"), materializedContent, "utf8"); await writeFile(path.join(runtimeSkillDir, "SKILL.md"), materializedContent, "utf8");
await writeFile(path.join(runtimeSkillDir, ".qjclaw-skill.json"), JSON.stringify({ await writeFile(path.join(runtimeSkillDir, ".qjclaw-skill.json"), JSON.stringify({
source: "cloud", source: "cloud",
selected,
selectedSkillName: target.name, selectedSkillName: target.name,
runtimeSkillName, runtimeSkillName,
localPath: target.localPath, localPath: target.localPath,
......
...@@ -17,5 +17,5 @@ ...@@ -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-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-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-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, [int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token', [string]$GatewayToken = 'qjc-bundled-runtime-token',
[int]$SmokePort = 4318, [int]$SmokePort = 4318,
...@@ -16,6 +16,27 @@ function Write-Utf8File { ...@@ -16,6 +16,27 @@ function Write-Utf8File {
[System.IO.File]::WriteAllText($filePath, $content, $encoding) [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 $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $BaseOutputDir) { if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\cloud-bundle-smoke' $BaseOutputDir = Join-Path $repoRoot '.tmp\cloud-bundle-smoke'
...@@ -48,6 +69,7 @@ $bundleSharedSkillB = 'cloud-bundle-shared-skill-b' ...@@ -48,6 +69,7 @@ $bundleSharedSkillB = 'cloud-bundle-shared-skill-b'
$bundleSharedCronA = 'cloud-bundle-task-a.txt' $bundleSharedCronA = 'cloud-bundle-task-a.txt'
$bundleSharedCronB = 'cloud-bundle-task-b.txt' $bundleSharedCronB = 'cloud-bundle-task-b.txt'
$expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileName" $expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileName"
$electronSmokeScript = Join-Path $repoRoot 'build\scripts\electron-smoke.ps1'
if (Test-Path $BaseOutputDir) { if (Test-Path $BaseOutputDir) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
...@@ -126,29 +148,27 @@ $env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersionA ...@@ -126,29 +148,27 @@ $env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersionA
try { try {
Write-Host 'Running cloud bundle smoke phase 1 (payload sync path)' Write-Host 'Running cloud bundle smoke phase 1 (payload sync path)'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') ` Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'cloud-bundle phase 1' -ArgumentList @(
-SmokeOutput $phase1Output ` '-SmokeOutput', $phase1Output,
-SmokePort $SmokePort ` '-SmokePort', $SmokePort,
-SmokeToken $SmokeToken ` '-SmokeToken', $SmokeToken,
-UserDataPath $userDataPath ` '-UserDataPath', $userDataPath,
-LogsPath $phase1LogsPath ` '-LogsPath', $phase1LogsPath,
-RuntimeMode 'bundled-runtime' ` '-RuntimeMode', 'bundled-runtime',
-ExpectBundledRuntime ` '-ExpectBundledRuntime',
-ExpectWorkspaceEntry ` '-ExpectWorkspaceEntry',
-ExpectRemoteBundle ` '-ExpectRemoteBundle',
-WorkspaceProjectId $bundleProjectId ` '-WorkspaceProjectId', $bundleProjectId,
-WorkspaceProjectName $bundleProjectName ` '-WorkspaceProjectName', $bundleProjectName,
-SmokePrompt 'Describe the current project root and confirm cloud bundle workspace execution.' ` '-SmokePrompt', 'Describe the current project root and confirm cloud bundle workspace execution.',
-SmokeSkillId '__cloud_bundle_workspace_entry_disabled__' ` '-SmokeSkillId', '__cloud_bundle_workspace_entry_disabled__',
-ExpectedBundleSourceUrl $expectedBundleSourceUrl ` '-ExpectedBundleSourceUrl', $expectedBundleSourceUrl,
-ExpectedBundleConfigVersion $bundleConfigVersionA ` '-ExpectedBundleConfigVersion', $bundleConfigVersionA,
-ExpectedBundleFileName $bundleFileName ` '-ExpectedBundleFileName', $bundleFileName,
-ExpectedBundleSkillId $bundleSkillId ` '-ExpectedBundleSkillId', $bundleSkillId,
-ExpectedReadmeMarker $bundleReadmeMarkerA ` '-ExpectedReadmeMarker', $bundleReadmeMarkerA,
-TimeoutSeconds $TimeoutSeconds '-TimeoutSeconds', $TimeoutSeconds
if ($LASTEXITCODE -ne 0) { )
exit $LASTEXITCODE
}
$projectPath = Join-Path $userDataPath (Join-Path 'projects' $bundleProjectId) $projectPath = Join-Path $userDataPath (Join-Path 'projects' $bundleProjectId)
$bundleManifestPath = Join-Path $userDataPath 'manifests\project-bundles.json' $bundleManifestPath = Join-Path $userDataPath 'manifests\project-bundles.json'
...@@ -164,60 +184,56 @@ try { ...@@ -164,60 +184,56 @@ try {
} }
Write-Host 'Running cloud bundle smoke phase 2 (cached init path)' Write-Host 'Running cloud bundle smoke phase 2 (cached init path)'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') ` Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'cloud-bundle phase 2' -ArgumentList @(
-SmokeOutput $phase2Output ` '-SmokeOutput', $phase2Output,
-SmokePort $SmokePort ` '-SmokePort', $SmokePort,
-SmokeToken $SmokeToken ` '-SmokeToken', $SmokeToken,
-UserDataPath $userDataPath ` '-UserDataPath', $userDataPath,
-LogsPath $phase2LogsPath ` '-LogsPath', $phase2LogsPath,
-RuntimeMode 'bundled-runtime' ` '-RuntimeMode', 'bundled-runtime',
-ExpectBundledRuntime ` '-ExpectBundledRuntime',
-PreserveUserData ` '-PreserveUserData',
-ExpectWorkspaceEntry ` '-ExpectWorkspaceEntry',
-ExpectRemoteBundle ` '-ExpectRemoteBundle',
-WorkspaceProjectId $bundleProjectId ` '-WorkspaceProjectId', $bundleProjectId,
-WorkspaceProjectName $bundleProjectName ` '-WorkspaceProjectName', $bundleProjectName,
-SmokePrompt 'Describe the current project root and confirm cached cloud bundle workspace execution.' ` '-SmokePrompt', 'Describe the current project root and confirm cached cloud bundle workspace execution.',
-SmokeSkillId '__cloud_bundle_workspace_entry_disabled__' ` '-SmokeSkillId', '__cloud_bundle_workspace_entry_disabled__',
-ExpectedBundleSourceUrl $expectedBundleSourceUrl ` '-ExpectedBundleSourceUrl', $expectedBundleSourceUrl,
-ExpectedBundleConfigVersion $bundleConfigVersionA ` '-ExpectedBundleConfigVersion', $bundleConfigVersionA,
-ExpectedBundleFileName $bundleFileName ` '-ExpectedBundleFileName', $bundleFileName,
-ExpectedBundleSkillId $bundleSkillId ` '-ExpectedBundleSkillId', $bundleSkillId,
-ExpectedReadmeMarker $bundleReadmeMarkerA ` '-ExpectedReadmeMarker', $bundleReadmeMarkerA,
-TimeoutSeconds $TimeoutSeconds '-TimeoutSeconds', $TimeoutSeconds
if ($LASTEXITCODE -ne 0) { )
exit $LASTEXITCODE
}
$env:QJCLAW_SMOKE_BUNDLE_ZIP_PATH = $bundleZipPathB $env:QJCLAW_SMOKE_BUNDLE_ZIP_PATH = $bundleZipPathB
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersionB $env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersionB
Write-Host 'Running cloud bundle smoke phase 3 (same-project replacement path)' Write-Host 'Running cloud bundle smoke phase 3 (same-project replacement path)'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') ` Invoke-ElectronSmokeWithRetry -ScriptPath $electronSmokeScript -Label 'cloud-bundle phase 3' -ArgumentList @(
-SmokeOutput $phase3Output ` '-SmokeOutput', $phase3Output,
-SmokePort $SmokePort ` '-SmokePort', $SmokePort,
-SmokeToken $SmokeToken ` '-SmokeToken', $SmokeToken,
-UserDataPath $userDataPath ` '-UserDataPath', $userDataPath,
-LogsPath $phase3LogsPath ` '-LogsPath', $phase3LogsPath,
-RuntimeMode 'bundled-runtime' ` '-RuntimeMode', 'bundled-runtime',
-ExpectBundledRuntime ` '-ExpectBundledRuntime',
-PreserveUserData ` '-PreserveUserData',
-ExpectWorkspaceEntry ` '-ExpectWorkspaceEntry',
-ExpectRemoteBundle ` '-ExpectRemoteBundle',
-WorkspaceProjectId $bundleProjectId ` '-WorkspaceProjectId', $bundleProjectId,
-WorkspaceProjectName $bundleProjectName ` '-WorkspaceProjectName', $bundleProjectName,
-SmokePrompt 'Quote the current README marker line and confirm same-project replacement workspace execution.' ` '-SmokePrompt', 'Quote the current README marker line and confirm same-project replacement workspace execution.',
-SmokeSkillId '__cloud_bundle_workspace_entry_disabled__' ` '-SmokeSkillId', '__cloud_bundle_workspace_entry_disabled__',
-ExpectedBundleSourceUrl $expectedBundleSourceUrl ` '-ExpectedBundleSourceUrl', $expectedBundleSourceUrl,
-ExpectedBundleConfigVersion $bundleConfigVersionB ` '-ExpectedBundleConfigVersion', $bundleConfigVersionB,
-ExpectedBundleFileName $bundleFileName ` '-ExpectedBundleFileName', $bundleFileName,
-ExpectedBundleSkillId $bundleSkillId ` '-ExpectedBundleSkillId', $bundleSkillId,
-ExpectedReadmeMarker $bundleReadmeMarkerB ` '-ExpectedReadmeMarker', $bundleReadmeMarkerB,
-UnexpectedReadmeMarker $bundleReadmeMarkerA ` '-UnexpectedReadmeMarker', $bundleReadmeMarkerA,
-TimeoutSeconds $TimeoutSeconds '-TimeoutSeconds', $TimeoutSeconds
if ($LASTEXITCODE -ne 0) { )
exit $LASTEXITCODE
}
$summary = & node -e @" $summary = & node -e @"
const fs = require('fs'); const fs = require('fs');
......
...@@ -273,9 +273,18 @@ if (expectBundled === 'true') { ...@@ -273,9 +273,18 @@ if (expectBundled === 'true') {
if (!runtimeStatus.pythonReady) { if (!runtimeStatus.pythonReady) {
throw new Error('Bundled runtime did not report a ready Python payload.'); 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.'); 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') { if (!sendResult.status || sendResult.status.state !== 'connected') {
throw new Error('Gateway did not reconnect after bundled runtime startup: ' + (sendResult.status && sendResult.status.state)); throw new Error('Gateway did not reconnect after bundled runtime startup: ' + (sendResult.status && sendResult.status.state));
} }
...@@ -290,8 +299,10 @@ if (expectWorkspaceEntry === 'true') { ...@@ -290,8 +299,10 @@ if (expectWorkspaceEntry === 'true') {
const expectedProjectRoot = path.join(expectedUserData, 'projects', workspaceProjectId); const expectedProjectRoot = path.join(expectedUserData, 'projects', workspaceProjectId);
const expectedSessionPrefix = 'project:' + workspaceProjectId + ':'; const expectedSessionPrefix = 'project:' + workspaceProjectId + ':';
const selectedSkillId = String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || ''); const selectedSkillId = String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || '');
if (!statusLabels.some((label) => label.toLowerCase().includes('workspace'))) { const reportedWorkspaceLabel = 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 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')) { if (!assistantContent.includes('desktop project-isolated workspace')) {
throw new Error('Workspace-entry smoke did not echo the injected isolated workspace context.'); throw new Error('Workspace-entry smoke did not echo the injected isolated workspace context.');
...@@ -305,7 +316,7 @@ if (expectWorkspaceEntry === 'true') { ...@@ -305,7 +316,7 @@ if (expectWorkspaceEntry === 'true') {
if (!assistantContent.includes('Keep project context isolated to this project and session.')) { 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.'); 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); throw new Error('Workspace-entry smoke unexpectedly selected a skill: ' + selectedSkillId);
} }
if (!String(sendResult.sessionId || streamSmoke.sessionId || '').startsWith(expectedSessionPrefix)) { if (!String(sendResult.sessionId || streamSmoke.sessionId || '').startsWith(expectedSessionPrefix)) {
......
...@@ -48,6 +48,10 @@ if (-not $LogsPath) { ...@@ -48,6 +48,10 @@ if (-not $LogsPath) {
$installParent = Split-Path $InstallDir -Parent $installParent = Split-Path $InstallDir -Parent
New-Item -ItemType Directory -Force -Path $installParent | Out-Null 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 { function Stop-SmokeAppProcesses {
$appProcesses = Get-Process -Name 'QianjiangClaw' -ErrorAction SilentlyContinue $appProcesses = Get-Process -Name 'QianjiangClaw' -ErrorAction SilentlyContinue
...@@ -123,29 +127,18 @@ while ((Get-Date) -lt $installDeadline) { ...@@ -123,29 +127,18 @@ while ((Get-Date) -lt $installDeadline) {
$lastSnapshot = $null $lastSnapshot = $null
} }
if ($setupProcess.HasExited -and -not $requiredPathsReady) { # Do not break early when the launcher process exits — NSIS may spawn a
break # 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 Start-Sleep -Milliseconds 500
} }
if (-not $installReady) { 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 Stop-Process -Id $setupProcess.Id -Force -ErrorAction SilentlyContinue
if (-not $requiredPathsReady) {
throw "Installer did not materialize the packaged files within $InstallTimeoutSeconds seconds." 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." throw "Installer did not reach a stable packaged file state within $InstallTimeoutSeconds seconds."
} }
...@@ -173,7 +166,7 @@ if (-not (Test-Path $packagedPythonManifest)) { ...@@ -173,7 +166,7 @@ if (-not (Test-Path $packagedPythonManifest)) {
throw "Bundled Python manifest not found at $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') { if ($LASTEXITCODE -ne 0 -or $pythonImportProbe -notmatch 'ok') {
throw 'Bundled Python import probe failed for the packaged runtime payload.' throw 'Bundled Python import probe failed for the packaged runtime payload.'
} }
......
...@@ -64,6 +64,7 @@ function Test-RuntimePayloadReady { ...@@ -64,6 +64,7 @@ function Test-RuntimePayloadReady {
(Join-Path $RuntimeDir 'python\python.exe'), (Join-Path $RuntimeDir 'python\python.exe'),
(Join-Path $RuntimeDir 'python\python-manifest.json'), (Join-Path $RuntimeDir 'python\python-manifest.json'),
(Join-Path $RuntimeDir 'python\runtime-requirements.lock.txt'), (Join-Path $RuntimeDir 'python\runtime-requirements.lock.txt'),
(Join-Path $RuntimeDir 'playwright-browsers'),
(Join-Path $RuntimeDir 'runtime-manifest.json'), (Join-Path $RuntimeDir 'runtime-manifest.json'),
(Join-Path $RuntimeDir 'README.md') (Join-Path $RuntimeDir 'README.md')
) )
...@@ -99,6 +100,7 @@ function New-RuntimeSummary { ...@@ -99,6 +100,7 @@ function New-RuntimeSummary {
pythonExecutable = (Join-Path $RuntimeDir 'python\python.exe') pythonExecutable = (Join-Path $RuntimeDir 'python\python.exe')
pythonManifestPath = (Join-Path $RuntimeDir 'python\python-manifest.json') pythonManifestPath = (Join-Path $RuntimeDir 'python\python-manifest.json')
requirementsPath = (Join-Path $RuntimeDir 'python\runtime-requirements.lock.txt') requirementsPath = (Join-Path $RuntimeDir 'python\runtime-requirements.lock.txt')
playwrightBrowsersPath = (Join-Path $RuntimeDir 'playwright-browsers')
gatewayPort = $Manifest.gatewayPort gatewayPort = $Manifest.gatewayPort
gatewayToken = $Manifest.gatewayToken gatewayToken = $Manifest.gatewayToken
installedPythonPackages = $Manifest.installedPythonPackages installedPythonPackages = $Manifest.installedPythonPackages
...@@ -207,6 +209,7 @@ $openclawDir = Join-Path $stagingDir 'openclaw' ...@@ -207,6 +209,7 @@ $openclawDir = Join-Path $stagingDir 'openclaw'
$openclawPackageDir = Join-Path $openclawDir 'package' $openclawPackageDir = Join-Path $openclawDir 'package'
$configDir = Join-Path $stagingDir 'config' $configDir = Join-Path $stagingDir 'config'
$pythonDir = Join-Path $stagingDir 'python' $pythonDir = Join-Path $stagingDir 'python'
$playwrightBrowsersDir = Join-Path $stagingDir 'playwright-browsers'
$manifestPath = Join-Path $stagingDir 'runtime-manifest.json' $manifestPath = Join-Path $stagingDir 'runtime-manifest.json'
$wrapperPath = Join-Path $openclawDir 'index.js' $wrapperPath = Join-Path $openclawDir 'index.js'
$configPath = Join-Path $configDir 'openclaw.json' $configPath = Join-Path $configDir 'openclaw.json'
...@@ -221,7 +224,7 @@ if (Test-Path $stagingDir) { ...@@ -221,7 +224,7 @@ if (Test-Path $stagingDir) {
} }
try { 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 Copy-Item -Path $SourceNodeExe -Destination (Join-Path $nodeDir 'node.exe') -Force
Write-Host "Copying OpenClaw package from $SourceOpenClawDir" Write-Host "Copying OpenClaw package from $SourceOpenClawDir"
...@@ -259,6 +262,13 @@ try { ...@@ -259,6 +262,13 @@ try {
throw 'Failed to install bundled Python dependencies.' 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 Copy-Item -Path $RequirementsPath -Destination $pythonRequirementsCopy -Force
$manifestScript = @" $manifestScript = @"
...@@ -325,6 +335,7 @@ print(json.dumps(payload)) ...@@ -325,6 +335,7 @@ print(json.dumps(payload))
pythonExecutable = 'python/python.exe' pythonExecutable = 'python/python.exe'
pythonManifestPath = 'python/python-manifest.json' pythonManifestPath = 'python/python-manifest.json'
requirementsPath = 'python/runtime-requirements.lock.txt' requirementsPath = 'python/runtime-requirements.lock.txt'
playwrightBrowsersPath = 'playwright-browsers'
gatewayPort = $GatewayPort gatewayPort = $GatewayPort
gatewayToken = $GatewayToken gatewayToken = $GatewayToken
materializedAt = $materializedAt materializedAt = $materializedAt
...@@ -375,6 +386,7 @@ Immutable packaged payload under `vendor/openclaw-runtime/` includes: ...@@ -375,6 +386,7 @@ Immutable packaged payload under `vendor/openclaw-runtime/` includes:
- `python/python.exe` - `python/python.exe`
- `python/python-manifest.json` - `python/python-manifest.json`
- `python/runtime-requirements.lock.txt` - `python/runtime-requirements.lock.txt`
- `playwright-browsers/`
Mutable runtime data lives outside the installer payload and should be created under Electron `userData/runtime/`. 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]$TimeoutSeconds = 180,
[int]$GatewayPort = 18889, [int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token' [string]$GatewayToken = 'qjc-bundled-runtime-token'
...@@ -26,6 +26,12 @@ if ($LASTEXITCODE -ne 0) { ...@@ -26,6 +26,12 @@ if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE 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' 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 powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\cloud-bundle-smoke.ps1') -TimeoutSeconds $TimeoutSeconds -GatewayPort $GatewayPort -GatewayToken $GatewayToken -SkipMaterializeRuntime
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
...@@ -43,3 +49,28 @@ powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\pro ...@@ -43,3 +49,28 @@ powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\pro
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE 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, [int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token', [string]$GatewayToken = 'qjc-bundled-runtime-token',
[string]$SmokeOutput, [string]$SmokeOutput,
...@@ -10,6 +10,27 @@ param( ...@@ -10,6 +10,27 @@ param(
$ErrorActionPreference = 'Stop' $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 $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $SmokeOutput) { if (-not $SmokeOutput) {
$SmokeOutput = Join-Path $repoRoot '.tmp\workspace-entry-smoke\result.json' $SmokeOutput = Join-Path $repoRoot '.tmp\workspace-entry-smoke\result.json'
...@@ -29,17 +50,18 @@ if (-not $SkipMaterializeRuntime) { ...@@ -29,17 +50,18 @@ if (-not $SkipMaterializeRuntime) {
} }
} }
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') ` Invoke-ElectronSmokeWithRetry -ScriptPath (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') -Label 'workspace-entry smoke' -ArgumentList @(
-SmokeOutput $SmokeOutput ` '-SmokeOutput', $SmokeOutput,
-UserDataPath $UserDataPath ` '-UserDataPath', $UserDataPath,
-LogsPath $LogsPath ` '-LogsPath', $LogsPath,
-RuntimeMode 'bundled-runtime' ` '-RuntimeMode', 'bundled-runtime',
-ExpectBundledRuntime ` '-ExpectBundledRuntime',
-PrepareWorkspaceEntryFixture ` '-PrepareWorkspaceEntryFixture',
-ExpectWorkspaceEntry ` '-ExpectWorkspaceEntry',
-WorkspaceProjectId 'workspace-entry-smoke' ` '-WorkspaceProjectId', 'workspace-entry-smoke',
-WorkspaceProjectName 'Workspace Entry Smoke' ` '-WorkspaceProjectName', 'Workspace Entry Smoke',
-SmokePrompt 'Describe the current project root and confirm workspace execution.' ` '-SmokePrompt', 'Describe the current project root and confirm workspace execution.',
-SmokeSkillId '__workspace_entry_disabled__' ` '-SmokeSkillId', '__workspace_entry_disabled__',
-TimeoutSeconds $TimeoutSeconds '-TimeoutSeconds', $TimeoutSeconds
exit $LASTEXITCODE )
exit 0
# Project Bundle Isolation Plan # Project Bundle Isolation Plan
Date: 2026-04-01 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 ## 1. Product constraints
This plan follows the confirmed product constraints: This plan follows the confirmed product constraints:
...@@ -9,6 +9,10 @@ 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`. - The active bundle source is `skills[].skill.download_url`.
- `project_bundle_url` is not part of the current implementation. - `project_bundle_url` is not part of the current implementation.
- Project inventory is fixed by backend configuration. - 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 ## 2. What is already implemented
### Cloud bundle ingestion ### Cloud bundle ingestion
...@@ -63,6 +67,7 @@ This plan follows the confirmed product constraints: ...@@ -63,6 +67,7 @@ This plan follows the confirmed product constraints:
- `project-bundle-reconcile-smoke` - `project-bundle-reconcile-smoke`
- `project-bundle-freshness-smoke` - `project-bundle-freshness-smoke`
- `project-bundle-replacement-smoke` - `project-bundle-replacement-smoke`
- `project-bundle-churn-smoke`
- `project-isolation-smoke` - `project-isolation-smoke`
Additional lifecycle coverage now verified: Additional lifecycle coverage now verified:
...@@ -70,18 +75,22 @@ Additional lifecycle coverage now verified: ...@@ -70,18 +75,22 @@ Additional lifecycle coverage now verified:
- cloud bundle smoke covers cached `init` - cloud bundle smoke covers cached `init`
- cloud bundle smoke covers same-`projectId` replacement through Electron UI/main flow - 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 - 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 ## 3. What is no longer pending
The previously pending items are now implemented: The previously pending items are now implemented:
- full cloud-owned project inventory enforcement - full cloud-owned project inventory enforcement
- strong rollback guarantees for partially failed bundle replacement - 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 - end-to-end lifecycle smoke for replacement scenarios
## 4. Remaining follow-up work ## 4. Remaining follow-up work
The main work left is follow-up hardening rather than missing core behavior: 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 `project-isolation-smoke.ps1` and `cloud-bundle-smoke.ps1` should be wired into broader external CI or release gates
- decide whether replacement lifecycle smoke should also be aggregated into higher-level 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 - 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 ## 5. File-by-file status
### `apps/desktop/src/main/services/project-bundle.ts` ### `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: ...@@ -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` ### `apps/desktop/src/main/services/project-store.ts`
- Owns active-project state without recreating a local fallback project when inventory is empty. - 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` ### `apps/desktop/src/main/ipc.ts`
- Keeps send and stream paths aligned on post-execution refresh behavior. - Keeps send and stream paths aligned on post-execution refresh behavior.
- Preserves background refresh semantics without adding reply latency. - Preserves background refresh semantics without adding reply latency.
### `build/scripts/*.ps1` ### `build/scripts/*.ps1`
- Current smokes cover empty inventory, bundle reconcile, freshness, replacement rollback, and Electron lifecycle replacement. - Current smokes cover empty inventory, bundle reconcile, freshness, replacement rollback, multi-project churn, and Electron lifecycle replacement.
- `cloud-bundle-smoke.ps1` is now the main Electron lifecycle regression for bundle 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 ## 6. Practical status summary
Current phase: Current phase:
...@@ -109,9 +119,11 @@ Current phase: ...@@ -109,9 +119,11 @@ Current phase:
- stale bundle cleanup works - stale bundle cleanup works
- bundle freshness hardening works - bundle freshness hardening works
- replacement / rollback hardening works - replacement / rollback hardening works
- multi-project churn coverage exists at the service level
- lifecycle smoke for replacement is in place - lifecycle smoke for replacement is in place
Recommended immediate focus: Recommended immediate focus:
- keep the current smoke set green - 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 - 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 # Project Isolation Execution Design
Date: 2026-04-01 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 ## 1. Scope and product constraints
This document describes the current desktop implementation for project-isolated execution. This document describes the current desktop implementation for project-isolated execution.
...@@ -10,6 +10,10 @@ Confirmed product constraints: ...@@ -10,6 +10,10 @@ Confirmed product constraints:
- The active bundle source is `skills[].skill.download_url`. - The active bundle source is `skills[].skill.download_url`.
- `project_bundle_url` is not part of the current implementation. - `project_bundle_url` is not part of the current implementation.
- Local project CRUD is not part of the target product shape. - 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 ## 2. Implemented architecture
The implemented chain is: The implemented chain is:
...@@ -28,6 +32,7 @@ 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. 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. 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. 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 ## 3. Code-truth execution behavior
Current behavior verified from code: Current behavior verified from code:
...@@ -97,8 +102,15 @@ Current smoke coverage in the repo: ...@@ -97,8 +102,15 @@ Current smoke coverage in the repo:
- validates same-project replacement at the service layer - validates same-project replacement at the service layer
- validates rollback after injected post-commit failure - validates rollback after injected post-commit failure
- validates successful recovery on the next sync - 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` - `project-isolation-smoke.ps1`
- aggregates the main project-isolation regression smokes - aggregates the main project-isolation regression smokes
- now includes reconcile, freshness, replacement, and churn service-level coverage
Electron smoke validation is also stronger now: Electron smoke validation is also stronger now:
- workspace-entry validation no longer assumes the final status label must contain `workspace` - workspace-entry validation no longer assumes the final status label must contain `workspace`
...@@ -108,14 +120,16 @@ Electron smoke validation is also stronger now: ...@@ -108,14 +120,16 @@ Electron smoke validation is also stronger now:
## 5. What is still incomplete ## 5. What is still incomplete
The earlier core gaps are closed. The earlier core gaps are closed.
The main follow-up areas now are: The main follow-up areas now are:
- broader multi-project churn coverage beyond the current targeted replacement path - deciding whether the `project-isolation-smoke.ps1` and `cloud-bundle-smoke.ps1` gate pair should be wired into broader external CI / release coverage
- deciding whether the new replacement lifecycle smoke should be promoted into broader CI / release gating - 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 - 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 ## 6. Recommended next implementation order
1. Keep the current project-isolation smoke set green. 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. 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 multi-project behaviors are introduced. 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 ## 7. Bottom line
Current status: Current status:
...@@ -128,4 +142,5 @@ Current status: ...@@ -128,4 +142,5 @@ Current status:
- Stale bundle reconciliation is implemented for bundle-managed cleanup. - Stale bundle reconciliation is implemented for bundle-managed cleanup.
- Bundle freshness hardening is implemented and smoke-verified. - Bundle freshness hardening is implemented and smoke-verified.
- Replacement / rollback 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. - Multi-project churn coverage is implemented at the service level.
\ No newline at end of file - Remaining work is mostly broader regression breadth, gate wiring, and future protocol evolution.
# Single Instance + Task Isolation # Single Instance + Task Isolation
Date: 2026-04-01 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 ## 1. Intent
The goal is a single desktop app instance with isolated project execution. The goal is a single desktop app instance with isolated project execution.
...@@ -47,6 +47,13 @@ All three routes are project-aware. ...@@ -47,6 +47,13 @@ All three routes are project-aware.
- Same-`projectId` bundle replacement now uses explicit stage/commit/finalize-or-rollback handling. - 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. - 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 ## 3. Current default chat behavior
Default chat is no longer a special weak path. Default chat is no longer a special weak path.
...@@ -69,20 +76,23 @@ Current repo smoke coverage includes: ...@@ -69,20 +76,23 @@ Current repo smoke coverage includes:
- `project-bundle-reconcile-smoke.ps1` - `project-bundle-reconcile-smoke.ps1`
- `project-bundle-freshness-smoke.ps1` - `project-bundle-freshness-smoke.ps1`
- `project-bundle-replacement-smoke.ps1` - `project-bundle-replacement-smoke.ps1`
- `project-bundle-churn-smoke.ps1`
- `project-isolation-smoke.ps1` - `project-isolation-smoke.ps1`
Additional verified points: Additional verified points:
- cloud bundle smoke passes with freshness probing enabled - cloud bundle smoke passes with freshness probing enabled
- cloud bundle smoke now validates same-`projectId` replacement through the Electron UI/main chain - 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 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 - Electron smoke validates workspace-agent status history instead of relying on the final status label only
## 5. Important current limitations ## 5. Important current limitations
### Broad UI regression breadth is still selective ### 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. 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 ### Stress breadth 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. 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 ## 6. What has been completed so far
Completed enough to count as real implementation: Completed enough to count as real implementation:
...@@ -95,17 +105,19 @@ 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 - stale bundle-managed cleanup for project/skill/cron/manifest state
- bundle freshness hardening using remote metadata probe - bundle freshness hardening using remote metadata probe
- replacement / rollback hardening for same-project bundle updates - 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. This project is no longer at the design-only stage.
## 7. What should happen next ## 7. What should happen next
Recommended next work: Recommended next work:
1. Keep the current smoke set green as adjacent runtime work lands. 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. 2. Use `project-isolation-smoke.ps1` plus `cloud-bundle-smoke.ps1` as the main higher-level regression gate pair.
3. Expand coverage only when upcoming product changes introduce new isolation surfaces. 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 ## 8. Final summary
The single-instance plus task-isolation foundation is implemented. 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 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. The remaining work is mostly broader regression breadth and future protocol evolution, not missing core behavior.
\ No newline at end of file
...@@ -17,6 +17,8 @@ ...@@ -17,6 +17,8 @@
"smoke:workspace-entry": "powershell -ExecutionPolicy Bypass -File build/scripts/workspace-entry-smoke.ps1", "smoke:workspace-entry": "powershell -ExecutionPolicy Bypass -File build/scripts/workspace-entry-smoke.ps1",
"smoke:cloud-bundle": "powershell -ExecutionPolicy Bypass -File build/scripts/cloud-bundle-smoke.ps1", "smoke: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: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: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: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", "smoke:project-context-refresh": "powershell -ExecutionPolicy Bypass -File build/scripts/project-context-refresh-smoke.ps1",
...@@ -24,6 +26,7 @@ ...@@ -24,6 +26,7 @@
"smoke:bundle-reconcile": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-reconcile-smoke.ps1", "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-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-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" "smoke:installer:bundled-runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/installer-smoke.ps1 -RuntimeMode bundled-runtime -ExpectBundledRuntime"
}, },
"pnpm": { "pnpm": {
...@@ -32,3 +35,5 @@ ...@@ -32,3 +35,5 @@
] ]
} }
} }
...@@ -302,6 +302,28 @@ export interface SessionSummary { ...@@ -302,6 +302,28 @@ export interface SessionSummary {
updatedAt: string; 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 { export interface ProjectSummary {
id: string; id: string;
name: string; name: string;
...@@ -310,6 +332,10 @@ export interface ProjectSummary { ...@@ -310,6 +332,10 @@ export interface ProjectSummary {
updatedAt: string; updatedAt: string;
skillCount: number; skillCount: number;
ready: boolean; ready: boolean;
projectType?: string;
platform?: string;
defaultEntryId?: string;
defaultEntryType?: ProjectPackageEntryType;
} }
export interface ProjectSessionSummary extends SessionSummary { export interface ProjectSessionSummary extends SessionSummary {
...@@ -356,6 +382,7 @@ export interface ProjectExecutionRequest { ...@@ -356,6 +382,7 @@ export interface ProjectExecutionRequest {
userPrompt: string; userPrompt: string;
context: ProjectContextSnapshot; context: ProjectContextSnapshot;
selectedSkillId: string | null; selectedSkillId: string | null;
projectConfig?: ProjectPackageConfig | null;
} }
export type ProjectExecutionDecision = 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