Files
next-tjwater-frontend/src/features/agent/frontend-action/executor.ts
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

122 lines
3.7 KiB
TypeScript

import {
parseFrontendActionRequest,
readActionResults,
storeActionResult,
type FrontendActionRequest,
type FrontendActionResult
} from ".";
type ExecuteAction = (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
type SubmitResult = (
sessionId: string,
actionId: string,
result: FrontendActionResult
) => Promise<void>;
const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]+$/;
export class FrontendActionExecutor {
private readonly controllers = new Map<string, AbortController>();
private queue: Promise<void> = Promise.resolve();
constructor(
private readonly execute: ExecuteAction,
private readonly submit: SubmitResult
) {}
async handle(value: unknown, activeSessionId: string | null): Promise<void> {
const request = parseFrontendActionRequest(value);
if (!request || request.sessionId !== activeSessionId) return;
const saved = readActionResults(request.sessionId)[request.actionId];
if (saved) {
await this.submit(request.sessionId, request.actionId, saved);
return;
}
if (this.controllers.has(request.actionId)) return;
const controller = new AbortController();
this.controllers.set(request.actionId, controller);
const pending = this.queue.then(async () => {
const result = await this.executeOnce(request, controller);
storeActionResult(request.sessionId, result);
await this.submit(request.sessionId, request.actionId, result);
});
this.queue = pending.catch(() => undefined);
return pending;
}
cancelAll(): void {
for (const controller of this.controllers.values()) controller.abort();
this.controllers.clear();
this.queue = Promise.resolve();
}
private async executeOnce(
request: FrontendActionRequest,
controller: AbortController
): Promise<FrontendActionResult> {
const remaining = request.expiresAt - Date.now();
const timer =
remaining > 0
? setTimeout(
() => controller.abort(new DOMException("Action expired", "TimeoutError")),
remaining
)
: undefined;
try {
controller.signal.throwIfAborted();
if (remaining <= 0)
return this.failure(
request,
"expired",
"ACTION_EXPIRED",
"frontend action expired before execution"
);
const output = await this.execute(request, controller.signal);
controller.signal.throwIfAborted();
return {
version: "frontend-action-result@2",
actionId: request.actionId,
status: "succeeded",
output,
completedAt: Date.now()
};
} catch (error) {
const expired =
controller.signal.reason instanceof DOMException &&
controller.signal.reason.name === "TimeoutError";
const message = error instanceof Error ? error.message : "frontend action failed";
const code = expired
? "ACTION_EXPIRED"
: controller.signal.aborted
? "ACTION_CANCELLED"
: ERROR_CODE_PATTERN.test(message)
? message
: "ACTION_FAILED";
return this.failure(
request,
expired ? "expired" : controller.signal.aborted ? "cancelled" : "failed",
code,
message
);
} finally {
clearTimeout(timer);
if (this.controllers.get(request.actionId) === controller)
this.controllers.delete(request.actionId);
}
}
private failure(
request: FrontendActionRequest,
status: "failed" | "cancelled" | "expired",
code: string,
message: string
): FrontendActionResult {
return {
version: "frontend-action-result@2",
actionId: request.actionId,
status,
error: { code, message },
completedAt: Date.now()
};
}
}