Files
ThothII/frontend/src/api/sessions.ts
T

229 lines
9.3 KiB
TypeScript

import { apiFetch } from "./client";
import {
captureAuthOperation,
isAuthOperationCurrent,
requireAuthOperationPrecondition,
type AuthOperationPrecondition,
} from "../auth/authOperation";
import { getSettings } from "./settings";
import { getWorkspace, listWorkspaces } from "./workspaces";
import {
WORKSPACE_POLICY_ERROR, WORKSPACE_SUMMARY_ERROR, WorkspaceSelectionError, workspacePolicyGate,
workspacePreferences, type WorkspacePreference,
} from "../workspaces/drafts";
import type {
AuthenticatedUser, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse,
} from "./types";
type NewSessionInput = { question: string; name?: string } & Partial<WorkspacePreference>;
async function selectedPreferences(precondition?: AuthOperationPrecondition): Promise<WorkspacePreference> {
requireAuthOperationPrecondition(precondition);
const saved = workspacePreferences.load();
if (saved.workspaceId && saved.provider && saved.model && saved.thinking) return saved;
// A direct new-session entry point can run before the composer has mounted. Seed current
// application memory from the installation defaults once, then keep choices ephemeral.
const legacy = await getSettings();
requireAuthOperationPrecondition(precondition);
return workspacePreferences.save({
workspaceId: saved.workspaceId ?? legacy.workspace,
provider: saved.provider ?? legacy.provider,
model: saved.model ?? legacy.model,
thinking: saved.thinking ?? legacy.thinking,
});
}
function reconcileWorkspacePolicy(preferences: WorkspacePreference, allowed: readonly string[], defaultModel?: string) {
if (allowed.length === 0) throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
const selected = preferences.provider && preferences.model
? `${preferences.provider}/${preferences.model}`
: undefined;
if (selected && allowed.includes(selected)) return preferences;
const replacement = defaultModel && allowed.includes(defaultModel) ? defaultModel : allowed[0];
const separator = replacement.indexOf("/");
if (separator <= 0 || separator === replacement.length - 1) {
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
}
return workspacePreferences.save({
...preferences,
provider: replacement.slice(0, separator),
model: replacement.slice(separator + 1),
});
}
async function ensureWorkspaceSelectionPolicy(precondition?: AuthOperationPrecondition): Promise<WorkspacePreference> {
while (true) {
requireAuthOperationPrecondition(precondition);
const preferences = workspacePreferences.load();
const workspaceId = preferences.workspaceId;
if (!workspaceId) return preferences;
workspacePolicyGate.beginSummary(workspaceId);
let workspaces: Awaited<ReturnType<typeof listWorkspaces>>;
try {
workspaces = await listWorkspaces();
} catch {
requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
workspacePolicyGate.rejectSummary(workspaceId);
throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR);
}
requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
const workspace = workspaces.find((candidate) => candidate.id === workspaceId);
if (!workspace) {
if (workspaces.length === 0) {
workspacePolicyGate.allowLegacy(workspaceId);
return workspacePreferences.load();
}
let installationDefault: string | undefined;
try {
installationDefault = (await getSettings()).workspace;
} catch {
requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
workspacePolicyGate.rejectSummary(workspaceId);
throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR);
}
requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
const replacement = workspaces.find(
(candidate) => candidate.id === installationDefault && candidate.revision,
) ?? workspaces.find((candidate) => candidate.revision);
if (!replacement) {
requireAuthOperationPrecondition(precondition);
workspacePolicyGate.reject(workspaceId);
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
}
requireAuthOperationPrecondition(precondition);
workspacePreferences.save({ ...preferences, workspaceId: replacement.id });
continue;
}
if (!workspace.revision) {
workspacePolicyGate.reject(workspaceId);
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
}
workspacePolicyGate.select(workspaceId);
const outcome = await Promise.race([
getWorkspace(workspaceId).then(
(record) => ({ kind: "record" as const, record }),
() => ({ kind: "error" as const }),
),
workspacePolicyGate.waitForCurrent(() => workspacePreferences.load()).then(
(selection) => ({ kind: "selection" as const, selection }),
),
]);
requireAuthOperationPrecondition(precondition);
if (outcome.kind === "selection") {
if (outcome.selection.workspaceId !== workspaceId) continue;
return outcome.selection;
}
if (outcome.kind === "error") {
requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
workspacePolicyGate.reject(workspaceId);
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
}
const { record } = outcome;
requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
let selection: WorkspacePreference;
try {
selection = reconcileWorkspacePolicy(
preferences,
record.workspace.llm_policy.allowed,
record.workspace.llm_policy.default,
);
} catch (error) {
requireAuthOperationPrecondition(precondition);
workspacePolicyGate.reject(workspaceId);
throw error;
}
requireAuthOperationPrecondition(precondition);
workspacePolicyGate.resolve(workspaceId);
requireAuthOperationPrecondition(precondition);
if (workspacePreferences.load().workspaceId === workspaceId) return selection;
}
}
export async function createSession(i: NewSessionInput, precondition?: AuthOperationPrecondition) {
const initiatingOperation = captureAuthOperation();
const effectivePrecondition = precondition ?? (initiatingOperation ? {
operation: initiatingOperation,
isCurrent: () => isAuthOperationCurrent(initiatingOperation, {
sessionId: null,
disposalEpoch: initiatingOperation.disposalEpoch,
}),
} : undefined);
requireAuthOperationPrecondition(effectivePrecondition);
await selectedPreferences(effectivePrecondition);
requireAuthOperationPrecondition(effectivePrecondition);
const selection = await ensureWorkspaceSelectionPolicy(effectivePrecondition);
requireAuthOperationPrecondition(effectivePrecondition);
return apiFetch<{ id: string }>("/sessions", {
method: "POST",
body: JSON.stringify({ ...i, ...selection }),
});
}
/** Best-effort warm-up; callers must not await it before showing the composer. */
export const prewarmRuntime = () =>
apiFetch<void>("/runtime/prewarm", { method: "POST" });
/** Compatibility export for existing shell tests/callers; AuthGate uses the validated auth API. */
export const getMe = () => apiFetch<AuthenticatedUser>("/me");
export const listSessions = (scope: SessionScope = "mine") =>
apiFetch<SessionSummary[]>(`/sessions?scope=${scope}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const getSession = (id: string) => apiFetch<any>(`/sessions/${id}`);
export const postResponse = (id: string, uiResponse: UiResponse) =>
apiFetch<void>(`/sessions/${id}/response`, {
method: "POST",
body: JSON.stringify({ ui_response: uiResponse }),
});
export const postSteer = (id: string, text: string) =>
apiFetch<void>(`/sessions/${id}/steer`, {
method: "POST",
body: JSON.stringify({ text }),
});
export const closeSession = (id: string) =>
apiFetch<void>(`/sessions/${id}/close`, { method: "POST" });
export const resumeSession = (id: string) =>
apiFetch<ResumeSessionResult>(`/sessions/${id}/resume`, { method: "POST" });
export const renameSession = (id: string, name: string) =>
apiFetch<void>(`/sessions/${id}/rename`, { method: "POST", body: JSON.stringify({ name }) });
export const setSessionGroup = (id: string, group: string) =>
apiFetch<void>(`/sessions/${id}/group`, { method: "POST", body: JSON.stringify({ group }) });
export const archiveSession = (id: string) =>
apiFetch<void>(`/sessions/${id}/archive`, { method: "POST" });
export const unarchiveSession = (id: string) =>
apiFetch<void>(`/sessions/${id}/unarchive`, { method: "POST" });
export const deleteSession = (id: string) =>
apiFetch<void>(`/sessions/${id}`, { method: "DELETE" });
export const getSessionDocuments = (id: string) =>
apiFetch<SessionDocument[]>(`/sessions/${id}/documents`);
export async function checkDwhHealth(): Promise<{ ok: boolean }> {
const timeout = new Promise<{ ok: false }>((r) => setTimeout(() => r({ ok: false }), 5000));
try {
return await Promise.race([
apiFetch<{ ok: boolean; detail: string }>("/health/dwh"),
timeout,
]);
} catch {
return { ok: false };
}
}