Extract plugin sdk runtime helpers

This commit is contained in:
OpenPets Dev 2026-06-18 18:58:01 +00:00
parent 385d14184f
commit af3bd09496
6 changed files with 108 additions and 19 deletions

View file

@ -48,6 +48,7 @@ const behaviorTests = [
".test-dist/tests/agent-setup-support.test.js",
".test-dist/tests/plugin-config.test.js",
".test-dist/tests/plugin-sdk-bridge.test.js",
".test-dist/tests/plugin-sdk-runtime-helpers.test.js",
".test-dist/tests/plugin-sdk-surface-boundaries.test.js",
".test-dist/tests/plugin-state.test.js",
".test-dist/tests/plugin-runtime.test.js",

View file

@ -118,6 +118,8 @@ const agentSetupSource = readFileSync(join(appDir, "src", "agent-setup.ts"), "ut
const agentSetupCommandContextSource = readFileSync(join(appDir, "src", "agent-setup-command-context.ts"), "utf8");
const agentSetupEditorToolsSource = readFileSync(join(appDir, "src", "agent-setup-editor-tools.ts"), "utf8");
const agentSetupSupportSource = readFileSync(join(appDir, "src", "agent-setup-support.ts"), "utf8");
const pluginSdkBridgeSource = readFileSync(join(appDir, "src", "plugin-sdk-bridge.ts"), "utf8");
const pluginSdkRuntimeHelpersSource = readFileSync(join(appDir, "src", "plugin-sdk-runtime-helpers.ts"), "utf8");
const pluginServiceSource = readFileSync(join(appDir, "src", "plugin-service.ts"), "utf8");
const pluginServiceDefaultPetSource = readFileSync(join(appDir, "src", "plugin-service-default-pet.ts"), "utf8");
const pluginServiceSnapshotSource = readFileSync(join(appDir, "src", "plugin-service-snapshot.ts"), "utf8");
@ -274,6 +276,8 @@ assert.match(controlCenterSettingsCombinedSource, /API key or token|OpenAI API k
assert.match(pluginServiceSource, /from "\.\/plugin-service-default-pet(?:\.js)?"/, "plugin-service must import the extracted default-pet helper seam.");
assert.match(pluginServiceSource, /from "\.\/plugin-service-snapshot(?:\.js)?"/, "plugin-service must import the extracted snapshot helper seam.");
assert.match(pluginServiceSource, /from "\.\/plugin-service-text-helpers(?:\.js)?"/, "plugin-service must import the extracted plugin text helper seam.");
assert.match(pluginSdkBridgeSource, /from "\.\/plugin-sdk-runtime-helpers(?:\.js)?"/, "plugin-sdk-bridge must import the extracted runtime helper seam.");
assert.match(pluginSdkRuntimeHelpersSource, /export function nextScheduleDelayMs/, "plugin-sdk runtime helper seam must own schedule delay calculation.");
assert.match(pluginServiceDefaultPetSource, /export async function buildDefaultPetPluginCommands/, "plugin-service default-pet helper must export default familiar command shaping.");
assert.match(pluginServiceSnapshotSource, /export async function buildSafePluginRecord/, "plugin-service snapshot helper must export safe plugin snapshot shaping.");
assert.match(pluginServiceSnapshotSource, /export function validatePluginServiceConfigReplacement/, "plugin-service snapshot helper must export config validation delegation.");

View file

@ -120,7 +120,7 @@ main.ts → initializePluginService(userData, defaultPluginPetApi, appVersion, E
├── plugin-runtime.ts reloads enabled manifests
│ ├── declarative runtime schedules timer triggers
│ ├── plugin-js-host.ts starts hidden sandboxed BrowserWindow hosts for JavaScript plugins
│ └── plugin-sdk-bridge.ts → plugin-familiar-api.ts/default-familiar-controller plus schedules, storage, commands, status, logs, and restricted network
│ └── plugin-sdk-bridge.ts → plugin-sdk-runtime-helpers.ts plus plugin-familiar-api.ts/default-familiar-controller for schedules, storage, commands, status, logs, and restricted network
├── plugin-service.ts orchestrates UI actions, permission confirmation, config validation, install/update/uninstall/load-local, and runtime reloads
└── lifecycle.ts → stopPluginService() on quit
@ -231,7 +231,8 @@ plugin-local-loader.ts validates selected folder manifest and snapshots only fam
- `plugin-package.ts`: Catalog plugin package download/install with HTTPS host/path allowlist, SHA-256 verification, ZIP size/entry restrictions, manifest/catalog consistency checks, and safe uninstall path resolution.
- `plugin-local-loader.ts`: Developer loader that validates a selected local folder and snapshots only the manifest into `plugins-dev` with symlink/path/size protections.
- `plugin-js-host.ts`: Sandboxed hidden BrowserWindow host for JavaScript plugin entry modules with per-plugin session partitioning, navigation/window-open hardening, SDK IPC tokening, registration handshake, config listener cleanup, and teardown.
- `plugin-sdk-bridge.ts`: Permission-checked JavaScript plugin SDK for familiar speech/reactions, one-shot/repeating/daily schedules, storage with quotas, config listeners, commands, status, logs, and HTTPS-only public-host fetch.
- `plugin-sdk-bridge.ts`: Permission-checked JavaScript plugin SDK for familiar speech/reactions, storage with quotas, config listeners, commands, status, logs, and HTTPS-only public-host fetch.
- `plugin-sdk-runtime-helpers.ts`: Extracted schedule timing, timeout wrapping, bubble counting, and callback error normalization helpers for the JavaScript plugin SDK bridge.
**Agent Integration**:
- `agent-setup.ts`: Claude detection, MCP configuration orchestration, hooks management, action journaling

View file

@ -24,6 +24,13 @@ import {
validateNetOptions,
} from "./plugin-sdk-network.js";
import { pluginSdkQuotas } from "./plugin-sdk-quotas.js";
import {
countActiveBubbles,
nextScheduleDelayMs,
parseDaily,
safeError,
withTimeout,
} from "./plugin-sdk-runtime-helpers.js";
import {
createPluginAudioApi,
createPluginBusApi,
@ -40,7 +47,6 @@ import {
commandIdPattern,
isRecord,
namedHostIcons,
nextCronRunMs,
normalizeJson,
parseCronExpression,
renderLimitedMarkdown,
@ -681,19 +687,3 @@ export class PluginSdkBridge {
return state;
}
}
function countActiveBubbles(state: PluginRuntimeState): number { return state.bubbles.size; }
function parseDaily(spec: string | { time: string; days?: number[] }): { time: string; days?: number[] } { const value = typeof spec === "string" ? { time: spec } : spec; const m = /^(\d{2}):(\d{2})$/.exec(value.time); if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) throw new Error("Daily schedule time must be HH:mm between 00:00 and 23:59."); if (value.days && (!Array.isArray(value.days) || value.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6))) throw new Error("Daily schedule days must be weekdays 0-6."); return value; }
function msUntilDaily(spec: { time: string; days?: number[] }): number { const [hour, minute] = spec.time.split(":").map(Number); const now = new Date(); for (let add = 0; add <= 7; add += 1) { const next = new Date(now); next.setDate(now.getDate() + add); next.setHours(hour ?? 0, minute ?? 0, 0, 0); if (next > now && (!spec.days || spec.days.includes(next.getDay()))) return next.getTime() - now.getTime(); } return 24 * 60 * 60 * 1000; }
function nextScheduleDelayMs(spec: ScheduleSpec): number | null {
if (spec.type === "once") return Math.max(1, spec.delayMs);
if (spec.type === "every") return Math.max(1, spec.intervalMs);
if (spec.type === "daily") return msUntilDaily(spec.daily);
if (spec.type === "at") return Math.max(1_000, spec.timestamp - Date.now());
const next = nextCronRunMs(spec.expr, Date.now());
return next === null ? null : Math.max(1_000, next - Date.now());
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> { return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error("Plugin command timed out.")), timeoutMs); promise.then((v) => { clearTimeout(timeout); resolve(v); }, (e) => { clearTimeout(timeout); reject(e); }); }); }
function safeError(error: unknown): string { return error instanceof Error ? error.message : "Plugin SDK callback failed."; }

