diff --git a/.dockerignore b/.dockerignore index 2330a18f..0be4e90c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,6 +17,7 @@ frontend/vite.database-management-prototype.config.ts !deploy/env/*.env.example deploy/thothii.env deploy/secrets/ +deploy/psd/ harness/workspaces/*.yaml !harness/workspaces/local.yaml !harness/workspaces/tht.example.yaml diff --git a/CONTEXT.md b/CONTEXT.md index 116056e1..826e80cf 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -574,6 +574,11 @@ può osservare, come tabelle, colonne, relazioni, indici o enum. Una capability ## Amministrazione e integrazione +**Workspace Readiness** — La preparazione di uno specifico Workspace per l'uso nel +workflow, comprensiva della disponibilità degli artefatti derivati dai suoi metadati +Database e dalle sue Evidence. Il preprocessing appartiene a questa preparazione; +la configurazione e la sincronizzazione del catalogo restano responsabilità Database. + **Administration Surface** — Una superficie amministrativa autonoma per configurare o curare una parte dell'installazione. Workspace, Evidence, Memory, Database e Pi sono superfici peer e non dipendono dall'esistenza di una sessione attiva. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index b490df64..d01250f1 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # ThothII — Project State -Last updated: 2026-09-10. +Last updated: 2026-09-12. This file is the short operational snapshot. Stable commands and the architecture mental model live in `AGENTS.md`; current design and runtime contracts live under `docs/architecture/`, @@ -14,6 +14,23 @@ requirements as mandatory; do not replace the running server stack in place. ## Current product shape +The latest context-shelf A and five Administration pages are implemented locally. +At the owner's request, local Docker project `thothii-18998cca7b0a` was rebuilt and +its core/frontend recreated on 2026-09-12. The real UI at `http://127.0.0.1:8080` +serves shelf A; both services and their existing dependencies are healthy. +Runtime model projections were regenerated as schema v2 with the single +`zai/glm-5.3` interaction default. Persistent services/volumes were not recreated. +The local launcher `/private/tmp/thothii-memory-preview.sh` now adds +`/private/tmp/thothii-context-a.compose.yaml` last, building from this checkout +instead of the earlier Memory worktree. Previous images are retained under +`thothii-core:before-context-a-20260912` and +`thothii-frontend:before-context-a-20260912`. Remote server deployment remains pending. +Core/session behavior is retained; one independently remembered workspace/model +pair controls both Core and Admin. Unsaved Admin changes block navigation and +context changes; operation activity locks the selectors. See +`docs/reports/2026-09-12-context-shelf-a-implementation.md` for verification and the +remaining server/Omics integration gate. Prototype alternatives remain untouched. + ThothII is a human-in-the-loop datamart builder with three independently built layers: ```text @@ -252,7 +269,28 @@ regenerates the backend catalog, Pi `models.json`/`settings.json`, and Compose o installation-local `generated/` directory. Those projections are replaceable runtime adapters: they are not edited, backed up, or treated as configuration. -Session and metadata defaults use canonical `provider/model` IDs. Provider authentication declares +Core and Administration now share one canonical `modelCatalog.defaults.interaction` per installation, +independent of workspace. Runtime catalog schema v2 contains only `defaultInteraction`; apply the host, +backend, and regenerated projections together. Equal legacy defaults normalize on read; conflicting +ones require an explicit operator choice. With Admin AI configured, selectable models are the +intersection of the Pi and LiteLLM adapters; Core-only installations remain supported without Admin AI. +The existing Core and Database controls share the operational selection. Explicit model choices are +remembered per authenticated user/application mount in this browser, not in installation settings. +Resume uses that selection/default while preserving the historical workspace/revision and manifest. +Every model must be manually exercised in both Core and Admin as documented in +`docs/general/pi-configuration.md`; validation is not a live model certification. +These changes and shelf A are now deployed to local Docker; remote deployment remains pending. + +PSD DeepSeek Pro/Flash now share canonical `deepseek/...` identities across native Pi and LiteLLM, +using the existing `DEEPSEEK_API_KEY` bundle entry. Its value was confirmed identical to the working +Pi key without exposing it; no secret files were changed. The duplicate `deepseek-metadata` descriptor +is removed locally and the tracked example uses the shared provider. `secret_env` overrides legacy +Pi auth only inside temporary runtime snapshots and fails closed if the bundle key is missing. +The original auth store/history and `zai/glm-5.3` default are preserved. Local Docker projections +and core/frontend were updated together on 2026-09-12. Regenerate projections with +the matching release for the separate server deployment. + +Provider authentication declares one explicit mode (`secret_env`, `pi_auth`, or `none`); `secret_env` names a protected bundle key. The backend settings store now owns only the selected workspace and thinking level. Existing v1 installations use the explicit catalog migration command; schema-v3 workspace descriptors are diff --git a/backend/src/catalog/metadata-generation-models.ts b/backend/src/catalog/metadata-generation-models.ts index f9ea08be..264c1781 100644 --- a/backend/src/catalog/metadata-generation-models.ts +++ b/backend/src/catalog/metadata-generation-models.ts @@ -102,7 +102,7 @@ export function loadMetadataGenerationModels(options: { ...(apiKeyEnv ? { apiKeyEnv, apiKey } : {}), })); } - const result = new RestartLoadedMetadataGenerationModels(models, catalog.defaultMetadataGeneration); + const result = new RestartLoadedMetadataGenerationModels(models, models.size ? catalog.defaultInteraction : null); const safe = result.catalog(); return { catalog: () => ({ diff --git a/backend/src/models/runtime-model-catalog.ts b/backend/src/models/runtime-model-catalog.ts index 3c1dfde6..fa28ebb0 100644 --- a/backend/src/models/runtime-model-catalog.ts +++ b/backend/src/models/runtime-model-catalog.ts @@ -47,9 +47,8 @@ const runtimeModelSchema = z.object({ }).strict(); const catalogSchema = z.object({ - schemaVersion: z.literal(1), - defaultSession: canonicalId, - defaultMetadataGeneration: canonicalId.optional(), + schemaVersion: z.literal(2), + defaultInteraction: canonicalId, embedding: z.object({ id: canonicalId, dimensions: z.number().int().positive() }).strict(), models: z.array(runtimeModelSchema).max(64), }).strict(); @@ -57,8 +56,7 @@ const catalogSchema = z.object({ export type RuntimeModel = z.infer; export interface RuntimeModelCatalog { - readonly defaultSession: string | null; - readonly defaultMetadataGeneration: string | null; + readonly defaultInteraction: string | null; readonly embedding: Readonly<{ id: string; dimensions: number }> | null; sessionModels(): readonly RuntimeModel[]; metadataModels(): readonly RuntimeModel[]; @@ -66,19 +64,21 @@ export interface RuntimeModelCatalog { } class RestartLoadedRuntimeModelCatalog implements RuntimeModelCatalog { - readonly defaultSession: string | null; - readonly defaultMetadataGeneration: string | null; + readonly defaultInteraction: string | null; readonly embedding: Readonly<{ id: string; dimensions: number }> | null; readonly #sessions: readonly RuntimeModel[]; readonly #metadata: readonly RuntimeModel[]; readonly #sessionIds: ReadonlySet; constructor(catalog?: z.infer) { - this.defaultSession = catalog?.defaultSession ?? null; - this.defaultMetadataGeneration = catalog?.defaultMetadataGeneration ?? null; + this.defaultInteraction = catalog?.defaultInteraction ?? null; this.embedding = catalog ? Object.freeze({ ...catalog.embedding }) : null; - this.#sessions = Object.freeze((catalog?.models ?? []).filter((model) => model.session !== undefined)); - this.#metadata = Object.freeze((catalog?.models ?? []).filter((model) => model.metadataGeneration !== undefined)); + // One operational list. Session-only installations can still run Core, but once Admin + // LLM models are configured every selectable model must support both adapters. + const hasMetadata = catalog?.models.some((model) => model.metadataGeneration !== undefined); + this.#sessions = Object.freeze((catalog?.models ?? []).filter((model) => + model.session !== undefined && (!hasMetadata || model.metadataGeneration !== undefined))); + this.#metadata = Object.freeze(this.#sessions.filter((model) => model.metadataGeneration !== undefined)); this.#sessionIds = new Set(this.#sessions.map((model) => model.id)); } @@ -129,11 +129,9 @@ export function loadRuntimeModelCatalog(file?: string): RuntimeModelCatalog { if (ids.size !== parsed.data.models.length) throw new Error("runtime model catalog contains duplicate models"); const sessions = parsed.data.models.filter((model) => model.session !== undefined).map((model) => model.id); const metadata = parsed.data.models.filter((model) => model.metadataGeneration !== undefined).map((model) => model.id); - if (!sessions.includes(parsed.data.defaultSession)) throw new Error("runtime model catalog session default is invalid"); - if ((metadata.length > 0) !== (parsed.data.defaultMetadataGeneration !== undefined) - || (parsed.data.defaultMetadataGeneration !== undefined - && !metadata.includes(parsed.data.defaultMetadataGeneration))) { - throw new Error("runtime model catalog metadata default is invalid"); + if (!sessions.includes(parsed.data.defaultInteraction) + || (metadata.length > 0 && !metadata.includes(parsed.data.defaultInteraction))) { + throw new Error("runtime model catalog interaction default is invalid"); } return new RestartLoadedRuntimeModelCatalog(parsed.data); } diff --git a/backend/src/pi/list-models.ts b/backend/src/pi/list-models.ts index 653f44e7..a2977d4a 100644 --- a/backend/src/pi/list-models.ts +++ b/backend/src/pi/list-models.ts @@ -11,6 +11,7 @@ import { validateDeclarativePiConfig, } from "./managed-config.js"; import type { RuntimeModelCatalog } from "../models/runtime-model-catalog.js"; +import { secretValue } from "../config/secret-bundle.js"; export interface PiModel { provider: string; @@ -64,6 +65,14 @@ export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): ListModels } const env = buildPiChildEnv({}); + // Pi's availability enumeration also needs the catalog-owned credentials for built-in + // providers. It must keep working after their obsolete Pi auth entries are removed. + for (const model of opts.modelCatalog?.sessionModels() ?? []) { + const name = model.authentication.mode === "secret_env" ? model.authentication.apiKeyEnv : undefined; + if (!name) continue; + const value = secretValue(cfg, name); + if (value) env[name] = value; + } delete env.THT_DATA_ROOT; if (cfg.dataRoot !== undefined) env.THT_DATA_ROOT = cfg.dataRoot; const child = spawnFn(cfg.piBin, ["--mode", "rpc"], { cwd: cfg.harnessDir, env }); diff --git a/backend/src/pi/managed-config.ts b/backend/src/pi/managed-config.ts index 646189dd..4d292f47 100644 --- a/backend/src/pi/managed-config.ts +++ b/backend/src/pi/managed-config.ts @@ -138,7 +138,7 @@ export interface PiRuntimeAgentSnapshot { * Bind a session Pi process to the exact managed auth/model bytes validated at spawn time. * Other agent resources remain live through symlinks, while session storage stays persistent. */ -export function createPiRuntimeAgentSnapshot(): PiRuntimeAgentSnapshot { +export function createPiRuntimeAgentSnapshot(options: { excludeAuthProvider?: string } = {}): PiRuntimeAgentSnapshot { const sourceAgentDir = configuredPiAgentDir(); const auth = readPiAgentFile(sourceAgentDir, "auth.json", true); const models = readPiAgentFile(sourceAgentDir, "models.json", true); @@ -165,7 +165,18 @@ export function createPiRuntimeAgentSnapshot(): PiRuntimeAgentSnapshot { ); } if (auth !== undefined) { - writeFileSync(join(snapshotDir, "auth.json"), auth, { flag: "wx", mode: 0o600 }); + let effectiveAuth = auth; + if (options.excludeAuthProvider) { + const parsed = parsePiConfigJson(auth); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new PiManagedConfigError(); + const provider = options.excludeAuthProvider.trim().toLowerCase(); + effectiveAuth = JSON.stringify(Object.fromEntries( + Object.entries(parsed).filter(([key]) => key.trim().toLowerCase() !== provider), + )); + } + // secret_env is authoritative for this provider. Keep the operator's auth file intact, + // but do not let an old Pi credential override the shared bundle inside this child. + writeFileSync(join(snapshotDir, "auth.json"), effectiveAuth, { flag: "wx", mode: 0o600 }); } if (models !== undefined) { writeFileSync(join(snapshotDir, "models.json"), models, { flag: "wx", mode: 0o600 }); diff --git a/backend/src/pi/management.ts b/backend/src/pi/management.ts index 5bba1fe9..8df76734 100644 --- a/backend/src/pi/management.ts +++ b/backend/src/pi/management.ts @@ -106,23 +106,23 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P }); const credentialStatus = deps.credentialStatus ?? ((provider: string | undefined) => { try { - const model = deps.modelCatalog.defaultSession - ? deps.modelCatalog.sessionModels().find((entry) => entry.id === deps.modelCatalog.defaultSession) + const model = deps.modelCatalog.defaultInteraction + ? deps.modelCatalog.sessionModels().find((entry) => entry.id === deps.modelCatalog.defaultInteraction) : undefined; const credentialName = model?.authentication.mode === "secret_env" ? model.authentication.apiKeyEnv : undefined; - const configuredApiKey = configuredPiProviderApiKey( + const configuredApiKey = credentialName ? `$${credentialName}` : configuredPiProviderApiKey( readConfiguredPiAgentFile("models.json", true), provider, - ) ?? (credentialName ? `$${credentialName}` : undefined); + ); return piProviderCredentialStatus({ provider, - authProviders: loadPiAuthProviders(), + authProviders: credentialName ? new Set() : loadPiAuthProviders(), resolveCredentialValue: () => credentialName ? secretValue(config, credentialName) : config.modelCatalogFile ? undefined : secretValue(config, "THT_MODEL_API_KEY"), - credentialFile: config.modelApiKeyFile, + credentialFile: credentialName ? undefined : config.modelApiKeyFile, configuredApiKey, }); } catch { @@ -152,8 +152,8 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P const installationConfig = (): PiInstallationConfig => { const settings = readSettings(); const reasoning = config.defaults.thinking ?? settings.thinking; - const selected = deps.modelCatalog.defaultSession - ? splitCanonicalModelId(deps.modelCatalog.defaultSession) + const selected = deps.modelCatalog.defaultInteraction + ? splitCanonicalModelId(deps.modelCatalog.defaultInteraction) : undefined; return { ...(selected ? selected : {}), diff --git a/backend/src/pi/pi-process-manager.ts b/backend/src/pi/pi-process-manager.ts index ffc61927..5712bcd6 100644 --- a/backend/src/pi/pi-process-manager.ts +++ b/backend/src/pi/pi-process-manager.ts @@ -63,7 +63,7 @@ export class PiProcessManager { ) { this.modelCatalog = opts?.modelCatalog ?? loadRuntimeModelCatalog(cfg.modelCatalogFile); this.modelCatalogConfigured = cfg.modelCatalogFile !== undefined - || this.modelCatalog.defaultSession !== null; + || this.modelCatalog.defaultInteraction !== null; this.loadAuthProviders = opts?.authProviders ?? ((agentDir) => loadPiAuthProviders({ agentDir })); if (opts?.spawnFn) { @@ -89,25 +89,23 @@ export class PiProcessManager { // This is the final shared boundary for createFor(), spawnFor(), and resume(). Validate // before auth-provider inspection, then make Pi consume the exact copied bytes rather than // reopening mutable mounted auth/models files after this check. - const agent = createPiRuntimeAgentSnapshot(); + const catalogModel = provider && model + ? this.modelCatalog.sessionModels().find((entry) => entry.provider === provider && entry.model === model) + : undefined; + const credentialName = catalogModel?.authentication.mode === "secret_env" + ? catalogModel.authentication.apiKeyEnv : undefined; + const agent = createPiRuntimeAgentSnapshot({ excludeAuthProvider: credentialName ? provider : undefined }); let child: ChildProcessWithoutNullStreams | undefined; try { - const catalogModel = provider && model - ? this.modelCatalog.sessionModels() - .find((entry) => entry.provider === provider && entry.model === model) - : undefined; - const credentialName = catalogModel?.authentication.mode === "secret_env" - ? catalogModel.authentication.apiKeyEnv - : undefined; - const projectedApiKey = configuredPiProviderApiKey(agent.models, provider) - ?? (credentialName ? `$${credentialName}` : undefined); + const projectedApiKey = credentialName ? `$${credentialName}` + : configuredPiProviderApiKey(agent.models, provider); const env = buildPiChildEnv({ provider, - authProviders: this.loadAuthProviders(agent.agentDir), + authProviders: credentialName ? new Set() : this.loadAuthProviders(agent.agentDir), credentialValue: credentialName ? secretValue(this.cfg, credentialName) : this.modelCatalogConfigured ? undefined : secretValue(this.cfg, "THT_MODEL_API_KEY"), - credentialFile: this.cfg.modelApiKeyFile, + credentialFile: credentialName ? undefined : this.cfg.modelApiKeyFile, configuredApiKey: projectedApiKey, additions: { THT_SESSION: sessionId, THT_AUTHOR: author }, }); diff --git a/backend/src/pi/provider-smoke.ts b/backend/src/pi/provider-smoke.ts index 88937054..e235fcb0 100644 --- a/backend/src/pi/provider-smoke.ts +++ b/backend/src/pi/provider-smoke.ts @@ -70,28 +70,29 @@ export function createPiProviderSmoke( try { const canonicalProvider = canonicalPiProvider(provider); if (!canonicalProvider || timeoutMs <= 0) throw providerFailure(); - const configuredAuthProviders = authProviders(); + const configuredAuthProviders = new Set(authProviders()); const configuredModels = options.readModelsStore ? options.readModelsStore() : readConfiguredPiAgentFile("models.json", true); const catalog = options.modelCatalog ?? loadRuntimeModelCatalog(config.modelCatalogFile); const catalogConfigured = config.modelCatalogFile !== undefined - || catalog.defaultSession !== null; + || catalog.defaultInteraction !== null; const catalogModel = catalog.sessionModels() .find((entry) => entry.provider === canonicalProvider && entry.model === model); const upstreamModel = catalogModel?.upstreamModel ?? model; const credentialName = catalogModel?.authentication.mode === "secret_env" ? catalogModel.authentication.apiKeyEnv : undefined; - const projectedApiKey = configuredPiProviderApiKey(configuredModels, canonicalProvider) - ?? (credentialName ? `$${credentialName}` : undefined); + if (credentialName) configuredAuthProviders.delete(canonicalProvider); + const projectedApiKey = credentialName ? `$${credentialName}` + : configuredPiProviderApiKey(configuredModels, canonicalProvider); const env = buildPiChildEnv({ provider: canonicalProvider, authProviders: configuredAuthProviders, credentialValue: credentialName ? secretValue(config, credentialName) : catalogConfigured ? undefined : secretValue(config, "THT_MODEL_API_KEY"), - credentialFile: config.modelApiKeyFile, + credentialFile: credentialName ? undefined : config.modelApiKeyFile, configuredApiKey: projectedApiKey, }); clearPrincipalEnvironment(env); diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 682dd357..918c9c05 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -383,6 +383,10 @@ export function sessionRoutes( question: string; name?: string; workspace?: string; workspaceId?: string; provider?: string; model?: string; thinking?: string; }; + if ((b.provider === undefined) !== (b.model === undefined) + || (b.provider !== undefined && (typeof b.provider !== "string" || typeof b.model !== "string" || !b.provider || !b.model))) { + return reply.code(400).send({ error: "provider and model must be supplied together" }); + } const principal = getPrincipal(req); let s: Settings; try { s = await d.getSettings(principal); } catch { return storageFailure(reply); } @@ -446,11 +450,9 @@ export function sessionRoutes( } } const requestedCanonical = b.provider && b.model ? `${b.provider}/${b.model}` : undefined; - let selectedCanonical = requestedCanonical ?? d.modelCatalog.defaultSession; - let modelWarning: string | undefined; - if (selectedCanonical && d.modelCatalog.defaultSession && !d.modelCatalog.hasSession(selectedCanonical)) { - selectedCanonical = d.modelCatalog.defaultSession; - modelWarning = `Configured model ${requestedCanonical ?? "selection"} is unavailable; using ${selectedCanonical}.`; + const selectedCanonical = requestedCanonical ?? d.modelCatalog.defaultInteraction; + if (selectedCanonical && d.modelCatalog.defaultInteraction && !d.modelCatalog.hasSession(selectedCanonical)) { + return reply.code(503).send({ error: MODEL_UNAVAILABLE_MESSAGE, code: "model_unavailable" }); } const selected = selectedCanonical ? splitCanonicalModelId(selectedCanonical) : undefined; const provider = selected?.provider ?? b.provider; @@ -554,7 +556,7 @@ export function sessionRoutes( ), () => d.mgr.start(id, rt, runtimeOptions), ); - return { id, ...(modelWarning ? { warning: modelWarning } : {}) }; + return { id }; } finally { if (revisionLease && !manifestPersisted) { await revisionLease.abort().catch((error: unknown) => { @@ -680,10 +682,17 @@ export function sessionRoutes( provider?: string; model?: string; thinking?: string; workspace_id?: string; workspace_revision?: string; }; - const savedCanonical = saved.provider && saved.model ? `${saved.provider}/${saved.model}` : ""; - if (d.modelCatalog.defaultSession && (!savedCanonical || !d.modelCatalog.hasSession(savedCanonical))) { + const requested = (req.body ?? {}) as { provider?: string; model?: string; thinking?: string }; + if ((requested.provider === undefined) !== (requested.model === undefined) + || (requested.provider !== undefined && (typeof requested.provider !== "string" || typeof requested.model !== "string" || !requested.provider || !requested.model))) { + return reply.code(400).send({ error: "provider and model must be supplied together" }); + } + const selectedCanonical = requested.provider && requested.model + ? `${requested.provider}/${requested.model}` : d.modelCatalog.defaultInteraction; + if (d.modelCatalog.defaultInteraction && (!selectedCanonical || !d.modelCatalog.hasSession(selectedCanonical))) { return reply.code(503).send({ error: MODEL_UNAVAILABLE_MESSAGE, code: "model_unavailable" }); } + const selected = selectedCanonical ? splitCanonicalModelId(selectedCanonical) : saved; let workspaceConfigPath: string; let workspaceDescriptor: WorkspaceDescriptor | undefined; try { @@ -716,7 +725,7 @@ export function sessionRoutes( if (existing) { const state = existing.bridge.turnState(); if (state === "running" || state === "waiting") { - return reply.code(200).send({ id, alreadyActive: true }); + return reply.code(200).send({ id, alreadyActive: true, workspaceId: saved.workspace_id }); } } const ensure = await d.readiness.ensure( @@ -727,8 +736,8 @@ export function sessionRoutes( ...(ensure.code ? { code: ensure.code } : {}), }); const options = { - provider: saved?.provider, - model: saved?.model, + provider: selected.provider, + model: selected.model, thinking: saved?.thinking ?? settings.thinking, author: principal.displayName ?? principal.subject, principal, @@ -750,7 +759,7 @@ export function sessionRoutes( if (current) { const state = current.bridge.turnState(); if (state === "running" || state === "waiting") { - return reply.code(200).send({ id, alreadyActive: true }); + return reply.code(200).send({ id, alreadyActive: true, workspaceId: saved.workspace_id }); } } @@ -791,7 +800,7 @@ export function sessionRoutes( d.mgr.configure(rt, runtimeOptions), null, () => d.mgr.start(id, rt, runtimeOptions), ); - return reply.code(200).send({ id, alreadyActive: false }); + return reply.code(200).send({ id, alreadyActive: false, workspaceId: saved.workspace_id }); }); }); app.post("/sessions/:id/close", async (req, reply) => { diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 9da6b146..a756f5e6 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -16,8 +16,8 @@ export function effectiveSettings( modelCatalog?: RuntimeModelCatalog, ): Settings { const workspaces = listWorkspaces(cfg.harnessDir); - const selected = modelCatalog?.defaultSession - ? splitCanonicalModelId(modelCatalog.defaultSession) + const selected = modelCatalog?.defaultInteraction + ? splitCanonicalModelId(modelCatalog.defaultInteraction) : undefined; return { workspace: stored.workspace ?? workspaces[0]?.name, diff --git a/backend/test/list-models.test.ts b/backend/test/list-models.test.ts index ba1930eb..1c0346fa 100644 --- a/backend/test/list-models.test.ts +++ b/backend/test/list-models.test.ts @@ -56,7 +56,7 @@ test("catalog listing translates upstream Pi IDs back to canonical model keys", session: { reasoning: true, contextWindow: 32768, maxTokens: 8192 }, }; const modelCatalog: RuntimeModelCatalog = { - defaultSession: model.id, defaultMetadataGeneration: null, embedding: null, + defaultInteraction: model.id, embedding: null, sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id, }; try { @@ -75,6 +75,38 @@ test("catalog listing translates upstream Pi IDs back to canonical model keys", } }); +test("catalog listing supplies the shared DeepSeek key without requiring Pi auth", async () => { + const script = scriptWith([{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }]); + const secret = join(path.dirname(script), "thothii.secrets"); + writeFileSync(secret, "DEEPSEEK_API_KEY=shared-key\nOPENAI_API_KEY=unrelated-key\n", { mode: 0o600 }); + const model: RuntimeModel = { + id: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro", + label: "DeepSeek V4 Pro", upstreamModel: "deepseek-v4-pro", + authentication: { mode: "secret_env", apiKeyEnv: "DEEPSEEK_API_KEY" }, + sessionAdapter: { mode: "pi_builtin" }, session: { reasoning: false }, + }; + const modelCatalog: RuntimeModelCatalog = { + defaultInteraction: model.id, embedding: null, + sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id, + }; + try { + const lister = createPiModelLister(loadConfig({ THT_SECRETS_FILE: secret }), { + ...noManagedModels, modelCatalog, loadEnabledModels: enabled(model.id), + spawnFn: (_command, _args, options) => { + expect(options.env.DEEPSEEK_API_KEY).toBe("shared-key"); + expect(options.env).not.toHaveProperty("OPENAI_API_KEY"); + expect(options.env).not.toHaveProperty("THT_SECRETS_FILE"); + return spawn("node", [FAKE, script], { env: options.env }) as any; + }, + }); + await expect(lister()).resolves.toEqual([{ + provider: model.provider, id: model.model, name: model.label, reasoning: false, + }]); + } finally { + rmSync(path.dirname(script), { recursive: true, force: true }); + } +}); + test("createPiModelLister caches within ttl (spawns once for two calls)", async () => { const script = scriptWith([{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }]); try { diff --git a/backend/test/metadata-generation-models.test.ts b/backend/test/metadata-generation-models.test.ts index 1e67cef0..6f5d00b0 100644 --- a/backend/test/metadata-generation-models.test.ts +++ b/backend/test/metadata-generation-models.test.ts @@ -20,9 +20,8 @@ function runtimeCatalog(overrides: Record = {}, secrets = "OPEN const catalogFile = join(root, "catalog.json"); const secretsFile = join(root, "thothii.secrets"); const catalog = { - schemaVersion: 1, - defaultSession: "zai/glm-5.3", - defaultMetadataGeneration: "zai/glm-5.3", + schemaVersion: 2, + defaultInteraction: "zai/glm-5.3", embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 }, models: [ { @@ -55,8 +54,8 @@ test("loads session default and safe metadata choices from the normalized runtim const runtime = loadRuntimeModelCatalog(catalogFile); const metadata = loadMetadataGenerationModels({ catalogFile, secretsFile }); - expect(runtime.defaultSession).toBe("zai/glm-5.3"); - expect(runtime.hasSession("deepseek/deepseek-v4-pro")).toBe(true); + expect(runtime.defaultInteraction).toBe("zai/glm-5.3"); + expect(runtime.hasSession("deepseek/deepseek-v4-pro")).toBe(false); expect(metadata.catalog()).toEqual({ models: [{ id: "zai/glm-5.3", label: "GLM 5.3" }], default: "zai/glm-5.3", @@ -69,14 +68,36 @@ test("loads session default and safe metadata choices from the normalized runtim expect(() => metadata.resolve("zai/missing")).toThrow(MetadataGenerationModelUnavailableError); }); +test("DeepSeek uses one identity and bundle credential for native Pi and LiteLLM", () => { + const models = ["deepseek-v4-pro", "deepseek-v4-flash"].map((model) => ({ + id: `deepseek/${model}`, provider: "deepseek", model, label: model, upstreamModel: model, + authentication: { mode: "secret_env", apiKeyEnv: "DEEPSEEK_API_KEY" }, + sessionAdapter: { mode: "pi_builtin" }, metadataAdapter: { litellmProvider: "deepseek" }, + session: { reasoning: false }, metadataGeneration: { disableThinking: false }, + })); + const files = runtimeCatalog({ defaultInteraction: models[0].id, models }, "DEEPSEEK_API_KEY=shared-key\n"); + const runtime = loadRuntimeModelCatalog(files.catalogFile); + const metadata = loadMetadataGenerationModels(files); + expect(runtime.sessionModels().map((model) => model.id)).toEqual(metadata.catalog().models.map((model) => model.id)); + expect(metadata.catalog().default).toBe(runtime.defaultInteraction); + for (const model of models) { + expect(metadata.resolve(model.id)).toMatchObject({ + id: model.id, provider: "deepseek", model: model.model, + apiKeyEnv: "DEEPSEEK_API_KEY", apiKey: "shared-key", + }); + } + expect(() => metadata.resolve("deepseek-metadata/deepseek-v4-pro")) + .toThrow(MetadataGenerationModelUnavailableError); +}); + test("returns empty catalogs when no runtime projection is configured", () => { - expect(loadRuntimeModelCatalog().defaultSession).toBeNull(); + expect(loadRuntimeModelCatalog().defaultInteraction).toBeNull(); expect(loadMetadataGenerationModels({}).catalog()).toEqual({ models: [], default: null }); }); test("rejects a drifted default and an unprotected projection", () => { - const drifted = runtimeCatalog({ defaultSession: "zai/missing" }); - expect(() => loadRuntimeModelCatalog(drifted.catalogFile)).toThrow("session default is invalid"); + const drifted = runtimeCatalog({ defaultInteraction: "zai/missing" }); + expect(() => loadRuntimeModelCatalog(drifted.catalogFile)).toThrow("interaction default is invalid"); const unprotected = runtimeCatalog(); chmodSync(unprotected.catalogFile, 0o666); @@ -85,7 +106,7 @@ test("rejects a drifted default and an unprotected projection", () => { test("rejects authentication semantics that cannot come from the installation catalog", () => { const invalid = runtimeCatalog({ - defaultMetadataGeneration: undefined, + defaultInteraction: undefined, models: [{ id: "zai/glm-5.3", provider: "zai", @@ -112,3 +133,13 @@ test("splits canonical session identities without provider aliases", () => { expect(splitCanonicalModelId("zai/glm-5.3")).toEqual({ provider: "zai", model: "glm-5.3" }); expect(() => splitCanonicalModelId("glm-5.3")).toThrow("model identity is invalid"); }); + +test("rejects a default supported by only one configured use", () => { + const { catalogFile } = runtimeCatalog({ defaultInteraction: "deepseek/deepseek-v4-pro" }); + expect(() => loadRuntimeModelCatalog(catalogFile)).toThrow("interaction default is invalid"); +}); + +test("requires regenerated runtime schema v2 rather than interpreting two legacy defaults", () => { + const { catalogFile } = runtimeCatalog({ schemaVersion: 1, defaultSession: "zai/glm-5.3" }); + expect(() => loadRuntimeModelCatalog(catalogFile)).toThrow("runtime model catalog is invalid"); +}); diff --git a/backend/test/pi-management.test.ts b/backend/test/pi-management.test.ts index f03c336e..52d1ff02 100644 --- a/backend/test/pi-management.test.ts +++ b/backend/test/pi-management.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test, vi } from "vitest"; @@ -7,7 +7,34 @@ import { createPiManagement, type PiExecFile, } from "../src/pi/management.js"; -import type { RuntimeModelCatalog } from "../src/models/runtime-model-catalog.js"; +import type { RuntimeModel, RuntimeModelCatalog } from "../src/models/runtime-model-catalog.js"; + +test.each([false, true])("catalog credential status ignores legacy Pi auth, missing bundle key: %s", async (missingKey) => { + const root = mkdtempSync(join(tmpdir(), "tht-catalog-status-")); + writeFileSync(join(root, "auth.json"), JSON.stringify({ deepseek: { type: "api_key", key: "stale-key" } }), { mode: 0o600 }); + const secret = join(root, "thothii.secrets"); + writeFileSync(secret, missingKey ? "THT_MODEL_API_KEY=legacy-key\n" : "DEEPSEEK_API_KEY=shared-key\n", { mode: 0o600 }); + vi.stubEnv("PI_CODING_AGENT_DIR", root); + const model: RuntimeModel = { + id: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro", + label: "DeepSeek", upstreamModel: "deepseek-v4-pro", + authentication: { mode: "secret_env", apiKeyEnv: "DEEPSEEK_API_KEY" }, + sessionAdapter: { mode: "pi_builtin" }, session: { reasoning: false }, + }; + try { + const service = createPiManagement(loadConfig({ THT_SECRETS_FILE: secret }), { + modelCatalog: { + defaultInteraction: model.id, embedding: null, + sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id, + }, + execute: successfulExec([]), readSettings: () => ({ thinking: "medium" }), + }); + expect((await service.status()).credentials).toBe(missingKey ? "missing" : "present"); + } finally { + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); + } +}); function configFor(settingsFile = join(mkdtempSync(join(tmpdir(), "tht-pi-management-")), "settings.json")) { return loadConfig({ @@ -19,8 +46,7 @@ function configFor(settingsFile = join(mkdtempSync(join(tmpdir(), "tht-pi-manage } const modelCatalog: RuntimeModelCatalog = { - defaultSession: "zai/glm-5.2", - defaultMetadataGeneration: null, + defaultInteraction: "zai/glm-5.2", embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 }, sessionModels: () => [], metadataModels: () => [], diff --git a/backend/test/pi-process-manager.test.ts b/backend/test/pi-process-manager.test.ts index 62cd9456..7ceef352 100644 --- a/backend/test/pi-process-manager.test.ts +++ b/backend/test/pi-process-manager.test.ts @@ -642,27 +642,33 @@ test("session Pi spawn reads the single secret bundle and scrubs its path", asyn } }); -test("session Pi spawn resolves the selected catalog credential from the secret bundle", () => { +test.each([false, true])("session Pi uses the catalog bundle despite stale Pi auth: %s", (missingKey) => { const root = mkdtempSync(path.join(tmpdir(), "thothii-catalog-credential-")); const agentDir = path.join(root, "agent"); mkdirSync(agentDir, { mode: 0o700 }); - writeFileSync(path.join(agentDir, "auth.json"), "{}\n", { mode: 0o600 }); + const originalAuth = JSON.stringify({ + deepseek: { type: "api_key", key: "stale-key" }, + anthropic: { type: "api_key", key: "unrelated-key" }, + }); + writeFileSync(path.join(agentDir, "auth.json"), originalAuth, { mode: 0o600 }); writeFileSync(path.join(agentDir, "models.json"), '{"providers":{}}\n', { mode: 0o600 }); const secret = path.join(root, "thothii.secrets"); - writeFileSync(secret, "ZAI_API_KEY=catalog-secret\nTHT_MODEL_API_KEY=legacy-secret\n", { mode: 0o600 }); + writeFileSync(secret, missingKey ? "THT_MODEL_API_KEY=legacy-secret\n" + : "DEEPSEEK_API_KEY=catalog-secret\nTHT_MODEL_API_KEY=legacy-secret\n", { mode: 0o600 }); + const legacyKey = path.join(root, "legacy-key"); + writeFileSync(legacyKey, "legacy-file-key", { mode: 0o600 }); const model: RuntimeModel = { - id: "openai/test-model", - provider: "openai", - model: "test-model", - label: "Test model", - upstreamModel: "test-model", - authentication: { mode: "secret_env", apiKeyEnv: "ZAI_API_KEY" }, + id: "deepseek/deepseek-v4-pro", + provider: "deepseek", + model: "deepseek-v4-pro", + label: "DeepSeek V4 Pro", + upstreamModel: "deepseek-v4-pro", + authentication: { mode: "secret_env", apiKeyEnv: "DEEPSEEK_API_KEY" }, sessionAdapter: { mode: "pi_builtin" }, session: { reasoning: false }, }; const modelCatalog: RuntimeModelCatalog = { - defaultSession: model.id, - defaultMetadataGeneration: null, + defaultInteraction: model.id, embedding: null, sessionModels: () => [model], metadataModels: () => [], @@ -672,17 +678,26 @@ test("session Pi spawn resolves the selected catalog credential from the secret const child = recordingChild(); child.stderr.resume = () => {}; vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); - const mgr = new PiProcessManager(loadConfig({ THT_SECRETS_FILE: secret }), { + const mgr = new PiProcessManager(loadConfig({ THT_SECRETS_FILE: secret, THT_MODEL_API_KEY_FILE: legacyKey }), { modelCatalog, - authProviders: () => new Set(), + authProviders: () => new Set(["deepseek"]), spawnFn: (...args: any[]) => { calls.push(args); return child as any; }, }); try { - mgr.createFor("catalog-credential", { provider: "openai", model: "test-model" }); - expect(calls[0][2].env.ZAI_API_KEY).toBe("catalog-secret"); + const create = () => mgr.createFor("catalog-credential", { provider: model.provider, model: model.model }); + if (missingKey) { + expect(create).toThrow("model provider credential is unavailable"); + expect(calls).toHaveLength(0); + return; + } + create(); + expect(calls[0][2].env.DEEPSEEK_API_KEY).toBe("catalog-secret"); expect(calls[0][2].env).not.toHaveProperty("OPENAI_API_KEY"); expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY"); + expect(JSON.parse(readFileSync(path.join(calls[0][2].env.PI_CODING_AGENT_DIR, "auth.json"), "utf8"))) + .toEqual({ anthropic: { type: "api_key", key: "unrelated-key" } }); } finally { + expect(readFileSync(path.join(agentDir, "auth.json"), "utf8")).toBe(originalAuth); mgr.teardown("catalog-credential"); vi.unstubAllEnvs(); rmSync(root, { recursive: true, force: true }); @@ -750,7 +765,7 @@ test("set_model translates a canonical catalog key to its upstream Pi model ID", session: { reasoning: false, contextWindow: 32768, maxTokens: 8192 }, }; const modelCatalog: RuntimeModelCatalog = { - defaultSession: model.id, defaultMetadataGeneration: null, embedding: null, + defaultInteraction: model.id, embedding: null, sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id, }; const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), { diff --git a/backend/test/pi-provider-smoke.test.ts b/backend/test/pi-provider-smoke.test.ts index 3be1b525..ca3f4e42 100644 --- a/backend/test/pi-provider-smoke.test.ts +++ b/backend/test/pi-provider-smoke.test.ts @@ -61,20 +61,22 @@ test("provider smoke resolves the selected catalog credential from the secret bu session: { reasoning: false }, }; const modelCatalog: RuntimeModelCatalog = { - defaultSession: model.id, - defaultMetadataGeneration: null, + defaultInteraction: model.id, embedding: null, sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id, }; let spawnEnv: NodeJS.ProcessEnv | undefined; + const readAuthStore = vi.fn(() => JSON.stringify({ openai: { type: "api_key", key: "stale-key" } })); const smoke = createPiProviderSmoke(loadConfig({ THT_SECRETS_FILE: secret }), { modelCatalog, - authProviders: () => new Set(), + authProviders: () => new Set(["openai"]), + readAuthStore, readModelsStore: () => undefined, spawnFn: (_command, _args, options) => { spawnEnv = options.env; + expect(existsSync(join(options.env.PI_CODING_AGENT_DIR!, "auth.json"))).toBe(false); return successfulProviderChild(); }, }); @@ -85,6 +87,7 @@ test("provider smoke resolves the selected catalog credential from the secret bu expect(spawnEnv?.ZAI_API_KEY).toBe("catalog-secret"); expect(spawnEnv).not.toHaveProperty("OPENAI_API_KEY"); expect(spawnEnv).not.toHaveProperty("THT_MODEL_API_KEY"); + expect(readAuthStore).not.toHaveBeenCalled(); } finally { rmSync(root, { recursive: true, force: true }); } @@ -306,7 +309,7 @@ test("provider smoke makes one configured request from an isolated no-capability sessionAdapter: { mode: "pi_builtin" }, session: { reasoning: true }, }; const smokeCatalog: RuntimeModelCatalog = { - defaultSession: smokeModel.id, defaultMetadataGeneration: null, embedding: null, + defaultInteraction: smokeModel.id, embedding: null, sessionModels: () => [smokeModel], metadataModels: () => [], hasSession: (id) => id === smokeModel.id, }; diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 29a86711..973211ec 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -27,10 +27,9 @@ function operationalWorkspace(id = "default") { } as const; } -function sessionCatalog(defaultSession = "zai/glm-5.2", available = [defaultSession]) { +function sessionCatalog(defaultInteraction = "zai/glm-5.2", available = [defaultInteraction]) { return { - defaultSession, - defaultMetadataGeneration: null, + defaultInteraction, embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 }, sessionModels: () => [], metadataModels: () => [], @@ -909,7 +908,7 @@ test("session lifecycle locates a B session when installation default is A", asy active = undefined; expect((await app.inject({ method: "POST", url: "/sessions/session-b/resume" })).json()) - .toEqual({ id: "session-b", alreadyActive: false }); + .toEqual({ id: "session-b", alreadyActive: false, workspaceId: "b-workspace" }); expect(calls).toContain(`new:${bPath}`); expect(calls).toContain(`list:${bPath}`); expect(calls).toContain(`show:${bPath}`); @@ -1179,6 +1178,29 @@ test("POST /sessions/:id/resume uses the manifest's retained workspace revision" ); }); +test.each([undefined, { provider: "local", model: "qwen" }])("resume uses the global model/default, not the historical manifest (%j)", async (payload) => { + const configure = vi.fn(async () => {}); + const manifest = { status: "open", archived: false, provider: "retired", model: "old-model" }; + const runtime = { bridge: { onClientEvent: () => {}, emitClientEvent: () => {} } }; + let current: any; + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + mgr: { + get: () => current, + createFor: () => { current = runtime; return runtime; }, + configure, start: () => {}, + } as any, + thtRunner: { sessionShow: async () => manifest, reopenSession: async () => {} } as any, + readiness: { ensure: async () => ({ ok: true }) } as any, + runtimeModelCatalog: sessionCatalog("zai/glm-5.2", ["zai/glm-5.2", "local/qwen"]), + getSettings: () => ({ workspace: "psd", thinking: "medium" }) as any, + }); + const response = await app.inject({ method: "POST", url: "/sessions/model-resume/resume", ...(payload ? { payload } : {}) }); + expect(response.statusCode).toBe(200); + await new Promise((resolve) => setImmediate(resolve)); + expect(configure).toHaveBeenCalledWith(expect.anything(), expect.objectContaining(payload ?? { provider: "zai", model: "glm-5.2" })); + expect(manifest).toMatchObject({ provider: "retired", model: "old-model" }); +}); + test("POST /sessions/:id/resume returns a sanitized error when its retained revision is unavailable", async () => { const rawFailure = "cannot read /data/workspace-registry/snapshots/secret-revision"; const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { @@ -2691,7 +2713,7 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => { expect(ensureWs).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`); }); -test("POST /sessions falls back from a stale requested model to the catalog default", async () => { +test("POST /sessions rejects a stale requested model without silently using the default", async () => { let created = 0; let persisted: any; const runtime = { bridge: { onClientEvent: () => {} } }; @@ -2721,13 +2743,10 @@ test("POST /sessions falls back from a stale requested model to the catalog defa payload: { question: "q", provider: "deepseek", model: "deepseek-v4-pro" }, }); - expect(res.statusCode).toBe(200); - expect(res.json()).toEqual({ - id: "fallback", - warning: "Configured model deepseek/deepseek-v4-pro is unavailable; using zai/glm-5.2.", - }); - expect(persisted).toMatchObject({ provider: "zai", model: "glm-5.2" }); - expect(created).toBe(1); + expect(res.statusCode).toBe(503); + expect(res.json()).toMatchObject({ code: "model_unavailable" }); + expect(persisted).toBeUndefined(); + expect(created).toBe(0); }); test("POST /sessions marks a persisted session failed when runtime construction throws", async () => { diff --git a/backend/test/routes-sql-meta.test.ts b/backend/test/routes-sql-meta.test.ts index 583b351f..f58fb9da 100644 --- a/backend/test/routes-sql-meta.test.ts +++ b/backend/test/routes-sql-meta.test.ts @@ -160,8 +160,7 @@ test("GET /models returns session choices from the installation model catalog", const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: {} as any, runtimeModelCatalog: { - defaultSession: "zai/glm-5.2", - defaultMetadataGeneration: null, + defaultInteraction: "zai/glm-5.2", embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 }, sessionModels: () => [{ id: "zai/glm-5.2", provider: "zai", model: "glm-5.2", label: "GLM 5.2", @@ -187,8 +186,7 @@ test("GET /models returns an empty list when the catalog has no session models", const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: {} as any, runtimeModelCatalog: { - defaultSession: null, - defaultMetadataGeneration: null, + defaultInteraction: null, embedding: null, sessionModels: () => [], metadataModels: () => [], diff --git a/deploy/psd/thothii-installation.yaml.example b/deploy/psd/thothii-installation.yaml.example index f373dd48..4f4b653c 100644 --- a/deploy/psd/thothii-installation.yaml.example +++ b/deploy/psd/thothii-installation.yaml.example @@ -10,22 +10,28 @@ workspaceRepository: access: ssh modelCatalog: defaults: - session: zai/glm-5.3 - metadataGeneration: zai/glm-5.3 + interaction: zai/glm-5.3 embedding: id: ollama/qwen3-embedding:0.6b dimensions: 1024 providers: deepseek: authentication: - mode: pi_auth + mode: secret_env + apiKeyEnv: DEEPSEEK_API_KEY session: mode: pi_builtin + metadataGeneration: + litellmProvider: deepseek models: deepseek-v4-pro: + label: DeepSeek V4 Pro session: {} + metadataGeneration: {} deepseek-v4-flash: + label: DeepSeek V4 Flash session: {} + metadataGeneration: {} zai: endpoint: baseUrl: https://api.z.ai/api/coding/paas/v4 diff --git a/docs/adr/0020-unify-administration-pages-and-use-namespaced-routes.md b/docs/adr/0020-unify-administration-pages-and-use-namespaced-routes.md index 0f3be97e..0efd7b8f 100644 --- a/docs/adr/0020-unify-administration-pages-and-use-namespaced-routes.md +++ b/docs/adr/0020-unify-administration-pages-and-use-namespaced-routes.md @@ -13,3 +13,8 @@ We considered React-only state, path-based routes and hash routes. React-only st deep-link behavior, path routes require a host catch-all route that does not yet exist in Omics Portal, and hash routes can conflict with host-page fragments. A namespaced query route preserves the current same-document integration boundary while leaving room for a future path adapter. + +The accepted visual direction is A / Workbench for all five surfaces; B and C remain recoverable +prototypes. Workspace owns readiness and preprocessing, consuming the Database catalog as a +prerequisite. Database owns connection/binding, schema synchronization, descriptions and sensitivity; +its page links to the related Workspace's preparation rather than duplicating the preprocessing action. diff --git a/docs/general/pi-configuration.md b/docs/general/pi-configuration.md index 8aab896d..4c789289 100644 --- a/docs/general/pi-configuration.md +++ b/docs/general/pi-configuration.md @@ -14,8 +14,7 @@ provider/model environment defaults. Those former sources are retired. schemaVersion: 2 modelCatalog: defaults: - session: zai/glm-5.3 - metadataGeneration: zai/glm-5.3 + interaction: zai/glm-5.3 embedding: id: ollama/qwen3-embedding:0.6b @@ -50,23 +49,63 @@ it contains that use block: - `metadataGeneration` makes it selectable for description generation; - `embedding` is a single installation-level model rather than a selectable list. -`defaults.session` is required. `defaults.metadataGeneration` is required exactly when at least -one metadata-generation model exists. A session manifest pins its canonical identity, so removing a -model never silently changes an existing session: resume fails with `model_unavailable`. +`defaults.interaction` is the only LLM default, required once per installation, never per workspace. +Core and Administration share the user's operational model choice. An explicit choice takes priority +over the default and is remembered in this browser for the authenticated user and application mount. +Switching workspace does not change the model. Browser-storage restrictions may limit remembering +to the current visit; preferences do not synchronize across devices or change installation YAML. +An unavailable remembered model is not silently replaced: select another configured model. + +When any metadata-generation models are configured, the default and the operational list must +support both `session` and `metadataGeneration`. Entries for just one adapter may remain in the +installation inventory, but are not selectable for global interaction. Core-only installations remain +supported when no metadata-generation model is configured; Admin AI is then unavailable. +The embedding model remains separate and is unaffected by the interaction selector. + +New sessions record the selected model in their manifest. Resume retains the session's workspace and +revision, but uses the current global model (the installation default for clients that omit a model). +Historical manifest model fields are not rewritten by resume. Archived/finalized sessions remain read-only. + +### Required operator verification for every model + +Catalog validation checks configuration, not model behavior. Before offering a model to users, and +after changing its endpoint, adapters, or the Pi/LiteLLM versions, the operator must verify **both**: + +1. **Core / Pi:** select the model, start a test session in a prepared test workspace, exercise an + actual tool call and its returned result, a human review gate, and stop/resume. Check streaming, + tool arguments, authentication, and reasoning/token-limit compatibility. A plain chat reply or + `tht pi test` alone is not sufficient. +2. **Administration / LiteLLM:** select the same model and generate descriptions for a small, + non-sensitive test table. Check the structured result is accepted and the generation completes. + Review the output quality before using it on real metadata. This action writes test metadata + and may incur provider charges: use an authorized test database and approved data. + +There is no automatic certification flag or startup model probe. The operator owns this verification; +do not infer compatibility from the model label or from success in just one path. Both adapters point +to one catalog identity; Pi does not need to route through a new LiteLLM proxy. Models using only +`pi_auth` cannot serve the current LiteLLM path and are excluded from shared selection. ## Session adapters -Use `pi_builtin` for a model whose technical definition ships with Pi: +Use `pi_builtin` for a model whose technical definition ships with Pi. This does not require +`pi_auth`: a shared bundle credential lets native Pi and LiteLLM use the same provider identity: ```yaml deepseek: authentication: - mode: pi_auth + mode: secret_env + apiKeyEnv: DEEPSEEK_API_KEY session: mode: pi_builtin + metadataGeneration: + litellmProvider: deepseek models: deepseek-v4-pro: session: {} + metadataGeneration: {} + deepseek-v4-flash: + session: {} + metadataGeneration: {} ``` Use `openai_compatible` for an explicit compatible endpoint. Each eligible session model must then @@ -89,6 +128,18 @@ Secret values never belong in installation YAML, generated files, logs, CLI argu requests. The YAML contains only an environment-variable name or an authentication mode. Pi's protected credential file remains selected by the installation authentication configuration. +For catalog providers using `secret_env`, the bundle is authoritative in both Core and Admin. +ThothII removes only the selected provider's old auth entry from the temporary Pi session snapshot; +the operator's original Pi auth store and other providers are unchanged. Provider smoke checks use +the same precedence, and model enumeration receives the catalog-declared bundle keys. A missing +declared key is an error, not permission to fall back to Pi auth or the legacy generic key file. +After rotating a bundle key, apply the normal installation lifecycle so processes reload it. + +The PSD descriptor now declares only `deepseek/deepseek-v4-pro` and `deepseek/deepseek-v4-flash` +for both uses; it no longer duplicates them under `deepseek-metadata`. Historical records are not +rewritten. A saved obsolete identity must be explicitly reselected from the current catalog; +it is not silently remapped to another model or account. + ## Generated runtime projections Before Compose starts, `tht` validates the installation and atomically writes deterministic files @@ -133,6 +184,16 @@ configuration command. There is no `tht pi configure` and no separate apply comm ## Migrating a legacy installation +For schema-v2 descriptors with the former `defaults.session` and `defaults.metadataGeneration`, +replace both with `defaults.interaction`. Equal legacy values are accepted and normalized in memory; +the loader never rewrites the descriptor. Different values fail with `migration_required`: explicitly +choose a model supporting both uses, remove both old fields, and set the single new field. Do not mix +new and legacy fields. A Core-only legacy session default can be normalized when Admin AI is absent. + +The generated runtime catalog now uses schema version **2** and only `defaultInteraction`. Regenerate +and apply all runtime projections with the matching host/backend release using the normal installation +lifecycle; do not deploy only the backend against an old generated catalog or hand-edit generated JSON. + The migrator reads the former installation `metadataGeneration` block and the two former Pi JSON files, but never modifies them. Supply the facts that cannot be inferred safely and write a separate candidate: @@ -148,13 +209,15 @@ tht --installation /absolute/path/legacy/thothii-installation.yaml installation Review the candidate, move the legacy source files out of the installation only after approval, then select the v2 descriptor. Ambiguous aliases, endpoint conflicts, or missing authentication facts produce field-level errors; the migrator does not guess. +The legacy CLI flag `--session-default` now supplies the unified interaction default in the candidate; +if it conflicts with the legacy metadata default, align that choice explicitly before retrying. ## Troubleshooting | Symptom | Meaning | Action | | --- | --- | --- | | `migration_required` | A retired model source or installation schema is still present | Run the installation migrator and review its candidate | -| Unknown session or metadata default | The canonical ID is missing the corresponding use block | Correct the provider/model key or add the intended use block | +| Invalid interaction default | The canonical ID does not support all configured uses | Correct `defaults.interaction` or the intended adapter blocks | | Generated projection drift | Runtime files differ from the descriptor-derived bytes | Run `tht start` or `tht pi restart --yes --drain` | -| `model_unavailable` on resume | The session's pinned model is no longer session-eligible | Restore that catalog entry or keep the session unavailable; do not remap it | +| `model_unavailable` on create/resume | The selected global model is no longer eligible | Explicitly choose an eligible model; no fallback is applied | | Provider smoke failure | Credentials, endpoint, or provider availability is invalid | Correct the protected credential or catalog endpoint, restart, then run `tht pi test` | diff --git a/docs/plans/2026-09-10-administration-pages-spec.md b/docs/plans/2026-09-10-administration-pages-spec.md new file mode 100644 index 00000000..edd93be8 --- /dev/null +++ b/docs/plans/2026-09-10-administration-pages-spec.md @@ -0,0 +1,84 @@ +# Unified Administration Pages + +Status: revised direction A accepted and implemented locally (2026-09-12). +The owner selected the latest collapsible context shelf A after restoration of +the original Core. Core and session management must remain functionally unchanged; +the five Administration pages and global context are the implementation scope. +Prototype history remains in `frontend/prototypes/`, including +`administration-review/README.md` and `context-shelf/README.md`. +Final integration remains on the server. +See [implementation and acceptance evidence](../reports/2026-09-12-context-shelf-a-implementation.md). + +## Problem Statement + +Workspace and Pi open over the work area while Database, Memory and Evidence have +different page structures. Administrators need five predictable, independently +accessible pages that fit beside the Omics Portal sidebar and below its red header. + +## Solution + +Use the chosen A / Workbench direction for all five Administration Pages: a warm +page heading, restrained red actions, readable serif titles, sans-serif controls, +compact identity/status information, and an index/detail work area appropriate to +each domain. Keep B / Inspector and C / Operations Deck as recoverable prototypes. +Navigation remains on the right, collapsing within the available application width. + +## User Stories + +1. As an administrator, I want every management entry to open a full page, so that I can use the entire work area. +2. As an administrator, I want the five pages to share hierarchy and typography, so that actions and current context are predictable. +3. As an administrator, I want to refresh or bookmark a management page, so that I can return directly to it. +4. As an administrator, I want Back and Forward to restore navigation, so that browser controls behave normally. +5. As an administrator, I want unsaved edits protected during navigation, so that leaving a page does not silently discard work. +6. As an administrator, I want existing permissions enforced on direct links, so that a URL cannot bypass access controls. +7. As a reviewer, I want my active session retained while visiting Administration, so that navigation does not restart or stop Pi. +8. As a workspace operator, I want to select a workspace and inspect its source, runtime requirements and validation, so that I know which environment I am preparing. +9. As a workspace operator, I want preprocessing beside that workspace's readiness, so that the operation's target is explicit. +10. As a workspace operator, I want catalog revision and preprocessing diagnostics, so that I can understand missing prerequisites and stale derived data. +11. As a database operator, I want configuration, synchronization, descriptions and sensitivity in Database management, so that ownership of catalog changes remains clear. +12. As a database operator, I want a link to the related workspace's preprocessing status, so that I can complete preparation after catalog changes. +13. As a Memory curator, I want existing filtering, CRUD, links and index recovery inside the shared workbench, so that the redesign preserves my workflows. +14. As an Evidence curator, I want browsing, source review and file-maintenance instructions inside the shared workbench, so that external Markdown editing remains the authoring workflow. +15. As a Pi operator, I want runtime status, catalog defaults, models and diagnostics in a page, so that I can inspect the installation without a management popup. +16. As a portal user, I want layout based on the available container width, so that a desktop viewport with a wide portal sidebar still works. +17. As a keyboard or mobile user, I want accessible navigation and non-overlapping controls, so that all five pages remain usable at narrow widths and zoom. +18. As a product owner, I want B and C preserved, so that an individual page can adopt another design later. + +## Implementation Decisions + +- Five peer Administration Surfaces, independent of active session existence. +- One collapsible top shelf A selects workspace and canonical interaction model for Core and all Administration pages. No duplicate operational selectors inside those pages or the Core composer. +- Each explicit choice is remembered independently per browser origin, application mount and authenticated principal. Absent an explicit choice, use the installation default. Removed/unavailable choices require explicit correction, never silent substitution. +- Neither Core nor Administration activities are enabled until both choices resolve to available catalog entries. Background refresh errors retain already validated context and mounted drafts. +- Workspace/model changes are locked during Core or Administration operations. Navigation itself does not cancel those operations: visited Administration pages and the original Core stay mounted. +- Resume returns the session's pinned workspace in its lifecycle response and adopts that workspace without replacing the global model. Older responses can fall back to the session manifest. +- Preserve the original eight-phase workflow, left activity log, gate widgets, composer, My sessions/All sessions tabs, groups, archive and session lifecycle. A navigation drawer remains reachable when the log is open or the available width is narrow. +- Shared Workbench page/header/layout primitives; data ownership and mutation APIs remain domain-specific. +- Namespaced `thoth_route=administration/` query routing, preserving host path, other query parameters, fragment and history state. Optional `thoth_workspace` carries only a stable workspace identity for cross-links. +- Unsaved Administration changes block in-app navigation, browser Back/Forward, and context changes. Save successfully or explicitly cancel inside the editor before leaving; navigation does not offer automatic discard. Reload gets native before-unload protection. Busy state locks context selection, not read-only cross-page navigation. +- Workspace owns readiness and full preprocessing. Database owns binding, physical schema synchronization, descriptions and sensitivity. Database status links to workspace preparation; it does not duplicate the run action. +- Preprocessing permission remains the existing backend permission. This UI uses the already unified Installation Model Catalog (`defaults.interaction`) and does not introduce another model authority. +- A shared responsive frame uses available container width, scoped CSS and bounded scrolling. The portal remains the owner of its header and left sidebar; Thoth does not duplicate them. The host can set `--thoth-app-height` to its available height below the header. +- Existing catalog grids remain appropriate for tabular data; form content is inline in the work area. Short confirmations and secondary operation/history panels may remain dialogs/panels. +- English interface labels; persisted document content retains the workspace language. +- A is the production direction. The three read-only prototypes and their launch script remain available and are not imported by production code. + +## Testing Decisions + +- Verify public behavior at the existing AppShell and management-page boundaries with MSW API fixtures; avoid tests of private state or CSS implementation details. +- Cover all five direct routes, browser history, host URL preservation, permission denial, guarded navigation and retained session behavior. +- Exercise workspace-specific preprocessing targeting and database-to-workspace navigation with authoritative API status fixtures. +- Use the existing authenticated Playwright stack to verify full pages, forms, container resize, narrow screens and a simulated portal header/sidebar. Fixtures avoid mutating the installed PSD knowledge. +- Run frontend typecheck/build, focused tests while implementing, and the full frontend test suite at integration. + +## Out of Scope + +Schema migrations, a new preprocessing service, Evidence web editing, new model configuration authority, actual deployment into Omics Portal, production deployment, and deletion of prototype variants. The only additional backend contract change in this UI increment is the pinned workspace identity in successful Resume responses. + +## Further Notes + +This implements the page direction recorded by ADR 0020, refined by the owner's +2026-09-12 acceptance of shelf A and explicit functional-preservation requirements. +Testing reuses the existing public UI seams. Ticket breakdown is recorded alongside +this spec as four independently reviewable increments. Gitea publication is pending +authenticated access; no remote issue identifiers are claimed by these local files. diff --git a/docs/plans/administration-pages/01-navigation.md b/docs/plans/administration-pages/01-navigation.md new file mode 100644 index 00000000..87066cb1 --- /dev/null +++ b/docs/plans/administration-pages/01-navigation.md @@ -0,0 +1,13 @@ +# A1: Navigable Administration Pages + +**What to build:** Workspace and Pi become pages; all five surfaces support deep links, browser history, permission enforcement and guarded navigation while retaining session state. + +**Blocked by:** None (can start immediately). + +**Status:** implemented and locally verified on 2026-09-12; local ticket, Gitea publication pending. + +- [x] Five full-page surfaces reached from the current right navigation. +- [x] Refresh, Back/Forward and unrelated host URL/history fields preserved. +- [x] Unsaved edits block navigation; operations lock context without blocking page inspection. Unauthorized links denied. +- [x] Existing session continuation and management regressions pass. +- [x] One global shelf A; independent remembered workspace/model, validated defaults, session-pinned workspace on Resume. diff --git a/docs/plans/administration-pages/02-workspace-readiness.md b/docs/plans/administration-pages/02-workspace-readiness.md new file mode 100644 index 00000000..8f17cf71 --- /dev/null +++ b/docs/plans/administration-pages/02-workspace-readiness.md @@ -0,0 +1,12 @@ +# A2: Workspace preparation and Database dependency + +**What to build:** An operator selects a workspace, inspects readiness and catalog revisions, runs preprocessing and follows a reciprocal link to Database configuration. + +**Blocked by:** A1: Navigable Administration Pages. + +**Status:** implemented and regression-tested locally on 2026-09-12; configured server acceptance remains in A4. Local ticket, Gitea publication pending. + +- [x] Preprocessing runs against the globally selected workspace. +- [x] Missing, stale, blocked, running and failed status reflect the API. +- [x] Database shows the related workspace's readiness and links to its preparation. +- [x] Permission and confirmation regressions pass; no preprocessing/data-persistence behavior changed in this increment. diff --git a/docs/plans/administration-pages/03-workbench-family.md b/docs/plans/administration-pages/03-workbench-family.md new file mode 100644 index 00000000..1ce75603 --- /dev/null +++ b/docs/plans/administration-pages/03-workbench-family.md @@ -0,0 +1,12 @@ +# A3: Shared A / Workbench family + +**What to build:** Apply the chosen typography, page hierarchy and list/detail treatment to all five pages, preserving real domain operations and prototype alternatives. + +**Blocked by:** A1: Navigable Administration Pages. + +**Status:** implemented and locally verified on 2026-09-12; local ticket, Gitea publication pending. + +- [x] Common heading, warm surfaces, controls and status hierarchy across five domains. +- [x] Memory/Evidence browsing and editing/maintenance behavior preserved. +- [x] Database configuration remains in the page work area; Pi status and host instructions are readable. +- [x] A/B/C prototype files and launch scripts preserved. diff --git a/docs/plans/administration-pages/04-embedded-acceptance.md b/docs/plans/administration-pages/04-embedded-acceptance.md new file mode 100644 index 00000000..c4414e6f --- /dev/null +++ b/docs/plans/administration-pages/04-embedded-acceptance.md @@ -0,0 +1,15 @@ +# A4: Embedded and responsive acceptance + +**What to build:** Verify the complete family beside a portal sidebar and below its red header, including browser and keyboard navigation at narrow widths. + +**Blocked by:** A2: Workspace preparation and Database dependency; A3: Shared A / Workbench family. + +**Status:** local regression and browser acceptance completed on 2026-09-12; actual server/Omics integration gate remains open. Local ticket, Gitea publication pending. + +- [x] Sampled actual application at 390, 768 and 1280 CSS px; responsive context and navigation visually checked with synthetic data. +- [x] Navigation reachable at mobile and desktop widths; original session scope tabs retained. +- [x] Typecheck, build, frontend/backend regression suites and targeted browser checks recorded. +- [ ] Standards/spec review completed; limits of actual portal integration documented. +- [ ] On-server integration under the real portal header/sidebar, including keyboard traversal, zoom and configured runtime operations. + +Evidence and remaining gates: [implementation report](../../reports/2026-09-12-context-shelf-a-implementation.md). diff --git a/docs/reports/2026-09-12-context-shelf-a-implementation.md b/docs/reports/2026-09-12-context-shelf-a-implementation.md new file mode 100644 index 00000000..55e1d30d --- /dev/null +++ b/docs/reports/2026-09-12-context-shelf-a-implementation.md @@ -0,0 +1,96 @@ +# Context shelf A: implementation and local acceptance + +Date: 2026-09-12. Status: implemented and deployed to local Docker; not deployed to the server. + +## Accepted scope + +The owner selected the latest option A and explicitly required functional +preservation of Core and session management. Aesthetic refinement is limited to +the agreed Administration workbench and global context. + +- Workspace, Evidence, Memory, Database and Pi open as peer pages in the work area. +- A single top collapsible shelf selects workspace and interaction model. + Explicit choices are independently remembered per origin, mount and principal; + installation defaults fill only missing choices. Invalid saved choices fail + closed instead of silently choosing another context. +- Core's composer, eight phases, review widgets and left activity log remain the + original production components. Session scope tabs, grouping, rename, archive, + bulk operations, read-only inspection and guarded Resume remain intact. +- Visited Admin pages and Core remain mounted during page navigation. + Unsaved Admin edits block navigation/context changes until saved or explicitly + cancelled in the editor. Failed saves and failed catalog refreshes preserve drafts. +- Workspace/model selectors are locked during operations. A navigation drawer + preserves access to the session rail when the log is open or space is narrow. +- The unified Installation Model Catalog remains the only model authority. + Core and metadata generation consume the same canonical selection. + Resume now includes the session's pinned `workspaceId` in successful responses, + binding the global workspace immediately without replacing the selected model; + older backends have a manifest fallback. +- Impeccable informed typography, warm surfaces, restrained red, spacing, + natural help-text wrapping and container-responsive layout. No Omics header + or left sidebar was added to production ThothII. +- A, B and C prototype sources and launch scripts were preserved. + +## Verification + +Using Node 24.16.0: + +| Check | Result | +| --- | --- | +| Full frontend Vitest suite (two workers) | 671 tests passed, 82 files | +| Full backend Vitest suite (two workers) | Passed; existing 40 opt-in auth runtime projection tests skipped | +| Frontend TypeScript build check | Passed | +| Backend TypeScript check | Passed | +| Frontend production Vite build | Passed; bundle-size advisory remains | +| Git whitespace check | Passed | +| Strict MkDocs build | Passed | + +Regression fixtures now provide valid context where they exercise normal Core +or session workflows. Separate tests explicitly cover invalid saved context, +global selector uniqueness, lock/unlock, failed and successful saves, retained +drafts across refresh failure, Core/Admin round trips, and pinned-workspace Resume. +The existing 40 session-management tests remain green. + +Browser acceptance used the actual production React application against a +temporary read-only synthetic API, not the earlier prototype implementation. +Checked Core, expanded/collapsed shelf, Memory, session scope tabs and mobile +navigation at 390, 768 and 1280 CSS-pixel application widths. The test frame +scaled wider layouts to fit the inspection panel; layout width inside the frame +remained the stated CSS width. No real data, provider calls or runtime mutations +were performed. This is sampled visual acceptance, not certification of every +Admin editor on the real portal. + +## Remaining release gates + +### Local Docker update + +At the owner's subsequent request, core and frontend were rebuilt from +`/Users/mp/projects/ThothII` and recreated in the existing +`thothii-18998cca7b0a` project. Its launcher preserves all previous Compose +bindings and adds `/private/tmp/thothii-context-a.compose.yaml` last to override +the old worktree build context. Only core/frontend were recreated; PostgreSQL, +Qdrant, embedding and persistent volumes were retained. + +Verified HTTP 200 for the frontend and both direct/proxied core health endpoints. +The served production bundle contains the global shelf and unsaved-change guard, +without the synthetic preview API. All five persistent services report healthy. +The mounted catalog is schema v2, default `zai/glm-5.3`, with shared +DeepSeek Pro/Flash, local Qwen and GLM model identities. +Previous image tags are retained as `before-context-a-20260912` for each image. +This startup check does not exercise authenticated Core/provider or Admin workflows. + +### Server release + +1. Integrate on the server beneath Omics Portal's actual red header and beside + its existing sidebar. Supply available height through `--thoth-app-height` + when the host is shorter than the viewport. Check real host styles, widths, + zoom, keyboard traversal and Back/Forward. +2. Use approved test data to verify configured Core/Pi and Admin/LiteLLM models + through their real workflows. Unit fixtures do not certify provider compatibility. +3. Verify workspace readiness, synchronization and resume on the configured + stack before release. Follow the existing guarded server migration runbook. +4. Gitea publication and final independent standards/spec review remain separate + gates; no remote issues or deployment are claimed by this implementation. + +The working tree also contains earlier model-catalog and Administration work. +Unrelated changes were preserved; this report does not claim a clean or committed tree. diff --git a/docs/reports/2026-09-12-unified-interaction-model.md b/docs/reports/2026-09-12-unified-interaction-model.md new file mode 100644 index 00000000..33f9f6b7 --- /dev/null +++ b/docs/reports/2026-09-12-unified-interaction-model.md @@ -0,0 +1,80 @@ +# Unified interaction LLM — local implementation + +Date: 2026-09-12. This change is separate from selection of the final administration/context-shelf +prototype. No deployment, Gitea issue publication, or Omics Portal modification was performed. + +## Implemented + +- One authored `modelCatalog.defaults.interaction` per installation, independent of workspace. + The local PSD descriptor and tracked example now use `zai/glm-5.3` in this field. +- One generated `defaultInteraction` in runtime catalog schema v2; host projections, backend + settings, Pi diagnostics, Core admission and metadata-generation consumers use that value. +- Separate Pi/LiteLLM adapters remain. When Admin AI is configured, both public lists and Pi's + enabled list use their intersection. Single-use entries remain inventory, not global choices. + Core-only installations remain possible when no Admin model is configured. Embedding is unchanged. +- Equal legacy defaults normalize on read without rewriting the source; divergent defaults or + mixed old/new fields fail with migration guidance. Generated schema-v1 catalogs must be regenerated. +- The existing Core and Database selectors now share application preferences, using complete + canonical IDs, including when two providers use the same short model name. +- Explicit LLM choices are remembered in browser storage per origin/application mount and + authenticated issuer/subject. Automatic defaults are not stored as explicit choices. Workspace + and thinking preferences retain their existing in-memory lifetime. +- Core/description-generation activity disables the existing model controls using existing state + and the shared run cache. This is not a new cross-workspace/background job subsystem. +- New sessions reject unavailable selections without fallback. Resume receives the global model + (or uses the installation default when omitted), keeps the historical workspace/revision and does + not overwrite the original manifest model fields. Archived/finalized sessions remain read-only. +- Pi management instructions and the configuration guide require an operator to exercise each + model in both Core and Administration. There is no automatic certification flag or live probe. + +## PSD DeepSeek consolidation + +Following explicit operator approval, the local PSD descriptor and its tracked example use one +`deepseek` provider with native Pi and LiteLLM adapters. Both `deepseek/deepseek-v4-pro` and +`deepseek/deepseek-v4-flash` are eligible for shared selection. The duplicate `deepseek-metadata` +provider declaration is removed; historical records remain unchanged. The installation default +stays `zai/glm-5.3`. + +A value-free comparison confirmed that the existing Pi DeepSeek key and the existing +`DEEPSEEK_API_KEY` bundle entry are identical; both source files have mode 0600. No secret file +was changed. The catalog now declares `secret_env` / `DEEPSEEK_API_KEY` as the authoritative +source for both paths. Pi session snapshots exclude the selected provider's old auth entry, +without changing the original store or unrelated entries. Missing bundle keys fail closed instead +of falling back to old Pi auth or the generic key file. Availability enumeration, credential status, +and isolated provider smoke checks follow the catalog declaration as well. + +## Verification + +- All host CLI Go packages pass `go test ./...`. +- Backend under Node 24.16.0 after DeepSeek consolidation: 108 test files pass, 1 skipped; + 1,377 tests pass, 40 skipped. + Backend TypeScript check passes. +- New regression coverage checks shared DeepSeek configuration/projection, canonical Core/Admin + identities, bundle-backed model enumeration, stale Pi auth precedence, missing-key refusal, + preservation of the source auth store, and sanitized credential status. All credentials in these + tests are synthetic; no model inference is performed. +- Full frontend run: 658 tests pass; three remaining failures concern existing administration-layout + work: Workspace preprocessing region, Catalog status placement, and Sync history entry point. + These controls were already being changed in the dirty worktree before this model change. +- The focused Core/Admin model synchronization test also verifies locking during Core and + description-generation activity, unlocking afterward, and provider-qualified identity. It passes. +- Frontend typecheck still reports three `exact` option errors in the pre-existing, untracked + `AppShell.administration.test.tsx` (lines 48, 66, 71). No new type errors are reported. +- Earlier parallel runs encountered timing-sensitive auth-helper failures; the complete backend + rerun with Node 24 and two workers passes. The Homebrew `node@24` path on this machine actually + reported Node 25; the verified Node 24 executable is under the user's nvm installation. +- No live provider calls or full deployed Pi/LiteLLM/Omics acceptance tests were performed. +- `git diff --check` passes. Strict documentation build remains blocked by an existing link in + the untracked `plans/2026-09-10-administration-pages-spec.md` pointing outside the documentation + tree to the administration-review prototype README. The updated configuration guide adds no warning. + +The full frontend gate is not green; this is not a deployment-ready acceptance claim. Pre-existing +production UI edits and every prototype are preserved. The final collapsible global context layout, +general Core/Admin admission policy and server assembly remain in the wider UI workstream. + +## Applying later + +Use the matching host/backend release, review the installation descriptor, then apply the normal +installation lifecycle to regenerate **all** runtime projections together. Do not deploy only the new +backend against an old generated catalog. Follow the dual-path operator checklist in +[Installation Model Catalog](../general/pi-configuration.md). diff --git a/frontend/src/api/sessions.test.ts b/frontend/src/api/sessions.test.ts index 996bb93c..ff64fb09 100644 --- a/frontend/src/api/sessions.test.ts +++ b/frontend/src/api/sessions.test.ts @@ -232,11 +232,16 @@ test("getMe fetches the typed authenticated principal", async () => { }); test("resumeSession returns the typed runtime disposition", async () => { - server.use(http.post("/api/sessions/s1/resume", () => - HttpResponse.json({ id: "s1", alreadyActive: false }))); + workspacePreferences.save({ workspaceId: "different-workspace", provider: "local", model: "qwen", thinking: "medium" }); + let body: unknown; + server.use(http.post("/api/sessions/s1/resume", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ id: "s1", alreadyActive: false }); + })); const result: { id: string; alreadyActive: boolean } = await resumeSession("s1"); expect(result).toEqual({ id: "s1", alreadyActive: false }); + expect(body).toEqual({ provider: "local", model: "qwen" }); }); test("renameSession POSTs {name}", async () => { diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 4f6630a7..03dfcfe5 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -26,11 +26,12 @@ async function selectedPreferences(precondition?: AuthOperationPrecondition): Pr // application memory from the installation defaults once, then keep choices ephemeral. const legacy = await getSettings(); requireAuthOperationPrecondition(precondition); + const latest = workspacePreferences.load(); return workspacePreferences.save({ - workspaceId: saved.workspaceId ?? legacy.workspace, - provider: saved.provider ?? legacy.provider, - model: saved.model ?? legacy.model, - thinking: saved.thinking ?? legacy.thinking, + workspaceId: latest.workspaceId ?? legacy.workspace, + provider: latest.provider ?? legacy.provider, + model: latest.model ?? legacy.model, + thinking: latest.thinking ?? legacy.thinking, }); } @@ -139,8 +140,20 @@ export const postSteer = (id: string, text: string) => export const closeSession = (id: string) => apiFetch(`/sessions/${id}/close`, { method: "POST" }); -export const resumeSession = (id: string) => - apiFetch(`/sessions/${id}/resume`, { method: "POST" }); +export async function resumeSession(id: string) { + const operation = captureAuthOperation(); + const precondition = operation ? { + operation, + isCurrent: () => isAuthOperationCurrent(operation, { sessionId: null, disposalEpoch: operation.disposalEpoch }), + } : undefined; + const selected = await selectedPreferences(precondition); + requireAuthOperationPrecondition(precondition); + return apiFetch(`/sessions/${id}/resume`, { + method: "POST", + // The workspace is owned by the persisted session, never by this request. + body: JSON.stringify({ provider: selected.provider, model: selected.model }), + }); +} export const renameSession = (id: string, name: string) => apiFetch(`/sessions/${id}/rename`, { method: "POST", body: JSON.stringify({ name }) }); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 15f1c53d..cdc1eaf2 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -160,6 +160,8 @@ export interface Principal { export interface ResumeSessionResult { id: string; alreadyActive: boolean; + /** The session's pinned workspace; never supplied by the client. */ + workspaceId?: string; } export interface SessionDocument { diff --git a/frontend/src/models/useInteractionModelBusy.ts b/frontend/src/models/useInteractionModelBusy.ts new file mode 100644 index 00000000..fe4e974f --- /dev/null +++ b/frontend/src/models/useInteractionModelBusy.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { listDescriptionGenerationRuns } from "../api/catalog-databases"; +import { hasPermission, useAuthUser } from "../auth/authState"; +import { useSessionStore } from "../store/sessionStore"; + +/** Reuse the existing run cache across Core/Admin navigation; no background job manager. */ +export function useInteractionModelBusy(): boolean { + const coreActive = useSessionStore((state) => state.agentActive); + const user = useAuthUser(); + const { data: runs } = useQuery({ + queryKey: ["description-generation-runs", 50], + queryFn: () => listDescriptionGenerationRuns(50), + enabled: hasPermission(user, "database.manage"), + retry: false, + refetchInterval: 5_000, + }); + return coreActive || Boolean(runs?.some((run) => run.status === "queued" || run.status === "running")); +} diff --git a/frontend/src/shell/AppShell.administration.test.tsx b/frontend/src/shell/AppShell.administration.test.tsx new file mode 100644 index 00000000..de619080 --- /dev/null +++ b/frontend/src/shell/AppShell.administration.test.tsx @@ -0,0 +1,259 @@ +import { act, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; +import { vi } from "vitest"; +import { server } from "../test/msw"; +import { setAuthState } from "../auth/authState"; +import { useSessionStore } from "../store/sessionStore"; +import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures"; +import { AppShell } from "./AppShell"; +import { workspacePreferences } from "../workspaces/preferences"; +import { FakeEventSource } from "../test/fakeEventSource"; + +const permissions = ["session.use", "database.manage", "workspace.manage", "workspace.secrets.manage", "memory.manage", "evidence.manage", "pi.manage"]; +function renderShell(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) { + return render( + + ); +} +beforeEach(() => { + setAuthState({ issuer: "test", subject: "admin", roles: ["admin"], permissions, isAdmin: true, csrfToken: null, session: null }); + localStorage.clear(); useSessionStore.getState().resetSession(); + workspacePreferences.reset(); + window.history.replaceState(null, "", "/"); + FakeEventSource.instances = []; + (globalThis as any).EventSource = FakeEventSource; + server.use( + http.get("/api/sessions", () => HttpResponse.json([])), + http.get("/api/settings", () => HttpResponse.json({ workspace: "alpha", provider: "test", model: "test", thinking: "low" })), + http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })), + http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("alpha"), workspaceSummaryFixture("beta")])), + http.get("/api/workspace-registry/status", () => HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false })), + http.get("/api/workspaces/:id/runtime-configuration", ({ params }) => HttpResponse.json({ workspaceId: params.id, revision: workspaceRevisionFixture(String(params.id)), configurationState: "ready", requirements: [] })), + http.get("/api/workspaces/:id", ({ params }) => HttpResponse.json({ workspace: canonicalWorkspaceFixture(String(params.id)), revision: workspaceRevisionFixture(String(params.id)) })), + http.get("/api/pi-management/status", () => HttpResponse.json({ ready: true, credentials: "present", config: { provider: "test", model: "test", reasoning: "low" } })), + http.get("/api/workspaces/:id/memory", () => HttpResponse.json({ items: [], total: 0, page: 1, page_size: 25 })), + http.get("/api/workspaces/:id/memory/pending", () => HttpResponse.json([])), + ); +}); +afterEach(() => vi.restoreAllMocks()); + +test.each(["Workspace", "Evidence", "Memory", "Database", "Pi"])("reloads the %s route as a full page", async name => { + window.history.replaceState({ portal: "kept" }, "", `/portal?lang=it&thoth_route=administration/${name.toLowerCase()}#host`); + renderShell(); + expect(await screen.findByRole("main", { name: `${name} management` })).toBeVisible(); + expect(screen.queryByRole("dialog", { name: `${name} management` })).not.toBeInTheDocument(); + expect(window.location.hash).toBe("#host"); + expect(window.history.state.portal).toBe("kept"); +}); + +test("preserves host state and query parameters through Back and Forward", async () => { + window.history.replaceState({ portal: { view: "thoth" } }, "", "/portal?lang=it#host"); + renderShell(); + await userEvent.click(screen.getByRole("button", { name: "Administration" })); + await userEvent.click(screen.getByRole("button", { name: "Workspace management" })); + await userEvent.click(screen.getByRole("button", { name: "Pi management" })); + act(() => window.history.back()); + expect(await screen.findByRole("main", { name: "Workspace management" })).toBeVisible(); + act(() => window.history.forward()); + expect(await screen.findByRole("main", { name: "Pi management" })).toBeVisible(); + expect(new URLSearchParams(window.location.search).get("lang")).toBe("it"); + expect(window.location.pathname).toBe("/portal"); + expect(window.location.hash).toBe("#host"); + expect(window.history.state.portal).toEqual({ view: "thoth" }); +}); + +test.each(["workspace", "database", "memory", "evidence", "pi"])("denies a direct %s link for a non-administrator", async name => { + setAuthState({ issuer: "test", subject: "reader", roles: ["user"], permissions: ["session.use"], isAdmin: false, csrfToken: null, session: null }); + window.history.replaceState(null, "", `/?thoth_route=administration/${name}`); + renderShell(); + expect(await screen.findByRole("main", { name: "Administration unavailable" })).toBeVisible(); + expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument(); +}); + +test("Back and Core navigation preserve a Memory draft until the editor cancels it", async () => { + renderShell(); + await userEvent.click(screen.getByRole("button", { name: "Administration" })); + await userEvent.click(screen.getByRole("button", { name: "Workspace management" })); + await userEvent.click(screen.getByRole("button", { name: "Memory management" })); + const page = await screen.findByRole("main", { name: "Memory management" }); + await userEvent.click(await within(page).findByRole("button", { name: "New card" })); + await userEvent.type(within(page).getByLabelText("Title"), "Keep this draft"); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + act(() => window.history.back()); + await waitFor(() => expect(new URLSearchParams(window.location.search).get("thoth_route")).toBe("administration/memory")); + expect(within(page).getByLabelText("Title")).toHaveValue("Keep this draft"); + await userEvent.click(screen.getByRole("button", { name: "Return to session" })); + expect(page).toBeVisible(); + expect(confirm).not.toHaveBeenCalled(); + confirm.mockReturnValue(true); + await userEvent.click(within(page).getByRole("button", { name: "Cancel" })); + act(() => window.history.back()); + expect(await screen.findByRole("main", { name: "Workspace management" })).toBeVisible(); + act(() => window.history.forward()); + expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible(); +}); + +test("runs preprocessing for the globally selected workspace", async () => { + const targets: string[] = []; + server.use(http.post("/api/workspaces/:id/preprocessing", ({ params }) => { + targets.push(String(params.id)); + return HttpResponse.json({ schemaVersion: 1, workspaceId: params.id, state: "ready", actionable: true, clearable: true, detail: "Prepared" }); + })); + window.history.replaceState(null, "", "/?thoth_route=administration/workspace&thoth_workspace=beta"); + workspacePreferences.selectWorkspace("beta"); + renderShell(); + const preparation = await screen.findByRole("region", { name: "Workspace readiness" }); + await userEvent.click(await within(preparation).findByRole("button", { name: "Run again" })); + await waitFor(() => expect(targets).toEqual(["beta"])); + expect(within(preparation).getByRole("link", { name: "Open Database configuration and schema" })).toHaveAttribute("href", "/?thoth_route=administration%2Fdatabase&thoth_workspace=beta"); +}); + +test("option A has one collapsible global context and preserves the Core draft across Admin", async () => { + renderShell(); + const composer = await screen.findByRole("textbox", { name: /new question/i }); + await userEvent.type(composer, "Keep my original Core question"); + await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ })); + expect(screen.getAllByRole("combobox", { name: "Workspace" })).toHaveLength(1); + expect(screen.getAllByRole("combobox", { name: "Model" })).toHaveLength(1); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta"); + expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" }); + await userEvent.click(screen.getByRole("button", { name: "Done" })); + expect(screen.queryByRole("combobox", { name: "Model" })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Administration" })); + await userEvent.click(screen.getByRole("button", { name: "Memory management" })); + expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible(); + expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Return to session" })); + expect(screen.getByRole("textbox", { name: /new question/i })).toBe(composer); + expect(composer).toHaveValue("Keep my original Core question"); +}); + +test("invalid remembered choices do not silently fall back and gate both Core and Admin", async () => { + workspacePreferences.selectWorkspace("removed"); + workspacePreferences.selectModel("removed/model"); + window.history.replaceState(null, "", "/?thoth_route=administration/memory"); + renderShell(); + await screen.findByText(/Unavailable saved choices are not replaced/); + expect(screen.getByRole("button", { name: "New session" })).toBeDisabled(); + expect(screen.queryByRole("main", { name: "Memory management" })).not.toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument(); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "alpha"); + expect(screen.queryByRole("main", { name: "Memory management" })).not.toBeInTheDocument(); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Model" }), "test/test"); + expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible(); +}); + +test("workspace and model stay locked during Core and unlock after it finishes", async () => { + renderShell(); + await screen.findByRole("textbox", { name: /new question/i }); + await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ })); + act(() => useSessionStore.getState().setAgentActive(true)); + expect(screen.getByRole("combobox", { name: "Workspace" })).toBeDisabled(); + expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled(); + act(() => useSessionStore.getState().setAgentActive(false)); + expect(screen.getByRole("combobox", { name: "Workspace" })).toBeEnabled(); + expect(screen.getByRole("combobox", { name: "Model" })).toBeEnabled(); +}); + +test("dirty Memory blocks workspace and model changes, including after a failed save", async () => { + let saveAttempts = 0; + server.use( + http.get("/api/models", () => HttpResponse.json({ models: [ + { provider: "test", id: "test", name: "Test LLM", reasoning: true }, + { provider: "test", id: "other", name: "Other LLM", reasoning: true }, + ] })), + http.post("/api/workspaces/:id/memory", () => { saveAttempts++; return HttpResponse.json({ code: "memory_unavailable", error: "Save failed" }, { status: 500 }); }), + ); + window.history.replaceState(null, "", "/?thoth_route=administration/memory"); + renderShell(); + const page = await screen.findByRole("main", { name: "Memory management" }); + await userEvent.click(within(page).getByRole("button", { name: "New card" })); + await userEvent.type(within(page).getByLabelText("Title"), "Do not lose this"); + await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ })); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta"); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Model" }), "test/other"); + expect(workspacePreferences.load()).toMatchObject({ workspaceId: "alpha", model: "test" }); + await userEvent.type(within(page).getByLabelText("Content"), "Retain this content."); + await userEvent.type(within(page).getByLabelText("Scope"), "This workspace."); + await userEvent.click(within(page).getByRole("button", { name: "Save card" })); + await waitFor(() => expect(saveAttempts).toBe(1)); + expect(await within(page).findByRole("alert")).toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "Return to session" })); + expect(page).toBeVisible(); + expect(within(page).getByLabelText("Title")).toHaveValue("Do not lose this"); +}); + +test("a failed catalog refresh retains a mounted Admin draft", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + window.history.replaceState(null, "", "/?thoth_route=administration/memory"); + renderShell(client); + const page = await screen.findByRole("main", { name: "Memory management" }); + await userEvent.click(within(page).getByRole("button", { name: "New card" })); + await userEvent.type(within(page).getByLabelText("Title"), "Still here"); + server.use(http.get("/api/models", () => new HttpResponse(null, { status: 503 }))); + await act(async () => { await client.refetchQueries({ queryKey: ["models"], exact: true }); }); + expect(page).toBeVisible(); + expect(within(page).getByLabelText("Title")).toHaveValue("Still here"); +}); + +test("Resume binds the pinned workspace immediately and leaves the global model unchanged", async () => { + server.use( + http.get("/api/sessions", () => HttpResponse.json([{ + id: "in-beta", status: "open", question: "Beta session", active: true, + created_at: "2026-09-12T00:00:00Z", archived: false, + }])), + http.post("/api/sessions/in-beta/resume", () => HttpResponse.json({ id: "in-beta", alreadyActive: true, workspaceId: "beta" })), + http.get("/api/sessions/in-beta", () => HttpResponse.json({ id: "in-beta", phase: 3, workspace_id: "beta", provider: "old", model: "old" })), + ); + renderShell(); + await screen.findByRole("textbox", { name: /new question/i }); + await userEvent.click(await screen.findByTestId("session-item-in-beta")); + await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)); + expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" }); +}); + +test("a successful Memory save releases the navigation guard and the context lock", async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + server.use(http.post("/api/workspaces/:id/memory", async ({ request }) => { + const card = await request.json() as object; + await gate; + return HttpResponse.json({ id: "saved", saved: true, indexed: true, action: "upsert", + card: { ...card, id: "saved", workspace_id: "alpha", revision: "1", origin: "manual", indexed: true } }); + })); + window.history.replaceState(null, "", "/?thoth_route=administration/memory"); + renderShell(); + const page = await screen.findByRole("main", { name: "Memory management" }); + await userEvent.click(within(page).getByRole("button", { name: "New card" })); + await userEvent.type(within(page).getByLabelText("Title"), "Saved card"); + await userEvent.type(within(page).getByLabelText("Scope"), "This workspace"); + await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ })); + await userEvent.click(within(page).getByRole("button", { name: "Save card" })); + expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled(); + await userEvent.click(screen.getByRole("button", { name: "Return to session" })); + expect(page).toBeVisible(); + await act(async () => release()); + await within(page).findByText("Saved and indexed."); + expect(screen.getByRole("combobox", { name: "Model" })).toBeEnabled(); + await userEvent.click(screen.getByRole("button", { name: "Return to session" })); + expect(await screen.findByRole("textbox", { name: /new question/i })).toBeVisible(); +}); + +test("Admin generation locks the context and Core submission across page navigation", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + renderShell(client); + const composer = await screen.findByRole("textbox", { name: /new question/i }); + await userEvent.type(composer, "Wait for Admin to finish"); + await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ })); + await act(async () => { + client.setQueryData(["description-generation-runs", 50], [{ id: "r1", status: "running" }]); + }); + await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled()); + expect(screen.getByRole("combobox", { name: "Workspace" })).toBeDisabled(); + expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); + await act(async () => { client.setQueryData(["description-generation-runs", 50], []); }); + await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toBeEnabled()); + expect(screen.getByRole("button", { name: /send/i })).toBeEnabled(); +}); diff --git a/frontend/src/shell/AppShell.auth.test.tsx b/frontend/src/shell/AppShell.auth.test.tsx index 89d47154..000252ef 100644 --- a/frontend/src/shell/AppShell.auth.test.tsx +++ b/frontend/src/shell/AppShell.auth.test.tsx @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { AppShell } from "./AppShell"; import { clearAuthState, getAuthGeneration, getAuthState, setAuthState, useAuthGeneration, useAuthUser } from "../auth/authState"; import { server } from "../test/msw"; +import { workspaceSummaryFixture } from "../test/workspace-fixtures"; import { useSessionStore } from "../store/sessionStore"; import { workspacePreferences } from "../workspaces/preferences"; @@ -37,11 +38,11 @@ beforeEach(() => { server.use( http.get("/api/sessions", () => HttpResponse.json([])), http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })), - http.get("/api/workspaces", () => HttpResponse.json([])), + http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("default")])), http.get("/api/workspace-registry/status", () => HttpResponse.json({ branch: "main", ahead: 0, behind: 0, degraded: false, })), - http.get("/api/models", () => HttpResponse.json({ models: [] })), + http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })), http.get("/api/health/dwh", () => HttpResponse.json({ ok: true })), ); }); @@ -54,7 +55,9 @@ describe("authenticated shell permissions", () => { await userEvent.click(screen.getByRole("button", { name: "Memory management" })); const memory = screen.getByRole("main", { name: "Memory management" }); expect(memory).toBeVisible(); - expect(within(memory).getByRole("combobox", { name: "Workspace" })).toBeVisible(); + expect(within(memory).queryByRole("combobox", { name: "Workspace" })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ })); + expect(screen.getByRole("combobox", { name: "Workspace" })).toHaveValue("default"); }); test("does not use the legacy installation default as a preprocessing workspace", async () => { const requestedWorkspaceIds: string[] = []; @@ -98,6 +101,7 @@ describe("authenticated shell permissions", () => { const administration = await screen.findByRole("button", { name: "Administration" }); await user.click(administration); + await user.selectOptions(await screen.findByRole("combobox", { name: "Workspace" }), "psd-clinical"); await waitFor(() => expect(requestedWorkspaceIds).toContain("psd-clinical")); expect(requestedWorkspaceIds).not.toContain("local"); }); @@ -139,14 +143,14 @@ describe("authenticated shell permissions", () => { const separator = within(panel).getByRole("separator"); const workspace = within(panel).getByRole("button", { name: "Workspace management" }); const pi = within(panel).getByRole("button", { name: "Pi management" }); - const preprocessing = within(panel).getByRole("region", { name: "Workspace preprocessing" }); expect(panel.children[0]).toBe(database); expect(panel.children[1]).toBe(memory); expect(panel.children[2]).toBe(evidence); expect(panel.children[3]).toBe(separator); expect(panel.children[4]).toBe(workspace); expect(panel.children[5]).toBe(pi); - expect(panel.children[6]).toBe(preprocessing); + expect(within(panel).queryByRole("region", { name: "Workspace preprocessing" })).not.toBeInTheDocument(); + expect(within(panel).getByText(/Workspace readiness/)).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "All sessions" })).toBeInTheDocument(); await user.keyboard(" "); diff --git a/frontend/src/shell/AppShell.database-management.test.tsx b/frontend/src/shell/AppShell.database-management.test.tsx index 9a529f81..b2a9a56f 100644 --- a/frontend/src/shell/AppShell.database-management.test.tsx +++ b/frontend/src/shell/AppShell.database-management.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { server } from "../test/msw"; +import { workspaceSummaryFixture } from "../test/workspace-fixtures"; import { FakeEventSource } from "../test/fakeEventSource"; import { useSessionStore } from "../store/sessionStore"; import { clearAuthState, setAuthState } from "../auth/authState"; @@ -51,8 +52,8 @@ beforeEach(() => { model: "test", thinking: "low", })), - http.get("/api/workspaces", () => HttpResponse.json([])), - http.get("/api/models", () => HttpResponse.json({ models: [] })), + http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("default")])), + http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })), http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })), http.get("/api/catalog/metrics", () => HttpResponse.json({ scope: "global", @@ -214,10 +215,10 @@ test("hides the complete administrative navigation from a regular user", () => { expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument(); }); -test("does not leave database management without confirming a dirty form", async () => { +test("requires saving or cancelling a dirty database form before leaving", async () => { server.use(http.get("/api/catalog/databases", () => HttpResponse.json([{ id: "11111111-1111-4111-8111-111111111111", - workspaceId: "psd-clinical", + workspaceId: "default", workspaceName: "Policlinico San Donato", workspaceAvailable: true, workspaceRevision: { commit: "a".repeat(40), blob: "b".repeat(40) }, @@ -250,19 +251,19 @@ test("does not leave database management without confirming a dirty form", async await expandAdministration(); await userEvent.click(screen.getByRole("button", { name: "Database management" })); - await userEvent.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" })); - const schema = screen.getByLabelText("Schema"); + const schema = await screen.findByLabelText("Schema"); await userEvent.clear(schema); await userEvent.type(schema, "reporting"); const newSession = screen.getByRole("button", { name: "New session" }); await userEvent.click(newSession); - expect(confirm).toHaveBeenCalledWith("Discard unsaved database changes and leave database management?"); + expect(confirm).not.toHaveBeenCalled(); expect(screen.getByRole("main", { name: "Database management" })).toBeVisible(); expect(schema).toHaveValue("reporting"); confirm.mockReturnValue(true); + await userEvent.click(screen.getByRole("button", { name: "Back to list" })); await userEvent.click(newSession); await waitFor(() => expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument()); confirm.mockRestore(); diff --git a/frontend/src/shell/AppShell.new-session.test.tsx b/frontend/src/shell/AppShell.new-session.test.tsx index df896516..e21b2429 100644 --- a/frontend/src/shell/AppShell.new-session.test.tsx +++ b/frontend/src/shell/AppShell.new-session.test.tsx @@ -43,7 +43,7 @@ beforeEach(() => { snapshotPath: "/data/workspaces/default.yaml", }, }])), - http.get("/api/models", () => HttpResponse.json({ models: [] })), + http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })), ); }); @@ -60,7 +60,7 @@ test("New session starts prewarm without delaying composer focus", async () => { await userEvent.click(screen.getByRole("button", { name: /^new session$/i })); - const composer = screen.getByRole("textbox", { name: /new question/i }); + const composer = await screen.findByRole("textbox", { name: /new question/i }); await waitFor(() => expect(prewarmStarted).toBe(true)); await waitFor(() => expect(composer).toHaveFocus()); expect(composer).toHaveAttribute("data-awaiting-input", "true"); @@ -84,7 +84,7 @@ test("a known preprocessing requirement disables New session", async () => { const newSession = screen.getByRole("button", { name: "New session" }); await waitFor(() => expect(newSession).toBeDisabled()); expect(newSession).toHaveAttribute("data-navigation-state", "unavailable"); - expect(newSession).toHaveAttribute("title", "Catalog revision 18 is not indexed."); + await waitFor(() => expect(newSession).toHaveAttribute("title", "Catalog revision 18 is not indexed.")); }); test("records the prompt without central duplication, then opens the live log", async () => { @@ -98,7 +98,7 @@ test("records the prompt without central duplication, then opens the live log", ); renderShell(); - const composer = screen.getByRole("textbox", { name: /new question/i }); + const composer = await screen.findByRole("textbox", { name: /new question/i }); await userEvent.type(composer, "How many patients?"); await userEvent.click(screen.getByRole("button", { name: /send/i })); @@ -126,7 +126,7 @@ test("a failed create restores the landing view and preserves the question for r ); renderShell(); - const composer = screen.getByRole("textbox", { name: /new question/i }); + const composer = await screen.findByRole("textbox", { name: /new question/i }); await userEvent.type(composer, "Keep this question"); await userEvent.click(screen.getByRole("button", { name: /send/i })); @@ -150,7 +150,7 @@ test("a DWH-unreachable precheck shows a specific alert and preserves the questi ); renderShell(); - const composer = screen.getByRole("textbox", { name: /new question/i }); + const composer = await screen.findByRole("textbox", { name: /new question/i }); await userEvent.type(composer, "quanti pazienti?"); await userEvent.click(screen.getByRole("button", { name: /send/i })); @@ -176,13 +176,15 @@ test("model selector shows the three Pi-enabled models and stores the selected p ); renderShell(); - const select = await screen.findByRole("combobox", { name: "Model" }); + await screen.findByRole("textbox", { name: /new question/i }); + await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ })); + const select = screen.getByRole("combobox", { name: "Model" }); await waitFor(() => { expect(within(select).getAllByRole("option").map((option) => option.textContent)).toEqual([ - "GLM-5.2", "DeepSeek V4 Flash", "Qwen3.6 35B A3B Local", + "Choose a model", "GLM-5.2", "DeepSeek V4 Flash", "Qwen3.6 35B A3B Local", ]); }); - await userEvent.selectOptions(select, "qwen3.6-35b-a3b"); + await userEvent.selectOptions(select, "local-qwen/qwen3.6-35b-a3b"); await waitFor(() => expect(workspacePreferences.load()).toEqual({ workspaceId: "default", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low", })); @@ -205,7 +207,7 @@ test("opens Workspace management from the right sidebar without interrupting the await userEvent.click(screen.getByRole("button", { name: "Workspace management" })); expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible(); - const dialog = screen.getByRole("dialog", { name: "Workspace management" }); + const dialog = screen.getByRole("main", { name: "Workspace management" }); const workArea = screen.getByTestId("conversation-column"); const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" }); const workspaceManagement = within(sessionNavigation).getByRole("button", { name: "Workspace management" }); @@ -214,17 +216,17 @@ test("opens Workspace management from the right sidebar without interrupting the expect(sessionNavigation).not.toContainElement(dialog); expect(screen.getByTestId("app-shell")).toHaveAttribute("data-activity-layout", "closed"); expect(workspaceManagement).toHaveAttribute("aria-current", "page"); - expect(workspaceManagement).toHaveAttribute("aria-expanded", "true"); + expect(screen.queryByRole("dialog", { name: "Workspace management" })).not.toBeInTheDocument(); expect(workspaceManagement).toHaveAttribute("data-navigation-state", "current"); expect(workspaceManagement).toHaveClass("bg-[oklch(var(--nav-active))]"); expect(newSession).toHaveAttribute("data-navigation-state", "available"); expect(newSession).not.toHaveAttribute("aria-current"); - await userEvent.click(screen.getByRole("button", { name: "Close workspace management" })); - await waitFor(() => expect(screen.queryByRole("dialog", { name: "Workspace management" })).not.toBeInTheDocument()); + await userEvent.click(screen.getByRole("button", { name: "Return to session" })); + await waitFor(() => expect(screen.queryByRole("main", { name: "Workspace management" })).not.toBeInTheDocument()); expect(newSession).toHaveAttribute("aria-current", "page"); expect(newSession).toHaveAttribute("data-navigation-state", "current"); - expect(workspaceManagement).toHaveAttribute("aria-expanded", "false"); + expect(new URLSearchParams(window.location.search).has("thoth_route")).toBe(false); expect(workspaceManagement).toHaveAttribute("data-navigation-state", "available"); }); @@ -245,10 +247,10 @@ test("does not block the shell when the DWH is unavailable at startup", async () }); -test("marks the composer as awaiting input for a pending freetext gate", () => { +test("marks the composer as awaiting input for a pending freetext gate", async () => { useSessionStore.setState({ pendingWidget: { id: "free-1", widget: "freetext", title: "Clarify" }, }); renderShell(); - expect(screen.getByRole("textbox", { name: /new question/i })).toHaveAttribute("data-awaiting-input", "true"); + expect(await screen.findByRole("textbox", { name: /new question/i })).toHaveAttribute("data-awaiting-input", "true"); }); diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 243eaac4..a4d69186 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { server } from "../test/msw"; +import { workspaceSummaryFixture } from "../test/workspace-fixtures"; import { FakeEventSource } from "../test/fakeEventSource"; import { AppShell } from "./AppShell"; import type { AuthenticatedUser } from "../api/types"; @@ -74,8 +75,8 @@ beforeEach(() => { HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false, permissions: ["session.use", "pi.manage"] }), ), http.get("/api/settings", () => HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" })), - http.get("/api/workspaces", () => HttpResponse.json([])), - http.get("/api/models", () => HttpResponse.json({ models: [] })), + http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("psd")])), + http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "zai", id: "glm-5.2", name: "Test LLM", reasoning: true }] })), http.get("/api/sessions", () => HttpResponse.json(LIST)), http.get("/api/sessions/:id", () => HttpResponse.json({ id: "s1", status: "open", phase: 1 })), http.get("/api/sessions/:id/documents", () => HttpResponse.json([ @@ -123,7 +124,7 @@ test("Pi management preserves the open session summary and the model activity ti await user.click(screen.getByRole("button", { name: "Administration" })); await user.click(screen.getByRole("button", { name: "Pi management" })); expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible(); - const dialog = screen.getByRole("dialog", { name: "Pi management" }); + const dialog = screen.getByRole("main", { name: "Pi management" }); const workArea = screen.getByTestId("conversation-column"); const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" }); const piManagement = within(sessionNavigation).getByRole("button", { name: "Pi management" }); @@ -131,27 +132,27 @@ test("Pi management preserves the open session summary and the model activity ti expect(workArea).toContainElement(dialog); expect(sessionNavigation).not.toContainElement(dialog); expect(piManagement).toHaveAttribute("aria-current", "page"); - expect(piManagement).toHaveAttribute("aria-expanded", "true"); + expect(screen.queryByRole("dialog", { name: "Pi management" })).not.toBeInTheDocument(); expect(piManagement).toHaveAttribute("data-navigation-state", "current"); expect(newSession).toHaveAttribute("data-navigation-state", "available"); expect(newSession).not.toHaveAttribute("aria-current"); - const preservedSummary = screen.getByRole("complementary", { name: "Session summary" }); - expect(preservedSummary).not.toHaveAttribute("aria-hidden", "true"); + const preservedSummary = document.querySelector('[aria-label="Session summary"]'); + expect(preservedSummary).toHaveAttribute("aria-hidden", "true"); expect(preservedSummary).toHaveTextContent("Attiva uno"); - await user.click(screen.getByRole("button", { name: "Close Pi management" })); + await user.click(screen.getByRole("button", { name: "Return to session" })); await waitFor(() => { expect(screen.getByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale"); }); expect(newSession).toHaveAttribute("aria-current", "page"); expect(newSession).toHaveAttribute("data-navigation-state", "current"); - expect(piManagement).toHaveAttribute("aria-expanded", "false"); + expect(new URLSearchParams(window.location.search).has("thoth_route")).toBe(false); expect(piManagement).toHaveAttribute("data-navigation-state", "available"); await user.click(screen.getByRole("button", { name: "Resume" })); await screen.findByRole("button", { name: "Show model activity" }); act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Preserved activity" })); await user.click(screen.getByRole("button", { name: "Pi management" })); - await user.click(await screen.findByRole("button", { name: "Close Pi management" })); + await user.click(await screen.findByRole("button", { name: "Return to session" })); await waitFor(() => expect(screen.queryByRole("heading", { name: "Pi management" })).not.toBeInTheDocument()); await user.click(screen.getByRole("button", { name: "Show model activity" })); expect(await screen.findByRole("heading", { name: "Model activity" })).toBeVisible(); diff --git a/frontend/src/shell/AppShell.session-target.test.tsx b/frontend/src/shell/AppShell.session-target.test.tsx index 6a7ff664..1a87e625 100644 --- a/frontend/src/shell/AppShell.session-target.test.tsx +++ b/frontend/src/shell/AppShell.session-target.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, expect, test, vi } from "vitest"; import { http, HttpResponse } from "msw"; import { AppShell } from "./AppShell"; import { server } from "../test/msw"; +import { workspaceSummaryFixture } from "../test/workspace-fixtures"; import { FakeEventSource } from "../test/fakeEventSource"; import { clearAuthState, setAuthState } from "../auth/authState"; import { useSessionStore } from "../store/sessionStore"; @@ -32,8 +33,8 @@ beforeEach(() => { server.use( http.get("/api/me", () => HttpResponse.json({ issuer: "local", subject: "user-a", isAdmin: false })), http.get("/api/settings", () => HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })), - http.get("/api/workspaces", () => HttpResponse.json([])), - http.get("/api/models", () => HttpResponse.json({ models: [] })), + http.get("/api/workspaces", () => HttpResponse.json([workspaceSummaryFixture("default")])), + http.get("/api/models", () => HttpResponse.json({ models: [{ provider: "test", id: "test", name: "Test LLM", reasoning: true }] })), http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })), ); }); @@ -91,7 +92,7 @@ test("a held new-session completion cannot replace the newer active s2 target", http.get("/api/sessions/:id", ({ params }) => HttpResponse.json({ id: params.id, status: "open", phase: 1 })), ); renderShell(); - const composer = screen.getByRole("textbox", { name: /new question/i }); + const composer = await screen.findByRole("textbox", { name: /new question/i }); await userEvent.type(composer, "Held new question"); await userEvent.click(screen.getByRole("button", { name: /send/i })); await createStarted.promise; diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index a21589b5..193befbb 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -20,7 +20,6 @@ import { DatabaseManagementPage } from "./DatabaseManagementPage"; import { MemoryManagementPage } from "./MemoryManagementPage"; import { EvidenceManagementPage } from "./EvidenceManagementPage"; import { ArchiveNavigation } from "./ArchiveNavigation"; -import { WorkspacePreprocessingControl } from "./WorkspacePreprocessingControl"; import { Pencil, ArrowLeft, ArrowRight, ChevronDown, Trash2 } from "lucide-react"; import { Accordion } from "@base-ui/react/accordion"; import { Button, buttonVariants } from "../components/ui/button"; @@ -37,20 +36,23 @@ import { } from "../components/ui/dialog"; import type { SessionScope, SessionSummary } from "../api/types"; import { useAuthGeneration, useAuthUser } from "../auth/authState"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useIsMutating, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, KeyboardEvent } from "react"; import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation"; import { getSettings } from "../api/settings"; -import { workspacePreferences, type WorkspacePreference } from "../workspaces/preferences"; +import { workspacePreferences } from "../workspaces/preferences"; import { getWorkspacePreprocessingStatus } from "../api/workspace-preprocessing"; +import { AdministrationNavigationProvider, useAdministrationNavigation } from "./administration/AdministrationNavigation"; +import { AdministrationHeader } from "./administration/AdministrationPage"; +import { WorkingContextProvider, useWorkingContext } from "../workspaces/WorkingContext"; +import { WorkingContextShelf } from "./WorkingContextShelf"; +import { useInteractionModelBusy } from "../models/useInteractionModelBusy"; interface AppShellProps { canLogout: boolean; } -type ActiveSurface = "core" | "database-management" | "memory-management" | "evidence-management"; -type ActiveManagementPanel = "workspace" | "pi" | null; const SESSION_SCOPES: readonly SessionScope[] = ["mine", "all"]; function sessionScopeTabClass(selected: boolean): string { @@ -89,6 +91,10 @@ export function resolveDatabaseManagementPresentation({ } export function AppShell({ canLogout }: AppShellProps) { + const workingContext = useWorkingContext(); + const interactionBusy = useInteractionModelBusy(); + const mutationBusy = useIsMutating() > 0; + const [adminBusy, setAdminBusy] = useState({ database: false, workspace: false, evidence: false, pi: false }); const authenticatedUser = useAuthUser(); const [panelSession, setPanelSession] = useState(null); const { @@ -166,18 +172,9 @@ export function AppShell({ canLogout }: AppShellProps) { queryKey: ["settings"], queryFn: getSettings, }); - const [workspacePreference, setWorkspacePreference] = useState( - () => workspacePreferences.load(), - ); - useEffect(() => { - const unsubscribe = workspacePreferences.subscribe(setWorkspacePreference); - // Child effects may seed the singleton before this parent effect subscribes. - setWorkspacePreference(workspacePreferences.load()); - return unsubscribe; - }, []); // Only an ID selected from the active Workspace Registry may address workspace-scoped APIs. // Installation settings are a legacy hint and can name a removed/non-catalog workspace. - const selectedWorkspaceId = workspacePreference.workspaceId; + const selectedWorkspaceId = workingContext.workspace?.id; const preprocessingQuery = useQuery({ queryKey: ["workspace-preprocessing", selectedWorkspaceId], queryFn: () => getWorkspacePreprocessingStatus(selectedWorkspaceId!), @@ -204,7 +201,6 @@ export function AppShell({ canLogout }: AppShellProps) { const queryClient = useQueryClient(); const [showActivity, setShowActivity] = useState(false); - const [activeSurface, setActiveSurface] = useState("core"); const databaseManagementPresentation = resolveDatabaseManagementPresentation({ isDevelopment: import.meta.env.DEV, mode: import.meta.env.MODE, @@ -214,8 +210,25 @@ export function AppShell({ canLogout }: AppShellProps) { const databaseNavigationRef = useRef({ dirty: false, busy: false }); const updateDatabaseNavigationState = useCallback((state: { dirty: boolean; busy: boolean }) => { databaseNavigationRef.current = state; + setAdminBusy(previous => previous.database === state.busy ? previous : { ...previous, database: state.busy }); }, []); - const [activeManagementPanel, setActiveManagementPanel] = useState(null); + const workspaceNavigationRef = useRef({ dirty: false, busy: false }); + const updateWorkspaceNavigationState = useCallback((state: { dirty: boolean; busy: boolean }) => { + workspaceNavigationRef.current = state; + setAdminBusy(previous => previous.workspace === state.busy ? previous : { ...previous, workspace: state.busy }); + }, []); + const { route, navigate } = useAdministrationNavigation(canLeaveDatabaseManagement, () => { + if (activeSurface === "memory-management") return memoryDirty || memoryBusy; + const state = activeSurface === "database-management" ? databaseNavigationRef.current + : activeSurface === "workspace-management" ? workspaceNavigationRef.current : null; + return Boolean(state && (state.dirty || state.busy)); + }); + const activeSurface = route.surface; + const [visited, setVisited] = useState>(() => new Set([activeSurface])); + useEffect(() => { setVisited(previous => previous.has(activeSurface) ? previous : new Set([...previous, activeSurface])); }, [activeSurface]); + const administrationBusy = mutationBusy || memoryBusy || Object.values(adminBusy).some(Boolean); + const contextLocked = interactionBusy || creatingSession || administrationBusy + || preprocessingQuery.data?.state === "running"; const [adminNavigationValue, setAdminNavigationValue] = useState([]); const [activeOpen, setActiveOpen] = useState(true); const [archiveOpen, setArchiveOpen] = useState(false); @@ -306,30 +319,22 @@ export function AppShell({ canLogout }: AppShellProps) { } function canLeaveDatabaseManagement(): boolean { - if (activeSurface === "memory-management" && memoryBusy) { - toast.info("Wait for the Memory operation to finish before leaving this page"); + const dirty = activeSurface === "workspace-management" ? workspaceNavigationRef.current.dirty + : activeSurface === "memory-management" ? memoryDirty + : activeSurface === "database-management" ? databaseNavigationRef.current.dirty : false; + if (dirty) { + toast.warning("Save your administration changes, or cancel the edit, before leaving this page."); return false; } - if (activeSurface === "memory-management" && memoryDirty) { - return window.confirm("Discard unsaved Memory changes and leave Memory management?"); - } - if (activeSurface !== "database-management") return true; - if (databaseNavigationRef.current.busy) { - toast.info("Wait for the database operation to finish before leaving this page"); - return false; - } - if (databaseNavigationRef.current.dirty) { - return window.confirm("Discard unsaved database changes and leave database management?"); - } return true; } function openPanel(id: string) { + if (!workingContext.ready) return; if (!canLeaveDatabaseManagement()) return; const s = sessions.find((x) => x.id === id); if (!s) return; - setActiveSurface("core"); - setActiveManagementPanel(null); + navigate({ surface: "core" }, true); // A session with a live Pi runtime opens straight into its live view: doResume // reconnects to the already-active runtime and replays its pending gate, so an // in-progress session never shows an empty screen that reads as "stopped". Cold or @@ -349,9 +354,9 @@ export function AppShell({ canLogout }: AppShellProps) { }); } async function doResume(id: string, databaseExitApproved = false) { + if (!workingContext.ready) return; if (!databaseExitApproved && !canLeaveDatabaseManagement()) return; - setActiveSurface("core"); - setActiveManagementPanel(null); + navigate({ surface: "core" }, true); const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current }); if (!guard) return; const token = ++resumeInvocationRef.current; @@ -388,6 +393,9 @@ export function AppShell({ canLogout }: AppShellProps) { if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return; const latest = latestResumeIntentRef.current; if (latest?.token !== operation.latestToken || latest.id !== id) return; + // Bind context atomically with Resume, before displaying any session or Admin actions. + // Older servers omit this field; their manifest remains the compatibility fallback. + if (result.workspaceId) workspacePreferences.selectWorkspace(result.workspaceId); const reconnectSameSession = activeSessionIdRef.current === id; setPanelSession(null); setAwaitingQuestion(false); @@ -417,7 +425,7 @@ export function AppShell({ canLogout }: AppShellProps) { // Paint the persisted re-entry phase while the replacement stream starts replaying. // The manifest's `phase` is the 1-based current phase (1..8). try { - const m = (await getSession(id)) as { phase?: number }; + const m = (await getSession(id)) as { phase?: number; workspace_id?: string }; if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return; const latestAfterManifest = latestResumeIntentRef.current; if ( @@ -425,6 +433,7 @@ export function AppShell({ canLogout }: AppShellProps) { || latestAfterManifest.id !== id || activeSessionIdRef.current !== id ) return; + if (!result.workspaceId && m.workspace_id) workspacePreferences.selectWorkspace(m.workspace_id); if (typeof m.phase === "number" && m.phase >= 1 && m.phase <= 8) { setPhase(`F${m.phase}`); } @@ -557,7 +566,7 @@ export function AppShell({ canLogout }: AppShellProps) { session={s} groups={groups} onResume={() => doResume(s.id)} - onView={() => setPanelSession(s)} + onView={() => { if (canLeaveDatabaseManagement()) { navigate({ surface: "core" }, true); setPanelSession(s); } }} onRename={() => setRenameTarget(s)} onMove={(g) => move(s, g)} onNewGroup={() => newGroup(s)} @@ -633,13 +642,13 @@ export function AppShell({ canLogout }: AppShellProps) { }, [lastSystemEvent]); function startNewSession() { + if (!workingContext.ready || administrationBusy) return; if (preprocessingBlocksNewSession) { toast.warning(preprocessingQuery.data?.detail ?? "Workspace preprocessing is required."); return; } if (!canLeaveDatabaseManagement()) return; - setActiveSurface("core"); - setActiveManagementPanel(null); + navigate({ surface: "core" }, true); invalidateResumeIntent(); newSessionOperationRef.current = null; resetSession(); @@ -707,13 +716,35 @@ export function AppShell({ canLogout }: AppShellProps) { await logoutUser(); } - const currentNavigation = activeSurface === "database-management" - ? "database" - : activeSurface === "memory-management" ? "memory" : activeSurface === "evidence-management" ? "evidence" : activeManagementPanel ?? "core"; + const currentNavigation = activeSurface.replace("-management", ""); + const administrationPermitted = activeSurface === "core" || (isAdmin && ( + activeSurface === "workspace-management" || + (activeSurface === "database-management" && canManageDatabase) || + (activeSurface === "memory-management" && canManageMemory) || + (activeSurface === "evidence-management" && canManageEvidence) || + (activeSurface === "pi-management" && canManagePi) + )); const adminNavigationOpen = adminNavigationValue.includes("administration"); const managementNavigationCurrent = currentNavigation !== "core"; + function changeContextWorkspace(id: string) { + if (contextLocked || !canLeaveDatabaseManagement() || id === workingContext.workspaceId) return; + invalidateResumeIntent(); + setPanelSession(null); + selectActiveSession(null); + resetSession(); + setAwaitingQuestion(false); + workspacePreferences.selectWorkspace(id); + } + function changeContextModel(id: string) { + if (!contextLocked && canLeaveDatabaseManagement()) workspacePreferences.selectModel(id); + } + return ( + + +
+
@@ -748,7 +778,7 @@ export function AppShell({ canLogout }: AppShellProps) {
)} - {showActivity && setShowActivity(false)} onOpenWorkspaceManager={() => setActiveManagementPanel("workspace")} hidden={activeSurface !== "core"} />} + {showActivity && setShowActivity(false)} onOpenWorkspaceManager={() => navigate({ surface: "workspace-management" })} hidden={activeSurface !== "core"} />} {activeSurface === "core" && showActivity && desktopSplit && (
- {activeSurface === "memory-management" && ( - + {!workingContext.ready &&
} + {!administrationPermitted &&
+ +
} + {workingContext.ready && isAdmin && canManageMemory && (activeSurface === "memory-management" || visited.has("memory-management")) && ( +
)} - {activeSurface === "evidence-management" && } - {activeSurface === "database-management" && ( + {workingContext.ready && isAdmin && canManageEvidence && (activeSurface === "evidence-management" || visited.has("evidence-management")) &&
} + {workingContext.ready && isAdmin && canManageDatabase && (activeSurface === "database-management" || visited.has("database-management")) && ( +
+
)}
{activeSessionId && (
@@ -857,23 +895,29 @@ export function AppShell({ canLogout }: AppShellProps) {
- setActiveManagementPanel(null)} +
navigate({ surface: "core" })} + onNavigationStateChange={updateWorkspaceNavigationState} canManageWorkspace={canManageWorkspace} canManageSecrets={canManageWorkspaceSecrets} - /> - {canManagePi && ( - setActiveManagementPanel(null)} - /> + canPreprocess={canManageDatabase} + />
+ {isAdmin && canManagePi && ( +
navigate({ surface: "core" })} + />
)} {/* Right session rail */} - {(activeSurface !== "core" || !showActivity) && ( - + {( +