diff --git a/devlog/_plan/261111_link_child_updates/060_dashboard.md b/devlog/_plan/261111_link_child_updates/060_dashboard.md index 3b4a1818af9..c8e244bd26c 100644 --- a/devlog/_plan/261111_link_child_updates/060_dashboard.md +++ b/devlog/_plan/261111_link_child_updates/060_dashboard.md @@ -12,6 +12,15 @@ > after a reload) and 4529522094 (a 404 `job_not_found` is exposed by the hook, shown with its message, and refreshes > status without calling the operation failed; new key `remoteLink.job.notFound`). +> **Revision 5 (wp6 P re-verification, base dev `de0914a1a83b`).** Makes the L17 residual diff-level: the API module, +> the action components and the page wiring below carry concrete bodies instead of exports with prose. Corrects claims +> against the merged tree: `ChildRestartOutcome` has **four** values (`requested | unsupported | not-run | skipped`; +> 030 ships all four and 080 has not landed), `LinkChildOpsStatus.codex` is `null` on this base, the policy panel uses +> `Select` from `gui/src/ui.tsx` (a native `s and one minutes ; -// Save sends only changed keys through putChildPolicy (idle minutes × 60 000). warning → Banner tone warn -// "remoteLink.policy.invalid". 400 invalid_body / 503 policy_write_failed → translated error. + onConfirm(scope: "app-server" | "codex"): void }): ReactElement { + const t = useT(); + const [scope, setScope] = useState<"app-server" | "codex">("app-server"); + const ref = useRef(null); + useEffect(() => { + const dialog = ref.current; + if (!dialog) return; + if (props.open && !dialog.open) dialog.showModal(); + if (!props.open && dialog.open) dialog.close(); + }, [props.open]); + return ( + { event.preventDefault(); props.onCancel(); }} + className="remote-link-confirm-dialog m-auto w-[min(440px,calc(100vw-24px))] rounded-[14px] border-0 bg-surface p-1 text-fg shadow-lg ring-1 ring-line backdrop:bg-black/40"> +
+ + + +
+
+ + +
+
+ ); +} +``` +The dialog resets `scope` per mount (the action bar mounts it fresh on each open), so the choice is per click and never +remembered; there is no policy coupling (deviation 3). + +```tsx +export function ChildBulkBar(props: { apiBase: string; rows: readonly LinkRowWire[]; + onJob(job: ChildJobView): void; onError(error: LinkActionErrorView | null): void; + onRun(running: { linkId: "all"; op: ChildOp } | null): void }): ReactElement | null { + const t = useT(); + const [confirming, setConfirming] = useState(null); + const operable = props.rows.filter(row => rowOperable(row.direction, row.child ?? null)); + const updatable = operable.filter(row => updateAvailable(row.child!)); + if (operable.length === 0) return null; + const homeVersion = updatable[0]?.child?.homeVersion ?? operable[0]?.child?.homeVersion ?? ""; + const run = async (op: ChildOp, ids: readonly string[]) => { + props.onError(null); + props.onRun({ linkId: "all", op }); + try { + const results = await Promise.allSettled(chunkLinkIds(ids).map(group => postChildOperation(props.apiBase, { op, linkIds: group }))); + for (const result of results) if (result.status === "fulfilled") props.onJob(result.value); + const refused = results.find(result => result.status === "rejected"); + if (refused) props.onError(linkActionError(refused.reason)); + } + finally { props.onRun(null); } + }; + return ( +
+ + + setConfirming(null)} + onConfirm={() => { setConfirming(null); void run("update", linkIds(updatable)); }} /> +
+ ); +} ``` -No new colour, spacing or component: `StateBadge`, `Button`, `Banner` (`components/v2-startup/primitives.tsx:25,95`) -and the dialog shell already in `RemoteLinkDialogs.tsx`, so `gui/design-system/` needs no update. +Both bulk actions post explicit link ids, never `"all"` (Revision 7, #8581 review): the server expands `"all"` to +every stored link and runs a pinned sync before it decides currency, so a link outside the confirmation could be +reached. Check all names the operable rows; Update all names exactly the updatable rows its confirmation counts, and +is offered only when at least one row is updatable. The route accepts at most 64 ids per body (`MAX_LINK_IDS`), so +`run` posts one job per even group of at most 64 at once (`chunkLinkIds`, `Promise.allSettled`); each accepted group is tracked as its own job and the first refusal shows its error. + +```tsx +export function useChildJob(apiBase: string, job: ChildJobView): { job: ChildJobView; missing: boolean } { + const [snapshot, setSnapshot] = useState(job); + const [missing, setMissing] = useState(false); + useEffect(() => { + setSnapshot(job); + setMissing(false); + if (job.state === "done") return; + let stopped = false; + const controller = new AbortController(); + const tick = async () => { + if (document.visibilityState === "hidden") return; + try { + const next = await getChildJob(apiBase, job.jobId, controller.signal); + if (!stopped) setSnapshot(next); + } catch (error) { + if (stopped) return; + if (error instanceof LinkApiError && (error.code === "job_not_found" || error.status === 404)) { + stopped = true; // the Home restarted (retention is 1 h / 32 jobs) + controller.abort(); + setMissing(true); + } + } + }; + const timer = window.setInterval(() => void tick(), CHILD_JOB_POLL_MS); + const onVisibility = () => { if (document.visibilityState !== "hidden") void tick(); }; + document.addEventListener("visibilitychange", onVisibility); + return () => { stopped = true; controller.abort(); window.clearInterval(timer); document.removeEventListener("visibilitychange", onVisibility); }; + }, [apiBase, job.jobId, job.state]); + return { job: snapshot, missing }; +} +``` +Polling stops on a `done` snapshot, on unmount, on a job-id change, and on `job_not_found`; it skips hidden tabs and +resumes on visibility (`RemoteLink.tsx` uses the same pattern for status, `:238-241`). + +```tsx +export function ChildJobProgress(props: { apiBase: string; job: ChildJobView; onDone(): void }): ReactElement { + const t = useT(); + const { job, missing } = useChildJob(props.apiBase, props.job); + const done = job.state === "done" || missing; + const fired = useRef(false); + useEffect(() => { if (done && !fired.current) { fired.current = true; props.onDone(); } }, [done, props]); + return ( +
+

+ {t(ACTIVE_KEY[job.op])}{job.trigger === "auto" && <> · {t("remoteLink.job.automatic")}} +

+ {missing && {t("remoteLink.job.notFound")}} +
    + {job.items.map(item => { + const state = missing && (item.state === "queued" || item.state === "running") ? "uncertain" : item.state; + return ( +
  • + {item.alias} + {state in JOB_STATE_KEYS ? t(JOB_STATE_KEYS[state]!) : {state}} + {(item.fromVersion || item.toVersion) && {item.fromVersion ?? "?"} → {item.toVersion ?? "?"}} + {item.reason && {item.reason}} + {item.sync && {item.sync.state in SYNC_KEYS ? t(SYNC_KEYS[item.sync.state]!) : {item.sync.state}}} + {item.restart && + {item.restart.outcome in RESTART_KEYS ? t(RESTART_KEYS[item.restart.outcome]!) : {item.restart.outcome}} + } +
  • + ); + })} +
+
+ ); +} +``` +`JOB_STATE_KEYS`, `SYNC_KEYS` and `RESTART_KEYS` are `Record` maps over the enumerations this build knows; +an unmapped value (a newer Home, or 080 widening `ChildRestartOutcome`) renders as `` text and is never coerced +to `requested`. When polling fails with `job_not_found`, still-running items read `remoteLink.job.state.uncertain` — the +row cannot claim the Child failed — and `onDone` refreshes status once. + +```tsx +export function ChildPolicyPanel(props: { apiBase: string; busy: boolean; onBusy(on: boolean): void; + onError(error: LinkActionErrorView | null): void }): ReactElement { + const t = useT(); + const [read, setRead] = useState(null); + const [draft, setDraft] = useState(null); + const [saved, setSaved] = useState(false); + const load = useCallback(async (signal?: AbortSignal) => { + try { + const value = await getChildPolicy(props.apiBase, signal); + setRead(value); setDraft(value.policy); + } catch (error) { if (!signal?.aborted) props.onError(linkActionError(error)); } + }, [props]); + useEffect(() => { const controller = new AbortController(); void load(controller.signal); return () => controller.abort(); }, [load]); + if (!draft || !read) return <>; + const changed = changedPolicyKeys(read.policy, draft); // Partial of differing keys only + const save = async () => { + props.onBusy(true); props.onError(null); setSaved(false); + try { + const value = await putChildPolicy(props.apiBase, changed); + setRead(value); setDraft(value.policy); setSaved(true); + } catch (error) { props.onError(linkActionError(error)); } + finally { props.onBusy(false); } + }; + const minutes = Math.round(draft.idleAfterMs / 60_000); + const set = (key: K, value: ChildPolicyView[K]) => { setSaved(false); setDraft({ ...draft, [key]: value }); }; + return ( +
+ + {read.warning === "policy-file-invalid" && {t("remoteLink.policy.invalid")}} + + +
+ {saved && {t("remoteLink.policy.saved")}} + +
+
+ ); +} +``` +Three knobs plus the idle threshold; the control is `Select` from `gui/src/ui.tsx` (`gui/AGENTS.md` forbids a native +` setScope("app-server")} className="mt-1" /> + {t("remoteLink.restart.scope.appServer")}{t("remoteLink.restart.scope.appServerBody")} + + + +
+ + +
+ ; +} + +export function RestartCodexDialog(props: { alias: string; open: boolean; onCancel(): void; + onConfirm(scope: "app-server" | "codex"): void }): ReactElement { + const ref = useRef(null); + useEffect(() => { + const dialog = ref.current; + if (!dialog) return; + if (props.open && !dialog.open) { if (typeof dialog.showModal === "function") dialog.showModal(); else dialog.setAttribute("open", ""); } + if (!props.open && dialog.open) dialog.close(); + }, [props.open]); + return ( + { event.preventDefault(); props.onCancel(); }} + className="remote-link-confirm-dialog m-auto w-[min(440px,calc(100vw-24px))] rounded-[14px] border-0 bg-surface p-1 text-fg shadow-lg ring-1 ring-line backdrop:bg-black/40"> + {props.open && } + + ); +} + +export function ChildRowActions(props: { apiBase: string; link: LinkRowWire; disabled: boolean; + onJob(job: ChildJobView): void; onError(error: LinkActionErrorView | null): void; + onDialog(open: boolean): void; onRun(running: { linkId: string; op: ChildOp } | null): void }): ReactElement | null { + const t = useT(); + const [restarting, setRestarting] = useState(false); + const [confirmUpdate, setConfirmUpdate] = useState(false); + const row = props.link.child ?? null; + if (!rowOperable(props.link.direction, row)) return null; + // A job holding the link is the only global hold. Restart and update are withheld while the link is busy + // (030's runner refuses without SSH); observe and sync are allowed to try and report the server's own verdict. + // Revision 6: the idle-after window gates automatic actions only; a manual action waits on inFlight alone. + const held = props.disabled || row!.activeJob !== null; + const busy = held || row!.activity.inFlight > 0; + const run = async (body: ChildOperationBody) => { + props.onError(null); + props.onRun({ linkId: props.link.id, op: body.op }); + try { props.onJob(await postChildOperation(props.apiBase, body)); } + catch (error) { props.onError(linkActionError(error)); } + finally { props.onRun(null); } + }; + const single = (op: ChildOp, restart?: "app-server" | "codex") => + void run({ op, linkIds: [props.link.id], ...(restart ? { restart } : {}) }); + const canUpdate = updateAvailable(row!); + return ( + + + + + + { setRestarting(false); props.onDialog(false); }} + onConfirm={scope => { setRestarting(false); props.onDialog(false); single("restart-codex", scope); }} /> + { setConfirmUpdate(false); props.onDialog(false); }} + onConfirm={() => { setConfirmUpdate(false); props.onDialog(false); single("update"); }} /> + + ); +} + +export function ChildBulkBar(props: { apiBase: string; rows: readonly LinkRowWire[]; + onJob(job: ChildJobView): void; onError(error: LinkActionErrorView | null): void; + onRun(running: { linkId: "all"; op: ChildOp } | null): void }): ReactElement | null { + const t = useT(); + const [confirming, setConfirming] = useState(null); + const operable = props.rows.filter(row => rowOperable(row.direction, row.child ?? null)); + const updatable = operable.filter(row => updateAvailable(row.child!)); + if (operable.length === 0) return null; + const homeVersion = updatable[0]?.child?.homeVersion ?? operable[0]?.child?.homeVersion ?? ""; + // The server expands "all" to every stored link and runs a pinned sync before it decides currency, so a + // link outside the confirmation could still be reached. Both bulk actions name exactly the rows they cover: + // update the updatable ones the dialog counts, observe the operable ones in the bar's own list. + // The route takes at most 64 ids per body, so a larger list goes out as one job per group. The groups are + // independent: each accepted group is tracked as its own job, and the first refusal's error shows. + const run = async (op: ChildOp, ids: readonly string[]) => { + props.onError(null); + props.onRun({ linkId: "all", op }); + try { + const results = await Promise.allSettled(chunkLinkIds(ids).map(group => postChildOperation(props.apiBase, { op, linkIds: group }))); + for (const result of results) if (result.status === "fulfilled") props.onJob(result.value); + const refused = results.find(result => result.status === "rejected"); + if (refused) props.onError(linkActionError(refused.reason)); + } + finally { props.onRun(null); } + }; + return ( +
+ + + setConfirming(null)} + onConfirm={() => { setConfirming(null); void run("update", linkIds(updatable)); }} /> +
+ ); +} + +function useChildJob(apiBase: string, job: ChildJobView, onSnapshot?: (job: ChildJobView) => void): { job: ChildJobView; missing: boolean } { + // Both states are keyed by job id so a new job renders its own prop without a reset effect: an effect that + // synchronously reset them would cascade renders (react-compiler EffectSetState), and a stale snapshot would + // otherwise leak across jobs. + const [polled, setPolled] = useState<{ jobId: string; value: ChildJobView } | null>(null); + const [missingFor, setMissingFor] = useState(null); + const snapshot = polled?.jobId === job.jobId ? polled.value : job; + const missing = missingFor === job.jobId; + // The page holds rows from its own job snapshot, so each poll is handed up as it lands: a finished item must + // release its row while the rest of the job is still running, not only when the whole job ends. + const notify = useRef(onSnapshot); + useEffect(() => { notify.current = onSnapshot; }, [onSnapshot]); + useEffect(() => { + if (job.state === "done") return; + let stopped = false; + // The interval and the visibility listener can both fire while a request is pending, and responses need not + // arrive in the order they were sent: skip a tick while one is in flight, and drop any response older than + // the last applied one so a stale "running" snapshot cannot restore a hold a newer one released. + let inFlight = false; + let sequence = 0; + let applied = 0; + const controller = new AbortController(); + // The interval ticks skip while a request is pending, so a slow Home cannot pile them up; the visibility + // listener is user-driven and may overlap one, which the sequence tag then orders. + const tick = async (fromListener = false) => { + if (document.visibilityState === "hidden" || (inFlight && !fromListener)) return; + inFlight = true; + const mine = ++sequence; + // Each poll gets its own deadline: a GET that never settles would keep inFlight set, every interval tick + // would then skip, and the row would stay held until the page reloads. + const request = new AbortController(); + const stop = () => request.abort(); + controller.signal.addEventListener("abort", stop, { once: true }); + const deadline = window.setTimeout(stop, CHILD_JOB_POLL_TIMEOUT_MS); + try { + const next = await settleOrAbort(getChildJob(apiBase, job.jobId, request.signal), request.signal); + if (!stopped && mine > applied) { + applied = mine; + setPolled({ jobId: job.jobId, value: next }); notify.current?.(next); + } + } catch (error) { + if (error instanceof LinkApiError && (error.code === "job_not_found" || error.status === 404)) { + stopped = true; // the Home restarted (retention is 1 h / 32 jobs) + controller.abort(); + setMissingFor(job.jobId); + } + } finally { + window.clearTimeout(deadline); + controller.signal.removeEventListener("abort", stop); + inFlight = false; + } + }; + const timer = window.setInterval(() => void tick(), CHILD_JOB_POLL_MS); + const onVisibility = () => { if (document.visibilityState !== "hidden") void tick(true); }; + document.addEventListener("visibilitychange", onVisibility); + return () => { stopped = true; inFlight = false; controller.abort(); window.clearInterval(timer); document.removeEventListener("visibilitychange", onVisibility); }; + }, [apiBase, job.jobId, job.state]); + return { job: snapshot, missing }; +} + +export function ChildJobProgress(props: { apiBase: string; job: ChildJobView; onDone(): void; onJobUpdate?(job: ChildJobView): void }): ReactElement { + const t = useT(); + const { job, missing } = useChildJob(props.apiBase, props.job, props.onJobUpdate); + const done = job.state === "done" || missing; + const fired = useRef(false); + useEffect(() => { if (done && !fired.current) { fired.current = true; props.onDone(); } }, [done, props]); + return ( +
+

+ {t(ACTIVE_KEY[job.op])}{job.trigger === "auto" && <> · {t("remoteLink.job.automatic")}} +

+ {missing && {t("remoteLink.job.notFound")}} +
    + {job.items.map(item => { + const state = missing && (item.state === "queued" || item.state === "running") ? "uncertain" : item.state; + const stateKey: TKey | null = JOB_STATE_KEYS[state] ?? null; + const syncKey: TKey | null = item.sync ? SYNC_KEYS[item.sync.state] ?? null : null; + const restartKey: TKey | null = item.restart ? RESTART_KEYS[item.restart.outcome] ?? null : null; + return ( +
  • + {item.alias} + {stateKey ? t(stateKey) : {state}} + {(item.fromVersion || item.toVersion) && {`${item.fromVersion ?? "?"} → ${item.toVersion ?? "?"}`}} + {item.reason && {item.reason}} + {item.hint && {item.hint}} + {item.sync && {syncKey ? t(syncKey) : {item.sync.state}}} + {item.restart && + {restartKey ? t(restartKey) : {item.restart.outcome}} + } +
  • + ); + })} +
+
+ ); +} + +/** Validates the digits before converting, so an empty, partial or exponent input never enters the draft. */ +function parseIdleMinutes(raw: string): number | undefined { + if (!/^\d+$/.test(raw.trim())) return undefined; + const value = Number(raw.trim()); + return Number.isSafeInteger(value) && value >= 1 && value <= 60 ? value : undefined; +} + +export function ChildPolicyPanel(props: { apiBase: string; busy: boolean; reloadSignal: number; onBusy(on: boolean): void; + onError(error: LinkActionErrorView | null): void }): ReactElement | null { + const t = useT(); + const [read, setRead] = useState(null); + const [draft, setDraft] = useState(null); + const [saved, setSaved] = useState(false); + const [loadError, setLoadError] = useState(null); + const [attempt, setAttempt] = useState(0); + const { apiBase, reloadSignal } = props; + // A save and a reload race on the same document. Each save bumps the generation, so a GET that began before it + // cannot apply afterwards; and a reload only takes over the draft when nothing was edited while it was in flight. + const generation = useRef(0); + const saving = useRef(false); + const draftRef = useRef(draft); + useEffect(() => { draftRef.current = draft; }, [draft]); + const readRef = useRef(read); + useEffect(() => { readRef.current = read; }, [read]); + // Revision 6: load once per mount, after a save, and on an explicit page Refresh. A status poll never re-reads, + // so an edit survives it. + const load = useCallback(async (signal?: AbortSignal) => { + const startedAt = generation.current; + const startedDraft = draftRef.current; + const startedRead = readRef.current; + try { + const value = await getChildPolicy(apiBase, signal); + if (signal?.aborted) return; + if (generation.current !== startedAt) return; // a save started after this GET began; it owns the values now + setRead(value); + // The operator changed knobs while this reload ran: merge field by field, keeping exactly those they changed + // against the baseline this GET started from and adopting the server's value for every untouched field. + if (draftRef.current !== startedDraft && startedRead) { + // Compare the live draft, not the one this GET started from: the operator's edit happened in between. + const operatorChanged = changedPolicyKeys(startedRead.policy, draftRef.current!); + setDraft({ ...value.policy, ...operatorChanged }); + } else setDraft(value.policy); + setLoadError(null); + } catch (error) { if (!signal?.aborted) setLoadError(linkActionError(error)); } + }, [apiBase]); + // Deferred a microtask so the fetch starts after mount commits; calling the loader directly trips + // react-compiler's EffectSetState rule even though its state writes land after the await. + useEffect(() => { + if (saving.current) return; // a reload never interrupts a save, including the page Refresh's policy part + const controller = new AbortController(); + void Promise.resolve().then(() => load(controller.signal)); + return () => controller.abort(); + }, [load, attempt, reloadSignal]); + if (!draft || !read) { + if (!loadError) return null; + return ( +
+ + { setLoadError(null); setAttempt(value => value + 1); }}>{t("link.retry")}}> + {t("remoteLink.policy.loadFailed")} + +
+ ); + } + const changed = changedPolicyKeys(read.policy, draft); + const damaged = read.warning === "policy-file-invalid"; + const save = async () => { + saving.current = true; + generation.current += 1; + props.onBusy(true); props.onError(null); setSaved(false); + try { + // Over a damaged file the server merges onto FAIL_CLOSED; send every knob so the visible choices persist. + const value = await putChildPolicy(apiBase, damaged ? draft : changed); + setRead(value); setDraft(value.policy); setSaved(true); + } catch (error) { props.onError(linkActionError(error)); } + finally { saving.current = false; props.onBusy(false); } + }; + const minutes = Math.round(draft.idleAfterMs / 60_000); + const set = (key: K, value: ChildPolicyView[K]) => { setSaved(false); setDraft({ ...draft, [key]: value }); }; + return ( +
+ + {damaged && {t("remoteLink.policy.invalid")}} + + +
+ {saved && {t("remoteLink.policy.saved")}} + +
+
+ ); +} diff --git a/gui/src/remote-link-api.ts b/gui/src/remote-link-api.ts index a9a1d82e0ed..a3767a067d9 100644 --- a/gui/src/remote-link-api.ts +++ b/gui/src/remote-link-api.ts @@ -1,3 +1,5 @@ +import { parseChildOps, type ChildOpsRowView } from "./remote-link-child-ops-api"; + export const LINK_ERROR_CODES = [ "admission_timeout", "admission_failed", @@ -11,6 +13,7 @@ export const LINK_ERROR_CODES = [ "host_not_confirmed", "invalid_alias", "invalid_body", + "invalid_job_id", "invalid_link_id", "join_connect_failed", "join_in_progress", @@ -20,15 +23,19 @@ export const LINK_ERROR_CODES = [ "join_restart_failed", "join_rollback_failed", "join_tunnel_failed", + "job_not_found", "key_issue_failed", "key_revoke_failed", "link_apply_failed", "link_exists", "link_not_found", "link_not_home_initiated", + "link_operation_in_progress", "link_remove_failed", "link_unavailable", "listener_unavailable", + "no_eligible_links", + "policy_write_failed", "probe_failed", "purge_unsupported", "remote_connect_failed", @@ -40,6 +47,7 @@ export const LINK_ERROR_CODES = [ "remote_plant_timeout", "remote_plant_unavailable", "remote_port_failed", + "runtime_role_client", "standalone_required", "tailscale_session_refused", "version_probe_failed", @@ -76,6 +84,8 @@ export interface LinkRowWire { tunnelPort: number; childKind?: "full" | "ephemeral" | null; appliedCodexSettingsDigest?: string | null; + /** Present only on a Home that runs wp2 or later; absent rows render exactly as before. */ + child?: ChildOpsRowView | null; } export interface RemoteLinkStatusWire { role: "standalone" | "home" | "child"; @@ -113,7 +123,7 @@ export class LinkApiError extends Error { const HINT_MAX_CHARS = 160; -function isRecord(value: unknown): value is Record { +export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -147,7 +157,8 @@ export function parseRemoteLinkStatus(value: unknown): RemoteLinkStatusWire { if (!isRecord(item) || !nonEmpty(item.id) || !nonEmpty(item.alias) || !["hub-initiated", "client-initiated"].includes(String(item.direction)) || !isLinkState(item.state) || !nonEmpty(item.since) || (item.reason !== null && typeof item.reason !== "string") || typeof item.tunnelPort !== "number") throw new Error("invalid link"); const childKind: "full" | "ephemeral" | null = item.childKind === "full" || item.childKind === "ephemeral" ? item.childKind : null; const appliedCodexSettingsDigest = nonEmpty(item.appliedCodexSettingsDigest) ? item.appliedCodexSettingsDigest : null; - return { id: item.id, alias: item.alias, direction: item.direction as LinkWireDirection, state: item.state, since: item.since, reason: item.reason as string | null, tunnelPort: item.tunnelPort, childKind, appliedCodexSettingsDigest }; + return { id: item.id, alias: item.alias, direction: item.direction as LinkWireDirection, state: item.state, since: item.since, reason: item.reason as string | null, tunnelPort: item.tunnelPort, childKind, appliedCodexSettingsDigest, + child: parseChildOps(item.child) }; }); let child: RemoteLinkStatusWire["child"] = null; if (value.child !== null) { diff --git a/gui/src/remote-link-child-ops-api.ts b/gui/src/remote-link-child-ops-api.ts new file mode 100644 index 00000000000..d1518357e3f --- /dev/null +++ b/gui/src/remote-link-child-ops-api.ts @@ -0,0 +1,199 @@ +import { isRecord, requestLinkJson } from "./remote-link-api"; + +export type ChildInstallKind = "npm" | "pnpm" | "bun" | "source" | "mise" | "desktop-app" | "goat" | "standalone" | "ocx-child" | "unknown"; +export const CHILD_INSTALL_KINDS: readonly ChildInstallKind[] = ["npm", "pnpm", "bun", "source", "mise", "desktop-app", "goat", "standalone", "ocx-child", "unknown"]; +export type ChildUpdateRoute = "orchestrated" | "desktop-request" | "owner-action" | "replant" | "none"; +/** Wire mirror of src/link/child-observations.ts `ChildOp` (000 "Canonical names"); gui/ cannot import src/. */ +export const CHILD_OPS = ["observe", "sync", "restart-codex", "update"] as const; +export type ChildOp = typeof CHILD_OPS[number]; +/** src/link/child-ops-types.ts `CHILD_ITEM_STATES` (11). */ +export const CHILD_ITEM_STATES = ["queued", "running", "succeeded", "up-to-date", "busy", "offline", "refused", + "owner-action", "unsupported", "failed", "uncertain"] as const; +export type ChildItemState = typeof CHILD_ITEM_STATES[number]; +/** 030 ships exactly these four (child-ops-types.ts:24). 080 may widen it; an unknown value renders as . */ +export const CHILD_RESTART_OUTCOMES = ["requested", "unsupported", "not-run", "skipped"] as const; +export type ChildRestartOutcome = typeof CHILD_RESTART_OUTCOMES[number]; + +export interface ChildCodexView { appServers: "fresh" | "stale" | "not_running" | "unknown"; staleCount: number; + catalogMatchesHome: boolean | null; catalogSyncedAt: number | null; reportedAt: number } +export interface ChildOpsRowView { + homeVersion: string; runningVersion: string | null; installedVersion: string | null; installKind: ChildInstallKind | null; + versionSkew: "same" | "behind" | "ahead" | "unknown"; + update: { route: ChildUpdateRoute | null; reason: string | null; observedAt: string | null }; + catalog: { state: "current" | "behind" | "unknown"; pulledAt: number | null; homeChangedAt: number | null }; + /** Always null on this base (status-projection.ts `LinkChildOpsStatus.codex` is typed `null`); 080 fills it. */ + codex: ChildCodexView | null; childBuild: string | null; + activity: { idle: boolean; inFlight: number; lastDataEndAt: number | null }; + activeJob: { jobId: string; op: ChildOp; state: "queued" | "running" } | null; + lastOperation: { op: ChildOp; jobId: string; state: string; reason: string | null; + fromVersion: string | null; toVersion: string | null; finishedAt: string | null } | null; +} +export interface ChildJobItemView { linkId: string; alias: string; state: string; reason: string | null; hint: string | null; + fromVersion: string | null; toVersion: string | null; + sync: { state: string; reason: string | null } | null; + restart: { requested: string; outcome: string } | null } +export interface ChildJobView { jobId: string; op: ChildOp; trigger: "manual" | "auto"; restart: "none" | "app-server" | "codex"; + state: "queued" | "running" | "done"; items: ChildJobItemView[] } +export interface ChildPolicyView { catalogFanout: "auto" | "manual"; childCodexRestart: "never" | "app-server-when-idle"; + updates: "off" | "notify" | "auto-when-idle"; idleAfterMs: number } +export interface ChildPolicyReadView { policy: ChildPolicyView; warning: "policy-file-invalid" | null } +export interface ChildOperationBody { op: ChildOp; linkIds: string[] | "all"; restart?: "app-server" | "codex" } + +const text = (v: unknown): string | null => typeof v === "string" && v.length > 0 && v.length <= 256 ? v : null; +const num = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) ? v : null; +const bool = (v: unknown): boolean | null => typeof v === "boolean" ? v : null; +const member = (all: readonly T[], v: unknown): T | null => (all as readonly unknown[]).includes(v) ? v as T : null; + +/** Tolerant: absent, or any key this build cannot type, yields null so the row renders exactly as today. */ +export function parseChildOps(value: unknown): ChildOpsRowView | null { + if (!isRecord(value)) return null; + const homeVersion = text(value.homeVersion); + const update = isRecord(value.update) ? value.update : null; + const catalog = isRecord(value.catalog) ? value.catalog : null; + const activity = isRecord(value.activity) ? value.activity : null; + const job = isRecord(value.activeJob) ? value.activeJob : null; + const last = isRecord(value.lastOperation) ? value.lastOperation : null; + const codex = isRecord(value.codex) ? { + appServers: member(["fresh", "stale", "not_running", "unknown"] as const, value.codex.appServers) ?? "unknown", + staleCount: num(value.codex.staleCount) ?? 0, + catalogMatchesHome: bool(value.codex.catalogMatchesHome), + catalogSyncedAt: num(value.codex.catalogSyncedAt), reportedAt: num(value.codex.reportedAt) ?? 0, + } : null; + if (!homeVersion || !update || !catalog || !activity) return null; + const op = member(CHILD_OPS, last?.op); + return { + homeVersion, + runningVersion: text(value.runningVersion), installedVersion: text(value.installedVersion), + installKind: member(CHILD_INSTALL_KINDS, value.installKind), + versionSkew: member(["same", "behind", "ahead", "unknown"] as const, value.versionSkew) ?? "unknown", + update: { route: member(["orchestrated", "desktop-request", "owner-action", "replant", "none"] as const, update.route), + reason: text(update.reason), observedAt: text(update.observedAt) }, + catalog: { state: member(["current", "behind", "unknown"] as const, catalog.state) ?? "unknown", + pulledAt: num(catalog.pulledAt), homeChangedAt: num(catalog.homeChangedAt) }, + codex, childBuild: text(value.childBuild), + activity: { idle: activity.idle === true, inFlight: num(activity.inFlight) ?? 0, lastDataEndAt: num(activity.lastDataEndAt) }, + activeJob: job && text(job.jobId) && job.state !== "done" && member(CHILD_OPS, job.op) + ? { jobId: text(job.jobId)!, op: member(CHILD_OPS, job.op)!, state: job.state === "queued" ? "queued" : "running" } : null, + lastOperation: last && op && text(last.jobId) ? { op, jobId: text(last.jobId)!, state: text(last.state) ?? "failed", + reason: text(last.reason), fromVersion: text(last.fromVersion), toVersion: text(last.toVersion), finishedAt: text(last.finishedAt) } : null, + }; +} + +/** Strict on the fields it renders, open on the enum strings: an unknown state or outcome is kept and shown as . */ +export function parseChildJob(value: unknown): ChildJobView { + if (!isRecord(value) || value.schema !== "ocx.link.child-job/1" || !text(value.jobId) || !Array.isArray(value.items)) throw new Error("invalid job"); + const op = member(CHILD_OPS, value.op); + if (!op) throw new Error("invalid job op"); + return { + jobId: text(value.jobId)!, op, + trigger: value.trigger === "auto" ? "auto" : "manual", + restart: member(["none", "app-server", "codex"] as const, value.restart) ?? "none", + state: member(["queued", "running", "done"] as const, value.state) ?? "running", + items: value.items.filter(isRecord).map(raw => { + const sync = isRecord(raw.sync) ? { state: text(raw.sync.state) ?? "failed", reason: text(raw.sync.reason) } : null; + const restart = isRecord(raw.restart) ? { requested: text(raw.restart.requested) ?? "app-server", outcome: text(raw.restart.outcome) ?? "" } : null; + return { linkId: text(raw.linkId) ?? "", alias: text(raw.alias) ?? "", state: text(raw.state) ?? "failed", + reason: text(raw.reason), hint: text(raw.hint), fromVersion: text(raw.fromVersion), toVersion: text(raw.toVersion), sync, restart }; + }), + }; +} + +export function parseChildPolicyRead(value: unknown): ChildPolicyReadView { + const policy = isRecord(value) && isRecord(value.policy) ? value.policy : null; + const idle = policy ? num(policy.idleAfterMs) : null; + if (!policy || idle === null) throw new Error("invalid policy"); + return { + policy: { + catalogFanout: member(["auto", "manual"] as const, policy.catalogFanout) ?? "manual", + childCodexRestart: member(["never", "app-server-when-idle"] as const, policy.childCodexRestart) ?? "never", + updates: member(["off", "notify", "auto-when-idle"] as const, policy.updates) ?? "off", + idleAfterMs: idle, + }, + warning: isRecord(value) && value.warning === "policy-file-invalid" ? "policy-file-invalid" : null, + }; +} + +const json = { "content-type": "application/json" }; +/** A submit that never settles would keep the row (or the bulk bar) busy until a reload; the job, if the Home took it, + * still shows up through the status rows' `activeJob`. */ +export const CHILD_OPERATION_POST_TIMEOUT_MS = 30_000; +export async function postChildOperation(apiBase: string, body: ChildOperationBody, + timeoutMs: number = CHILD_OPERATION_POST_TIMEOUT_MS): Promise { + const request = new AbortController(); + const deadline = setTimeout(() => request.abort(), timeoutMs); + try { + const out = await settleOrAbort(requestLinkJson<{ job?: unknown }>(apiBase, "/api/link/children/operations", + { method: "POST", headers: json, body: JSON.stringify(body), signal: request.signal }), request.signal); + return parseChildJob(out.job); + } finally { clearTimeout(deadline); } +} +export async function getChildJob(apiBase: string, jobId: string, signal?: AbortSignal): Promise { + const out = await requestLinkJson<{ job?: unknown }>(apiBase, `/api/link/children/jobs/${encodeURIComponent(jobId)}`, { signal }); + return parseChildJob(out.job); +} +export async function getChildPolicy(apiBase: string, signal?: AbortSignal): Promise { + return parseChildPolicyRead(await requestLinkJson(apiBase, "/api/link/children/policy", { signal })); +} +export async function putChildPolicy(apiBase: string, patch: Partial): Promise { + return parseChildPolicyRead(await requestLinkJson(apiBase, "/api/link/children/policy", { method: "PUT", headers: json, body: JSON.stringify(patch) })); +} + +/** Pure view helpers (unit-tested directly). */ +export function updateAvailable(row: ChildOpsRowView): boolean { + return row.versionSkew === "behind" && (row.update.route === "orchestrated" || row.update.route === "desktop-request"); +} +export function codexRestartNeeded(row: ChildOpsRowView): boolean { + return row.codex !== null && (row.codex.appServers === "stale" || row.codex.catalogMatchesHome === false); +} +/** Badges and actions exist only where the Home can reach the Child: a Home-initiated link with its row. */ +export function rowOperable(direction: "hub-initiated" | "client-initiated", row: ChildOpsRowView | null): boolean { + return direction === "hub-initiated" && row !== null; +} +/** "Restart held: a turn was running" is the server's only withheld outcome, so it owns the one warning tone. */ +export function restartWithheld(outcome: string): boolean { return outcome === "skipped"; } +/** A job this page tracked holds its links from acceptance, before the next status poll reports `activeJob`. */ +export function jobHoldsLink(jobs: readonly ChildJobView[], linkId: string): boolean { + return jobs.some(job => job.state !== "done" + && job.items.some(item => item.linkId === linkId && (item.state === "queued" || item.state === "running"))); +} +export const CHILD_JOB_POLL_MS = 2_000; +/** The operations route refuses a body naming more links than this (`CHILD_OPS_MAX_LINK_IDS` in src/link/child-ops-types.ts; + * gui/tests/remote-link-child-ops.test.tsx pins the copy). */ +export const CHILD_OPERATION_MAX_LINK_IDS = 64; +/** Splits a bulk target list into the fewest groups of at most `size` ids, with sizes differing by at most one; a + * Home's link count is unbounded. Even groups never leave a one-id tail for a list longer than one, which the route + * would treat as a single-link request (a held link answers 409 for the whole request instead of a `busy` item). */ +export function chunkLinkIds(ids: readonly string[], size = CHILD_OPERATION_MAX_LINK_IDS): string[][] { + const groups = Math.ceil(ids.length / size); + const base = groups > 0 ? Math.floor(ids.length / groups) : 0; + const larger = groups > 0 ? ids.length % groups : 0; // the first `larger` groups take one extra id + const chunks: string[][] = []; + let start = 0; + for (let index = 0; index < groups; index += 1) { + const length = base + (index < larger ? 1 : 0); + chunks.push(ids.slice(start, start + length)); + start += length; + } + return chunks; +} +/** One job poll may take this long; a GET that never settles would otherwise stop every later poll. */ +export const CHILD_JOB_POLL_TIMEOUT_MS = 15_000; + +/** Rejects as soon as `signal` aborts, even when the request underneath ignores it and never settles. */ +export function settleOrAbort(request: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason ?? new DOMException("aborted", "AbortError")); + if (signal.aborted) { onAbort(); return; } + signal.addEventListener("abort", onAbort, { once: true }); + request.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); + }); +} + +/** The keys the operator changed, so a save never rewrites a mode they left alone (Revision 6: full PUT over damage). */ +export function changedPolicyKeys(current: ChildPolicyView, draft: ChildPolicyView): Partial { + const changed: Partial = {}; + for (const key of ["catalogFanout", "childCodexRestart", "updates", "idleAfterMs"] as const) { + if (draft[key] !== current[key]) Object.assign(changed, { [key]: draft[key] }); + } + return changed; +} diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 02ba399b91c..f58f8ecec7b 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -49,6 +49,8 @@ const INTENTIONAL_ENGLISH = new Set([ "zoom.label", // "Actions" is the ordinary French word for a row's action column, read only by screen readers. "api.colActions", + // The GOAT product name, which the plan's 060 key table gives as "GOAT" in every locale. + "remoteLink.child.kind.goat", "common.github", // Product names and ordinary French words whose correct spelling is identical to English. "remote.pairingCommandWindows", diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 9e155e33aa6..1a4df8e754d 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -226,6 +226,9 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "logs.cost.lowerBound", // A token count ("200k") reads the same in every locale; the 1M choice carries the translated "default". "claude.contextAccounting200k", + // GOAT is a product name, and Taiwan readers know it by that name; the badge is a chip beside the + // translated install-kind label, so translating it would name a product nobody would recognize. + "remoteLink.child.kind.goat", ]); test("zh-TW ships no untranslated English placeholders beyond the intentional allowlist", async () => { diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx new file mode 100644 index 00000000000..a52eee3660c --- /dev/null +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -0,0 +1,843 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import type { Root } from "react-dom/client"; +import { act, type ReactElement } from "react"; +import RemoteLink from "../src/pages/RemoteLink"; +import { ChildJobProgress } from "../src/pages/RemoteLinkChildActions"; +import { LanguageProvider } from "../src/i18n/provider"; +import { CHILD_OPS_MAX_LINK_IDS } from "../../src/link/child-ops-types"; +import { + CHILD_JOB_POLL_MS, CHILD_JOB_POLL_TIMEOUT_MS, CHILD_OPERATION_MAX_LINK_IDS, chunkLinkIds, parseChildJob, parseChildOps, postChildOperation, +} from "../src/remote-link-child-ops-api"; + +const HOME = "http://fixture"; +let win: Window; +let root: Root | null = null; +let previous: Record; +const globals = ["window", "document", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])); + win = new Window({ url: "http://localhost/#remote" }); + Object.defineProperties(globalThis, { + window: { configurable: true, value: win }, document: { configurable: true, value: win.document }, navigator: { configurable: true, value: win.navigator }, localStorage: { configurable: true, value: win.localStorage }, IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); +}); + +afterEach(async () => { + try { + if (root) await act(async () => { root?.unmount(); }); + } finally { + root = null; + try { await win.happyDOM.close(); } finally { + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + } +}); + +function response(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); } +async function flush(): Promise { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); } +async function settleUntil(done: () => boolean, limitMs = 3_000): Promise { + const deadline = Date.now() + limitMs; + while (!done() && Date.now() < deadline) await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); +} + +type Call = { method: string; path: string; body?: Record }; +/** Every request the page makes, with a parsed JSON body, routed by path prefix. */ +function installFetch(handle: (call: Call) => Response | Promise): Call[] { + const calls: Call[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + const body = init?.body ? JSON.parse(String(init.body)) as Record : undefined; + const call: Call = { method: init?.method ?? "GET", path: url.pathname + url.search, body }; + calls.push(call); + return handle(call); + }) as typeof fetch; + return calls; +} + +function childBlock(overrides: Record = {}): Record { + return { + homeVersion: "2.35.0", runningVersion: "2.34.0", installedVersion: "2.34.0", installKind: "npm", + versionSkew: "behind", + update: { route: "orchestrated", reason: null, observedAt: "2026-11-12T00:00:00.000Z" }, + catalog: { state: "behind", pulledAt: 1, homeChangedAt: 2 }, + codex: null, childBuild: null, + activity: { idle: true, inFlight: 0, lastDataEndAt: null }, + activeJob: null, lastOperation: null, + ...overrides, + }; +} + +function row(id: string, alias: string, child: Record | null, direction: "hub-initiated" | "client-initiated" = "hub-initiated"): Record { + return { id, alias, direction, state: "connected", since: "now", reason: null, tunnelPort: 43110, childKind: null, appliedCodexSettingsDigest: null, ...(child ? { child } : {}) }; +} + +function status(links: Array>, role: "home" | "standalone" | "child" = "home"): Record { + return { role, listener: { state: "listening", port: 8788 }, child: null, joinAvailable: false, links }; +} + +const POLICY = { catalogFanout: "auto", childCodexRestart: "never", updates: "auto-when-idle", idleAfterMs: 600_000 }; + +function job(overrides: Record = {}): Record { + return { schema: "ocx.link.child-job/1", jobId: "ljob_0123456789abcdef", op: "update", trigger: "manual", restart: "none", state: "running", createdAt: "now", finishedAt: null, + items: [{ linkId: "l1", alias: "box-one", state: "running", reason: null, fromVersion: "2.34.0", toVersion: "2.35.0", startedAt: null, finishedAt: null }], ...overrides }; +} + +async function mount(): Promise { + const host = win.document.createElement("div"); + win.document.body.append(host); + root = (await import("react-dom/client")).createRoot(host); + await act(async () => { root?.render(); }); + await flush(); + return host; +} + +/** Mounts one component on its own; a finished job's panel leaves the page, so its last render is asserted here. */ +async function mountNode(element: ReactElement): Promise { + const host = win.document.createElement("div"); + win.document.body.append(host); + root = (await import("react-dom/client")).createRoot(host); + await act(async () => { root?.render({element}); }); + await flush(); + return host; +} + +function button(host: HTMLElement, label: string): HTMLButtonElement { + const found = [...host.querySelectorAll("button")].find(item => item.textContent?.trim() === label); + if (!found) throw new Error(`no button ${label}: ${host.textContent}`); + return found as HTMLButtonElement; +} +async function click(element: HTMLElement | null | undefined): Promise { if (!element) throw new Error("missing element"); await act(async () => { element.click(); }); await flush(); } +/** Runs the job panel's visibility poll now instead of waiting out CHILD_JOB_POLL_MS. */ +async function pollJobs(): Promise { + await act(async () => { win.document.dispatchEvent(new win.Event("visibilitychange")); }); + await flush(); +} +async function chooseOption(label: string): Promise { + const option = [...win.document.querySelectorAll("[role=option]")].find(item => item.textContent === label); + if (!option) throw new Error(`no option ${label}`); + await click(option as HTMLElement); +} +function openSelect(host: HTMLElement, index: number): HTMLButtonElement { + const triggers = [...host.querySelectorAll(".remote-link-policy [role=combobox]")]; + const trigger = triggers[index]; + if (!trigger) throw new Error(`no select ${index}`); + return trigger; +} + +/** The first dialog in a row that has rendered its body (the closed restart dialog comes first). */ +function openDialog(host: HTMLElement): HTMLDialogElement { + const dialog = [...host.querySelectorAll("dialog")].find(item => item.textContent !== ""); + if (!dialog) throw new Error(`no open dialog: ${host.textContent}`); + return dialog as HTMLDialogElement; +} + +test("status is requested with children=1", async () => { + const calls = installFetch(() => response(status([row("l1", "box-one", childBlock())]))); + await mount(); + expect(calls[0]?.path).toBe("/api/link/status?children=1"); +}); + +test("an older Home renders today's row with no child controls", async () => { + const calls = installFetch(call => call.path.startsWith("/api/link/status") ? response(status([row("l1", "box-one", null)])) : response({ error: { code: "not_found" } }, 404)); + const host = await mount(); + expect(host.textContent).toContain("box-one"); + expect(host.querySelector(".remote-link-child-badges")).toBeNull(); + expect(host.querySelector(".remote-link-child-actions")).toBeNull(); + expect(host.querySelector(".remote-link-child-bulk")).toBeNull(); + expect(host.querySelector(".remote-link-policy")).toBeNull(); + expect(button(host, "Disconnect").disabled).toBe(false); + expect(calls.some(call => call.path.startsWith("/api/link/children/"))).toBe(false); +}); + +test("badges carry version, skew, catalog, Codex restart and owner action", async () => { + installFetch(() => response(status([ + row("l1", "box-one", childBlock({ codex: { appServers: "stale", staleCount: 2, catalogMatchesHome: false, catalogSyncedAt: null, reportedAt: 5 } })), + row("l2", "box-two", childBlock({ versionSkew: "same", update: { route: "owner-action", reason: "desktop-app-owned", observedAt: null }, catalog: { state: "current", pulledAt: 1, homeChangedAt: 1 } })), + ]))); + const host = await mount(); + const first = host.querySelectorAll(".remote-link-child-badges")[0]!; + expect(first.textContent).toContain("v2.34.0"); + expect(first.textContent).toContain("Behind Home (2.35.0)"); + expect(first.textContent).toContain("Update available: 2.35.0"); + expect(first.textContent).toContain("Catalog out of date"); + expect(first.textContent).toContain("Codex restart needed (2)"); + const second = host.querySelectorAll(".remote-link-child-badges")[1]!; + expect(second.textContent).toContain("Same version as Home"); + expect(second.textContent).toContain("Use Check for Updates in OpenCodex Desktop on this Child"); +}); + +test("Check and Sync now post exact single-link bodies", async () => { + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock({ activity: { idle: false, inFlight: 1, lastDataEndAt: null } }))])); + if (call.path === "/api/link/children/operations") return response({ job: job({ op: "observe", state: "done", items: [] }) }, 202); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await click(button(host, "Check")); + await click(button(host, "Sync now")); + const posts = calls.filter(call => call.method === "POST"); + expect(posts.map(call => call.body)).toEqual([{ op: "observe", linkIds: ["l1"] }, { op: "sync", linkIds: ["l1"] }]); +}); + +test("Restart Codex picks a scope and states the app-server caveat", async () => { + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/operations") return response({ job: job({ op: "restart-codex", state: "done", items: [] }) }, 202); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await click(button(host, "Restart Codex")); + const dialog = openDialog(host); + expect(dialog.querySelectorAll("input[type=radio]")[0]?.checked).toBe(true); + expect(dialog.textContent).toContain("The Codex desktop app's own app-server is not refreshed"); + await click([...dialog.querySelectorAll("button")].find(item => item.textContent === "Restart")!); + expect(calls.filter(call => call.method === "POST").at(-1)?.body).toEqual({ op: "restart-codex", linkIds: ["l1"], restart: "app-server" }); + + await click(button(host, "Restart Codex")); + const reopened = openDialog(host); + await click(reopened.querySelectorAll("input[type=radio]")[1]!); + expect(reopened.textContent).toContain("Quits and relaunches the Codex desktop app on box-one"); + await click([...reopened.querySelectorAll("button")].find(item => item.textContent === "Restart")!); + expect(calls.filter(call => call.method === "POST").at(-1)?.body).toEqual({ op: "restart-codex", linkIds: ["l1"], restart: "codex" }); +}); + +test("a cancelled restart dialog reopens at the app-server default", async () => { + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/operations") return response({ job: job({ op: "restart-codex", state: "done", items: [] }) }, 202); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await click(button(host, "Restart Codex")); + await click(openDialog(host).querySelectorAll("input[type=radio]")[1]!); + await click([...openDialog(host).querySelectorAll("button")].find(item => item.textContent === "Cancel")!); + expect(calls.filter(call => call.method === "POST")).toHaveLength(0); + await click(button(host, "Restart Codex")); + const reopened = openDialog(host); + expect(reopened.querySelectorAll("input[type=radio]")[0]?.checked).toBe(true); + expect(reopened.querySelectorAll("input[type=radio]")[1]?.checked).toBe(false); +}); + +test("Update and Update all confirm the disruption and post their bodies", async () => { + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([ + row("l1", "box-one", childBlock()), + row("l2", "box-two", childBlock({ versionSkew: "same", update: { route: "orchestrated", reason: null, observedAt: null } })), + ])); + if (call.path === "/api/link/children/operations") return response({ job: job({ state: "done", items: [] }) }, 202); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + const updateAll = button(host, "Update all"); + expect(updateAll.disabled).toBe(false); + await click(updateAll); + const bulk = host.querySelector(".remote-link-child-bulk")!; + const bulkDialog = bulk.querySelector("dialog")!; + expect(bulkDialog.textContent).toContain("Update 1 Children to 2.35.0, one at a time."); + expect(bulkDialog.textContent).toContain("ends any turn currently relayed for it"); + expect(bulkDialog.textContent).toContain("Automatic updates wait until a Child is idle."); + await click([...bulkDialog.querySelectorAll("button")].find(item => item.textContent === "Update all")!); + expect(calls.filter(call => call.method === "POST").at(-1)?.body).toEqual({ op: "update", linkIds: ["l1"] }); + + const firstRow = host.querySelectorAll(".remote-link-row")[0]!; + await click([...firstRow.querySelectorAll("button")].find(item => item.textContent === "Update")!); + const rowDialog = openDialog(firstRow); + expect(rowDialog.textContent).toContain("Update box-one to 2.35.0?"); + expect(rowDialog.textContent).toContain("ends any turn currently relayed for it"); + await click([...rowDialog.querySelectorAll("button")].find(item => item.textContent === "Update")!); + expect(calls.filter(call => call.method === "POST").at(-1)?.body).toEqual({ op: "update", linkIds: ["l1"] }); +}); + +test("Update all and Check all name exactly the rows they cover", async () => { + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([ + row("l1", "box-one", childBlock()), + row("l2", "box-two", childBlock({ versionSkew: "same", update: { route: "orchestrated", reason: null, observedAt: null } })), + row("l3", "box-three", childBlock(), "client-initiated"), + row("l4", "box-four", childBlock()), + ])); + if (call.path === "/api/link/children/operations") return response({ job: job({ state: "done", items: [] }) }, 202); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await click(button(host, "Update all")); + const bulk = host.querySelector(".remote-link-child-bulk")!; + const bulkDialog = bulk.querySelector("dialog")!; + expect(bulkDialog.textContent).toContain("Update 2 Children to 2.35.0, one at a time."); + await click([...bulkDialog.querySelectorAll("button")].find(item => item.textContent === "Update all")!); + // l2 is current and l3 was started from itself, so neither is in the confirmation or the request. + expect(calls.filter(call => call.method === "POST").at(-1)?.body).toEqual({ op: "update", linkIds: ["l1", "l4"] }); + await click(button(host, "Check all")); + expect(calls.filter(call => call.method === "POST").at(-1)?.body).toEqual({ op: "observe", linkIds: ["l1", "l2", "l4"] }); +}); + +test("bulk actions split more than 64 Children into requests the server accepts", async () => { + // The operations route refuses a body naming more than 64 links, and a Home's link count is unbounded. + const ids = Array.from({ length: 65 }, (_, index) => `l${index}`); + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status(ids.map(id => row(id, `box-${id}`, childBlock())))); + if (call.path === "/api/link/children/operations") return response({ job: job({ jobId: `ljob_${String(calls.length).padStart(16, "0")}`, state: "done", items: [] }) }, 202); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await click(button(host, "Check all")); + await settleUntil(() => calls.filter(call => call.method === "POST").length === 2); + const checks = calls.filter(call => call.method === "POST").map(call => call.body); + expect(checks).toEqual([{ op: "observe", linkIds: ids.slice(0, 33) }, { op: "observe", linkIds: ids.slice(33) }]); + await click(button(host, "Update all")); + const bulkDialog = host.querySelector(".remote-link-child-bulk dialog")!; + expect(bulkDialog.textContent).toContain("Update 65 Children"); + await click([...bulkDialog.querySelectorAll("button")].find(item => item.textContent === "Update all")!); + await settleUntil(() => calls.filter(call => call.method === "POST").length === 4); + const updates = calls.filter(call => call.method === "POST").slice(2).map(call => call.body); + expect(updates).toEqual([{ op: "update", linkIds: ids.slice(0, 33) }, { op: "update", linkIds: ids.slice(33) }]); + expect(host.textContent).not.toContain("could not be completed"); +}); + +test("the GUI copy of the per-request link cap equals the route's, and groups are even and within it", () => { + // gui/ cannot import the route; a drifted copy would turn every large bulk action into whole-group refusals. + expect(CHILD_OPERATION_MAX_LINK_IDS).toBe(CHILD_OPS_MAX_LINK_IDS); + const ids = (count: number) => Array.from({ length: count }, (_, index) => `l${index}`); + expect(chunkLinkIds(ids(64)).map(group => group.length)).toEqual([64]); + expect(chunkLinkIds(ids(65)).map(group => group.length)).toEqual([33, 32]); + expect(chunkLinkIds(ids(129)).map(group => group.length)).toEqual([43, 43, 43]); + expect(chunkLinkIds([])).toEqual([]); + for (const count of [1, 63, 65, 128, 200, 1000, 4033, 4097, 10_000]) { + const groups = chunkLinkIds(ids(count)); + expect(groups.flat()).toEqual(ids(count)); + expect(groups.every(group => group.length <= CHILD_OPS_MAX_LINK_IDS && (count === 1 || group.length > 1))).toBe(true); + const sizes = groups.map(group => group.length); + expect(Math.max(...sizes) - Math.min(...sizes)).toBeLessThanOrEqual(1); + } +}); + +test("a submit that never settles is abandoned at its deadline", async () => { + // The bulk bar and row actions stay busy until the POST settles; one that hangs and ignores its signal must still end. + globalThis.fetch = (() => new Promise(() => undefined)) as unknown as typeof fetch; + const started = Date.now(); + await expect(postChildOperation(HOME, { op: "observe", linkIds: ["l1"] }, 30)).rejects.toBeDefined(); + expect(Date.now() - started).toBeLessThan(2_000); +}); + +test("a refused bulk group shows its error while the accepted group is still tracked", async () => { + const ids = Array.from({ length: 65 }, (_, index) => `l${index}`); + installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status(ids.map(id => row(id, `box-${id}`, childBlock())))); + if (call.path === "/api/link/children/operations") { + const linkIds = call.body?.linkIds as string[]; + if (linkIds.includes("l64")) return response({ error: { code: "link_operation_in_progress" } }, 409); + return response({ job: job({ jobId: "ljob_bulkgroup0000000", op: "observe", items: [ + { linkId: "l0", alias: "box-l0", state: "running", reason: null, fromVersion: null, toVersion: null, startedAt: null, finishedAt: null }, + ] }) }, 202); + } + if (call.path.startsWith("/api/link/children/jobs/")) return new Promise(() => undefined); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await click(button(host, "Check all")); + await settleUntil(() => host.textContent?.includes("Another operation is running on this Child.") === true); + expect(host.textContent).toContain("Another operation is running on this Child."); + // The first group was accepted, so its job panel shows and the row its job names (l0) is held. + expect(host.querySelector(".remote-link-job")).not.toBeNull(); + const firstRow = host.querySelectorAll(".remote-link-row")[0]!; + const rowButtons = [...firstRow.querySelectorAll("button")]; + for (const label of ["Check", "Sync now", "Update", "Disconnect"]) { + expect(rowButtons.find(item => item.textContent === label)!.disabled).toBe(true); + } + // A row the accepted job does not name keeps its controls. + const otherRow = host.querySelectorAll(".remote-link-row")[1]!; + expect([...otherRow.querySelectorAll("button")].find(item => item.textContent === "Check")!.disabled).toBe(false); +}); + +test("a running job shows per-item progress and refreshes status once when done", async () => { + let jobPolls = 0; + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/operations") return response({ job: job() }, 202); + if (call.path === "/api/link/children/jobs/ljob_0123456789abcdef") { + jobPolls += 1; + return response({ job: job({ state: "done", items: [{ linkId: "l1", alias: "box-one", state: "succeeded", reason: null, fromVersion: "2.34.0", toVersion: "2.35.0", sync: { state: "synced", reason: null }, restart: { requested: "app-server", outcome: "requested" }, startedAt: null, finishedAt: null }] }) }); + } + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await click([...host.querySelectorAll(".remote-link-row button")].find(item => item.textContent === "Update")!); + await click([...host.querySelectorAll(".remote-link-row dialog button")].find(item => item.textContent === "Update")!); + const panel = host.querySelector(".remote-link-job")!; + expect(panel.textContent).toContain("Updating…"); + expect(panel.textContent).toContain("Running"); + const statusReads = calls.filter(call => call.path.includes("/api/link/status")).length; + await pollJobs(); + // The panel leaves the page once the job is done; the rows carry the result after one refresh. + await settleUntil(() => host.querySelector(".remote-link-job") === null); + expect(host.querySelector(".remote-link-job")).toBeNull(); + // pollJobs also wakes the page's own status poll, so count the completion refresh where it is exact: onDone. + expect(calls.filter(call => call.path.includes("/api/link/status")).length).toBeGreaterThan(statusReads); + expect(jobPolls).toBeGreaterThan(0); +}); + +test("a finished job renders item states, sync and restart outcomes", async () => { + installFetch(call => call.path.startsWith("/api/link/children/jobs/") + ? response({ job: job({ state: "done", items: [{ linkId: "l1", alias: "box-one", state: "succeeded", reason: null, fromVersion: "2.34.0", toVersion: "2.35.0", sync: { state: "synced", reason: null }, restart: { requested: "app-server", outcome: "requested" }, startedAt: null, finishedAt: null }] }) }) + : response({ error: { code: "not_found" } }, 404)); + const finished = job({ state: "done", items: [{ linkId: "l1", alias: "box-one", state: "succeeded", reason: null, fromVersion: "2.34.0", toVersion: "2.35.0", sync: { state: "synced", reason: null }, restart: { requested: "app-server", outcome: "requested" }, startedAt: null, finishedAt: null }] }); + let doneCalls = 0; + const host = await mountNode( { doneCalls += 1; }} />); + const panel = host.querySelector(".remote-link-job")!; + expect(panel.textContent).toContain("Succeeded"); + expect(panel.textContent).toContain("Synced"); + expect(panel.textContent).toContain("Restart requested"); + expect(panel.textContent).toContain("2.34.0 → 2.35.0"); + expect(doneCalls).toBe(1); +}); + +test("a job_not_found answer stops polling and marks running items uncertain", async () => { + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/operations") return response({ job: job() }, 202); + if (call.path.startsWith("/api/link/children/jobs/")) return response({ error: { code: "job_not_found" } }, 404); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await click([...host.querySelectorAll(".remote-link-row button")].find(item => item.textContent === "Update")!); + await click([...host.querySelectorAll(".remote-link-row dialog button")].find(item => item.textContent === "Update")!); + await pollJobs(); + const polls = calls.filter(call => call.path.startsWith("/api/link/children/jobs/")).length; + await settleUntil(() => host.querySelector(".remote-link-job") === null); + await pollJobs(); + expect(calls.filter(call => call.path.startsWith("/api/link/children/jobs/")).length).toBe(polls); + expect(polls).toBeGreaterThan(0); +}); + +test("a lost job says its progress is gone and marks a running item uncertain", async () => { + installFetch(() => response({ error: { code: "job_not_found" } }, 404)); + const host = await mountNode( {}} />); + await settleUntil(() => (host.querySelector(".remote-link-job")?.textContent ?? "").includes("Outcome unknown")); + const panel = host.querySelector(".remote-link-job")!; + expect(panel.textContent).toContain("Outcome unknown"); + expect(panel.textContent).toContain("This Home restarted, so the job's progress is gone."); +}); + +test("a 409 link_operation_in_progress is shown translated", async () => { + installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/operations") return response({ error: { code: "link_operation_in_progress" } }, 409); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await click(button(host, "Check")); + expect(host.textContent).toContain("Another operation is running on this Child."); +}); + +test("an active job holds the row and its progress is adopted on load", async () => { + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([ + row("l1", "box-one", childBlock({ activeJob: { jobId: "ljob_0123456789abcdef", op: "sync", state: "running" } })), + row("l2", "box-two", childBlock({ activeJob: { jobId: "ljob_fedcba9876543210", op: "update", state: "running" } })), + ])); + if (call.path === "/api/link/children/jobs/ljob_0123456789abcdef") return response({ job: job({ jobId: "ljob_0123456789abcdef", op: "sync" }) }); + if (call.path === "/api/link/children/jobs/ljob_fedcba9876543210") return response({ job: job({ jobId: "ljob_fedcba9876543210", op: "update" }) }); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await settleUntil(() => host.querySelectorAll(".remote-link-job").length === 2); + expect(host.querySelectorAll(".remote-link-job")).toHaveLength(2); + expect(calls.filter(call => call.path.startsWith("/api/link/children/jobs/")).length).toBe(2); + const firstRow = host.querySelectorAll(".remote-link-row")[0]!; + for (const label of ["Check", "Sync now", "Restart Codex", "Update"]) { + expect(([...firstRow.querySelectorAll("button")].find(item => item.textContent === label) as HTMLButtonElement).disabled).toBe(true); + } + expect(([...firstRow.querySelectorAll("button")].find(item => item.textContent === "Disconnect") as HTMLButtonElement).disabled).toBe(true); +}); + +test("an accepted job holds the row before the next status poll reports it", async () => { + let polls = 0; + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) { + polls += 1; + // The first read has no activeJob; the server only reports it on a later poll. + return response(status([row("l1", "box-one", childBlock()), row("l2", "box-two", childBlock())])); + } + if (call.path === "/api/link/children/operations") return response({ job: job() }, 202); + if (call.path.startsWith("/api/link/children/jobs/")) return response({ job: job() }); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + const firstRow = host.querySelectorAll(".remote-link-row")[0]!; + await click([...firstRow.querySelectorAll("button")].find(item => item.textContent === "Update")!); + await click([...firstRow.querySelectorAll("dialog button")].find(item => item.textContent === "Update")!); + // The POST is accepted and the job is tracked: childOpBusy is clear, and no status poll has reported activeJob. + const rowButtons = () => [...firstRow.querySelectorAll("button")]; + for (const label of ["Check", "Sync now", "Restart Codex", "Update"]) { + expect(rowButtons().find(item => item.textContent === label)!.disabled).toBe(true); + } + expect(rowButtons().find(item => item.textContent === "Disconnect")!.disabled).toBe(true); + // The tracked job names l1 only; l2 keeps its controls. + const secondRow = host.querySelectorAll(".remote-link-row")[1]!; + expect(([...secondRow.querySelectorAll("button")].find(item => item.textContent === "Check") as HTMLButtonElement).disabled).toBe(false); + expect(([...secondRow.querySelectorAll("button")].find(item => item.textContent === "Disconnect") as HTMLButtonElement).disabled).toBe(false); + expect(calls.filter(call => call.method === "POST")).toHaveLength(1); + expect(polls).toBeGreaterThan(0); +}); + +test("a client-initiated row shows badges but no actions", async () => { + installFetch(() => response(status([row("l1", "box-one", childBlock(), "client-initiated")]))); + const host = await mount(); + expect(host.querySelector(".remote-link-child-badges")).not.toBeNull(); + expect(host.querySelector(".remote-link-child-actions")).toBeNull(); + expect(button(host, "Disconnect").disabled).toBe(false); +}); + +test("manual restart and update wait for in-flight activity only", async () => { + installFetch(call => call.path.startsWith("/api/link/status") + ? response(status([row("l1", "box-one", childBlock({ activity: { idle: false, inFlight: 2, lastDataEndAt: 7 } }))])) + : response({ error: { code: "not_found" } }, 404)); + const host = await mount(); + expect(button(host, "Check").disabled).toBe(false); + expect(button(host, "Sync now").disabled).toBe(false); + expect(button(host, "Restart Codex").disabled).toBe(true); + expect(button(host, "Update").disabled).toBe(true); +}); + +test("the policy panel loads once, keeps edits across a status poll, and saves only changed keys", async () => { + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/policy" && call.method === "GET") return response({ policy: POLICY, warning: null }); + if (call.path === "/api/link/children/policy" && call.method === "PUT") return response({ policy: { ...POLICY, catalogFanout: "manual" }, warning: null }); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await settleUntil(() => host.querySelector(".remote-link-policy") !== null); + expect(host.querySelector(".remote-link-policy")!.textContent).toContain("Child automation"); + await click(openSelect(host, 0)); + await chooseOption("Manual"); + await act(async () => { win.document.dispatchEvent(new win.Event("visibilitychange")); }); + await flush(); + expect(openSelect(host, 0).textContent).toContain("Manual"); + expect(calls.filter(call => call.path === "/api/link/children/policy" && call.method === "GET")).toHaveLength(1); + await click(button(host, "Save")); + expect(calls.filter(call => call.method === "PUT").at(-1)?.body).toEqual({ catalogFanout: "manual" }); + expect(host.querySelector(".remote-link-policy")!.textContent).toContain("Saved"); +}); + +test("a damaged policy file warns and a save writes every knob", async () => { + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/policy" && call.method === "GET") return response({ policy: { catalogFanout: "manual", childCodexRestart: "never", updates: "off", idleAfterMs: 600_000 }, warning: "policy-file-invalid" }); + if (call.path === "/api/link/children/policy" && call.method === "PUT") return response({ policy: { ...POLICY }, warning: null }); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await settleUntil(() => host.querySelector(".remote-link-policy") !== null); + expect(host.querySelector(".remote-link-policy")!.textContent).toContain("automatic catalog fan-out and automatic updates are off"); + await click(openSelect(host, 0)); + await chooseOption("Automatic"); + await click(button(host, "Save")); + expect(calls.filter(call => call.method === "PUT").at(-1)?.body).toEqual({ catalogFanout: "auto", childCodexRestart: "never", updates: "off", idleAfterMs: 600_000 }); +}); + +test("a failed policy load shows an error with Retry, and Retry re-reads the policy", async () => { + let reads = 0; + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/policy" && call.method === "GET") { + reads += 1; + return reads === 1 ? response({ error: { code: "policy_write_failed" } }, 500) : response({ policy: POLICY, warning: null }); + } + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await settleUntil(() => host.querySelector(".remote-link-policy")?.textContent?.includes("Could not load Child automation settings") ?? false); + const panel = host.querySelector(".remote-link-policy")!; + expect(panel.textContent).toContain("Could not load Child automation settings."); + expect(calls.filter(call => call.path === "/api/link/children/policy")).toHaveLength(1); + await click(button(host, "Retry")); + await settleUntil(() => host.querySelector(".remote-link-policy")?.querySelector("[role=combobox]") !== null); + const reloaded = host.querySelector(".remote-link-policy")!; + expect(reloaded.querySelectorAll("[role=combobox]").length).toBeGreaterThan(0); + expect(reloaded.textContent).not.toContain("Could not load Child automation settings."); + expect(calls.filter(call => call.path === "/api/link/children/policy" && call.method === "GET")).toHaveLength(2); +}); + +test("the page Refresh also reloads the policy", async () => { + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/policy" && call.method === "GET") return response({ policy: POLICY, warning: null }); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await settleUntil(() => host.querySelector(".remote-link-policy") !== null); + expect(calls.filter(call => call.path === "/api/link/children/policy")).toHaveLength(1); + // A status poll alone must not re-read the policy, or an in-progress edit would be lost. + await act(async () => { win.document.dispatchEvent(new win.Event("visibilitychange")); }); + await flush(); + expect(calls.filter(call => call.path === "/api/link/children/policy")).toHaveLength(1); + await click(button(host, "Refresh")); + await settleUntil(() => calls.filter(call => call.path === "/api/link/children/policy").length === 2); + expect(calls.filter(call => call.path === "/api/link/children/policy" && call.method === "GET").length).toBe(2); +}); + +test("the idle-minutes input only accepts digits in range", async () => { + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/policy" && call.method === "GET") return response({ policy: POLICY, warning: null }); + if (call.path === "/api/link/children/policy" && call.method === "PUT") return response({ policy: POLICY, warning: null }); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await settleUntil(() => host.querySelector(".remote-link-policy") !== null); + const input = host.querySelector(".remote-link-policy input[type=number]")!; + const type = async (value: string) => { + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + input.dispatchEvent(new win.Event("change", { bubbles: true })); + await flush(); + }); + }; + // Empty, partial and out-of-range text never reaches the draft: the input keeps the last valid value. + for (const bad of ["", "0", "61", "1e3", "-5", "7.5"]) { await type(bad); expect(input.value).toBe("10"); } + await type("15"); + expect(input.value).toBe("15"); + await click(button(host, "Save")); + expect(calls.filter(call => call.method === "PUT").at(-1)?.body).toEqual({ idleAfterMs: 900_000 }); +}); + +test("a withheld restart says a request was in flight", async () => { + installFetch(() => response({ job: job({ state: "done", items: [{ linkId: "l1", alias: "box-one", state: "succeeded", reason: null, fromVersion: null, toVersion: null, restart: { requested: "app-server", outcome: "skipped" }, startedAt: null, finishedAt: null }] }) })); + const host = await mountNode( {}} />); + await settleUntil(() => (host.querySelector(".remote-link-job")?.textContent ?? "").includes("Restart withheld")); + const panel = host.querySelector(".remote-link-job")!; + expect(panel.textContent).toContain("Restart withheld: a request was in flight on this Child."); + expect(panel.textContent).not.toContain("Restart requested"); +}); + +test("an unknown last-operation state renders as code", async () => { + installFetch(() => response(status([row("l1", "box-one", childBlock({ + lastOperation: { op: "update", jobId: "ljob_0123456789abcdef", state: "melted", reason: null, fromVersion: null, toVersion: null, finishedAt: null }, + }))]))); + const host = await mount(); + const badges = host.querySelector(".remote-link-child-badges")!; + expect([...badges.querySelectorAll("code")].map(item => item.textContent)).toContain("melted"); + // `remoteLink.job.last` names the operation with a noun key (OP_NOUN_KEY), not the in-progress label. + expect(badges.textContent).toContain("Last operation: Update — melted"); +}); + +test("an unknown restart outcome renders as code instead of a guessed sentence", async () => { + const melted = job({ items: [{ linkId: "l1", alias: "box-one", state: "succeeded", reason: null, fromVersion: null, toVersion: null, restart: { requested: "app-server", outcome: "melted" }, startedAt: null, finishedAt: null }] }); + installFetch(() => response({ job: melted })); + const host = await mountNode( {}} />); + await settleUntil(() => [...host.querySelectorAll("code")].some(item => item.textContent === "melted")); + expect([...host.querySelectorAll("code")].map(item => item.textContent)).toContain("melted"); + expect(host.querySelector(".remote-link-job")!.textContent).not.toContain("Restart requested"); +}); + +test("a job item renders the hint the server attached to it", async () => { + const hinted = job({ state: "done", items: [{ linkId: "l1", alias: "box-one", state: "owner-action", reason: "launcher-node-missing", + hint: "Install Node.js where the npm or pnpm that installed OpenCodex on this Child can find it, or update there with ocx update.", + fromVersion: null, toVersion: null, startedAt: null, finishedAt: null }] }); + installFetch(() => response({ job: hinted })); + const host = await mountNode( {}} />); + const panel = host.querySelector(".remote-link-job")!; + expect(panel.textContent).toContain("launcher-node-missing"); + expect(panel.textContent).toContain("Install Node.js where the npm or pnpm that installed OpenCodex on this Child can find it, or update there with ocx update."); +}); + +test("parsers reject a malformed child block and keep an unknown job state", () => { + expect(parseChildOps(null)).toBeNull(); + expect(parseChildOps({ homeVersion: "1.0.0" })).toBeNull(); + expect(parseChildOps(childBlock())?.versionSkew).toBe("behind"); + const parsed = parseChildJob(job({ state: "melted" })); + expect(parsed.state).toBe("running"); + expect(parsed.items[0]?.state).toBe("running"); + expect(() => parseChildJob({ schema: "ocx.link.child-job/1" })).toThrow(); +}); + +test("a two-item job releases a row as soon as its own item finishes", async () => { + const twoItemJob = (first: string) => job({ jobId: "ljob_twoitems00000000", state: "running", items: [ + { linkId: "l1", alias: "box-one", state: first, reason: null, fromVersion: null, toVersion: null, startedAt: null, finishedAt: null }, + { linkId: "l2", alias: "box-two", state: "running", reason: null, fromVersion: null, toVersion: null, startedAt: null, finishedAt: null }, + ] }); + let reads = 0; + installFetch(call => { + // No status row carries activeJob: the page's accepted job is the only signal, as right after the POST. + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock()), row("l2", "box-two", childBlock())])); + if (call.path === "/api/link/children/operations") return response({ job: twoItemJob("running") }, 202); + if (call.path === "/api/link/children/jobs/ljob_twoitems00000000") { + reads += 1; + return response({ job: twoItemJob(reads === 1 ? "running" : "succeeded") }); + } + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await click(button(host, "Check all")); + await settleUntil(() => host.querySelectorAll(".remote-link-job").length === 1); + const check = (index: number) => [...host.querySelectorAll(".remote-link-row")[index]!.querySelectorAll("button")].find(item => item.textContent === "Check") as HTMLButtonElement; + // Both items are still running, so both rows are held. + expect(check(0).disabled).toBe(true); + expect(check(1).disabled).toBe(true); + await pollJobs(); + await settleUntil(() => reads >= 2); + // The first item reached a terminal state; the page must release that row without waiting for the second. + await settleUntil(() => !check(0).disabled); + expect(check(0).disabled).toBe(false); + expect(check(1).disabled).toBe(true); +}); + +function deferred(): { promise: Promise; resolve(value: T): void } { + let resolve!: (value: T) => void; + const promise = new Promise(res => { resolve = res; }); + return { promise, resolve }; +} + +test("a policy GET in flight when a save starts never overwrites the saved values", async () => { + const stale = deferred(); + let reads = 0; + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/policy" && call.method === "GET") { + reads += 1; + return reads === 1 ? response({ policy: POLICY, warning: null }) : stale.promise; + } + if (call.path === "/api/link/children/policy" && call.method === "PUT") return response({ policy: { ...POLICY, catalogFanout: "manual" }, warning: null }); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await settleUntil(() => host.querySelector(".remote-link-policy") !== null); + await click(button(host, "Refresh")); + await settleUntil(() => reads === 2); + await click(openSelect(host, 0)); + await chooseOption("Manual"); + await click(button(host, "Save")); + expect(host.querySelector(".remote-link-policy")!.textContent).toContain("Saved"); + // The reload that began before the save lands afterwards: it must not replace what the save wrote. + await act(async () => { stale.resolve(response({ policy: { ...POLICY, catalogFanout: "auto", updates: "off" }, warning: null })); await flush(); }); + expect(openSelect(host, 0).textContent).toContain("Manual"); + expect(host.querySelector(".remote-link-policy")!.textContent).toContain("Saved"); + expect(calls.filter(call => call.method === "PUT")).toHaveLength(1); +}); + +test("an edit made during a policy reload survives it", async () => { + const slow = deferred(); + let reads = 0; + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/policy" && call.method === "GET") { + reads += 1; + return reads === 1 ? response({ policy: POLICY, warning: null }) : slow.promise; + } + if (call.path === "/api/link/children/policy" && call.method === "PUT") return response({ policy: { ...POLICY, catalogFanout: "manual" }, warning: null }); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await settleUntil(() => host.querySelector(".remote-link-policy") !== null); + await click(button(host, "Refresh")); + await settleUntil(() => reads === 2); + await click(openSelect(host, 0)); + await chooseOption("Manual"); + // The reload lands after the edit; the operator's choice must stay, and the save must carry it. + await act(async () => { slow.resolve(response({ policy: POLICY, warning: null })); await flush(); }); + expect(openSelect(host, 0).textContent).toContain("Manual"); + await click(button(host, "Save")); + expect(calls.filter(call => call.method === "PUT").at(-1)?.body).toEqual({ catalogFanout: "manual" }); +}); + +test("a job poll that never settles times out, so the interval polls again and releases the row", async () => { + // Capture the poll interval and the per-request deadline so the test drives them instead of waiting them out. + const ticks: Array<() => void> = []; + const deadlines: Array<() => void> = []; + const setIntervalReal = win.setInterval.bind(win); + const setTimeoutReal = win.setTimeout.bind(win); + Object.assign(win, { + setInterval: (callback: () => void, ms?: number) => { if (ms === CHILD_JOB_POLL_MS) ticks.push(callback); return setIntervalReal(() => undefined, 3_600_000); }, + setTimeout: (callback: () => void, ms?: number) => { if (ms === CHILD_JOB_POLL_TIMEOUT_MS) deadlines.push(callback); return setTimeoutReal(callback, ms === CHILD_JOB_POLL_TIMEOUT_MS ? 3_600_000 : ms); }, + }); + const items = (state: string) => job({ jobId: "ljob_hung000000000000", state: "running", items: [ + { linkId: "l1", alias: "box-one", state, reason: null, fromVersion: null, toVersion: null, startedAt: null, finishedAt: null }, + ] }); + let reads = 0; + installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/operations") return response({ job: items("running") }, 202); + if (call.path === "/api/link/children/jobs/ljob_hung000000000000") { + reads += 1; + // The first poll hangs forever and ignores its abort signal, like a stalled connection. + return reads === 1 ? new Promise(() => undefined) : response({ job: items("succeeded") }); + } + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await click(button(host, "Check")); + expect(ticks.length).toBe(1); + await act(async () => { ticks[0]!(); await flush(); }); + expect(reads).toBe(1); + expect(button(host, "Check").disabled).toBe(true); + // While the hung request is pending, an interval tick sends nothing. + await act(async () => { ticks[0]!(); await flush(); }); + expect(reads).toBe(1); + expect(deadlines.length).toBe(1); + await act(async () => { deadlines[0]!(); await flush(); }); + await act(async () => { ticks[0]!(); await flush(); }); + expect(reads).toBe(2); + expect(button(host, "Check").disabled).toBe(false); + expect(host.querySelector(".remote-link-job")!.textContent).toContain("Succeeded"); +}); + +test("an older poll landing after a newer one never restores a released hold", async () => { + const first = deferred(); + const second = deferred(); + const items = (state: string) => job({ jobId: "ljob_overlap000000000", state: "running", items: [ + { linkId: "l1", alias: "box-one", state, reason: null, fromVersion: null, toVersion: null, startedAt: null, finishedAt: null }, + ] }); + let reads = 0; + installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/operations") return response({ job: items("running") }, 202); + if (call.path === "/api/link/children/jobs/ljob_overlap000000000") { + reads += 1; + // Poll A (running) is deliberately slow; poll B (succeeded) resolves first. + return reads === 1 ? first.promise : reads === 2 ? second.promise : response({ job: items("succeeded") }); + } + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await click(button(host, "Check")); + await settleUntil(() => reads === 1); + // A second tick fires while the first request is still pending. Serialized polling skips it; without the guard + // both are in flight and the older running response can land last and restore the hold. + await pollJobs(); + await act(async () => { second.resolve(response({ job: items("succeeded") })); await flush(); }); + await act(async () => { first.resolve(response({ job: items("running") })); await flush(); }); + // Asserted before the next interval tick: a later poll would re-release a row a stale response re-held. + expect(button(host, "Check").disabled).toBe(false); + expect(host.querySelector(".remote-link-job")!.textContent).toContain("Succeeded"); +}); + +test("a reload merges only the fields the operator changed onto the server's answer", async () => { + const slow = deferred(); + let reads = 0; + const calls = installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock())])); + if (call.path === "/api/link/children/policy" && call.method === "GET") { + reads += 1; + // The reload reports updates: off, a field the operator never touched. + return reads === 1 ? response({ policy: POLICY, warning: null }) : slow.promise; + } + if (call.path === "/api/link/children/policy" && call.method === "PUT") return response({ policy: { ...POLICY, catalogFanout: "manual", updates: "off" }, warning: null }); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await settleUntil(() => host.querySelector(".remote-link-policy") !== null); + await click(button(host, "Refresh")); + await settleUntil(() => reads === 2); + await click(openSelect(host, 0)); + await chooseOption("Manual"); + await act(async () => { slow.resolve(response({ policy: { ...POLICY, updates: "off" }, warning: null })); await flush(); }); + // The untouched field adopts the server's value; the operator's field stays. + expect(openSelect(host, 2).textContent).toContain("Off"); + expect(openSelect(host, 0).textContent).toContain("Manual"); + await click(button(host, "Save")); + expect(calls.filter(call => call.method === "PUT").at(-1)?.body).toEqual({ catalogFanout: "manual" }); +}); diff --git a/gui/tests/remote-link.test.tsx b/gui/tests/remote-link.test.tsx index 75fa7a392b3..d0ba6f21808 100644 --- a/gui/tests/remote-link.test.tsx +++ b/gui/tests/remote-link.test.tsx @@ -58,12 +58,15 @@ async function mount(props: Partial> = { } test("current translated link errors remain implemented across route and enrollment owners", async () => { - const source = (await Promise.all(["link-routes", "link-enrollment", "link-route-state"].map(name => + // Child operations answer their own refusals through a `switch (code) { case "x": return fail(code, ...) }` + // block instead of `fail("x", ...)` calls, so the second pattern counts that switch. + const source = (await Promise.all(["link-routes", "link-enrollment", "link-route-state", "link-child-ops-routes"].map(name => Bun.file(`../src/server/management/${name}.ts`).text()))).join("\n"); // DELETE --purge forwards the code purgeDisconnectRemote chose, so its literals count as route codes. const purgeSource = await Bun.file("../src/link/remote-plant.ts").text(); const fromRoutes = [ ...[...source.matchAll(/(?:fail\(|remoteFailure\([^,\n]+,\s*)"([a-z_]+)"/g)].map(match => match[1]), + ...[...source.matchAll(/case "([a-z_]+)": return fail\(code/g)].map(match => match[1]), ...[...purgeSource.matchAll(/\bcode: "([a-z_]+)"/g)].map(match => match[1]), ].filter((value, index, all) => all.indexOf(value) === index).sort(); // New phase errors use generic text. These two old-server translations remain compatible diff --git a/src/link/child-ops-types.ts b/src/link/child-ops-types.ts index 6426ce809bd..b4de49ab6cd 100644 --- a/src/link/child-ops-types.ts +++ b/src/link/child-ops-types.ts @@ -11,6 +11,8 @@ import type { ObserveOutcome } from "./child-observe"; export type { ChildOp } from "./child-observations"; export const CHILD_JOB_SCHEMA = "ocx.link.child-job/1" as const; export const CHILD_OPS = ["observe", "sync", "restart-codex", "update"] as const satisfies readonly ChildOp[]; +/** The most link ids one operations request may name; the dashboard splits a larger bulk list into groups of this size. */ +export const CHILD_OPS_MAX_LINK_IDS = 64; export type ChildRestart = "none" | "app-server" | "codex"; export type ChildTrigger = "manual" | "auto"; export const CHILD_ITEM_STATES = ["queued", "running", "succeeded", "up-to-date", "busy", "offline", diff --git a/src/server/management/link-child-ops-routes.ts b/src/server/management/link-child-ops-routes.ts index b7ca0e82a37..9e632521c00 100644 --- a/src/server/management/link-child-ops-routes.ts +++ b/src/server/management/link-child-ops-routes.ts @@ -4,7 +4,7 @@ import { createCatalogRevisionMonitor, type CatalogRevisionMonitor } from "../.. import { observeChild } from "../../link/child-observe"; import { patchChildObservation, readChildObservations, updateChildObservations } from "../../link/child-observations"; import { sharedChildOpsEngine, type ChildOpsEngine, type SubmitRefusal, type SubmitResult } from "../../link/child-ops-engine"; -import { CHILD_OPS, type ChildOp, type ChildOpsEnv, type ChildRestart } from "../../link/child-ops-types"; +import { CHILD_OPS, CHILD_OPS_MAX_LINK_IDS, type ChildOp, type ChildOpsEnv, type ChildRestart } from "../../link/child-ops-types"; import { requireRemotePlatform } from "../../link/remote-platform"; import { inspectRemoteChildRuntime } from "../../link/remote-plant"; import { attachLinkChildOps, projectLinkStatus, type LinkStatusDto } from "../../link/status-projection"; @@ -17,7 +17,7 @@ import { auth, fail, isRecord, knownHostsFile, readStoreFor, runnerFor, type Lin const LINK_ID = /^lnk_[0-9a-f]{16}$/; const JOB_ID = /^ljob_[0-9a-f]{16}$/; -const MAX_LINK_IDS = 64; +const MAX_LINK_IDS = CHILD_OPS_MAX_LINK_IDS; const JOB_PATH = /^\/api\/link\/children\/jobs\/([^/]+)$/; const NO_STORE = { "cache-control": "no-store" }; diff --git a/structure/remote-link.md b/structure/remote-link.md index f0b57d1579d..f1152ebb53f 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -52,6 +52,10 @@ For an empty-store Home-initiated Child, `src/link/status-projection.ts` derives `gui/src/pages/RemoteLink.tsx` distinguishes loading, successful empty discovery, and failed discovery. Add Child explains Home-to-Child SSH prerequisites and the Home's `~/.ssh/config`, with a Host example, setup guide, rescan and manual alias entry. Find Home names the initiating computer instead. Rescan preserves the alias but resets fingerprint review; cancelled or superseded requests cannot restore stale state. Failed discovery offers Retry without claiming the host list is empty. Action errors and retries appear only in the active sheet while it is open; the server-code translation and sanitized hint remain intact. Probe recovery includes a display-only SSH diagnostic, and connecting still requires explicit fingerprint confirmation. Retry on a failed or reconnecting Home link stays disabled through both the reconnect POST and its subsequent status refresh, including POST failures. `gui/tests/remote-link.test.tsx` covers these presentation states and native control focus. +Status rows request `/api/link/status?children=1`, so a Home with the wp2+ runtime receives a `child` block per Home-initiated link; against any Home that omits it, the page renders as before and calls no child endpoint. [`gui/src/remote-link-child-ops-api.ts`](../gui/src/remote-link-child-ops-api.ts) parses that block defensively: a malformed `child` yields no badges, an unknown job state maps to `running` so the page keeps polling (and a whole job whose top-level state is unknown reads `running` while each item keeps its own token), and unknown sync or `lastOperation.state` values render as `` rather than a sentence. [`gui/src/pages/RemoteLinkChildActions.tsx`](../gui/src/pages/RemoteLinkChildActions.tsx) renders the badges (version and skew, install kind, catalog, Codex restart needed or not running, last and running operation) and the four row actions from `child` alone. The Codex badges stay dormant until the Child reports `child.codex`, which no released Child sends yet (a later release adds it), so the catalog badge is today's only catalog signal. Check, Sync now, Restart Codex and Update each post one `{op, linkIds:[id], restart?}` to `/api/link/children/operations`; Restart Codex chooses `app-server` or `codex` in a dialog, and Update confirms the target Home version and says a manual update ends the turn relayed for that Child while automatic updates wait for idle. Check all and Update all post the explicit link ids they counted, at most 64 per request (even groups, one job each; the cap is `CHILD_OPS_MAX_LINK_IDS` in `src/link/child-ops-types.ts`, pinned by a GUI parity test; a submit gives up after 30 s and a job poll after 15 s), so a row the confirmation dialog excluded is never touched; restart has no bulk path. A job shows per-Child progress from `GET /api/link/children/jobs/`, then the panel leaves and status refreshes; a job the Home forgot says so. Each polled job snapshot replaces the page's copy, so a row's actions and Disconnect are disabled only while that row's own item runs and come back when its item finishes, even while the rest of a bulk job continues; a 409 `link_operation_in_progress` renders translated. Child-op and policy activity stay in page state separate from the sheet's `busy` union, so no Child operation can block the Add Child sheet. + +The **Child automation** panel reads `GET /api/link/children/policy` and writes `PUT /api/link/children/policy`. It loads once per mount and after a save, never on each status poll, so an in-progress edit survives polling. Saving sends only changed keys, except while the damaged-file warning is shown: then it sends all four so the visible modes persist. The server re-checks policy, link state and Child activity on every job. Coverage: `gui/tests/remote-link-child-ops.test.tsx`. + ## Dashboard admission The dashboard link routes (`GET /api/link/status`, `GET /api/link/diagnostics`, `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply`, `POST /api/link/{id}/reconnect`, `DELETE /api/link/{id}`, `POST /api/link/children/operations` and `GET /api/link/children/jobs/{jobId}`) admit a paired GUI session, or, on a standalone runtime only, the current loopback-issued GUI session that reached the public listener bound to a loopback hostname. `POST /api/link/join` is stricter and requires the paired session because it durably redirects this machine's client traffic. The hub-link, hub-management and claude-intercept ingresses are never trusted loopback ingress, a stale session is refused, and a hub keeps the paired-only rule. The Tailscale identity refusal runs before either check. A loopback session is minted by the loopback dashboard bootstrap without a credential, so it proves possession, not user presence: any local process can fetch the bootstrap and replay its token and CSRF value, which is why it cannot authorize joining and why `src/client/machine-listener.ts` keeps durable machine changes on the connected listener away from that session. The remaining link routes accept this casual-path trade, the same as `POST /api/github/star` in `src/server/management/sidebar-routes.ts`; it is not a secret-backed boundary like an operator-created pairing grant or the admin token.