View file

@ -0,0 +1,61 @@
import type { PluginRuntimeState, ScheduleSpec } from "./plugin-sdk-state.js";
import { nextCronRunMs } from "./plugin-sdk-validators.js";
export function countActiveBubbles(state: PluginRuntimeState): number {
return state.bubbles.size;
}
export function parseDaily(spec: string | { time: string; days?: number[] }): { time: string; days?: number[] } {
const value = typeof spec === "string" ? { time: spec } : spec;
const match = /^(\d{2}):(\d{2})$/.exec(value.time);
if (!match || Number(match[1]) > 23 || Number(match[2]) > 59) {
throw new Error("Daily schedule time must be HH:mm between 00:00 and 23:59.");
}
if (value.days && (!Array.isArray(value.days) || value.days.some((day) => !Number.isInteger(day) || day < 0 || day > 6))) {
throw new Error("Daily schedule days must be weekdays 0-6.");
}
return value;
}
export function nextScheduleDelayMs(spec: ScheduleSpec): number | null {
if (spec.type === "once") return Math.max(1, spec.delayMs);
if (spec.type === "every") return Math.max(1, spec.intervalMs);
if (spec.type === "daily") return msUntilDaily(spec.daily);
if (spec.type === "at") return Math.max(1_000, spec.timestamp - Date.now());
const next = nextCronRunMs(spec.expr, Date.now());
return next === null ? null : Math.max(1_000, next - Date.now());
}
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Plugin command timed out.")), timeoutMs);
promise.then(
(value) => {
clearTimeout(timeout);
resolve(value);
},
(error) => {
clearTimeout(timeout);
reject(error);
},
);
});
}
export function safeError(error: unknown): string {
return error instanceof Error ? error.message : "Plugin SDK callback failed.";
}
function msUntilDaily(spec: { time: string; days?: number[] }): number {
const [hour, minute] = spec.time.split(":").map(Number);
const now = new Date();
for (let add = 0; add <= 7; add += 1) {
const next = new Date(now);
next.setDate(now.getDate() + add);
next.setHours(hour ?? 0, minute ?? 0, 0, 0);
if (next > now && (!spec.days || spec.days.includes(next.getDay()))) {
return next.getTime() - now.getTime();
}
}
return 24 * 60 * 60 * 1000;
}

