mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Tighten app-server lifecycle and settings boundaries
This commit is contained in:
parent
2de0dc8399
commit
b7e024ada0
21 changed files with 324 additions and 179 deletions
|
|
@ -1,5 +1,11 @@
|
|||
import type { AppServerClient } from "./client";
|
||||
|
||||
export interface AppServerClientAccess {
|
||||
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options?: { unhandledServerRequestMessage?: string }): Promise<T>;
|
||||
export type AppServerClientRequestPolicy = { kind: "interactive" } | { kind: "reject"; message: string };
|
||||
|
||||
export interface AppServerClientAccessOptions {
|
||||
serverRequests?: AppServerClientRequestPolicy;
|
||||
}
|
||||
|
||||
export interface AppServerClientAccess {
|
||||
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options?: AppServerClientAccessOptions): Promise<T>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,8 +203,8 @@ export class AppServerClient {
|
|||
}
|
||||
|
||||
async connect(): Promise<InitializeResponse> {
|
||||
if (this.activeTransport()?.isRunning()) {
|
||||
throw new Error("Codex app-server is already running.");
|
||||
if (this.lifecycle.kind !== "disconnected") {
|
||||
throw new Error("Codex app-server client is already connecting or connected.");
|
||||
}
|
||||
|
||||
const transportRef: { current: AppServerTransport | null } = { current: null };
|
||||
|
|
@ -232,9 +232,10 @@ export class AppServerClient {
|
|||
if (!transport) return;
|
||||
if (!this.isActiveTransport(transport)) return;
|
||||
const intentional = this.intentionallyStoppedTransports.has(transport);
|
||||
const wasInitialized = this.lifecycle.kind === "initialized";
|
||||
this.lifecycle = { kind: "disconnected" };
|
||||
this.rpc.rejectAll(new Error(`Codex app-server exited: ${String(code ?? signal ?? "unknown")}`));
|
||||
if (intentional) return;
|
||||
if (intentional || !wasInitialized) return;
|
||||
this.handlers.onExit(code, signal);
|
||||
},
|
||||
};
|
||||
|
|
@ -243,22 +244,32 @@ export class AppServerClient {
|
|||
: new StdioAppServerTransport(this.codexPath, this.cwd, transportHandlers);
|
||||
transportRef.current = transport;
|
||||
this.lifecycle = { kind: "starting", transport };
|
||||
transport.start();
|
||||
|
||||
const init = await this.request("initialize", {
|
||||
clientInfo: {
|
||||
name: "obsidian_codex_panel",
|
||||
title: "Codex Panel",
|
||||
version: CLIENT_VERSION,
|
||||
},
|
||||
capabilities: {
|
||||
experimentalApi: true,
|
||||
requestAttestation: false,
|
||||
},
|
||||
});
|
||||
this.notify({ method: "initialized" });
|
||||
this.lifecycle = { kind: "initialized", transport, initializeResponse: init };
|
||||
return init;
|
||||
try {
|
||||
transport.start();
|
||||
const init = await this.request("initialize", {
|
||||
clientInfo: {
|
||||
name: "obsidian_codex_panel",
|
||||
title: "Codex Panel",
|
||||
version: CLIENT_VERSION,
|
||||
},
|
||||
capabilities: {
|
||||
experimentalApi: true,
|
||||
requestAttestation: false,
|
||||
},
|
||||
});
|
||||
this.notify({ method: "initialized" });
|
||||
this.lifecycle = { kind: "initialized", transport, initializeResponse: init };
|
||||
return init;
|
||||
} catch (error) {
|
||||
if (this.isActiveTransport(transport)) {
|
||||
const normalized = error instanceof Error ? error : new Error(String(error));
|
||||
this.lifecycle = { kind: "disconnected" };
|
||||
this.rpc.rejectAll(normalized);
|
||||
this.intentionallyStoppedTransports.add(transport);
|
||||
transport.stop();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import { AppServerClient } from "./client";
|
||||
|
||||
interface ShortLivedAppServerClientOptions {
|
||||
unhandledServerRequestMessage?: string;
|
||||
}
|
||||
import type { AppServerClientAccessOptions } from "./client-access";
|
||||
|
||||
export async function withShortLivedAppServerClient<T>(
|
||||
codexPath: string,
|
||||
cwd: string,
|
||||
operation: (client: AppServerClient) => Promise<T>,
|
||||
options: ShortLivedAppServerClientOptions = {},
|
||||
options: AppServerClientAccessOptions = {},
|
||||
): Promise<T> {
|
||||
let client!: AppServerClient;
|
||||
client = new AppServerClient(codexPath, cwd, {
|
||||
|
|
@ -17,7 +14,9 @@ export async function withShortLivedAppServerClient<T>(
|
|||
client.rejectServerRequest(
|
||||
request.id,
|
||||
-32601,
|
||||
options.unhandledServerRequestMessage ?? "This Codex Panel view does not handle server requests.",
|
||||
options.serverRequests?.kind === "reject"
|
||||
? options.serverRequests.message
|
||||
: "This Codex Panel view does not handle server requests.",
|
||||
);
|
||||
},
|
||||
onLog: () => undefined,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { QueryClient, QueryObserver, type QueryObserverResult } from "@tanstack/query-core";
|
||||
|
||||
import type { AppServerClient } from "../connection/client";
|
||||
import type { AppServerClientAccessOptions } from "../connection/client-access";
|
||||
import { listModelMetadata } from "../catalog";
|
||||
import { readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
|
||||
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
|
||||
|
|
@ -29,7 +30,7 @@ export interface AppServerQueryClientRunner {
|
|||
runWithClient<T>(
|
||||
context: AppServerQueryContext,
|
||||
operation: (client: AppServerClient) => Promise<T>,
|
||||
options?: { unhandledServerRequestMessage?: string },
|
||||
options?: AppServerClientAccessOptions,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
|
|
@ -386,7 +387,7 @@ export class AppServerQueryCache {
|
|||
queryFn: async (): Promise<readonly ModelMetadata[]> => {
|
||||
return cloneModelMetadata(
|
||||
await this.runWithClient(refreshContext, (client) => listModelMetadata(client), {
|
||||
unhandledServerRequestMessage: "Codex model list refresh does not handle server requests.",
|
||||
serverRequests: { kind: "reject", message: "Codex model list refresh does not handle server requests." },
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
@ -452,7 +453,7 @@ export class AppServerQueryCache {
|
|||
private runWithClient<T>(
|
||||
context: AppServerQueryContext,
|
||||
operation: (client: AppServerClient) => Promise<T>,
|
||||
options: { unhandledServerRequestMessage?: string } = {},
|
||||
options: AppServerClientAccessOptions = {},
|
||||
): Promise<T> {
|
||||
if (!this.clientRunner) {
|
||||
throw new Error("Codex app-server query client runner is not configured.");
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
type AppServerStartEphemeralThreadOptions,
|
||||
type AppServerStartStructuredTurnOptions,
|
||||
} from "../connection/client";
|
||||
import type { AppServerClientRequestPolicy } from "../connection/client-access";
|
||||
import type { RequestId, ServerNotification } from "../connection/rpc-messages";
|
||||
import type { ModelMetadataClient } from "../catalog";
|
||||
import { lastAgentMessageTextFromTurnRecord, type TurnItem, type TurnRecord } from "../protocol/turn";
|
||||
|
|
@ -51,7 +52,7 @@ export interface RunEphemeralStructuredTurnOptions {
|
|||
prompt: string;
|
||||
outputSchema: StructuredTurnOutputSchema;
|
||||
timeoutMs: number;
|
||||
unhandledServerRequestMessage: string;
|
||||
serverRequests: Extract<AppServerClientRequestPolicy, { kind: "reject" }>;
|
||||
exitedMessage: string;
|
||||
timedOutMessage: string;
|
||||
abortMessage?: string;
|
||||
|
|
@ -105,7 +106,7 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
|
|||
handleNotification(notification);
|
||||
},
|
||||
onServerRequest: (request) => {
|
||||
client.rejectServerRequest(request.id, -32601, options.unhandledServerRequestMessage);
|
||||
client.rejectServerRequest(request.id, -32601, options.serverRequests.message);
|
||||
},
|
||||
onLog: () => undefined,
|
||||
onExit: () => {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export async function generateThreadTitleWithCodex(
|
|||
prompt: threadTitlePrompt(context),
|
||||
outputSchema: TITLE_OUTPUT_SCHEMA,
|
||||
timeoutMs: THREAD_TITLE_TIMEOUT_MS,
|
||||
unhandledServerRequestMessage: "Thread title generation does not handle server requests.",
|
||||
serverRequests: { kind: "reject", message: "Thread title generation does not handle server requests." },
|
||||
exitedMessage: "Codex title generation app-server exited.",
|
||||
timedOutMessage: "Timed out while generating a Codex thread title.",
|
||||
resolveRuntime: (client) => threadTitleRuntimeOverrideForClient(client, runtimeSettings),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { toolInventoryAppsFromAppInfos, toolInventoryPluginsFromInstalledRespons
|
|||
|
||||
const APP_PAGE_LIMIT = 100;
|
||||
const APP_PAGE_LOOP_LIMIT = 20;
|
||||
const PLUGIN_DETAILS_CONCURRENCY = 4;
|
||||
|
||||
export interface ReadToolInventoryOptions {
|
||||
readonly threadId?: string | null;
|
||||
|
|
@ -101,6 +102,7 @@ async function listAllApps(
|
|||
threadId: string | null,
|
||||
): Promise<Awaited<ReturnType<AppServerClient["listApps"]>>["data"]> {
|
||||
let cursor: string | null = null;
|
||||
const seenCursors = new Set<string>();
|
||||
const apps: Awaited<ReturnType<AppServerClient["listApps"]>>["data"] = [];
|
||||
for (let page = 0; page < APP_PAGE_LOOP_LIMIT; page += 1) {
|
||||
const response = await client.listApps({
|
||||
|
|
@ -111,8 +113,12 @@ async function listAllApps(
|
|||
apps.push(...response.data);
|
||||
cursor = response.nextCursor;
|
||||
if (!cursor) return apps;
|
||||
if (seenCursors.has(cursor)) {
|
||||
throw new Error("Codex app-server returned a repeated app list cursor.");
|
||||
}
|
||||
seenCursors.add(cursor);
|
||||
}
|
||||
return apps;
|
||||
throw new Error("Codex app-server returned too many app list pages.");
|
||||
}
|
||||
|
||||
async function readPlugins(
|
||||
|
|
@ -128,7 +134,7 @@ async function readPlugins(
|
|||
try {
|
||||
const response = await client.listInstalledPlugins(cwd);
|
||||
const { plugins, marketplaceErrors } = toolInventoryPluginsFromInstalledResponse(response);
|
||||
const pluginsWithDetails = await Promise.all(plugins.map((plugin) => readPluginDetails(client, plugin)));
|
||||
const pluginsWithDetails = await mapLimit(plugins, PLUGIN_DETAILS_CONCURRENCY, (plugin) => readPluginDetails(client, plugin));
|
||||
return {
|
||||
data: pluginsWithDetails,
|
||||
marketplaceErrors,
|
||||
|
|
@ -145,6 +151,24 @@ async function readPlugins(
|
|||
}
|
||||
}
|
||||
|
||||
async function mapLimit<T, R>(items: readonly T[], concurrency: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
for (;;) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await fn(items[index] as T, index);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(Math.max(concurrency, 1), items.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function readPluginDetails(client: AppServerClient, plugin: ToolInventoryPlugin): Promise<ToolInventoryPlugin> {
|
||||
if (!isUsablePlugin(plugin)) return plugin;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export async function runSelectionRewrite(options: RunSelectionRewriteOptions):
|
|||
prompt: options.prompt,
|
||||
outputSchema: SELECTION_REWRITE_OUTPUT_SCHEMA,
|
||||
timeoutMs: SELECTION_REWRITE_TIMEOUT_MS,
|
||||
unhandledServerRequestMessage: "Selection rewrite does not handle server requests.",
|
||||
serverRequests: { kind: "reject", message: "Selection rewrite does not handle server requests." },
|
||||
exitedMessage: "Selection rewrite app-server exited.",
|
||||
timedOutMessage: "Timed out while rewriting the selection.",
|
||||
abortMessage: "Selection rewrite cancelled.",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants"
|
|||
import { AppServerQueryCache } from "./app-server/query/cache";
|
||||
import { AppServerSharedQueries } from "./app-server/query/shared-queries";
|
||||
import type { AppServerClient } from "./app-server/connection/client";
|
||||
import type { AppServerClientAccess } from "./app-server/connection/client-access";
|
||||
import type { AppServerClientAccess, AppServerClientAccessOptions } from "./app-server/connection/client-access";
|
||||
import { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client";
|
||||
import { appServerQueryContextIsComplete, type AppServerQueryContext } from "./app-server/query/keys";
|
||||
import type { ChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
|
||||
|
|
@ -169,7 +169,7 @@ export class CodexPanelRuntime implements AppServerClientAccess {
|
|||
};
|
||||
}
|
||||
|
||||
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options: { unhandledServerRequestMessage?: string } = {}): Promise<T> {
|
||||
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options: AppServerClientAccessOptions = {}): Promise<T> {
|
||||
return this.runWithAppServerClient(this.appServerQueryContext(), operation, options);
|
||||
}
|
||||
|
||||
|
|
@ -224,12 +224,12 @@ export class CodexPanelRuntime implements AppServerClientAccess {
|
|||
private async runWithAppServerClient<T>(
|
||||
context: AppServerQueryContext,
|
||||
operation: (client: AppServerClient) => Promise<T>,
|
||||
options: { unhandledServerRequestMessage?: string } = {},
|
||||
options: AppServerClientAccessOptions = {},
|
||||
): Promise<T> {
|
||||
if (!appServerQueryContextIsComplete(context)) {
|
||||
throw new Error("Codex app-server query context is incomplete.");
|
||||
}
|
||||
if (options.unhandledServerRequestMessage !== undefined) {
|
||||
if (options.serverRequests?.kind === "reject") {
|
||||
return withShortLivedAppServerClient(context.codexPath, context.vaultPath, operation, options);
|
||||
}
|
||||
const chatSurface = this.connectedClientSurface();
|
||||
|
|
|
|||
|
|
@ -2,18 +2,10 @@ import type { ComponentChild as UiNode } from "preact";
|
|||
|
||||
import type { Thread } from "../domain/threads/model";
|
||||
import { threadArchiveDisplayTitle } from "../domain/threads/title";
|
||||
import { ObsidianExtraButton, ObsidianTextInput, ObsidianToggle } from "../shared/ui/components";
|
||||
import { shortThreadId } from "../utils";
|
||||
import type { ArchivedThreadSectionState } from "./section-state";
|
||||
import {
|
||||
SettingRow,
|
||||
SettingsGroup,
|
||||
SettingsHeading,
|
||||
SettingsIconButton,
|
||||
SettingsItems,
|
||||
SettingsStatusRow,
|
||||
TextControl,
|
||||
ToggleControl,
|
||||
} from "./setting-components";
|
||||
import { SettingRow, SettingsGroup, SettingsHeading, SettingsItems, SettingsStatusRow } from "./setting-components";
|
||||
|
||||
export function ArchivedThreadSection({ state }: { state: ArchivedThreadSectionState }): UiNode {
|
||||
return (
|
||||
|
|
@ -38,7 +30,7 @@ function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState })
|
|||
return (
|
||||
<SettingsItems>
|
||||
<SettingRow name="Save note by default" desc="Save a Markdown note during the default archive action.">
|
||||
<ToggleControl
|
||||
<ObsidianToggle
|
||||
checked={state.exportEnabled}
|
||||
onChange={(checked) => {
|
||||
state.onExportEnabledChange(checked);
|
||||
|
|
@ -46,7 +38,7 @@ function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState })
|
|||
/>
|
||||
</SettingRow>
|
||||
<SettingRow name="Saved note folder" desc="Vault folder for saved thread notes.">
|
||||
<TextControl
|
||||
<ObsidianTextInput
|
||||
placeholder="Codex archives"
|
||||
value={state.exportFolderTemplate}
|
||||
onChange={(value) => {
|
||||
|
|
@ -55,7 +47,7 @@ function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState })
|
|||
/>
|
||||
</SettingRow>
|
||||
<SettingRow name="Saved note filename" desc="Filename template. Supports {{date}}, {{time}}, {{title}}, {{id}}, and {{shortId}}.">
|
||||
<TextControl
|
||||
<ObsidianTextInput
|
||||
placeholder="{{date}} {{time}} {{title}} {{shortId}}.md"
|
||||
value={state.exportFilenameTemplate}
|
||||
onChange={(value) => {
|
||||
|
|
@ -64,7 +56,7 @@ function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState })
|
|||
/>
|
||||
</SettingRow>
|
||||
<SettingRow name="Saved note tags" desc="Tags added to saved notes, separated by commas.">
|
||||
<TextControl
|
||||
<ObsidianTextInput
|
||||
placeholder="Codex, archive"
|
||||
value={state.exportTags}
|
||||
onChange={(value) => {
|
||||
|
|
@ -104,7 +96,7 @@ function ArchivedThreadRow({ thread, state }: { thread: Thread; state: ArchivedT
|
|||
}
|
||||
>
|
||||
{!deleteConfirming ? (
|
||||
<SettingsIconButton
|
||||
<ObsidianExtraButton
|
||||
icon="rotate-ccw"
|
||||
label="Restore thread"
|
||||
className="codex-panel-settings__archived-restore"
|
||||
|
|
@ -113,7 +105,7 @@ function ArchivedThreadRow({ thread, state }: { thread: Thread; state: ArchivedT
|
|||
}}
|
||||
/>
|
||||
) : null}
|
||||
<SettingsIconButton
|
||||
<ObsidianExtraButton
|
||||
icon={deleteConfirming ? "check" : "shredder"}
|
||||
label="Delete thread"
|
||||
className={deleteConfirming ? "codex-panel-settings__archived-delete-confirm" : "codex-panel-settings__archived-delete"}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@ import { loadHookData } from "./app-server-data";
|
|||
import type { SettingsDynamicDataHost } from "./host";
|
||||
import {
|
||||
createSettingsDynamicSectionLifecycle,
|
||||
transitionSettingsDataRefreshLifecycle,
|
||||
transitionSettingsDynamicSectionLifecycle,
|
||||
type SettingsDataRefreshLifecycleState,
|
||||
type SettingsDynamicSectionLifecycleState,
|
||||
} from "./lifecycle";
|
||||
|
||||
|
|
@ -49,7 +47,6 @@ export class SettingsDynamicDataController {
|
|||
private modelsOperationToken = 0;
|
||||
private hooksOperationToken = 0;
|
||||
private archivedThreadsOperationToken = 0;
|
||||
private settingsDataRefreshLifecycle: SettingsDataRefreshLifecycleState = { kind: "idle" };
|
||||
|
||||
private archivedThreads: Thread[] = [];
|
||||
private archivedThreadsLoaded = false;
|
||||
|
|
@ -108,7 +105,6 @@ export class SettingsDynamicDataController {
|
|||
this.modelsOperationToken += 1;
|
||||
this.hooksOperationToken += 1;
|
||||
this.archivedThreadsOperationToken += 1;
|
||||
this.settingsDataRefreshLifecycle = { kind: "idle" };
|
||||
this.models = [...(this.host.appServerData.modelsSnapshot() ?? [])];
|
||||
this.modelsLifecycle = createSettingsDynamicSectionLifecycle();
|
||||
this.hooks = [];
|
||||
|
|
@ -156,10 +152,6 @@ export class SettingsDynamicDataController {
|
|||
const modelsOperationToken = this.nextModelsOperationToken();
|
||||
const hooksOperationToken = this.nextHooksOperationToken();
|
||||
const archivedThreadsOperationToken = this.nextArchivedThreadsOperationToken();
|
||||
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, {
|
||||
type: "started",
|
||||
operationToken,
|
||||
});
|
||||
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
|
||||
type: "started",
|
||||
status: "Loading models...",
|
||||
|
|
@ -274,11 +266,6 @@ export class SettingsDynamicDataController {
|
|||
}
|
||||
} finally {
|
||||
const staleRefresh = this.isStaleSettingsRefreshOperation(operationToken);
|
||||
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, {
|
||||
type: "completed",
|
||||
failedCount,
|
||||
operationToken,
|
||||
});
|
||||
if (!staleRefresh) {
|
||||
if (failedCount > 0) {
|
||||
this.callbacks.notify("Could not refresh all Codex data.");
|
||||
|
|
@ -289,7 +276,9 @@ export class SettingsDynamicDataController {
|
|||
}
|
||||
|
||||
settingsDataLoading(): boolean {
|
||||
return this.settingsDataRefreshLifecycle.kind === "loading";
|
||||
return (
|
||||
this.modelsLifecycle.kind === "loading" || this.hooksLifecycle.kind === "loading" || this.archivedThreadsLifecycle.kind === "loading"
|
||||
);
|
||||
}
|
||||
|
||||
snapshot(): SettingsDynamicDataSnapshot {
|
||||
|
|
@ -475,7 +464,7 @@ export class SettingsDynamicDataController {
|
|||
|
||||
private async withSettingsConnection<T>(operation: (client: AppServerClient) => Promise<T>): Promise<T> {
|
||||
return this.host.clientAccess.withClient(operation, {
|
||||
unhandledServerRequestMessage: "Codex Panel settings does not handle server requests.",
|
||||
serverRequests: { kind: "reject", message: "Codex Panel settings does not handle server requests." },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import type { ComponentChild as UiNode } from "preact";
|
|||
|
||||
import type { ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata";
|
||||
import { findModelMetadataByIdOrName, supportedEffortsForModelMetadata } from "../domain/catalog/metadata";
|
||||
import { ObsidianDropdown } from "../shared/ui/components";
|
||||
import type { HelperSettingsState } from "./section-state";
|
||||
import { SelectControl, SettingRow, SettingsGroup, SettingsHeading, SettingsItems } from "./setting-components";
|
||||
import { SettingRow, SettingsGroup, SettingsHeading, SettingsItems } from "./setting-components";
|
||||
|
||||
const CODEX_DEFAULT_VALUE = "__codex-default__";
|
||||
|
||||
|
|
@ -56,14 +57,14 @@ function ModelEffortSetting({
|
|||
const efforts = reasoningEffortsForSelectedModel(models, modelValue);
|
||||
return (
|
||||
<SettingRow name={name} desc={desc}>
|
||||
<SelectControl
|
||||
<ObsidianDropdown
|
||||
value={modelValue ?? CODEX_DEFAULT_VALUE}
|
||||
onChange={(value) => {
|
||||
onModelChange(value === CODEX_DEFAULT_VALUE ? null : value);
|
||||
}}
|
||||
options={modelSelectOptions(models, modelValue)}
|
||||
/>
|
||||
<SelectControl
|
||||
<ObsidianDropdown
|
||||
value={effortValue ?? CODEX_DEFAULT_VALUE}
|
||||
onChange={(value) => {
|
||||
onEffortChange(value === CODEX_DEFAULT_VALUE ? null : value);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,3 @@
|
|||
export type SettingsDataRefreshLifecycleState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading"; operationToken: number }
|
||||
| { kind: "completed"; failedCount: number; operationToken: number };
|
||||
|
||||
export type SettingsDataRefreshLifecycleEvent =
|
||||
| { type: "started"; operationToken: number }
|
||||
| { type: "completed"; failedCount: number; operationToken: number };
|
||||
|
||||
export type SettingsDynamicSectionLifecycleState =
|
||||
| { kind: "idle"; status: "" }
|
||||
| { kind: "loading"; status: string; operationToken: number }
|
||||
|
|
@ -19,20 +10,6 @@ export type SettingsDynamicSectionLifecycleEvent =
|
|||
| { type: "failed"; status: string; operationToken: number }
|
||||
| { type: "reset" };
|
||||
|
||||
export function transitionSettingsDataRefreshLifecycle(
|
||||
state: SettingsDataRefreshLifecycleState,
|
||||
event: SettingsDataRefreshLifecycleEvent,
|
||||
): SettingsDataRefreshLifecycleState {
|
||||
switch (event.type) {
|
||||
case "started":
|
||||
if (isStaleSettingsDataRefreshEvent(state, event.operationToken)) return state;
|
||||
return { kind: "loading", operationToken: event.operationToken };
|
||||
case "completed":
|
||||
if (isStaleSettingsDataRefreshEvent(state, event.operationToken)) return state;
|
||||
return { kind: "completed", failedCount: event.failedCount, operationToken: event.operationToken };
|
||||
}
|
||||
}
|
||||
|
||||
export function createSettingsDynamicSectionLifecycle(): SettingsDynamicSectionLifecycleState {
|
||||
return { kind: "idle", status: "" };
|
||||
}
|
||||
|
|
@ -59,7 +36,3 @@ export function transitionSettingsDynamicSectionLifecycle(
|
|||
function isStaleSettingsDynamicSectionEvent(state: SettingsDynamicSectionLifecycleState, operationToken: number): boolean {
|
||||
return "operationToken" in state && state.operationToken > operationToken;
|
||||
}
|
||||
|
||||
function isStaleSettingsDataRefreshEvent(state: SettingsDataRefreshLifecycleState, operationToken: number): boolean {
|
||||
return "operationToken" in state && state.operationToken > operationToken;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
import type { ComponentChild as UiNode } from "preact";
|
||||
|
||||
import {
|
||||
ObsidianDropdown,
|
||||
type ObsidianDropdownOption,
|
||||
ObsidianExtraButton,
|
||||
ObsidianTextInput,
|
||||
ObsidianToggle,
|
||||
} from "../shared/ui/components";
|
||||
|
||||
export function SettingsGroup({ className, children }: { className: string; children: UiNode }): UiNode {
|
||||
return <section className={`setting-group ${className}`}>{children}</section>;
|
||||
}
|
||||
|
|
@ -62,45 +54,3 @@ export function SettingRow({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsIconButton({
|
||||
icon,
|
||||
label,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
className: string;
|
||||
onClick: () => void;
|
||||
}): UiNode {
|
||||
return <ObsidianExtraButton icon={icon} label={label} className={className} onClick={onClick} />;
|
||||
}
|
||||
|
||||
export function SelectControl({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: readonly ObsidianDropdownOption[];
|
||||
}): UiNode {
|
||||
return <ObsidianDropdown value={value} options={options} onChange={onChange} />;
|
||||
}
|
||||
|
||||
export function TextControl({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
}): UiNode {
|
||||
return <ObsidianTextInput value={value} placeholder={placeholder} onChange={onChange} />;
|
||||
}
|
||||
|
||||
export function ToggleControl({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }): UiNode {
|
||||
return <ObsidianToggle checked={checked} onChange={onChange} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -285,6 +285,114 @@ describe("AppServerClient", () => {
|
|||
expect(onExit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("cleans up the active transport when initialize fails", async () => {
|
||||
const transports: FakeTransport[] = [];
|
||||
const onExit = vi.fn();
|
||||
const client = new AppServerClient(
|
||||
"/bin/codex",
|
||||
"/vault",
|
||||
{
|
||||
onNotification: () => undefined,
|
||||
onServerRequest: () => undefined,
|
||||
onLog: () => undefined,
|
||||
onExit,
|
||||
},
|
||||
500,
|
||||
(handlers) => {
|
||||
const transport = new FakeTransport(handlers);
|
||||
transports.push(transport);
|
||||
return transport;
|
||||
},
|
||||
);
|
||||
|
||||
const connecting = client.connect();
|
||||
const firstTransport = expectTransport(transports[0]);
|
||||
const initialize = latestSent(firstTransport);
|
||||
if (!("id" in initialize) || typeof initialize.id !== "number") throw new Error("Expected initialize request.");
|
||||
|
||||
const rejection = expect(connecting).rejects.toThrow("initialize failed");
|
||||
firstTransport.emitLine({ id: initialize.id, error: { code: -32000, message: "initialize failed" } });
|
||||
|
||||
await rejection;
|
||||
expect(client.isConnected()).toBe(false);
|
||||
expect(firstTransport.isRunning()).toBe(false);
|
||||
|
||||
firstTransport.emitExit(1);
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
|
||||
const reconnecting = client.connect();
|
||||
const secondTransport = expectTransport(transports[1]);
|
||||
const secondInitialize = latestSent(secondTransport);
|
||||
if (!("id" in secondInitialize) || typeof secondInitialize.id !== "number") throw new Error("Expected initialize request.");
|
||||
secondTransport.emitLine({ id: secondInitialize.id, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
|
||||
|
||||
await expect(reconnecting).resolves.toMatchObject({ codexHome: "/tmp/codex" });
|
||||
});
|
||||
|
||||
it("cleans up the active transport when initialize times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("window", {
|
||||
clearTimeout,
|
||||
setTimeout,
|
||||
});
|
||||
let transport!: FakeTransport;
|
||||
const onExit = vi.fn();
|
||||
const client = new AppServerClient(
|
||||
"/bin/codex",
|
||||
"/vault",
|
||||
{
|
||||
onNotification: () => undefined,
|
||||
onServerRequest: () => undefined,
|
||||
onLog: () => undefined,
|
||||
onExit,
|
||||
},
|
||||
500,
|
||||
(handlers) => {
|
||||
transport = new FakeTransport(handlers);
|
||||
return transport;
|
||||
},
|
||||
);
|
||||
|
||||
const connecting = client.connect();
|
||||
const rejection = expect(connecting).rejects.toThrow("Codex app-server request timed out: initialize");
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
await rejection;
|
||||
expect(client.isConnected()).toBe(false);
|
||||
expect(transport.isRunning()).toBe(false);
|
||||
|
||||
transport.emitExit(1);
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects connect without notifying external exit handlers when transport exits during initialize", async () => {
|
||||
let transport!: FakeTransport;
|
||||
const onExit = vi.fn();
|
||||
const client = new AppServerClient(
|
||||
"/bin/codex",
|
||||
"/vault",
|
||||
{
|
||||
onNotification: () => undefined,
|
||||
onServerRequest: () => undefined,
|
||||
onLog: () => undefined,
|
||||
onExit,
|
||||
},
|
||||
500,
|
||||
(handlers) => {
|
||||
transport = new FakeTransport(handlers);
|
||||
return transport;
|
||||
},
|
||||
);
|
||||
|
||||
const connecting = client.connect();
|
||||
const rejection = expect(connecting).rejects.toThrow("Codex app-server exited: 1");
|
||||
transport.emitExit(1);
|
||||
|
||||
await rejection;
|
||||
expect(client.isConnected()).toBe(false);
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores stale transport events after reconnecting", async () => {
|
||||
const transports: FakeTransport[] = [];
|
||||
const onExit = vi.fn();
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ describe("ConnectionManager", () => {
|
|||
expect(manager.currentClient()).toBeInstanceOf(AppServerClient);
|
||||
});
|
||||
|
||||
it("reports app-server exit during initialization", async () => {
|
||||
it("rejects app-server exit during initialization without reporting a connected-client exit", async () => {
|
||||
let transport!: SilentTransport;
|
||||
const onExit = vi.fn();
|
||||
const manager = new ConnectionManager(
|
||||
|
|
@ -137,7 +137,7 @@ describe("ConnectionManager", () => {
|
|||
transport.emitExit();
|
||||
|
||||
await expect(connecting).rejects.toThrow("Codex app-server exited: unknown");
|
||||
expect(onExit).toHaveBeenCalledOnce();
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
expect(manager.currentClient()).toBeNull();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ function runOptions(clientFactory: EphemeralStructuredTurnClientFactory): Parame
|
|||
prompt: "Run.",
|
||||
outputSchema: { type: "object" },
|
||||
timeoutMs: 10_000,
|
||||
unhandledServerRequestMessage: "Structured test does not handle server requests.",
|
||||
serverRequests: { kind: "reject", message: "Structured test does not handle server requests." },
|
||||
exitedMessage: "Structured test app-server exited.",
|
||||
timedOutMessage: "Structured test timed out.",
|
||||
clientFactory,
|
||||
|
|
|
|||
|
|
@ -76,4 +76,114 @@ describe("tool inventory", () => {
|
|||
["remote-plugin", { skillCount: 0, hookCount: 0, appCount: 0, mcpServerCount: 1 }, null],
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails app inventory when app pagination repeats a cursor", async () => {
|
||||
const client = toolInventoryClient({
|
||||
listApps: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: [{ name: "first", title: "First", readOnlyHint: false }], nextCursor: "repeat" })
|
||||
.mockResolvedValueOnce({ data: [{ name: "second", title: "Second", readOnlyHint: false }], nextCursor: "repeat" }),
|
||||
});
|
||||
|
||||
const result = await readToolInventory(client, "/vault");
|
||||
|
||||
expect(result.inventory.apps).toBeNull();
|
||||
expect(result.inventory.appsError).toBe("Codex app-server returned a repeated app list cursor.");
|
||||
});
|
||||
|
||||
it("fails app inventory when app pagination exceeds the page limit", async () => {
|
||||
let page = 0;
|
||||
const client = toolInventoryClient({
|
||||
listApps: vi.fn().mockImplementation(() => {
|
||||
page += 1;
|
||||
return Promise.resolve({ data: [], nextCursor: `cursor-${String(page)}` });
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await readToolInventory(client, "/vault");
|
||||
|
||||
expect(result.inventory.apps).toBeNull();
|
||||
expect(result.inventory.appsError).toBe("Codex app-server returned too many app list pages.");
|
||||
});
|
||||
|
||||
it("limits plugin detail reads while preserving plugin order", async () => {
|
||||
let activeReads = 0;
|
||||
let maxActiveReads = 0;
|
||||
const plugins = Array.from({ length: 10 }, (_, index) => installedPlugin(`plugin-${String(index)}`));
|
||||
const readPlugin = vi.fn(async (params: Parameters<AppServerClient["readPlugin"]>[0]) => {
|
||||
activeReads += 1;
|
||||
maxActiveReads = Math.max(maxActiveReads, activeReads);
|
||||
await Promise.resolve();
|
||||
activeReads -= 1;
|
||||
return {
|
||||
plugin: {
|
||||
skills: [{ name: `${params.pluginName}-skill` }],
|
||||
hooks: [],
|
||||
apps: [],
|
||||
appTemplates: [],
|
||||
mcpServers: [],
|
||||
},
|
||||
};
|
||||
});
|
||||
const client = toolInventoryClient({
|
||||
listInstalledPlugins: vi.fn().mockResolvedValue({
|
||||
marketplaces: [{ name: "local-marketplace", path: "/marketplaces/local.json", plugins }],
|
||||
marketplaceLoadErrors: [],
|
||||
}),
|
||||
readPlugin,
|
||||
});
|
||||
|
||||
const result = await readToolInventory(client, "/vault");
|
||||
|
||||
expect(maxActiveReads).toBe(4);
|
||||
expect(result.inventory.plugins?.map((plugin) => plugin.name)).toEqual(plugins.map((plugin) => plugin.name));
|
||||
expect(result.inventory.plugins?.every((plugin) => plugin.details?.skillCount === 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function toolInventoryClient(
|
||||
overrides: Partial<{
|
||||
listApps: ReturnType<typeof vi.fn>;
|
||||
listInstalledPlugins: ReturnType<typeof vi.fn>;
|
||||
readPlugin: ReturnType<typeof vi.fn>;
|
||||
}> = {},
|
||||
): AppServerClient {
|
||||
return {
|
||||
listApps: overrides.listApps ?? vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
|
||||
listInstalledPlugins:
|
||||
overrides.listInstalledPlugins ??
|
||||
vi.fn().mockResolvedValue({
|
||||
marketplaces: [],
|
||||
marketplaceLoadErrors: [],
|
||||
}),
|
||||
readPlugin:
|
||||
overrides.readPlugin ??
|
||||
vi.fn().mockResolvedValue({
|
||||
plugin: { skills: [], hooks: [], apps: [], appTemplates: [], mcpServers: [] },
|
||||
}),
|
||||
listMcpServerStatus: vi.fn().mockResolvedValue({ data: [] }),
|
||||
listSkills: vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", skills: [] }] }),
|
||||
} as unknown as AppServerClient;
|
||||
}
|
||||
|
||||
function installedPlugin(name: string): {
|
||||
id: string;
|
||||
name: string;
|
||||
interface: { displayName: string };
|
||||
localVersion: string;
|
||||
installed: boolean;
|
||||
enabled: boolean;
|
||||
availability: string;
|
||||
source: { type: string; path: string };
|
||||
} {
|
||||
return {
|
||||
id: `${name}@local-marketplace`,
|
||||
name,
|
||||
interface: { displayName: name },
|
||||
localVersion: "1.0.0",
|
||||
installed: true,
|
||||
enabled: true,
|
||||
availability: "AVAILABLE",
|
||||
source: { type: "local", path: `/plugins/${name}` },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -572,13 +572,13 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
|
|||
plugin.settings.codexPath = "codex";
|
||||
|
||||
const result = await plugin.runtime.withClient((client) => client.readEffectiveConfig("/vault") as Promise<unknown>, {
|
||||
unhandledServerRequestMessage: "Settings refresh does not handle server requests.",
|
||||
serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." },
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(runWithAppServerClient).not.toHaveBeenCalled();
|
||||
expect(withShortLivedAppServerClientMock).toHaveBeenCalledWith("codex", "/vault", expect.any(Function), {
|
||||
unhandledServerRequestMessage: "Settings refresh does not handle server requests.",
|
||||
serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." },
|
||||
});
|
||||
expect(shortLivedClient.readEffectiveConfig).toHaveBeenCalledWith("/vault");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,30 +2,9 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import type { AppServerClient } from "../../src/app-server/connection/client";
|
||||
import { loadHookData } from "../../src/settings/app-server-data";
|
||||
import {
|
||||
createSettingsDynamicSectionLifecycle,
|
||||
transitionSettingsDynamicSectionLifecycle,
|
||||
transitionSettingsDataRefreshLifecycle,
|
||||
} from "../../src/settings/lifecycle";
|
||||
import { createSettingsDynamicSectionLifecycle, transitionSettingsDynamicSectionLifecycle } from "../../src/settings/lifecycle";
|
||||
|
||||
describe("settings lifecycle", () => {
|
||||
it("tracks settings data refresh lifecycle", () => {
|
||||
const idle = { kind: "idle" } as const;
|
||||
|
||||
const loading = transitionSettingsDataRefreshLifecycle(idle, { type: "started", operationToken: 1 });
|
||||
expect(loading).toEqual({ kind: "loading", operationToken: 1 });
|
||||
expect(transitionSettingsDataRefreshLifecycle(loading, { type: "started", operationToken: 0 })).toBe(loading);
|
||||
|
||||
const newerLoading = transitionSettingsDataRefreshLifecycle(loading, { type: "started", operationToken: 2 });
|
||||
expect(newerLoading).toEqual({ kind: "loading", operationToken: 2 });
|
||||
expect(transitionSettingsDataRefreshLifecycle(newerLoading, { type: "completed", failedCount: 2, operationToken: 1 })).toBe(
|
||||
newerLoading,
|
||||
);
|
||||
|
||||
const completed = transitionSettingsDataRefreshLifecycle(newerLoading, { type: "completed", failedCount: 2, operationToken: 2 });
|
||||
expect(completed).toEqual({ kind: "completed", failedCount: 2, operationToken: 2 });
|
||||
});
|
||||
|
||||
it("tracks dynamic section lifecycle", () => {
|
||||
const idle = createSettingsDynamicSectionLifecycle();
|
||||
expect(idle).toEqual({ kind: "idle", status: "" });
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
|
||||
import type { CatalogHookMetadata, CatalogModel } from "../../src/app-server/protocol/catalog";
|
||||
import type { AppServerObservedQueryResult } from "../../src/app-server/query/cache";
|
||||
import type { AppServerClientAccessOptions } from "../../src/app-server/connection/client-access";
|
||||
import type { ThreadRecord } from "../../src/app-server/protocol/thread";
|
||||
import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata";
|
||||
import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog";
|
||||
|
|
@ -1060,7 +1061,7 @@ function settingsTabHost(
|
|||
settings,
|
||||
vaultPath: "/vault",
|
||||
clientAccess: {
|
||||
withClient: <T>(operation: (client: never) => Promise<T>, clientOptions?: { unhandledServerRequestMessage?: string }) =>
|
||||
withClient: <T>(operation: (client: never) => Promise<T>, clientOptions?: AppServerClientAccessOptions) =>
|
||||
withShortLivedAppServerClientMock(settings.codexPath, "/vault", operation, clientOptions) as Promise<T>,
|
||||
},
|
||||
saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined),
|
||||
|
|
|
|||
Loading…
Reference in a new issue