refactor: executor protocol, fallback, debounce

This commit is contained in:
2026-09-26 02:20:34 +05:00
parent 5f77c097e2
commit 0933f1cdea
14 changed files with 739 additions and 301 deletions
+289
View File
@@ -0,0 +1,289 @@
import { describe, expect, it, vi } from "vitest";
import { ToolError } from "../core/errors";
import {
createExecutor,
type ExecuteContext,
type WorkerFactory,
type WorkerLike,
} from "./executor";
import {
decodeWorkerResponse,
type WorkerRequest,
type WorkerResponse,
} from "./protocol";
import { handleWorkerRequest } from "./worker-handler";
import { field, toolSchema, type Dimension } from "../registry-schema";
import type { ToolEntry } from "../registry";
class FakeWorker implements WorkerLike {
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
requests: WorkerRequest[] = [];
terminated = false;
throwOnPost = false;
postMessage(request: WorkerRequest): void {
this.requests.push(request);
if (this.throwOnPost) throw new Error("post failed");
}
respond(response: WorkerResponse): void {
this.onmessage?.({ data: response } as MessageEvent<WorkerResponse>);
}
fail(): void {
this.onerror?.(new Event("error"));
}
terminate(): void {
this.terminated = true;
}
}
function createFactory(throwOnPost = false): {
factory: WorkerFactory;
workers: FakeWorker[];
} {
const workers: FakeWorker[] = [];
return {
factory: () => {
const worker = new FakeWorker();
worker.throwOnPost = throwOnPost;
workers.push(worker);
return worker;
},
workers,
};
}
const emptySchema = toolSchema<Record<string, never>>({});
function makeTool(
run: ToolEntry["run"] = () => "direct",
overrides: Partial<ToolEntry> = {},
): ToolEntry {
return {
id: "test-tool",
title: "Test tool",
description: "Test tool",
category: "analyze",
schema: emptySchema,
input: "none",
run,
...overrides,
};
}
function image(width = 2, height = 2): WorkerRequest["source"] {
return {
width,
height,
data: new Uint8ClampedArray(width * height * 4),
};
}
describe("createExecutor", () => {
it("validates required input before creating a worker", async () => {
const factory = vi.fn<WorkerFactory>();
const execute = createExecutor({ workerFactory: factory });
const imageTool = makeTool(vi.fn(), { input: "image" });
const textTool = makeTool(vi.fn(), { input: "text" });
await expect(execute(imageTool, { params: {} })).rejects.toMatchObject({
key: "errors.sourceRequired",
});
await expect(
execute(textTool, { params: {}, text: " " }),
).rejects.toMatchObject({ key: "errors.textRequired" });
expect(factory).not.toHaveBeenCalled();
});
it("routes domOnly tools directly", async () => {
const { factory, workers } = createFactory();
const execute = createExecutor({ workerFactory: factory });
const run = vi.fn(() => "direct");
const tool = makeTool(run, { domOnly: true });
await expect(execute(tool, { params: {} })).resolves.toBe("direct");
expect(workers).toHaveLength(0);
});
it("passes source-aware sanitized params through the worker request", async () => {
const { factory, workers } = createFactory();
const execute = createExecutor({ workerFactory: factory });
const schema = toolSchema<{ size: Dimension }>({
size: field.dimension({
min: 1,
max: 100,
width: 1,
height: 1,
defaultFromSource: true,
}),
});
const tool = makeTool(() => "worker", { input: "image", schema });
const promise = execute(tool, {
params: { size: { width: 0, height: 0 } },
source: image(64, 48) as ExecuteContext["source"],
});
const worker = workers[0];
const request = worker.requests[0];
worker.respond({ id: request.id, ok: true, text: "worker" });
await expect(promise).resolves.toBe("worker");
expect(request.params).toEqual({ size: { width: 64, height: 48 } });
});
it("resolves concurrent worker responses by request id", async () => {
const { factory, workers } = createFactory();
const execute = createExecutor({ workerFactory: factory });
const tool = makeTool();
const first = execute(tool, { params: {} });
const second = execute(tool, { params: {} });
const worker = workers[0];
const [firstRequest, secondRequest] = worker.requests;
worker.respond({ id: secondRequest.id, ok: true, text: "second" });
worker.respond({ id: firstRequest.id, ok: true, text: "first" });
await expect(first).resolves.toBe("first");
await expect(second).resolves.toBe("second");
});
it("does not retry a worker ToolError directly", async () => {
const { factory, workers } = createFactory();
const execute = createExecutor({ workerFactory: factory });
const run = vi.fn(() => "direct");
const tool = makeTool(run);
const promise = execute(tool, { params: {} });
const worker = workers[0];
const request = worker.requests[0];
worker.respond({
id: request.id,
ok: false,
errorKey: "errors.badPixelToken",
errorVars: { value: "x" },
});
await expect(promise).rejects.toMatchObject({
key: "errors.badPixelToken",
vars: { value: "x" },
});
expect(run).not.toHaveBeenCalled();
});
it("falls back when worker construction fails", async () => {
const factory = vi.fn<WorkerFactory>(() => {
throw new Error("construction failed");
});
const execute = createExecutor({ workerFactory: factory });
const run = vi.fn(() => "direct");
const tool = makeTool(run);
await expect(execute(tool, { params: {} })).resolves.toBe("direct");
await expect(execute(tool, { params: {} })).resolves.toBe("direct");
expect(factory).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledTimes(2);
});
it("falls back after transport failures and disables the worker", async () => {
const { factory, workers } = createFactory(true);
const execute = createExecutor({ workerFactory: factory });
const run = vi.fn(() => "direct");
const tool = makeTool(run);
const postFailure = execute(tool, { params: {} });
await expect(postFailure).resolves.toBe("direct");
expect(workers[0].terminated).toBe(true);
await expect(execute(tool, { params: {} })).resolves.toBe("direct");
expect(workers).toHaveLength(1);
});
it("falls back after onerror and malformed responses", async () => {
for (const failure of ["error", "malformed"] as const) {
const { factory, workers } = createFactory();
const execute = createExecutor({ workerFactory: factory });
const tool = makeTool(() => "direct");
const promise = execute(tool, { params: {} });
const worker = workers[0];
if (failure === "error") {
worker.fail();
} else {
worker.respond({ id: worker.requests[0].id, ok: true });
}
await expect(promise).resolves.toBe("direct");
expect(worker.terminated).toBe(true);
}
});
it("decodes image, verdict, and files worker responses", () => {
const data = new Uint8ClampedArray([1, 2, 3, 4]);
const imageResult = decodeWorkerResponse({
id: 1,
ok: true,
width: 1,
height: 1,
data,
});
expect(imageResult).toMatchObject({ width: 1, height: 1 });
const verdict = decodeWorkerResponse({
id: 2,
ok: true,
text: "line",
textVars: { kb: "1" },
});
expect(verdict).toEqual({ key: "line", vars: { kb: "1" } });
const files = decodeWorkerResponse({
id: 3,
ok: true,
files: [{ name: "part.png", width: 1, height: 1, data }],
});
expect(files).toMatchObject({
files: [{ name: "part.png", image: { width: 1, height: 1 } }],
});
});
});
describe("handleWorkerRequest", () => {
it("serializes image results and preserves ToolError keys", async () => {
const imageResult = await handleWorkerRequest({
id: 1,
toolId: "flip-png",
params: {},
source: image(),
});
expect(imageResult.response).toMatchObject({
id: 1,
ok: true,
width: 2,
height: 2,
});
expect(imageResult.transfer).toHaveLength(1);
const errorResult = await handleWorkerRequest({
id: 2,
toolId: "bytes-to-png",
params: { width: 32 },
text: Array.from({ length: 132 }, () => "1").join(" "),
});
expect(errorResult.response).toMatchObject({
id: 2,
ok: false,
errorKey: "errors.pixelCountMismatch",
errorVars: { count: 33, width: 32 },
});
});
it("serializes FileResult transfers", async () => {
const result = await handleWorkerRequest({
id: 3,
toolId: "split-into-parts-png",
params: { columns: 2, rows: 2 },
source: image(),
});
expect(result.response).toMatchObject({ id: 3, ok: true });
expect(result.transfer).toHaveLength(4);
});
});
+154 -169
View File
@@ -1,184 +1,169 @@
import { ToolError } from "$lib/core/errors";
import type { PixelImage } from "$lib/core/types";
import type { ToolContext, ToolEntry, ToolResult } from "$lib/registry";
import { sanitizeSchemaParams } from "$lib/registry-schema";
import { ToolError } from "../core/errors";
import type { PixelImage } from "../core/types";
import type { ToolContext, ToolEntry, ToolResult } from "../registry";
import { sanitizeSchemaParams } from "../registry-schema";
import {
decodeWorkerResponse,
isWorkerResponse,
type WorkerRequest,
type WorkerResponse,
} from "./protocol";
/** Вход единой точки исполнения. `source`/`text` — по контракту `tool.input`. */
export type ExecuteContext = {
params: Record<string, unknown>;
source?: PixelImage;
text?: string;
};
/**
* Единственная точка исполнения инструмента. Валидирует наличие входа по
* `tool.input`, санитит параметры и запускает `tool.run`. Выбор места
* исполнения (worker/поток) — внутренняя забота executor'а, диспетча методов
* нет: у любого инструмента один `run(ctx)`.
*/
export async function execute(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
const params = sanitizeSchemaParams(tool.schema, ctx.params, {
source: ctx.source,
});
if (tool.input === "image" && !ctx.source) {
throw new ToolError("errors.sourceRequired");
}
if (tool.input === "text" && !ctx.text?.trim()) {
throw new ToolError("errors.textRequired");
}
return route(tool, { params, source: ctx.source, text: ctx.text });
export type WorkerLike = {
postMessage(message: WorkerRequest): void;
terminate(): void;
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null;
onerror: ((event: Event) => void) | null;
};
export type WorkerFactory = () => WorkerLike;
type PendingRequest = {
resolve: (result: ToolResult) => void;
reject: (error: unknown) => void;
};
function defaultWorkerFactory(): WorkerLike {
return new Worker(new URL("./executor.worker.ts", import.meta.url), {
type: "module",
}) as unknown as WorkerLike;
}
/**
* Маршрутизация по способу исполнения. В worker уходит всё, кроме
* domOnly-инструментов: даже текстовые вердикты могут быть тяжёлыми
* (гистограммы, сложный анализ), поэтому ограничивать по типу результата
* неверно. Ограничение одно — доступ к DOM, которого в worker нет.
*/
async function route(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
if (tool.domOnly || typeof Worker === "undefined") {
return await runDirect(tool, ctx);
}
const worker = ensureWorker();
if (worker === null) {
return await runDirect(tool, ctx);
}
try {
return await runInWorker(worker, tool, ctx);
} catch (workerError) {
disableWorker();
void workerError;
return await runDirect(tool, ctx);
}
}
export function createExecutor(
options: { workerFactory?: WorkerFactory } = {},
): (tool: ToolEntry, ctx: ExecuteContext) => Promise<ToolResult> {
const workerFactory = options.workerFactory ?? defaultWorkerFactory;
let worker: WorkerLike | null = null;
let workerTried = false;
let nextRequestId = 1;
const pending = new Map<number, PendingRequest>();
async function runDirect(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
return await tool.run(ctx as ToolContext<Record<string, unknown>>);
}
let worker: Worker | null = null;
let workerTried = false;
let nextRequestId = 1;
const pending = new Map<
number,
{ resolve: (result: ToolResult) => void; reject: (e: Error) => void }
>();
function ensureWorker(): Worker | null {
if (workerTried) return worker;
workerTried = true;
try {
const candidate = new Worker(
new URL("./executor.worker.ts", import.meta.url),
{
type: "module",
},
);
candidate.onmessage = (event: MessageEvent) => {
const payload = event.data as {
id: number;
ok: boolean;
width?: number;
height?: number;
data?: Uint8ClampedArray;
files?: {
name: string;
width: number;
height: number;
data: Uint8ClampedArray;
}[];
text?: string;
textVars?: Record<string, string | number>;
error?: string;
errorKey?: string;
errorVars?: Record<string, string | number>;
};
const entry = pending.get(payload.id);
if (!entry) return;
pending.delete(payload.id);
if (payload.ok && payload.data && payload.width && payload.height) {
entry.resolve({
width: payload.width,
height: payload.height,
data: new Uint8ClampedArray(payload.data),
});
} else if (payload.ok && Array.isArray(payload.files)) {
entry.resolve({
files: payload.files.map((file) => ({
name: file.name,
image: {
width: file.width,
height: file.height,
data: new Uint8ClampedArray(file.data),
},
})),
});
} else if (payload.ok && typeof payload.text === "string") {
entry.resolve(
payload.textVars
? { key: payload.text, vars: payload.textVars }
: payload.text,
);
} else if (payload.errorKey) {
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
} else {
entry.reject(
payload.error
? new Error(payload.error)
: new ToolError("errors.workerFailed"),
);
}
};
candidate.onerror = () => {
worker = null;
for (const entry of pending.values()) {
entry.reject(new ToolError("errors.workerUnavailable"));
}
pending.clear();
};
worker = candidate;
} catch {
worker = null;
function failWorker(candidate: WorkerLike, error: unknown): void {
if (worker === candidate) worker = null;
candidate.terminate();
for (const [id, entry] of pending) {
pending.delete(id);
entry.reject(error);
}
}
return worker;
}
function disableWorker(): void {
if (worker) {
worker.terminate();
}
worker = null;
}
function runInWorker(
workerInstance: Worker,
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
return new Promise((resolve, reject) => {
const id = nextRequestId++;
pending.set(id, { resolve, reject });
workerInstance.postMessage({
id,
toolId: tool.id,
params: ctx.params,
source: ctx.source
? {
width: ctx.source.width,
height: ctx.source.height,
data: new Uint8ClampedArray(ctx.source.data),
function ensureWorker(): WorkerLike | null {
if (workerTried) return worker;
workerTried = true;
let candidate: WorkerLike | undefined;
try {
candidate = workerFactory();
candidate.onmessage = (event) => {
const payload: unknown = event.data;
if (!isWorkerResponse(payload)) {
failWorker(
candidate as WorkerLike,
new Error("Invalid worker response"),
);
return;
}
const entry = pending.get(payload.id);
if (!entry) return;
try {
const result = decodeWorkerResponse(payload);
pending.delete(payload.id);
entry.resolve(result);
} catch (error) {
if (error instanceof ToolError) {
pending.delete(payload.id);
entry.reject(error);
} else {
failWorker(candidate as WorkerLike, error);
}
: undefined,
text: ctx.text,
}
};
candidate.onerror = () => {
failWorker(candidate as WorkerLike, new Error("Worker error"));
};
worker = candidate;
return candidate;
} catch (error) {
candidate?.terminate();
worker = null;
return null;
}
}
function disableWorker(candidate: WorkerLike): void {
if (worker !== candidate) return;
worker = null;
candidate.terminate();
}
async function runDirect(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
return await tool.run(ctx as ToolContext<Record<string, unknown>>);
}
function runInWorker(
candidate: WorkerLike,
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
return new Promise((resolve, reject) => {
const id = nextRequestId++;
pending.set(id, { resolve, reject });
try {
candidate.postMessage({
id,
toolId: tool.id,
params: ctx.params,
source: ctx.source
? {
width: ctx.source.width,
height: ctx.source.height,
data: new Uint8ClampedArray(ctx.source.data),
}
: undefined,
text: ctx.text,
});
} catch (error) {
failWorker(candidate, error);
}
});
});
}
async function route(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
if (tool.domOnly) return await runDirect(tool, ctx);
const candidate = ensureWorker();
if (candidate === null) return await runDirect(tool, ctx);
try {
return await runInWorker(candidate, tool, ctx);
} catch (error) {
if (error instanceof ToolError) throw error;
disableWorker(candidate);
return await runDirect(tool, ctx);
}
}
return async (tool, ctx) => {
const params = sanitizeSchemaParams(tool.schema, ctx.params, {
source: ctx.source,
});
if (tool.input === "image" && !ctx.source) {
throw new ToolError("errors.sourceRequired");
}
if (tool.input === "text" && !ctx.text?.trim()) {
throw new ToolError("errors.textRequired");
}
return await route(tool, { ...ctx, params });
};
}
export const execute = createExecutor();
+8 -107
View File
@@ -1,113 +1,14 @@
/// <reference lib="webworker" />
import { ToolError } from "$lib/core/errors";
import type { PixelImage } from "$lib/core/types";
import { getTool } from "$lib/registry";
import { sanitizeSchemaParams } from "$lib/registry-schema";
type WorkerRequest = {
id: number;
toolId: string;
params: Record<string, unknown>;
source?: { width: number; height: number; data: Uint8ClampedArray };
text?: string;
};
type WorkerResponse =
| {
id: number;
ok: true;
width?: number;
height?: number;
data?: Uint8ClampedArray;
text?: string;
textVars?: Record<string, string | number>;
files?: WorkerImageFile[];
}
| {
id: number;
ok: false;
error?: string;
errorKey?: string;
errorVars?: Record<string, string | number>;
};
type WorkerImageFile = {
name: string;
width: number;
height: number;
data: Uint8ClampedArray;
};
import { handleWorkerRequest } from "./worker-handler";
import type { WorkerRequest, WorkerResponse } from "./protocol";
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
void handle(event.data);
void respond(event.data);
};
async function handle(request: WorkerRequest): Promise<void> {
try {
const tool = getTool(request.toolId);
if (!tool?.run) {
throw new ToolError("errors.noImageRun");
}
const source: PixelImage | undefined = request.source
? {
width: request.source.width,
height: request.source.height,
data: new Uint8ClampedArray(request.source.data),
}
: undefined;
const output = await tool.run({
params: sanitizeSchemaParams(tool.schema, request.params, { source }),
source,
text: request.text,
});
if (typeof output === "string") {
(self as unknown as Worker).postMessage({
id: request.id,
ok: true,
text: output,
} satisfies WorkerResponse);
return;
}
if ("key" in output) {
(self as unknown as Worker).postMessage({
id: request.id,
ok: true,
text: output.key,
textVars: output.vars,
} satisfies WorkerResponse);
return;
}
if ("files" in output) {
const files = output.files.map((file) => ({
name: file.name,
width: file.image.width,
height: file.image.height,
data: file.image.data,
}));
(self as unknown as Worker).postMessage(
{ id: request.id, ok: true, files } satisfies WorkerResponse,
files.map((file) => file.data.buffer),
);
return;
}
(self as unknown as Worker).postMessage(
{
id: request.id,
ok: true,
width: output.width,
height: output.height,
data: output.data,
} satisfies WorkerResponse,
[output.data.buffer],
);
} catch (e) {
const toolError = e instanceof ToolError ? e : undefined;
(self as unknown as Worker).postMessage({
id: request.id,
ok: false,
error: e instanceof Error ? e.message : String(e),
errorKey: toolError?.key,
errorVars: toolError?.vars,
} satisfies WorkerResponse);
}
async function respond(request: WorkerRequest): Promise<void> {
const result = await handleWorkerRequest(request);
(self as unknown as Worker).postMessage(result.response, result.transfer);
}
export type { WorkerRequest, WorkerResponse };
+161
View File
@@ -0,0 +1,161 @@
import { ToolError, type ErrorVars } from "../core/errors";
import type { FileResult, ToolResult } from "../registry";
export type WorkerImage = {
width: number;
height: number;
data: Uint8ClampedArray;
};
export type WorkerFile = {
name: string;
width: number;
height: number;
data: Uint8ClampedArray;
};
export type WorkerRequest = {
id: number;
toolId: string;
params: Record<string, unknown>;
source?: WorkerImage;
text?: string;
};
export type WorkerResponse =
| {
id: number;
ok: true;
width?: number;
height?: number;
data?: Uint8ClampedArray;
text?: string;
textVars?: ErrorVars;
files?: WorkerFile[];
}
| {
id: number;
ok: false;
error?: string;
errorKey?: string;
errorVars?: ErrorVars;
};
export type WorkerResult = {
response: WorkerResponse;
transfer: Transferable[];
};
function transferables(...values: Uint8ClampedArray[]): Transferable[] {
return values.map((value) => value.buffer as ArrayBuffer);
}
export function isWorkerResponse(value: unknown): value is WorkerResponse {
if (typeof value !== "object" || value === null) return false;
const candidate = value as { id?: unknown; ok?: unknown };
return typeof candidate.id === "number" && typeof candidate.ok === "boolean";
}
export function encodeWorkerResponse(
id: number,
result: ToolResult,
): WorkerResult {
if (typeof result === "string") {
return { response: { id, ok: true, text: result }, transfer: [] };
}
if ("key" in result) {
return {
response: { id, ok: true, text: result.key, textVars: result.vars },
transfer: [],
};
}
if ("files" in result) {
const files = result.files.map((file) => ({
name: file.name,
width: file.image.width,
height: file.image.height,
data: file.image.data,
}));
return {
response: { id, ok: true, files },
transfer: transferables(...files.map((file) => file.data)),
};
}
return {
response: {
id,
ok: true,
width: result.width,
height: result.height,
data: result.data,
},
transfer: transferables(result.data),
};
}
export function encodeWorkerError(id: number, error: unknown): WorkerResult {
const toolError = error instanceof ToolError ? error : undefined;
return {
response: {
id,
ok: false,
error: error instanceof Error ? error.message : String(error),
errorKey: toolError?.key,
errorVars: toolError?.vars,
},
transfer: [],
};
}
function isWorkerFile(value: WorkerFile): boolean {
return (
typeof value.name === "string" &&
typeof value.width === "number" &&
typeof value.height === "number" &&
value.data instanceof Uint8ClampedArray
);
}
export function decodeWorkerResponse(payload: WorkerResponse): ToolResult {
if (payload.ok) {
if (
typeof payload.width === "number" &&
typeof payload.height === "number" &&
payload.data instanceof Uint8ClampedArray
) {
return {
width: payload.width,
height: payload.height,
data: new Uint8ClampedArray(payload.data),
};
}
if (Array.isArray(payload.files)) {
if (!payload.files.every(isWorkerFile)) {
throw new Error("Invalid worker response");
}
return {
files: payload.files.map((file) => ({
name: file.name,
image: {
width: file.width,
height: file.height,
data: new Uint8ClampedArray(file.data),
},
})),
} satisfies FileResult;
}
if (typeof payload.text === "string") {
return payload.textVars
? { key: payload.text, vars: payload.textVars }
: payload.text;
}
throw new Error("Invalid worker response");
}
if (payload.errorKey) {
throw new ToolError(payload.errorKey, payload.errorVars);
}
if (payload.error?.startsWith("errors.")) {
throw new ToolError(payload.error);
}
throw new Error(payload.error ?? "Invalid worker response");
}
+36
View File
@@ -0,0 +1,36 @@
import { ToolError } from "../core/errors";
import type { PixelImage } from "../core/types";
import { getTool } from "../registry";
import { sanitizeSchemaParams } from "../registry-schema";
import {
encodeWorkerError,
encodeWorkerResponse,
type WorkerRequest,
type WorkerResult,
} from "./protocol";
export async function handleWorkerRequest(
request: WorkerRequest,
): Promise<WorkerResult> {
try {
const tool = getTool(request.toolId);
if (!tool?.run) {
throw new ToolError("errors.noImageRun");
}
const source: PixelImage | undefined = request.source
? {
width: request.source.width,
height: request.source.height,
data: new Uint8ClampedArray(request.source.data),
}
: undefined;
const output = await tool.run({
params: sanitizeSchemaParams(tool.schema, request.params, { source }),
source,
text: request.text,
});
return encodeWorkerResponse(request.id, output);
} catch (error) {
return encodeWorkerError(request.id, error);
}
}