79 lines
4.7 KiB
TypeScript
79 lines
4.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import type { WorkbenchApplication, WorkbenchTurnInput } from "./contracts.ts";
|
|
|
|
type NativeState = {
|
|
sessions: Record<string, Record<string, unknown>>;
|
|
turns: Record<string, Record<string, unknown>>;
|
|
};
|
|
|
|
export function createNativeTestWorkbenchApplication(options: { stateFile: string; now?: () => string }): WorkbenchApplication {
|
|
const now = options.now ?? (() => new Date().toISOString());
|
|
const update = async <T>(fn: (state: NativeState) => T | Promise<T>) => {
|
|
const state = await load(options.stateFile);
|
|
const result = await fn(state);
|
|
await mkdir(path.dirname(options.stateFile), { recursive: true });
|
|
await writeFile(options.stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
return result;
|
|
};
|
|
return {
|
|
async snapshot() { return load(options.stateFile); },
|
|
async health() {
|
|
const state = await load(options.stateFile);
|
|
return { serviceId: "hwlab-workbench-native-test", status: "ready", sessionCount: Object.keys(state.sessions).length, turnCount: Object.keys(state.turns).length };
|
|
},
|
|
async createSession({ actor, params }) {
|
|
return update((state) => {
|
|
const sessionId = validOptionalId(params.sessionId, "ses_") ?? `ses_${randomUUID()}`;
|
|
const conversationId = validOptionalId(params.conversationId, "cnv_") ?? `cnv_${randomUUID()}`;
|
|
const session = { sessionId, conversationId, projectId: text(params.projectId) || "prj_hwpod_workbench", ownerUserId: actor.id, status: "idle", providerProfile: text(params.providerProfile) || "native-test", createdAt: now(), updatedAt: now(), mode: "native-test", messages: [] as Record<string, unknown>[] };
|
|
state.sessions[sessionId] = session;
|
|
return { status: "created", session, valuesRedacted: true, secretMaterialStored: false };
|
|
});
|
|
},
|
|
async admitTurn(input) {
|
|
return update((state) => {
|
|
const session = state.sessions[input.sessionId];
|
|
if (!session || session.ownerUserId !== input.actor.id) throw codedError("session_not_found", "session is not visible to the actor");
|
|
const createdAt = now();
|
|
const message = text(input.params.message);
|
|
state.turns[input.traceId] = { traceId: input.traceId, sessionId: input.sessionId, ownerUserId: input.actor.id, status: "admitted", message, createdAt, updatedAt: createdAt, terminal: false };
|
|
const messages = Array.isArray(session.messages) ? session.messages as Record<string, unknown>[] : [];
|
|
messages.push({ messageId: `msg_user_${input.traceId.slice(4)}`, role: "user", content: message, traceId: input.traceId, status: "completed", createdAt });
|
|
session.messages = messages;
|
|
session.status = "running";
|
|
session.lastTraceId = input.traceId;
|
|
session.updatedAt = createdAt;
|
|
return input;
|
|
});
|
|
},
|
|
async dispatchTurn(input: WorkbenchTurnInput) {
|
|
return update((state) => {
|
|
const turn = state.turns[input.traceId];
|
|
if (!turn) throw codedError("turn_not_admitted", "turn must be admitted before dispatch");
|
|
Object.assign(turn, { status: "running", updatedAt: now(), runId: `run_native_${input.traceId.slice(4)}`, commandId: `cmd_native_${input.traceId.slice(4)}` });
|
|
return { accepted: true, status: "running", traceId: input.traceId, sessionId: input.sessionId, runId: turn.runId, commandId: turn.commandId, mode: "native-test", terminal: false };
|
|
});
|
|
},
|
|
async cancelTurn(input) {
|
|
return update((state) => {
|
|
const turn = state.turns[input.traceId];
|
|
if (!turn || turn.ownerUserId !== input.actor.id) throw codedError("turn_not_found", "turn is not visible to the actor");
|
|
Object.assign(turn, { status: "cancel_requested", updatedAt: now() });
|
|
return { accepted: true, status: "cancel_requested", traceId: input.traceId, terminal: false, mode: "native-test" };
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
async function load(stateFile: string): Promise<NativeState> {
|
|
const parsed = JSON.parse(await readFile(stateFile, "utf8").catch(() => '{"sessions":{},"turns":{}}'));
|
|
return { sessions: object(parsed.sessions), turns: object(parsed.turns) };
|
|
}
|
|
function object(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, Record<string, unknown>> : {}; }
|
|
function text(value: unknown) { return String(value ?? "").trim(); }
|
|
function validOptionalId(value: unknown, prefix: string) { const id = text(value); if (!id) return null; if (!id.startsWith(prefix)) throw codedError("invalid_input", `${prefix.slice(0, -1)} id is invalid`); return id; }
|
|
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
|