feat(FN-2477): add executable OpenClaw runtime adapter

- Replace deferred OpenClaw placeholder runtime with an executable adapter that creates sessions, prompts with fallback, describes models, and disposes safely
- Add a dedicated pi-module seam and local runtime contract types to decouple plugin compilation from engine internals while preserving runtime behavior
- Update OpenClaw plugin metadata, manifest/package descriptions, and README to reflect active execution support instead of deferred status
- Harden dashboard DevServerStore save flow against ENOENT temp-directory races and add regression coverage for project-directory removal during save
This commit is contained in:
Fusion
2026-04-24 10:55:28 -07:00
committed by gsxdsm
parent ab1e67452a
commit e27d31bd47
18 changed files with 391 additions and 125 deletions

View File

@@ -2414,6 +2414,16 @@ describe("SettingsModal", () => {
});
it("re-opening modal shows previously saved notification settings", async () => {
const openNotificationsSection = () => {
const notificationsButton = screen.queryByRole("button", { name: /Notifications/ });
if (notificationsButton) {
fireEvent.click(notificationsButton);
} else {
// Mobile layout uses the section picker dropdown instead of sidebar buttons.
fireEvent.change(screen.getByLabelText("Settings Section"), { target: { value: "notifications" } });
}
};
// First render with ntfy enabled
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
@@ -2424,7 +2434,7 @@ describe("SettingsModal", () => {
const { unmount } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
openNotificationsSection();
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
@@ -2443,7 +2453,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
openNotificationsSection();
const newCheckbox = screen.getByLabelText("Enable ntfy.sh notifications") as HTMLInputElement;
expect(newCheckbox.checked).toBe(true);
const newInput = screen.getByLabelText("ntfy Topic") as HTMLInputElement;

View File

@@ -3,7 +3,7 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
DEV_SERVER_CONFIG_DEFAULTS,
DEV_SERVER_DEFAULT_STATE,
@@ -26,6 +26,7 @@ describe("DevServerStore", () => {
const tempDirs: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
@@ -315,6 +316,21 @@ describe("DevServerStore", () => {
expect(persisted.state.logHistory).toEqual([]);
});
it("treats ENOENT as non-fatal when project directory is removed during save", async () => {
const projectDir = createTempProject();
tempDirs.push(projectDir);
const store = new DevServerStore(projectDir);
await store.load();
rmSync(projectDir, { recursive: true, force: true });
await expect(store.updateState({ id: "gone", status: "stopped" })).resolves.toMatchObject({
id: "gone",
status: "stopped",
});
});
it("singleton cache returns same instance for same path", async () => {
const projectDir = createTempProject();
tempDirs.push(projectDir);

View File

@@ -1,4 +1,4 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
export type DevServerStatus = "starting" | "running" | "stopped" | "failed";
@@ -149,18 +149,50 @@ export class DevServerStore {
async save(): Promise<void> {
const dir = dirname(this.filePath);
try {
await access(dir);
} catch {
await mkdir(dir, { recursive: true });
}
const payload: DevServerStoreFile = {
state: this.state,
config: this.config,
};
const serializedPayload = JSON.stringify(payload, null, 2);
const isMissingPathError = (error: unknown): boolean => {
return (error as NodeJS.ErrnoException).code === "ENOENT";
};
await writeFile(this.filePath, JSON.stringify(payload, null, 2), "utf-8");
try {
await mkdir(dir, { recursive: true });
} catch (error) {
if (isMissingPathError(error)) {
return;
}
throw error;
}
try {
await writeFile(this.filePath, serializedPayload, "utf-8");
} catch (error) {
if (!isMissingPathError(error)) {
throw error;
}
// Directory may have been removed between mkdir and write (e.g. temp-dir cleanup race).
try {
await mkdir(dir, { recursive: true });
} catch (retryMkdirError) {
if (isMissingPathError(retryMkdirError)) {
return;
}
throw retryMkdirError;
}
try {
await writeFile(this.filePath, serializedPayload, "utf-8");
} catch (retryWriteError) {
if (isMissingPathError(retryWriteError)) {
return;
}
throw retryWriteError;
}
}
}
getState(): DevServerState {