Simplify settings dynamic section lifecycle

This commit is contained in:
murashit 2026-06-30 23:13:12 +09:00
parent 478e5ef563
commit 332f11ab7e
3 changed files with 55 additions and 184 deletions

View file

@ -9,7 +9,9 @@ import type { SettingsDynamicSectionsHost } from "./host";
import {
createSettingsDynamicSectionLifecycle,
type SettingsDynamicSectionLifecycleState,
transitionSettingsDynamicSectionLifecycle,
settingsDynamicSectionFailed,
settingsDynamicSectionLoaded,
settingsDynamicSectionLoading,
} from "./lifecycle";
interface SettingsDynamicSectionsControllerCallbacks {
@ -32,7 +34,6 @@ export interface SettingsDynamicSectionsSnapshot {
export class SettingsDynamicSectionsController {
private dynamicSectionsAutoLoadStarted = false;
private dynamicSectionsRefreshOperationToken = 0;
private modelsOperationToken = 0;
private hooksOperationToken = 0;
private archivedThreadsOperationToken = 0;
@ -62,11 +63,7 @@ export class SettingsDynamicSectionsController {
if (archivedThreads) {
this.archivedThreads = [...archivedThreads];
this.archivedThreadsLoaded = true;
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: archivedThreadsStatus(archivedThreads.length),
operationToken: this.archivedThreadsOperationToken,
});
this.archivedThreadsLifecycle = settingsDynamicSectionLoaded(archivedThreadsStatus(archivedThreads.length));
}
this.unsubscribeModels = this.host.dynamicData.observeModelsResult(
(result) => {
@ -90,7 +87,6 @@ export class SettingsDynamicSectionsController {
resetDynamicSectionContext(): void {
this.dynamicSectionsAutoLoadStarted = false;
this.dynamicSectionsRefreshOperationToken += 1;
this.modelsOperationToken += 1;
this.hooksOperationToken += 1;
this.archivedThreadsOperationToken += 1;
@ -126,36 +122,19 @@ export class SettingsDynamicSectionsController {
this.archivedThreads = [...observedThreads];
this.archivedThreadsLoaded = true;
if (this.archivedThreadsLifecycle.kind !== "loading") {
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: archivedThreadsStatus(observedThreads.length),
operationToken: this.archivedThreadsOperationToken,
});
this.archivedThreadsLifecycle = settingsDynamicSectionLoaded(archivedThreadsStatus(observedThreads.length));
}
this.callbacks.display();
}
async refreshDynamicSections(options: { forceModels?: boolean } = {}): Promise<void> {
this.dynamicSectionsAutoLoadStarted = true;
const operationToken = this.nextDynamicSectionsRefreshOperationToken();
const modelsOperationToken = this.nextModelsOperationToken();
const hooksOperationToken = this.nextHooksOperationToken();
const archivedThreadsOperationToken = this.nextArchivedThreadsOperationToken();
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "started",
status: "Loading models...",
operationToken: modelsOperationToken,
});
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "started",
status: "Loading archived threads...",
operationToken: archivedThreadsOperationToken,
});
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "started",
status: "Loading hooks...",
operationToken: hooksOperationToken,
});
this.modelsLifecycle = settingsDynamicSectionLoading("Loading models...");
this.archivedThreadsLifecycle = settingsDynamicSectionLoading("Loading archived threads...");
this.hooksLifecycle = settingsDynamicSectionLoading("Loading hooks...");
this.callbacks.display();
let failedCount = 0;
@ -165,26 +144,19 @@ export class SettingsDynamicSectionsController {
this.host.dynamicData.loadHooks(),
this.host.dynamicData.refreshArchivedThreads(),
] as const);
if (this.isStaleDynamicSectionsRefreshOperation(operationToken)) return;
if (this.isStaleModelsOperation(modelsOperationToken)) {
// A newer models operation owns this section.
} else if (modelsResult.status === "fulfilled") {
this.models = [...modelsResult.value];
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "loaded",
status: `Loaded ${String(modelsResult.value.length)} model${modelsResult.value.length === 1 ? "" : "s"}.`,
operationToken: modelsOperationToken,
});
this.modelsLifecycle = settingsDynamicSectionLoaded(
`Loaded ${String(modelsResult.value.length)} model${modelsResult.value.length === 1 ? "" : "s"}.`,
);
} else if (isStaleSettingsDynamicDataContextError(modelsResult.reason)) {
return;
} else {
failedCount += 1;
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "failed",
status: `Could not load models: ${errorMessage(modelsResult.reason)}`,
operationToken: modelsOperationToken,
});
this.modelsLifecycle = settingsDynamicSectionFailed(`Could not load models: ${errorMessage(modelsResult.reason)}`);
}
if (this.isStaleHooksOperation(hooksOperationToken)) {
@ -194,18 +166,10 @@ export class SettingsDynamicSectionsController {
this.hookWarnings = [...hooksResult.value.warnings];
this.hookErrors = [...hooksResult.value.errors];
this.hooksLoaded = true;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
status: hooksResult.value.status,
operationToken: hooksOperationToken,
});
this.hooksLifecycle = settingsDynamicSectionLoaded(hooksResult.value.status);
} else {
failedCount += 1;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "failed",
status: `Could not load hooks: ${errorMessage(hooksResult.reason)}`,
operationToken: hooksOperationToken,
});
this.hooksLifecycle = settingsDynamicSectionFailed(`Could not load hooks: ${errorMessage(hooksResult.reason)}`);
}
if (this.isStaleArchivedThreadsOperation(archivedThreadsOperationToken)) {
@ -213,54 +177,32 @@ export class SettingsDynamicSectionsController {
} else if (archivedThreadsResult.status === "fulfilled") {
this.archivedThreads = [...archivedThreadsResult.value];
this.archivedThreadsLoaded = true;
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: archivedThreadsStatus(archivedThreadsResult.value.length),
operationToken: archivedThreadsOperationToken,
});
this.archivedThreadsLifecycle = settingsDynamicSectionLoaded(archivedThreadsStatus(archivedThreadsResult.value.length));
} else if (isStaleSettingsDynamicDataContextError(archivedThreadsResult.reason)) {
return;
} else {
failedCount += 1;
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "failed",
status: `Could not load archived threads: ${errorMessage(archivedThreadsResult.reason)}`,
operationToken: archivedThreadsOperationToken,
});
this.archivedThreadsLifecycle = settingsDynamicSectionFailed(
`Could not load archived threads: ${errorMessage(archivedThreadsResult.reason)}`,
);
}
} catch (error) {
if (this.isStaleDynamicSectionsRefreshOperation(operationToken)) return;
failedCount = 3;
const message = errorMessage(error);
if (!this.isStaleModelsOperation(modelsOperationToken)) {
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "failed",
status: `Could not load models: ${message}`,
operationToken: modelsOperationToken,
});
this.modelsLifecycle = settingsDynamicSectionFailed(`Could not load models: ${message}`);
}
if (!this.isStaleHooksOperation(hooksOperationToken)) {
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "failed",
status: `Could not load hooks: ${message}`,
operationToken: hooksOperationToken,
});
this.hooksLifecycle = settingsDynamicSectionFailed(`Could not load hooks: ${message}`);
}
if (!this.isStaleArchivedThreadsOperation(archivedThreadsOperationToken)) {
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "failed",
status: `Could not load archived threads: ${message}`,
operationToken: archivedThreadsOperationToken,
});
this.archivedThreadsLifecycle = settingsDynamicSectionFailed(`Could not load archived threads: ${message}`);
}
} finally {
const staleRefresh = this.isStaleDynamicSectionsRefreshOperation(operationToken);
if (!staleRefresh) {
if (failedCount > 0) {
this.callbacks.notify("Could not refresh all Codex details.");
}
this.callbacks.display();
if (failedCount > 0) {
this.callbacks.notify("Could not refresh all Codex details.");
}
this.callbacks.display();
}
}
@ -294,11 +236,7 @@ export class SettingsDynamicSectionsController {
operation: async (operationToken) => {
await this.host.dynamicData.trustHook(hook);
if (this.isStaleHooksOperation(operationToken)) return;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
status: "Trusted hook definition.",
operationToken,
});
this.hooksLifecycle = settingsDynamicSectionLoaded("Trusted hook definition.");
await this.loadHooks();
},
});
@ -313,11 +251,7 @@ export class SettingsDynamicSectionsController {
operation: async (operationToken) => {
await this.host.dynamicData.setHookEnabled(hook, enabled);
if (this.isStaleHooksOperation(operationToken)) return;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
status: enabled ? "Enabled hook." : "Disabled hook.",
operationToken,
});
this.hooksLifecycle = settingsDynamicSectionLoaded(enabled ? "Enabled hook." : "Disabled hook.");
await this.loadHooks();
},
});
@ -335,11 +269,7 @@ export class SettingsDynamicSectionsController {
});
if (this.isStaleArchivedThreadsOperation(operationToken)) return;
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: `Restored "${threadArchiveDisplayTitle(restoredThread)}".`,
operationToken,
});
this.archivedThreadsLifecycle = settingsDynamicSectionLoaded(`Restored "${threadArchiveDisplayTitle(restoredThread)}".`);
},
});
}
@ -358,11 +288,7 @@ export class SettingsDynamicSectionsController {
});
if (this.isStaleArchivedThreadsOperation(operationToken)) return;
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: `Deleted "${title}".`,
operationToken,
});
this.archivedThreadsLifecycle = settingsDynamicSectionLoaded(`Deleted "${title}".`);
},
});
}
@ -397,11 +323,7 @@ export class SettingsDynamicSectionsController {
this.hookWarnings = [...hooks.warnings];
this.hookErrors = [...hooks.errors];
this.hooksLoaded = true;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
status: hooks.status,
operationToken,
});
this.hooksLifecycle = settingsDynamicSectionLoaded(hooks.status);
},
});
}
@ -416,8 +338,6 @@ export class SettingsDynamicSectionsController {
const operationToken = options.section === "hooks" ? this.nextHooksOperationToken() : this.nextArchivedThreadsOperationToken();
const stale = (): boolean =>
options.section === "hooks" ? this.isStaleHooksOperation(operationToken) : this.isStaleArchivedThreadsOperation(operationToken);
const lifecycle = (): SettingsDynamicSectionLifecycleState =>
options.section === "hooks" ? this.hooksLifecycle : this.archivedThreadsLifecycle;
const setLifecycle = (state: SettingsDynamicSectionLifecycleState): void => {
if (options.section === "hooks") {
this.hooksLifecycle = state;
@ -426,36 +346,19 @@ export class SettingsDynamicSectionsController {
}
};
setLifecycle(
transitionSettingsDynamicSectionLifecycle(lifecycle(), {
type: "started",
status: options.loadingStatus,
operationToken,
}),
);
setLifecycle(settingsDynamicSectionLoading(options.loadingStatus));
this.callbacks.display();
try {
await options.operation(operationToken);
} catch (error) {
if (stale()) return;
setLifecycle(
transitionSettingsDynamicSectionLifecycle(lifecycle(), {
type: "failed",
status: options.failureStatus(error),
operationToken,
}),
);
setLifecycle(settingsDynamicSectionFailed(options.failureStatus(error)));
this.callbacks.notify(options.failureNotice);
} finally {
if (!stale()) this.callbacks.display();
}
}
private nextDynamicSectionsRefreshOperationToken(): number {
this.dynamicSectionsRefreshOperationToken += 1;
return this.dynamicSectionsRefreshOperationToken;
}
private nextModelsOperationToken(): number {
this.modelsOperationToken += 1;
return this.modelsOperationToken;
@ -471,10 +374,6 @@ export class SettingsDynamicSectionsController {
return this.archivedThreadsOperationToken;
}
private isStaleDynamicSectionsRefreshOperation(operationToken: number): boolean {
return operationToken !== this.dynamicSectionsRefreshOperationToken;
}
private isStaleModelsOperation(operationToken: number): boolean {
return operationToken !== this.modelsOperationToken;
}

View file

@ -1,38 +1,21 @@
export type SettingsDynamicSectionLifecycleState =
| { kind: "idle"; status: "" }
| { kind: "loading"; status: string; operationToken: number }
| { kind: "loaded"; status: string; operationToken: number }
| { kind: "failed"; status: string; operationToken: number };
export type SettingsDynamicSectionLifecycleEvent =
| { type: "started"; status: string; operationToken: number }
| { type: "loaded"; status: string; operationToken: number }
| { type: "failed"; status: string; operationToken: number }
| { type: "reset" };
| { kind: "loading"; status: string }
| { kind: "loaded"; status: string }
| { kind: "failed"; status: string };
export function createSettingsDynamicSectionLifecycle(): SettingsDynamicSectionLifecycleState {
return { kind: "idle", status: "" };
}
export function transitionSettingsDynamicSectionLifecycle(
state: SettingsDynamicSectionLifecycleState,
event: SettingsDynamicSectionLifecycleEvent,
): SettingsDynamicSectionLifecycleState {
switch (event.type) {
case "started":
if (isStaleSettingsDynamicSectionEvent(state, event.operationToken)) return state;
return { kind: "loading", status: event.status, operationToken: event.operationToken };
case "loaded":
if (isStaleSettingsDynamicSectionEvent(state, event.operationToken)) return state;
return { kind: "loaded", status: event.status, operationToken: event.operationToken };
case "failed":
if (isStaleSettingsDynamicSectionEvent(state, event.operationToken)) return state;
return { kind: "failed", status: event.status, operationToken: event.operationToken };
case "reset":
return createSettingsDynamicSectionLifecycle();
}
export function settingsDynamicSectionLoading(status: string): SettingsDynamicSectionLifecycleState {
return { kind: "loading", status };
}
function isStaleSettingsDynamicSectionEvent(state: SettingsDynamicSectionLifecycleState, operationToken: number): boolean {
return "operationToken" in state && state.operationToken > operationToken;
export function settingsDynamicSectionLoaded(status: string): SettingsDynamicSectionLifecycleState {
return { kind: "loaded", status };
}
export function settingsDynamicSectionFailed(status: string): SettingsDynamicSectionLifecycleState {
return { kind: "failed", status };
}

View file

@ -1,36 +1,25 @@
import { describe, expect, it } from "vitest";
import { createSettingsDynamicSectionLifecycle, transitionSettingsDynamicSectionLifecycle } from "../../src/settings/lifecycle";
import {
createSettingsDynamicSectionLifecycle,
settingsDynamicSectionFailed,
settingsDynamicSectionLoaded,
settingsDynamicSectionLoading,
} from "../../src/settings/lifecycle";
describe("settings lifecycle", () => {
it("tracks dynamic section lifecycle", () => {
const idle = createSettingsDynamicSectionLifecycle();
expect(idle).toEqual({ kind: "idle", status: "" });
const loading = transitionSettingsDynamicSectionLifecycle(idle, { type: "started", status: "Loading hooks...", operationToken: 1 });
expect(loading).toEqual({ kind: "loading", status: "Loading hooks...", operationToken: 1 });
const loading = settingsDynamicSectionLoading("Loading hooks...");
expect(loading).toEqual({ kind: "loading", status: "Loading hooks..." });
expect(transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Stale result.", operationToken: 0 })).toBe(
loading,
);
const loaded = settingsDynamicSectionLoaded("Loaded 1 hook.");
expect(loaded).toEqual({ kind: "loaded", status: "Loaded 1 hook." });
const loaded = transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Loaded 1 hook.", operationToken: 1 });
expect(loaded).toEqual({ kind: "loaded", status: "Loaded 1 hook.", operationToken: 1 });
const failed = settingsDynamicSectionFailed("Could not load hooks.");
expect(failed).toEqual({ kind: "failed", status: "Could not load hooks." });
const failed = transitionSettingsDynamicSectionLifecycle(loaded, {
type: "failed",
status: "Could not load hooks.",
operationToken: 1,
});
expect(failed).toEqual({ kind: "failed", status: "Could not load hooks.", operationToken: 1 });
const laterLoaded = transitionSettingsDynamicSectionLifecycle(failed, { type: "loaded", status: "Loaded 2 hooks.", operationToken: 2 });
expect(
transitionSettingsDynamicSectionLifecycle(laterLoaded, { type: "started", status: "Loading old hooks...", operationToken: 1 }),
).toBe(laterLoaded);
expect(transitionSettingsDynamicSectionLifecycle(laterLoaded, { type: "failed", status: "Late old failure.", operationToken: 1 })).toBe(
laterLoaded,
);
expect(transitionSettingsDynamicSectionLifecycle(failed, { type: "reset" })).toEqual(idle);
expect(createSettingsDynamicSectionLifecycle()).toEqual(idle);
});
});