View file

@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { countActiveBubbles, nextScheduleDelayMs, parseDaily, safeError, withTimeout } from "../src/plugin-sdk-runtime-helpers.js";
const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
const pluginSdkBridgeSource = readFileSync(resolve(desktopRoot, "src/plugin-sdk-bridge.ts"), "utf8");
const pluginSdkRuntimeHelpersSource = readFileSync(resolve(desktopRoot, "src/plugin-sdk-runtime-helpers.ts"), "utf8");
assert.match(pluginSdkBridgeSource, /from "\.\/plugin-sdk-runtime-helpers\.js"/, "Plugin SDK bridge must import the extracted runtime helper seam.");
assert.match(pluginSdkRuntimeHelpersSource, /export function nextScheduleDelayMs/, "Plugin SDK runtime helpers must export schedule delay calculation.");
assert.match(pluginSdkRuntimeHelpersSource, /export function withTimeout/, "Plugin SDK runtime helpers must export timeout wrapping.");
assert.deepEqual(parseDaily("09:15"), { time: "09:15" });
assert.deepEqual(parseDaily({ time: "18:45", days: [1, 3, 5] }), { time: "18:45", days: [1, 3, 5] });
assert.throws(() => parseDaily("25:00"), /Daily schedule time must be HH:mm/);
assert.throws(() => parseDaily({ time: "12:30", days: [7] }), /Daily schedule days must be weekdays 0-6/);
assert.equal(nextScheduleDelayMs({ type: "once", delayMs: 0 }), 1);
assert.equal(nextScheduleDelayMs({ type: "every", intervalMs: 25 }), 25);
assert.equal(nextScheduleDelayMs({ type: "at", timestamp: Date.now() - 5_000 }), 1_000);
assert.equal(safeError(new Error("boom")), "boom");
assert.equal(safeError("boom"), "Plugin SDK callback failed.");
assert.equal(countActiveBubbles({ bubbles: new Map([["one", {}], ["two", {}]]) } as never), 2);
await assert.rejects(() => withTimeout(new Promise(() => undefined), 5), /Plugin command timed out\./);
await assert.doesNotReject(() => withTimeout(Promise.resolve("ok"), 50));
console.error("Plugin SDK runtime helper validation passed.");