Commit 4c113250 authored by AI-甘富林's avatar AI-甘富林

Implement project bundle isolation flow

parent c484f174
This diff is collapsed.
This diff is collapsed.
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
interface RunnerInput {
vendorPackageDir: string;
projectRoot: string;
sessionId: string;
prompt: string;
runId?: string;
}
interface AgentCommandModule {
t?: (options: Record<string, unknown>, runtime?: Record<string, unknown>) => Promise<unknown>;
}
interface InstrumentedModelSelectionModule {
__qjcOnAgentEvent?: (listener: (event: InstrumentedAgentEvent) => void) => (() => boolean) | (() => void);
}
interface InstrumentedAgentEvent {
runId?: string;
stream?: string;
data?: {
text?: unknown;
delta?: unknown;
};
}
const EVENT_PREFIX = "QJC_WORKSPACE_EVENT\t";
function emit(payload: Record<string, unknown>): void {
process.stdout.write(`${EVENT_PREFIX}${JSON.stringify(payload)}\n`);
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
async function resolveAgentModulePath(vendorPackageDir: string): Promise<string> {
const distDir = path.join(vendorPackageDir, "dist");
const entries = await readdir(distDir);
const agentModuleFile = [...entries]
.filter((entry) => /^agent-[A-Za-z0-9_-]+\.js$/.test(entry))
.sort()[0];
if (!agentModuleFile) {
throw new Error(`Unable to locate bundled OpenClaw agent module under ${distDir}.`);
}
return path.join(distDir, agentModuleFile);
}
function buildInstrumentationKey(agentModulePath: string, modelSelectionSpecifier: string): string {
return createHash("sha1")
.update(agentModulePath)
.update("\n")
.update(modelSelectionSpecifier)
.digest("hex")
.slice(0, 12);
}
async function ensureInstrumentedWorkspaceModules(agentModulePath: string): Promise<{
agentModuleUrl: string;
modelSelectionModuleUrl: string;
}> {
const agentSource = await readFile(agentModulePath, "utf8");
const modelSelectionImportMatch = agentSource.match(/from\s+["'](\.\/model-selection-[^"']+\.js)["']/);
if (!modelSelectionImportMatch) {
throw new Error(`Unable to locate model-selection import in ${agentModulePath}.`);
}
const modelSelectionSpecifier = modelSelectionImportMatch[1];
const distDir = path.dirname(agentModulePath);
const modelSelectionPath = path.resolve(distDir, modelSelectionSpecifier);
const instrumentationKey = buildInstrumentationKey(agentModulePath, modelSelectionSpecifier);
const instrumentedAgentFileName = `.qjc-agent-${instrumentationKey}.js`;
const instrumentedModelSelectionFileName = `.qjc-model-selection-${instrumentationKey}.js`;
const instrumentedAgentPath = path.join(distDir, instrumentedAgentFileName);
const instrumentedModelSelectionPath = path.join(distDir, instrumentedModelSelectionFileName);
await mkdir(distDir, { recursive: true });
const modelSelectionSource = await readFile(modelSelectionPath, "utf8");
const instrumentedModelSelectionSource = `${modelSelectionSource}\nexport { onAgentEvent as __qjcOnAgentEvent };\n`;
await writeFile(instrumentedModelSelectionPath, instrumentedModelSelectionSource, "utf8");
const instrumentedAgentSource = agentSource.replace(
modelSelectionSpecifier,
`./${instrumentedModelSelectionFileName}`
);
await writeFile(instrumentedAgentPath, instrumentedAgentSource, "utf8");
return {
agentModuleUrl: pathToFileURL(instrumentedAgentPath).href,
modelSelectionModuleUrl: pathToFileURL(instrumentedModelSelectionPath).href
};
}
function extractReplyText(result: unknown): string {
const payloads = Array.isArray((result as { payloads?: unknown[] } | null)?.payloads)
? ((result as { payloads: unknown[] }).payloads)
: [];
const parts: string[] = [];
for (const payload of payloads) {
if (typeof payload === "string") {
parts.push(payload);
continue;
}
if (!payload || typeof payload !== "object") {
continue;
}
const typed = payload as {
text?: unknown;
markdown?: unknown;
content?: unknown;
caption?: unknown;
};
for (const candidate of [typed.text, typed.markdown, typed.content, typed.caption]) {
if (typeof candidate === "string" && candidate.trim()) {
parts.push(candidate);
break;
}
}
}
return parts.join("\n\n").trim();
}
function createRuntimeSessionIdentity(sessionId: string): { sessionId: string; sessionKey: string } {
const digest = createHash("sha1").update(sessionId).digest("hex");
const runtimeSessionId = `desktop-${digest.slice(0, 32)}`;
return {
sessionId: runtimeSessionId,
sessionKey: `${runtimeSessionId}-key`
};
}
async function main(): Promise<void> {
const inputRaw = await readStdin();
const input = JSON.parse(inputRaw) as RunnerInput;
const runId = input.runId?.trim() || randomUUID();
process.env.OPENCLAW_HIDE_BANNER = "1";
process.env.OPENCLAW_SUPPRESS_NOTES = "1";
process.env.NODE_NO_WARNINGS = process.env.NODE_NO_WARNINGS || "1";
process.chdir(input.projectRoot);
emit({
type: "started",
runId
});
emit({
type: "status",
runId,
stage: "workspace-agent",
label: "Running project workspace agent"
});
const agentModulePath = await resolveAgentModulePath(input.vendorPackageDir);
const instrumentedModules = await ensureInstrumentedWorkspaceModules(agentModulePath);
const agentModule = await import(instrumentedModules.agentModuleUrl) as AgentCommandModule;
const modelSelectionModule = await import(instrumentedModules.modelSelectionModuleUrl) as InstrumentedModelSelectionModule;
if (typeof agentModule.t !== "function") {
throw new Error("Bundled OpenClaw agent module does not expose agentCommand.");
}
let streamedText = "";
const unsubscribe = typeof modelSelectionModule.__qjcOnAgentEvent === "function"
? modelSelectionModule.__qjcOnAgentEvent((event) => {
if (event.runId !== runId || event.stream !== "assistant") {
return;
}
const fullText = typeof event.data?.text === "string" ? event.data.text : "";
const deltaFromEvent = typeof event.data?.delta === "string" ? event.data.delta : "";
const nextFullText = fullText && fullText.length >= streamedText.length
? fullText
: deltaFromEvent
? streamedText + deltaFromEvent
: streamedText;
const textDelta = nextFullText.startsWith(streamedText)
? nextFullText.slice(streamedText.length)
: deltaFromEvent || nextFullText;
if (!textDelta) {
return;
}
streamedText = nextFullText;
emit({
type: "delta",
runId,
textDelta,
fullText: nextFullText
});
})
: undefined;
const silentRuntime = {
log: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
debug: () => undefined
};
const runtimeSession = createRuntimeSessionIdentity(input.sessionId);
try {
const result = await agentModule.t({
message: input.prompt,
sessionId: runtimeSession.sessionId,
sessionKey: runtimeSession.sessionKey,
workspaceDir: input.projectRoot,
runId
}, silentRuntime);
emit({
type: "completed",
runId,
content: extractReplyText(result),
result
});
} finally {
unsubscribe?.();
}
}
main().catch((error) => {
emit({
type: "error",
message: toErrorMessage(error)
});
process.exitCode = 1;
});
...@@ -599,9 +599,11 @@ export class OpenClawConfigClient { ...@@ -599,9 +599,11 @@ export class OpenClawConfigClient {
const payload = action === "init" && this.payloadCache const payload = action === "init" && this.payloadCache
? this.payloadCache ? this.payloadCache
: await this.fetchPayload(action); : await this.fetchPayload(action);
return this.mergeConfig(defaultConfig, payload); const effectivePayload = action === "sync" && payload.changed === false && this.payloadCache
? this.payloadCache
: payload;
return this.mergeConfig(defaultConfig, effectivePayload);
} }
getRemoteSkillAssets(): RemoteSkillAsset[] { getRemoteSkillAssets(): RemoteSkillAsset[] {
return toRemoteSkillAssets(this.payloadCache); return toRemoteSkillAssets(this.payloadCache);
} }
...@@ -994,3 +996,4 @@ export class ModelConfigClient { ...@@ -994,3 +996,4 @@ export class ModelConfigClient {
This diff is collapsed.
import type { ProjectExecutionDecision } from "@qjclaw/shared-types";
import type { ProjectContextService } from "./project-context.js";
import type { ProjectStoreService } from "./project-store.js";
interface ProjectContextRefreshOptions {
sessionId: string;
projectId: string;
projectContextService: ProjectContextService;
projectStore: ProjectStoreService;
}
export function shouldRefreshProjectContextAfterExecution(decision: ProjectExecutionDecision): boolean {
switch (decision.kind) {
case "chat-fallback":
case "skill":
case "workspace-entry":
return true;
default: {
const exhaustiveCheck: never = decision;
throw new Error(`Unhandled project execution decision: ${JSON.stringify(exhaustiveCheck)}`);
}
}
}
export async function refreshProjectContextAfterExecution({
sessionId,
projectId,
projectContextService,
projectStore
}: ProjectContextRefreshOptions): Promise<void> {
projectContextService.invalidateSnapshot(projectId);
try {
const snapshot = await projectContextService.refreshSnapshot(projectId);
const latestSessionState = await projectStore.getSessionState(sessionId);
if (latestSessionState.contextSnapshotId !== snapshot.snapshotId) {
await projectStore.bindSessionContextSnapshot(sessionId, snapshot.snapshotId);
}
} catch {
projectContextService.invalidateSnapshot(projectId);
}
}
import { createHash } from "node:crypto";
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import type { ProjectContextSnapshot, ProjectSummary, WorkspaceSkillSummary } from "@qjclaw/shared-types";
import type { ProjectStoreService } from "./project-store.js";
const MAX_MEMORY_FILES = 8;
const MAX_MEMORY_SNIPPET_LENGTH = 600;
const MAX_MEMORY_SUMMARY_LENGTH = 3200;
const MAX_TRACKED_MEMORY_FILES = 64;
const TEXT_FILE_PATTERN = /\.(md|txt|json|ya?ml)$/i;
const CONTEXT_ROOT_FILES = ["project.json", "SOUL.md", "USER.md", "README.md"] as const;
interface ProjectContextCacheEntry {
snapshot: ProjectContextSnapshot;
stateHash: string;
}
interface TrackedFileState {
relativePath: string;
size: number;
mtimeMs: number;
}
interface ProjectContextBuildState {
project: ProjectSummary;
projectRoot: string;
skills: WorkspaceSkillSummary[];
stateHash: string;
}
function normalizeText(content: string): string {
return content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").trim();
}
function truncateText(content: string, limit: number): string {
if (content.length <= limit) {
return content;
}
return `${content.slice(0, limit).trimEnd()}...`;
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}
export class ProjectContextService {
private readonly projectStore: ProjectStoreService;
private readonly snapshotCache = new Map<string, ProjectContextCacheEntry>();
private readonly dirtyProjects = new Set<string>();
constructor(projectStore: ProjectStoreService) {
this.projectStore = projectStore;
}
async getSnapshot(projectId: string): Promise<ProjectContextSnapshot> {
const buildState = await this.loadBuildState(projectId);
const cached = this.snapshotCache.get(projectId);
if (cached && cached.stateHash === buildState.stateHash && !this.dirtyProjects.has(projectId)) {
return cached.snapshot;
}
const nextSnapshot = await this.buildSnapshot(buildState);
this.snapshotCache.set(projectId, {
snapshot: nextSnapshot,
stateHash: buildState.stateHash
});
this.dirtyProjects.delete(projectId);
return nextSnapshot;
}
async refreshSnapshot(projectId: string): Promise<ProjectContextSnapshot> {
const buildState = await this.loadBuildState(projectId);
const nextSnapshot = await this.buildSnapshot(buildState);
this.snapshotCache.set(projectId, {
snapshot: nextSnapshot,
stateHash: buildState.stateHash
});
this.dirtyProjects.delete(projectId);
return nextSnapshot;
}
invalidateSnapshot(projectId: string): void {
this.dirtyProjects.add(projectId);
}
async buildSystemContext(projectId: string): Promise<string> {
const snapshot = await this.getSnapshot(projectId);
return this.renderSystemContext(snapshot);
}
private async loadBuildState(projectId: string): Promise<ProjectContextBuildState> {
const [project, projectRoot, skills] = await Promise.all([
this.projectStore.getProjectSummary(projectId),
this.projectStore.getProjectRoot(projectId),
this.projectStore.listProjectSkills(projectId)
]);
const stateHash = await this.computeStateHash(project, projectRoot, skills);
return {
project,
projectRoot,
skills,
stateHash
};
}
private async computeStateHash(
project: ProjectSummary,
projectRoot: string,
skills: WorkspaceSkillSummary[]
): Promise<string> {
const trackedRootStates = await Promise.all(
CONTEXT_ROOT_FILES.map((fileName) => this.readTrackedFileState(projectRoot, path.join(projectRoot, fileName)))
);
const memoryStates = await this.collectTrackedFileStates(path.join(projectRoot, "memory"));
const statePayload = JSON.stringify({
project: {
id: project.id,
name: project.name,
description: project.description ?? null,
version: project.version ?? null,
ready: project.ready,
updatedAt: project.updatedAt
},
skills: skills.map((skill) => ({
id: skill.id,
name: skill.name,
description: skill.description ?? null,
ready: skill.ready,
updatedAt: skill.lastSyncedAt ?? null
})),
files: trackedRootStates.filter((state): state is TrackedFileState => state !== null),
memoryFiles: memoryStates
});
return createHash("sha1").update(statePayload).digest("hex");
}
private async buildSnapshot(buildState: ProjectContextBuildState): Promise<ProjectContextSnapshot> {
const [soul, user, readme, memorySummary] = await Promise.all([
this.readOptionalTextFile(path.join(buildState.projectRoot, "SOUL.md")),
this.readOptionalTextFile(path.join(buildState.projectRoot, "USER.md")),
this.readOptionalTextFile(path.join(buildState.projectRoot, "README.md")),
this.buildMemorySummary(path.join(buildState.projectRoot, "memory"))
]);
const boundSkills = buildState.skills.map((skill) => ({
id: skill.id,
name: skill.name,
description: skill.description
}));
return {
projectId: buildState.project.id,
projectName: buildState.project.name,
projectRoot: buildState.projectRoot,
snapshotId: buildState.stateHash,
generatedAt: new Date().toISOString(),
soul,
user,
readme,
memorySummary,
boundSkills
};
}
private renderSystemContext(snapshot: ProjectContextSnapshot): string {
const sections: string[] = [
"You are operating inside a desktop project-isolated workspace.",
`Current project: ${snapshot.projectName} (${snapshot.projectId})`,
`Project root: ${snapshot.projectRoot}`
];
if (snapshot.boundSkills.length > 0) {
sections.push([
"Available project skills:",
...snapshot.boundSkills.map((skill) => `- ${skill.name} (${skill.id})${skill.description ? `: ${skill.description}` : ""}`)
].join("\n"));
}
if (snapshot.soul) {
sections.push(["[SOUL.md]", snapshot.soul].join("\n"));
}
if (snapshot.user) {
sections.push(["[USER.md]", snapshot.user].join("\n"));
}
if (snapshot.memorySummary) {
sections.push(["[memory summary]", snapshot.memorySummary].join("\n"));
}
if (snapshot.readme) {
sections.push(["[README.md]", snapshot.readme].join("\n"));
}
sections.push("Keep project context isolated to this project and do not mix state with other projects or sessions.");
return sections.join("\n\n");
}
private async readOptionalTextFile(filePath: string): Promise<string | null> {
if (!(await pathExists(filePath))) {
return null;
}
const raw = await readFile(filePath, "utf8");
const normalized = normalizeText(raw);
return normalized || null;
}
private async buildMemorySummary(memoryRoot: string): Promise<string | null> {
if (!(await pathExists(memoryRoot))) {
return null;
}
const snippets = await this.collectMemorySnippets(memoryRoot);
if (snippets.length === 0) {
return null;
}
const summary = snippets.join("\n\n");
return truncateText(summary, MAX_MEMORY_SUMMARY_LENGTH);
}
private async collectMemorySnippets(rootDir: string, currentDir = rootDir, bucket: string[] = []): Promise<string[]> {
if (bucket.length >= MAX_MEMORY_FILES) {
return bucket;
}
const entries = await readdir(currentDir, { withFileTypes: true }).catch(() => []);
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, "en"))) {
if (bucket.length >= MAX_MEMORY_FILES) {
break;
}
const targetPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await this.collectMemorySnippets(rootDir, targetPath, bucket);
continue;
}
if (!entry.isFile() || !TEXT_FILE_PATTERN.test(entry.name)) {
continue;
}
const raw = await readFile(targetPath, "utf8").catch(() => "");
const normalized = normalizeText(raw);
if (!normalized) {
continue;
}
const relativePath = path.relative(rootDir, targetPath).replace(/\\/g, "/");
bucket.push(`${relativePath}:\n${truncateText(normalized, MAX_MEMORY_SNIPPET_LENGTH)}`);
}
return bucket;
}
private async collectTrackedFileStates(rootDir: string, currentDir = rootDir, bucket: TrackedFileState[] = []): Promise<TrackedFileState[]> {
if (!(await pathExists(currentDir)) || bucket.length >= MAX_TRACKED_MEMORY_FILES) {
return bucket;
}
const entries = await readdir(currentDir, { withFileTypes: true }).catch(() => []);
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, "en"))) {
if (bucket.length >= MAX_TRACKED_MEMORY_FILES) {
break;
}
const targetPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await this.collectTrackedFileStates(rootDir, targetPath, bucket);
continue;
}
if (!entry.isFile() || !TEXT_FILE_PATTERN.test(entry.name)) {
continue;
}
const tracked = await this.readTrackedFileState(rootDir, targetPath);
if (tracked) {
bucket.push(tracked);
}
}
return bucket;
}
private async readTrackedFileState(rootDir: string, filePath: string): Promise<TrackedFileState | null> {
try {
const fileStat = await stat(filePath);
if (!fileStat.isFile()) {
return null;
}
return {
relativePath: path.relative(rootDir, filePath).replace(/\\/g, "/"),
size: fileStat.size,
mtimeMs: fileStat.mtimeMs
};
} catch {
return null;
}
}
}
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import type {
ProjectContextSnapshot,
ProjectExecutionDecision,
ProjectExecutionRequest
} from "@qjclaw/shared-types";
const WORKSPACE_ENTRY_MARKERS = ["AGENT", "AGENT.md", "AGENTS.md"];
async function pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}
function renderSystemContext(snapshot: ProjectContextSnapshot): string {
const sections: string[] = [
"You are operating inside a desktop project-isolated workspace.",
`Current project: ${snapshot.projectName} (${snapshot.projectId})`,
`Project root: ${snapshot.projectRoot}`
];
if (snapshot.boundSkills.length > 0) {
sections.push([
"Available project skills:",
...snapshot.boundSkills.map((skill) => `- ${skill.name} (${skill.id})${skill.description ? `: ${skill.description}` : ""}`)
].join("\n"));
}
if (snapshot.soul) {
sections.push(["[SOUL.md]", snapshot.soul].join("\n"));
}
if (snapshot.user) {
sections.push(["[USER.md]", snapshot.user].join("\n"));
}
if (snapshot.memorySummary) {
sections.push(["[memory summary]", snapshot.memorySummary].join("\n"));
}
if (snapshot.readme) {
sections.push(["[README.md]", snapshot.readme].join("\n"));
}
sections.push("Keep project context isolated to this project and session.");
return sections.join("\n\n");
}
function buildPreparedPrompt(snapshot: ProjectContextSnapshot, userPrompt: string): string {
return [
renderSystemContext(snapshot),
"User request:",
userPrompt
].join("\n\n");
}
export class ProjectExecutionRouter {
async decide(request: ProjectExecutionRequest): Promise<ProjectExecutionDecision> {
const preparedPrompt = buildPreparedPrompt(request.context, request.userPrompt);
if (request.selectedSkillId) {
return {
kind: "skill",
skillId: request.selectedSkillId,
preparedPrompt
};
}
const workspaceEntryReason = await this.detectWorkspaceEntry(request.projectRoot);
if (workspaceEntryReason) {
return {
kind: "workspace-entry",
projectRoot: request.projectRoot,
preparedPrompt,
reason: workspaceEntryReason
};
}
return {
kind: "chat-fallback",
preparedPrompt
};
}
private async detectWorkspaceEntry(projectRoot: string): Promise<string | null> {
const projectJsonPath = path.join(projectRoot, "project.json");
try {
const raw = await readFile(projectJsonPath, "utf8");
const projectConfig = JSON.parse(raw) as Record<string, unknown>;
const declaredEntry = ["workspaceEntry", "executionEntry", "workspace_entry"]
.map((key) => projectConfig[key])
.find((value) => typeof value === "string" && value.trim().length > 0);
if (typeof declaredEntry === "string") {
return `project.json declares workspace entry ${declaredEntry.trim()}`;
}
const enabledFlag = ["workspaceEntryEnabled", "executionEntryEnabled"]
.map((key) => projectConfig[key])
.find((value) => typeof value === "boolean");
if (enabledFlag === true) {
return "project.json enables workspace entry";
}
} catch {
// Ignore project.json parse failures here and continue with marker files.
}
for (const marker of WORKSPACE_ENTRY_MARKERS) {
if (await pathExists(path.join(projectRoot, marker))) {
return `workspace marker ${marker} detected`;
}
}
return null;
}
}
\ No newline at end of file
import type {
ProjectSessionSummary,
ProjectSummary,
WorkspaceSkillSummary
} from "@qjclaw/shared-types";
import type { ProjectStoreService } from "./project-store.js";
export const EMPTY_PROJECT_INVENTORY_MESSAGE = "Waiting for cloud project bundle sync.";
export interface ActiveProjectWorkspaceState {
projects: ProjectSummary[];
currentProject: ProjectSummary | null;
sessions: ProjectSessionSummary[];
skills: WorkspaceSkillSummary[];
}
export async function getActiveProjectOrNull(projectStore: ProjectStoreService): Promise<ProjectSummary | null> {
const projects = await projectStore.listProjects();
if (projects.length === 0) {
return null;
}
return projectStore.getActiveProject();
}
export async function requireActiveProject(projectStore: ProjectStoreService): Promise<ProjectSummary> {
const project = await getActiveProjectOrNull(projectStore);
if (!project) {
throw new Error(EMPTY_PROJECT_INVENTORY_MESSAGE);
}
return project;
}
export async function loadActiveProjectWorkspaceState(projectStore: ProjectStoreService): Promise<ActiveProjectWorkspaceState> {
const projects = await projectStore.listProjects();
if (projects.length === 0) {
return {
projects,
currentProject: null,
sessions: [],
skills: []
};
}
const currentProject = await projectStore.getActiveProject();
const [sessions, skills] = await Promise.all([
projectStore.listSessions(currentProject.id),
projectStore.listProjectSkills(currentProject.id)
]);
return {
projects,
currentProject,
sessions,
skills
};
}
export async function listSessionsForActiveProject(projectStore: ProjectStoreService): Promise<ProjectSessionSummary[]> {
const project = await getActiveProjectOrNull(projectStore);
if (!project) {
return [];
}
return projectStore.listSessions(project.id);
}
export async function createSessionForActiveProject(
projectStore: ProjectStoreService,
title?: string
): Promise<ProjectSessionSummary> {
const project = await requireActiveProject(projectStore);
return projectStore.createSession(title, project.id);
}
This diff is collapsed.
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { stat } from "node:fs/promises";
import path from "node:path";
import type { RuntimeManager } from "@qjclaw/runtime-manager";
import type { ChatMessage } from "@qjclaw/shared-types";
interface ProjectWorkspaceExecutionInput {
sessionId: string;
projectRoot: string;
prompt: string;
runId?: string;
}
interface ProjectWorkspaceExecutionCallbacks {
onStarted?: (runId: string) => void;
onStatus?: (stage: string, label: string, detail?: string) => void;
onDelta?: (textDelta: string, fullText: string | undefined, runId: string) => void;
}
interface RunnerEvent {
type?: string;
runId?: string;
stage?: string;
label?: string;
detail?: string;
message?: string;
content?: string;
textDelta?: string;
fullText?: string;
result?: unknown;
}
const EVENT_PREFIX = "QJC_WORKSPACE_EVENT\t";
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}
function parseRunnerEvent(line: string): RunnerEvent | null {
if (!line.startsWith(EVENT_PREFIX)) {
return null;
}
try {
return JSON.parse(line.slice(EVENT_PREFIX.length)) as RunnerEvent;
} catch {
return null;
}
}
function extractReplyText(result: unknown): string {
const payloads = Array.isArray((result as { payloads?: unknown[] } | null)?.payloads)
? ((result as { payloads: unknown[] }).payloads)
: [];
const parts: string[] = [];
for (const payload of payloads) {
if (typeof payload === "string") {
parts.push(payload);
continue;
}
if (!payload || typeof payload !== "object") {
continue;
}
const typed = payload as {
text?: unknown;
markdown?: unknown;
content?: unknown;
caption?: unknown;
};
for (const candidate of [typed.text, typed.markdown, typed.content, typed.caption]) {
if (typeof candidate === "string" && candidate.trim()) {
parts.push(candidate);
break;
}
}
}
return parts.join("\n\n").trim();
}
export class ProjectWorkspaceExecutorService {
private readonly runtimeManager: RuntimeManager;
constructor(runtimeManager: RuntimeManager) {
this.runtimeManager = runtimeManager;
}
async execute(
input: ProjectWorkspaceExecutionInput,
callbacks: ProjectWorkspaceExecutionCallbacks = {}
): Promise<{ runId: string; reply: ChatMessage }> {
const runtimeStatus = await this.runtimeManager.status();
if (runtimeStatus.payloadState !== "ready") {
throw new Error("Bundled runtime payload is not ready for project workspace execution.");
}
await this.runtimeManager.syncManagedConfig("sync");
const paths = this.runtimeManager.resolveBundledPaths();
const runnerScriptPath = path.resolve(__dirname, "./project-workspace-agent-runner.js");
if (!(await pathExists(runnerScriptPath))) {
throw new Error(`Workspace runner script is missing: ${runnerScriptPath}`);
}
const vendorPackageDir = path.join(paths.runtimeDir, "openclaw", "package");
const runId = input.runId?.trim() || randomUUID();
callbacks.onStatus?.("launch-workspace", "Launching project workspace agent");
return await new Promise<{ runId: string; reply: ChatMessage }>((resolve, reject) => {
let settled = false;
let stderr = "";
let stdoutBuffer = "";
let activeRunId = runId;
const child = spawn(paths.nodeExecutable, [runnerScriptPath], {
cwd: input.projectRoot,
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
OPENCLAW_HOME: paths.runtimeDataDir,
OPENCLAW_STATE_DIR: paths.runtimeStateDir,
OPENCLAW_CONFIG_PATH: paths.managedConfigPath,
OPENCLAW_HIDE_BANNER: "1",
OPENCLAW_SUPPRESS_NOTES: "1",
NODE_NO_WARNINGS: process.env.NODE_NO_WARNINGS || "1"
}
});
const finishWithError = (message: string) => {
if (settled) {
return;
}
settled = true;
reject(new Error(message));
};
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdoutBuffer += chunk;
const lines = stdoutBuffer.split(/\r?\n/);
stdoutBuffer = lines.pop() ?? "";
for (const line of lines) {
const event = parseRunnerEvent(line);
if (!event) {
continue;
}
if (event.type === "started" && typeof event.runId === "string" && event.runId) {
activeRunId = event.runId;
callbacks.onStarted?.(activeRunId);
continue;
}
if (event.type === "status" && typeof event.stage === "string" && typeof event.label === "string") {
callbacks.onStatus?.(event.stage, event.label, typeof event.detail === "string" ? event.detail : undefined);
continue;
}
if (event.type === "delta" && typeof event.textDelta === "string" && event.textDelta) {
callbacks.onDelta?.(
event.textDelta,
typeof event.fullText === "string" ? event.fullText : undefined,
event.runId ?? activeRunId
);
continue;
}
if (event.type === "completed") {
if (settled) {
return;
}
settled = true;
const content = typeof event.content === "string" && event.content.trim()
? event.content
: extractReplyText(event.result);
resolve({
runId: activeRunId,
reply: {
id: randomUUID(),
role: "assistant",
content,
createdAt: new Date().toISOString()
}
});
return;
}
if (event.type === "error") {
finishWithError(event.message?.trim() || "Project workspace execution failed.");
return;
}
}
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", (error) => {
finishWithError(`Failed to start project workspace runner: ${toErrorMessage(error)}`);
});
child.on("close", (code) => {
if (settled) {
return;
}
const message = stderr.trim() || `Project workspace runner exited with code ${code ?? "unknown"}.`;
finishWithError(message);
});
const payload = JSON.stringify({
vendorPackageDir,
projectRoot: input.projectRoot,
sessionId: input.sessionId,
prompt: input.prompt,
runId
});
child.stdin.write(payload);
child.stdin.end();
});
}
}
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 { SkillExecutionTarget, SkillStoreService } from "./skill-store.js"; import type { SkillExecutionTarget, SkillStoreService } from "./skill-store.js";
interface PreparedSkillExecution { interface PreparedSkillExecution {
...@@ -66,15 +67,17 @@ function applyRuntimeSkillName(content: string, runtimeSkillName: string): strin ...@@ -66,15 +67,17 @@ function applyRuntimeSkillName(content: string, runtimeSkillName: string): strin
export class RuntimeSkillBridgeService { export class RuntimeSkillBridgeService {
private readonly skillStore: SkillStoreService; private readonly skillStore: SkillStoreService;
private readonly projectStore: ProjectStoreService;
private readonly runtimeManager: RuntimeManager; private readonly runtimeManager: RuntimeManager;
constructor(skillStore: SkillStoreService, runtimeManager: RuntimeManager) { constructor(skillStore: SkillStoreService, projectStore: ProjectStoreService, runtimeManager: RuntimeManager) {
this.skillStore = skillStore; this.skillStore = skillStore;
this.projectStore = projectStore;
this.runtimeManager = runtimeManager; this.runtimeManager = runtimeManager;
} }
async preparePrompt(prompt: string, skillId: string): Promise<PreparedSkillExecution> { async preparePrompt(prompt: string, skillId: string, projectId?: string): Promise<PreparedSkillExecution> {
const target = 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.");
} }
......
import http from "node:http"; import { readFile, stat } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
interface SmokeBundleFixture {
zipPath: string;
fileName: string;
skillId: string;
skillTitle: string;
skillDescription: string;
configVersion: string;
downloadUrl: string;
}
interface SmokeBundleResponseHeaders {
"Content-Type": string;
"Content-Length": string;
"Cache-Control": string;
ETag: string;
"Last-Modified": string;
}
function extractPromptText(value: unknown): string { function extractPromptText(value: unknown): string {
if (typeof value === "string") { if (typeof value === "string") {
...@@ -41,12 +61,61 @@ function buildSmokeReply(body: Record<string, unknown>): string { ...@@ -41,12 +61,61 @@ function buildSmokeReply(body: Record<string, unknown>): string {
return `Smoke stream ok: ${prompt.trim()}`; return `Smoke stream ok: ${prompt.trim()}`;
} }
async function pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}
async function resolveSmokeBundleFixture(baseUrl: string, defaultConfigVersion: string): Promise<SmokeBundleFixture | null> {
const zipPath = process.env.QJCLAW_SMOKE_BUNDLE_ZIP_PATH?.trim();
if (!zipPath) {
return null;
}
if (!(await pathExists(zipPath))) {
throw new Error(`Smoke bundle zip does not exist: ${zipPath}`);
}
const fileName = process.env.QJCLAW_SMOKE_BUNDLE_FILE_NAME?.trim() || path.basename(zipPath);
const skillId = process.env.QJCLAW_SMOKE_BUNDLE_SKILL_ID?.trim() || "workspace-bundle";
const skillTitle = process.env.QJCLAW_SMOKE_BUNDLE_SKILL_TITLE?.trim() || "Workspace Bundle";
const skillDescription = process.env.QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION?.trim() || "Remote project bundle for smoke validation.";
const configVersion = process.env.QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION?.trim() || defaultConfigVersion;
return {
zipPath,
fileName,
skillId,
skillTitle,
skillDescription,
configVersion,
downloadUrl: `${baseUrl}/downloads/${encodeURIComponent(fileName)}`
};
}
async function buildSmokeBundleHeaders(bundleFixture: SmokeBundleFixture): Promise<SmokeBundleResponseHeaders> {
const bundleStat = await stat(bundleFixture.zipPath);
const etag = `W/"${bundleStat.size.toString(16)}-${Math.trunc(bundleStat.mtimeMs).toString(16)}"`;
return {
"Content-Type": "application/zip",
"Content-Length": String(bundleStat.size),
"Cache-Control": "no-store",
ETag: etag,
"Last-Modified": bundleStat.mtime.toUTCString()
};
}
export async function startSmokeCloudApiServer(baseUrl: string, token: string, runtimeApiKey = "smoke-runtime-api-key"): Promise<() => Promise<void>> { export async function startSmokeCloudApiServer(baseUrl: string, token: string, runtimeApiKey = "smoke-runtime-api-key"): Promise<() => Promise<void>> {
const url = new URL(baseUrl); const url = new URL(baseUrl);
const hostname = url.hostname; const hostname = url.hostname;
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80)); const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
const providerToken = "runtime-provider-token"; const providerToken = "runtime-provider-token";
const providerBaseUrl = `${baseUrl}/openai/v1`; const providerBaseUrl = `${baseUrl}/openai/v1`;
const defaultConfigVersion = "2026-03-23T20:00:00.000Z";
const bundleFixture = await resolveSmokeBundleFixture(baseUrl, defaultConfigVersion);
const currentVersion = bundleFixture?.configVersion ?? defaultConfigVersion;
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const requestUrl = new URL(req.url || "/", `${url.protocol}//${url.host}`); const requestUrl = new URL(req.url || "/", `${url.protocol}//${url.host}`);
...@@ -134,6 +203,19 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r ...@@ -134,6 +203,19 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r
}; };
const handleRequest = async () => { const handleRequest = async () => {
if ((req.method === "GET" || req.method === "HEAD") && bundleFixture && requestUrl.pathname === `/downloads/${encodeURIComponent(bundleFixture.fileName)}`) {
const headers = await buildSmokeBundleHeaders(bundleFixture);
if (req.method === "HEAD") {
res.writeHead(200, headers);
res.end();
return;
}
const payload = await readFile(bundleFixture.zipPath);
res.writeHead(200, headers);
res.end(payload);
return;
}
if (req.method === "POST" && requestUrl.pathname === "/openclaw-employee-config") { if (req.method === "POST" && requestUrl.pathname === "/openclaw-employee-config") {
const body = await readJsonBody(); const body = await readJsonBody();
const apiKey = typeof body.api_key === "string" ? body.api_key : ""; const apiKey = typeof body.api_key === "string" ? body.api_key : "";
...@@ -145,20 +227,51 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r ...@@ -145,20 +227,51 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r
return; return;
} }
const currentVersion = "2026-03-23T20:00:00.000Z";
if (action === "sync" && configVersion === currentVersion) { if (action === "sync" && configVersion === currentVersion) {
sendJson(200, { changed: false, config_version: currentVersion }); sendJson(200, { changed: false, config_version: currentVersion });
return; return;
} }
const remoteSkills = bundleFixture ? [
{
binding_id: `binding-${bundleFixture.skillId}`,
skill_id: bundleFixture.skillId,
skill_config: {},
skill: {
id: bundleFixture.skillId,
title: bundleFixture.skillTitle,
description: bundleFixture.skillDescription,
category: "project",
file_name: bundleFixture.fileName,
file_size: (await stat(bundleFixture.zipPath)).size,
download_url: bundleFixture.downloadUrl
}
}
] : [
{
binding_id: "binding-legal-research",
skill_id: "legal-research",
skill_config: {},
skill: {
id: "legal-research",
title: "Legal Research",
description: "Finds statutes and cases.",
category: "research",
file_name: "legal-research.skill.json",
file_size: 1024,
download_url: "https://example.invalid/legal-research.skill.json"
}
}
];
sendJson(200, { sendJson(200, {
changed: true, changed: true,
employee_id: "employee-smoke", employee_id: "employee-smoke",
name: "Smoke Lobster", name: "Smoke Lobster",
status: "running", status: "running",
deployment_type: "local", deployment_type: "local",
persona_prompt: "你是前台验证用的小龙虾员工,请直接响应用户。", persona_prompt: "You are a smoke validation employee. Respond directly to the user.",
welcome_message: "你好,我已经接入真实运行时配置。", welcome_message: "Hello, the smoke runtime configuration is connected.",
work_hours: { work_hours: {
start: null, start: null,
end: null, end: null,
...@@ -183,22 +296,7 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r ...@@ -183,22 +296,7 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r
provider_type: "openai_compatible" provider_type: "openai_compatible"
} }
}, },
skills: [ skills: remoteSkills,
{
binding_id: "binding-legal-research",
skill_id: "legal-research",
skill_config: {},
skill: {
id: "legal-research",
title: "Legal Research",
description: "Finds statutes and cases.",
category: "research",
file_name: "legal-research.skill.json",
file_size: 1024,
download_url: "https://example.invalid/legal-research.skill.json"
}
}
],
channels: [ channels: [
{ {
id: "channel-web", id: "channel-web",
...@@ -428,5 +526,3 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r ...@@ -428,5 +526,3 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r
}); });
}; };
} }
...@@ -40,6 +40,10 @@ const desktopApi: DesktopApi = { ...@@ -40,6 +40,10 @@ const desktopApi: DesktopApi = {
load: () => ipcRenderer.invoke(IPC_CHANNELS.configLoad), load: () => ipcRenderer.invoke(IPC_CHANNELS.configLoad),
save: (input: SaveConfigInput) => ipcRenderer.invoke(IPC_CHANNELS.configSave, input) save: (input: SaveConfigInput) => ipcRenderer.invoke(IPC_CHANNELS.configSave, input)
}, },
projects: {
list: () => ipcRenderer.invoke(IPC_CHANNELS.projectsList),
setActive: (projectId: string) => ipcRenderer.invoke(IPC_CHANNELS.projectsSetActive, projectId)
},
auth: { auth: {
getSessionSummary: () => ipcRenderer.invoke(IPC_CHANNELS.authGetSession), getSessionSummary: () => ipcRenderer.invoke(IPC_CHANNELS.authGetSession),
signIn: (input: SignInInput) => ipcRenderer.invoke(IPC_CHANNELS.authSignIn, input), signIn: (input: SignInInput) => ipcRenderer.invoke(IPC_CHANNELS.authSignIn, input),
...@@ -62,6 +66,8 @@ const desktopApi: DesktopApi = { ...@@ -62,6 +66,8 @@ const desktopApi: DesktopApi = {
}, },
chat: { chat: {
listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.chatListSessions), listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.chatListSessions),
createSession: (title?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatCreateSession, title),
closeSession: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatCloseSession, sessionId),
listMessages: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatListMessages, sessionId), listMessages: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatListMessages, sessionId),
sendPrompt: (sessionId: string, prompt: string, skillId?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatSendPrompt, sessionId, prompt, skillId), sendPrompt: (sessionId: string, prompt: string, skillId?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatSendPrompt, sessionId, prompt, skillId),
streamPrompt: (sessionId: string, prompt: string, skillId?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatStreamPrompt, sessionId, prompt, skillId), streamPrompt: (sessionId: string, prompt: string, skillId?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatStreamPrompt, sessionId, prompt, skillId),
......
...@@ -7,7 +7,8 @@ export default defineConfig([ ...@@ -7,7 +7,8 @@ export default defineConfig([
clean: true, clean: true,
dts: false, dts: false,
entry: { entry: {
index: "src/main/index.ts" index: "src/main/index.ts",
"project-workspace-agent-runner": "src/main/project-workspace-agent-runner.ts"
}, },
external: ["electron"], external: ["electron"],
noExternal: bundledWorkspaceDeps, noExternal: bundledWorkspaceDeps,
...@@ -30,3 +31,4 @@ export default defineConfig([ ...@@ -30,3 +31,4 @@ export default defineConfig([
target: "node20" target: "node20"
} }
]); ]);
This diff is collapsed.
...@@ -929,3 +929,95 @@ strong { font-weight: 600; } ...@@ -929,3 +929,95 @@ strong { font-weight: 600; }
transition: none; transition: none;
} }
} }
.project-switcher,
.session-strip {
display: grid;
gap: 10px;
padding: 14px 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(129, 187, 255, 0.18);
}
.project-switcher-head,
.session-strip-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.project-switcher-head span,
.session-strip-head span {
font-size: 12px;
color: var(--text-soft);
}
.project-switcher-head strong {
font-size: 14px;
color: var(--text-main);
}
.project-chip-row,
.session-tab-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.project-chip,
.session-tab {
border-radius: 999px;
border: 1px solid rgba(129, 187, 255, 0.2);
background: rgba(240, 248, 255, 0.88);
}
.project-chip {
padding: 8px 14px;
color: var(--text-soft);
}
.project-chip.active {
color: #0a4f9d;
background: rgba(129, 187, 255, 0.18);
border-color: rgba(57, 131, 226, 0.34);
}
.session-tab {
display: inline-flex;
align-items: center;
overflow: hidden;
}
.session-tab.active {
background: rgba(129, 187, 255, 0.18);
border-color: rgba(57, 131, 226, 0.34);
}
.session-tab-main,
.session-tab-close,
.session-add {
border: none;
background: transparent;
}
.session-tab-main {
padding: 8px 12px;
color: var(--text-soft);
}
.session-tab.active .session-tab-main {
color: #0a4f9d;
}
.session-tab-close {
padding: 8px 10px 8px 0;
color: var(--text-soft);
}
.session-add {
padding: 0;
color: #0a4f9d;
}
...@@ -4,7 +4,18 @@ ...@@ -4,7 +4,18 @@
- `apps/desktop` packages the final EXE - `apps/desktop` packages the final EXE
- `vendor/openclaw-runtime` is reserved for the pinned runtime payload - `vendor/openclaw-runtime` is reserved for the pinned runtime payload
- `installer-smoke.ps1` performs a real silent NSIS install into `.tmp`, launches the installed app in smoke mode, and validates packaged paths plus diagnostics output - `installer-smoke.ps1` performs a real silent NSIS install into `.tmp`, launches the installed app in smoke mode, and validates packaged paths plus diagnostics output
- `electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output - `electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output; it now also supports preparing a workspace-entry fixture, preserving `userData`, and remote bundle-specific assertions
- `materialize-runtime-payload.ps1` generates a local bundled runtime payload under `vendor/openclaw-runtime/` by copying the local `node.exe`, the installed OpenClaw package, a local OpenClaw config snapshot, and a self-contained Python runtime with the locked dependency set installed into it - `materialize-runtime-payload.ps1` generates a local bundled runtime payload under `vendor/openclaw-runtime/` by copying the local `node.exe`, the installed OpenClaw package, a local OpenClaw config snapshot, and a self-contained Python runtime with the locked dependency set installed into it; when the existing payload manifest's `materializationKey` still matches the current inputs, it short-circuits and reuses the payload without rerunning `pip` upgrade or dependency installation
- `materialize-runtime-cache-smoke.ps1` materializes an isolated runtime directory twice and asserts the first run is a cache miss while the second run is a cache hit that skips `pip` upgrade and locked dependency installation; `pnpm smoke:materialize-cache`
- `bundled-runtime-smoke.ps1` materializes the local runtime payload, forces bundled-runtime mode, and validates that Electron can launch and use the managed runtime end to end - `bundled-runtime-smoke.ps1` materializes the local runtime payload, forces bundled-runtime mode, and validates that Electron can launch and use the managed runtime end to end
- `workspace-entry-smoke.ps1` materializes the bundled runtime payload, prepares an isolated active project fixture, and validates the workspace-entry execution path end to end as a formal regression smoke; `pnpm smoke:workspace-entry`
- `cloud-bundle-smoke.ps1` generates real same-project bundle variants, serves them through the smoke cloud API, and validates the full `cloud zip -> bundle sync -> active project -> workspace-entry` chain for payload `sync`, cached `init`, and same-`projectId` replacement with refreshed README/shared-entry materialization; `pnpm smoke:cloud-bundle`
- `default-chat-smoke.ps1` compiles the targeted `default-chat-context-smoke.ts` service-level smoke with the local desktop TypeScript toolchain and verifies `chat-fallback` routing, project context injection into the prepared prompt, post-execution snapshot refresh/rebind, and reuse of the refreshed snapshot on the next request; `pnpm smoke:default-chat`
- `installer-smoke.ps1` validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe` - `installer-smoke.ps1` validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe`
- `project-context-refresh-smoke.ps1` compiles the targeted `project-context-refresh-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies ProjectContextService snapshot cache, dirty invalidation, refresh, and `session.contextSnapshotId` rebinding; `pnpm smoke:project-context-refresh`
- `project-empty-inventory-smoke.ps1` compiles the targeted `project-empty-inventory-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies that an empty project inventory stays empty, session listing returns `[]`, session creation is blocked with the pending-cloud message, and the first synced bundle-backed project becomes active cleanly; `pnpm smoke:empty-project-inventory`
- `project-bundle-reconcile-smoke.ps1` compiles the targeted `project-bundle-reconcile-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies stale bundle project removal, shared `skills/` cleanup, shared `cron/` cleanup, manifest pruning, and empty-inventory cleanup without recreating a local fallback project; `pnpm smoke:bundle-reconcile`
- `project-bundle-freshness-smoke.ps1` compiles the targeted `project-bundle-freshness-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies that the same bundle URL plus unchanged `configVersion` still re-syncs when remote `ETag` / `Last-Modified` freshness metadata changes; `pnpm smoke:bundle-freshness`
- `project-bundle-replacement-smoke.ps1` compiles the targeted `project-bundle-replacement-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies same-project replacement, shared `skills/` and `cron/` ownership cleanup, rollback on an injected post-commit failure, and successful recovery on the next sync; `pnpm smoke:bundle-replacement`
- `project-isolation-smoke.ps1` runs the project-isolation regression smokes back to back, including the targeted default-chat, project-context refresh, and empty-project-inventory smokes; `pnpm smoke:project-isolation`
This diff is collapsed.
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 {
refreshProjectContextAfterExecution,
shouldRefreshProjectContextAfterExecution
} from "../../apps/desktop/src/main/services/project-context-lifecycle.js";
import { ProjectContextService } from "../../apps/desktop/src/main/services/project-context.js";
import { ProjectExecutionRouter } from "../../apps/desktop/src/main/services/project-execution-router.js";
import { ProjectStoreService } from "../../apps/desktop/src/main/services/project-store.js";
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
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", "default-chat-smoke", "context-result.json"));
const tempRoot = path.dirname(resultPath);
const userDataPath = path.join(tempRoot, "context-user-data");
const readmeMarkerBefore = "README marker before default chat refresh smoke";
const readmeMarkerAfter = "README marker after default chat refresh smoke";
const promptBefore = "Repeat the README marker and confirm the current project root.";
const promptAfter = "Repeat the updated README marker and confirm the current project root again.";
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 project = await projectStore.upsertProject({
id: "default-chat-smoke",
name: "Default Chat Smoke",
description: "Verifies chat-fallback project context binding and post-execution refresh.",
ready: true,
boundSkillIds: []
});
await projectStore.setActiveProject(project.id);
const projectRoot = await projectStore.getProjectRoot(project.id);
const readmePath = path.join(projectRoot, "README.md");
await writeFile(readmePath, `# Default Chat Smoke\n\n${readmeMarkerBefore}\n`, "utf8");
const projectContextService = new ProjectContextService(projectStore);
const projectExecutionRouter = new ProjectExecutionRouter();
const session = await projectStore.createSession("Default Chat Smoke", project.id);
async function prepare(prompt: string) {
const sessionStateBefore = await projectStore.getSessionState(session.id);
const snapshot = await projectContextService.getSnapshot(project.id);
if (sessionStateBefore.contextSnapshotId !== snapshot.snapshotId) {
await projectStore.bindSessionContextSnapshot(session.id, snapshot.snapshotId);
}
const decision = await projectExecutionRouter.decide({
sessionId: session.id,
projectId: project.id,
projectRoot,
userPrompt: prompt,
context: snapshot,
selectedSkillId: null
});
const sessionStateAfter = await projectStore.getSessionState(session.id);
return {
sessionStateBefore,
sessionStateAfter,
snapshot,
decision
};
}
const firstPreparation = await prepare(promptBefore);
assert(firstPreparation.decision.kind === "chat-fallback", "Expected the first default chat preparation to route to chat-fallback.");
assert(firstPreparation.sessionStateAfter.contextSnapshotId === firstPreparation.snapshot.snapshotId, "Initial default chat preparation did not bind session.contextSnapshotId.");
assert(shouldRefreshProjectContextAfterExecution(firstPreparation.decision), "chat-fallback should schedule post-execution project context refresh.");
assert(firstPreparation.decision.preparedPrompt.includes(readmeMarkerBefore), "Initial default chat prepared prompt did not include the original README marker.");
assert(firstPreparation.decision.preparedPrompt.includes(`Current project: ${project.name} (${project.id})`), "Initial default chat prepared prompt did not include the project identity.");
assert(firstPreparation.decision.preparedPrompt.includes(`Project root: ${projectRoot}`), "Initial default chat prepared prompt did not include the project root.");
assert(firstPreparation.decision.preparedPrompt.includes("Keep project context isolated to this project and session."), "Initial default chat prepared prompt did not include the isolation instruction.");
await delay(25);
await writeFile(readmePath, `# Default Chat Smoke\n\n${readmeMarkerAfter}\n`, "utf8");
const staleSessionState = await projectStore.getSessionState(session.id);
assert(staleSessionState.contextSnapshotId === firstPreparation.snapshot.snapshotId, "Session snapshot changed before the post-execution refresh step ran.");
await refreshProjectContextAfterExecution({
sessionId: session.id,
projectId: project.id,
projectContextService,
projectStore
});
const refreshedSnapshot = await projectContextService.getSnapshot(project.id);
const reboundSessionState = await projectStore.getSessionState(session.id);
assert(refreshedSnapshot.snapshotId !== firstPreparation.snapshot.snapshotId, "Post-execution default chat refresh did not produce a new snapshotId.");
assert(refreshedSnapshot.readme?.includes(readmeMarkerAfter), "Post-execution default chat refresh did not include the updated README marker.");
assert(reboundSessionState.contextSnapshotId === refreshedSnapshot.snapshotId, "Post-execution default chat refresh did not rebind session.contextSnapshotId.");
const secondPreparation = await prepare(promptAfter);
assert(secondPreparation.decision.kind === "chat-fallback", "Expected the second default chat preparation to route to chat-fallback.");
assert(secondPreparation.snapshot.snapshotId === refreshedSnapshot.snapshotId, "Second default chat preparation did not reuse the refreshed snapshotId.");
assert(secondPreparation.sessionStateAfter.contextSnapshotId === secondPreparation.snapshot.snapshotId, "Second default chat preparation did not preserve the rebound session.contextSnapshotId.");
assert(secondPreparation.decision.preparedPrompt.includes(readmeMarkerAfter), "Second default chat prepared prompt did not include the updated README marker.");
assert(secondPreparation.decision.preparedPrompt.includes(promptAfter), "Second default chat prepared prompt did not preserve the user prompt.");
const cachedAfterRefresh = await projectContextService.getSnapshot(project.id);
assert(cachedAfterRefresh.snapshotId === refreshedSnapshot.snapshotId, "Default chat snapshot cache was not stable after post-execution refresh.");
const summary = {
ok: true,
userDataPath,
projectId: project.id,
sessionId: session.id,
projectRoot,
readmePath,
initialSnapshotId: firstPreparation.snapshot.snapshotId,
refreshedSnapshotId: refreshedSnapshot.snapshotId,
finalSnapshotId: cachedAfterRefresh.snapshotId,
initialSessionSnapshotId: firstPreparation.sessionStateAfter.contextSnapshotId,
staleSessionSnapshotId: staleSessionState.contextSnapshotId,
reboundSessionSnapshotId: reboundSessionState.contextSnapshotId,
firstDecisionKind: firstPreparation.decision.kind,
secondDecisionKind: secondPreparation.decision.kind,
postExecutionRefreshEnabled: shouldRefreshProjectContextAfterExecution(firstPreparation.decision),
firstPreparedPromptIncludesInitialReadme: firstPreparation.decision.preparedPrompt.includes(readmeMarkerBefore),
secondPreparedPromptIncludesUpdatedReadme: secondPreparation.decision.preparedPrompt.includes(readmeMarkerAfter),
sessionReboundAfterRefresh: reboundSessionState.contextSnapshotId === refreshedSnapshot.snapshotId,
secondPreparationReusedRefreshedSnapshot: secondPreparation.snapshot.snapshotId === refreshedSnapshot.snapshotId,
snapshotChangedAfterExecution: refreshedSnapshot.snapshotId !== firstPreparation.snapshot.snapshotId
};
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", "default-chat-smoke", "context-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,
[int]$TimeoutSeconds = 120,
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token',
[switch]$SkipMaterializeRuntime
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$sourcePath = Join-Path $repoRoot 'build\scripts\default-chat-context-smoke.ts'
$tempRoot = Join-Path $repoRoot '.tmp\default-chat-smoke'
$compileRoot = Join-Path $tempRoot 'compiled'
$entryPath = Join-Path $compileRoot 'build\scripts\default-chat-context-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 "Default chat 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 default-chat smoke with local TypeScript'
corepack @compileArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $entryPath)) {
throw "Default chat smoke entry was not emitted: $entryPath"
}
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
Write-Host 'Running default-chat smoke'
node $entryPath $resolvedResultPath
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $resolvedResultPath)) {
throw "Default chat smoke did not produce a result file: $resolvedResultPath"
}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
param(
[string]$SmokeOutput
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$sourcePath = Join-Path $repoRoot 'build\scripts\project-bundle-freshness-smoke.ts'
$tempRoot = Join-Path $repoRoot '.tmp\project-bundle-freshness-smoke'
$compileRoot = Join-Path $tempRoot 'compiled'
$entryPath = Join-Path $compileRoot 'build\scripts\project-bundle-freshness-smoke.js'
$compilePackagePath = Join-Path $compileRoot 'package.json'
$bundleRootA = Join-Path $tempRoot 'bundle-src-a'
$bundleRootB = Join-Path $tempRoot 'bundle-src-b'
$bundleZipPathA = Join-Path $tempRoot 'bundle-a.zip'
$bundleZipPathB = Join-Path $tempRoot 'bundle-b.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)
}
if (-not (Test-Path $sourcePath)) {
throw "Project bundle freshness 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 freshness smoke with local TypeScript'
corepack @compileArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $entryPath)) {
throw "Project bundle freshness smoke entry was not emitted: $entryPath"
}
foreach ($path in @($bundleRootA, $bundleRootB)) {
if (Test-Path $path) {
Remove-Item $path -Recurse -Force
}
}
foreach ($path in @($bundleZipPathA, $bundleZipPathB)) {
if (Test-Path $path) {
Remove-Item $path -Force
}
}
foreach ($root in @($bundleRootA, $bundleRootB)) {
New-Item -ItemType Directory -Path (Join-Path $root 'memory') -Force | Out-Null
}
Write-Utf8File -FilePath (Join-Path $bundleRootA 'project.json') -Content (@{
id = 'bundle-freshness-smoke'
name = 'Bundle Freshness Smoke'
version = '1.0.0'
description = 'Verifies bundle freshness detection for same URL and config version.'
} | ConvertTo-Json -Depth 5)
Write-Utf8File -FilePath (Join-Path $bundleRootA 'README.md') -Content '# Bundle Freshness Smoke`n`nFreshness variant A'
Write-Utf8File -FilePath (Join-Path $bundleRootA 'memory\summary.md') -Content 'bundle freshness variant a'
Write-Utf8File -FilePath (Join-Path $bundleRootB 'project.json') -Content (@{
id = 'bundle-freshness-smoke'
name = 'Bundle Freshness Smoke'
version = '1.0.0'
description = 'Verifies bundle freshness detection for same URL and config version.'
} | ConvertTo-Json -Depth 5)
Write-Utf8File -FilePath (Join-Path $bundleRootB 'README.md') -Content '# Bundle Freshness Smoke`n`nFreshness variant B'
Write-Utf8File -FilePath (Join-Path $bundleRootB 'memory\summary.md') -Content 'bundle freshness variant b'
Compress-Archive -Path (Join-Path $bundleRootA '*') -DestinationPath $bundleZipPathA -Force
Compress-Archive -Path (Join-Path $bundleRootB '*') -DestinationPath $bundleZipPathB -Force
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
Write-Host 'Running project-bundle freshness smoke'
node $entryPath $resolvedResultPath $bundleZipPathA $bundleZipPathB
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $resolvedResultPath)) {
throw "Project bundle freshness smoke did not produce a result file: $resolvedResultPath"
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
param(
[int]$TimeoutSeconds = 180,
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token'
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
Write-Host "Materializing shared bundled runtime payload on port $GatewayPort"
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\materialize-runtime-payload.ps1') -GatewayPort $GatewayPort -GatewayToken $GatewayToken
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host 'Running workspace-entry isolation smoke'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\workspace-entry-smoke.ps1') -TimeoutSeconds $TimeoutSeconds -GatewayPort $GatewayPort -GatewayToken $GatewayToken -SkipMaterializeRuntime
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host 'Running default-chat isolation smoke'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\default-chat-smoke.ps1') -TimeoutSeconds $TimeoutSeconds -GatewayPort $GatewayPort -GatewayToken $GatewayToken -SkipMaterializeRuntime
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host 'Running cloud-bundle isolation smoke'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\cloud-bundle-smoke.ps1') -TimeoutSeconds $TimeoutSeconds -GatewayPort $GatewayPort -GatewayToken $GatewayToken -SkipMaterializeRuntime
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host 'Running project-context refresh smoke'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\project-context-refresh-smoke.ps1')
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Host 'Running empty project-inventory smoke'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\project-empty-inventory-smoke.ps1')
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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