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

Implement project bundle isolation flow

parent c484f174
import path from "node:path";
import path from "node:path";
import { appendFile, readFile, writeFile } from "node:fs/promises";
import { BrowserWindow, app } from "electron";
import { GatewayClient } from "@qjclaw/gateway-client";
......@@ -18,6 +18,11 @@ import { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js";
import { RuntimeSkillBridgeService } from "./services/runtime-skill-bridge.js";
import { SkillClient } from "./services/skill-client.js";
import { SkillStoreService } from "./services/skill-store.js";
import { ProjectStoreService } from "./services/project-store.js";
import { ProjectBundleService } from "./services/project-bundle.js";
import { ProjectContextService } from "./services/project-context.js";
import { ProjectExecutionRouter } from "./services/project-execution-router.js";
import { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
interface RendererSmokeState {
usingMockApi: boolean;
......@@ -404,10 +409,15 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
? (readyWorkspaceSkills.find((skill) => skill.id === preferredSkillId)?.id
?? readySkills.find((skill) => skill.id === preferredSkillId)?.id)
: (readyWorkspaceSkills[0]?.id ?? readySkills[0]?.id);
const sessions = await api.chat.listSessions();
const sessionId = state?.activeSessionId || sessions[0]?.id || "desktop-main";
let sessions = workspace.sessions?.length ? workspace.sessions : await api.chat.listSessions();
let sessionId = sessions.find((session) => session.id === state?.activeSessionId)?.id ?? sessions[0]?.id;
if (!sessionId) {
const createdSession = await api.chat.createSession("Smoke Test");
sessions = [createdSession];
sessionId = createdSession.id;
}
const system = await api.system.getSummary();
await actions.sendChatPrompt(${JSON.stringify(prompt)}, selectedSkillId);
await actions.sendChatPrompt(${JSON.stringify(prompt)}, selectedSkillId, sessionId);
return {
prompt: ${JSON.stringify(prompt)},
runtimeCloudStatus,
......@@ -448,7 +458,13 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
if (!api) {
throw new Error("Renderer is using mock desktop API.");
}
const sessionId = state?.streamSmoke?.sessionId || state?.activeSessionId || "desktop-main";
let sessions = state?.workspaceSummary?.sessions?.length ? state.workspaceSummary.sessions : await api.chat.listSessions();
const sessionId = (state?.streamSmoke?.sessionId && sessions.some((session) => session.id === state.streamSmoke.sessionId)
? state.streamSmoke.sessionId
: sessions.find((session) => session.id === state?.activeSessionId)?.id ?? sessions[0]?.id);
if (!sessionId) {
throw new Error("Renderer smoke state did not publish a project-scoped session.");
}
const runtimeTelemetryAfterWait = await api.runtimeTelemetry.getStatus();
const messages = await api.chat.listMessages(sessionId);
const chatMessages = messages.filter((message) => message.role === "assistant" || message.role === "user");
......@@ -506,6 +522,14 @@ async function bootstrap(): Promise<void> {
const smokeOutputPath = process.env.QJCLAW_SMOKE_OUTPUT;
const smokeEnabled = Boolean(smokeOutputPath);
const traceBootstrap = async (message: string) => {
if (!smokeOutputPath) {
return;
}
const line = "[" + new Date().toISOString() + "] bootstrap:" + message + "\n";
await appendFile(smokeOutputPath + ".trace.log", line, "utf8").catch(() => undefined);
};
await traceBootstrap("when-ready");
const systemSummary = buildSystemSummary();
const configService = new AppConfigService(systemSummary.userDataPath);
......@@ -516,6 +540,7 @@ async function bootstrap(): Promise<void> {
let stopSmokeCloudApiServer: (() => Promise<void>) | undefined;
if (smokeEnabled) {
await traceBootstrap("smoke-config-start");
const smokeCloudBaseUrl = process.env.QJCLAW_SMOKE_CLOUD_API_BASE_URL;
const smokeAuthToken = process.env.QJCLAW_SMOKE_AUTH_TOKEN;
const smokeRuntimeApiKey = process.env.QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY ?? "smoke-runtime-api-key";
......@@ -547,16 +572,50 @@ async function bootstrap(): Promise<void> {
const diagnosticsService = new DiagnosticsService(systemSummary.userDataPath);
const deviceIdentityService = new DeviceIdentityService(systemSummary.userDataPath);
await deviceIdentityService.load();
const localOpenClawConfig = await loadLocalOpenClawGatewayConfig();
await traceBootstrap("device-identity-loaded");
await traceBootstrap("local-openclaw-config-start");
const localOpenClawConfig = await loadLocalOpenClawGatewayConfig();
await traceBootstrap("local-openclaw-config-done");
const runtimeCloudClient = new OpenClawConfigClient(configService, secretManager);
await traceBootstrap("runtime-cloud-hydrate-start");
await runtimeCloudClient.hydrateCache();
await traceBootstrap("runtime-cloud-hydrate-done");
const skillStore = new SkillStoreService(systemSummary.userDataPath);
const projectStore = new ProjectStoreService(configService);
await projectStore.initialize();
await traceBootstrap("project-store-initialized");
const projectBundleService = new ProjectBundleService(configService, projectStore);
const syncProjectBundles = async (
skills: Parameters<ProjectBundleService["syncRemoteBundles"]>[0],
configVersion: string | undefined,
action: RuntimeCloudFetchAction
) => {
await traceBootstrap("project-bundle-sync:" + action + ":start:" + skills.length);
try {
await projectBundleService.syncRemoteBundles(skills, configVersion, action);
await traceBootstrap("project-bundle-sync:" + action + ":done");
} catch (error) {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
console.error("Project bundle sync failed", {
action,
configVersion,
skillIds: skills.map((skill) => skill.skillId),
error: message
});
await traceBootstrap("project-bundle-sync:" + action + ":error:" + message.replace(/\r?\n/g, " | "));
}
};
const projectContextService = new ProjectContextService(projectStore);
const projectExecutionRouter = new ProjectExecutionRouter();
runtimeCloudClient.onPayloadUpdated(async ({ config: payloadConfig, skills }) => {
await skillStore.reconcile(skills, payloadConfig.configVersion);
await syncProjectBundles(skills, payloadConfig.configVersion, "sync");
});
const cachedRuntimeCloudStatus = await runtimeCloudClient.getStatus();
if (cachedRuntimeCloudStatus.config) {
await skillStore.reconcile(runtimeCloudClient.getRemoteSkillAssets(), cachedRuntimeCloudStatus.config.configVersion).catch(() => undefined);
const cachedRemoteSkills = runtimeCloudClient.getRemoteSkillAssets();
await skillStore.reconcile(cachedRemoteSkills, cachedRuntimeCloudStatus.config.configVersion).catch(() => undefined);
await syncProjectBundles(cachedRemoteSkills, cachedRuntimeCloudStatus.config.configVersion, "init");
}
const runtimeManager = new RuntimeManager({
......@@ -577,7 +636,10 @@ async function bootstrap(): Promise<void> {
},
strictBundledRuntime: systemSummary.isPackaged
});
await traceBootstrap("runtime-configure-start");
await runtimeManager.configure();
await traceBootstrap("runtime-configure-done");
const projectWorkspaceExecutor = new ProjectWorkspaceExecutorService(runtimeManager);
const runtimeStatus = await runtimeManager.status();
if (systemSummary.isPackaged && runtimeStatus.payloadState !== "ready") {
throw new Error(`Packaged app bundled runtime is not ready: ${runtimeStatus.payloadState}`);
......@@ -593,7 +655,7 @@ async function bootstrap(): Promise<void> {
}
});
const runtimeSkillBridge = new RuntimeSkillBridgeService(skillStore, runtimeManager);
const runtimeSkillBridge = new RuntimeSkillBridgeService(skillStore, projectStore, runtimeManager);
await runtimeSkillBridge.clearManagedSkills().catch(() => undefined);
const authClient = new AuthClient(configService, secretManager);
......@@ -634,13 +696,16 @@ async function bootstrap(): Promise<void> {
};
if (resolveRequestedRuntimeMode(config.runtimeMode) !== "external-gateway" && (await secretManager.getApiKey())) {
await traceBootstrap("runtime-bootstrap-start");
try {
const shouldUseRuntimeCloud = config.setupMode === "employee-key";
const usingCachedRuntimeCloudConfig = shouldUseRuntimeCloud && runtimeCloudClient.hasCachedPayload();
if (shouldUseRuntimeCloud && !usingCachedRuntimeCloudConfig) {
await runtimeCloudClient.fetchConfig("init");
}
await traceBootstrap("runtime-start");
await runtimeManager.start();
await traceBootstrap("runtime-started");
const runtimeGatewayConnection = await runtimeManager.getGatewayConnection();
if (runtimeGatewayConnection.url) {
await gatewayClient.reconfigure(
......@@ -649,9 +714,13 @@ async function bootstrap(): Promise<void> {
(await secretManager.getDeviceToken()) ?? undefined
);
}
await traceBootstrap("gateway-connect");
await gatewayClient.connect().catch(() => undefined);
await traceBootstrap("gateway-connect-done");
if (config.setupMode === "employee-key") {
await traceBootstrap("runtime-cloud-supervisor-start");
await runtimeCloudSupervisor.start();
await traceBootstrap("runtime-cloud-supervisor-started");
if (usingCachedRuntimeCloudConfig) {
scheduleRuntimeCloudRefresh("bootstrap");
}
......@@ -665,6 +734,7 @@ async function bootstrap(): Promise<void> {
void gatewayClient.connect().catch(() => undefined);
}
await traceBootstrap("register-ipc");
registerDesktopIpc({
appVersion: app.getVersion(),
configService,
......@@ -682,6 +752,10 @@ async function bootstrap(): Promise<void> {
runtimeCloudSupervisor,
dailyReportService,
runtimeSkillBridge,
projectStore,
projectContextService,
projectExecutionRouter,
projectWorkspaceExecutor,
systemSummary,
localOpenClawConfig
});
......@@ -707,8 +781,11 @@ async function bootstrap(): Promise<void> {
})();
});
await traceBootstrap("create-window");
const window = createMainWindow(smokeEnabled);
await traceBootstrap("window-created");
if (smokeEnabled && smokeOutputPath) {
await traceBootstrap("run-smoke-test-start");
void runSmokeTest(window, smokeOutputPath);
}
......@@ -740,3 +817,6 @@ void bootstrap().catch(async (error) => {
import { randomUUID } from "node:crypto";
import { ipcMain, shell } from "electron";
import { ipcMain, shell, type WebContents } from "electron";
import {
IPC_CHANNELS,
type AppConfig,
type ChatMessage,
type ChatStreamEvent,
type DesktopApi,
type GatewayStatus,
......@@ -13,6 +14,7 @@ import {
type SaveConfigInput,
type SignInInput,
type SystemSummary,
type ProjectExecutionDecision,
type WorkspaceSummary,
type WorkspaceWarmupResult
} from "@qjclaw/shared-types";
......@@ -28,6 +30,20 @@ import { resolveEffectiveGatewayUrl, type LocalOpenClawGatewayConfig } from "./s
import type { SecretManager } from "./services/secrets.js";
import type { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js";
import type { RuntimeSkillBridgeService } from "./services/runtime-skill-bridge.js";
import type { ProjectStoreService } from "./services/project-store.js";
import {
EMPTY_PROJECT_INVENTORY_MESSAGE,
createSessionForActiveProject,
listSessionsForActiveProject,
loadActiveProjectWorkspaceState
} from "./services/project-inventory-state.js";
import type { ProjectContextService } from "./services/project-context.js";
import type { ProjectExecutionRouter } from "./services/project-execution-router.js";
import type { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
import {
refreshProjectContextAfterExecution,
shouldRefreshProjectContextAfterExecution
} from "./services/project-context-lifecycle.js";
interface MainServices {
configService: AppConfigService;
......@@ -45,6 +61,10 @@ interface MainServices {
runtimeCloudSupervisor: RuntimeCloudSupervisor;
dailyReportService: DailyReportService;
runtimeSkillBridge: RuntimeSkillBridgeService;
projectStore: ProjectStoreService;
projectContextService: ProjectContextService;
projectExecutionRouter: ProjectExecutionRouter;
projectWorkspaceExecutor: ProjectWorkspaceExecutorService;
appVersion: string;
systemSummary: SystemSummary;
localOpenClawConfig?: LocalOpenClawGatewayConfig | null;
......@@ -308,12 +328,15 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
profileClient,
secretManager,
skillClient,
skillStore,
modelConfigClient,
runtimeCloudClient,
runtimeCloudSupervisor,
dailyReportService,
runtimeSkillBridge,
projectStore,
projectContextService,
projectExecutionRouter,
projectWorkspaceExecutor,
systemSummary,
localOpenClawConfig
} = services;
......@@ -520,6 +543,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
const buildWorkspaceSummary = async (): Promise<WorkspaceSummary> => {
const config = await getEffectiveConfig();
await projectStore.initialize();
const runtimeStatus = await runtimeManager.status();
const runtimeCloudStatus: RuntimeCloudStatus = config.setupMode === "employee-key"
? await runtimeCloudClient.getStatus()
......@@ -535,8 +559,26 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
const gatewayStatus = config.apiKeyConfigured
? await gatewayClient.status().catch(() => null)
: null;
const chatSummary = buildChatSummary(config, runtimeStatus, runtimeCloudStatus, gatewayStatus, workspaceWarmupInFlight);
const skills = await skillStore.listWorkspaceSkills();
const baseChatSummary = buildChatSummary(config, runtimeStatus, runtimeCloudStatus, gatewayStatus, workspaceWarmupInFlight);
const {
projects,
currentProject,
sessions,
skills
} = await loadActiveProjectWorkspaceState(projectStore);
const chatSummary = projects.length > 0
? baseChatSummary
: {
chatReady: false,
chatLaunchState: config.apiKeyConfigured ? "starting" as const : baseChatSummary.chatLaunchState,
chatStatusMessage: config.apiKeyConfigured
? EMPTY_PROJECT_INVENTORY_MESSAGE
: baseChatSummary.chatStatusMessage,
startupPhase: config.apiKeyConfigured ? "syncing-config" as const : baseChatSummary.startupPhase,
startupMessage: config.apiKeyConfigured
? EMPTY_PROJECT_INVENTORY_MESSAGE
: baseChatSummary.startupMessage
};
return {
apiKeyConfigured: config.apiKeyConfigured,
......@@ -558,6 +600,13 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
runtimeCloudState: runtimeCloudStatus.state,
runtimeState: runtimeStatus.processState,
runtimeMessage: runtimeStatus.message,
currentProjectId: currentProject?.id,
currentProjectName: currentProject?.name,
projectVersion: currentProject?.version,
projectReady: currentProject?.ready ?? false,
projectCount: projects.length,
projects,
sessions,
skillCount: skills.length,
skills,
plugins: buildPluginSummaries(runtimeStatus),
......@@ -619,11 +668,11 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
});
};
const resolveExecutionPolicy = async (skillId?: string) => {
const resolveExecutionPolicy = async (projectId: string, skillId?: string) => {
const config = await getEffectiveConfig();
const [runtimeCloudStatus, skills] = await Promise.all([
runtimeCloudClient.getStatus(),
skillStore.listWorkspaceSkills()
projectStore.listProjectSkills(projectId)
]);
const selectedSkill = skillId ? skills.find((skill) => skill.id === skillId) : undefined;
const configuredModelId = runtimeCloudStatus.config?.modelId;
......@@ -656,131 +705,165 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
};
};
const emitChatStreamEvent = (sender: Electron.WebContents, payload: ChatStreamEvent) => {
sender.send(IPC_CHANNELS.chatStreamEvent, payload);
const streamListeners = new Set<(payload: ChatStreamEvent) => void>();
const broadcastChatStreamEvent = (payload: ChatStreamEvent, sender?: WebContents) => {
if (sender && !sender.isDestroyed()) {
sender.send(IPC_CHANNELS.chatStreamEvent, payload);
}
for (const listener of streamListeners) {
try {
listener(payload);
} catch {
// Ignore listener failures so one consumer does not break the stream.
}
}
};
const prepareGatewayPrompt = async (prompt: string, skillId?: string): Promise<string> => {
if (!skillId) {
const prepareGatewayPrompt = async (decision: ProjectExecutionDecision, projectId: string): Promise<string | undefined> => {
if (decision.kind === "chat-fallback") {
await runtimeSkillBridge.clearManagedSkills();
return prompt;
return decision.preparedPrompt;
}
const runtimeStatus = await runtimeManager.status();
if (runtimeStatus.activeMode !== "bundled-runtime") {
throw new Error("Selected skills currently require bundled runtime. Switch from external gateway mode and try again.");
if (decision.kind === "skill") {
const runtimeStatus = await runtimeManager.status();
if (runtimeStatus.activeMode !== "bundled-runtime") {
throw new Error("Selected skills currently require bundled runtime. Switch from external gateway mode and try again.");
}
const prepared = await runtimeSkillBridge.preparePrompt(decision.preparedPrompt, decision.skillId, projectId);
return prepared.prompt;
}
const prepared = await runtimeSkillBridge.preparePrompt(prompt, skillId);
return prepared.prompt;
await runtimeSkillBridge.clearManagedSkills();
return undefined;
};
ipcMain.handle(IPC_CHANNELS.workspaceGetSummary, async () => buildWorkspaceSummary());
ipcMain.handle(IPC_CHANNELS.workspaceWarmup, async () => queueWorkspaceWarmup("workspace-warmup", { action: "init" }));
ipcMain.handle(IPC_CHANNELS.gatewayStatus, async () => gatewayClient.status());
ipcMain.handle(IPC_CHANNELS.gatewayConnect, async () => gatewayClient.connect());
ipcMain.handle(IPC_CHANNELS.gatewayDisconnect, async () => gatewayClient.disconnect());
ipcMain.handle(IPC_CHANNELS.gatewayReconnect, async () => gatewayClient.reconnect());
ipcMain.handle(IPC_CHANNELS.gatewayHealth, async () => gatewayClient.health());
ipcMain.handle(IPC_CHANNELS.gatewayTailLogs, async (_event, limit?: number) => gatewayClient.tailLogs(limit));
ipcMain.handle(IPC_CHANNELS.runtimeGetStatus, async () => runtimeManager.status());
ipcMain.handle(IPC_CHANNELS.runtimeTailLogs, async (_event, limit?: number) => runtimeManager.tailLogs(limit));
ipcMain.handle(IPC_CHANNELS.runtimeStart, async () => startManagedRuntime("runtime-start", { action: "init" }));
ipcMain.handle(IPC_CHANNELS.runtimeStop, async () => {
await runtimeCloudSupervisor.stop("runtime-stop");
const status = await runtimeManager.stop();
await reconfigureGatewayClient();
return status;
const createChatMessage = (role: ChatMessage["role"], content: string): ChatMessage => ({
id: randomUUID(),
role,
content,
createdAt: new Date().toISOString()
});
ipcMain.handle(IPC_CHANNELS.runtimeRestart, async () => startManagedRuntime("runtime-restart", { action: "init", restart: true }));
ipcMain.handle(IPC_CHANNELS.runtimeHealth, async () => runtimeManager.health());
ipcMain.handle(IPC_CHANNELS.runtimeCloudGetStatus, async () => runtimeCloudClient.getStatus());
ipcMain.handle(IPC_CHANNELS.runtimeCloudFetchConfig, async (_event, action) => runtimeCloudClient.fetchConfig(action));
ipcMain.handle(IPC_CHANNELS.runtimeTelemetryGetStatus, async () => runtimeCloudSupervisor.getStatus());
ipcMain.handle(IPC_CHANNELS.configLoad, async () => getEffectiveConfig());
ipcMain.handle(IPC_CHANNELS.configSave, async (_event, input: SaveConfigInput) => {
const previousConfig = await configService.load();
const config = await configService.save(input);
if (typeof input.apiKey === "string") {
await secretManager.setApiKey(input.apiKey || undefined);
const ensureLocalTranscript = async (sessionId: string): Promise<ChatMessage[]> => {
const localMessages = await projectStore.listSessionMessages(sessionId);
if (localMessages.length > 0) {
return localMessages;
}
if (typeof input.gatewayToken === "string") {
await secretManager.setGatewayToken(input.gatewayToken || undefined);
}
if (typeof input.authToken === "string") {
await secretManager.setAuthToken(input.authToken || undefined);
}
if (config.setupMode === "direct-provider" || previousConfig.setupMode !== config.setupMode) {
await runtimeCloudClient.clearCache().catch(() => undefined);
}
await runtimeManager.setRequestedMode(config.runtimeMode);
if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) {
void queueWorkspaceWarmup("config-save", {
action: "init",
config,
inputToken: input.gatewayToken
});
} else {
await runtimeCloudSupervisor.stop("config-save");
await reconfigureGatewayClient(config, input.gatewayToken);
if (config.setupMode === "employee-key") {
await syncRuntimeCloudSupervisor("config-save");
try {
const gatewayMessages = await gatewayClient.listMessages(sessionId);
if (gatewayMessages.length > 0) {
await projectStore.seedSessionMessages(sessionId, gatewayMessages);
}
return gatewayMessages;
} catch {
return localMessages;
}
void dailyReportService.runDueCheck().catch(() => undefined);
return getEffectiveConfig();
});
};
ipcMain.handle(IPC_CHANNELS.authGetSession, async () => authClient.getSessionSummary());
ipcMain.handle(IPC_CHANNELS.authSignIn, async (_event, input: SignInInput) => {
const session = await authClient.signIn(input.accessToken);
await configService.save({ ...(await getEffectiveConfig()), authToken: input.accessToken });
return session;
});
ipcMain.handle(IPC_CHANNELS.authSignOut, async () => {
const session = await authClient.signOut();
await configService.save({ ...(await getEffectiveConfig()), authToken: "" });
return session;
});
ipcMain.handle(IPC_CHANNELS.profileGetSummary, async () => profileClient.getSummary());
ipcMain.handle(IPC_CHANNELS.creditsGetSummary, async () => creditClient.getSummary());
ipcMain.handle(IPC_CHANNELS.skillsList, async () => skillClient.list());
ipcMain.handle(IPC_CHANNELS.modelConfigGetSummary, async () => modelConfigClient.getSummary());
ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary);
const prepareProjectAwareExecution = async (sessionId: string, prompt: string, skillId?: string) => {
const selectedSkillId = skillId?.trim() ? skillId.trim() : null;
const sessionState = await projectStore.getSessionState(sessionId);
await projectStore.setSessionSelectedSkill(sessionId, selectedSkillId);
const snapshot = await projectContextService.getSnapshot(sessionState.projectId);
if (sessionState.contextSnapshotId !== snapshot.snapshotId) {
await projectStore.bindSessionContextSnapshot(sessionId, snapshot.snapshotId);
}
ipcMain.handle(IPC_CHANNELS.chatListSessions, async () => {
const sessions = await gatewayClient.listSessions();
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id));
return sessions;
});
ipcMain.handle(IPC_CHANNELS.chatListMessages, async (_event, sessionId: string) => gatewayClient.listMessages(sessionId));
ipcMain.handle(IPC_CHANNELS.chatSendPrompt, async (_event, sessionId: string, prompt: string, skillId?: string) => {
const executionPolicy = await resolveExecutionPolicy(skillId);
const gatewayPrompt = await prepareGatewayPrompt(prompt, skillId);
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId);
const decision = await projectExecutionRouter.decide({
sessionId,
projectId: sessionState.projectId,
projectRoot: sessionState.projectRoot,
userPrompt: prompt,
context: snapshot,
selectedSkillId
});
const executionSkillId = decision.kind === "skill" ? decision.skillId : undefined;
const executionPolicy = await resolveExecutionPolicy(sessionState.projectId, executionSkillId);
const gatewayPrompt = await prepareGatewayPrompt(decision, sessionState.projectId);
return {
sessionState,
snapshot,
decision,
executionPolicy,
gatewayPrompt
};
};
const listChatMessages = async (sessionId: string): Promise<ChatMessage[]> => ensureLocalTranscript(sessionId);
const sendPrompt = async (sessionId: string, prompt: string, skillId?: string) => {
await projectStore.updateSessionLastActive(sessionId);
const preparedExecution = await prepareProjectAwareExecution(sessionId, prompt, skillId);
const executionSkillId = preparedExecution.decision.kind === "skill" ? preparedExecution.decision.skillId : undefined;
const shouldScheduleContextRefresh = shouldRefreshProjectContextAfterExecution(preparedExecution.decision);
await ensureLocalTranscript(sessionId);
await projectStore.appendSessionMessage(sessionId, createChatMessage("user", prompt));
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, executionSkillId);
try {
const result = await gatewayClient.sendPrompt(sessionId, gatewayPrompt);
runtimeCloudSupervisor.noteMessageSent(result.sessionId, result.reply.content, executionPolicy.modelId, skillId);
return { ...result, executionPolicy };
if (preparedExecution.decision.kind === "workspace-entry") {
const result = await projectWorkspaceExecutor.execute({
sessionId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt: preparedExecution.decision.preparedPrompt
});
await projectStore.appendSessionMessage(sessionId, result.reply);
await projectStore.updateSessionLastActive(sessionId).catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(sessionId, result.reply.content, preparedExecution.executionPolicy.modelId, executionSkillId);
return {
sessionId,
reply: result.reply,
executionPolicy: preparedExecution.executionPolicy
};
}
const result = await gatewayClient.sendPrompt(sessionId, preparedExecution.gatewayPrompt ?? prompt);
await projectStore.appendSessionMessage(result.sessionId, result.reply);
await projectStore.updateSessionLastActive(result.sessionId).catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(result.sessionId, result.reply.content, preparedExecution.executionPolicy.modelId, executionSkillId);
return { ...result, executionPolicy: preparedExecution.executionPolicy };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runtimeCloudSupervisor.noteError("chat_send_failed", message, {
modelId: executionPolicy.modelId,
modelId: preparedExecution.executionPolicy.modelId,
sessionId
});
throw error;
} finally {
if (shouldScheduleContextRefresh) {
void refreshProjectContextAfterExecution({
sessionId,
projectId: preparedExecution.sessionState.projectId,
projectContextService,
projectStore
});
}
}
});
ipcMain.handle(IPC_CHANNELS.chatStreamPrompt, async (event, sessionId: string, prompt: string, skillId?: string) => {
};
const streamPrompt = async (sessionId: string, prompt: string, skillId?: string, sender?: WebContents) => {
const requestId = randomUUID();
let executionPolicy: Awaited<ReturnType<typeof resolveExecutionPolicy>> | null = null;
let executionSkillId: string | undefined;
let refreshProjectId: string | null = null;
let shouldScheduleContextRefresh = false;
let contextRefreshQueued = false;
let settled = false;
let ready = false;
let startedEvent: ChatStreamEvent | null = null;
const queuedEvents: ChatStreamEvent[] = [];
const queueProjectContextRefresh = () => {
if (contextRefreshQueued || !shouldScheduleContextRefresh || !refreshProjectId) {
return;
}
contextRefreshQueued = true;
void refreshProjectContextAfterExecution({
sessionId,
projectId: refreshProjectId,
projectContextService,
projectStore
});
};
const queueOrSend = (payload: ChatStreamEvent) => {
if (!ready) {
if (payload.type === "started") {
......@@ -790,29 +873,120 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
}
return;
}
emitChatStreamEvent(event.sender, payload);
broadcastChatStreamEvent(payload, sender);
};
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId);
const flushQueuedEvents = (fallbackStarted?: ChatStreamEvent) => {
setTimeout(() => {
if (startedEvent) {
broadcastChatStreamEvent(startedEvent, sender);
} else if (fallbackStarted) {
broadcastChatStreamEvent(fallbackStarted, sender);
}
for (const queuedEvent of queuedEvents) {
broadcastChatStreamEvent(queuedEvent, sender);
}
}, 0);
};
await projectStore.updateSessionLastActive(sessionId);
try {
queueOrSend({
type: "status",
requestId,
sessionId,
stage: "prepare-request",
label: skillId ? "\u6b63\u5728\u8c03\u53d6\u76f8\u5173\u6280\u80fd" : "\u6b63\u5728\u7406\u89e3\u4f60\u7684\u95ee\u9898"
label: skillId ? "Preparing project context and skill" : "Preparing project context"
});
executionPolicy = await resolveExecutionPolicy(skillId);
const gatewayPrompt = await prepareGatewayPrompt(prompt, skillId);
const preparedExecution = await prepareProjectAwareExecution(sessionId, prompt, skillId);
executionPolicy = preparedExecution.executionPolicy;
executionSkillId = preparedExecution.decision.kind === "skill" ? preparedExecution.decision.skillId : undefined;
refreshProjectId = preparedExecution.sessionState.projectId;
shouldScheduleContextRefresh = shouldRefreshProjectContextAfterExecution(preparedExecution.decision);
await ensureLocalTranscript(sessionId);
await projectStore.appendSessionMessage(sessionId, createChatMessage("user", prompt));
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, executionSkillId);
queueOrSend({
type: "status",
requestId,
sessionId,
stage: "await-model",
label: "\u5df2\u6536\u5230\u95ee\u9898\uff0c\u6b63\u5728\u7ec4\u7ec7\u56de\u7b54"
label: "Question received, preparing response"
});
const stream = await gatewayClient.streamPrompt(sessionId, gatewayPrompt, {
if (preparedExecution.decision.kind === "workspace-entry") {
ready = true;
flushQueuedEvents();
void (async () => {
try {
const result = await projectWorkspaceExecutor.execute({
sessionId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt: preparedExecution.decision.preparedPrompt
}, {
onStarted: (runId) => {
queueOrSend({
type: "started",
requestId,
sessionId,
runId,
executionPolicy: executionPolicy ?? undefined
});
},
onStatus: (stage, label, detail) => {
queueOrSend({
type: "status",
requestId,
sessionId,
stage,
label,
detail
});
},
onDelta: (textDelta, fullText, runId) => {
queueOrSend({
type: "delta",
requestId,
sessionId,
runId,
textDelta,
fullText
});
}
});
settled = true;
await projectStore.appendSessionMessage(sessionId, result.reply);
await projectStore.updateSessionLastActive(sessionId).catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(sessionId, result.reply.content, executionPolicy?.modelId, executionSkillId);
queueOrSend({
type: "completed",
requestId,
sessionId,
runId: result.runId,
reply: result.reply,
executionPolicy: executionPolicy ?? undefined
});
} catch (error) {
settled = true;
const message = error instanceof Error ? error.message : String(error);
runtimeCloudSupervisor.noteError("chat_stream_failed", message, {
modelId: executionPolicy?.modelId,
sessionId
});
queueOrSend({
type: "error",
requestId,
sessionId,
message
});
} finally {
queueProjectContextRefresh();
}
})();
return {
requestId,
sessionId,
executionPolicy: executionPolicy ?? undefined
};
}
const stream = await gatewayClient.streamPrompt(sessionId, preparedExecution.gatewayPrompt ?? prompt, {
onStarted: ({ sessionId: nextSessionId, runId }) => {
queueOrSend({
type: "started",
......@@ -845,7 +1019,11 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
},
onCompleted: ({ sessionId: nextSessionId, runId, reply }) => {
settled = true;
runtimeCloudSupervisor.noteMessageSent(nextSessionId, reply.content, executionPolicy?.modelId, skillId);
void (async () => {
await projectStore.appendSessionMessage(nextSessionId, reply);
await projectStore.updateSessionLastActive(nextSessionId).catch(() => undefined);
})().catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(nextSessionId, reply.content, executionPolicy?.modelId, executionSkillId);
queueOrSend({
type: "completed",
requestId,
......@@ -854,6 +1032,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
reply,
executionPolicy: executionPolicy ?? undefined
});
queueProjectContextRefresh();
},
onError: ({ sessionId: nextSessionId, runId, error }) => {
settled = true;
......@@ -868,23 +1047,17 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
runId,
message: error.message
});
queueProjectContextRefresh();
}
});
ready = true;
setTimeout(() => {
emitChatStreamEvent(event.sender, startedEvent ?? {
type: "started",
requestId,
sessionId: stream.sessionId,
runId: stream.runId,
executionPolicy: executionPolicy ?? undefined
});
for (const queuedEvent of queuedEvents) {
emitChatStreamEvent(event.sender, queuedEvent);
}
}, 0);
flushQueuedEvents({
type: "started",
requestId,
sessionId: stream.sessionId,
runId: stream.runId,
executionPolicy: executionPolicy ?? undefined
});
return { requestId, sessionId: stream.sessionId, runId: stream.runId, executionPolicy: executionPolicy ?? undefined };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
......@@ -894,8 +1067,113 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
sessionId
});
}
queueProjectContextRefresh();
throw error;
}
};
ipcMain.handle(IPC_CHANNELS.workspaceGetSummary, async () => buildWorkspaceSummary());
ipcMain.handle(IPC_CHANNELS.workspaceWarmup, async () => queueWorkspaceWarmup("workspace-warmup", { action: "init" }));
ipcMain.handle(IPC_CHANNELS.gatewayStatus, async () => gatewayClient.status());
ipcMain.handle(IPC_CHANNELS.gatewayConnect, async () => gatewayClient.connect());
ipcMain.handle(IPC_CHANNELS.gatewayDisconnect, async () => gatewayClient.disconnect());
ipcMain.handle(IPC_CHANNELS.gatewayReconnect, async () => gatewayClient.reconnect());
ipcMain.handle(IPC_CHANNELS.gatewayHealth, async () => gatewayClient.health());
ipcMain.handle(IPC_CHANNELS.gatewayTailLogs, async (_event, limit?: number) => gatewayClient.tailLogs(limit));
ipcMain.handle(IPC_CHANNELS.runtimeGetStatus, async () => runtimeManager.status());
ipcMain.handle(IPC_CHANNELS.runtimeTailLogs, async (_event, limit?: number) => runtimeManager.tailLogs(limit));
ipcMain.handle(IPC_CHANNELS.runtimeStart, async () => startManagedRuntime("runtime-start", { action: "init" }));
ipcMain.handle(IPC_CHANNELS.runtimeStop, async () => {
await runtimeCloudSupervisor.stop("runtime-stop");
const status = await runtimeManager.stop();
await reconfigureGatewayClient();
return status;
});
ipcMain.handle(IPC_CHANNELS.runtimeRestart, async () => startManagedRuntime("runtime-restart", { action: "init", restart: true }));
ipcMain.handle(IPC_CHANNELS.runtimeHealth, async () => runtimeManager.health());
ipcMain.handle(IPC_CHANNELS.runtimeCloudGetStatus, async () => runtimeCloudClient.getStatus());
ipcMain.handle(IPC_CHANNELS.runtimeCloudFetchConfig, async (_event, action) => runtimeCloudClient.fetchConfig(action));
ipcMain.handle(IPC_CHANNELS.runtimeTelemetryGetStatus, async () => runtimeCloudSupervisor.getStatus());
ipcMain.handle(IPC_CHANNELS.configLoad, async () => getEffectiveConfig());
ipcMain.handle(IPC_CHANNELS.configSave, async (_event, input: SaveConfigInput) => {
const previousConfig = await configService.load();
const config = await configService.save(input);
if (typeof input.apiKey === "string") {
await secretManager.setApiKey(input.apiKey || undefined);
}
if (typeof input.gatewayToken === "string") {
await secretManager.setGatewayToken(input.gatewayToken || undefined);
}
if (typeof input.authToken === "string") {
await secretManager.setAuthToken(input.authToken || undefined);
}
if (config.setupMode === "direct-provider" || previousConfig.setupMode !== config.setupMode) {
await runtimeCloudClient.clearCache().catch(() => undefined);
}
await runtimeManager.setRequestedMode(config.runtimeMode);
if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) {
void queueWorkspaceWarmup("config-save", {
action: "init",
config,
inputToken: input.gatewayToken
});
} else {
await runtimeCloudSupervisor.stop("config-save");
await reconfigureGatewayClient(config, input.gatewayToken);
if (config.setupMode === "employee-key") {
await syncRuntimeCloudSupervisor("config-save");
}
}
void dailyReportService.runDueCheck().catch(() => undefined);
return getEffectiveConfig();
});
ipcMain.handle(IPC_CHANNELS.authGetSession, async () => authClient.getSessionSummary());
ipcMain.handle(IPC_CHANNELS.authSignIn, async (_event, input: SignInInput) => {
const session = await authClient.signIn(input.accessToken);
await configService.save({ ...(await getEffectiveConfig()), authToken: input.accessToken });
return session;
});
ipcMain.handle(IPC_CHANNELS.authSignOut, async () => {
const session = await authClient.signOut();
await configService.save({ ...(await getEffectiveConfig()), authToken: "" });
return session;
});
ipcMain.handle(IPC_CHANNELS.profileGetSummary, async () => profileClient.getSummary());
ipcMain.handle(IPC_CHANNELS.creditsGetSummary, async () => creditClient.getSummary());
ipcMain.handle(IPC_CHANNELS.skillsList, async () => skillClient.list());
ipcMain.handle(IPC_CHANNELS.modelConfigGetSummary, async () => modelConfigClient.getSummary());
ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary);
ipcMain.handle(IPC_CHANNELS.projectsList, async () => projectStore.listProjects());
ipcMain.handle(IPC_CHANNELS.projectsSetActive, async (_event, projectId: string) => {
await projectStore.setActiveProject(projectId);
return buildWorkspaceSummary();
});
ipcMain.handle(IPC_CHANNELS.chatListSessions, async () => {
const sessions = await listSessionsForActiveProject(projectStore);
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id));
return sessions;
});
ipcMain.handle(IPC_CHANNELS.chatCreateSession, async (_event, title?: string) => {
const session = await createSessionForActiveProject(projectStore, title);
runtimeCloudSupervisor.noteSessions([session.id]);
return session;
});
ipcMain.handle(IPC_CHANNELS.chatCloseSession, async (_event, sessionId: string) => {
const sessions = await projectStore.closeSession(sessionId);
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id));
return sessions;
});
ipcMain.handle(IPC_CHANNELS.chatListMessages, async (_event, sessionId: string) => listChatMessages(sessionId));
ipcMain.handle(IPC_CHANNELS.chatSendPrompt, async (_event, sessionId: string, prompt: string, skillId?: string) => {
return sendPrompt(sessionId, prompt, skillId);
});
ipcMain.handle(IPC_CHANNELS.chatStreamPrompt, async (event, sessionId: string, prompt: string, skillId?: string) => {
return streamPrompt(sessionId, prompt, skillId, event.sender);
});
ipcMain.handle(IPC_CHANNELS.diagnosticsOpenControlUi, async () => {
const config = await getEffectiveConfig();
......@@ -966,6 +1244,13 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
return getEffectiveConfig();
}
},
projects: {
list: () => projectStore.listProjects(),
setActive: async (projectId: string) => {
await projectStore.setActiveProject(projectId);
return buildWorkspaceSummary();
}
},
auth: {
getSessionSummary: () => authClient.getSessionSummary(),
signIn: (input: SignInInput) => authClient.signIn(input.accessToken),
......@@ -988,40 +1273,29 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
},
chat: {
listSessions: async () => {
const sessions = await gatewayClient.listSessions();
const sessions = await listSessionsForActiveProject(projectStore);
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id));
return sessions;
},
listMessages: (sessionId: string) => gatewayClient.listMessages(sessionId),
sendPrompt: async (sessionId: string, prompt: string, skillId?: string) => {
const executionPolicy = await resolveExecutionPolicy(skillId);
const gatewayPrompt = await prepareGatewayPrompt(prompt, skillId);
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId);
try {
const result = await gatewayClient.sendPrompt(sessionId, gatewayPrompt);
runtimeCloudSupervisor.noteMessageSent(result.sessionId, result.reply.content, executionPolicy.modelId, skillId);
return { ...result, executionPolicy };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runtimeCloudSupervisor.noteError("chat_send_failed", message, {
modelId: executionPolicy.modelId,
sessionId
});
throw error;
}
createSession: async (title?: string) => {
const session = await createSessionForActiveProject(projectStore, title);
runtimeCloudSupervisor.noteSessions([session.id]);
return session;
},
streamPrompt: async (sessionId: string, prompt: string, skillId?: string) => {
const executionPolicy = await resolveExecutionPolicy(skillId);
const gatewayPrompt = await prepareGatewayPrompt(prompt, skillId);
const stream = await gatewayClient.streamPrompt(sessionId, gatewayPrompt);
return {
requestId: randomUUID(),
sessionId: stream.sessionId,
runId: stream.runId,
executionPolicy
};
closeSession: async (sessionId: string) => {
const sessions = await projectStore.closeSession(sessionId);
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id));
return sessions;
},
onStreamEvent: () => () => undefined
listMessages: (sessionId: string) => listChatMessages(sessionId),
sendPrompt: async (sessionId: string, prompt: string, skillId?: string) => sendPrompt(sessionId, prompt, skillId),
streamPrompt: async (sessionId: string, prompt: string, skillId?: string) => streamPrompt(sessionId, prompt, skillId),
onStreamEvent: (listener) => {
streamListeners.add(listener);
return () => {
streamListeners.delete(listener);
};
}
},
diagnostics: {
openControlUi: async () => {
......@@ -1038,4 +1312,3 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
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 {
const payload = action === "init" && this.payloadCache
? this.payloadCache
: 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[] {
return toRemoteSkillAssets(this.payloadCache);
}
......@@ -994,3 +996,4 @@ export class ModelConfigClient {
import { createHash } from "node:crypto";
import { promisify } from "node:util";
import { execFile } from "node:child_process";
import http from "node:http";
import https from "node:https";
import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import type { RuntimeCloudFetchAction } from "@qjclaw/shared-types";
import type { AppConfigService } from "./app-config.js";
import type { ProjectStoreService } from "./project-store.js";
import type { RemoteSkillAsset } from "./skill-store.js";
const execFileAsync = promisify(execFile);
interface BundleManifestRecord {
sourceUrl: string;
fileName: string;
configVersion?: string;
updatedAt: string;
projectId: string;
checksum?: string;
sourceSkillId?: string;
sharedSkillEntries?: string[];
sharedCronEntries?: string[];
remoteFileSize?: number;
remoteContentLength?: number;
remoteEtag?: string;
remoteLastModified?: string;
}
interface BundleProjectMetadata {
projectId: string;
projectName: string;
version?: string;
description?: string;
projectSourceRoot: string;
sharedSourceRoot: string;
}
interface ProjectJsonShape {
id?: string;
name?: string;
version?: string;
description?: string;
}
interface BundleReplacementOperation {
stagedPath: string;
targetPath: string;
}
interface CommittedBundleReplacementOperation extends BundleReplacementOperation {
backupPath?: string;
targetExisted: boolean;
applied: boolean;
}
interface MaterializedBundleTransaction {
skillIds: string[];
sharedSkillEntries: string[];
sharedCronEntries: string[];
finalize(): Promise<void>;
rollback(): Promise<void>;
}
interface StagedSharedEntries {
entryNames: string[];
directoryIds: string[];
operations: BundleReplacementOperation[];
}
interface RemoteBundleProbeResult {
etag?: string;
lastModified?: string;
contentLength?: number;
}
const MANIFEST_FILE = "project-bundles.json";
const MANIFESTS_DIR = "manifests";
const TEMP_DIR = ".bundle-tmp";
const REDIRECT_STATUS_CODES = new Set([301, 302, 307, 308]);
const HEAD_UNSUPPORTED_STATUS_CODES = new Set([403, 404, 405, 501]);
const MAX_REDIRECTS = 5;
function nowIso(): string {
return new Date().toISOString();
}
function normalizeBundleName(fileName: string): string {
return fileName.replace(/\.zip$/i, "").trim() || "project-bundle";
}
function slugify(value: string): string {
const ascii = value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
if (ascii) {
return ascii;
}
return `project-${createHash("sha1").update(value).digest("hex").slice(0, 10)}`;
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}
async function readJsonFile<T>(filePath: string): Promise<T | null> {
try {
const raw = await readFile(filePath, "utf8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function writeJsonFile(filePath: string, payload: unknown): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.tmp-${Date.now()}`;
await writeFile(tempPath, JSON.stringify(payload, null, 2), "utf8");
await rm(filePath, { force: true }).catch(() => undefined);
await rename(tempPath, filePath);
}
function resolveDirectChildPath(rootPath: string, entryName: string): string {
const trimmed = entryName.trim();
if (!trimmed || path.basename(trimmed) !== trimmed) {
throw new Error(`Invalid managed entry name: ${entryName}`);
}
const resolvedRoot = path.resolve(rootPath);
const targetPath = path.resolve(resolvedRoot, trimmed);
const rootPrefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`;
if (targetPath !== resolvedRoot && !targetPath.startsWith(rootPrefix)) {
throw new Error(`Managed entry path escaped root: ${entryName}`);
}
return targetPath;
}
function uniqueStrings(values: Iterable<string>): string[] {
return [...new Set([...values].map((value) => value.trim()).filter(Boolean))].sort((left, right) => left.localeCompare(right, "en"));
}
function normalizeHeaderValue(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) {
return value[0]?.trim() || undefined;
}
return value?.trim() || undefined;
}
function normalizeContentLength(value: string | undefined): number | undefined {
if (!value) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
}
export class ProjectBundleService {
private readonly configService: AppConfigService;
private readonly projectStore: ProjectStoreService;
constructor(configService: AppConfigService, projectStore: ProjectStoreService) {
this.configService = configService;
this.projectStore = projectStore;
}
async syncRemoteBundles(remoteSkills: RemoteSkillAsset[], configVersion?: string, _action?: RuntimeCloudFetchAction): Promise<void> {
const bundleAssets = remoteSkills.filter((asset) => asset.downloadUrl && asset.fileName && /\.zip$/i.test(asset.fileName));
const workspaceRoot = await this.projectStore.getWorkspaceRoot();
const manifestPath = path.join(workspaceRoot, MANIFESTS_DIR, MANIFEST_FILE);
const currentManifest = (await readJsonFile<Record<string, BundleManifestRecord>>(manifestPath)) ?? {};
const nextManifest: Record<string, BundleManifestRecord> = {};
const seenBundleKeys = new Set<string>();
for (const asset of bundleAssets) {
if (!asset.downloadUrl || !asset.fileName) {
continue;
}
const bundleKey = this.getBundleAssetKey(asset);
if (seenBundleKeys.has(bundleKey)) {
continue;
}
seenBundleKeys.add(bundleKey);
const currentRecord = this.findManifestRecordForAsset(currentManifest, asset);
const nextRecord = await this.resolveNextManifestRecord(workspaceRoot, asset, configVersion, currentRecord);
if (nextManifest[nextRecord.projectId]) {
throw new Error(`Project bundle sync resolved duplicate projectId ${nextRecord.projectId}.`);
}
nextManifest[nextRecord.projectId] = nextRecord;
}
await this.cleanupRemovedBundleState(workspaceRoot, currentManifest, nextManifest);
await writeJsonFile(manifestPath, nextManifest);
}
private getBundleAssetKey(asset: Pick<RemoteSkillAsset, "skillId" | "downloadUrl">): string {
return `${asset.skillId.trim().toLowerCase()}::${asset.downloadUrl?.trim() ?? ""}`;
}
private getBundleRecordKey(record: BundleManifestRecord): string {
return `${record.sourceSkillId?.trim().toLowerCase() ?? ""}::${record.sourceUrl.trim()}`;
}
private findManifestRecordForAsset(
manifest: Record<string, BundleManifestRecord>,
asset: Pick<RemoteSkillAsset, "skillId" | "downloadUrl">
): BundleManifestRecord | undefined {
const expectedKey = this.getBundleAssetKey(asset);
const records = Object.values(manifest);
return records.find((record) => this.getBundleRecordKey(record) === expectedKey)
?? records.find((record) => record.sourceUrl === asset.downloadUrl);
}
private async resolveNextManifestRecord(
workspaceRoot: string,
asset: RemoteSkillAsset,
configVersion: string | undefined,
currentRecord: BundleManifestRecord | undefined
): Promise<BundleManifestRecord> {
if (!this.canReuseManifestRecord(currentRecord, asset, configVersion)) {
return this.downloadAndInstallBundle(workspaceRoot, asset, configVersion);
}
const freshnessProbe = await this.probeRemoteBundle(new URL(asset.downloadUrl!));
if (this.shouldRedownloadBundle(currentRecord, asset, freshnessProbe)) {
return this.downloadAndInstallBundle(workspaceRoot, asset, configVersion, freshnessProbe);
}
return this.updateManifestRecordFromProbe(currentRecord, asset, configVersion, freshnessProbe);
}
private canReuseManifestRecord(
record: BundleManifestRecord | undefined,
asset: RemoteSkillAsset,
configVersion?: string
): record is BundleManifestRecord {
return Boolean(
record
&& record.sourceUrl === asset.downloadUrl
&& record.fileName === asset.fileName
&& record.configVersion === configVersion
&& record.checksum
&& Array.isArray(record.sharedSkillEntries)
&& Array.isArray(record.sharedCronEntries)
);
}
private shouldRedownloadBundle(
record: BundleManifestRecord,
asset: RemoteSkillAsset,
freshnessProbe: RemoteBundleProbeResult | null
): boolean {
if (typeof asset.fileSize === "number" && typeof record.remoteFileSize === "number" && asset.fileSize !== record.remoteFileSize) {
return true;
}
const probeHasFreshnessIdentity = Boolean(
freshnessProbe?.etag
|| freshnessProbe?.lastModified
|| typeof freshnessProbe?.contentLength === "number"
);
const recordHasFreshnessIdentity = Boolean(
record.remoteEtag
|| record.remoteLastModified
|| typeof record.remoteContentLength === "number"
);
if (probeHasFreshnessIdentity && !recordHasFreshnessIdentity) {
return true;
}
if (freshnessProbe?.etag && record.remoteEtag && freshnessProbe.etag !== record.remoteEtag) {
return true;
}
if (freshnessProbe?.lastModified && record.remoteLastModified && freshnessProbe.lastModified !== record.remoteLastModified) {
return true;
}
if (typeof freshnessProbe?.contentLength === "number" && typeof record.remoteContentLength === "number" && freshnessProbe.contentLength !== record.remoteContentLength) {
return true;
}
return false;
}
private updateManifestRecordFromProbe(
record: BundleManifestRecord,
asset: RemoteSkillAsset,
configVersion: string | undefined,
freshnessProbe: RemoteBundleProbeResult | null
): BundleManifestRecord {
return {
...record,
sourceUrl: asset.downloadUrl!,
fileName: asset.fileName!,
sourceSkillId: asset.skillId,
configVersion,
updatedAt: nowIso(),
remoteFileSize: asset.fileSize ?? record.remoteFileSize,
remoteContentLength: freshnessProbe?.contentLength ?? record.remoteContentLength,
remoteEtag: freshnessProbe?.etag ?? record.remoteEtag,
remoteLastModified: freshnessProbe?.lastModified ?? record.remoteLastModified
};
}
private async cleanupRemovedBundleState(
workspaceRoot: string,
currentManifest: Record<string, BundleManifestRecord>,
nextManifest: Record<string, BundleManifestRecord>
): Promise<void> {
const currentRecords = Object.values(currentManifest);
if (currentRecords.length === 0) {
return;
}
const nextRecords = Object.values(nextManifest);
const expectedProjectIds = new Set(nextRecords.map((record) => record.projectId));
const expectedSkillEntries = new Set(nextRecords.flatMap((record) => record.sharedSkillEntries ?? []));
const expectedCronEntries = new Set(nextRecords.flatMap((record) => record.sharedCronEntries ?? []));
for (const projectId of uniqueStrings(currentRecords.map((record) => record.projectId))) {
if (!expectedProjectIds.has(projectId)) {
await this.projectStore.removeProject(projectId);
}
}
await this.cleanupManagedEntries(
path.join(workspaceRoot, "skills"),
uniqueStrings(currentRecords.flatMap((record) => record.sharedSkillEntries ?? [])),
expectedSkillEntries
);
await this.cleanupManagedEntries(
path.join(workspaceRoot, "cron"),
uniqueStrings(currentRecords.flatMap((record) => record.sharedCronEntries ?? [])),
expectedCronEntries
);
}
private async cleanupManagedEntries(rootPath: string, currentEntries: string[], expectedEntries: Set<string>): Promise<void> {
for (const entryName of currentEntries) {
if (expectedEntries.has(entryName)) {
continue;
}
const targetPath = resolveDirectChildPath(rootPath, entryName);
await rm(targetPath, { recursive: true, force: true }).catch(() => undefined);
}
}
private async downloadAndInstallBundle(
workspaceRoot: string,
asset: RemoteSkillAsset,
configVersion?: string,
freshnessProbe?: RemoteBundleProbeResult | null
): Promise<BundleManifestRecord> {
const tempRoot = path.join(workspaceRoot, TEMP_DIR, `${Date.now()}-${asset.skillId}`);
const zipPath = path.join(tempRoot, asset.fileName ?? `${asset.skillId}.zip`);
const extractPath = path.join(tempRoot, "unzipped");
await mkdir(extractPath, { recursive: true });
try {
const resolvedFreshnessProbe = freshnessProbe === undefined
? await this.probeRemoteBundle(new URL(asset.downloadUrl!))
: freshnessProbe;
const payload = await this.downloadToBuffer(new URL(asset.downloadUrl!));
await mkdir(path.dirname(zipPath), { recursive: true });
await writeFile(zipPath, payload);
await this.extractZip(zipPath, extractPath);
const contentRoot = await this.resolveArchiveContentRoot(extractPath);
const metadata = await this.resolveBundleMetadata(contentRoot, asset, configVersion);
const materialized = await this.materializeBundle(workspaceRoot, tempRoot, metadata);
try {
await this.projectStore.syncBundleProject({
projectId: metadata.projectId,
projectName: metadata.projectName,
version: metadata.version ?? configVersion,
boundSkillIds: materialized.skillIds,
description: metadata.description ?? asset.description,
ready: true
});
} catch (error) {
await materialized.rollback().catch(() => undefined);
throw error;
}
await materialized.finalize().catch(() => undefined);
return {
sourceUrl: asset.downloadUrl!,
fileName: asset.fileName ?? `${asset.skillId}.zip`,
configVersion,
updatedAt: nowIso(),
projectId: metadata.projectId,
checksum: createHash("sha1").update(payload).digest("hex"),
sourceSkillId: asset.skillId,
sharedSkillEntries: materialized.sharedSkillEntries,
sharedCronEntries: materialized.sharedCronEntries,
remoteFileSize: asset.fileSize ?? payload.length,
remoteContentLength: resolvedFreshnessProbe?.contentLength ?? payload.length,
remoteEtag: resolvedFreshnessProbe?.etag,
remoteLastModified: resolvedFreshnessProbe?.lastModified
};
} finally {
await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined);
}
}
private async resolveBundleMetadata(sourceRoot: string, asset: RemoteSkillAsset, configVersion?: string): Promise<BundleProjectMetadata> {
const sourceProjectsDir = path.join(sourceRoot, "projects");
const bundleName = normalizeBundleName(asset.fileName ?? asset.name);
if (await pathExists(sourceProjectsDir)) {
const projectEntries = (await readdir(sourceProjectsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory());
if (projectEntries.length !== 1) {
throw new Error(`Project bundle must contain exactly one project directory under projects/, found ${projectEntries.length}.`);
}
const projectEntry = projectEntries[0];
const projectSourceRoot = path.join(sourceProjectsDir, projectEntry.name);
const projectJson = await this.readProjectJson(path.join(projectSourceRoot, "project.json"));
const projectId = projectJson?.id?.trim() || projectEntry.name.trim() || slugify(bundleName);
const projectName = projectJson?.name?.trim() || projectEntry.name.trim() || bundleName;
return {
projectId,
projectName,
version: projectJson?.version?.trim() || configVersion,
description: projectJson?.description?.trim() || asset.description,
projectSourceRoot,
sharedSourceRoot: sourceRoot
};
}
const rootProjectJson = await this.readProjectJson(path.join(sourceRoot, "project.json"));
const projectId = rootProjectJson?.id?.trim() || slugify(bundleName);
const projectName = rootProjectJson?.name?.trim() || bundleName;
return {
projectId,
projectName,
version: rootProjectJson?.version?.trim() || configVersion,
description: rootProjectJson?.description?.trim() || asset.description,
projectSourceRoot: sourceRoot,
sharedSourceRoot: sourceRoot
};
}
private async materializeBundle(
workspaceRoot: string,
tempRoot: string,
metadata: BundleProjectMetadata
): Promise<MaterializedBundleTransaction> {
const projectsRoot = path.join(workspaceRoot, "projects");
const skillsRoot = path.join(workspaceRoot, "skills");
const cronRoot = path.join(workspaceRoot, "cron");
await Promise.all([
mkdir(projectsRoot, { recursive: true }),
mkdir(skillsRoot, { recursive: true }),
mkdir(cronRoot, { recursive: true })
]);
const stagingRoot = path.join(tempRoot, "materialized");
const stagedProjectRoot = path.join(stagingRoot, "projects", metadata.projectId);
await this.stageProjectDirectory(metadata, stagedProjectRoot);
const stagedSkills = await this.stageSharedDirectories(
this.resolveSharedSubdir(metadata, "skills"),
path.join(stagingRoot, "skills"),
skillsRoot
);
const stagedCron = await this.stageSharedDirectories(
this.resolveSharedSubdir(metadata, "cron"),
path.join(stagingRoot, "cron"),
cronRoot
);
const replacementOperations: BundleReplacementOperation[] = [
...stagedSkills.operations,
...stagedCron.operations,
this.buildReplacementOperation(stagedProjectRoot, path.join(projectsRoot, metadata.projectId))
];
const backupRoot = path.join(tempRoot, "replacement-backups");
const committedOperations: CommittedBundleReplacementOperation[] = [];
let settled = false;
try {
for (const operation of replacementOperations) {
const committedOperation: CommittedBundleReplacementOperation = {
...operation,
targetExisted: await pathExists(operation.targetPath),
backupPath: undefined,
applied: false
};
if (committedOperation.targetExisted) {
committedOperation.backupPath = this.buildReplacementBackupPath(backupRoot, operation.targetPath);
await mkdir(path.dirname(committedOperation.backupPath), { recursive: true });
await rm(committedOperation.backupPath, { recursive: true, force: true }).catch(() => undefined);
await rename(operation.targetPath, committedOperation.backupPath);
}
committedOperations.push(committedOperation);
await mkdir(path.dirname(operation.targetPath), { recursive: true });
await rename(operation.stagedPath, operation.targetPath);
committedOperation.applied = true;
}
} catch (error) {
await this.rollbackReplacementOperations(committedOperations).catch(() => undefined);
throw error;
}
return {
skillIds: stagedSkills.directoryIds,
sharedSkillEntries: stagedSkills.entryNames,
sharedCronEntries: stagedCron.entryNames,
finalize: async () => {
if (settled) {
return;
}
settled = true;
await this.cleanupReplacementBackups(committedOperations);
},
rollback: async () => {
if (settled) {
return;
}
settled = true;
await this.rollbackReplacementOperations(committedOperations);
}
};
}
private resolveSharedSubdir(metadata: BundleProjectMetadata, dirname: "skills" | "cron"): string | null {
const rootDir = path.join(metadata.sharedSourceRoot, dirname);
const projectDir = path.join(metadata.projectSourceRoot, dirname);
if (metadata.sharedSourceRoot !== metadata.projectSourceRoot && rootDir !== projectDir) {
return rootDir;
}
return projectDir;
}
private async stageProjectDirectory(metadata: BundleProjectMetadata, stagedProjectRoot: string): Promise<void> {
await rm(stagedProjectRoot, { recursive: true, force: true }).catch(() => undefined);
await mkdir(path.dirname(stagedProjectRoot), { recursive: true });
await cp(metadata.projectSourceRoot, stagedProjectRoot, { recursive: true, force: true });
const projectJsonPath = path.join(stagedProjectRoot, "project.json");
const existing = await this.readProjectJson(projectJsonPath);
await writeJsonFile(projectJsonPath, {
id: metadata.projectId,
name: metadata.projectName,
version: metadata.version ?? existing?.version,
description: metadata.description ?? existing?.description,
updatedAt: nowIso(),
ready: true,
boundSkillIds: []
});
await mkdir(path.join(stagedProjectRoot, "memory"), { recursive: true });
}
private async stageSharedDirectories(sourceDir: string | null, stagingDir: string, targetRoot: string): Promise<StagedSharedEntries> {
if (!sourceDir || !(await pathExists(sourceDir))) {
return {
entryNames: [],
directoryIds: [],
operations: []
};
}
const entries = await readdir(sourceDir, { withFileTypes: true });
const entryNames: string[] = [];
const directoryIds: string[] = [];
const operations: BundleReplacementOperation[] = [];
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isFile()) {
continue;
}
const stagedEntryPath = path.join(stagingDir, entry.name);
await mkdir(path.dirname(stagedEntryPath), { recursive: true });
await rm(stagedEntryPath, { recursive: true, force: true }).catch(() => undefined);
await cp(path.join(sourceDir, entry.name), stagedEntryPath, { recursive: true, force: true });
operations.push(this.buildReplacementOperation(stagedEntryPath, path.join(targetRoot, entry.name)));
entryNames.push(entry.name);
if (entry.isDirectory()) {
directoryIds.push(entry.name);
}
}
return {
entryNames: uniqueStrings(entryNames),
directoryIds: uniqueStrings(directoryIds),
operations
};
}
private buildReplacementOperation(stagedPath: string, targetPath: string): BundleReplacementOperation {
return {
stagedPath,
targetPath
};
}
private buildReplacementBackupPath(backupRoot: string, targetPath: string): string {
const hashedTarget = createHash("sha1").update(targetPath).digest("hex");
return path.join(backupRoot, `${path.basename(targetPath)}-${hashedTarget}`);
}
private async rollbackReplacementOperations(operations: CommittedBundleReplacementOperation[]): Promise<void> {
for (const operation of [...operations].reverse()) {
if (operation.applied) {
await rm(operation.targetPath, { recursive: true, force: true }).catch(() => undefined);
}
if (operation.backupPath && await pathExists(operation.backupPath)) {
await mkdir(path.dirname(operation.targetPath), { recursive: true });
await rm(operation.targetPath, { recursive: true, force: true }).catch(() => undefined);
await rename(operation.backupPath, operation.targetPath);
}
}
}
private async cleanupReplacementBackups(operations: CommittedBundleReplacementOperation[]): Promise<void> {
const backupPaths = uniqueStrings(operations.map((operation) => operation.backupPath ?? ""));
for (const backupPath of backupPaths) {
await rm(backupPath, { recursive: true, force: true }).catch(() => undefined);
}
}
private async readProjectJson(filePath: string): Promise<ProjectJsonShape | null> {
return readJsonFile<ProjectJsonShape>(filePath);
}
private async resolveArchiveContentRoot(extractPath: string): Promise<string> {
const entries = await readdir(extractPath, { withFileTypes: true });
const directories = entries.filter((entry) => entry.isDirectory());
const files = entries.filter((entry) => entry.isFile());
if (directories.length === 1 && files.length === 0) {
const singleDirectory = directories[0].name;
if (singleDirectory === "projects" || singleDirectory === "skills" || singleDirectory === "cron") {
return extractPath;
}
return path.join(extractPath, singleDirectory);
}
return extractPath;
}
private async extractZip(zipPath: string, destinationPath: string): Promise<void> {
await mkdir(destinationPath, { recursive: true });
const escapedZipPath = zipPath.replace(/'/g, "''");
const escapedDestinationPath = destinationPath.replace(/'/g, "''");
await execFileAsync("powershell.exe", [
"-NoProfile",
"-Command",
`Expand-Archive -LiteralPath '${escapedZipPath}' -DestinationPath '${escapedDestinationPath}' -Force`
]);
}
private async probeRemoteBundle(url: URL, redirectCount = 0): Promise<RemoteBundleProbeResult | null> {
const client = url.protocol === "https:" ? https : http;
return new Promise<RemoteBundleProbeResult | null>((resolve, reject) => {
const request = client.request(url, { method: "HEAD" }, (response) => {
const status = response.statusCode ?? 500;
const location = response.headers.location;
if (location && REDIRECT_STATUS_CODES.has(status)) {
if (redirectCount >= MAX_REDIRECTS) {
reject(new Error("Project bundle probe redirected too many times."));
response.resume();
return;
}
const redirectUrl = new URL(location, url);
response.resume();
void this.probeRemoteBundle(redirectUrl, redirectCount + 1).then(resolve, reject);
return;
}
if (HEAD_UNSUPPORTED_STATUS_CODES.has(status)) {
response.resume();
resolve(null);
return;
}
if (status < 200 || status >= 300) {
reject(new Error(`Project bundle freshness probe failed with HTTP status ${status}.`));
response.resume();
return;
}
const etag = normalizeHeaderValue(response.headers.etag);
const lastModified = normalizeHeaderValue(response.headers["last-modified"]);
const contentLength = normalizeContentLength(normalizeHeaderValue(response.headers["content-length"]));
response.resume();
resolve({
etag,
lastModified,
contentLength
});
});
request.on("error", (error) => reject(new Error(`Project bundle freshness probe failed: ${error.message}`)));
request.end();
});
}
private async downloadToBuffer(url: URL, redirectCount = 0): Promise<Buffer> {
const client = url.protocol === "https:" ? https : http;
return new Promise<Buffer>((resolve, reject) => {
const request = client.get(url, (response) => {
const status = response.statusCode ?? 500;
const location = response.headers.location;
if (location && REDIRECT_STATUS_CODES.has(status)) {
if (redirectCount >= MAX_REDIRECTS) {
reject(new Error("Project bundle download redirected too many times."));
response.resume();
return;
}
const redirectUrl = new URL(location, url);
response.resume();
void this.downloadToBuffer(redirectUrl, redirectCount + 1).then(resolve, reject);
return;
}
if (status < 200 || status >= 300) {
reject(new Error(`Project bundle download failed with HTTP status ${status}.`));
response.resume();
return;
}
const chunks: Buffer[] = [];
response.on("data", (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on("end", () => resolve(Buffer.concat(chunks)));
});
request.on("error", (error) => reject(new Error(`Project bundle download failed: ${error.message}`)));
});
}
}
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);
}
import { randomUUID, createHash } from "node:crypto";
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import type {
ChatMessage,
ProjectSessionState,
ProjectSessionSummary,
ProjectSummary,
WorkspaceSkillSummary
} from "@qjclaw/shared-types";
import type { AppConfigService } from "./app-config.js";
import type { SkillExecutionTarget } from "./skill-store.js";
interface StoredProjectRecord {
id: string;
name: string;
description?: string;
version?: string;
ready?: boolean;
boundSkillIds?: string[];
updatedAt: string;
}
interface StoredSessionRecord {
id: string;
title: string;
createdAt?: string;
updatedAt: string;
lastActiveAt?: string;
contextSnapshotId?: string | null;
contextLoadedAt?: string | null;
selectedSkillId?: string | null;
draft?: string;
}
interface ActiveProjectRecord {
projectId: string;
}
interface ProjectSeed {
id: string;
name: string;
description?: string;
version?: string;
boundSkillIds?: string[];
ready?: boolean;
}
const PROJECTS_DIR = "projects";
const SKILLS_DIR = "skills";
const CRON_DIR = "cron";
const MANIFESTS_DIR = "manifests";
const ACTIVE_PROJECT_FILE = "active-project.json";
const PROJECT_FILE = "project.json";
const SESSIONS_FILE = "sessions.json";
const SESSION_MESSAGES_DIR = "session-messages";
function nowIso(): string {
return new Date().toISOString();
}
function slugify(value: string): string {
const ascii = value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
if (ascii) {
return ascii;
}
const digest = createHash("sha1").update(value).digest("hex").slice(0, 10);
return `project-${digest}`;
}
function sanitizeSkillId(value: string): string {
const trimmed = value.trim();
return trimmed || `skill-${createHash("sha1").update(value).digest("hex").slice(0, 8)}`;
}
function sortProjects(items: ProjectSummary[]): ProjectSummary[] {
return [...items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || left.name.localeCompare(right.name, "zh-CN"));
}
function sortSessionStates(items: ProjectSessionState[]): ProjectSessionState[] {
return [...items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
function compareSkills(left: WorkspaceSkillSummary, right: WorkspaceSkillSummary): number {
return left.name.localeCompare(right.name, "zh-CN");
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}
async function readJsonFile<T>(filePath: string): Promise<T | null> {
try {
const raw = await readFile(filePath, "utf8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function writeJsonFile(filePath: string, payload: unknown): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.tmp-${Date.now()}`;
await writeFile(tempPath, JSON.stringify(payload, null, 2), "utf8");
await rm(filePath, { force: true }).catch(() => undefined);
await rename(tempPath, filePath);
}
export class ProjectStoreService {
private readonly configService: AppConfigService;
constructor(configService: AppConfigService) {
this.configService = configService;
}
async initialize(): Promise<void> {
const workspaceRoot = await this.getWorkspaceRoot();
await Promise.all([
mkdir(path.join(workspaceRoot, PROJECTS_DIR), { recursive: true }),
mkdir(path.join(workspaceRoot, SKILLS_DIR), { recursive: true }),
mkdir(path.join(workspaceRoot, CRON_DIR), { recursive: true }),
mkdir(path.join(workspaceRoot, MANIFESTS_DIR), { recursive: true })
]);
const projects = await this.readProjects();
if (projects.length === 0) {
await writeJsonFile(await this.getActiveProjectFilePath(), {});
return;
}
const activeRecord = await readJsonFile<ActiveProjectRecord>(await this.getActiveProjectFilePath());
if (!activeRecord?.projectId || !projects.some((project) => project.id === activeRecord.projectId)) {
await writeJsonFile(await this.getActiveProjectFilePath(), { projectId: projects[0].id });
}
}
async getWorkspaceRoot(): Promise<string> {
const config = await this.configService.load();
const configured = config.workspacePath.trim();
const workspaceRoot = configured || this.configService.getDataPath("workspace");
await mkdir(workspaceRoot, { recursive: true });
return workspaceRoot;
}
async listProjects(): Promise<ProjectSummary[]> {
await this.initialize();
return sortProjects(await this.readProjects());
}
async getActiveProject(): Promise<ProjectSummary> {
await this.initialize();
const projects = await this.readProjects();
const activeRecord = await readJsonFile<ActiveProjectRecord>(await this.getActiveProjectFilePath());
const activeProject = projects.find((item) => item.id === activeRecord?.projectId) ?? projects[0];
if (!activeProject) {
throw new Error("No available projects.");
}
if (activeRecord?.projectId !== activeProject.id) {
await this.setActiveProject(activeProject.id);
}
return activeProject;
}
async setActiveProject(projectId: string): Promise<ProjectSummary> {
await this.initialize();
const projects = await this.readProjects();
const project = projects.find((item) => item.id === projectId);
if (!project) {
throw new Error(`Project ${projectId} was not found.`);
}
await writeJsonFile(await this.getActiveProjectFilePath(), { projectId });
return project;
}
async getProjectRoot(projectId: string): Promise<string> {
const project = await this.getProjectById(projectId);
return this.getProjectDir(project.id);
}
async getProjectSummary(projectId: string): Promise<ProjectSummary> {
return this.getProjectById(projectId);
}
async upsertProject(seed: ProjectSeed): Promise<ProjectSummary> {
await this.initialize();
const projectDir = await this.getProjectDir(seed.id);
await mkdir(projectDir, { recursive: true });
const existing = await readJsonFile<StoredProjectRecord>(path.join(projectDir, PROJECT_FILE));
const record: StoredProjectRecord = {
id: seed.id,
name: seed.name,
description: seed.description ?? existing?.description,
version: seed.version ?? existing?.version,
ready: seed.ready ?? existing?.ready ?? true,
boundSkillIds: [...new Set((seed.boundSkillIds ?? existing?.boundSkillIds ?? []).map((skillId) => sanitizeSkillId(skillId)))],
updatedAt: nowIso()
};
await writeJsonFile(path.join(projectDir, PROJECT_FILE), record);
await mkdir(path.join(projectDir, "memory"), { recursive: true });
await this.ensureProjectSessionStates(seed.id);
return this.toProjectSummary(record);
}
async updateProjectSkills(projectId: string, skillIds: string[]): Promise<ProjectSummary> {
const projectDir = await this.getProjectDir(projectId);
const existing = await readJsonFile<StoredProjectRecord>(path.join(projectDir, PROJECT_FILE));
if (!existing) {
throw new Error(`Project ${projectId} was not found.`);
}
return this.upsertProject({
id: existing.id,
name: existing.name,
description: existing.description,
version: existing.version,
ready: existing.ready,
boundSkillIds: skillIds
});
}
async listSessions(projectId?: string): Promise<ProjectSessionSummary[]> {
const targetProjectId = projectId ?? (await this.getActiveProject()).id;
const sessions = await this.ensureProjectSessionStates(targetProjectId);
return sortSessionStates(sessions).map((session) => this.toProjectSessionSummary(session));
}
async createSession(title?: string, projectId?: string): Promise<ProjectSessionSummary> {
const activeProject = projectId ? (await this.getProjectById(projectId)) : await this.getActiveProject();
const projectRoot = await this.getProjectDir(activeProject.id);
const sessions = await this.ensureProjectSessionStates(activeProject.id);
const timestamp = nowIso();
const session = this.toProjectSessionState(activeProject.id, projectRoot, {
id: `project:${activeProject.id}:${randomUUID()}`,
title: title?.trim() || `New Session ${sessions.length + 1}`,
createdAt: timestamp,
updatedAt: timestamp,
lastActiveAt: timestamp,
contextSnapshotId: null,
contextLoadedAt: null,
selectedSkillId: null,
draft: ""
});
const nextSessions = sortSessionStates([session, ...sessions]);
await this.writeSessions(activeProject.id, nextSessions);
return this.toProjectSessionSummary(session);
}
async closeSession(sessionId: string): Promise<ProjectSessionSummary[]> {
const projectId = await this.resolveProjectIdForSession(sessionId);
const sessions = await this.ensureProjectSessionStates(projectId);
const remaining = sessions.filter((session) => session.sessionId !== sessionId);
if (remaining.length > 0) {
await this.writeSessions(projectId, remaining);
return sortSessionStates(remaining).map((session) => this.toProjectSessionSummary(session));
}
const created = await this.createSession(undefined, projectId);
return [created];
}
async touchSession(sessionId: string, title?: string): Promise<void> {
await this.updateSessionState(sessionId, (session) => ({
...session,
title: title?.trim() || session.title,
updatedAt: nowIso(),
lastActiveAt: nowIso()
}));
}
async updateSessionLastActive(sessionId: string): Promise<void> {
await this.updateSessionState(sessionId, (session) => ({
...session,
updatedAt: nowIso(),
lastActiveAt: nowIso()
}));
}
async renameSession(sessionId: string, title: string): Promise<ProjectSessionSummary[]> {
const nextTitle = title.trim();
if (!nextTitle) {
throw new Error("Session title is required.");
}
const projectId = await this.resolveProjectIdForSession(sessionId);
await this.updateSessionState(sessionId, (session) => ({
...session,
title: nextTitle,
updatedAt: nowIso(),
lastActiveAt: nowIso()
}));
return this.listSessions(projectId);
}
async persistDraft(sessionId: string, draft: string): Promise<void> {
await this.updateSessionState(sessionId, (session) => ({
...session,
draft,
updatedAt: nowIso()
}));
}
async bindSessionContextSnapshot(sessionId: string, snapshotId: string): Promise<void> {
const timestamp = nowIso();
await this.updateSessionState(sessionId, (session) => ({
...session,
contextSnapshotId: snapshotId,
contextLoadedAt: timestamp,
updatedAt: timestamp
}));
}
async setSessionSelectedSkill(sessionId: string, skillId: string | null): Promise<void> {
await this.updateSessionState(sessionId, (session) => ({
...session,
selectedSkillId: skillId,
updatedAt: nowIso()
}));
}
async getSessionState(sessionId: string): Promise<ProjectSessionState> {
const projectId = await this.resolveProjectIdForSession(sessionId);
const sessions = await this.ensureProjectSessionStates(projectId);
const session = sessions.find((item) => item.sessionId === sessionId);
if (!session) {
throw new Error(`Session ${sessionId} was not found.`);
}
return session;
}
async listSessionMessages(sessionId: string): Promise<ChatMessage[]> {
const messagesPath = await this.getSessionMessagesPath(sessionId);
const existing = await readJsonFile<ChatMessage[]>(messagesPath);
return Array.isArray(existing) ? existing : [];
}
async seedSessionMessages(sessionId: string, messages: ChatMessage[]): Promise<void> {
if (messages.length === 0) {
return;
}
await writeJsonFile(await this.getSessionMessagesPath(sessionId), messages);
}
async appendSessionMessage(sessionId: string, message: ChatMessage): Promise<void> {
const messages = await this.listSessionMessages(sessionId);
messages.push(message);
await writeJsonFile(await this.getSessionMessagesPath(sessionId), messages);
}
async listCurrentProjectSkills(): Promise<WorkspaceSkillSummary[]> {
const project = await this.getActiveProject();
return this.listProjectSkills(project.id);
}
async listProjectSkills(projectId: string): Promise<WorkspaceSkillSummary[]> {
const project = await this.getProjectById(projectId);
const projectRecord = await this.readProjectRecord(project.id);
const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []);
const workspaceRoot = await this.getWorkspaceRoot();
const skillsRoot = path.join(workspaceRoot, SKILLS_DIR);
const dirEntries = await readdir(skillsRoot, { withFileTypes: true }).catch(() => []);
const skills: WorkspaceSkillSummary[] = [];
for (const entry of dirEntries) {
if (!entry.isDirectory()) {
continue;
}
const skillDir = path.join(skillsRoot, entry.name);
const files = await readdir(skillDir, { withFileTypes: true }).catch(() => []);
const skillFile = files.find((file) => file.isFile() && /\.(md|txt)$/i.test(file.name));
const skillId = sanitizeSkillId(entry.name);
if (boundSkillIds.size > 0 && !boundSkillIds.has(skillId)) {
continue;
}
skills.push({
id: skillId,
name: entry.name,
description: `Local skill from project ${project.name}.`,
category: "project",
enabled: true,
ready: Boolean(skillFile),
downloadState: skillFile ? "ready" : "failed",
fileName: skillFile?.name,
lastSyncedAt: project.updatedAt,
lastError: skillFile ? undefined : "Skill directory does not contain a runnable description file."
});
}
return skills.sort(compareSkills);
}
async getCurrentProjectSkillTarget(skillId: string): Promise<SkillExecutionTarget | undefined> {
const activeProject = await this.getActiveProject();
return this.getProjectSkillTarget(activeProject.id, skillId);
}
async getProjectSkillTarget(projectId: string, skillId: string): Promise<SkillExecutionTarget | undefined> {
const projectRecord = await this.readProjectRecord(projectId);
const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []);
if (boundSkillIds.size > 0 && !boundSkillIds.has(skillId)) {
return undefined;
}
const workspaceRoot = await this.getWorkspaceRoot();
const skillsRoot = path.join(workspaceRoot, SKILLS_DIR);
const dirEntries = await readdir(skillsRoot, { withFileTypes: true }).catch(() => []);
for (const entry of dirEntries) {
if (!entry.isDirectory()) {
continue;
}
if (sanitizeSkillId(entry.name) !== skillId) {
continue;
}
const skillDir = path.join(skillsRoot, entry.name);
const files = await readdir(skillDir, { withFileTypes: true }).catch(() => []);
const skillFile = files.find((file) => file.isFile() && /\.(md|txt)$/i.test(file.name));
if (!skillFile) {
return undefined;
}
return {
skillId,
name: entry.name,
fileName: skillFile.name,
localPath: path.join(skillDir, skillFile.name)
};
}
return undefined;
}
async syncBundleProject(input: {
projectId?: string;
projectName: string;
version?: string;
boundSkillIds?: string[];
description?: string;
ready?: boolean;
}): Promise<ProjectSummary> {
const projectId = input.projectId?.trim() || slugify(input.projectName);
const project = await this.upsertProject({
id: projectId,
name: input.projectName,
description: input.description,
version: input.version,
boundSkillIds: input.boundSkillIds,
ready: input.ready ?? true
});
const activeProject = await this.getActiveProject().catch(() => null);
if (!activeProject) {
await this.setActiveProject(project.id);
}
return project;
}
async removeProject(projectId: string): Promise<void> {
await this.initialize();
const workspaceRoot = await this.getWorkspaceRoot();
const projectsRoot = path.join(workspaceRoot, PROJECTS_DIR);
const projectDir = this.resolveWorkspaceChildPath(projectsRoot, projectId);
await rm(projectDir, { recursive: true, force: true }).catch(() => undefined);
const activeProjectPath = await this.getActiveProjectFilePath();
const activeRecord = await readJsonFile<ActiveProjectRecord>(activeProjectPath);
const remainingProjects = sortProjects(await this.readProjects());
if (remainingProjects.length === 0) {
await writeJsonFile(activeProjectPath, {});
return;
}
if (!activeRecord?.projectId || activeRecord.projectId === projectId || !remainingProjects.some((project) => project.id === activeRecord.projectId)) {
await writeJsonFile(activeProjectPath, { projectId: remainingProjects[0].id });
}
}
resolveProjectIdFromSessionId(sessionId: string): string | undefined {
const matched = /^project:([^:]+):/.exec(sessionId);
return matched?.[1];
}
private async getProjectById(projectId: string): Promise<ProjectSummary> {
const projects = await this.listProjects();
const project = projects.find((item) => item.id === projectId);
if (!project) {
throw new Error(`Project ${projectId} was not found.`);
}
return project;
}
private async resolveProjectIdForSession(sessionId: string): Promise<string> {
const hintedProjectId = this.resolveProjectIdFromSessionId(sessionId);
if (hintedProjectId) {
const record = await this.readProjectRecord(hintedProjectId);
if (record) {
return hintedProjectId;
}
}
const projects = await this.listProjects();
for (const project of projects) {
const sessions = await this.ensureProjectSessionStates(project.id);
if (sessions.some((session) => session.sessionId === sessionId)) {
return project.id;
}
}
throw new Error(`Session ${sessionId} was not found.`);
}
private async ensureProjectSessionStates(projectId: string): Promise<ProjectSessionState[]> {
const sessionsPath = await this.getProjectSessionsPath(projectId);
const existing = await readJsonFile<StoredSessionRecord[]>(sessionsPath);
const projectRoot = await this.getProjectDir(projectId);
if (Array.isArray(existing) && existing.length > 0) {
return sortSessionStates(existing.map((session) => this.toProjectSessionState(projectId, projectRoot, session)));
}
const timestamp = nowIso();
const defaultSession = this.toProjectSessionState(projectId, projectRoot, {
id: `project:${projectId}:${randomUUID()}`,
title: "Default Session",
createdAt: timestamp,
updatedAt: timestamp,
lastActiveAt: timestamp,
contextSnapshotId: null,
contextLoadedAt: null,
selectedSkillId: null,
draft: ""
});
await this.writeSessions(projectId, [defaultSession]);
return [defaultSession];
}
private async updateSessionState(
sessionId: string,
updater: (session: ProjectSessionState) => ProjectSessionState
): Promise<ProjectSessionState> {
const projectId = await this.resolveProjectIdForSession(sessionId);
const sessions = await this.ensureProjectSessionStates(projectId);
const nextSessions = sessions.map((session) => session.sessionId === sessionId ? updater(session) : session);
const updated = nextSessions.find((session) => session.sessionId === sessionId);
if (!updated) {
throw new Error(`Session ${sessionId} was not found.`);
}
await this.writeSessions(projectId, nextSessions);
return updated;
}
private async writeSessions(projectId: string, sessions: ProjectSessionState[]): Promise<void> {
const payload: StoredSessionRecord[] = sessions.map((session) => ({
id: session.sessionId,
title: session.title,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
lastActiveAt: session.lastActiveAt,
contextSnapshotId: session.contextSnapshotId,
contextLoadedAt: session.contextLoadedAt,
selectedSkillId: session.selectedSkillId,
draft: session.draft
}));
await writeJsonFile(await this.getProjectSessionsPath(projectId), payload);
}
private toProjectSessionSummary(session: ProjectSessionState): ProjectSessionSummary {
return {
id: session.sessionId,
projectId: session.projectId,
title: session.title,
updatedAt: session.updatedAt
};
}
private toProjectSessionState(projectId: string, projectRoot: string, record: StoredSessionRecord): ProjectSessionState {
const createdAt = record.createdAt ?? record.updatedAt ?? nowIso();
const updatedAt = record.updatedAt ?? createdAt;
const lastActiveAt = record.lastActiveAt ?? updatedAt;
return {
sessionId: record.id,
projectId,
projectRoot,
title: record.title,
createdAt,
updatedAt,
lastActiveAt,
contextSnapshotId: record.contextSnapshotId ?? null,
contextLoadedAt: record.contextLoadedAt ?? null,
selectedSkillId: record.selectedSkillId ?? null,
draft: record.draft ?? ""
};
}
private async readProjects(): Promise<ProjectSummary[]> {
const workspaceRoot = await this.getWorkspaceRoot();
const projectsRoot = path.join(workspaceRoot, PROJECTS_DIR);
const entries = await readdir(projectsRoot, { withFileTypes: true }).catch(() => []);
const projects: ProjectSummary[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const record = await this.readProjectRecord(entry.name);
if (!record) {
continue;
}
projects.push(this.toProjectSummary(record));
}
return projects;
}
private async readProjectRecord(projectId: string): Promise<StoredProjectRecord | null> {
return readJsonFile<StoredProjectRecord>(path.join(await this.getProjectDir(projectId), PROJECT_FILE));
}
private toProjectSummary(record: StoredProjectRecord): ProjectSummary {
return {
id: record.id,
name: record.name,
description: record.description,
version: record.version,
updatedAt: record.updatedAt,
skillCount: record.boundSkillIds?.length ?? 0,
ready: record.ready !== false
};
}
private async getActiveProjectFilePath(): Promise<string> {
return path.join(await this.getWorkspaceRoot(), MANIFESTS_DIR, ACTIVE_PROJECT_FILE);
}
private async getProjectDir(projectId: string): Promise<string> {
return this.resolveWorkspaceChildPath(path.join(await this.getWorkspaceRoot(), PROJECTS_DIR), projectId);
}
private resolveWorkspaceChildPath(rootPath: string, childName: string): string {
const trimmed = childName.trim();
if (!trimmed || path.basename(trimmed) !== trimmed) {
throw new Error(`Invalid workspace child name: ${childName}`);
}
const resolvedRoot = path.resolve(rootPath);
const targetPath = path.resolve(resolvedRoot, trimmed);
const rootPrefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`;
if (targetPath !== resolvedRoot && !targetPath.startsWith(rootPrefix)) {
throw new Error(`Workspace child path escaped root: ${childName}`);
}
return targetPath;
}
private async getProjectSessionsPath(projectId: string): Promise<string> {
return path.join(await this.getProjectDir(projectId), SESSIONS_FILE);
}
private async getSessionMessagesPath(sessionId: string): Promise<string> {
const projectId = await this.resolveProjectIdForSession(sessionId);
const fileName = `${createHash("sha1").update(sessionId).digest("hex")}.json`;
return path.join(await this.getProjectDir(projectId), SESSION_MESSAGES_DIR, fileName);
}
}
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 path from "node:path";
import type { RuntimeManager } from "@qjclaw/runtime-manager";
import type { ProjectStoreService } from "./project-store.js";
import type { SkillExecutionTarget, SkillStoreService } from "./skill-store.js";
interface PreparedSkillExecution {
......@@ -66,15 +67,17 @@ function applyRuntimeSkillName(content: string, runtimeSkillName: string): strin
export class RuntimeSkillBridgeService {
private readonly skillStore: SkillStoreService;
private readonly projectStore: ProjectStoreService;
private readonly runtimeManager: RuntimeManager;
constructor(skillStore: SkillStoreService, runtimeManager: RuntimeManager) {
constructor(skillStore: SkillStoreService, projectStore: ProjectStoreService, runtimeManager: RuntimeManager) {
this.skillStore = skillStore;
this.projectStore = projectStore;
this.runtimeManager = runtimeManager;
}
async preparePrompt(prompt: string, skillId: string): Promise<PreparedSkillExecution> {
const target = await this.skillStore.getExecutionTarget(skillId);
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);
if (!target) {
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 {
if (typeof value === "string") {
......@@ -41,12 +61,61 @@ function buildSmokeReply(body: Record<string, unknown>): string {
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>> {
const url = new URL(baseUrl);
const hostname = url.hostname;
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
const providerToken = "runtime-provider-token";
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 requestUrl = new URL(req.url || "/", `${url.protocol}//${url.host}`);
......@@ -134,6 +203,19 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r
};
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") {
const body = await readJsonBody();
const apiKey = typeof body.api_key === "string" ? body.api_key : "";
......@@ -145,20 +227,51 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r
return;
}
const currentVersion = "2026-03-23T20:00:00.000Z";
if (action === "sync" && configVersion === currentVersion) {
sendJson(200, { changed: false, config_version: currentVersion });
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, {
changed: true,
employee_id: "employee-smoke",
name: "Smoke Lobster",
status: "running",
deployment_type: "local",
persona_prompt: "你是前台验证用的小龙虾员工,请直接响应用户。",
welcome_message: "你好,我已经接入真实运行时配置。",
persona_prompt: "You are a smoke validation employee. Respond directly to the user.",
welcome_message: "Hello, the smoke runtime configuration is connected.",
work_hours: {
start: null,
end: null,
......@@ -183,22 +296,7 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r
provider_type: "openai_compatible"
}
},
skills: [
{
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"
}
}
],
skills: remoteSkills,
channels: [
{
id: "channel-web",
......@@ -428,5 +526,3 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r
});
};
}
......@@ -40,6 +40,10 @@ const desktopApi: DesktopApi = {
load: () => ipcRenderer.invoke(IPC_CHANNELS.configLoad),
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: {
getSessionSummary: () => ipcRenderer.invoke(IPC_CHANNELS.authGetSession),
signIn: (input: SignInInput) => ipcRenderer.invoke(IPC_CHANNELS.authSignIn, input),
......@@ -62,6 +66,8 @@ const desktopApi: DesktopApi = {
},
chat: {
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),
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),
......@@ -84,4 +90,4 @@ const desktopApi: DesktopApi = {
const smokeEnabled = process.argv.includes("--qjc-smoke");
contextBridge.exposeInMainWorld("qjcDesktop", desktopApi);
contextBridge.exposeInMainWorld("qjcSmokeEnabled", smokeEnabled);
\ No newline at end of file
contextBridge.exposeInMainWorld("qjcSmokeEnabled", smokeEnabled);
......@@ -7,7 +7,8 @@ export default defineConfig([
clean: true,
dts: false,
entry: {
index: "src/main/index.ts"
index: "src/main/index.ts",
"project-workspace-agent-runner": "src/main/project-workspace-agent-runner.ts"
},
external: ["electron"],
noExternal: bundledWorkspaceDeps,
......@@ -30,3 +31,4 @@ export default defineConfig([
target: "node20"
}
]);
......@@ -76,6 +76,7 @@ interface SmokeStreamSnapshot {
executionPolicySource?: string;
executionPolicyModel?: string;
latestStatusLabel?: string;
statusLabels: string[];
lastError?: string;
}
......@@ -83,6 +84,11 @@ const DEFAULT_SESSION_ID = "desktop-main";
const SUCCESS_NOTICE_TIMEOUT_MS = 2400;
const TYPEWRITER_CHARS_PER_FRAME = 3;
const MAX_TRACE_ITEMS = 60;
function resolvePreferredSessionId(sessions: Array<{ id: string }>, activeSessionId?: string | null): string | undefined {
return sessions.find((session) => session.id === activeSessionId)?.id ?? sessions[0]?.id;
}
function createClientMessageId(prefix: string): string {
return globalThis.crypto?.randomUUID?.() ?? `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
......@@ -109,6 +115,20 @@ function pushTraceItem(current: ConversationTraceItem[], item: ConversationTrace
return next.slice(-MAX_TRACE_ITEMS);
}
function appendSmokeStatusLabel(currentLabels: string[] | undefined, label: string): string[] {
const trimmed = label.trim();
if (!trimmed) {
return currentLabels ?? [];
}
const labels = currentLabels ?? [];
if (labels.at(-1) === trimmed) {
return labels;
}
return [...labels, trimmed].slice(-20);
}
function buildUserMessage(content: string): UiChatMessage {
return {
id: createClientMessageId("user"),
......@@ -182,6 +202,10 @@ const ui = {
startupPrepareRuntime: "\u51c6\u5907\u672c\u5730\u52a9\u624b",
startupConnectService: "\u8fde\u63a5\u804a\u5929\u670d\u52a1",
startupEnterChat: "\u8fdb\u5165\u5bf9\u8bdd",
projectSwitcherLabel: "\u5f53\u524d\u9879\u76ee",
projectSessionsLabel: "\u4f1a\u8bdd",
newSession: "\u65b0\u5efa\u4f1a\u8bdd",
closeSession: "\u5173\u95ed\u4f1a\u8bdd",
traceTitle: "\u601d\u8003\u8fc7\u7a0b",
showTrace: "\u67e5\u770b\u8be6\u60c5",
hideTrace: "\u6536\u8d77\u8be6\u60c5",
......@@ -331,43 +355,53 @@ const mockDesktopApi = {
profile: { getSummary: async () => ({ userId: "demo", displayName: "demo", email: "demo@example.com", organizationName: "demo" }) },
credits: { getSummary: async () => ({ balance: 0, granted: 0, used: 0, currency: "credits", status: "ok", updatedAt: new Date().toISOString() }) },
skills: { list: async () => [] },
projects: {
list: async () => ([
{ id: "xiaohongshu", name: "\u5c0f\u7ea2\u4e66", version: "demo-project", updatedAt: new Date().toISOString(), skillCount: 2, ready: true },
{ id: "douyin", name: "\u6296\u97f3", version: "demo-project", updatedAt: new Date().toISOString(), skillCount: 1, ready: true }
]),
setActive: async () => mockDesktopApi.workspace.getSummary()
},
modelConfig: { getSummary: async () => ({ source: "cloud", updatedAt: new Date().toISOString(), fetchedAt: new Date().toISOString(), routingMode: "platform-managed", fallbackMode: "cloud-required", defaultChatModelId: "gpt-5.4-mini", defaultChatModelLabel: "GPT-5.4 Mini", items: [], skillBindings: [], message: "mock" }) },
system: { getSummary: async () => ({ appName: "QianjiangClaw", appVersion: "0.1.0", isPackaged: false, platform: "win32", arch: "x64", appPath: "D:/qjclaw/apps/desktop", resourcesPath: "D:/qjclaw/apps/desktop/dist", userDataPath: "D:/qjclaw/.tmp/user-data", logsPath: "D:/qjclaw/.tmp/logs" }) },
chat: {
listSessions: async () => [{ id: DEFAULT_SESSION_ID, title: ui.defaultChat, updatedAt: new Date().toISOString() }],
listSessions: async () => ([{ id: "project:xiaohongshu:default", projectId: "xiaohongshu", title: ui.defaultChat, updatedAt: new Date().toISOString() }]),
createSession: async (title?: string) => ({ id: `project:xiaohongshu:${createClientMessageId("session")}`, projectId: "xiaohongshu", title: title || "\u65b0\u5bf9\u8bdd", updatedAt: new Date().toISOString() }),
closeSession: async () => ([{ id: "project:xiaohongshu:default", projectId: "xiaohongshu", title: ui.defaultChat, updatedAt: new Date().toISOString() }]),
listMessages: async () => [{ id: "message-1", role: "assistant", content: "Mock UI active.", createdAt: new Date().toISOString() }],
sendPrompt: async (_sessionId: string, prompt: string, skillId?: string) => ({ sessionId: DEFAULT_SESSION_ID, reply: { id: "reply-1", role: "assistant", content: "Mock: " + prompt, createdAt: new Date().toISOString() }, executionPolicy: { source: skillId ? "cloud-skill-binding" : "cloud-default", modelId: "gpt-5.4-mini", modelLabel: "GPT-5.4 Mini", routingMode: "platform-managed", skillId, skillName: skillId, message: "mock" } }),
sendPrompt: async (sessionId: string, prompt: string, skillId?: string) => ({ sessionId: sessionId || "project:xiaohongshu:default", reply: { id: "reply-1", role: "assistant", content: "Mock: " + prompt, createdAt: new Date().toISOString() }, executionPolicy: { source: skillId ? "cloud-skill-binding" : "cloud-default", modelId: "gpt-5.4-mini", modelLabel: "GPT-5.4 Mini", routingMode: "platform-managed", skillId, skillName: skillId, message: "mock" } }),
streamPrompt: async (_sessionId: string, prompt: string, skillId?: string) => {
const requestId = createClientMessageId("mock-request");
const runId = createClientMessageId("mock-run");
const sessionId = _sessionId || "project:xiaohongshu:default";
const executionPolicy = { source: skillId ? "cloud-skill-binding" as const : "cloud-default" as const, modelId: "gpt-5.4-mini", modelLabel: "GPT-5.4 Mini", routingMode: "platform-managed" as const, skillId, skillName: skillId, message: "mock" };
const replyText = "Mock: " + prompt;
const chunks = replyText.match(/.{1,6}/g) ?? [replyText];
let fullText = "";
window.setTimeout(() => {
emitMockChatStreamEvent({ type: "status", requestId, sessionId: DEFAULT_SESSION_ID, runId, stage: "prepare-request", label: ui.preparingReply });
emitMockChatStreamEvent({ type: "started", requestId, sessionId: DEFAULT_SESSION_ID, runId, executionPolicy });
emitMockChatStreamEvent({ type: "status", requestId, sessionId, runId, stage: "prepare-request", label: ui.preparingReply });
emitMockChatStreamEvent({ type: "started", requestId, sessionId, runId, executionPolicy });
}, 0);
window.setTimeout(() => {
emitMockChatStreamEvent({ type: "status", requestId, sessionId: DEFAULT_SESSION_ID, runId, stage: "await-model", label: ui.waitingReply });
emitMockChatStreamEvent({ type: "status", requestId, sessionId, runId, stage: "await-model", label: ui.waitingReply });
}, 30);
chunks.forEach((chunk, index) => {
window.setTimeout(() => {
fullText += chunk;
emitMockChatStreamEvent({ type: "delta", requestId, sessionId: DEFAULT_SESSION_ID, runId, textDelta: chunk, fullText });
emitMockChatStreamEvent({ type: "delta", requestId, sessionId, runId, textDelta: chunk, fullText });
}, 90 * (index + 1));
});
window.setTimeout(() => {
emitMockChatStreamEvent({
type: "completed",
requestId,
sessionId: DEFAULT_SESSION_ID,
sessionId,
runId,
reply: { id: createClientMessageId("mock-reply"), role: "assistant", content: replyText, createdAt: new Date().toISOString() },
executionPolicy
});
}, 90 * (chunks.length + 1));
return { requestId, sessionId: DEFAULT_SESSION_ID, runId, executionPolicy };
return { requestId, sessionId, runId, executionPolicy };
},
onStreamEvent: (listener: ChatStreamListener) => {
mockChatStreamListeners.add(listener);
......@@ -409,7 +443,7 @@ declare global {
streamSmoke: SmokeStreamSnapshot | null;
};
__QJC_SMOKE_ACTIONS__?: {
sendChatPrompt(prompt: string, skillId?: string): Promise<void>;
sendChatPrompt(prompt: string, skillId?: string, sessionId?: string): Promise<void>;
};
}
}
......@@ -500,6 +534,7 @@ export default function App() {
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealth | null>(null);
const [messages, setMessages] = useState<UiChatMessage[]>([]);
const [activeSessionId, setActiveSessionId] = useState(DEFAULT_SESSION_ID);
const [projectActionPending, setProjectActionPending] = useState(false);
const [selectedSkillId, setSelectedSkillId] = useState(DEFAULT_SKILL.id);
const [prompt, setPrompt] = useState("");
const [apiKeyDraft, setApiKeyDraft] = useState("");
......@@ -534,11 +569,14 @@ export default function App() {
const startupCurtainFootnote = chatLaunchState === "error"
? startupCurtainCopy.retryHint
: startupCurtainCopy.loadingLabel;
const sessions = !setupRequired ? [{ id: activeSessionId, title: ui.defaultChat, updatedAt: new Date().toISOString() }] : [];
const projects = workspace?.projects ?? [];
const sessions = !setupRequired ? (workspace?.sessions ?? [{ id: activeSessionId, projectId: workspace?.currentProjectId ?? "default", title: ui.defaultChat, updatedAt: new Date().toISOString() }]) : [];
const resolvedActiveSessionId = useMemo(() => resolvePreferredSessionId(sessions, activeSessionId), [activeSessionId, sessions]);
const isBound = !setupRequired;
const hasActiveProject = Boolean(workspace?.projectReady && workspace?.currentProjectId);
const showStartupOverlay = viewMode !== "settings" && ((refreshing && !workspace) || setupRequired || (isBound && chatLaunchState !== "ready"));
const sending = sendPhase !== "idle";
const canSend = isBound && prompt.trim().length > 0 && !sending && !saving;
const canSend = isBound && hasActiveProject && prompt.trim().length > 0 && !sending && !saving;
const sendButtonLabel = sendPhase === "preparing"
? ui.preparing
: sendPhase === "streaming" || sendPhase === "finalizing"
......@@ -606,7 +644,9 @@ export default function App() {
setRuntimeCloudStatus(nextCloud);
setRuntimeTelemetry(nextTelemetry);
setSystemSummary(nextSystem);
setActiveSessionId(DEFAULT_SESSION_ID);
const nextSessions = nextWorkspace.sessions ?? [];
const nextSessionId = nextSessions.find((session) => session.id === activeSessionId)?.id ?? nextSessions[0]?.id ?? DEFAULT_SESSION_ID;
setActiveSessionId(nextSessionId);
setWorkspacePathDraft((current) => current || nextConfig.workspacePath);
setGatewayStatus(statusResult);
......@@ -619,7 +659,7 @@ export default function App() {
const canReadMessages = nextWorkspace.chatReady && canExchangeMessages(nextRuntime, statusResult);
if (canReadMessages) {
setGatewayHealth(await desktopApi.gateway.health().catch(() => null));
await loadMessages(DEFAULT_SESSION_ID, true, false);
await loadMessages(nextSessionId, true, false);
} else {
setGatewayHealth(null);
setMessages([]);
......@@ -638,6 +678,14 @@ export default function App() {
void refresh();
}, []);
useEffect(() => {
if (!resolvedActiveSessionId || resolvedActiveSessionId === activeSessionId) {
return;
}
setActiveSessionId(resolvedActiveSessionId);
}, [activeSessionId, resolvedActiveSessionId]);
useEffect(() => {
if (viewMode === "settings" || !showStartupOverlay || !isBound || chatLaunchState !== "starting") {
return;
......@@ -713,6 +761,82 @@ export default function App() {
setBaseUrlDraft(config.baseUrl);
setDefaultModelDraft(config.defaultModel);
}, [config?.setupMode, config?.provider, config?.baseUrl, config?.defaultModel]);
useEffect(() => {
if (!isBound || !resolvedActiveSessionId || !workspace?.chatReady || !canExchangeMessages(runtimeStatus, gatewayStatus)) {
return;
}
void loadMessages(resolvedActiveSessionId, true, false);
}, [gatewayStatus, isBound, resolvedActiveSessionId, runtimeStatus, workspace?.chatReady]);
async function switchProject(projectId: string) {
if (projectActionPending) {
return;
}
setProjectActionPending(true);
setErrorText("");
try {
const nextWorkspace = await desktopApi.projects.setActive(projectId);
setWorkspace(nextWorkspace);
const nextSessionId = nextWorkspace.sessions[0]?.id ?? DEFAULT_SESSION_ID;
setActiveSessionId(nextSessionId);
setMessages([]);
if (nextWorkspace.chatReady && canExchangeMessages(runtimeStatus, gatewayStatus)) {
await loadMessages(nextSessionId, true, false);
}
} catch (error) {
setErrorText(err(error));
} finally {
setProjectActionPending(false);
}
}
async function createProjectSession() {
if (projectActionPending) {
return;
}
setProjectActionPending(true);
setErrorText("");
try {
const session = await desktopApi.chat.createSession();
const nextWorkspace = await desktopApi.workspace.getSummary().catch(() => null);
if (nextWorkspace) {
setWorkspace(nextWorkspace);
}
setActiveSessionId(session.id);
setMessages([]);
} catch (error) {
setErrorText(err(error));
} finally {
setProjectActionPending(false);
}
}
async function closeProjectSession(sessionId: string) {
if (projectActionPending) {
return;
}
setProjectActionPending(true);
setErrorText("");
try {
const nextSessions = await desktopApi.chat.closeSession(sessionId);
const nextWorkspace = await desktopApi.workspace.getSummary().catch(() => null);
if (nextWorkspace) {
setWorkspace(nextWorkspace);
}
const nextSessionId = nextSessions.find((session) => session.id !== sessionId)?.id ?? nextSessions[0]?.id ?? DEFAULT_SESSION_ID;
setActiveSessionId(nextSessionId);
setMessages([]);
} catch (error) {
setErrorText(err(error));
} finally {
setProjectActionPending(false);
}
}
useEffect(() => {
if (!effectiveSkills.some((skill) => skill.id === selectedSkillId)) {
setSelectedSkillId(effectiveSkills[0]?.id ?? DEFAULT_SKILL.id);
......@@ -742,11 +866,11 @@ export default function App() {
sessions,
messages: toPlainMessages(messages),
logs: [],
activeSessionId,
activeSessionId: resolvedActiveSessionId ?? "",
workspaceSummary: workspace,
streamSmoke
};
}, [activeSessionId, config, gatewayHealth, gatewayStatus, messages, runtimeCloudStatus, runtimeStatus, runtimeTelemetry, sessions, streamSmoke, systemSummary, workspace]);
}, [config, gatewayHealth, gatewayStatus, messages, resolvedActiveSessionId, runtimeCloudStatus, runtimeStatus, runtimeTelemetry, sessions, streamSmoke, systemSummary, workspace]);
useEffect(() => {
if (!smokeEnabled) {
......@@ -755,14 +879,17 @@ export default function App() {
}
window.__QJC_SMOKE_ACTIONS__ = {
sendChatPrompt: async (nextPrompt: string, skillId?: string) => {
sendChatPrompt: async (nextPrompt: string, skillId?: string, sessionId?: string) => {
setViewMode("chat");
if (skillId) {
setSelectedSkillId(skillId);
}
if (sessionId) {
setActiveSessionId(sessionId);
}
setPrompt(nextPrompt);
window.setTimeout(() => {
void submitPrompt(nextPrompt, skillId);
void submitPrompt(nextPrompt, skillId, sessionId);
}, 0);
}
};
......@@ -1051,7 +1178,8 @@ export default function App() {
sessionId: event.sessionId,
runId: event.runId ?? current.runId,
statusEventCount: current.statusEventCount + 1,
latestStatusLabel: event.label
latestStatusLabel: event.label,
statusLabels: appendSmokeStatusLabel(current.statusLabels, event.label)
} : current);
return;
}
......@@ -1135,12 +1263,12 @@ export default function App() {
const resolvedDefaultModel = (resolvedSetupMode === "direct-provider" ? defaultModelDraft : config.defaultModel).trim() || config.defaultModel;
if (!trimmedApiKey) {
setErrorText(resolvedSetupMode === "direct-provider" ? "?????? API Key?" : ui.bindFirstError);
setErrorText(resolvedSetupMode === "direct-provider" ? "\u8bf7\u8f93\u5165 API Key" : ui.bindFirstError);
return;
}
if (resolvedSetupMode === "direct-provider" && !resolvedBaseUrl) {
setErrorText("?????? Base URL?");
setErrorText("\u8bf7\u8f93\u5165 Base URL");
return;
}
......@@ -1171,7 +1299,7 @@ export default function App() {
setBaseUrlDraft(savedConfig.baseUrl);
setDefaultModelDraft(savedConfig.defaultModel);
setInfoText(resolvedSetupMode === "direct-provider"
? "?????????????????"
? "\u914d\u7f6e\u5df2\u4fdd\u5b58\uff0c\u6b63\u5728\u91cd\u65b0\u8fde\u63a5\u3002"
: ui.saveSuccessPending);
void refresh(false);
} catch (error) {
......@@ -1187,7 +1315,8 @@ export default function App() {
appendTrace(assistantMessageId, stage, label, detail);
updateStreamSmoke((current) => current ? {
...current,
latestStatusLabel: label
latestStatusLabel: label,
statusLabels: appendSmokeStatusLabel(current.statusLabels, label)
} : current);
};
......@@ -1207,7 +1336,7 @@ export default function App() {
if (!latestWorkspace.apiKeyConfigured) {
throw new Error(ui.bindFirstError);
}
if (latestWorkspace.chatReady || canExchangeMessages(latestRuntime, latestGateway)) {
if (latestWorkspace.chatReady && Boolean(latestWorkspace.projectReady && latestWorkspace.currentProjectId) && canExchangeMessages(latestRuntime, latestGateway)) {
return;
}
......@@ -1240,14 +1369,14 @@ export default function App() {
}
setGatewayStatus(confirmedGateway);
if (confirmedWorkspace.chatReady || canExchangeMessages(confirmedRuntime, confirmedGateway)) {
if (confirmedWorkspace.chatReady && Boolean(confirmedWorkspace.projectReady && confirmedWorkspace.currentProjectId) && canExchangeMessages(confirmedRuntime, confirmedGateway)) {
return;
}
throw new Error(confirmedWorkspace.chatStatusMessage ?? ui.chatNotReadyError);
}
async function submitPrompt(promptText: string, requestedSkillId?: string) {
async function submitPrompt(promptText: string, requestedSkillId?: string, forcedSessionId?: string) {
const trimmedPrompt = promptText.trim();
if (!trimmedPrompt || sending || saving) {
return;
......@@ -1269,37 +1398,50 @@ export default function App() {
createdAt: new Date().toISOString()
});
setMessages((current) => [...current, userMessage, assistantMessage]);
setActiveSessionId(DEFAULT_SESSION_ID);
updateStreamSmoke(() => ({
phase: "requested",
prompt: trimmedPrompt,
selectedSkillId: skillId,
requestId: undefined,
sessionId: DEFAULT_SESSION_ID,
runId: undefined,
assistantMessageId: assistantMessage.id,
startedEventCount: 0,
statusEventCount: 0,
deltaEventCount: 0,
completedEventCount: 0,
errorEventCount: 0,
fallbackUsed: false,
renderedContent: "",
finalContent: "",
latestStatusLabel: ui.preparingReply
}));
let sessionId = forcedSessionId ?? resolvedActiveSessionId;
try {
const chatReadyAtSend = Boolean(workspace?.chatReady) && canExchangeMessages(runtimeStatus, gatewayStatus);
if (!chatReadyAtSend) {
await ensureChatAvailable(assistantMessage.id);
}
if (!sessionId) {
const createdSession = await desktopApi.chat.createSession();
sessionId = createdSession.id;
setActiveSessionId(createdSession.id);
setWorkspace((current) => current ? {
...current,
sessions: [createdSession, ...current.sessions.filter((session) => session.id !== createdSession.id)]
} : current);
}
setActiveSessionId(sessionId);
updateStreamSmoke(() => ({
phase: "requested",
prompt: trimmedPrompt,
selectedSkillId: skillId,
requestId: undefined,
sessionId,
runId: undefined,
assistantMessageId: assistantMessage.id,
startedEventCount: 0,
statusEventCount: 0,
deltaEventCount: 0,
completedEventCount: 0,
errorEventCount: 0,
fallbackUsed: false,
renderedContent: "",
finalContent: "",
latestStatusLabel: ui.preparingReply,
statusLabels: [ui.preparingReply]
}));
updateAssistantStatus(assistantMessage.id, ui.waitingReply);
appendTrace(assistantMessage.id, "await-model", ui.waitingReply);
try {
const stream = await desktopApi.chat.streamPrompt(DEFAULT_SESSION_ID, trimmedPrompt, skillId);
const stream = await desktopApi.chat.streamPrompt(sessionId, trimmedPrompt, skillId);
activeStreamRef.current = {
requestId: stream.requestId,
assistantMessageId: assistantMessage.id,
......@@ -1320,7 +1462,7 @@ export default function App() {
setSendPhase("finalizing");
appendTrace(assistantMessage.id, "fallback", ui.fallbackReply);
updateAssistantStatus(assistantMessage.id, ui.generating);
await completeWithFallback(DEFAULT_SESSION_ID, trimmedPrompt, skillId, assistantMessage.id);
await completeWithFallback(sessionId, trimmedPrompt, skillId, assistantMessage.id);
}
} catch (error) {
setSendPhase("idle");
......@@ -1409,6 +1551,45 @@ export default function App() {
{viewMode === "chat" ? (
<section className="panel chat-panel">
<>
{!showBindEntry && projects.length > 0 ? (
<div className="project-switcher">
<div className="project-switcher-head">
<span>{ui.projectSwitcherLabel}</span>
<strong>{workspace?.currentProjectName ?? projects[0]?.name ?? ui.defaultChat}</strong>
</div>
<div className="project-chip-row">
{projects.map((project) => (
<button
key={project.id}
type="button"
className={"project-chip" + (workspace?.currentProjectId === project.id ? " active" : "")}
disabled={projectActionPending}
onClick={() => void switchProject(project.id)}
>
{project.name}
</button>
))}
</div>
</div>
) : null}
{!showBindEntry && sessions.length > 0 ? (
<div className="session-strip">
<div className="session-strip-head">
<span>{ui.projectSessionsLabel}</span>
<button type="button" className="session-add" disabled={projectActionPending || !isBound || !projects.length} onClick={() => void createProjectSession()}>+ {ui.newSession}</button>
</div>
<div className="session-tab-row">
{sessions.map((session) => (
<div key={session.id} className={"session-tab" + (activeSessionId === session.id ? " active" : "")}>
<button type="button" className="session-tab-main" disabled={projectActionPending} onClick={() => setActiveSessionId(session.id)}>{session.title}</button>
{sessions.length > 1 ? (
<button type="button" className="session-tab-close" aria-label={ui.closeSession} disabled={projectActionPending} onClick={() => void closeProjectSession(session.id)}>?</button>
) : null}
</div>
))}
</div>
</div>
) : null}
{showBindEntry ? (
<div className="bind-entry">
<div className="bind-entry-copy">
......@@ -1519,17 +1700,16 @@ export default function App() {
</div>
<div className="form-grid single">
<label>
????
Setup Mode
<select value={setupModeDraft} onChange={(event) => setSetupModeDraft(event.target.value as SetupMode)}>
<option value="employee-key">????</option>
<option value="direct-provider">?????</option>
<option value="employee-key">Employee Key</option>
<option value="direct-provider">Direct Provider</option>
</select>
</label>
{setupModeDraft === "direct-provider" ? (
<>
<label>
??
<select value={providerDraft} onChange={(event) => setProviderDraft(event.target.value)}>
Provider<select value={providerDraft} onChange={(event) => setProviderDraft(event.target.value)}>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="openai-compatible">OpenAI Compatible</option>
......@@ -1540,14 +1720,13 @@ export default function App() {
<input value={baseUrlDraft} placeholder="https://api.openai.com/v1" onChange={(event) => setBaseUrlDraft(event.target.value)} />
</label>
<label>
????
<input value={defaultModelDraft} placeholder="gpt-5.4-mini" onChange={(event) => setDefaultModelDraft(event.target.value)} />
Default Model<input value={defaultModelDraft} placeholder="gpt-5.4-mini" onChange={(event) => setDefaultModelDraft(event.target.value)} />
</label>
</>
) : null}
<label>
{setupModeDraft === "direct-provider" ? "API Key" : ui.apiKey}
<input type="password" value={apiKeyDraft} placeholder={setupModeDraft === "direct-provider" ? "????? API Key" : (workspace?.apiKeyConfigured ? ui.changeApiKey : ui.apiKeyPlaceholder)} onChange={(event) => setApiKeyDraft(event.target.value)} />
<input type="password" value={apiKeyDraft} placeholder={setupModeDraft === "direct-provider" ? "Enter API Key" : (workspace?.apiKeyConfigured ? ui.changeApiKey : ui.apiKeyPlaceholder)} onChange={(event) => setApiKeyDraft(event.target.value)} />
</label>
</div>
<div className="button-row settings-actions">
......@@ -1587,14 +1766,14 @@ export default function App() {
{setupRequired ? (
<div className="startup-setup-shell">
<div className="startup-setup-tabs">
<button type="button" className={"startup-setup-tab" + (setupModeDraft === "employee-key" ? " active" : "")} onClick={() => setSetupModeDraft("employee-key")}>????</button>
<button type="button" className={"startup-setup-tab" + (setupModeDraft === "direct-provider" ? " active" : "")} onClick={() => setSetupModeDraft("direct-provider")}>?????</button>
<button type="button" className={"startup-setup-tab" + (setupModeDraft === "employee-key" ? " active" : "")} onClick={() => setSetupModeDraft("employee-key")}>Employee Key</button>
<button type="button" className={"startup-setup-tab" + (setupModeDraft === "direct-provider" ? " active" : "")} onClick={() => setSetupModeDraft("direct-provider")}>Direct Provider</button>
</div>
<div className="startup-setup-form">
{setupModeDraft === "direct-provider" ? (
<>
<label>
<span className="field-label">??</span>
<span className="field-label">Provider</span>
<select value={providerDraft} onChange={(event) => setProviderDraft(event.target.value)}>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
......@@ -1606,20 +1785,20 @@ export default function App() {
<input value={baseUrlDraft} placeholder="https://api.openai.com/v1" onChange={(event) => setBaseUrlDraft(event.target.value)} />
</label>
<label>
<span className="field-label">????</span>
<span className="field-label">Default Model</span>
<input value={defaultModelDraft} placeholder="gpt-5.4-mini" onChange={(event) => setDefaultModelDraft(event.target.value)} />
</label>
</>
) : (
<p className="startup-setup-note">??????????Claw ?????????????????</p>
<p className="startup-setup-note">Enter an employee key and Claw will sync runtime configuration automatically.</p>
)}
<label>
<span className="field-label">{setupModeDraft === "direct-provider" ? "API Key" : ui.apiKey}</span>
<input type="password" value={apiKeyDraft} placeholder={setupModeDraft === "direct-provider" ? "????? API Key" : ui.apiKeyPlaceholder} onChange={(event) => setApiKeyDraft(event.target.value)} />
<input type="password" value={apiKeyDraft} placeholder={setupModeDraft === "direct-provider" ? "Enter API Key" : ui.apiKeyPlaceholder} onChange={(event) => setApiKeyDraft(event.target.value)} />
</label>
</div>
<div className="button-row startup-overlay-actions">
<button type="button" disabled={setupActionDisabled} onClick={() => void saveConfig(apiKeyDraft, setupModeDraft)}>{saving ? ui.preparing : "?????"}</button>
<button type="button" disabled={setupActionDisabled} onClick={() => void saveConfig(apiKeyDraft, setupModeDraft)}>{saving ? ui.preparing : "Bind Now"}</button>
<button type="button" className="secondary" onClick={() => setViewMode("settings")}>{ui.openSettings}</button>
</div>
</div>
......@@ -1667,6 +1846,15 @@ export default function App() {
......
......@@ -929,3 +929,95 @@ strong { font-weight: 600; }
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 @@
- `apps/desktop` packages the final EXE
- `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
- `electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output
- `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
- `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; 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
- `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`
- `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`
param(
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token',
[int]$SmokePort = 4318,
[string]$SmokeToken = 'smoke-token',
[string]$BaseOutputDir,
[int]$TimeoutSeconds = 120,
[switch]$SkipMaterializeRuntime
)
$ErrorActionPreference = 'Stop'
function Write-Utf8File {
param([string]$filePath, [string]$content)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($filePath, $content, $encoding)
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\cloud-bundle-smoke'
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
$userDataPath = Join-Path $BaseOutputDir 'user-data'
$phase1LogsPath = Join-Path $BaseOutputDir 'phase1-logs'
$phase2LogsPath = Join-Path $BaseOutputDir 'phase2-logs'
$phase3LogsPath = Join-Path $BaseOutputDir 'phase3-logs'
$phase1Output = Join-Path $BaseOutputDir 'phase1-result.json'
$phase2Output = Join-Path $BaseOutputDir 'phase2-result.json'
$phase3Output = Join-Path $BaseOutputDir 'phase3-result.json'
$bundleSourceRoot = Join-Path $BaseOutputDir 'bundle-src'
$bundleSourceRootA = Join-Path $bundleSourceRoot 'variant-a'
$bundleSourceRootB = Join-Path $bundleSourceRoot 'variant-b'
$bundleRootA = Join-Path $bundleSourceRootA 'cloud-bundle-smoke'
$bundleRootB = Join-Path $bundleSourceRootB 'cloud-bundle-smoke'
$bundleZipPathA = Join-Path $BaseOutputDir 'cloud-bundle-smoke-a.zip'
$bundleZipPathB = Join-Path $BaseOutputDir 'cloud-bundle-smoke-b.zip'
$bundleFileName = 'cloud-bundle-smoke.zip'
$bundleProjectId = 'cloud-bundle-smoke'
$bundleProjectName = 'Cloud Bundle Smoke'
$bundleSkillId = 'cloud-bundle-smoke-skill'
$bundleConfigVersionA = '2026-03-31T09:30:00.000Z'
$bundleConfigVersionB = '2026-03-31T10:30:00.000Z'
$bundleReadmeMarkerA = 'Cloud bundle replacement marker A.'
$bundleReadmeMarkerB = 'Cloud bundle replacement marker B.'
$bundleSharedSkillA = 'cloud-bundle-shared-skill-a'
$bundleSharedSkillB = 'cloud-bundle-shared-skill-b'
$bundleSharedCronA = 'cloud-bundle-task-a.txt'
$bundleSharedCronB = 'cloud-bundle-task-b.txt'
$expectedBundleSourceUrl = "http://127.0.0.1:$SmokePort/downloads/$bundleFileName"
if (Test-Path $BaseOutputDir) {
Remove-Item $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir, $bundleRootA, $bundleRootB | Out-Null
$bundleProjectJson = [ordered]@{
id = $bundleProjectId
name = $bundleProjectName
description = 'Remote bundle smoke fixture for project isolation.'
version = '1.0.0'
}
Write-Utf8File (Join-Path $bundleRootA 'project.json') ($bundleProjectJson | ConvertTo-Json -Depth 5)
Write-Utf8File (Join-Path $bundleRootB 'project.json') ($bundleProjectJson | ConvertTo-Json -Depth 5)
$agentContentA = @(
'# Cloud Bundle Smoke',
'',
'This project exists to validate cloud zip sync and project-isolated workspace execution.',
'If asked, confirm the project root and that execution happened inside this project workspace.',
'Bundle variant A is currently active.'
) -join [Environment]::NewLine
$agentContentB = @(
'# Cloud Bundle Smoke',
'',
'This project exists to validate cloud zip sync and project-isolated workspace execution.',
'If asked, confirm the project root and that execution happened inside this project workspace.',
'Bundle variant B replaced the previous contents for the same project.'
) -join [Environment]::NewLine
Write-Utf8File (Join-Path $bundleRootA 'AGENT.md') $agentContentA
Write-Utf8File (Join-Path $bundleRootB 'AGENT.md') $agentContentB
$readmeContentA = @(
'# Cloud Bundle Smoke',
'',
'This README should be injected into the workspace-entry prompt after the bundle is downloaded and materialized.',
$bundleReadmeMarkerA
) -join [Environment]::NewLine
$readmeContentB = @(
'# Cloud Bundle Smoke',
'',
'This README should be injected into the workspace-entry prompt after the bundle is downloaded and materialized.',
$bundleReadmeMarkerB
) -join [Environment]::NewLine
Write-Utf8File (Join-Path $bundleRootA 'README.md') $readmeContentA
Write-Utf8File (Join-Path $bundleRootB 'README.md') $readmeContentB
New-Item -ItemType Directory -Force -Path (Join-Path $bundleRootA 'memory'), (Join-Path $bundleRootB 'memory') | Out-Null
Write-Utf8File (Join-Path $bundleRootA 'memory\summary.md') 'Cloud bundle smoke memory marker A.'
Write-Utf8File (Join-Path $bundleRootB 'memory\summary.md') 'Cloud bundle smoke memory marker B.'
New-Item -ItemType Directory -Force -Path (Join-Path (Join-Path $bundleRootA 'skills') $bundleSharedSkillA), (Join-Path (Join-Path $bundleRootB 'skills') $bundleSharedSkillB), (Join-Path $bundleRootA 'cron'), (Join-Path $bundleRootB 'cron') | Out-Null
Write-Utf8File (Join-Path $bundleRootA "skills\$bundleSharedSkillA\SKILL.md") '# Cloud Bundle Shared Skill A'
Write-Utf8File (Join-Path $bundleRootB "skills\$bundleSharedSkillB\SKILL.md") '# Cloud Bundle Shared Skill B'
Write-Utf8File (Join-Path $bundleRootA "cron\$bundleSharedCronA") 'cloud bundle replacement task A'
Write-Utf8File (Join-Path $bundleRootB "cron\$bundleSharedCronB") 'cloud bundle replacement task B'
foreach ($bundleZipPath in @($bundleZipPathA, $bundleZipPathB)) {
if (Test-Path $bundleZipPath) {
Remove-Item $bundleZipPath -Force
}
}
Compress-Archive -Path (Join-Path $bundleSourceRootA '*') -DestinationPath $bundleZipPathA -Force
Compress-Archive -Path (Join-Path $bundleSourceRootB '*') -DestinationPath $bundleZipPathB -Force
if (-not $SkipMaterializeRuntime) {
Write-Host "Materializing bundled runtime payload on port $GatewayPort"
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\materialize-runtime-payload.ps1') -GatewayPort $GatewayPort -GatewayToken $GatewayToken
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
$env:QJCLAW_SMOKE_BUNDLE_ZIP_PATH = $bundleZipPathA
$env:QJCLAW_SMOKE_BUNDLE_FILE_NAME = $bundleFileName
$env:QJCLAW_SMOKE_BUNDLE_SKILL_ID = $bundleSkillId
$env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE = 'Cloud Bundle Smoke Skill'
$env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION = 'Remote zip-backed project bundle for smoke validation.'
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersionA
try {
Write-Host 'Running cloud bundle smoke phase 1 (payload sync path)'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') `
-SmokeOutput $phase1Output `
-SmokePort $SmokePort `
-SmokeToken $SmokeToken `
-UserDataPath $userDataPath `
-LogsPath $phase1LogsPath `
-RuntimeMode 'bundled-runtime' `
-ExpectBundledRuntime `
-ExpectWorkspaceEntry `
-ExpectRemoteBundle `
-WorkspaceProjectId $bundleProjectId `
-WorkspaceProjectName $bundleProjectName `
-SmokePrompt 'Describe the current project root and confirm cloud bundle workspace execution.' `
-SmokeSkillId '__cloud_bundle_workspace_entry_disabled__' `
-ExpectedBundleSourceUrl $expectedBundleSourceUrl `
-ExpectedBundleConfigVersion $bundleConfigVersionA `
-ExpectedBundleFileName $bundleFileName `
-ExpectedBundleSkillId $bundleSkillId `
-ExpectedReadmeMarker $bundleReadmeMarkerA `
-TimeoutSeconds $TimeoutSeconds
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$projectPath = Join-Path $userDataPath (Join-Path 'projects' $bundleProjectId)
$bundleManifestPath = Join-Path $userDataPath 'manifests\project-bundles.json'
$activeProjectManifestPath = Join-Path $userDataPath 'manifests\active-project.json'
if (Test-Path $projectPath) {
Remove-Item $projectPath -Recurse -Force -ErrorAction SilentlyContinue
}
if (Test-Path $bundleManifestPath) {
Remove-Item $bundleManifestPath -Force -ErrorAction SilentlyContinue
}
if (Test-Path $activeProjectManifestPath) {
Remove-Item $activeProjectManifestPath -Force -ErrorAction SilentlyContinue
}
Write-Host 'Running cloud bundle smoke phase 2 (cached init path)'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') `
-SmokeOutput $phase2Output `
-SmokePort $SmokePort `
-SmokeToken $SmokeToken `
-UserDataPath $userDataPath `
-LogsPath $phase2LogsPath `
-RuntimeMode 'bundled-runtime' `
-ExpectBundledRuntime `
-PreserveUserData `
-ExpectWorkspaceEntry `
-ExpectRemoteBundle `
-WorkspaceProjectId $bundleProjectId `
-WorkspaceProjectName $bundleProjectName `
-SmokePrompt 'Describe the current project root and confirm cached cloud bundle workspace execution.' `
-SmokeSkillId '__cloud_bundle_workspace_entry_disabled__' `
-ExpectedBundleSourceUrl $expectedBundleSourceUrl `
-ExpectedBundleConfigVersion $bundleConfigVersionA `
-ExpectedBundleFileName $bundleFileName `
-ExpectedBundleSkillId $bundleSkillId `
-ExpectedReadmeMarker $bundleReadmeMarkerA `
-TimeoutSeconds $TimeoutSeconds
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$env:QJCLAW_SMOKE_BUNDLE_ZIP_PATH = $bundleZipPathB
$env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION = $bundleConfigVersionB
Write-Host 'Running cloud bundle smoke phase 3 (same-project replacement path)'
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') `
-SmokeOutput $phase3Output `
-SmokePort $SmokePort `
-SmokeToken $SmokeToken `
-UserDataPath $userDataPath `
-LogsPath $phase3LogsPath `
-RuntimeMode 'bundled-runtime' `
-ExpectBundledRuntime `
-PreserveUserData `
-ExpectWorkspaceEntry `
-ExpectRemoteBundle `
-WorkspaceProjectId $bundleProjectId `
-WorkspaceProjectName $bundleProjectName `
-SmokePrompt 'Quote the current README marker line and confirm same-project replacement workspace execution.' `
-SmokeSkillId '__cloud_bundle_workspace_entry_disabled__' `
-ExpectedBundleSourceUrl $expectedBundleSourceUrl `
-ExpectedBundleConfigVersion $bundleConfigVersionB `
-ExpectedBundleFileName $bundleFileName `
-ExpectedBundleSkillId $bundleSkillId `
-ExpectedReadmeMarker $bundleReadmeMarkerB `
-UnexpectedReadmeMarker $bundleReadmeMarkerA `
-TimeoutSeconds $TimeoutSeconds
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$summary = & node -e @"
const fs = require('fs');
const path = require('path');
const [
phase1Path,
phase2Path,
phase3Path,
expectedUserData,
bundleZipPathA,
bundleZipPathB,
expectedBundleSourceUrl,
expectedReadmeMarkerA,
expectedReadmeMarkerB,
sharedSkillA,
sharedSkillB,
sharedCronA,
sharedCronB
] = process.argv.slice(1);
const phase1 = JSON.parse(fs.readFileSync(phase1Path, 'utf8'));
const phase2 = JSON.parse(fs.readFileSync(phase2Path, 'utf8'));
const phase3 = JSON.parse(fs.readFileSync(phase3Path, 'utf8'));
const readmePath = path.join(expectedUserData, 'projects', 'cloud-bundle-smoke', 'README.md');
const manifestPath = path.join(expectedUserData, 'manifests', 'project-bundles.json');
const readmeContent = fs.readFileSync(readmePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const manifestRecord = manifest['cloud-bundle-smoke'];
if (!manifestRecord || typeof manifestRecord !== 'object') {
throw new Error('Cloud bundle smoke phase 3 did not persist a manifest record for cloud-bundle-smoke.');
}
if (!readmeContent.includes(expectedReadmeMarkerB)) {
throw new Error('Cloud bundle smoke phase 3 did not materialize README marker B on disk.');
}
if (readmeContent.includes(expectedReadmeMarkerA)) {
throw new Error('Cloud bundle smoke phase 3 still has stale README marker A on disk.');
}
if (!fs.existsSync(path.join(expectedUserData, 'skills', sharedSkillB))) {
throw new Error('Cloud bundle smoke phase 3 did not materialize replacement shared skill entry.');
}
if (fs.existsSync(path.join(expectedUserData, 'skills', sharedSkillA))) {
throw new Error('Cloud bundle smoke phase 3 left stale shared skill entry A on disk.');
}
if (!fs.existsSync(path.join(expectedUserData, 'cron', sharedCronB))) {
throw new Error('Cloud bundle smoke phase 3 did not materialize replacement cron entry.');
}
if (fs.existsSync(path.join(expectedUserData, 'cron', sharedCronA))) {
throw new Error('Cloud bundle smoke phase 3 left stale cron entry A on disk.');
}
console.log(JSON.stringify({
ok: true,
bundleZipPathA,
bundleZipPathB,
expectedBundleSourceUrl,
phase1Output: phase1Path,
phase2Output: phase2Path,
phase3Output: phase3Path,
phase1SessionId: phase1.sendResult.sessionId,
phase2SessionId: phase2.sendResult.sessionId,
phase3SessionId: phase3.sendResult.sessionId,
phase1ProjectId: phase1.finalState.workspaceSummary.currentProjectId,
phase2ProjectId: phase2.finalState.workspaceSummary.currentProjectId,
phase3ProjectId: phase3.finalState.workspaceSummary.currentProjectId,
phase1LatestStatusLabel: phase1.sendResult.streamSmoke.latestStatusLabel,
phase2LatestStatusLabel: phase2.sendResult.streamSmoke.latestStatusLabel,
phase3LatestStatusLabel: phase3.sendResult.streamSmoke.latestStatusLabel,
phase1ExpectedReadmeMarker: expectedReadmeMarkerA,
phase3ExpectedReadmeMarker: expectedReadmeMarkerB,
manifestConfigVersionAfterReplacement: manifestRecord.configVersion || null,
manifestRemoteEtagAfterReplacement: manifestRecord.remoteEtag || null,
manifestSharedSkillEntriesAfterReplacement: manifestRecord.sharedSkillEntries || [],
manifestSharedCronEntriesAfterReplacement: manifestRecord.sharedCronEntries || [],
readmePath
}, null, 2));
"@ $phase1Output $phase2Output $phase3Output $userDataPath $bundleZipPathA $bundleZipPathB $expectedBundleSourceUrl $bundleReadmeMarkerA $bundleReadmeMarkerB $bundleSharedSkillA $bundleSharedSkillB $bundleSharedCronA $bundleSharedCronB
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Write-Output $summary
}
finally {
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_ZIP_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_FILE_NAME -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_ID -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_TITLE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_SKILL_DESCRIPTION -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_BUNDLE_CONFIG_VERSION -ErrorAction SilentlyContinue
}
\ No newline at end of file
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
param(
param(
[string]$SmokeOutput,
[int]$SmokePort = 4318,
[string]$SmokeToken = 'smoke-token',
......@@ -6,7 +6,23 @@
[string]$LogsPath,
[string]$RuntimeMode = 'auto',
[switch]$ExpectBundledRuntime,
[int]$TimeoutSeconds = 60
[string]$SmokePrompt,
[string]$SmokeSkillId,
[switch]$PreserveUserData,
[switch]$PrepareWorkspaceEntryFixture,
[switch]$ExpectWorkspaceEntry,
[switch]$ExpectRemoteBundle,
[string]$WorkspaceProjectId = 'workspace-entry-smoke',
[string]$WorkspaceProjectName = 'Workspace Entry Smoke',
[string]$WorkspaceProjectDescription = 'Workspace-entry smoke fixture for desktop project isolation.',
[string]$WorkspaceMarkerFile = 'AGENT.md',
[string]$ExpectedBundleSourceUrl,
[string]$ExpectedBundleConfigVersion,
[string]$ExpectedBundleFileName,
[string]$ExpectedBundleSkillId,
[string]$ExpectedReadmeMarker,
[string]$UnexpectedReadmeMarker,
[int]$TimeoutSeconds = 180
)
$ErrorActionPreference = 'Stop'
......@@ -35,6 +51,13 @@ if (-not $LogsPath) {
$SmokeOutput = [System.IO.Path]::GetFullPath($SmokeOutput)
$UserDataPath = [System.IO.Path]::GetFullPath($UserDataPath)
$LogsPath = [System.IO.Path]::GetFullPath($LogsPath)
$workspaceProjectRoot = Join-Path $UserDataPath (Join-Path 'projects' $WorkspaceProjectId)
function Write-Utf8File {
param([string]$filePath, [string]$content)
$encoding = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($filePath, $content, $encoding)
}
foreach ($pathValue in @($SmokeOutput, $UserDataPath, $LogsPath)) {
$parent = Split-Path $pathValue -Parent
......@@ -46,7 +69,7 @@ foreach ($pathValue in @($SmokeOutput, $UserDataPath, $LogsPath)) {
if (Test-Path $SmokeOutput) {
Remove-Item $SmokeOutput -Force
}
if (Test-Path $UserDataPath) {
if (-not $PreserveUserData -and (Test-Path $UserDataPath)) {
Remove-Item $UserDataPath -Recurse -Force -ErrorAction SilentlyContinue
}
if (Test-Path $LogsPath) {
......@@ -54,6 +77,46 @@ if (Test-Path $LogsPath) {
}
New-Item -ItemType Directory -Force -Path $UserDataPath, $LogsPath | Out-Null
if ($PrepareWorkspaceEntryFixture) {
$workspaceManifestRoot = Join-Path $UserDataPath 'manifests'
$workspaceMemoryRoot = Join-Path $workspaceProjectRoot 'memory'
New-Item -ItemType Directory -Force -Path $workspaceProjectRoot, $workspaceManifestRoot, $workspaceMemoryRoot | Out-Null
$timestamp = (Get-Date).ToUniversalTime().ToString('o')
$projectPayload = [ordered]@{
id = $WorkspaceProjectId
name = $WorkspaceProjectName
description = $WorkspaceProjectDescription
ready = $true
boundSkillIds = @()
updatedAt = $timestamp
workspaceEntryEnabled = $true
}
$projectJsonPath = Join-Path $workspaceProjectRoot 'project.json'
Write-Utf8File $projectJsonPath ($projectPayload | ConvertTo-Json -Depth 6)
$markerContents = @(
'# Workspace Entry Smoke Fixture',
'',
'This project exists to validate desktop project-isolated workspace execution.',
'If asked, confirm the project root and that the request ran inside this workspace.'
) -join [Environment]::NewLine
Write-Utf8File (Join-Path $workspaceProjectRoot $WorkspaceMarkerFile) $markerContents
$readmeContents = @(
'# Workspace Entry Smoke',
'',
'This fixture should be selected as the active desktop project during smoke validation.'
) -join [Environment]::NewLine
Write-Utf8File (Join-Path $workspaceProjectRoot 'README.md') $readmeContents
$activeProjectPayload = [ordered]@{
projectId = $WorkspaceProjectId
}
Write-Utf8File (Join-Path $workspaceManifestRoot 'active-project.json') ($activeProjectPayload | ConvertTo-Json -Depth 3)
Write-Host "Prepared workspace-entry fixture at $workspaceProjectRoot"
}
$env:QJCLAW_RENDERER_URL = $rendererUrl
$env:QJCLAW_SMOKE_OUTPUT = $SmokeOutput
$env:QJCLAW_SMOKE_CLOUD_API_BASE_URL = "http://127.0.0.1:$SmokePort"
......@@ -68,6 +131,12 @@ $env:QJCLAW_LOGS_PATH = $LogsPath
if ($RuntimeMode) {
$env:QJCLAW_RUNTIME_MODE = $RuntimeMode
}
if ($PSBoundParameters.ContainsKey('SmokePrompt')) {
$env:QJCLAW_SMOKE_PROMPT = $SmokePrompt
}
if ($PSBoundParameters.ContainsKey('SmokeSkillId')) {
$env:QJCLAW_SMOKE_SKILL_ID = $SmokeSkillId
}
try {
Write-Host "Running Electron smoke with isolated userData at $UserDataPath"
......@@ -89,8 +158,13 @@ try {
}
if ($alive) {
Stop-Process -Id $process.Id -Force
throw "Electron smoke process did not exit within $TimeoutSeconds seconds."
if (Test-Path $SmokeOutput) {
Write-Host 'Smoke output captured; terminating lingering Electron process.'
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
} else {
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
throw "Electron smoke process did not exit within $TimeoutSeconds seconds."
}
}
if (-not (Test-Path $SmokeOutput)) {
......@@ -98,10 +172,30 @@ try {
}
$expectBundledValue = if ($ExpectBundledRuntime) { 'true' } else { 'false' }
$expectWorkspaceEntryValue = if ($ExpectWorkspaceEntry) { 'true' } else { 'false' }
$expectRemoteBundleValue = if ($ExpectRemoteBundle) { 'true' } else { 'false' }
$validator = @"
const fs = require('fs');
const path = require('path');
const [smokeOutput, expectedUserData, expectedLogs, runtimeMode, expectBundled] = process.argv.slice(1);
const [
smokeOutput,
expectedUserData,
expectedLogs,
runtimeMode,
expectBundled,
expectWorkspaceEntry,
expectRemoteBundle,
workspaceProjectId,
workspaceProjectName,
workspaceMarkerFile,
expectedBundleSourceUrl,
expectedBundleConfigVersion,
expectedBundleFileName,
expectedBundleSkillId,
expectedReadmeMarker,
unexpectedReadmeMarker
] = process.argv.slice(1);
const result = JSON.parse(fs.readFileSync(smokeOutput, 'utf8'));
if (!result.ok) {
const message = result.error || 'Unknown smoke failure.';
......@@ -186,12 +280,125 @@ if (expectBundled === 'true') {
throw new Error('Gateway did not reconnect after bundled runtime startup: ' + (sendResult.status && sendResult.status.state));
}
}
let workspaceEntryValidated = false;
if (expectWorkspaceEntry === 'true') {
const latestStatusLabel = String(streamSmoke.latestStatusLabel || '');
const statusLabels = Array.isArray(streamSmoke.statusLabels)
? streamSmoke.statusLabels.map((value) => String(value || ''))
: [];
const assistantContent = String((sendResult.lastAssistantMessage && sendResult.lastAssistantMessage.content) || streamSmoke.finalContent || streamSmoke.renderedContent || '');
const expectedProjectRoot = path.join(expectedUserData, 'projects', workspaceProjectId);
const expectedSessionPrefix = 'project:' + workspaceProjectId + ':';
const selectedSkillId = String(sendResult.selectedSkillId || streamSmoke.selectedSkillId || '');
if (!statusLabels.some((label) => label.toLowerCase().includes('workspace'))) {
throw new Error('Workspace-entry smoke did not report a workspace status label. history=' + JSON.stringify(statusLabels) + ' latest=' + latestStatusLabel);
}
if (!assistantContent.includes('desktop project-isolated workspace')) {
throw new Error('Workspace-entry smoke did not echo the injected isolated workspace context.');
}
if (!assistantContent.includes('Current project: ' + workspaceProjectName + ' (' + workspaceProjectId + ')')) {
throw new Error('Workspace-entry smoke did not reference the expected project identity.');
}
if (!assistantContent.includes('Project root: ' + expectedProjectRoot)) {
throw new Error('Workspace-entry smoke did not reference the expected project root.');
}
if (!assistantContent.includes('Keep project context isolated to this project and session.')) {
throw new Error('Workspace-entry smoke did not preserve the project isolation instruction.');
}
if (selectedSkillId) {
throw new Error('Workspace-entry smoke unexpectedly selected a skill: ' + selectedSkillId);
}
if (!String(sendResult.sessionId || streamSmoke.sessionId || '').startsWith(expectedSessionPrefix)) {
throw new Error('Workspace-entry smoke did not bind the session to the expected project: ' + String(sendResult.sessionId || streamSmoke.sessionId || ''));
}
if (Number(streamSmoke.deltaEventCount || 0) < 1) {
throw new Error('Workspace-entry smoke did not emit a delta event.');
}
if (Number(sendResult.messageCount || 0) < 2) {
throw new Error('Workspace-entry smoke did not persist the expected user/assistant message pair.');
}
const markerPath = path.join(expectedProjectRoot, workspaceMarkerFile);
if (!fs.existsSync(markerPath)) {
throw new Error('Workspace-entry smoke fixture marker file was not found: ' + markerPath);
}
const readmePath = path.join(expectedProjectRoot, 'README.md');
const readmeContent = fs.existsSync(readmePath) ? fs.readFileSync(readmePath, 'utf8') : '';
if (expectedReadmeMarker && !assistantContent.includes(expectedReadmeMarker)) {
throw new Error('Workspace-entry smoke did not include the expected README marker in assistant content: ' + expectedReadmeMarker);
}
if (expectedReadmeMarker && !readmeContent.includes(expectedReadmeMarker)) {
throw new Error('Workspace-entry smoke did not materialize the expected README marker on disk: ' + expectedReadmeMarker);
}
if (unexpectedReadmeMarker && assistantContent.includes(unexpectedReadmeMarker)) {
throw new Error('Workspace-entry smoke still included the stale README marker in assistant content: ' + unexpectedReadmeMarker);
}
if (unexpectedReadmeMarker && readmeContent.includes(unexpectedReadmeMarker)) {
throw new Error('Workspace-entry smoke still materialized the stale README marker on disk: ' + unexpectedReadmeMarker);
}
workspaceEntryValidated = true;
}
let remoteBundleValidated = false;
if (expectRemoteBundle === 'true') {
const workspaceSummary = result.finalState && result.finalState.workspaceSummary
? result.finalState.workspaceSummary
: (result.initialState && result.initialState.workspaceSummary) || {};
const currentProjectId = String(workspaceSummary.currentProjectId || '');
const currentProjectName = String(workspaceSummary.currentProjectName || '');
const bundleManifestPath = path.join(expectedUserData, 'manifests', 'project-bundles.json');
const activeProjectManifestPath = path.join(expectedUserData, 'manifests', 'active-project.json');
const projectRootPath = path.join(expectedUserData, 'projects', workspaceProjectId);
if (currentProjectId !== workspaceProjectId) {
throw new Error('Remote bundle smoke did not activate the expected project: ' + currentProjectId);
}
if (currentProjectName !== workspaceProjectName) {
throw new Error('Remote bundle smoke did not expose the expected project name: ' + currentProjectName);
}
if (!fs.existsSync(bundleManifestPath)) {
throw new Error('Remote bundle smoke did not produce project-bundles manifest: ' + bundleManifestPath);
}
if (!fs.existsSync(activeProjectManifestPath)) {
throw new Error('Remote bundle smoke did not persist active-project manifest: ' + activeProjectManifestPath);
}
if (!fs.existsSync(projectRootPath)) {
throw new Error('Remote bundle smoke did not materialize project root: ' + projectRootPath);
}
const bundleManifest = JSON.parse(fs.readFileSync(bundleManifestPath, 'utf8'));
const manifestRecord = bundleManifest && bundleManifest[workspaceProjectId];
if (!manifestRecord || typeof manifestRecord !== 'object') {
throw new Error('Remote bundle smoke did not record manifest entry for project ' + workspaceProjectId);
}
const activeProjectRecord = JSON.parse(fs.readFileSync(activeProjectManifestPath, 'utf8'));
if (String(activeProjectRecord.projectId || '') !== workspaceProjectId) {
throw new Error('Remote bundle smoke did not persist the expected active project id: ' + String(activeProjectRecord.projectId || ''));
}
if (expectedBundleSourceUrl && String(manifestRecord.sourceUrl || '') !== expectedBundleSourceUrl) {
throw new Error('Remote bundle manifest sourceUrl mismatch: ' + String(manifestRecord.sourceUrl || ''));
}
if (expectedBundleConfigVersion && String(manifestRecord.configVersion || '') !== expectedBundleConfigVersion) {
throw new Error('Remote bundle manifest configVersion mismatch: ' + String(manifestRecord.configVersion || ''));
}
if (expectedBundleFileName && String(manifestRecord.fileName || '') !== expectedBundleFileName) {
throw new Error('Remote bundle manifest fileName mismatch: ' + String(manifestRecord.fileName || ''));
}
if (expectedBundleSkillId && String(manifestRecord.sourceSkillId || '') !== expectedBundleSkillId) {
throw new Error('Remote bundle manifest sourceSkillId mismatch: ' + String(manifestRecord.sourceSkillId || ''));
}
if (String(manifestRecord.projectId || '') !== workspaceProjectId) {
throw new Error('Remote bundle manifest projectId mismatch: ' + String(manifestRecord.projectId || ''));
}
if (!String(manifestRecord.checksum || '')) {
throw new Error('Remote bundle manifest did not record checksum.');
}
remoteBundleValidated = true;
}
const summary = {
ok: true,
smokeOutput,
runtimeMode,
userDataPath: expectedUserData,
logsPath: expectedLogs,
currentProjectId: String(((result.finalState && result.finalState.workspaceSummary) || (result.initialState && result.initialState.workspaceSummary) || {}).currentProjectId || ''),
currentProjectName: String(((result.finalState && result.finalState.workspaceSummary) || (result.initialState && result.initialState.workspaceSummary) || {}).currentProjectName || ''),
selectedSkillId: String(sendResult.selectedSkillId || ''),
executionPolicySource: String(streamSmoke.executionPolicySource || ''),
executionPolicyModel: String(streamSmoke.executionPolicyModel || ''),
......@@ -199,6 +406,10 @@ const summary = {
streamStartedEventCount: Number(streamSmoke.startedEventCount || 0),
streamDeltaEventCount: Number(streamSmoke.deltaEventCount || 0),
streamCompletedEventCount: Number(streamSmoke.completedEventCount || 0),
latestStatusLabel: String(streamSmoke.latestStatusLabel || ''),
statusLabels: Array.isArray(streamSmoke.statusLabels) ? streamSmoke.statusLabels.map((value) => String(value || '')) : [],
expectedReadmeMarker: String(expectedReadmeMarker || ''),
unexpectedReadmeMarker: String(unexpectedReadmeMarker || ''),
runtimeActiveMode: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.activeMode || ''),
runtimeProcessState: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.processState || ''),
runtimeGatewayUrl: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.gatewayUrl || ''),
......@@ -209,11 +420,13 @@ const summary = {
heartbeatSuccessCount: Number(runtimeTelemetry.heartbeatSuccessCount || 0),
configSyncSuccessCount: Number(runtimeTelemetry.configSyncSuccessCount || 0),
totalAcceptedEventCount: Number(runtimeTelemetry.totalAcceptedEventCount || 0),
workspaceEntryValidated,
remoteBundleValidated,
diagnosticsPath,
};
console.log(JSON.stringify(summary, null, 2));
"@
$summary = & node -e $validator $SmokeOutput $UserDataPath $LogsPath $RuntimeMode $expectBundledValue
$summary = & node -e $validator $SmokeOutput $UserDataPath $LogsPath $RuntimeMode $expectBundledValue $expectWorkspaceEntryValue $expectRemoteBundleValue $WorkspaceProjectId $WorkspaceProjectName $WorkspaceMarkerFile $ExpectedBundleSourceUrl $ExpectedBundleConfigVersion $ExpectedBundleFileName $ExpectedBundleSkillId $ExpectedReadmeMarker $UnexpectedReadmeMarker
if ($LASTEXITCODE -ne 0) {
throw 'Electron smoke validation failed.'
}
......@@ -232,8 +445,7 @@ finally {
Remove-Item Env:QJCLAW_USER_DATA_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_LOGS_PATH -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_RUNTIME_MODE -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_PROMPT -ErrorAction SilentlyContinue
Remove-Item Env:QJCLAW_SMOKE_SKILL_ID -ErrorAction SilentlyContinue
}
param(
[string]$BaseOutputDir,
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token'
)
$ErrorActionPreference = 'Stop'
function Get-TrailingJson {
param(
[Parameter(Mandatory = $true)]
[string]$Text
)
$startIndex = $Text.LastIndexOf("`n{")
if ($startIndex -ge 0) {
return $Text.Substring($startIndex + 1).Trim()
}
$startIndex = $Text.IndexOf('{')
if ($startIndex -ge 0) {
return $Text.Substring($startIndex).Trim()
}
throw 'Unable to locate JSON payload in materialize-runtime-payload output.'
}
function Invoke-MaterializeRun {
param(
[Parameter(Mandatory = $true)]
[string]$Label,
[Parameter(Mandatory = $true)]
[string]$ScriptPath,
[Parameter(Mandatory = $true)]
[string]$RuntimeDir,
[Parameter(Mandatory = $true)]
[string]$StdoutPath,
[Parameter(Mandatory = $true)]
[string]$StderrPath,
[Parameter(Mandatory = $true)]
[int]$GatewayPort,
[Parameter(Mandatory = $true)]
[string]$GatewayToken
)
if (Test-Path $StdoutPath) {
Remove-Item -LiteralPath $StdoutPath -Force
}
if (Test-Path $StderrPath) {
Remove-Item -LiteralPath $StderrPath -Force
}
Write-Host "Running materialize-runtime-payload.ps1 ($Label)"
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$process = Start-Process `
-FilePath 'powershell' `
-ArgumentList @(
'-ExecutionPolicy', 'Bypass',
'-File', $ScriptPath,
'-RuntimeDir', $RuntimeDir,
'-GatewayPort', $GatewayPort.ToString(),
'-GatewayToken', $GatewayToken
) `
-Wait `
-PassThru `
-NoNewWindow `
-RedirectStandardOutput $StdoutPath `
-RedirectStandardError $StderrPath
$stopwatch.Stop()
$stdout = if (Test-Path $StdoutPath) { [string](Get-Content -Path $StdoutPath -Raw) } else { '' }
$stderr = if (Test-Path $StderrPath) { [string](Get-Content -Path $StderrPath -Raw) } else { '' }
if ($stdout -and $stdout.Trim()) {
Write-Host ($stdout.TrimEnd())
}
if ($stderr -and $stderr.Trim()) {
Write-Warning ($stderr.TrimEnd())
}
if ($process.ExitCode -ne 0) {
throw "materialize-runtime-payload.ps1 failed during $Label with exit code $($process.ExitCode)"
}
$summary = Get-TrailingJson -Text $stdout | ConvertFrom-Json
return [PSCustomObject]@{
label = $Label
exitCode = $process.ExitCode
elapsedMs = [math]::Round($stopwatch.Elapsed.TotalMilliseconds, 2)
stdoutPath = $StdoutPath
stderrPath = $StderrPath
stdout = $stdout
stderr = $stderr
summary = $summary
}
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $BaseOutputDir) {
$BaseOutputDir = Join-Path $repoRoot '.tmp\mrcs'
}
$BaseOutputDir = [System.IO.Path]::GetFullPath($BaseOutputDir)
$runtimeDir = Join-Path $BaseOutputDir 'rt'
$logsDir = Join-Path $BaseOutputDir 'logs'
$materializeScript = Join-Path $repoRoot 'build\scripts\materialize-runtime-payload.ps1'
$manifestPath = Join-Path $runtimeDir 'runtime-manifest.json'
$pythonManifestPath = Join-Path $runtimeDir 'python\python-manifest.json'
if (Test-Path $BaseOutputDir) {
Remove-Item -LiteralPath $BaseOutputDir -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Force -Path $BaseOutputDir, $logsDir | Out-Null
$firstRun = Invoke-MaterializeRun `
-Label 'first-run' `
-ScriptPath $materializeScript `
-RuntimeDir $runtimeDir `
-StdoutPath (Join-Path $logsDir 'first-run.stdout.log') `
-StderrPath (Join-Path $logsDir 'first-run.stderr.log') `
-GatewayPort $GatewayPort `
-GatewayToken $GatewayToken
if (-not $firstRun.summary.ok) {
throw 'First materialization run did not report ok=true.'
}
if ($firstRun.summary.cacheHit) {
throw 'First materialization run unexpectedly reported cacheHit=true.'
}
if (-not ($firstRun.stdout -match 'Upgrading pip in bundled Python runtime')) {
throw 'First materialization run did not perform the pip upgrade step.'
}
if (-not ($firstRun.stdout -match 'Installing locked runtime dependencies')) {
throw 'First materialization run did not install locked runtime dependencies.'
}
if (-not (Test-Path $manifestPath)) {
throw "Expected runtime manifest at $manifestPath after first run."
}
if (-not (Test-Path $pythonManifestPath)) {
throw "Expected Python manifest at $pythonManifestPath after first run."
}
$secondRun = Invoke-MaterializeRun `
-Label 'second-run' `
-ScriptPath $materializeScript `
-RuntimeDir $runtimeDir `
-StdoutPath (Join-Path $logsDir 'second-run.stdout.log') `
-StderrPath (Join-Path $logsDir 'second-run.stderr.log') `
-GatewayPort $GatewayPort `
-GatewayToken $GatewayToken
if (-not $secondRun.summary.ok) {
throw 'Second materialization run did not report ok=true.'
}
if (-not $secondRun.summary.cacheHit) {
throw 'Second materialization run did not report cacheHit=true.'
}
if ($secondRun.summary.materializationKey -ne $firstRun.summary.materializationKey) {
throw 'Materialization key changed between the first and second run.'
}
if (-not ($secondRun.stdout -match 'Reusing bundled runtime payload')) {
throw 'Second materialization run did not report runtime payload reuse.'
}
if ($secondRun.stdout -match 'Upgrading pip in bundled Python runtime') {
throw 'Second materialization run unexpectedly reran the pip upgrade step.'
}
if ($secondRun.stdout -match 'Installing locked runtime dependencies') {
throw 'Second materialization run unexpectedly reinstalled locked runtime dependencies.'
}
[PSCustomObject]@{
ok = $true
runtimeDir = $runtimeDir
manifestPath = $manifestPath
firstRun = [PSCustomObject]@{
cacheHit = $firstRun.summary.cacheHit
elapsedMs = $firstRun.elapsedMs
materializationKey = $firstRun.summary.materializationKey
stdoutPath = $firstRun.stdoutPath
stderrPath = $firstRun.stderrPath
}
secondRun = [PSCustomObject]@{
cacheHit = $secondRun.summary.cacheHit
elapsedMs = $secondRun.elapsedMs
materializationKey = $secondRun.summary.materializationKey
stdoutPath = $secondRun.stdoutPath
stderrPath = $secondRun.stderrPath
}
} | ConvertTo-Json -Depth 10
......@@ -11,6 +11,100 @@ param(
$ErrorActionPreference = 'Stop'
function Get-FileFingerprint {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[switch]$IncludeHash
)
$item = Get-Item -LiteralPath $Path -ErrorAction Stop
$fingerprint = [ordered]@{
path = [System.IO.Path]::GetFullPath($Path)
length = $item.Length
lastWriteTimeUtc = $item.LastWriteTimeUtc.ToString('o')
}
if ($IncludeHash) {
$fingerprint.hashSha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
return $fingerprint
}
function Get-MaterializationKey {
param(
[Parameter(Mandatory = $true)]
[hashtable]$Inputs
)
$serialized = $Inputs | ConvertTo-Json -Depth 20 -Compress
$bytes = [System.Text.Encoding]::UTF8.GetBytes($serialized)
$sha256 = [System.Security.Cryptography.SHA256Managed]::Create()
try {
$hashBytes = $sha256.ComputeHash($bytes)
} finally {
$sha256.Dispose()
}
return ([System.BitConverter]::ToString($hashBytes)).Replace('-', '').ToLowerInvariant()
}
function Test-RuntimePayloadReady {
param(
[Parameter(Mandatory = $true)]
[string]$RuntimeDir
)
$requiredPaths = @(
(Join-Path $RuntimeDir 'node\node.exe'),
(Join-Path $RuntimeDir 'openclaw\index.js'),
(Join-Path $RuntimeDir 'openclaw\package\openclaw.mjs'),
(Join-Path $RuntimeDir 'openclaw\package\package.json'),
(Join-Path $RuntimeDir 'config\openclaw.json'),
(Join-Path $RuntimeDir 'python\python.exe'),
(Join-Path $RuntimeDir 'python\python-manifest.json'),
(Join-Path $RuntimeDir 'python\runtime-requirements.lock.txt'),
(Join-Path $RuntimeDir 'runtime-manifest.json'),
(Join-Path $RuntimeDir 'README.md')
)
foreach ($requiredPath in $requiredPaths) {
if (-not (Test-Path $requiredPath)) {
return $false
}
}
return $true
}
function New-RuntimeSummary {
param(
[Parameter(Mandatory = $true)]
[string]$RuntimeDir,
[Parameter(Mandatory = $true)]
[psobject]$Manifest,
[bool]$CacheHit = $false
)
return [PSCustomObject]@{
ok = $true
cacheHit = $CacheHit
materializationKey = $Manifest.materializationKey
runtimeDir = $RuntimeDir
nodeExecutable = (Join-Path $RuntimeDir 'node\node.exe')
openClawEntry = (Join-Path $RuntimeDir 'openclaw\index.js')
openClawPackageDir = (Join-Path $RuntimeDir 'openclaw\package')
configPath = (Join-Path $RuntimeDir 'config\openclaw.json')
manifestPath = (Join-Path $RuntimeDir 'runtime-manifest.json')
pythonExecutable = (Join-Path $RuntimeDir 'python\python.exe')
pythonManifestPath = (Join-Path $RuntimeDir 'python\python-manifest.json')
requirementsPath = (Join-Path $RuntimeDir 'python\runtime-requirements.lock.txt')
gatewayPort = $Manifest.gatewayPort
gatewayToken = $Manifest.gatewayToken
installedPythonPackages = $Manifest.installedPythonPackages
}
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $RuntimeDir) {
$RuntimeDir = Join-Path $repoRoot 'vendor\openclaw-runtime'
......@@ -54,6 +148,7 @@ $SourcePythonExe = [System.IO.Path]::GetFullPath($SourcePythonExe)
$RequirementsPath = [System.IO.Path]::GetFullPath($RequirementsPath)
$SourceOpenClawDir = Split-Path $SourceOpenClawEntry -Parent
$SourcePythonDir = Split-Path $SourcePythonExe -Parent
$SourceOpenClawPackageJsonPath = Join-Path $SourceOpenClawDir 'package.json'
if (-not (Test-Path $SourceConfigPath)) {
throw "OpenClaw config not found at $SourceConfigPath"
......@@ -73,10 +168,39 @@ if (-not (Test-Path $RequirementsPath)) {
if (-not (Test-Path $SourceOpenClawDir)) {
throw "OpenClaw package directory not found at $SourceOpenClawDir"
}
if (-not (Test-Path $SourceOpenClawPackageJsonPath)) {
throw "OpenClaw package.json not found at $SourceOpenClawPackageJsonPath"
}
if (-not (Test-Path $SourcePythonDir)) {
throw "Python installation directory not found at $SourcePythonDir"
}
$materializationInputs = [ordered]@{
schemaVersion = 1
gatewayPort = $GatewayPort
gatewayToken = $GatewayToken
sourceConfig = Get-FileFingerprint -Path $SourceConfigPath -IncludeHash
sourceNodeExe = Get-FileFingerprint -Path $SourceNodeExe
sourcePythonExe = Get-FileFingerprint -Path $SourcePythonExe
sourceOpenClawEntry = Get-FileFingerprint -Path $SourceOpenClawEntry
sourceOpenClawPackageJson = Get-FileFingerprint -Path $SourceOpenClawPackageJsonPath -IncludeHash
requirements = Get-FileFingerprint -Path $RequirementsPath -IncludeHash
}
$materializationKey = Get-MaterializationKey -Inputs $materializationInputs
$existingManifestPath = Join-Path $RuntimeDir 'runtime-manifest.json'
if ((Test-Path $existingManifestPath) -and (Test-RuntimePayloadReady -RuntimeDir $RuntimeDir)) {
try {
$existingManifest = Get-Content $existingManifestPath -Raw | ConvertFrom-Json
if ($existingManifest.materializationKey -eq $materializationKey) {
Write-Host "Reusing bundled runtime payload at $RuntimeDir (materialization key match)"
New-RuntimeSummary -RuntimeDir $RuntimeDir -Manifest $existingManifest -CacheHit $true | ConvertTo-Json -Depth 20
exit 0
}
} catch {
Write-Warning "Existing runtime manifest could not be reused: $($_.Exception.Message)"
}
}
$stagingDir = Join-Path (Split-Path $RuntimeDir -Parent) ('openclaw-runtime.staging.' + ([guid]::NewGuid().ToString('N')))
$nodeDir = Join-Path $stagingDir 'node'
$openclawDir = Join-Path $stagingDir 'openclaw'
......@@ -204,6 +328,8 @@ print(json.dumps(payload))
gatewayPort = $GatewayPort
gatewayToken = $GatewayToken
materializedAt = $materializedAt
materializationKey = $materializationKey
materializationInputs = $materializationInputs
pythonVersion = $pythonManifest.pythonVersion
openClawVersion = if ($openClawPackage) { $openClawPackage.version } else { $null }
installedPythonPackages = @($pythonManifest.requestedPackages | ForEach-Object { "$($_.name)==$($_.version)" })
......@@ -279,21 +405,7 @@ The payload is considered ready only when the Node entry, OpenClaw package, Pyth
Move-Item -Path $stagingDir -Destination $RuntimeDir
$stagingDir = $null
[PSCustomObject]@{
ok = $true
runtimeDir = $RuntimeDir
nodeExecutable = (Join-Path $RuntimeDir 'node\node.exe')
openClawEntry = (Join-Path $RuntimeDir 'openclaw\index.js')
openClawPackageDir = (Join-Path $RuntimeDir 'openclaw\package')
configPath = (Join-Path $RuntimeDir 'config\openclaw.json')
manifestPath = (Join-Path $RuntimeDir 'runtime-manifest.json')
pythonExecutable = (Join-Path $RuntimeDir 'python\python.exe')
pythonManifestPath = (Join-Path $RuntimeDir 'python\python-manifest.json')
requirementsPath = (Join-Path $RuntimeDir 'python\runtime-requirements.lock.txt')
gatewayPort = $GatewayPort
gatewayToken = $GatewayToken
installedPythonPackages = $manifest.installedPythonPackages
} | ConvertTo-Json -Depth 20
New-RuntimeSummary -RuntimeDir $RuntimeDir -Manifest ([pscustomobject]$manifest) | ConvertTo-Json -Depth 20
} finally {
if ($stagingDir -and (Test-Path $stagingDir)) {
Remove-Item -Recurse -Force $stagingDir -ErrorAction SilentlyContinue
......
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"
}
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { once } from "node:events";
import { fileURLToPath } from "node:url";
import { AppConfigService } from "../../apps/desktop/src/main/services/app-config.js";
import { ProjectBundleService } from "../../apps/desktop/src/main/services/project-bundle.js";
import { ProjectStoreService } from "../../apps/desktop/src/main/services/project-store.js";
import type { RemoteSkillAsset } 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 pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}
async function readJsonFile<T>(filePath: string): Promise<T | null> {
try {
const raw = await readFile(filePath, "utf8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
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-bundle-freshness-smoke", "result.json"));
const bundleZipPathA = path.resolve(process.argv[3] ?? path.join(path.dirname(resultPath), "bundle-a.zip"));
const bundleZipPathB = path.resolve(process.argv[4] ?? path.join(path.dirname(resultPath), "bundle-b.zip"));
const tempRoot = path.dirname(resultPath);
const userDataPath = path.join(tempRoot, "user-data");
const projectId = "bundle-freshness-smoke";
const configVersion = "freshness-config-v1";
let activeVariant: "a" | "b" = "a";
assert(await pathExists(bundleZipPathA), `Bundle zip fixture A was not found: ${bundleZipPathA}`);
assert(await pathExists(bundleZipPathB), `Bundle zip fixture B was not found: ${bundleZipPathB}`);
await rm(userDataPath, { recursive: true, force: true });
await mkdir(userDataPath, { recursive: true });
const server = http.createServer(async (request, response) => {
try {
if (request.url !== "/bundle.zip") {
response.statusCode = 404;
response.end("not found");
return;
}
const variantPath = activeVariant === "a" ? bundleZipPathA : bundleZipPathB;
const payload = await readFile(variantPath);
const etag = activeVariant === "a" ? '"bundle-freshness-a"' : '"bundle-freshness-b"';
const lastModified = activeVariant === "a"
? "Tue, 31 Mar 2026 13:00:00 GMT"
: "Tue, 31 Mar 2026 13:05:00 GMT";
if (request.method === "HEAD") {
response.writeHead(200, {
ETag: etag,
"Last-Modified": lastModified,
"Content-Length": "4096",
"Cache-Control": "no-store"
});
response.end();
return;
}
if (request.method === "GET") {
response.writeHead(200, {
"Content-Type": "application/zip",
"Cache-Control": "no-store"
});
response.end(payload);
return;
}
response.statusCode = 405;
response.end("method not allowed");
} catch (error) {
response.statusCode = 500;
response.end(error instanceof Error ? error.message : String(error));
}
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address === "object", "Failed to start local freshness smoke server.");
const downloadUrl = `http://127.0.0.1:${address.port}/bundle.zip`;
try {
const configService = new AppConfigService(userDataPath);
await configService.load();
const projectStore = new ProjectStoreService(configService);
await projectStore.initialize();
const projectBundleService = new ProjectBundleService(configService, projectStore);
const remoteAsset: RemoteSkillAsset = {
bindingId: "bundle-freshness-binding",
skillId: "bundle-freshness-skill-asset",
name: "Bundle Freshness Smoke Asset",
description: "Remote bundle smoke asset for freshness validation.",
category: "project",
fileName: "bundle.zip",
downloadUrl
};
await projectBundleService.syncRemoteBundles([remoteAsset], configVersion, "sync");
const workspaceRoot = await projectStore.getWorkspaceRoot();
const manifestPath = path.join(workspaceRoot, "manifests", "project-bundles.json");
const readmePath = path.join(workspaceRoot, "projects", projectId, "README.md");
const manifestAfterFirstSync = (await readJsonFile<Record<string, {
checksum?: string;
remoteEtag?: string;
remoteLastModified?: string;
remoteContentLength?: number;
}>>(manifestPath)) ?? {};
const firstRecord = manifestAfterFirstSync[projectId];
assert(firstRecord, "First bundle sync did not persist a manifest record.");
assert(firstRecord.remoteEtag === '"bundle-freshness-a"', `Unexpected first remoteEtag: ${String(firstRecord.remoteEtag || "")}`);
assert(firstRecord.remoteLastModified === "Tue, 31 Mar 2026 13:00:00 GMT", `Unexpected first remoteLastModified: ${String(firstRecord.remoteLastModified || "")}`);
assert(firstRecord.remoteContentLength === 4096, `Unexpected first remoteContentLength: ${String(firstRecord.remoteContentLength ?? "")}`);
const readmeAfterFirstSync = await readFile(readmePath, "utf8");
assert(readmeAfterFirstSync.includes("Freshness variant A"), "First bundle sync did not materialize README variant A.");
activeVariant = "b";
await projectBundleService.syncRemoteBundles([remoteAsset], configVersion, "sync");
const manifestAfterSecondSync = (await readJsonFile<Record<string, {
checksum?: string;
remoteEtag?: string;
remoteLastModified?: string;
remoteContentLength?: number;
}>>(manifestPath)) ?? {};
const secondRecord = manifestAfterSecondSync[projectId];
assert(secondRecord, "Second bundle sync did not persist a manifest record.");
assert(secondRecord.remoteEtag === '"bundle-freshness-b"', `Unexpected second remoteEtag: ${String(secondRecord.remoteEtag || "")}`);
assert(secondRecord.remoteLastModified === "Tue, 31 Mar 2026 13:05:00 GMT", `Unexpected second remoteLastModified: ${String(secondRecord.remoteLastModified || "")}`);
assert(secondRecord.remoteContentLength === 4096, `Unexpected second remoteContentLength: ${String(secondRecord.remoteContentLength ?? "")}`);
assert(String(secondRecord.checksum || "") !== String(firstRecord.checksum || ""), "Bundle checksum did not change after remote freshness metadata changed.");
const readmeAfterSecondSync = await readFile(readmePath, "utf8");
assert(readmeAfterSecondSync.includes("Freshness variant B"), "Second bundle sync did not materialize README variant B.");
assert(!readmeAfterSecondSync.includes("Freshness variant A"), "Second bundle sync still contains README variant A content.");
const summary = {
ok: true,
userDataPath,
workspaceRoot,
bundleZipPathA,
bundleZipPathB,
downloadUrl,
configVersion,
firstChecksum: firstRecord.checksum ?? null,
secondChecksum: secondRecord.checksum ?? null,
firstRemoteEtag: firstRecord.remoteEtag ?? null,
secondRemoteEtag: secondRecord.remoteEtag ?? null,
firstRemoteLastModified: firstRecord.remoteLastModified ?? null,
secondRemoteLastModified: secondRecord.remoteLastModified ?? null,
readmePath
};
await mkdir(path.dirname(resultPath), { recursive: true });
await writeFile(resultPath, JSON.stringify(summary, null, 2), "utf8");
console.log(JSON.stringify(summary, null, 2));
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
}
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-bundle-freshness-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-bundle-reconcile-smoke.ts'
$tempRoot = Join-Path $repoRoot '.tmp\project-bundle-reconcile-smoke'
$compileRoot = Join-Path $tempRoot 'compiled'
$entryPath = Join-Path $compileRoot 'build\scripts\project-bundle-reconcile-smoke.js'
$compilePackagePath = Join-Path $compileRoot 'package.json'
$bundleRoot = Join-Path $tempRoot 'bundle-src'
$bundleZipPath = Join-Path $tempRoot 'bundle.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 reconcile 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 reconcile smoke with local TypeScript'
corepack @compileArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $entryPath)) {
throw "Project bundle reconcile smoke entry was not emitted: $entryPath"
}
if (Test-Path $bundleRoot) {
Remove-Item $bundleRoot -Recurse -Force
}
if (Test-Path $bundleZipPath) {
Remove-Item $bundleZipPath -Force
}
New-Item -ItemType Directory -Path (Join-Path $bundleRoot 'skills\bundle-reconcile-skill') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $bundleRoot 'memory') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $bundleRoot 'cron') -Force | Out-Null
Write-Utf8File -FilePath (Join-Path $bundleRoot 'project.json') -Content (@{
id = 'bundle-reconcile-smoke'
name = 'Bundle Reconcile Smoke'
version = '1.0.0'
description = 'Verifies stale bundle cleanup and reconciliation.'
} | ConvertTo-Json -Depth 5)
Write-Utf8File -FilePath (Join-Path $bundleRoot 'README.md') -Content '# Bundle Reconcile Smoke'
Write-Utf8File -FilePath (Join-Path $bundleRoot 'memory\summary.md') -Content 'Bundle reconcile smoke memory marker.'
Write-Utf8File -FilePath (Join-Path $bundleRoot 'skills\bundle-reconcile-skill\SKILL.md') -Content '# Bundle Reconcile Skill'
Write-Utf8File -FilePath (Join-Path $bundleRoot 'cron\bundle-reconcile-task.txt') -Content 'bundle cron smoke'
Compress-Archive -Path (Join-Path $bundleRoot '*') -DestinationPath $bundleZipPath -Force
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
Write-Host 'Running project-bundle reconcile smoke'
node $entryPath $resolvedResultPath $bundleZipPath
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $resolvedResultPath)) {
throw "Project bundle reconcile smoke did not produce a result file: $resolvedResultPath"
}
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { once } from "node:events";
import { fileURLToPath } from "node:url";
import { AppConfigService } from "../../apps/desktop/src/main/services/app-config.js";
import { ProjectBundleService } from "../../apps/desktop/src/main/services/project-bundle.js";
import { ProjectStoreService } from "../../apps/desktop/src/main/services/project-store.js";
import type { RemoteSkillAsset } 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 pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}
async function readJsonFile<T>(filePath: string): Promise<T | null> {
try {
const raw = await readFile(filePath, "utf8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
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-bundle-reconcile-smoke", "result.json"));
const bundleZipPath = path.resolve(process.argv[3] ?? path.join(path.dirname(resultPath), "bundle.zip"));
const tempRoot = path.dirname(resultPath);
const userDataPath = path.join(tempRoot, "user-data");
const projectId = "bundle-reconcile-smoke";
const skillEntryName = "bundle-reconcile-skill";
const cronEntryName = "bundle-reconcile-task.txt";
assert(await pathExists(bundleZipPath), `Bundle zip fixture was not found: ${bundleZipPath}`);
await rm(userDataPath, { recursive: true, force: true });
await mkdir(userDataPath, { recursive: true });
const server = http.createServer(async (request, response) => {
try {
if (request.url !== "/bundle.zip") {
response.statusCode = 404;
response.end("not found");
return;
}
const payload = await readFile(bundleZipPath);
response.statusCode = 200;
response.setHeader("Content-Type", "application/zip");
response.end(payload);
} catch (error) {
response.statusCode = 500;
response.end(error instanceof Error ? error.message : String(error));
}
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address === "object", "Failed to start local bundle smoke server.");
const downloadUrl = `http://127.0.0.1:${address.port}/bundle.zip`;
try {
const configService = new AppConfigService(userDataPath);
await configService.load();
const projectStore = new ProjectStoreService(configService);
await projectStore.initialize();
const projectBundleService = new ProjectBundleService(configService, projectStore);
const remoteAsset: RemoteSkillAsset = {
bindingId: "bundle-reconcile-binding",
skillId: "bundle-reconcile-skill-asset",
name: "Bundle Reconcile Smoke Asset",
description: "Remote bundle smoke asset for reconciliation.",
category: "project",
fileName: path.basename(bundleZipPath),
downloadUrl
};
await projectBundleService.syncRemoteBundles([remoteAsset], "config-v1", "sync");
const workspaceRoot = await projectStore.getWorkspaceRoot();
const projectPath = path.join(workspaceRoot, "projects", projectId);
const skillPath = path.join(workspaceRoot, "skills", skillEntryName);
const cronPath = path.join(workspaceRoot, "cron", cronEntryName);
const manifestPath = path.join(workspaceRoot, "manifests", "project-bundles.json");
assert(await pathExists(projectPath), "Bundle project was not materialized before reconciliation cleanup.");
assert(await pathExists(skillPath), "Bundle skill entry was not materialized before reconciliation cleanup.");
assert(await pathExists(cronPath), "Bundle cron entry was not materialized before reconciliation cleanup.");
const manifestBefore = (await readJsonFile<Record<string, {
sharedSkillEntries?: string[];
sharedCronEntries?: string[];
projectId: string;
sourceUrl: string;
}>>(manifestPath)) ?? {};
const manifestRecord = manifestBefore[projectId];
assert(manifestRecord, "Bundle manifest record was not persisted.");
assert(manifestRecord.projectId === projectId, "Bundle manifest record projectId did not match.");
assert(manifestRecord.sourceUrl === downloadUrl, "Bundle manifest record sourceUrl did not match.");
assert(manifestRecord.sharedSkillEntries?.includes(skillEntryName), "Bundle manifest record did not track shared skill ownership.");
assert(manifestRecord.sharedCronEntries?.includes(cronEntryName), "Bundle manifest record did not track cron ownership.");
const activeProjectAfterSync = await projectStore.getActiveProject();
assert(activeProjectAfterSync.id === projectId, "Bundle project did not become the active project after sync.");
await projectBundleService.syncRemoteBundles([], "config-v2", "sync");
assert(!(await pathExists(projectPath)), "Stale bundle project directory was not removed during reconciliation.");
assert(!(await pathExists(skillPath)), "Stale shared skill entry was not removed during reconciliation.");
assert(!(await pathExists(cronPath)), "Stale cron entry was not removed during reconciliation.");
const manifestAfter = (await readJsonFile<Record<string, unknown>>(manifestPath)) ?? {};
assert(Object.keys(manifestAfter).length === 0, "Bundle manifest was not pruned after reconciliation cleanup.");
const projectsAfterCleanup = await projectStore.listProjects();
assert(!projectsAfterCleanup.some((project) => project.id === projectId), "Removed bundle project still exists in project list after cleanup.");
assert(projectsAfterCleanup.length === 0, "Project list should be empty after removing the only bundle-backed project.");
const activeProjectFailure = await projectStore.getActiveProject()
.then(() => null)
.catch((error) => error instanceof Error ? error.message : String(error));
assert(activeProjectFailure === "No available projects.", "Active project lookup did not fail with the expected empty-inventory error.");
const summary = {
ok: true,
userDataPath,
workspaceRoot,
bundleZipPath,
downloadUrl,
projectId,
skillEntryName,
cronEntryName,
activeProjectAfterSync: activeProjectAfterSync.id,
activeProjectAfterCleanup: null,
activeProjectFailure,
projectsAfterCleanup: projectsAfterCleanup.map((project) => project.id),
manifestKeysAfterCleanup: Object.keys(manifestAfter)
};
await mkdir(path.dirname(resultPath), { recursive: true });
await writeFile(resultPath, JSON.stringify(summary, null, 2), "utf8");
console.log(JSON.stringify(summary, null, 2));
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
}
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-bundle-reconcile-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-bundle-replacement-smoke.ts'
$tempRoot = Join-Path $repoRoot '.tmp\project-bundle-replacement-smoke'
$compileRoot = Join-Path $tempRoot 'compiled'
$entryPath = Join-Path $compileRoot 'build\scripts\project-bundle-replacement-smoke.js'
$compilePackagePath = Join-Path $compileRoot 'package.json'
$bundleRootA = Join-Path $tempRoot 'bundle-src-a'
$bundleRootB = Join-Path $tempRoot 'bundle-src-b'
$bundleRootC = Join-Path $tempRoot 'bundle-src-c'
$bundleZipPathA = Join-Path $tempRoot 'bundle-a.zip'
$bundleZipPathB = Join-Path $tempRoot 'bundle-b.zip'
$bundleZipPathC = Join-Path $tempRoot 'bundle-c.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]$VariantLabel,
[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 = 'bundle-replacement-smoke'
name = 'Bundle Replacement Smoke'
version = '1.0.0'
description = 'Verifies bundle replacement and rollback hardening.'
} | ConvertTo-Json -Depth 5)
Write-Utf8File -FilePath (Join-Path $Root 'README.md') -Content ("# Bundle Replacement Smoke`n`nReplacement variant $VariantLabel")
Write-Utf8File -FilePath (Join-Path $Root 'memory\summary.md') -Content ("bundle replacement variant $VariantLabel")
Write-Utf8File -FilePath (Join-Path (Join-Path (Join-Path $Root 'skills') $SkillEntry) 'SKILL.md') -Content ("# Replacement Skill $VariantLabel")
Write-Utf8File -FilePath (Join-Path (Join-Path $Root 'cron') $CronEntry) -Content ("bundle replacement cron $VariantLabel")
}
if (-not (Test-Path $sourcePath)) {
throw "Project bundle replacement 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 replacement smoke with local TypeScript'
corepack @compileArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $entryPath)) {
throw "Project bundle replacement smoke entry was not emitted: $entryPath"
}
foreach ($path in @($bundleZipPathA, $bundleZipPathB, $bundleZipPathC)) {
if (Test-Path $path) {
Remove-Item $path -Force
}
}
New-BundleFixture -Root $bundleRootA -VariantLabel 'A' -SkillEntry 'replacement-skill-a' -CronEntry 'replacement-task-a.txt'
New-BundleFixture -Root $bundleRootB -VariantLabel 'B' -SkillEntry 'replacement-skill-b' -CronEntry 'replacement-task-b.txt'
New-BundleFixture -Root $bundleRootC -VariantLabel 'C' -SkillEntry 'replacement-skill-c' -CronEntry 'replacement-task-c.txt'
Compress-Archive -Path (Join-Path $bundleRootA '*') -DestinationPath $bundleZipPathA -Force
Compress-Archive -Path (Join-Path $bundleRootB '*') -DestinationPath $bundleZipPathB -Force
Compress-Archive -Path (Join-Path $bundleRootC '*') -DestinationPath $bundleZipPathC -Force
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
Write-Host 'Running project-bundle replacement smoke'
node $entryPath $resolvedResultPath $bundleZipPathA $bundleZipPathB $bundleZipPathC $bundleRootA $bundleRootB $bundleRootC
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $resolvedResultPath)) {
throw "Project bundle replacement smoke did not produce a result file: $resolvedResultPath"
}
import { cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { once } from "node:events";
import { fileURLToPath } from "node:url";
import { AppConfigService } from "../../apps/desktop/src/main/services/app-config.js";
import { ProjectBundleService } from "../../apps/desktop/src/main/services/project-bundle.js";
import { ProjectStoreService } from "../../apps/desktop/src/main/services/project-store.js";
import type { RemoteSkillAsset } 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 pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}
async function readJsonFile<T>(filePath: string): Promise<T | null> {
try {
const raw = await readFile(filePath, "utf8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
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-bundle-replacement-smoke", "result.json"));
const bundleZipPathA = path.resolve(process.argv[3] ?? path.join(path.dirname(resultPath), "bundle-a.zip"));
const bundleZipPathB = path.resolve(process.argv[4] ?? path.join(path.dirname(resultPath), "bundle-b.zip"));
const bundleZipPathC = path.resolve(process.argv[5] ?? path.join(path.dirname(resultPath), "bundle-c.zip"));
const bundleSourceRootA = path.resolve(process.argv[6] ?? path.join(path.dirname(resultPath), "bundle-src-a"));
const bundleSourceRootB = path.resolve(process.argv[7] ?? path.join(path.dirname(resultPath), "bundle-src-b"));
const bundleSourceRootC = path.resolve(process.argv[8] ?? path.join(path.dirname(resultPath), "bundle-src-c"));
const tempRoot = path.dirname(resultPath);
const userDataPath = path.join(tempRoot, "user-data");
const projectId = "bundle-replacement-smoke";
const configVersion = "bundle-replacement-config-v1";
let activeVariant: "a" | "b" | "c" = "a";
assert(await pathExists(bundleZipPathA), `Bundle zip fixture A was not found: ${bundleZipPathA}`);
assert(await pathExists(bundleZipPathB), `Bundle zip fixture B was not found: ${bundleZipPathB}`);
assert(await pathExists(bundleZipPathC), `Bundle zip fixture C was not found: ${bundleZipPathC}`);
assert(await pathExists(bundleSourceRootA), `Bundle source fixture A was not found: ${bundleSourceRootA}`);
assert(await pathExists(bundleSourceRootB), `Bundle source fixture B was not found: ${bundleSourceRootB}`);
assert(await pathExists(bundleSourceRootC), `Bundle source fixture C was not found: ${bundleSourceRootC}`);
await rm(userDataPath, { recursive: true, force: true });
await mkdir(userDataPath, { recursive: true });
const variantSkillEntry = {
a: "replacement-skill-a",
b: "replacement-skill-b",
c: "replacement-skill-c"
} as const;
const variantCronEntry = {
a: "replacement-task-a.txt",
b: "replacement-task-b.txt",
c: "replacement-task-c.txt"
} as const;
const server = http.createServer(async (request, response) => {
try {
if (request.url !== "/bundle.zip") {
response.statusCode = 404;
response.end("not found");
return;
}
const variantPath = activeVariant === "a" ? bundleZipPathA : activeVariant === "b" ? bundleZipPathB : bundleZipPathC;
const payload = await readFile(variantPath);
const etag = activeVariant === "a"
? '"bundle-replacement-a"'
: activeVariant === "b"
? '"bundle-replacement-b"'
: '"bundle-replacement-c"';
const lastModified = activeVariant === "a"
? "Tue, 31 Mar 2026 14:00:00 GMT"
: activeVariant === "b"
? "Tue, 31 Mar 2026 14:05:00 GMT"
: "Tue, 31 Mar 2026 14:10:00 GMT";
if (request.method === "HEAD") {
response.writeHead(200, {
ETag: etag,
"Last-Modified": lastModified,
"Content-Length": "4096",
"Cache-Control": "no-store"
});
response.end();
return;
}
if (request.method === "GET") {
response.writeHead(200, {
"Content-Type": "application/zip",
"Cache-Control": "no-store"
});
response.end(payload);
return;
}
response.statusCode = 405;
response.end("method not allowed");
} catch (error) {
response.statusCode = 500;
response.end(error instanceof Error ? error.message : String(error));
}
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address === "object", "Failed to start local replacement smoke server.");
const downloadUrl = `http://127.0.0.1:${address.port}/bundle.zip`;
try {
const configService = new AppConfigService(userDataPath);
await configService.load();
const projectStore = new ProjectStoreService(configService);
await projectStore.initialize();
const projectBundleService = new ProjectBundleService(configService, projectStore);
const projectBundleServiceWithTestHooks = projectBundleService as unknown as {
extractZip(zipPath: string, destinationPath: string): Promise<void>;
};
projectBundleServiceWithTestHooks.extractZip = async (_zipPath: string, destinationPath: string) => {
const sourceRoot = activeVariant === "a"
? bundleSourceRootA
: activeVariant === "b"
? bundleSourceRootB
: bundleSourceRootC;
await rm(destinationPath, { recursive: true, force: true }).catch(() => undefined);
await mkdir(destinationPath, { recursive: true });
await cp(sourceRoot, destinationPath, { recursive: true, force: true });
};
const remoteAsset: RemoteSkillAsset = {
bindingId: "bundle-replacement-binding",
skillId: "bundle-replacement-skill-asset",
name: "Bundle Replacement Smoke Asset",
description: "Remote bundle smoke asset for replacement and rollback validation.",
category: "project",
fileName: "bundle.zip",
downloadUrl
};
const workspaceRoot = await projectStore.getWorkspaceRoot();
const projectPath = path.join(workspaceRoot, "projects", projectId);
const readmePath = path.join(projectPath, "README.md");
const manifestPath = path.join(workspaceRoot, "manifests", "project-bundles.json");
await projectBundleService.syncRemoteBundles([remoteAsset], configVersion, "sync");
assert((await readFile(readmePath, "utf8")).includes("Replacement variant A"), "Variant A was not materialized into the project README.");
assert(await pathExists(path.join(workspaceRoot, "skills", variantSkillEntry.a)), "Variant A skill entry was not materialized.");
assert(await pathExists(path.join(workspaceRoot, "cron", variantCronEntry.a)), "Variant A cron entry was not materialized.");
activeVariant = "b";
await projectBundleService.syncRemoteBundles([remoteAsset], configVersion, "sync");
const manifestAfterB = (await readJsonFile<Record<string, {
checksum?: string;
remoteEtag?: string;
remoteLastModified?: string;
sharedSkillEntries?: string[];
sharedCronEntries?: string[];
}>>(manifestPath)) ?? {};
const recordAfterB = manifestAfterB[projectId];
assert(recordAfterB, "Variant B sync did not persist a manifest record.");
assert(recordAfterB.remoteEtag === '"bundle-replacement-b"', `Unexpected variant B remoteEtag: ${String(recordAfterB.remoteEtag || "")}`);
assert((await readFile(readmePath, "utf8")).includes("Replacement variant B"), "Variant B was not materialized into the project README.");
assert(await pathExists(path.join(workspaceRoot, "skills", variantSkillEntry.b)), "Variant B skill entry was not materialized.");
assert(await pathExists(path.join(workspaceRoot, "cron", variantCronEntry.b)), "Variant B cron entry was not materialized.");
assert(!(await pathExists(path.join(workspaceRoot, "skills", variantSkillEntry.a))), "Variant A skill entry was not cleaned up after replacement with variant B.");
assert(!(await pathExists(path.join(workspaceRoot, "cron", variantCronEntry.a))), "Variant A cron entry was not cleaned up after replacement with variant B.");
const originalSyncBundleProject = projectStore.syncBundleProject.bind(projectStore);
let injectedFailureCount = 0;
(projectStore as ProjectStoreService & {
syncBundleProject: typeof originalSyncBundleProject;
}).syncBundleProject = async (...args: Parameters<typeof originalSyncBundleProject>) => {
if (injectedFailureCount === 0) {
injectedFailureCount += 1;
throw new Error("Simulated syncBundleProject failure after bundle replacement commit.");
}
return originalSyncBundleProject(...args);
};
activeVariant = "c";
const replacementFailure = await projectBundleService.syncRemoteBundles([remoteAsset], configVersion, "sync")
.then(() => null)
.catch((error) => error instanceof Error ? error.message : String(error));
(projectStore as ProjectStoreService & {
syncBundleProject: typeof originalSyncBundleProject;
}).syncBundleProject = originalSyncBundleProject;
assert(replacementFailure === "Simulated syncBundleProject failure after bundle replacement commit.", "Injected replacement failure did not surface the expected error.");
const readmeAfterFailure = await readFile(readmePath, "utf8");
assert(readmeAfterFailure.includes("Replacement variant B"), "Rollback did not restore variant B README after injected failure.");
assert(!readmeAfterFailure.includes("Replacement variant C"), "Rollback left variant C README content in place after injected failure.");
assert(await pathExists(path.join(workspaceRoot, "skills", variantSkillEntry.b)), "Rollback did not restore variant B skill entry after injected failure.");
assert(!(await pathExists(path.join(workspaceRoot, "skills", variantSkillEntry.c))), "Rollback left variant C skill entry in place after injected failure.");
assert(await pathExists(path.join(workspaceRoot, "cron", variantCronEntry.b)), "Rollback did not restore variant B cron entry after injected failure.");
assert(!(await pathExists(path.join(workspaceRoot, "cron", variantCronEntry.c))), "Rollback left variant C cron entry in place after injected failure.");
const manifestAfterFailure = (await readJsonFile<Record<string, {
checksum?: string;
remoteEtag?: string;
remoteLastModified?: string;
sharedSkillEntries?: string[];
sharedCronEntries?: string[];
}>>(manifestPath)) ?? {};
const recordAfterFailure = manifestAfterFailure[projectId];
assert(recordAfterFailure?.checksum === recordAfterB.checksum, "Manifest checksum changed despite rollback after injected replacement failure.");
assert(recordAfterFailure?.remoteEtag === recordAfterB.remoteEtag, "Manifest remoteEtag changed despite rollback after injected replacement failure.");
await projectBundleService.syncRemoteBundles([remoteAsset], configVersion, "sync");
const readmeAfterRecovery = await readFile(readmePath, "utf8");
assert(readmeAfterRecovery.includes("Replacement variant C"), "Recovery sync did not materialize variant C after rollback.");
assert(await pathExists(path.join(workspaceRoot, "skills", variantSkillEntry.c)), "Recovery sync did not materialize variant C skill entry.");
assert(await pathExists(path.join(workspaceRoot, "cron", variantCronEntry.c)), "Recovery sync did not materialize variant C cron entry.");
assert(!(await pathExists(path.join(workspaceRoot, "skills", variantSkillEntry.b))), "Recovery sync did not clean up variant B skill entry.");
assert(!(await pathExists(path.join(workspaceRoot, "cron", variantCronEntry.b))), "Recovery sync did not clean up variant B cron entry.");
const manifestAfterRecovery = (await readJsonFile<Record<string, {
checksum?: string;
remoteEtag?: string;
remoteLastModified?: string;
sharedSkillEntries?: string[];
sharedCronEntries?: string[];
}>>(manifestPath)) ?? {};
const recordAfterRecovery = manifestAfterRecovery[projectId];
assert(recordAfterRecovery?.remoteEtag === '"bundle-replacement-c"', `Unexpected variant C remoteEtag: ${String(recordAfterRecovery?.remoteEtag || "")}`);
const summary = {
ok: true,
userDataPath,
workspaceRoot,
bundleZipPathA,
bundleZipPathB,
bundleZipPathC,
bundleSourceRootA,
bundleSourceRootB,
bundleSourceRootC,
downloadUrl,
configVersion,
replacementFailure,
manifestRemoteEtagAfterB: recordAfterB.remoteEtag ?? null,
manifestRemoteEtagAfterFailure: recordAfterFailure?.remoteEtag ?? null,
manifestRemoteEtagAfterRecovery: recordAfterRecovery?.remoteEtag ?? null,
checksumAfterB: recordAfterB.checksum ?? null,
checksumAfterFailure: recordAfterFailure?.checksum ?? null,
checksumAfterRecovery: recordAfterRecovery?.checksum ?? null,
readmePath,
projectId
};
await mkdir(path.dirname(resultPath), { recursive: true });
await writeFile(resultPath, JSON.stringify(summary, null, 2), "utf8");
console.log(JSON.stringify(summary, null, 2));
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
}
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-bundle-replacement-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]$ResultPath
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$desktopAppRoot = Join-Path $repoRoot 'apps\desktop'
$sourcePath = Join-Path $repoRoot 'build\scripts\project-context-refresh-smoke.ts'
$tempRoot = Join-Path $repoRoot '.tmp\project-context-refresh-smoke'
$compileRoot = Join-Path $tempRoot 'compiled'
$entryPath = Join-Path $compileRoot 'build\scripts\project-context-refresh-smoke.js'
$compilePackagePath = Join-Path $compileRoot 'package.json'
$resolvedResultPath = if ($ResultPath) { $ResultPath } 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 context refresh 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', $desktopAppRoot,
'exec',
'tsc',
'--module', 'ES2022',
'--moduleResolution', 'node',
'--target', 'ES2022',
'--lib', 'ES2022',
'--types', 'node',
'--esModuleInterop',
'--allowSyntheticDefaultImports',
'--skipLibCheck',
'--outDir', $compileRoot,
$sourcePath
)
Write-Host 'Compiling project-context refresh smoke with local TypeScript'
corepack @compileArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $entryPath)) {
throw "Project context refresh smoke entry was not emitted: $entryPath"
}
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
Write-Host 'Running project-context refresh smoke'
node $entryPath $resolvedResultPath
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $resolvedResultPath)) {
throw "Project context refresh smoke did not produce a result file: $resolvedResultPath"
}
\ No newline at end of file
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 { ProjectStoreService } from "../../apps/desktop/src/main/services/project-store.js";
import { ProjectContextService } from "../../apps/desktop/src/main/services/project-context.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", "project-context-refresh-smoke", "result.json"));
const tempRoot = path.dirname(resultPath);
const userDataPath = path.join(tempRoot, "user-data");
const readmeMarkerBefore = "README marker before refresh smoke";
const readmeMarkerAfter = "README marker after refresh smoke";
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: "context-refresh-smoke",
name: "Context Refresh Smoke",
description: "Verifies project context snapshot invalidation and 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, `# Context Refresh Smoke\n\n${readmeMarkerBefore}\n`, "utf8");
const projectContextService = new ProjectContextService(projectStore);
const session = await projectStore.createSession("Context Refresh Smoke", project.id);
const initialSnapshot = await projectContextService.getSnapshot(project.id);
const cachedSnapshot = await projectContextService.getSnapshot(project.id);
assert(initialSnapshot.snapshotId === cachedSnapshot.snapshotId, "Expected unchanged project context to reuse cached snapshotId.");
assert(cachedSnapshot.readme?.includes(readmeMarkerBefore), "Initial snapshot did not include README marker.");
await delay(25);
projectContextService.invalidateSnapshot(project.id);
const forcedRefreshSnapshot = await projectContextService.refreshSnapshot(project.id);
assert(forcedRefreshSnapshot.snapshotId === initialSnapshot.snapshotId, "Refresh without file changes unexpectedly changed snapshotId.");
await projectStore.bindSessionContextSnapshot(session.id, initialSnapshot.snapshotId);
const boundBeforeChange = await projectStore.getSessionState(session.id);
assert(boundBeforeChange.contextSnapshotId === initialSnapshot.snapshotId, "Session binding did not persist initial snapshotId.");
await delay(25);
await writeFile(readmePath, `# Context Refresh Smoke\n\n${readmeMarkerAfter}\n`, "utf8");
const staleSessionState = await projectStore.getSessionState(session.id);
assert(staleSessionState.contextSnapshotId === initialSnapshot.snapshotId, "Session binding changed before refresh smoke step executed.");
projectContextService.invalidateSnapshot(project.id);
const refreshedSnapshot = await projectContextService.refreshSnapshot(project.id);
assert(refreshedSnapshot.snapshotId !== initialSnapshot.snapshotId, "Tracked file change did not produce a new snapshotId.");
assert(refreshedSnapshot.readme?.includes(readmeMarkerAfter), "Refreshed snapshot did not include updated README marker.");
const renderedContext = await projectContextService.buildSystemContext(project.id);
assert(renderedContext.includes(readmeMarkerAfter), "Rendered system context did not include refreshed README content.");
if (staleSessionState.contextSnapshotId !== refreshedSnapshot.snapshotId) {
await projectStore.bindSessionContextSnapshot(session.id, refreshedSnapshot.snapshotId);
}
const reboundSessionState = await projectStore.getSessionState(session.id);
assert(reboundSessionState.contextSnapshotId === refreshedSnapshot.snapshotId, "Session contextSnapshotId was not rebound to refreshed snapshotId.");
const finalSnapshot = await projectContextService.getSnapshot(project.id);
assert(finalSnapshot.snapshotId === refreshedSnapshot.snapshotId, "Post-refresh getSnapshot did not reuse refreshed snapshotId.");
const summary = {
ok: true,
userDataPath,
projectId: project.id,
sessionId: session.id,
initialSnapshotId: initialSnapshot.snapshotId,
forcedRefreshSnapshotId: forcedRefreshSnapshot.snapshotId,
refreshedSnapshotId: refreshedSnapshot.snapshotId,
finalSnapshotId: finalSnapshot.snapshotId,
staleSessionSnapshotId: staleSessionState.contextSnapshotId,
reboundSessionSnapshotId: reboundSessionState.contextSnapshotId,
cacheStable: initialSnapshot.snapshotId === cachedSnapshot.snapshotId,
fileChangeDetected: refreshedSnapshot.snapshotId !== initialSnapshot.snapshotId,
rebindApplied: reboundSessionState.contextSnapshotId === refreshedSnapshot.snapshotId,
renderedContextIncludesUpdatedReadme: renderedContext.includes(readmeMarkerAfter),
readmePath
};
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-context-refresh-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;
});
\ No newline at end of file
param(
[string]$ResultPath
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$desktopAppRoot = Join-Path $repoRoot 'apps\desktop'
$sourcePath = Join-Path $repoRoot 'build\scripts\project-empty-inventory-smoke.ts'
$tempRoot = Join-Path $repoRoot '.tmp\project-empty-inventory-smoke'
$compileRoot = Join-Path $tempRoot 'compiled'
$entryPath = Join-Path $compileRoot 'build\scripts\project-empty-inventory-smoke.js'
$compilePackagePath = Join-Path $compileRoot 'package.json'
$resolvedResultPath = if ($ResultPath) { $ResultPath } 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 empty-inventory 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', $desktopAppRoot,
'exec',
'tsc',
'--module', 'ES2022',
'--moduleResolution', 'node',
'--target', 'ES2022',
'--lib', 'ES2022',
'--types', 'node',
'--esModuleInterop',
'--allowSyntheticDefaultImports',
'--skipLibCheck',
'--outDir', $compileRoot,
$sourcePath
)
Write-Host 'Compiling project empty-inventory smoke with local TypeScript'
corepack @compileArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $entryPath)) {
throw "Project empty-inventory smoke entry was not emitted: $entryPath"
}
Write-Utf8File -FilePath $compilePackagePath -Content '{"type":"module"}'
Write-Host 'Running project empty-inventory smoke'
node $entryPath $resolvedResultPath
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $resolvedResultPath)) {
throw "Project empty-inventory 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 {
EMPTY_PROJECT_INVENTORY_MESSAGE,
createSessionForActiveProject,
listSessionsForActiveProject,
loadActiveProjectWorkspaceState,
requireActiveProject
} from "../../apps/desktop/src/main/services/project-inventory-state.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 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-empty-inventory-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 initialState = await loadActiveProjectWorkspaceState(projectStore);
assert(initialState.projects.length === 0, "Initial project inventory should be empty.");
assert(initialState.currentProject === null, "Initial active project should be null.");
assert(initialState.sessions.length === 0, "Initial session list should be empty.");
assert(initialState.skills.length === 0, "Initial skill list should be empty.");
const listedSessionsBeforeSync = await listSessionsForActiveProject(projectStore);
assert(listedSessionsBeforeSync.length === 0, "Listing sessions without a project should return an empty array.");
const requiredProjectFailure = await requireActiveProject(projectStore)
.then(() => null)
.catch((error) => error instanceof Error ? error.message : String(error));
assert(requiredProjectFailure === EMPTY_PROJECT_INVENTORY_MESSAGE, "Active project guard did not return the expected pending-cloud message.");
const createSessionFailure = await createSessionForActiveProject(projectStore, "Should Fail")
.then(() => null)
.catch((error) => error instanceof Error ? error.message : String(error));
assert(createSessionFailure === EMPTY_PROJECT_INVENTORY_MESSAGE, "Session creation guard did not return the expected pending-cloud message.");
const syncedProject = await projectStore.syncBundleProject({
projectId: "cloud-owned-project",
projectName: "Cloud Owned Project",
version: "config-v1",
ready: true,
boundSkillIds: []
});
const syncedState = await loadActiveProjectWorkspaceState(projectStore);
assert(syncedState.projects.length === 1, "Project inventory should contain the synced bundle-backed project.");
assert(syncedState.currentProject?.id === syncedProject.id, "Synced bundle-backed project should become active when inventory was empty.");
assert(syncedState.sessions.length === 1, "Loading workspace state for a new active project should materialize exactly one default session.");
assert(syncedState.sessions[0]?.projectId === syncedProject.id, "Default session should be bound to the synced active project.");
const createdSession = await createSessionForActiveProject(projectStore, "After Sync");
const listedSessionsAfterSync = await listSessionsForActiveProject(projectStore);
assert(createdSession.projectId === syncedProject.id, "Created session after bundle sync should be bound to the active project.");
assert(listedSessionsAfterSync.some((session) => session.id === createdSession.id), "Created session after bundle sync should appear in the active session list.");
const summary = {
ok: true,
userDataPath,
emptyInventoryMessage: EMPTY_PROJECT_INVENTORY_MESSAGE,
initialProjectCount: initialState.projects.length,
initialSessionCount: initialState.sessions.length,
requiredProjectFailure,
createSessionFailure,
syncedProjectId: syncedProject.id,
syncedProjectCount: syncedState.projects.length,
syncedDefaultSessionCount: syncedState.sessions.length,
createdSessionId: createdSession.id,
listedSessionCountAfterSync: listedSessionsAfterSync.length
};
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-empty-inventory-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(
[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
}
param(
[int]$GatewayPort = 18889,
[string]$GatewayToken = 'qjc-bundled-runtime-token',
[string]$SmokeOutput,
[string]$UserDataPath,
[string]$LogsPath,
[int]$TimeoutSeconds = 120,
[switch]$SkipMaterializeRuntime
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if (-not $SmokeOutput) {
$SmokeOutput = Join-Path $repoRoot '.tmp\workspace-entry-smoke\result.json'
}
if (-not $UserDataPath) {
$UserDataPath = Join-Path $repoRoot '.tmp\workspace-entry-smoke\user-data'
}
if (-not $LogsPath) {
$LogsPath = Join-Path $repoRoot '.tmp\workspace-entry-smoke\logs'
}
if (-not $SkipMaterializeRuntime) {
Write-Host "Materializing bundled runtime payload on port $GatewayPort"
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\materialize-runtime-payload.ps1') -GatewayPort $GatewayPort -GatewayToken $GatewayToken
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot 'build\scripts\electron-smoke.ps1') `
-SmokeOutput $SmokeOutput `
-UserDataPath $UserDataPath `
-LogsPath $LogsPath `
-RuntimeMode 'bundled-runtime' `
-ExpectBundledRuntime `
-PrepareWorkspaceEntryFixture `
-ExpectWorkspaceEntry `
-WorkspaceProjectId 'workspace-entry-smoke' `
-WorkspaceProjectName 'Workspace Entry Smoke' `
-SmokePrompt 'Describe the current project root and confirm workspace execution.' `
-SmokeSkillId '__workspace_entry_disabled__' `
-TimeoutSeconds $TimeoutSeconds
exit $LASTEXITCODE
# Project Bundle Isolation Plan
Date: 2026-04-01
Status: Bundle ingestion, cloud-owned inventory enforcement, freshness hardening, replacement/rollback hardening, and lifecycle smoke are implemented
## 1. Product constraints
This plan follows the confirmed product constraints:
- Projects are defined by backend config, not local CRUD.
- The current source payload is `openclaw-employee-config`.
- The active bundle source is `skills[].skill.download_url`.
- `project_bundle_url` is not part of the current implementation.
- Project inventory is fixed by backend configuration.
## 2. What is already implemented
### Cloud bundle ingestion
- Runtime cloud bootstrap and payload update both trigger project bundle sync.
- Zip-backed remote skills are discovered and downloaded.
- Bundles are extracted and normalized.
- Project files are materialized into local project roots.
- Shared bundle `skills/` and `cron/` content is materialized.
- Synced projects are registered in local project metadata.
### Cloud-owned inventory enforcement
- No implicit local `default` project is created when there are no materialized projects.
- Session listing returns `[]` when no active project exists.
- Session creation is blocked with `Waiting for cloud project bundle sync.` until a cloud-backed project arrives.
- The first synced bundle-backed project becomes active cleanly.
### Project-bound execution
- Sessions are bound to projects.
- Each execution is prepared against the session's project.
- Project context snapshots are injected into request preparation.
- The router can choose `workspace-entry`, `skill`, or `chat-fallback`.
- `chat-fallback`, `skill`, and `workspace-entry` all schedule post-execution project context refresh.
### Stale bundle reconciliation
- Sync derives the expected bundle-backed project set from the current payload.
- Bundle-managed projects missing from the expected set are removed locally.
- Bundle-managed shared `skills/` entries missing from the expected set are removed locally.
- Bundle-managed shared `cron/` entries missing from the expected set are removed locally.
- Stale bundle manifest records are pruned.
- If the active bundle project is removed, active project falls back to the remaining valid project set.
### Freshness hardening
- Reuse is no longer assumed from `sourceUrl + configVersion` alone.
- When a manifest record is otherwise reusable, the bundle service performs a `HEAD` probe.
- The probe captures `ETag`, `Last-Modified`, and `Content-Length` when available.
- If those values change, the bundle is re-downloaded and re-materialized.
- If an older manifest lacks freshness identity and the remote now exposes one, the service forces one refresh to establish the baseline.
- Bundle manifest records now persist remote freshness metadata.
### Replacement and rollback hardening
- Bundle installation now follows `stage -> commit -> finalize/rollback`.
- Replacement can swap the project root plus shared `skills/` and `cron/` entries for the same logical `projectId`.
- If replacement fails after commit but before project metadata is fully updated, the previous project root, shared skill entries, and cron entries are restored.
- Recovery on the next sync is verified and keeps manifest ownership aligned with the recovered state.
### Regression smoke coverage
- `workspace-entry-smoke`
- `cloud-bundle-smoke`
- `project-context-refresh-smoke`
- `default-chat-smoke`
- `project-empty-inventory-smoke`
- `project-bundle-reconcile-smoke`
- `project-bundle-freshness-smoke`
- `project-bundle-replacement-smoke`
- `project-isolation-smoke`
Additional lifecycle coverage now verified:
- cloud bundle smoke covers payload `sync`
- cloud bundle smoke covers cached `init`
- cloud bundle smoke covers same-`projectId` replacement through Electron UI/main flow
- replacement smoke covers service-level rollback injection and recovery on the next sync
## 3. What is no longer pending
The previously pending items are now implemented:
- full cloud-owned project inventory enforcement
- strong rollback guarantees for partially failed bundle replacement
- end-to-end lifecycle smoke for replacement scenarios
## 4. Remaining follow-up work
The main work left is follow-up hardening rather than missing core behavior:
- broaden replacement coverage beyond the single-project happy path to larger multi-project churn scenarios
- decide whether replacement lifecycle smoke should also be aggregated into higher-level CI or release gates
- keep the design docs aligned as the project-isolation surface expands
## 5. File-by-file status
### `apps/desktop/src/main/services/project-bundle.ts`
- Owns bundle freshness probing, expected-set reconciliation, replacement ordering, and rollback behavior.
- Persists manifest ownership and remote freshness metadata for bundle-managed state.
### `apps/desktop/src/main/services/project-store.ts`
- Owns active-project state without recreating a local fallback project when inventory is empty.
- Keeps active-project transitions consistent during bundle arrival, removal, and replacement.
### `apps/desktop/src/main/ipc.ts`
- Keeps send and stream paths aligned on post-execution refresh behavior.
- Preserves background refresh semantics without adding reply latency.
### `build/scripts/*.ps1`
- Current smokes cover empty inventory, bundle reconcile, freshness, replacement rollback, and Electron lifecycle replacement.
- `cloud-bundle-smoke.ps1` is now the main Electron lifecycle regression for bundle replacement.
## 6. Practical status summary
Current phase:
- not design-only
- not blocked on the earlier inventory / rollback gaps
- core execution chain works
- empty inventory semantics work
- stale bundle cleanup works
- bundle freshness hardening works
- replacement / rollback hardening works
- lifecycle smoke for replacement is in place
Recommended immediate focus:
- keep the current smoke set green
- sync higher-level docs and rollout notes
- expand regression breadth only if upcoming product changes need it
\ No newline at end of file
# Project Isolation Execution Design
Date: 2026-04-01
Status: Core execution chain, cloud-owned inventory, bundle freshness hardening, replacement/rollback hardening, and lifecycle smoke are implemented
## 1. Scope and product constraints
This document describes the current desktop implementation for project-isolated execution.
Confirmed product constraints:
- Project inventory is fixed by backend configuration.
- Projects currently come from `openclaw-employee-config`.
- The active bundle source is `skills[].skill.download_url`.
- `project_bundle_url` is not part of the current implementation.
- Local project CRUD is not part of the target product shape.
## 2. Implemented architecture
The implemented chain is:
1. Runtime cloud bootstrap or payload sync triggers project bundle sync.
2. Remote zip bundles are discovered from `skills[].skill.download_url`, downloaded, and extracted.
3. Bundle content is materialized into local project roots plus shared `skills/` and `cron/` content.
4. Materialized projects are registered in local project metadata.
5. Empty inventory does not recreate a local fallback project; chat session creation waits for the first cloud-backed project.
6. Sessions are bound to projects through `ProjectStoreService`.
7. `ProjectContextService` builds and caches project snapshots from `SOUL.md`, `USER.md`, `README.md`, and tracked memory files.
8. `ProjectExecutionRouter` routes each request to one of:
- `workspace-entry`
- `skill`
- `chat-fallback`
9. `ipc.ts` prepares each request against the session's project snapshot before execution.
10. Bundle reconciliation removes stale bundle-managed projects, shared `skills/` entries, shared `cron/` entries, and stale manifest records when the expected cloud bundle set changes.
11. Bundle freshness probes the remote bundle with HTTP metadata before deciding whether an existing local materialization can be reused.
12. Same-`projectId` replacement now stages project/shared assets, commits them in order, and either finalizes or rolls back the whole replacement set.
## 3. Code-truth execution behavior
Current behavior verified from code:
- `workspace-entry` executes inside the isolated project root.
- `skill` routing uses project skill binding and runtime skill bridge prompt preparation.
- `chat-fallback` also receives injected project context and stays project-bound.
- Session to project binding is real.
- Session to snapshot binding is real.
- Project context injection is real.
- Cloud zip sync is real.
- Empty inventory semantics are real.
- Stale bundle reconciliation is real for bundle-managed project and shared asset cleanup.
- Bundle freshness hardening is real.
- Replacement / rollback hardening is real for same-project bundle updates.
Default chat refresh semantics are explicit in code:
- `apps/desktop/src/main/services/project-context-lifecycle.ts` defines shared post-execution refresh behavior.
- `chat-fallback`, `skill`, and `workspace-entry` all schedule post-execution project context refresh.
- The refresh is asynchronous and does not block reply delivery.
- When refresh succeeds, `session.contextSnapshotId` is rebound to the refreshed snapshot.
- The next request reuses the refreshed snapshot instead of discovering it lazily.
Bundle freshness semantics are explicit in code:
- Reuse is no longer based only on `sourceUrl + configVersion`.
- If a manifest record is reusable, the app sends a `HEAD` probe before skipping reinstall.
- The probe records `ETag`, `Last-Modified`, and `Content-Length` when available.
- If remote freshness identity changes, the bundle is re-downloaded and re-materialized.
- If an older manifest has no freshness identity but the remote now exposes one, one re-download is forced to establish the baseline.
Bundle replacement semantics are explicit in code:
- bundle install follows `stage -> commit -> finalize/rollback`
- project root and shared `skills/` / `cron/` content are treated as one replacement transaction
- if metadata sync fails after commit, the previous project root and shared assets are restored
- the next sync can recover cleanly and persist the new manifest ownership
## 4. Verified smoke coverage
Current smoke coverage in the repo:
- `workspace-entry-smoke.ps1`
- validates workspace-entry execution inside an isolated project
- `cloud-bundle-smoke.ps1`
- validates cloud zip to bundle sync to active project to workspace-entry
- covers payload `sync`
- covers cached `init`
- covers same-`projectId` replacement through the Electron UI/main flow
- `project-context-refresh-smoke.ps1`
- validates snapshot cache, invalidation, refresh, and session snapshot rebinding
- `default-chat-smoke.ps1`
- validates `chat-fallback` routing
- validates project context injection into the prepared prompt
- validates post-execution snapshot refresh
- validates session snapshot rebinding
- validates reuse of the refreshed snapshot on the next request
- `project-empty-inventory-smoke.ps1`
- validates no-project inventory stays empty
- validates session listing returns `[]`
- validates session creation is blocked until cloud bundle sync completes
- `project-bundle-reconcile-smoke.ps1`
- validates stale bundle project removal
- validates shared `skills/` cleanup
- validates shared `cron/` cleanup
- validates manifest pruning
- validates active-project fallback after cleanup
- `project-bundle-freshness-smoke.ps1`
- validates re-download when `ETag` / `Last-Modified` change at the same download URL
- validates persisted freshness metadata in bundle manifest
- `project-bundle-replacement-smoke.ps1`
- validates same-project replacement at the service layer
- validates rollback after injected post-commit failure
- validates successful recovery on the next sync
- `project-isolation-smoke.ps1`
- aggregates the main project-isolation regression smokes
Electron smoke validation is also stronger now:
- workspace-entry validation no longer assumes the final status label must contain `workspace`
- smoke state records status label history and validates that a workspace-agent phase actually occurred
- smoke action selection now follows the active project session instead of assuming a stable pre-replacement session id
## 5. What is still incomplete
The earlier core gaps are closed.
The main follow-up areas now are:
- broader multi-project churn coverage beyond the current targeted replacement path
- deciding whether the new replacement lifecycle smoke should be promoted into broader CI / release gating
- keeping related product and rollout docs aligned with the implementation
## 6. Recommended next implementation order
1. Keep the current project-isolation smoke set green.
2. Promote lifecycle replacement smoke into higher-level release coverage if upcoming changes raise the risk level.
3. Expand breadth only when new isolation surfaces or multi-project behaviors are introduced.
## 7. Bottom line
Current status:
- The core project isolation chain is implemented.
- Session and project binding are implemented.
- Cloud-owned inventory enforcement is implemented.
- Project context injection is implemented.
- Cloud zip sync is implemented.
- Default chat refresh is aligned with other project-aware execution paths.
- Stale bundle reconciliation is implemented for bundle-managed cleanup.
- Bundle freshness hardening is implemented and smoke-verified.
- Replacement / rollback hardening is implemented and smoke-verified.
- Remaining work is mostly broader regression breadth and routine follow-up maintenance.
\ No newline at end of file
# Single Instance + Task Isolation
Date: 2026-04-01
Status: Foundation, cloud-owned inventory, freshness hardening, replacement/rollback hardening, and lifecycle smoke are implemented
## 1. Intent
The goal is a single desktop app instance with isolated project execution.
Isolation means:
- each session is bound to a project
- prompts are prepared with that project's context only
- execution routing stays inside the selected project's boundaries
- project state should not leak across sessions or projects
## 2. What is implemented
### Project and session model
- Projects exist in local workspace state.
- Sessions are created per project.
- Active project selection is real.
- Session to project binding is real.
### Cloud-owned inventory behavior
- No project means no implicit local `default` project.
- Session listing returns `[]` when inventory is empty.
- Creating a session while waiting for the first cloud project returns `Waiting for cloud project bundle sync.`.
- The first synced bundle-backed project becomes active without local fallback recreation.
### Project context model
- `ProjectContextService` builds snapshots from project root files.
- `SOUL.md`, `USER.md`, `README.md`, and tracked memory files are included.
- Snapshot caching, invalidation, and refresh are implemented.
- `session.contextSnapshotId` rebinding is implemented.
### Execution routing
The router can choose:
- `workspace-entry`
- `skill`
- `chat-fallback`
All three routes are project-aware.
### Cloud bundle path
- Cloud payload can define zip-backed projects.
- Bundles are downloaded and materialized locally.
- Shared bundle assets can also be materialized.
- Stale bundle-managed projects and shared assets can be removed when the expected cloud bundle set changes.
- Same-URL bundle changes can now trigger re-download through HTTP freshness metadata.
- 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.
## 3. Current default chat behavior
Default chat is no longer a special weak path.
Current behavior:
- it reads the latest available project snapshot before each request
- it injects project context into prepared prompt content
- after a `chat-fallback` turn completes, it queues project context refresh in the background
- when refresh succeeds, `session.contextSnapshotId` is rebound to the refreshed snapshot
- the next request reuses the refreshed snapshot
The refresh remains asynchronous so reply delivery is not blocked.
## 4. Verified coverage
Current repo smoke coverage includes:
- `workspace-entry-smoke.ps1`
- `cloud-bundle-smoke.ps1`
- `project-context-refresh-smoke.ps1`
- `default-chat-smoke.ps1`
- `project-empty-inventory-smoke.ps1`
- `project-bundle-reconcile-smoke.ps1`
- `project-bundle-freshness-smoke.ps1`
- `project-bundle-replacement-smoke.ps1`
- `project-isolation-smoke.ps1`
Additional verified points:
- cloud bundle smoke passes with freshness probing enabled
- cloud bundle smoke now validates same-`projectId` replacement through the Electron UI/main chain
- service-level replacement smoke validates rollback injection and recovery on the next sync
- Electron smoke validates workspace-agent status history instead of relying on the final status label only
## 5. Important current limitations
### Broad UI regression breadth is still selective
The targeted Electron lifecycle smoke for cloud bundle replacement now exists, but the full UI regression matrix is still intentionally selective rather than exhaustive.
### Follow-up hardening can still expand
The current replacement lifecycle coverage is strong for the implemented path, but future changes may still need wider multi-project churn and stress coverage.
## 6. What has been completed so far
Completed enough to count as real implementation:
- project-bound session model
- cloud-owned inventory enforcement
- project context snapshot model
- cloud zip materialization path
- project-aware execution routing
- default chat refresh alignment with other project-aware paths
- stale bundle-managed cleanup for project/skill/cron/manifest state
- bundle freshness hardening using remote metadata probe
- replacement / rollback hardening for same-project bundle updates
- smoke coverage for empty inventory, removal, freshness, replacement, and Electron lifecycle validation
This project is no longer at the design-only stage.
## 7. What should happen next
Recommended next work:
1. Keep the current smoke set green as adjacent runtime work lands.
2. Fold the new lifecycle replacement smoke into any broader release gate if needed.
3. Expand coverage only when upcoming product changes introduce new isolation surfaces.
## 8. Final summary
The single-instance plus task-isolation foundation is implemented.
The earlier main gaps around cloud-owned inventory, replacement/rollback hardening, and lifecycle smoke have been closed.
The remaining work is mostly broader regression breadth and routine maintenance.
\ No newline at end of file
......@@ -14,6 +14,16 @@
"smoke:execution-policy": "powershell -ExecutionPolicy Bypass -File build/scripts/electron-smoke.ps1",
"materialize:runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/materialize-runtime-payload.ps1",
"smoke:bundled-runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/bundled-runtime-smoke.ps1",
"smoke:workspace-entry": "powershell -ExecutionPolicy Bypass -File build/scripts/workspace-entry-smoke.ps1",
"smoke:cloud-bundle": "powershell -ExecutionPolicy Bypass -File build/scripts/cloud-bundle-smoke.ps1",
"smoke:default-chat": "powershell -ExecutionPolicy Bypass -File build/scripts/default-chat-smoke.ps1",
"smoke:project-isolation": "powershell -ExecutionPolicy Bypass -File build/scripts/project-isolation-smoke.ps1",
"smoke:materialize-cache": "powershell -ExecutionPolicy Bypass -File build/scripts/materialize-runtime-cache-smoke.ps1",
"smoke:project-context-refresh": "powershell -ExecutionPolicy Bypass -File build/scripts/project-context-refresh-smoke.ps1",
"smoke:empty-project-inventory": "powershell -ExecutionPolicy Bypass -File build/scripts/project-empty-inventory-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-replacement": "powershell -ExecutionPolicy Bypass -File build/scripts/project-bundle-replacement-smoke.ps1",
"smoke:installer:bundled-runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/installer-smoke.ps1 -RuntimeMode bundled-runtime -ExpectBundledRuntime"
},
"pnpm": {
......
export const IPC_CHANNELS = {
export const IPC_CHANNELS = {
workspaceGetSummary: "workspace:get-summary",
workspaceWarmup: "workspace:warmup",
gatewayStatus: "gateway:status",
......@@ -18,7 +18,11 @@
runtimeTelemetryGetStatus: "runtime-telemetry:get-status",
configLoad: "config:load",
configSave: "config:save",
projectsList: "projects:list",
projectsSetActive: "projects:set-active",
chatListSessions: "chat:list-sessions",
chatCreateSession: "chat:create-session",
chatCloseSession: "chat:close-session",
chatListMessages: "chat:list-messages",
chatSendPrompt: "chat:send-prompt",
chatStreamPrompt: "chat:stream-prompt",
......@@ -272,6 +276,13 @@ export interface WorkspaceSummary {
runtimeCloudState: RuntimeCloudState;
runtimeState: RuntimeProcessState | "unavailable";
runtimeMessage?: string;
currentProjectId?: string;
currentProjectName?: string;
projectVersion?: string;
projectReady: boolean;
projectCount: number;
projects: ProjectSummary[];
sessions: ProjectSessionSummary[];
skillCount: number;
skills: WorkspaceSkillSummary[];
plugins: PluginSummary[];
......@@ -291,6 +302,79 @@ export interface SessionSummary {
updatedAt: string;
}
export interface ProjectSummary {
id: string;
name: string;
description?: string;
version?: string;
updatedAt: string;
skillCount: number;
ready: boolean;
}
export interface ProjectSessionSummary extends SessionSummary {
projectId: string;
}
export interface ProjectContextBoundSkill {
id: string;
name: string;
description?: string;
}
export interface ProjectContextSnapshot {
projectId: string;
projectName: string;
projectRoot: string;
snapshotId: string;
generatedAt: string;
soul: string | null;
user: string | null;
readme: string | null;
memorySummary: string | null;
boundSkills: ProjectContextBoundSkill[];
}
export interface ProjectSessionState {
sessionId: string;
projectId: string;
projectRoot: string;
title: string;
createdAt: string;
updatedAt: string;
lastActiveAt: string;
contextSnapshotId: string | null;
contextLoadedAt: string | null;
selectedSkillId: string | null;
draft: string;
}
export interface ProjectExecutionRequest {
sessionId: string;
projectId: string;
projectRoot: string;
userPrompt: string;
context: ProjectContextSnapshot;
selectedSkillId: string | null;
}
export type ProjectExecutionDecision =
| {
kind: "skill";
skillId: string;
preparedPrompt: string;
}
| {
kind: "workspace-entry";
projectRoot: string;
preparedPrompt: string;
reason: string;
}
| {
kind: "chat-fallback";
preparedPrompt: string;
};
export interface ChatMessage {
id: string;
role: MessageRole;
......@@ -536,6 +620,10 @@ export interface DesktopApi {
load(): Promise<AppConfig>;
save(input: SaveConfigInput): Promise<AppConfig>;
};
projects: {
list(): Promise<ProjectSummary[]>;
setActive(projectId: string): Promise<WorkspaceSummary>;
};
auth: {
getSessionSummary(): Promise<AuthSessionSummary>;
signIn(input: SignInInput): Promise<AuthSessionSummary>;
......@@ -557,7 +645,9 @@ export interface DesktopApi {
getSummary(): Promise<SystemSummary>;
};
chat: {
listSessions(): Promise<SessionSummary[]>;
listSessions(): Promise<ProjectSessionSummary[]>;
createSession(title?: string): Promise<ProjectSessionSummary>;
closeSession(sessionId: string): Promise<ProjectSessionSummary[]>;
listMessages(sessionId: string): Promise<ChatMessage[]>;
sendPrompt(sessionId: string, prompt: string, skillId?: string): Promise<PromptResult>;
streamPrompt(sessionId: string, prompt: string, skillId?: string): Promise<ChatStreamPromptResult>;
......@@ -574,3 +664,7 @@ export interface DesktopApi {
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