refactor: present Secrets reads and saves with native Solid actions (#180418)

Keep request admission and write settlement in the existing Secrets owner. Observe admitted reads, scope optimistic saving to its presentation, and release pending presentation on Gateway replacement or route departure.
This commit is contained in:
Peter Steinberger authored and GitHub committed 2026-10-22 01:29:09 -07:00
1 parent ad6af54167
commit d9e63d0471
5 files changed
+251 -59

No files matched your search

+124 -7
View File
@@ -1,3 +1,4 @@
import { createSignal, Show } from "solid-js";
import { afterEach, expect, it } from "vitest";
import type { SecretStoreEntry } from "../../../../packages/gateway-protocol/src/index.js";
import { createDeferred } from "../../../../test/helpers/promise.js";
@@ -28,11 +29,9 @@ function entry(name: string): SecretStoreEntry {
};
}
it("keeps reconnect reads current and releases the Gateway binding when the page unmounts", async () => {
const stale = createDeferred<{ entries: SecretStoreEntry[] }>();
const previousRequest = createGatewayRequestMock(async () => stale.promise);
function mountPage(request: ReturnType<typeof createGatewayRequestMock>) {
const snapshot: ApplicationGatewaySnapshot = {
client: createTestGatewayClient(previousRequest),
client: createTestGatewayClient(request),
phase: "connected",
offlineStable: false,
canvasPluginSurfaceUrl: null,
@@ -48,11 +47,70 @@ it("keeps reconnect reads current and releases the Gateway binding when the page
},
};
const source = createApplicationGateway(snapshot);
// SAFETY: Secrets consumes only the Gateway capability from this application fixture.
const context = { gateway: source.gateway } as ApplicationContext;
let pathname = "/settings/secrets";
const listeners = new Set<() => void>();
// SAFETY: Secrets consumes the Gateway and pathname subscription from this fixture.
const context = {
gateway: source.gateway,
router: {
getState: () => ({ location: { pathname } }),
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
} as ApplicationContext;
const provider = createSolidApplicationContextProvider(context);
const page = mountSolid(() => <SecretsPage />, { wrapper: provider.wrapper });
let hide = () => {};
const page = mountSolid(
() => {
const [visible, setVisible] = createSignal(true);
hide = () => setVisible(false);
return (
<Show when={visible()}>
<SecretsPage />
</Show>
);
},
{ wrapper: provider.wrapper },
);
flush();
return {
page,
source,
snapshot,
leaveRoute: () => {
pathname = "/settings/profile";
for (const listener of listeners) {
listener();
}
hide();
},
};
}
async function openExampleDraft(page: ReturnType<typeof mountSolid>) {
page.container.querySelector<HTMLButtonElement>("button.primary")!.click();
await waitForSolid(() =>
expect(page.container.querySelector("openclaw-modal-dialog")).not.toBeNull(),
);
for (const [selector, value] of [
['input[name="name"]', "EXAMPLE_URL"],
['textarea[name="value"]', "https://example.test"],
] as const) {
const input = page.container.querySelector<HTMLInputElement | HTMLTextAreaElement>(selector)!;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
flush();
return () =>
page.container.querySelector<HTMLButtonElement>('.secrets-store-dialog button[type="submit"]')!;
}
it("keeps reconnect reads current and releases the Gateway binding when the page unmounts", async () => {
const stale = createDeferred<{ entries: SecretStoreEntry[] }>();
const previousRequest = createGatewayRequestMock(async () => stale.promise);
const { page, source, snapshot } = mountPage(previousRequest);
expect(previousRequest).toHaveBeenCalledExactlyOnceWith("secrets.store.list", {});
source.publish({ ...snapshot, phase: "reconnecting" });
@@ -95,3 +153,62 @@ it("keeps reconnect reads current and releases the Gateway binding when the page
flush();
expect(retiredRequest).not.toHaveBeenCalled();
});
it("retains a failed save draft, retries it, and releases pending presentation on reconnect", async () => {
const firstSave = createDeferred<unknown>();
const secondSave = createDeferred<unknown>();
let saveCount = 0;
const request = createGatewayRequestMock(async (method) => {
if (method === "secrets.store.list") {
return { entries: [entry("SERVICE_URL")] };
}
if (method === "secrets.store.set") {
saveCount += 1;
return saveCount === 1 ? firstSave.promise : secondSave.promise;
}
throw new Error(`Unexpected method: ${method}`);
});
const { page, source, snapshot } = mountPage(request);
await waitForSolid(() => expect(page.container.textContent).toContain("SERVICE_URL"));
const submit = await openExampleDraft(page);
submit().click();
await waitForSolid(() => expect(submit().textContent).toContain("Saving"));
expect(submit().disabled).toBe(true);
firstSave.reject(new Error("Synthetic save unavailable"));
await waitForSolid(() => expect(submit().disabled).toBe(false));
expect(page.container.textContent).toContain("Synthetic save unavailable");
expect(page.container.querySelector<HTMLInputElement>('input[name="name"]')!.value).toBe(
"EXAMPLE_URL",
);
submit().click();
await waitForSolid(() => expect(submit().disabled).toBe(true));
expect(saveCount).toBe(2);
const replacement = createGatewayRequestMock(async () => ({ entries: [entry("CURRENT_URL")] }));
source.publish({ ...snapshot, client: createTestGatewayClient(replacement) });
await waitForSolid(() => expect(page.container.textContent).toContain("CURRENT_URL"));
expect(page.container.querySelector("openclaw-modal-dialog")).toBeNull();
secondSave.reject(new Error("Retired save unavailable"));
await Promise.resolve();
flush();
expect(page.container.textContent).not.toContain("Retired save unavailable");
});
it("releases route teardown while an admitted save is still pending", async () => {
const save = createDeferred<unknown>();
const request = createGatewayRequestMock(async (method) =>
method === "secrets.store.set" ? save.promise : { entries: [entry("SERVICE_URL")] },
);
const { page, leaveRoute } = mountPage(request);
await waitForSolid(() => expect(page.container.textContent).toContain("SERVICE_URL"));
const submit = await openExampleDraft(page);
submit().click();
await waitForSolid(() => expect(submit().disabled).toBe(true));
leaveRoute();
await waitForSolid(() =>
expect(page.container.querySelector("openclaw-secrets-page")).toBeNull(),
);
save.reject(new Error("Retired route save unavailable"));
await Promise.resolve();
flush();
expect(page.container.textContent).not.toContain("Retired route save unavailable");
});
+64 -14
View File
@@ -1,40 +1,90 @@
import { createMemo, createSignal, onSettled } from "solid-js";
import { action, createMemo, createOptimistic, onSettled } from "solid-js";
import { SettingsPageHeader } from "../../components/solid/settings-ui.tsx";
import { SettingsWorkspace } from "../../components/solid/settings-workspace.tsx";
import { formatUiError } from "../../lib/format-error.ts";
import { useApplication } from "../../lib/reactive/context.ts";
import { t } from "../../lib/reactive/i18n.ts";
import { projectMutableSource } from "../../lib/reactive/projection.ts";
import { SecretsPageState } from "./state.ts";
import { SecretsStore } from "./view.tsx";
export function SecretsPage() {
const context = useApplication();
const [revision, setRevision] = createSignal(0, { ownedWrite: true });
// This mount owns one secrets request state; Gateway replacement is bound below.
const model = new SecretsPageState(context, () => setRevision((value) => value + 1));
const model = new SecretsPageState(context, () => state.publish());
const state = projectMutableSource(model, (owner) => owner);
onSettled(() => {
const release = model.bindGateway(context.gateway);
const pathname = context.router.getState().location.pathname;
// Route intent releases the action before Solid can park the page's cleanup.
const releaseRoute = context.router.subscribe(() => {
if (context.router.getState().location.pathname !== pathname) {
model.retirePresentation();
}
});
return () => {
releaseRoute();
release();
model.dispose();
};
});
const state = createMemo(
() => {
revision();
return model;
},
{ equals: false },
);
const parsed = createMemo(() => {
revision();
return model.bulkParsed;
const scope = createMemo(() => state.read().store);
const receipt = createMemo(() => state.read().readReceipt);
const entries = createMemo(() => state.read().store.entries);
const read = createMemo(() => {
const store = scope();
const admitted = receipt();
entries();
return admitted?.store === store
? admitted.result.then(() => store.entries)
: Promise.resolve(store.entries);
});
const presentation = createMemo(() => {
const store = scope();
const [saving, setSaving] = createOptimistic(false);
return { store, saving, setSaving };
});
const presentMutation = action(function* (
view: ReturnType<typeof presentation>,
pending: Promise<void>,
retired: Promise<void>,
) {
try {
if (model.store === view.store) {
view.setSaving(true);
}
// Retiring the page releases presentation without cancelling an admitted write.
yield Promise.race([pending, retired]);
} catch (error) {
if (model.store === view.store) {
model.store.error = formatUiError(error);
model.notify();
}
}
});
async function mutate(operation: () => Promise<void> | undefined) {
const view = presentation();
const retired = model.presentationLifetime.promise;
const pending = operation();
model.notify();
if (pending) {
// Publish the owner's synchronous admission before joining the action.
await Promise.resolve();
await presentMutation(view, pending, retired);
}
}
const parsed = createMemo(() => state.read().bulkParsed);
return (
<openclaw-secrets-page>
<SettingsPageHeader title={t("tabs.secrets")} subtitle={t("secretsStore.hint")} />
<SettingsWorkspace>
<SecretsStore
state={state()}
state={state.read()}
scope={scope()}
read={read}
busy={presentation().saving() || state.read().store.busy}
onSubmit={(bulk) => void mutate(() => (bulk ? model.submitBulk() : model.submitDraft()))}
onRemove={(entry) => void mutate(() => model.removeEntry(entry))}
bulkSecretCount={parsed().entries.filter((entry) => entry.kind === "secret").length}
bulkEntryCount={parsed().entries.length}
bulkInvalidNames={parsed().invalidNames}
+27 -10
View File
@@ -20,6 +20,8 @@ const MAX_VALUE_BYTES = 64 * 1024;
export class SecretsPageState {
store = createInitialSecretsStoreState();
readReceipt: { store: SecretsStoreState; result: Promise<void> } | null = null;
presentationLifetime = Promise.withResolvers<void>();
dialogMode: "add" | "edit" | null = null;
draft: SecretsStoreDraft = {
name: "",
@@ -75,7 +77,14 @@ export class SecretsPageState {
this.resetGatewayState();
}
retirePresentation() {
this.presentationLifetime.resolve();
this.presentationLifetime = Promise.withResolvers<void>();
}
resetGatewayState(snapshot?: ApplicationContext["gateway"]["snapshot"]) {
this.retirePresentation();
this.readReceipt = null;
this.store = createInitialSecretsStoreState({
client: snapshot?.client ?? null,
connected: snapshot?.phase === "connected",
@@ -92,10 +101,18 @@ export class SecretsPageState {
ensureInitialData() {
if (this.canCall("secrets.store.list") && !this.store.loaded && !this.store.loading) {
void this.runStoreTask(loadSecretsStore);
this.reload();
}
}
reload() {
if (!this.canCall("secrets.store.list") || this.store.loading) {
return;
}
this.readReceipt = { store: this.store, result: this.runStoreTask(loadSecretsStore) };
this.notify();
}
async runStoreTask(task: (store: SecretsStoreState) => Promise<unknown>): Promise<void> {
const store = this.store;
try {
@@ -166,7 +183,7 @@ export class SecretsPageState {
save: (store: SecretsStoreState) => Promise<Result | null>,
finish: (result: Result) => string,
) {
void this.runStoreTask(async (store) => {
return this.runStoreTask(async (store) => {
const result = await save(store);
if (this.store !== store) {
return;
@@ -185,17 +202,17 @@ export class SecretsPageState {
submitDraft() {
if (!this.canCall("secrets.store.set") || !this.dialogMode) {
return;
return undefined;
}
const error = ENV_SECRET_REF_ID_RE.test(this.draft.name)
? this.validateValue(this.draft.value, this.draft.kind)
: t("secretsStore.badName");
if (error) {
this.formError = error;
return;
return undefined;
}
const draft = { ...this.draft };
this.saveEntryTask(
return this.saveEntryTask(
(store) => setSecretsStoreEntry(store, draft),
() => {
this.dialogMode = null;
@@ -224,25 +241,25 @@ export class SecretsPageState {
submitBulk() {
if (!this.canCall("secrets.store.set") || !this.bulkOpen) {
return;
return undefined;
}
const parsed = this.bulkParsed;
if (parsed.invalidNames.length > 0) {
this.formError = `${t("secretsStore.badName")} ${parsed.invalidNames.join(", ")}`;
return;
return undefined;
}
if (parsed.entries.length === 0) {
this.formError = t("secretsStore.required");
return;
return undefined;
}
for (const entry of parsed.entries) {
const error = this.validateValue(entry.value, entry.kind);
if (error) {
this.formError = `${entry.name}: ${error}`;
return;
return undefined;
}
}
this.saveEntryTask(
return this.saveEntryTask(
(store) => bulkSetSecretsStoreEntries(store, parsed.entries),
(result) => {
this.bulkOpen = false;
+11 -1
View File
@@ -37,7 +37,17 @@ function mount(
state.store.entries = entries;
Object.assign(state, overrides);
const view = mountSolid(() => (
<SecretsStore state={state} bulkSecretCount={0} bulkEntryCount={0} bulkInvalidNames={[]} />
<SecretsStore
state={state}
scope={state.store}
read={() => state.store.entries}
busy={state.store.busy}
onSubmit={(bulk) => void (bulk ? state.submitBulk() : state.submitDraft())}
onRemove={(entry) => void state.removeEntry(entry)}
bulkSecretCount={0}
bulkEntryCount={0}
bulkInvalidNames={[]}
/>
));
mounts.push(view);
return view.container;
+25 -27
View File
@@ -1,4 +1,4 @@
import { For, Show } from "solid-js";
import { For, Loading, Show } from "solid-js";
import type { SecretStoreEntry } from "../../../../packages/gateway-protocol/src/index.js";
import { Icon } from "../../components/solid/icon.tsx";
import { Menu } from "../../components/solid/menu.tsx";
@@ -12,13 +12,18 @@ import {
} from "../../components/solid/settings-ui.tsx";
import { formatRelativeTimestamp } from "../../lib/reactive/format.ts";
import { getLocale, t } from "../../lib/reactive/i18n.ts";
import { loadSecretsStore } from "../../lib/secrets-store/index.ts";
import type { SecretsStoreState } from "../../lib/secrets-store/index.ts";
import type { JSX as SolidJSX } from "../../types/solid-elements.d.ts";
import "../../styles/secrets-store.css";
import type { SecretsPageState } from "./state.ts";
type SecretsStoreViewProps = {
state: SecretsPageState;
scope: SecretsStoreState;
read: () => readonly SecretStoreEntry[];
busy: boolean;
onSubmit: (bulk: boolean) => void;
onRemove: (entry: SecretStoreEntry) => void;
bulkSecretCount: number;
bulkEntryCount: number;
bulkInvalidNames: readonly string[];
@@ -69,7 +74,7 @@ function TextAreaField(props: {
placeholder={
props.field === "hosts" ? t("secretsStore.allowedHostsPlaceholder") : undefined
}
disabled={props.view.state.store.busy}
disabled={props.view.busy}
value={value()}
onInput={(event) => input(event.currentTarget.value)}
/>
@@ -103,7 +108,7 @@ function EntryMenu(props: { view: SecretsStoreViewProps; entry: SecretStoreEntry
triggerClass="btn btn--sm btn--ghost secrets-store__menu-trigger"
triggerAttributes={{ title: t("secretsStore.actions") }}
trigger={<Icon name="moreHorizontal" />}
disabled={props.view.state.store.busy}
disabled={props.view.busy}
items={[
...(props.view.state.canCall("secrets.store.set")
? [{ id: "edit", label: t("secretsStore.edit") }]
@@ -117,7 +122,7 @@ function EntryMenu(props: { view: SecretsStoreViewProps; entry: SecretStoreEntry
const entry = props.entry;
props.view.state.act((state) => state.openEntry(entry));
} else if (item.id === "delete" && props.view.state.canCall("secrets.store.delete")) {
void props.view.state.removeEntry(props.entry);
props.view.onRemove(props.entry);
}
}}
/>
@@ -131,12 +136,9 @@ function SecretsTable(props: SecretsStoreViewProps) {
when={props.state.canCall("secrets.store.list")}
fallback={<SettingsEmpty message={t("secretsStore.unavail")} />}
>
<Show
when={!props.state.store.loading || props.state.store.entries.length > 0}
fallback={<SettingsLoadingSkeleton />}
>
<Loading on={props.scope} fallback={<SettingsLoadingSkeleton />}>
<Show
when={props.state.store.entries.length > 0}
when={props.read().length > 0}
fallback={
<div class="secrets-store__empty">
<SettingsEmpty message={t("tabs.secrets")} />
@@ -159,7 +161,7 @@ function SecretsTable(props: SecretsStoreViewProps) {
</tr>
</thead>
<tbody>
<For each={props.state.store.entries} keyed={(entry) => entry.name}>
<For each={props.read()} keyed={(entry) => entry.name}>
{(entry) => {
const value = () => {
const item = entry();
@@ -229,7 +231,7 @@ function SecretsTable(props: SecretsStoreViewProps) {
</table>
</div>
</Show>
</Show>
</Loading>
</Show>
);
}
@@ -261,14 +263,10 @@ function SecretDialog(props: {
>
<form
class="secrets-store-dialog"
aria-busy={props.view.state.store.busy ? "true" : "false"}
aria-busy={props.view.busy ? "true" : "false"}
onSubmit={(event) => {
event.preventDefault();
if (props.bulk) {
props.view.state.act((state) => state.submitBulk());
} else {
props.view.state.act((state) => state.submitDraft());
}
props.view.onSubmit(Boolean(props.bulk));
}}
>
<div class="secrets-store-dialog__header">
@@ -281,21 +279,21 @@ function SecretDialog(props: {
</div>
</Show>
<openclaw-modal-actions class="secrets-store-dialog__actions">
<button class="btn" type="button" disabled={props.view.state.store.busy} onClick={close}>
<button class="btn" type="button" disabled={props.view.busy} onClick={close}>
{t("common.cancel")}
</button>
<button
class="btn primary"
type="submit"
disabled={
props.view.state.store.busy ||
props.view.busy ||
Boolean(
props.bulk &&
(!props.view.bulkEntryCount || props.view.bulkInvalidNames.length > 0),
)
}
>
{props.view.state.store.busy ? t("common.saving") : t("common.save")}
{props.view.busy ? t("common.saving") : t("common.save")}
</button>
</openclaw-modal-actions>
</form>
@@ -320,7 +318,7 @@ function EntryDialog(props: SecretsStoreViewProps) {
spellcheck="false"
autofocus
readonly={props.state.dialogMode === "edit"}
disabled={props.state.store.busy}
disabled={props.busy}
value={props.state.draft.name}
onInput={(event) =>
props.state.act((state) => state.patchDraft({ name: event.currentTarget.value }))
@@ -347,7 +345,7 @@ function EntryDialog(props: SecretsStoreViewProps) {
name="access-mode"
value={kind()}
checked={props.state.draft.kind === kind()}
disabled={props.state.store.busy}
disabled={props.busy}
onChange={() => {
const selectedKind = kind();
props.state.act((state) => state.patchDraft({ kind: selectedKind }));
@@ -402,7 +400,7 @@ function BulkDialog(props: SecretsStoreViewProps) {
<input
type="checkbox"
checked={props.state.bulkAutoDetect}
disabled={props.state.store.busy}
disabled={props.busy}
onChange={(event) =>
props.state.act((state) => {
state.bulkAutoDetect = event.currentTarget.checked;
@@ -437,7 +435,7 @@ export function SecretsStore(props: SecretsStoreViewProps) {
type="button"
onClick={() => {
if (props.state.canCall("secrets.store.list")) {
void props.state.runStoreTask(loadSecretsStore);
props.state.reload();
}
}}
>
@@ -460,7 +458,7 @@ export function SecretsStore(props: SecretsStoreViewProps) {
<button
class="btn btn--sm"
type="button"
disabled={props.state.store.busy}
disabled={props.busy}
onClick={() => props.state.act((state) => state.openBulk())}
>
{t("secretsStore.bulk")}
@@ -468,7 +466,7 @@ export function SecretsStore(props: SecretsStoreViewProps) {
<button
class="btn btn--sm primary"
type="button"
disabled={props.state.store.busy}
disabled={props.busy}
onClick={() => props.state.act((state) => state.openEntry())}
>
<Icon name="plus" /> {t("secretsStore.add")}