fix: gate direct session creation by workspace policy
This commit is contained in:
@@ -13,6 +13,9 @@ test("createSession migrates legacy selections and POSTs browser preferences", a
|
|||||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
||||||
workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||||
})),
|
})),
|
||||||
|
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{
|
||||||
|
id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical",
|
||||||
|
}])),
|
||||||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
return HttpResponse.json({ id: "s1" });
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
import { getSettings } from "./settings";
|
import { getSettings } from "./settings";
|
||||||
import { workspacePolicyGate, workspacePreferences, type WorkspacePreference } from "../workspaces/drafts";
|
import { getWorkspace, listWorkspaces } from "./workspaces";
|
||||||
|
import {
|
||||||
|
WORKSPACE_POLICY_ERROR, WORKSPACE_SUMMARY_ERROR, WorkspaceSelectionError, workspacePolicyGate,
|
||||||
|
workspacePreferences, type WorkspacePreference,
|
||||||
|
} from "../workspaces/drafts";
|
||||||
import type {
|
import type {
|
||||||
Principal, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse,
|
Principal, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -22,9 +26,83 @@ async function selectedPreferences(): Promise<WorkspacePreference> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(): Promise<WorkspacePreference> {
|
||||||
|
while (true) {
|
||||||
|
const preferences = workspacePreferences.load();
|
||||||
|
const workspaceId = preferences.workspaceId;
|
||||||
|
if (!workspaceId) return preferences;
|
||||||
|
workspacePolicyGate.beginSummary(workspaceId);
|
||||||
|
let workspace;
|
||||||
|
try {
|
||||||
|
workspace = (await listWorkspaces()).find((candidate) => candidate.id === workspaceId);
|
||||||
|
} catch {
|
||||||
|
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
|
||||||
|
workspacePolicyGate.rejectSummary(workspaceId);
|
||||||
|
throw new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR);
|
||||||
|
}
|
||||||
|
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
|
||||||
|
if (!workspace?.revision) {
|
||||||
|
workspacePolicyGate.allowLegacy(workspaceId);
|
||||||
|
return workspacePreferences.load();
|
||||||
|
}
|
||||||
|
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 }),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (outcome.kind === "selection") {
|
||||||
|
if (outcome.selection.workspaceId !== workspaceId) continue;
|
||||||
|
return outcome.selection;
|
||||||
|
}
|
||||||
|
if (outcome.kind === "error") {
|
||||||
|
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
|
||||||
|
workspacePolicyGate.reject(workspaceId);
|
||||||
|
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
|
||||||
|
}
|
||||||
|
const { record } = outcome;
|
||||||
|
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) {
|
||||||
|
workspacePolicyGate.reject(workspaceId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
workspacePolicyGate.resolve(workspaceId);
|
||||||
|
if (workspacePreferences.load().workspaceId === workspaceId) return selection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSession(i: NewSessionInput) {
|
export async function createSession(i: NewSessionInput) {
|
||||||
await selectedPreferences();
|
await selectedPreferences();
|
||||||
const selection = await workspacePolicyGate.waitForCurrent(() => workspacePreferences.load());
|
const selection = await ensureWorkspaceSelectionPolicy();
|
||||||
return apiFetch<{ id: string }>("/sessions", {
|
return apiFetch<{ id: string }>("/sessions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ ...i, ...selection }),
|
body: JSON.stringify({ ...i, ...selection }),
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ test("submitting includes browser-local migrated preferences and calls onCreated
|
|||||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
||||||
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||||
})),
|
})),
|
||||||
|
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{
|
||||||
|
id: "default", name: "default", file: "default.yaml", displayName: "Default",
|
||||||
|
}])),
|
||||||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
return HttpResponse.json({ id: "s1" });
|
||||||
@@ -51,6 +54,56 @@ test("submitting includes browser-local migrated preferences and calls onCreated
|
|||||||
await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1"));
|
await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("first-run direct dialog creation waits for registry policy without a mounted footer", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
let releasePolicy!: () => void;
|
||||||
|
let summaryRequestStarted = false;
|
||||||
|
let policyRequestStarted = false;
|
||||||
|
const policyMayFinish = new Promise<void>((resolve) => { releasePolicy = resolve; });
|
||||||
|
const revision = {
|
||||||
|
id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot", state: "operational" as const,
|
||||||
|
};
|
||||||
|
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||||
|
workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||||
|
}));
|
||||||
|
server.use(
|
||||||
|
http.get("http://localhost:8787/workspaces", () => {
|
||||||
|
summaryRequestStarted = true;
|
||||||
|
return HttpResponse.json([{
|
||||||
|
id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical",
|
||||||
|
revision,
|
||||||
|
}]);
|
||||||
|
}),
|
||||||
|
http.get("http://localhost:8787/workspaces/psd-clinical", async () => {
|
||||||
|
policyRequestStarted = true;
|
||||||
|
await policyMayFinish;
|
||||||
|
return HttpResponse.json({
|
||||||
|
workspace: { llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] } }, revision,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: "s1" });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { onCreated } = renderDialog();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /new/i }));
|
||||||
|
await userEvent.type(await screen.findByLabelText(/question/i), "q");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(summaryRequestStarted).toBe(true));
|
||||||
|
await waitFor(() => expect(policyRequestStarted).toBe(true));
|
||||||
|
expect(body).toBeUndefined();
|
||||||
|
expect(screen.getByRole("button", { name: /creating/i })).toBeDisabled();
|
||||||
|
releasePolicy();
|
||||||
|
|
||||||
|
await waitFor(() => expect(body).toEqual({
|
||||||
|
question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||||
|
}));
|
||||||
|
await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1"));
|
||||||
|
});
|
||||||
|
|
||||||
test("empty question shows a validation error and does not submit", async () => {
|
test("empty question shows a validation error and does not submit", async () => {
|
||||||
renderDialog();
|
renderDialog();
|
||||||
await userEvent.click(screen.getByRole("button", { name: /new/i }));
|
await userEvent.click(screen.getByRole("button", { name: /new/i }));
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ test("new sessions send the browser-selected workspace, model, provider, and thi
|
|||||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high",
|
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high",
|
||||||
}));
|
}));
|
||||||
server.use(
|
server.use(
|
||||||
|
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([{
|
||||||
|
id: "psd-clinical", name: "psd-clinical", file: "psd-clinical.yaml", displayName: "PSD Clinical",
|
||||||
|
}])),
|
||||||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
return HttpResponse.json({ id: "s1" });
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export interface WorkspaceDraft {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const WORKSPACE_SUMMARY_ERROR = "Could not load workspace registry. Please retry.";
|
||||||
|
export const WORKSPACE_POLICY_ERROR = "Could not load selected workspace policy. Please retry.";
|
||||||
|
|
||||||
export class WorkspaceSelectionError extends Error {}
|
export class WorkspaceSelectionError extends Error {}
|
||||||
|
|
||||||
type PolicySelection = {
|
type PolicySelection = {
|
||||||
@@ -70,7 +73,7 @@ export const workspacePolicyGate = {
|
|||||||
rejectSummary(workspaceId: string): void {
|
rejectSummary(workspaceId: string): void {
|
||||||
if (!selection || selection.workspaceId !== workspaceId || selection.state !== "summary") return;
|
if (!selection || selection.workspaceId !== workspaceId || selection.state !== "summary") return;
|
||||||
selection.state = "error";
|
selection.state = "error";
|
||||||
selection.error = new WorkspaceSelectionError("Could not load workspace registry. Please retry.");
|
selection.error = new WorkspaceSelectionError(WORKSPACE_SUMMARY_ERROR);
|
||||||
selection.settle();
|
selection.settle();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -88,7 +91,7 @@ export const workspacePolicyGate = {
|
|||||||
reject(workspaceId: string): void {
|
reject(workspaceId: string): void {
|
||||||
if (!selection || selection.workspaceId !== workspaceId || selection.state !== "pending") return;
|
if (!selection || selection.workspaceId !== workspaceId || selection.state !== "pending") return;
|
||||||
selection.state = "error";
|
selection.state = "error";
|
||||||
selection.error = new WorkspaceSelectionError("Could not load selected workspace policy. Please retry.");
|
selection.error = new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
|
||||||
selection.settle();
|
selection.settle();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user