mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(runtime): remove obsolete lifetime and query abstractions
This commit is contained in:
parent
2d9f98f434
commit
193b680f06
21 changed files with 232 additions and 221 deletions
4
src/app-server/connection/execution-context.ts
Normal file
4
src/app-server/connection/execution-context.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface AppServerExecutionContext {
|
||||
readonly codexPath: string;
|
||||
readonly vaultPath: string;
|
||||
}
|
||||
|
|
@ -20,8 +20,10 @@ import {
|
|||
} from "../../domain/server/diagnostics";
|
||||
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../domain/server/metadata";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
import { StaleExecutionRuntimeError } from "../../shared/runtime/execution-runtime-lifetime";
|
||||
import type { AppServerClient } from "../connection/client";
|
||||
import type { AppServerClientAccess, AppServerClientAccessOptions } from "../connection/client-access";
|
||||
import type { AppServerExecutionContext } from "../connection/execution-context";
|
||||
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
|
||||
import { listModelMetadata } from "../services/catalog";
|
||||
import { readEffectiveConfig } from "../services/runtime-metadata";
|
||||
|
|
@ -34,34 +36,20 @@ import {
|
|||
applyActiveThreadMutation,
|
||||
recentActiveThreadsFromData,
|
||||
} from "./active-thread-inventory";
|
||||
import {
|
||||
type AppServerQueryContext,
|
||||
activeThreadSearchInventoryQueryKey,
|
||||
activeThreadsQueryKey,
|
||||
appServerModelsQueryKey,
|
||||
appServerPermissionProfilesQueryKey,
|
||||
appServerRateLimitsQueryKey,
|
||||
appServerRuntimeConfigQueryKey,
|
||||
appServerSkillsQueryKey,
|
||||
archivedThreadsQueryKey,
|
||||
} from "./keys";
|
||||
import { readPermissionProfileMetadataProbe, readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
|
||||
import type { ObservedPaginatedResult, ObservedPaginatedResultListener, ObservedResult, ObservedResultListener } from "./observed-result";
|
||||
import { cloneModelMetadata, cloneSharedServerMetadata, cloneSharedServerMetadataResource, cloneThreads } from "./snapshots";
|
||||
import { applyThreadListMutation, type ThreadListKind, type ThreadListMutation } from "./thread-list-mutation";
|
||||
|
||||
const MODELS_STALE_TIME_MS = 60_000;
|
||||
|
||||
export class StaleAppServerResourceContextError extends Error {
|
||||
constructor() {
|
||||
super("Codex app-server resource context changed while loading.");
|
||||
this.name = "StaleAppServerResourceContextError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isStaleAppServerResourceContextError(error: unknown): error is StaleAppServerResourceContextError {
|
||||
return error instanceof StaleAppServerResourceContextError;
|
||||
}
|
||||
const ACTIVE_THREADS_QUERY_KEY = ["threads", "active"] as const;
|
||||
const ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY = ["threads", "active-search-inventory"] as const;
|
||||
const ARCHIVED_THREADS_QUERY_KEY = ["threads", "archived"] as const;
|
||||
const MODELS_QUERY_KEY = ["models"] as const;
|
||||
const RUNTIME_CONFIG_QUERY_KEY = ["runtime-config"] as const;
|
||||
const SKILLS_QUERY_KEY = ["skills"] as const;
|
||||
const PERMISSION_PROFILES_QUERY_KEY = ["permission-profiles"] as const;
|
||||
const RATE_LIMITS_QUERY_KEY = ["rate-limits"] as const;
|
||||
|
||||
interface AppServerQueryOptions<T> {
|
||||
readonly queryKey: readonly unknown[];
|
||||
|
|
@ -69,7 +57,7 @@ interface AppServerQueryOptions<T> {
|
|||
readonly staleTime?: number;
|
||||
}
|
||||
|
||||
type ActiveThreadsQueryKey = ReturnType<typeof activeThreadsQueryKey>;
|
||||
type ActiveThreadsQueryKey = typeof ACTIVE_THREADS_QUERY_KEY;
|
||||
|
||||
interface MetadataResourceSnapshot<T> {
|
||||
readonly value: T;
|
||||
|
|
@ -80,16 +68,16 @@ type MetadataResourceKind = "skills" | "permissionProfiles" | "rateLimits";
|
|||
type MetadataResourceValue = readonly SkillMetadata[] | readonly RuntimePermissionProfileSummary[] | RateLimitSnapshot | null;
|
||||
|
||||
export class AppServerQueryCache {
|
||||
private readonly context: Readonly<AppServerQueryContext>;
|
||||
private readonly context: Readonly<AppServerExecutionContext>;
|
||||
private readonly client: QueryClient;
|
||||
private readonly clientAccess: AppServerClientAccess;
|
||||
private readonly observerUnsubscribes = new Set<() => void>();
|
||||
private disposed = false;
|
||||
|
||||
constructor(context: AppServerQueryContext, options: { client?: QueryClient; clientAccess: AppServerClientAccess }) {
|
||||
constructor(context: AppServerExecutionContext, clientAccess: AppServerClientAccess) {
|
||||
this.context = Object.freeze({ ...context });
|
||||
this.client = options.client ?? createAppServerQueryClient();
|
||||
this.clientAccess = options.clientAccess;
|
||||
this.client = createAppServerQueryClient();
|
||||
this.clientAccess = clientAccess;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
|
@ -102,14 +90,14 @@ export class AppServerQueryCache {
|
|||
|
||||
activeThreadsSnapshot(): readonly Thread[] | null {
|
||||
if (this.disposed) return null;
|
||||
const data = this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey());
|
||||
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
|
||||
const threads = activeThreadsFromData(data);
|
||||
return threads ? cloneThreads(threads) : null;
|
||||
}
|
||||
|
||||
recentActiveThreadsSnapshot(): readonly Thread[] | null {
|
||||
if (this.disposed) return null;
|
||||
const data = this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey());
|
||||
const data = this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY);
|
||||
const threads = recentActiveThreadsFromData(data);
|
||||
return threads ? cloneThreads(threads) : null;
|
||||
}
|
||||
|
|
@ -146,7 +134,7 @@ export class AppServerQueryCache {
|
|||
|
||||
fetchActiveThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
||||
return this.runWhileActive(async () => {
|
||||
const key = activeThreadsQueryKey();
|
||||
const key = ACTIVE_THREADS_QUERY_KEY;
|
||||
if (options.force) {
|
||||
if (this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward") {
|
||||
await this.client.cancelQueries({ queryKey: key, exact: true });
|
||||
|
|
@ -170,7 +158,7 @@ export class AppServerQueryCache {
|
|||
|
||||
fetchActiveThreadSearchInventory(): Promise<readonly Thread[]> {
|
||||
return this.runWhileActive(async () => {
|
||||
const key = activeThreadSearchInventoryQueryKey();
|
||||
const key = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
|
||||
const options = {
|
||||
queryKey: key,
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
|
|
@ -182,7 +170,7 @@ export class AppServerQueryCache {
|
|||
|
||||
hasMoreActiveThreads(): boolean {
|
||||
if (this.disposed) return false;
|
||||
return activeThreadDataHasMore(this.client.getQueryData<ActiveThreadData>(activeThreadsQueryKey()));
|
||||
return activeThreadDataHasMore(this.client.getQueryData<ActiveThreadData>(ACTIVE_THREADS_QUERY_KEY));
|
||||
}
|
||||
|
||||
loadMoreActiveThreads(): Promise<readonly Thread[]> {
|
||||
|
|
@ -212,7 +200,7 @@ export class AppServerQueryCache {
|
|||
|
||||
fetchArchivedThreads(options: { force?: boolean } = {}): Promise<readonly Thread[]> {
|
||||
return this.runWhileActive(async () => {
|
||||
const key = archivedThreadsQueryKey();
|
||||
const key = ARCHIVED_THREADS_QUERY_KEY;
|
||||
if (options.force) {
|
||||
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||
this.assertUsable();
|
||||
|
|
@ -228,7 +216,7 @@ export class AppServerQueryCache {
|
|||
if (mutations.length === 0) return;
|
||||
const activeMutations = mutations.filter((mutation) => mutation.list === "active");
|
||||
if (activeMutations.length > 0) {
|
||||
const key = activeThreadsQueryKey();
|
||||
const key = ACTIVE_THREADS_QUERY_KEY;
|
||||
const wasFetching = this.client.getQueryState(key)?.fetchStatus === "fetching";
|
||||
const fetchIsNextPage = this.client.getQueryState(key)?.fetchMeta?.fetchMore?.direction === "forward";
|
||||
void this.client.cancelQueries({ queryKey: key, exact: true });
|
||||
|
|
@ -236,7 +224,7 @@ export class AppServerQueryCache {
|
|||
const after = activeMutations.reduce<ActiveThreadData | undefined>(applyActiveThreadMutation, before);
|
||||
if (after !== before) this.client.setQueryData(key, after);
|
||||
void this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||
const searchKey = activeThreadSearchInventoryQueryKey();
|
||||
const searchKey = ACTIVE_THREAD_SEARCH_INVENTORY_QUERY_KEY;
|
||||
void this.client.cancelQueries({ queryKey: searchKey, exact: true });
|
||||
void this.client.invalidateQueries({ queryKey: searchKey, refetchType: "none" });
|
||||
|
||||
|
|
@ -250,7 +238,7 @@ export class AppServerQueryCache {
|
|||
|
||||
const archivedMutations = mutations.filter((mutation) => mutation.list === "archived");
|
||||
if (archivedMutations.length > 0) {
|
||||
const key = archivedThreadsQueryKey();
|
||||
const key = ARCHIVED_THREADS_QUERY_KEY;
|
||||
const wasFetching = this.client.getQueryState(key)?.fetchStatus === "fetching";
|
||||
void this.client.cancelQueries({ queryKey: key, exact: true });
|
||||
const before = this.archivedThreadsSnapshot();
|
||||
|
|
@ -269,13 +257,13 @@ export class AppServerQueryCache {
|
|||
|
||||
private threadListSnapshot(kind: ThreadListKind): readonly Thread[] | null {
|
||||
if (kind === "active") return this.activeThreadsSnapshot();
|
||||
const threads = this.client.getQueryData<readonly Thread[]>(archivedThreadsQueryKey());
|
||||
const threads = this.client.getQueryData<readonly Thread[]>(ARCHIVED_THREADS_QUERY_KEY);
|
||||
return threads ? cloneThreads(threads) : null;
|
||||
}
|
||||
|
||||
appServerMetadataSnapshot(): SharedServerMetadata | null {
|
||||
if (this.disposed) return null;
|
||||
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(appServerRuntimeConfigQueryKey());
|
||||
const runtimeConfig = this.client.getQueryData<RuntimeConfigSnapshot>(RUNTIME_CONFIG_QUERY_KEY);
|
||||
if (!runtimeConfig) return null;
|
||||
const skills = this.metadataResourceState("skills");
|
||||
const permissionProfiles = this.metadataResourceState("permissionProfiles");
|
||||
|
|
@ -398,7 +386,7 @@ export class AppServerQueryCache {
|
|||
|
||||
modelsSnapshot(): readonly ModelMetadata[] | null {
|
||||
if (this.disposed) return null;
|
||||
const models = this.client.getQueryData<readonly ModelMetadata[]>(appServerModelsQueryKey());
|
||||
const models = this.client.getQueryData<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
|
||||
return models ? cloneModelMetadata(models) : null;
|
||||
}
|
||||
|
||||
|
|
@ -409,7 +397,7 @@ export class AppServerQueryCache {
|
|||
|
||||
fetchModels(options: { force?: boolean } = {}): Promise<readonly ModelMetadata[]> {
|
||||
return this.runWhileActive(async () => {
|
||||
const key = appServerModelsQueryKey();
|
||||
const key = MODELS_QUERY_KEY;
|
||||
if (options.force) {
|
||||
await this.client.invalidateQueries({ queryKey: key, refetchType: "none" });
|
||||
this.assertUsable();
|
||||
|
|
@ -432,7 +420,7 @@ export class AppServerQueryCache {
|
|||
ActiveThreadCursor
|
||||
> {
|
||||
return {
|
||||
queryKey: activeThreadsQueryKey(),
|
||||
queryKey: ACTIVE_THREADS_QUERY_KEY,
|
||||
queryFn: async ({ pageParam, signal }) => {
|
||||
signal.throwIfAborted();
|
||||
const page = await this.runWithClient((client) =>
|
||||
|
|
@ -449,7 +437,7 @@ export class AppServerQueryCache {
|
|||
|
||||
private archivedThreadsQueryOptions(): AppServerQueryOptions<readonly Thread[]> {
|
||||
return {
|
||||
queryKey: archivedThreadsQueryKey(),
|
||||
queryKey: ARCHIVED_THREADS_QUERY_KEY,
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
this.runWithClient((client) => listThreads(client, this.context.vaultPath, { archived: true, signal })).then(cloneThreads),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
|
|
@ -458,7 +446,7 @@ export class AppServerQueryCache {
|
|||
|
||||
private runtimeConfigQueryOptions(): AppServerQueryOptions<RuntimeConfigSnapshot> {
|
||||
return {
|
||||
queryKey: appServerRuntimeConfigQueryKey(),
|
||||
queryKey: RUNTIME_CONFIG_QUERY_KEY,
|
||||
queryFn: async (): Promise<RuntimeConfigSnapshot> =>
|
||||
this.runWithClient(async (client) =>
|
||||
runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, this.context.vaultPath)),
|
||||
|
|
@ -468,7 +456,7 @@ export class AppServerQueryCache {
|
|||
|
||||
private skillsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly SkillMetadata[]>> {
|
||||
return {
|
||||
queryKey: appServerSkillsQueryKey(),
|
||||
queryKey: SKILLS_QUERY_KEY,
|
||||
queryFn: async () =>
|
||||
this.runWithClient(async (client) => successfulMetadataResource(await readSkillMetadataProbe(client, this.context.vaultPath))),
|
||||
};
|
||||
|
|
@ -476,7 +464,7 @@ export class AppServerQueryCache {
|
|||
|
||||
private permissionProfilesQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<readonly RuntimePermissionProfileSummary[]>> {
|
||||
return {
|
||||
queryKey: appServerPermissionProfilesQueryKey(),
|
||||
queryKey: PERMISSION_PROFILES_QUERY_KEY,
|
||||
queryFn: async () =>
|
||||
this.runWithClient(async (client) =>
|
||||
successfulMetadataResource(await readPermissionProfileMetadataProbe(client, this.context.vaultPath)),
|
||||
|
|
@ -486,7 +474,7 @@ export class AppServerQueryCache {
|
|||
|
||||
private rateLimitsQueryOptions(): AppServerQueryOptions<MetadataResourceSnapshot<RateLimitSnapshot | null>> {
|
||||
return {
|
||||
queryKey: appServerRateLimitsQueryKey(),
|
||||
queryKey: RATE_LIMITS_QUERY_KEY,
|
||||
queryFn: async () => this.runWithClient(async (client) => successfulMetadataResource(await readRateLimitMetadataProbe(client))),
|
||||
};
|
||||
}
|
||||
|
|
@ -518,7 +506,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
private async refreshNotifiedMetadataResource(resource: "skills" | "rateLimits"): Promise<void> {
|
||||
const queryKey = resource === "skills" ? appServerSkillsQueryKey() : appServerRateLimitsQueryKey();
|
||||
const queryKey = resource === "skills" ? SKILLS_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
|
||||
await this.client.cancelQueries({ queryKey, exact: true });
|
||||
this.assertUsable();
|
||||
try {
|
||||
|
|
@ -536,11 +524,7 @@ export class AppServerQueryCache {
|
|||
private metadataResourceState(resource: "rateLimits"): { value: RateLimitSnapshot | null; probe: DiagnosticProbeResult };
|
||||
private metadataResourceState(resource: MetadataResourceKind): { value: MetadataResourceValue; probe: DiagnosticProbeResult } {
|
||||
const key =
|
||||
resource === "skills"
|
||||
? appServerSkillsQueryKey()
|
||||
: resource === "permissionProfiles"
|
||||
? appServerPermissionProfilesQueryKey()
|
||||
: appServerRateLimitsQueryKey();
|
||||
resource === "skills" ? SKILLS_QUERY_KEY : resource === "permissionProfiles" ? PERMISSION_PROFILES_QUERY_KEY : RATE_LIMITS_QUERY_KEY;
|
||||
const state = this.client.getQueryState<MetadataResourceSnapshot<MetadataResourceValue>>(key);
|
||||
const failedProbe = diagnosticProbeFromError(state?.error);
|
||||
return {
|
||||
|
|
@ -550,7 +534,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
private modelsProbe(): DiagnosticProbeResult {
|
||||
const state = this.client.getQueryState<readonly ModelMetadata[]>(appServerModelsQueryKey());
|
||||
const state = this.client.getQueryState<readonly ModelMetadata[]>(MODELS_QUERY_KEY);
|
||||
return (
|
||||
diagnosticProbeFromError(state?.error) ??
|
||||
(state?.data
|
||||
|
|
@ -561,7 +545,7 @@ export class AppServerQueryCache {
|
|||
|
||||
private modelsQueryOptions(): AppServerQueryOptions<readonly ModelMetadata[]> {
|
||||
return {
|
||||
queryKey: appServerModelsQueryKey(),
|
||||
queryKey: MODELS_QUERY_KEY,
|
||||
queryFn: async (): Promise<readonly ModelMetadata[]> => {
|
||||
try {
|
||||
return cloneModelMetadata(
|
||||
|
|
@ -651,7 +635,7 @@ export class AppServerQueryCache {
|
|||
}
|
||||
|
||||
private assertUsable(): void {
|
||||
if (this.disposed) throw new StaleAppServerResourceContextError();
|
||||
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||
}
|
||||
|
||||
private runWhileActive<T>(operation: () => Promise<T>): Promise<T> {
|
||||
|
|
@ -662,7 +646,7 @@ export class AppServerQueryCache {
|
|||
this.assertUsable();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.disposed) throw new StaleAppServerResourceContextError();
|
||||
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
export interface AppServerQueryContext {
|
||||
readonly codexPath: string;
|
||||
readonly vaultPath: string;
|
||||
}
|
||||
|
||||
type AppServerQueryScope = readonly ["app-server"];
|
||||
export type AppServerActiveThreadsQueryKey = readonly [...AppServerQueryScope, "threads", "active"];
|
||||
export type AppServerActiveThreadSearchInventoryQueryKey = readonly [...AppServerQueryScope, "threads", "active-search-inventory"];
|
||||
export type AppServerArchivedThreadsQueryKey = readonly [...AppServerQueryScope, "threads", "archived"];
|
||||
export type AppServerModelsQueryKey = readonly [...AppServerQueryScope, "models"];
|
||||
export type AppServerRuntimeConfigQueryKey = readonly [...AppServerQueryScope, "runtime-config"];
|
||||
export type AppServerSkillsQueryKey = readonly [...AppServerQueryScope, "skills"];
|
||||
export type AppServerPermissionProfilesQueryKey = readonly [...AppServerQueryScope, "permission-profiles"];
|
||||
export type AppServerRateLimitsQueryKey = readonly [...AppServerQueryScope, "rate-limits"];
|
||||
|
||||
const APP_SERVER_QUERY_SCOPE: AppServerQueryScope = ["app-server"];
|
||||
|
||||
export function activeThreadsQueryKey(): AppServerActiveThreadsQueryKey {
|
||||
return [...APP_SERVER_QUERY_SCOPE, "threads", "active"];
|
||||
}
|
||||
|
||||
export function activeThreadSearchInventoryQueryKey(): AppServerActiveThreadSearchInventoryQueryKey {
|
||||
return [...APP_SERVER_QUERY_SCOPE, "threads", "active-search-inventory"];
|
||||
}
|
||||
|
||||
export function archivedThreadsQueryKey(): AppServerArchivedThreadsQueryKey {
|
||||
return [...APP_SERVER_QUERY_SCOPE, "threads", "archived"];
|
||||
}
|
||||
|
||||
export function appServerModelsQueryKey(): AppServerModelsQueryKey {
|
||||
return [...APP_SERVER_QUERY_SCOPE, "models"];
|
||||
}
|
||||
|
||||
export function appServerRuntimeConfigQueryKey(): AppServerRuntimeConfigQueryKey {
|
||||
return [...APP_SERVER_QUERY_SCOPE, "runtime-config"];
|
||||
}
|
||||
|
||||
export function appServerSkillsQueryKey(): AppServerSkillsQueryKey {
|
||||
return [...APP_SERVER_QUERY_SCOPE, "skills"];
|
||||
}
|
||||
|
||||
export function appServerPermissionProfilesQueryKey(): AppServerPermissionProfilesQueryKey {
|
||||
return [...APP_SERVER_QUERY_SCOPE, "permission-profiles"];
|
||||
}
|
||||
|
||||
export function appServerRateLimitsQueryKey(): AppServerRateLimitsQueryKey {
|
||||
return [...APP_SERVER_QUERY_SCOPE, "rate-limits"];
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@ import type { App } from "obsidian";
|
|||
|
||||
import type { AppServerClient } from "./app-server/connection/client";
|
||||
import type { AppServerClientAccess, AppServerClientAccessOptions } from "./app-server/connection/client-access";
|
||||
import type { AppServerExecutionContext } from "./app-server/connection/execution-context";
|
||||
import { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client";
|
||||
import { AppServerQueryCache, StaleAppServerResourceContextError } from "./app-server/query/cache";
|
||||
import type { AppServerQueryContext } from "./app-server/query/keys";
|
||||
import { AppServerQueryCache } from "./app-server/query/cache";
|
||||
import {
|
||||
type EphemeralStructuredTurnClient,
|
||||
type EphemeralStructuredTurnRunner,
|
||||
|
|
@ -24,11 +24,12 @@ import type { ThreadsRuntimeView } from "./features/threads-view/view.obsidian";
|
|||
import { createSettingsAppServerDynamicData } from "./settings/app-server-dynamic-data";
|
||||
import type { SettingsDynamicDataAccess } from "./settings/dynamic-data";
|
||||
import type { CodexPanelSettings } from "./settings/model";
|
||||
import { StaleExecutionRuntimeError } from "./shared/runtime/execution-runtime-lifetime";
|
||||
import { createKeyedOperationQueue } from "./shared/runtime/keyed-operation-queue";
|
||||
|
||||
export interface CodexExecutionRuntimeOptions {
|
||||
app: App;
|
||||
context: AppServerQueryContext;
|
||||
context: AppServerExecutionContext;
|
||||
settings: () => CodexPanelSettings;
|
||||
workspace: WorkspacePanels;
|
||||
onThreadCatalogEvent(event: ThreadCatalogEvent): void;
|
||||
|
|
@ -44,9 +45,9 @@ export interface ExecutionRuntimeViews {
|
|||
}
|
||||
|
||||
export class CodexExecutionRuntime implements AppServerClientAccess {
|
||||
readonly context: Readonly<AppServerQueryContext>;
|
||||
readonly appServerQueries: AppServerQueryCache;
|
||||
readonly threadCatalog: ThreadCatalog;
|
||||
private readonly context: Readonly<AppServerExecutionContext>;
|
||||
private readonly appServerQueries: AppServerQueryCache;
|
||||
private readonly threadCatalog: ThreadCatalog;
|
||||
readonly settingsDynamicData: SettingsDynamicDataAccess;
|
||||
private readonly threadNameMutations = createThreadNameMutationCoordinator();
|
||||
private readonly threadGoalOperations = createThreadGoalOperationCoordinator();
|
||||
|
|
@ -61,7 +62,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
|
|||
|
||||
constructor(private readonly options: CodexExecutionRuntimeOptions) {
|
||||
this.context = Object.freeze({ ...options.context });
|
||||
this.appServerQueries = new AppServerQueryCache(this.context, { clientAccess: this });
|
||||
this.appServerQueries = new AppServerQueryCache(this.context, this);
|
||||
this.threadCatalog = createThreadCatalog({
|
||||
store: this.appServerQueries,
|
||||
onEventApplied: (event) => {
|
||||
|
|
@ -269,20 +270,25 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
|
|||
this.assertActive();
|
||||
return operation(client);
|
||||
};
|
||||
const result = await withShortLivedAppServerClient(this.context.codexPath, this.context.vaultPath, guardedOperation, options, {
|
||||
created: (client) => {
|
||||
if (this.disposed) {
|
||||
client.disconnect();
|
||||
throw new StaleAppServerResourceContextError();
|
||||
}
|
||||
this.shortLivedClients.add(client);
|
||||
},
|
||||
disposed: (client) => {
|
||||
this.shortLivedClients.delete(client);
|
||||
},
|
||||
});
|
||||
this.assertActive();
|
||||
return result;
|
||||
try {
|
||||
const result = await withShortLivedAppServerClient(this.context.codexPath, this.context.vaultPath, guardedOperation, options, {
|
||||
created: (client) => {
|
||||
if (this.disposed) {
|
||||
client.disconnect();
|
||||
throw new StaleExecutionRuntimeError();
|
||||
}
|
||||
this.shortLivedClients.add(client);
|
||||
},
|
||||
disposed: (client) => {
|
||||
this.shortLivedClients.delete(client);
|
||||
},
|
||||
});
|
||||
this.assertActive();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private structuredTurnRunner(): EphemeralStructuredTurnRunner {
|
||||
|
|
@ -303,7 +309,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
|
|||
created: (client) => {
|
||||
if (this.disposed) {
|
||||
client.disconnect();
|
||||
throw new StaleAppServerResourceContextError();
|
||||
throw new StaleExecutionRuntimeError();
|
||||
}
|
||||
this.structuredTurnClients.add(client);
|
||||
},
|
||||
|
|
@ -332,7 +338,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
|
|||
}
|
||||
|
||||
private assertActive(): void {
|
||||
if (this.disposed) throw new StaleAppServerResourceContextError();
|
||||
if (this.disposed) throw new StaleExecutionRuntimeError();
|
||||
}
|
||||
|
||||
private tryCleanup(operation: () => void): boolean {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export interface ChatConnectionActionsHost {
|
|||
addSystemMessage: (text: string) => void;
|
||||
configuredCommand: () => string;
|
||||
isStaleConnectionError: (error: unknown) => boolean;
|
||||
isStaleResourceContextError: (error: unknown) => boolean;
|
||||
isStaleRuntimeError: (error: unknown) => boolean;
|
||||
notifyConnectionFailed: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +105,7 @@ async function refreshActiveThreads(host: ChatConnectionActionsHost): Promise<vo
|
|||
await host.refreshSharedThreads();
|
||||
host.refreshTabHeader();
|
||||
} catch (error) {
|
||||
if (host.isStaleResourceContextError(error)) return;
|
||||
if (host.isStaleRuntimeError(error)) return;
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
|
@ -143,7 +143,7 @@ async function initializeConnection(host: ChatConnectionActionsHost, isStale: ()
|
|||
} catch (error) {
|
||||
if (isStale()) return;
|
||||
if (host.isStaleConnectionError(error)) return;
|
||||
if (host.isStaleResourceContextError(error)) return;
|
||||
if (host.isStaleRuntimeError(error)) return;
|
||||
const message = connectionErrorMessage(error, host.configuredCommand());
|
||||
host.setStatus(STATUS_CONNECTION_FAILED, { kind: "failed", message });
|
||||
host.addSystemMessage(message);
|
||||
|
|
@ -158,7 +158,7 @@ async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStal
|
|||
try {
|
||||
await host.metadata.refreshAppServerMetadata();
|
||||
} catch (error) {
|
||||
if (isStale() || host.isStaleResourceContextError(error)) return;
|
||||
if (isStale() || host.isStaleRuntimeError(error)) return;
|
||||
host.addSystemMessage(`Could not refresh Codex metadata: ${errorMessage(error)}`);
|
||||
}
|
||||
if (isStale()) return;
|
||||
|
|
@ -166,7 +166,7 @@ async function hydrateConnectedResources(host: ChatConnectionActionsHost, isStal
|
|||
try {
|
||||
await host.refreshSharedThreads();
|
||||
} catch (error) {
|
||||
if (isStale() || host.isStaleResourceContextError(error)) return;
|
||||
if (isStale() || host.isStaleRuntimeError(error)) return;
|
||||
host.addSystemMessage(`Could not refresh Codex threads: ${errorMessage(error)}`);
|
||||
}
|
||||
if (isStale()) return;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export interface ServerMetadataActionsHost {
|
|||
refreshAppServerMetadata: () => Promise<void>;
|
||||
refreshSkills: () => Promise<void>;
|
||||
refreshRateLimits: () => Promise<void>;
|
||||
isStaleResourceContextError: (error: unknown) => boolean;
|
||||
isStaleRuntimeError: (error: unknown) => boolean;
|
||||
}
|
||||
|
||||
export interface ServerMetadataActions {
|
||||
|
|
@ -106,7 +106,7 @@ async function refreshAppServerMetadata(host: ServerMetadataActionsHost): Promis
|
|||
try {
|
||||
await host.refreshAppServerMetadata();
|
||||
} catch (error) {
|
||||
if (host.isStaleResourceContextError(error)) return;
|
||||
if (host.isStaleRuntimeError(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -115,7 +115,7 @@ async function refreshMetadataResource(host: ServerMetadataActionsHost, refresh:
|
|||
try {
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
if (!host.isStaleResourceContextError(error)) throw error;
|
||||
if (!host.isStaleRuntimeError(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { Notice } from "obsidian";
|
|||
|
||||
import type { AppServerClient, AppServerServerRequestResponder } from "../../../../app-server/connection/client";
|
||||
import { type ConnectionManager, StaleConnectionError } from "../../../../app-server/connection/connection-manager";
|
||||
import { isStaleAppServerResourceContextError } from "../../../../app-server/query/cache";
|
||||
import type { SharedServerMetadataResource } from "../../../../domain/server/metadata";
|
||||
import { isStaleExecutionRuntimeError } from "../../../../shared/runtime/execution-runtime-lifetime";
|
||||
import { type ChatInboundHandler, createChatInboundHandler } from "../../app-server/inbound/handler";
|
||||
import { type ChatConnectionActions, createChatConnectionActions } from "../../application/connection/connection-actions";
|
||||
import type { ServerDiagnosticsTransport } from "../../application/connection/metadata-transport";
|
||||
|
|
@ -127,7 +127,7 @@ export function createConnectionBundle(
|
|||
refreshAppServerMetadata: () => environment.plugin.appServerQueries.refreshAppServerMetadata(),
|
||||
refreshSkills: () => environment.plugin.appServerQueries.refreshSkills(),
|
||||
refreshRateLimits: () => environment.plugin.appServerQueries.refreshRateLimits(),
|
||||
isStaleResourceContextError: isStaleAppServerResourceContextError,
|
||||
isStaleRuntimeError: isStaleExecutionRuntimeError,
|
||||
});
|
||||
const serverDiagnostics = createServerDiagnosticsActions({
|
||||
stateStore,
|
||||
|
|
@ -224,7 +224,7 @@ export function createConnectionBundle(
|
|||
addSystemMessage: status.addSystemMessage,
|
||||
configuredCommand: () => environment.plugin.appServerContext.codexPath,
|
||||
isStaleConnectionError: (error) => error instanceof StaleConnectionError,
|
||||
isStaleResourceContextError: isStaleAppServerResourceContextError,
|
||||
isStaleRuntimeError: isStaleExecutionRuntimeError,
|
||||
notifyConnectionFailed: () => {
|
||||
new Notice("Codex app-server connection failed.");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { App, Component, EventRef } from "obsidian";
|
||||
|
||||
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
|
||||
import type { AppServerQueryContext } from "../../../app-server/query/keys";
|
||||
import type { AppServerExecutionContext } from "../../../app-server/connection/execution-context";
|
||||
import type { ObservedResultListener } from "../../../app-server/query/observed-result";
|
||||
import type { ModelMetadata } from "../../../domain/catalog/metadata";
|
||||
import type { SendShortcut } from "../../../domain/input/send-shortcut";
|
||||
|
|
@ -17,7 +17,7 @@ import type { ThreadGoalOperationCoordinator } from "../application/threads/goal
|
|||
|
||||
export interface CodexChatHost {
|
||||
readonly appServerClientAccess: AppServerClientAccess;
|
||||
readonly appServerContext: Readonly<AppServerQueryContext>;
|
||||
readonly appServerContext: Readonly<AppServerExecutionContext>;
|
||||
readonly settings: ChatPanelSettingsAccess;
|
||||
readonly workspace: WorkspacePanels;
|
||||
readonly appServerQueries: ChatAppServerQueries;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { codexPanelAppServerInitializeParams } from "../../../app-server/connection/client-profile";
|
||||
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import { isStaleAppServerResourceContextError } from "../../../app-server/query/cache";
|
||||
import { isStaleExecutionRuntimeError } from "../../../shared/runtime/execution-runtime-lifetime";
|
||||
import { createChatAppServerGateway, createChatCurrentAppServerGateway } from "../app-server/session-gateway";
|
||||
import { createReconnectPanelAction } from "../application/connection/reconnect-actions";
|
||||
import { createLocalIdSource, type LocalIdSource } from "../application/local-id-source";
|
||||
|
|
@ -282,7 +282,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat
|
|||
try {
|
||||
await connectionBundle.refreshSharedThreads();
|
||||
} catch (error) {
|
||||
if (isStaleAppServerResourceContextError(error)) return;
|
||||
if (isStaleExecutionRuntimeError(error)) return;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -130,10 +130,7 @@ function ChatPanelToolbarRegion({
|
|||
actions: ToolbarActions;
|
||||
}): UiNode {
|
||||
const model = useChatSelector(stateStore, selectChatPanelToolbar);
|
||||
return useMemo(
|
||||
() => <ChatPanelToolbar model={model} stateStore={stateStore} surface={surface} actions={actions} />,
|
||||
[model, stateStore, surface, actions],
|
||||
);
|
||||
return <ChatPanelToolbar model={model} stateStore={stateStore} surface={surface} actions={actions} />;
|
||||
}
|
||||
|
||||
function ChatPanelGoalRegion({ stateStore, surface }: { stateStore: ChatStateStore; surface: ChatPanelGoalSurface }): UiNode {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { Notice } from "obsidian";
|
||||
|
||||
import { isStaleAppServerResourceContextError } from "../../app-server/query/cache";
|
||||
import type { ObservedPaginatedResult } from "../../app-server/query/observed-result";
|
||||
import { observedInitialError, observedInitialLoading } from "../../app-server/query/observed-result";
|
||||
import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
import type { ThreadRenameLifecycleEvent } from "../../domain/threads/rename-lifecycle";
|
||||
import { DeferredTask } from "../../shared/runtime/deferred-task";
|
||||
import { isStaleExecutionRuntimeError } from "../../shared/runtime/execution-runtime-lifetime";
|
||||
import { OwnerLifetime } from "../../shared/runtime/owner-lifetime";
|
||||
import type { ThreadCatalogEventSink, ThreadCatalogPaginatedActiveReader } from "../threads/catalog/thread-catalog";
|
||||
import type { ArchiveExportDestination } from "../threads/workflows/archive-export";
|
||||
|
|
@ -135,7 +135,7 @@ export class ThreadsViewSession {
|
|||
try {
|
||||
await request();
|
||||
} catch (error) {
|
||||
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerResourceContextError(error)) return;
|
||||
if (!this.lifetime.isCurrent(lifetime) || isStaleExecutionRuntimeError(error)) return;
|
||||
if (!this.currentThreadsSnapshot()) {
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
this.render();
|
||||
|
|
@ -149,7 +149,7 @@ export class ThreadsViewSession {
|
|||
try {
|
||||
await this.host.threadCatalog.loadMoreActive();
|
||||
} catch (error) {
|
||||
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerResourceContextError(error)) return;
|
||||
if (!this.lifetime.isCurrent(lifetime) || isStaleExecutionRuntimeError(error)) return;
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
this.render();
|
||||
}
|
||||
|
|
|
|||
10
src/shared/runtime/execution-runtime-lifetime.ts
Normal file
10
src/shared/runtime/execution-runtime-lifetime.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export class StaleExecutionRuntimeError extends Error {
|
||||
constructor() {
|
||||
super("Codex execution runtime was disposed while work was in progress.");
|
||||
this.name = "StaleExecutionRuntimeError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isStaleExecutionRuntimeError(error: unknown): error is StaleExecutionRuntimeError {
|
||||
return error instanceof StaleExecutionRuntimeError;
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import { onlineManager, QueryClient } from "@tanstack/query-core";
|
||||
import { onlineManager } from "@tanstack/query-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AppServerClientAccess } from "../../src/app-server/connection/client-access";
|
||||
import type { AppServerExecutionContext } from "../../src/app-server/connection/execution-context";
|
||||
import type { CatalogModel, CatalogSkillMetadata } from "../../src/app-server/protocol/catalog";
|
||||
import { AppServerQueryCache, StaleAppServerResourceContextError } from "../../src/app-server/query/cache";
|
||||
import type { AppServerQueryContext } from "../../src/app-server/query/keys";
|
||||
import { AppServerQueryCache } from "../../src/app-server/query/cache";
|
||||
import type { RateLimitSnapshot } from "../../src/domain/runtime/metrics";
|
||||
import type { RuntimePermissionProfileSummary } from "../../src/domain/runtime/permissions";
|
||||
import type { Thread } from "../../src/domain/threads/model";
|
||||
import { StaleExecutionRuntimeError } from "../../src/shared/runtime/execution-runtime-lifetime";
|
||||
|
||||
describe("AppServerQueryCache", () => {
|
||||
it("uses its required runtime-owned client access", async () => {
|
||||
|
|
@ -22,25 +23,15 @@ describe("AppServerQueryCache", () => {
|
|||
expect(withClient).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps executable and vault context out of runtime-local query keys", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
it("copies its execution context before performing requests", async () => {
|
||||
const context = { codexPath: "/opt/codex", vaultPath: "/vault-a" };
|
||||
const request = vi.fn().mockResolvedValue({ data: [], nextCursor: null });
|
||||
const cache = new AppServerQueryCache(context, {
|
||||
client: queryClient,
|
||||
clientAccess: { withClient: async (operation) => operation({ request } as never) },
|
||||
});
|
||||
const cache = new AppServerQueryCache(context, { withClient: async (operation) => operation({ request } as never) });
|
||||
|
||||
context.codexPath = "/changed";
|
||||
context.vaultPath = "/vault-b";
|
||||
await cache.fetchActiveThreads();
|
||||
|
||||
expect(
|
||||
queryClient
|
||||
.getQueryCache()
|
||||
.getAll()
|
||||
.map((query) => query.queryKey),
|
||||
).toEqual([["app-server", "threads", "active"]]);
|
||||
expect(request).toHaveBeenCalledWith("thread/list", {
|
||||
cwd: "/vault-a",
|
||||
archived: false,
|
||||
|
|
@ -57,10 +48,10 @@ describe("AppServerQueryCache", () => {
|
|||
cache.dispose();
|
||||
cache.dispose();
|
||||
|
||||
expect(() => cache.fetchModels()).toThrow(StaleAppServerResourceContextError);
|
||||
expect(() => cache.fetchModels()).toThrow(StaleExecutionRuntimeError);
|
||||
});
|
||||
|
||||
it("classifies late completion after disposal as a stale resource context", async () => {
|
||||
it("classifies late completion after disposal as a stale execution runtime", async () => {
|
||||
const pending = deferred<readonly []>();
|
||||
const cache = createCache({
|
||||
withClient: vi.fn(() => pending.promise) as AppServerClientAccess["withClient"],
|
||||
|
|
@ -70,7 +61,7 @@ describe("AppServerQueryCache", () => {
|
|||
cache.dispose();
|
||||
pending.resolve([]);
|
||||
|
||||
await expect(fetch).rejects.toBeInstanceOf(StaleAppServerResourceContextError);
|
||||
await expect(fetch).rejects.toBeInstanceOf(StaleExecutionRuntimeError);
|
||||
});
|
||||
|
||||
it("tears down observers without notifying them after disposal", async () => {
|
||||
|
|
@ -85,7 +76,7 @@ describe("AppServerQueryCache", () => {
|
|||
listener.mockClear();
|
||||
cache.dispose();
|
||||
pending.resolve([]);
|
||||
await expect(fetch).rejects.toBeInstanceOf(StaleAppServerResourceContextError);
|
||||
await expect(fetch).rejects.toBeInstanceOf(StaleExecutionRuntimeError);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -146,7 +137,7 @@ describe("AppServerQueryCache", () => {
|
|||
});
|
||||
|
||||
it("keeps active and archived thread list snapshots separate", async () => {
|
||||
const fetchThreads = vi.fn((_context: AppServerQueryContext, archived: boolean) =>
|
||||
const fetchThreads = vi.fn((_context: AppServerExecutionContext, archived: boolean) =>
|
||||
Promise.resolve(archived ? [thread("archived", true)] : [thread("active")]),
|
||||
);
|
||||
const cache = cacheWithThreads(fetchThreads);
|
||||
|
|
@ -511,7 +502,7 @@ describe("AppServerQueryCache", () => {
|
|||
cache.dispose();
|
||||
|
||||
expect(cache.modelsSnapshot()).toBeNull();
|
||||
expect(() => cache.fetchModels()).toThrow(StaleAppServerResourceContextError);
|
||||
expect(() => cache.fetchModels()).toThrow(StaleExecutionRuntimeError);
|
||||
expect(listModels).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
|
@ -843,7 +834,7 @@ describe("AppServerQueryCache", () => {
|
|||
});
|
||||
});
|
||||
|
||||
function cacheContext(overrides: Partial<AppServerQueryContext> = {}): AppServerQueryContext {
|
||||
function cacheContext(overrides: Partial<AppServerExecutionContext> = {}): AppServerExecutionContext {
|
||||
return {
|
||||
codexPath: "codex",
|
||||
vaultPath: "/vault",
|
||||
|
|
@ -852,30 +843,28 @@ function cacheContext(overrides: Partial<AppServerQueryContext> = {}): AppServer
|
|||
}
|
||||
|
||||
function cacheWithThreads(
|
||||
fetchThreads: (context: AppServerQueryContext, archived: boolean) => Promise<readonly ReturnType<typeof thread>[]>,
|
||||
context: AppServerQueryContext = cacheContext(),
|
||||
fetchThreads: (context: AppServerExecutionContext, archived: boolean) => Promise<readonly ReturnType<typeof thread>[]>,
|
||||
context: AppServerExecutionContext = cacheContext(),
|
||||
): AppServerQueryCache {
|
||||
const runtimeContext = { ...context };
|
||||
return new AppServerQueryCache(context, {
|
||||
clientAccess: {
|
||||
withClient: async (operation) => {
|
||||
return operation({
|
||||
request: async (method: string, params: { archived?: boolean }) => {
|
||||
if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`);
|
||||
return {
|
||||
data: await fetchThreads(runtimeContext, params.archived ?? false),
|
||||
nextCursor: null,
|
||||
};
|
||||
},
|
||||
} as never);
|
||||
},
|
||||
withClient: async (operation) => {
|
||||
return operation({
|
||||
request: async (method: string, params: { archived?: boolean }) => {
|
||||
if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`);
|
||||
return {
|
||||
data: await fetchThreads(runtimeContext, params.archived ?? false),
|
||||
nextCursor: null,
|
||||
};
|
||||
},
|
||||
} as never);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cacheWithRequestHandlers(
|
||||
handlers: Record<string, (params: unknown) => Promise<unknown>>,
|
||||
context: AppServerQueryContext = cacheContext(),
|
||||
context: AppServerExecutionContext = cacheContext(),
|
||||
): AppServerQueryCache {
|
||||
const requestClient = {
|
||||
request: async (method: string, params: unknown) => {
|
||||
|
|
@ -885,14 +874,12 @@ function cacheWithRequestHandlers(
|
|||
},
|
||||
};
|
||||
return new AppServerQueryCache(context, {
|
||||
clientAccess: {
|
||||
withClient: async (operation) => operation(requestClient as never),
|
||||
},
|
||||
withClient: async (operation) => operation(requestClient as never),
|
||||
});
|
||||
}
|
||||
|
||||
function createCache(clientAccess: AppServerClientAccess): AppServerQueryCache {
|
||||
return new AppServerQueryCache(cacheContext(), { clientAccess });
|
||||
return new AppServerQueryCache(cacheContext(), clientAccess);
|
||||
}
|
||||
|
||||
function permissionProfile(id: string): RuntimePermissionProfileSummary {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { StaleAppServerResourceContextError } from "../src/app-server/query/cache";
|
||||
import { CodexExecutionRuntime } from "../src/execution-runtime";
|
||||
import type { ChatRuntimeView, CodexChatHost } from "../src/features/chat/host/contracts";
|
||||
import type { ThreadPickerController } from "../src/features/thread-picker/modal.obsidian";
|
||||
import { DEFAULT_SETTINGS } from "../src/settings/model";
|
||||
import { StaleExecutionRuntimeError } from "../src/shared/runtime/execution-runtime-lifetime";
|
||||
|
||||
const { openThreadPickerMock, withShortLivedAppServerClientMock } = vi.hoisted(() => ({
|
||||
openThreadPickerMock: vi.fn(),
|
||||
|
|
@ -80,16 +81,45 @@ describe("CodexExecutionRuntime", () => {
|
|||
);
|
||||
const runtime = executionRuntime();
|
||||
|
||||
const fetch = runtime.appServerQueries.fetchModels();
|
||||
const fetch = attachChatHost(runtime).appServerQueries.fetchModels();
|
||||
await Promise.resolve();
|
||||
runtime.dispose();
|
||||
|
||||
expect(client.disconnect).toHaveBeenCalledOnce();
|
||||
resolveModels({ data: [] });
|
||||
await expect(fetch).rejects.toBeInstanceOf(StaleAppServerResourceContextError);
|
||||
await expect(fetch).rejects.toBeInstanceOf(StaleExecutionRuntimeError);
|
||||
});
|
||||
|
||||
it("classifies a rejected short-lived operation as stale when disposed in flight", async () => {
|
||||
let rejectOperation: (error: Error) => void = () => undefined;
|
||||
const operation = new Promise<never>((_resolve, reject) => {
|
||||
rejectOperation = reject;
|
||||
});
|
||||
withShortLivedAppServerClientMock.mockReturnValue(operation);
|
||||
const runtime = executionRuntime();
|
||||
|
||||
const request = attachChatHost(runtime).appServerClientAccess.withClient(() => Promise.resolve("unused"));
|
||||
runtime.dispose();
|
||||
rejectOperation(new Error("Disconnected"));
|
||||
|
||||
await expect(request).rejects.toBeInstanceOf(StaleExecutionRuntimeError);
|
||||
});
|
||||
});
|
||||
|
||||
function attachChatHost(runtime: CodexExecutionRuntime): CodexChatHost {
|
||||
let host: CodexChatHost | null = null;
|
||||
const view: ChatRuntimeView = {
|
||||
attachRuntime: (nextHost) => {
|
||||
host = nextHost;
|
||||
},
|
||||
detachRuntime: vi.fn(),
|
||||
activateRuntime: vi.fn(),
|
||||
};
|
||||
runtime.attachChatView(view);
|
||||
if (!host) throw new Error("Runtime did not attach a chat host");
|
||||
return host;
|
||||
}
|
||||
|
||||
function pickerFactory(): {
|
||||
controllers: Array<ThreadPickerController & { close: ReturnType<typeof vi.fn> }>;
|
||||
finish: Array<() => void>;
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ function createActionsHarness({ connected = false, canConnect = true } = {}) {
|
|||
addSystemMessage: vi.fn(),
|
||||
configuredCommand: () => "codex",
|
||||
isStaleConnectionError: () => false,
|
||||
isStaleResourceContextError: () => false,
|
||||
isStaleRuntimeError: () => false,
|
||||
notifyConnectionFailed: vi.fn(),
|
||||
};
|
||||
return {
|
||||
|
|
@ -172,11 +172,11 @@ describe("ChatConnectionActions", () => {
|
|||
const { actions, host } = createActionsHarness({ connected: true });
|
||||
const error = new Error("stale");
|
||||
vi.mocked(host.refreshSharedThreads).mockRejectedValueOnce(error);
|
||||
host.isStaleResourceContextError = vi.fn((candidate) => candidate === error);
|
||||
host.isStaleRuntimeError = vi.fn((candidate) => candidate === error);
|
||||
|
||||
await actions.refreshActiveThreads();
|
||||
|
||||
expect(host.isStaleResourceContextError).toHaveBeenCalledWith(error);
|
||||
expect(host.isStaleRuntimeError).toHaveBeenCalledWith(error);
|
||||
expect(host.addSystemMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ describe("server metadata actions", () => {
|
|||
stateStore,
|
||||
...metadataCacheHost(),
|
||||
refreshAppServerMetadata,
|
||||
isStaleResourceContextError: () => false,
|
||||
isStaleRuntimeError: () => false,
|
||||
});
|
||||
|
||||
await actions.refreshAppServerMetadata();
|
||||
|
|
@ -52,7 +52,7 @@ describe("server metadata actions", () => {
|
|||
const actions = createServerMetadataActions({
|
||||
stateStore,
|
||||
...metadataCacheHost(),
|
||||
isStaleResourceContextError: () => false,
|
||||
isStaleRuntimeError: () => false,
|
||||
});
|
||||
|
||||
actions.applyAppServerMetadataResource({
|
||||
|
|
@ -75,7 +75,7 @@ describe("server metadata actions", () => {
|
|||
stateStore,
|
||||
...metadataCacheHost(cache),
|
||||
refreshAppServerMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
isStaleResourceContextError: () => false,
|
||||
isStaleRuntimeError: () => false,
|
||||
});
|
||||
|
||||
await actions.applyAppServerResourceEvent({
|
||||
|
|
@ -96,7 +96,7 @@ describe("server metadata actions", () => {
|
|||
stateStore,
|
||||
...metadataCacheHost(),
|
||||
refreshAppServerMetadata: vi.fn().mockRejectedValue(stale),
|
||||
isStaleResourceContextError: (error) => error === stale,
|
||||
isStaleRuntimeError: (error) => error === stale,
|
||||
});
|
||||
|
||||
await expect(actions.refreshAppServerMetadata()).resolves.toBeUndefined();
|
||||
|
|
@ -112,7 +112,7 @@ describe("server metadata actions", () => {
|
|||
stateStore,
|
||||
...metadataCacheHost(),
|
||||
refreshSkills: vi.fn().mockRejectedValue(stale),
|
||||
isStaleResourceContextError: (error) => error === stale,
|
||||
isStaleRuntimeError: (error) => error === stale,
|
||||
});
|
||||
|
||||
await actions.applyAppServerResourceEvent({ type: "skills-changed" });
|
||||
|
|
@ -172,7 +172,7 @@ describe("server diagnostics actions", () => {
|
|||
refreshAppServerMetadata: vi.fn().mockImplementation(async () => {
|
||||
cache.current = refreshedMetadata;
|
||||
}),
|
||||
isStaleResourceContextError: () => false,
|
||||
isStaleRuntimeError: () => false,
|
||||
});
|
||||
const readServerDiagnostics = vi.fn().mockResolvedValue(serverDiagnosticsSnapshot());
|
||||
const diagnostics = createServerDiagnosticsActions({
|
||||
|
|
@ -269,7 +269,7 @@ describe("server diagnostics actions", () => {
|
|||
stateStore,
|
||||
...metadataCache,
|
||||
refreshAppServerMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
isStaleResourceContextError: () => false,
|
||||
isStaleRuntimeError: () => false,
|
||||
});
|
||||
const diagnostics = createServerDiagnosticsActions({
|
||||
stateStore,
|
||||
|
|
@ -422,14 +422,14 @@ function metadataCacheHost(cache: { current: SharedServerMetadata | null } = { c
|
|||
refreshAppServerMetadata: () => Promise<void>;
|
||||
refreshSkills: () => Promise<void>;
|
||||
refreshRateLimits: () => Promise<void>;
|
||||
isStaleResourceContextError: (error: unknown) => boolean;
|
||||
isStaleRuntimeError: (error: unknown) => boolean;
|
||||
} {
|
||||
return {
|
||||
appServerMetadataSnapshot: () => cache.current,
|
||||
refreshAppServerMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
refreshSkills: vi.fn().mockResolvedValue(undefined),
|
||||
refreshRateLimits: vi.fn().mockResolvedValue(undefined),
|
||||
isStaleResourceContextError: () => false,
|
||||
isStaleRuntimeError: () => false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { StaleAppServerResourceContextError } from "../../../../src/app-server/query/cache";
|
||||
import type { Thread } from "../../../../src/domain/threads/model";
|
||||
import { type ChatStateStore, createChatStateStore } from "../../../../src/features/chat/application/state/store";
|
||||
import { createThreadGoalOperationCoordinator } from "../../../../src/features/chat/application/threads/goal-actions";
|
||||
|
|
@ -13,6 +12,7 @@ import { ChatPanelSessionRuntime } from "../../../../src/features/chat/host/sess
|
|||
import { createChatThreadStreamScrollBinding } from "../../../../src/features/chat/panel/thread-stream-scroll-binding";
|
||||
import { createThreadNameMutationCoordinator } from "../../../../src/features/threads/workflows/thread-name-mutation-coordinator";
|
||||
import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../../../src/settings/model";
|
||||
import { StaleExecutionRuntimeError } from "../../../../src/shared/runtime/execution-runtime-lifetime";
|
||||
import { createKeyedOperationQueue } from "../../../../src/shared/runtime/keyed-operation-queue";
|
||||
import { deferred, waitForAsyncWork } from "../../../support/async";
|
||||
import { installObsidianDomShims } from "../../../support/dom";
|
||||
|
|
@ -73,7 +73,7 @@ describe("ChatPanelSessionRuntime actions", () => {
|
|||
});
|
||||
|
||||
it("treats stale shared thread refreshes as runtime-local no-ops", async () => {
|
||||
const refresh = vi.fn().mockRejectedValue(new StaleAppServerResourceContextError());
|
||||
const refresh = vi.fn().mockRejectedValue(new StaleExecutionRuntimeError());
|
||||
const { runtime, stateStore } = sessionRuntimeFixture({
|
||||
environment: {
|
||||
plugin: {
|
||||
|
|
|
|||
|
|
@ -51,9 +51,45 @@ describe("chat toolbar archive confirmation state", () => {
|
|||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
|
||||
it("reprojects the archive default when settings refresh the mounted shell", async () => {
|
||||
const store = createChatStateStore();
|
||||
const container = document.createElement("div");
|
||||
const toolbarActions = createToolbarPanelActions({
|
||||
stateStore: store,
|
||||
threadActions: { archiveThread: vi.fn() } as unknown as ThreadManagementActions,
|
||||
});
|
||||
let archiveExportEnabled = true;
|
||||
const parts = shellParts(store, toolbarActions, () => archiveExportEnabled);
|
||||
store.dispatch({ type: "thread-list/applied", threads: [threadFixture("thread-1", "Thread one")] });
|
||||
store.dispatch({ type: "ui/panel-set", panel: "history" });
|
||||
toolbarActions.startArchive("thread-1");
|
||||
document.body.appendChild(container);
|
||||
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, { stateStore: store, showToolbar: true, parts });
|
||||
await settle();
|
||||
});
|
||||
expect(container.querySelector(".codex-panel__archive-default")?.getAttribute("aria-label")).toBe("Save and archive thread");
|
||||
|
||||
archiveExportEnabled = false;
|
||||
await act(async () => {
|
||||
renderChatPanelShell(container, { stateStore: store, showToolbar: true, parts });
|
||||
await settle();
|
||||
});
|
||||
expect(container.querySelector(".codex-panel__archive-default")?.getAttribute("aria-label")).toBe("Archive thread without saving");
|
||||
|
||||
await act(async () => {
|
||||
unmountChatPanelShell(container);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function toolbarSurface(_store: ReturnType<typeof createChatStateStore>, _toolbarActions: ToolbarPanelActions): ChatPanelToolbarSurface {
|
||||
function toolbarSurface(
|
||||
_store: ReturnType<typeof createChatStateStore>,
|
||||
_toolbarActions: ToolbarPanelActions,
|
||||
archiveExportEnabled: () => boolean,
|
||||
): ChatPanelToolbarSurface {
|
||||
return {
|
||||
connection: {
|
||||
connected: () => false,
|
||||
|
|
@ -64,13 +100,17 @@ function toolbarSurface(_store: ReturnType<typeof createChatStateStore>, _toolba
|
|||
settings: {
|
||||
vaultPath: () => "/vault",
|
||||
configuredCommand: () => "codex",
|
||||
archiveExportEnabled: () => true,
|
||||
archiveExportEnabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarPanelActions: ToolbarPanelActions): ChatPanelShellParts {
|
||||
const surface = surfaceFixture(store, toolbarPanelActions);
|
||||
function shellParts(
|
||||
store: ReturnType<typeof createChatStateStore>,
|
||||
toolbarPanelActions: ToolbarPanelActions,
|
||||
archiveExportEnabled: () => boolean = () => true,
|
||||
): ChatPanelShellParts {
|
||||
const surface = surfaceFixture(store, toolbarPanelActions, archiveExportEnabled);
|
||||
return {
|
||||
toolbar: {
|
||||
surface: surface.toolbar,
|
||||
|
|
@ -168,12 +208,13 @@ function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarPanel
|
|||
function surfaceFixture(
|
||||
store: ReturnType<typeof createChatStateStore>,
|
||||
toolbarActions: ToolbarPanelActions,
|
||||
archiveExportEnabled: () => boolean,
|
||||
): {
|
||||
toolbar: ChatPanelToolbarSurface;
|
||||
goal: ChatPanelGoalSurface;
|
||||
} {
|
||||
return {
|
||||
toolbar: toolbarSurface(store, toolbarActions),
|
||||
toolbar: toolbarSurface(store, toolbarActions, archiveExportEnabled),
|
||||
goal: {
|
||||
sendShortcut: () => "enter",
|
||||
actions: {
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ describe("CodexPanelPlugin runtime integration", () => {
|
|||
);
|
||||
|
||||
const first = threadCatalog(plugin).refreshActive();
|
||||
const staleFirst = expect(first).rejects.toThrow("Codex app-server resource context changed while loading.");
|
||||
const staleFirst = expect(first).rejects.toThrow("Codex execution runtime was disposed while work was in progress.");
|
||||
await flushMicrotasks();
|
||||
await publishCodexPath(plugin, "codex-b");
|
||||
const second = threadCatalog(plugin).refreshActive();
|
||||
|
|
@ -230,7 +230,7 @@ describe("CodexPanelPlugin runtime integration", () => {
|
|||
await publishCodexPath(plugin, "codex-b");
|
||||
result.resolve("stale-result");
|
||||
|
||||
await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading.");
|
||||
await expect(operation).rejects.toThrow("Codex execution runtime was disposed while work was in progress.");
|
||||
});
|
||||
|
||||
it("rejects an old A result after switching from A to B and back to A", async () => {
|
||||
|
|
@ -246,7 +246,7 @@ describe("CodexPanelPlugin runtime integration", () => {
|
|||
await publishCodexPath(plugin, "codex-a");
|
||||
result.resolve("stale-a");
|
||||
|
||||
await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading.");
|
||||
await expect(operation).rejects.toThrow("Codex execution runtime was disposed while work was in progress.");
|
||||
expect(currentChatHost(plugin).appServerContext).toEqual({ codexPath: "codex-a", vaultPath: "/vault" });
|
||||
});
|
||||
|
||||
|
|
@ -267,7 +267,7 @@ describe("CodexPanelPlugin runtime integration", () => {
|
|||
await publishCodexPath(plugin, "codex-b");
|
||||
clientReady.resolve();
|
||||
|
||||
await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading.");
|
||||
await expect(operation).rejects.toThrow("Codex execution runtime was disposed while work was in progress.");
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
expect(shortLivedClient.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
|
||||
import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog";
|
||||
import type { ThreadRecord } from "../../src/app-server/protocol/thread";
|
||||
import { StaleAppServerResourceContextError } from "../../src/app-server/query/cache";
|
||||
import type { ObservedResult } from "../../src/app-server/query/observed-result";
|
||||
import type { ModelMetadata } from "../../src/domain/catalog/metadata";
|
||||
import type { Thread } from "../../src/domain/threads/model";
|
||||
import { SettingsDynamicSectionsController } from "../../src/settings/dynamic-sections-controller";
|
||||
import { StaleExecutionRuntimeError } from "../../src/shared/runtime/execution-runtime-lifetime";
|
||||
import { deferred } from "../support/async";
|
||||
import {
|
||||
appServerThread,
|
||||
|
|
@ -350,7 +350,7 @@ describe("SettingsDynamicSectionsController", () => {
|
|||
oldRestore.resolve({
|
||||
thread: appServerThread({ id: "thread-shared", preview: "Old context" }),
|
||||
});
|
||||
await expect(staleMutation).rejects.toBeInstanceOf(StaleAppServerResourceContextError);
|
||||
await expect(staleMutation).rejects.toBeInstanceOf(StaleExecutionRuntimeError);
|
||||
});
|
||||
|
||||
it("records restored archived threads in the active catalog", async () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type { AppServerClient } from "../../src/app-server/connection/client";
|
|||
import type { AppServerClientAccessOptions } from "../../src/app-server/connection/client-access";
|
||||
import type { CatalogHookMetadata, CatalogModel } from "../../src/app-server/protocol/catalog";
|
||||
import type { ThreadRecord } from "../../src/app-server/protocol/thread";
|
||||
import { StaleAppServerResourceContextError } from "../../src/app-server/query/cache";
|
||||
import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata";
|
||||
import type { Thread } from "../../src/domain/threads/model";
|
||||
import type { ThreadCatalogEvent } from "../../src/features/threads/catalog/thread-catalog";
|
||||
|
|
@ -12,6 +11,7 @@ import { createSettingsAppServerDynamicData } from "../../src/settings/app-serve
|
|||
import type { SettingsDynamicDataAccess } from "../../src/settings/dynamic-data";
|
||||
import type { CodexPanelSettingTabHost } from "../../src/settings/host";
|
||||
import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../src/settings/model";
|
||||
import { StaleExecutionRuntimeError } from "../../src/shared/runtime/execution-runtime-lifetime";
|
||||
|
||||
type ShortLivedClientOperation = (
|
||||
codexPath: string,
|
||||
|
|
@ -234,12 +234,12 @@ export function settingsTabHost(options: SettingsTabHostOptions = {}): CodexPane
|
|||
settings.codexPath,
|
||||
"/vault",
|
||||
async (client) => {
|
||||
if (appServerQueries.contextKey() !== contextKey) throw new StaleAppServerResourceContextError();
|
||||
if (appServerQueries.contextKey() !== contextKey) throw new StaleExecutionRuntimeError();
|
||||
return operation(client);
|
||||
},
|
||||
clientOptions,
|
||||
)) as T;
|
||||
if (appServerQueries.contextKey() !== contextKey) throw new StaleAppServerResourceContextError();
|
||||
if (appServerQueries.contextKey() !== contextKey) throw new StaleExecutionRuntimeError();
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue