From f73682010ddc1f715ea8205cfa7d82df844b7589 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 06:54:43 +0900 Subject: [PATCH 01/26] docs(devlog): re-verify wp6 plan and fold its audit --- .../060_dashboard.md | 785 ++++++++++++++---- 1 file changed, 642 insertions(+), 143 deletions(-) diff --git a/devlog/_plan/261111_link_child_updates/060_dashboard.md b/devlog/_plan/261111_link_child_updates/060_dashboard.md index 3b4a1818af9..c65b9690241 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"> +
+ + + +
+
+ + +
+
+ ); +} ``` -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. +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 }): 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 (body: ChildOperationBody) => { + props.onError(null); + try { props.onJob(await postChildOperation(props.apiBase, body)); } + catch (error) { props.onError(linkActionError(error)); } + }; + return ( +
+ + + setConfirming(null)} + onConfirm={() => { setConfirming(null); void run({ op: "update", linkIds: "all" }); }} /> +
+ ); +} +``` +Check all names `"all"` because 004 D14 allows it for observe; the server still refuses a client-initiated link inside the +job, item by item, so a mixed list is safe. Update all is offered only when at least one row is actually updatable, and +the count/version in its confirmation describe exactly those rows. + +```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 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 ?? ""; + const run = async (body: ChildOperationBody) => { + props.onError(null); + props.onRun({ linkId: "all", op: body.op }); + try { props.onJob(await postChildOperation(props.apiBase, body)); } + catch (error) { props.onError(linkActionError(error)); } + finally { props.onRun(null); } + }; + return ( +
+ + + setConfirming(null)} + onConfirm={() => { setConfirming(null); void run({ op: "update", linkIds: "all" }); }} /> +
+ ); +} + +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 }; +} + +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; + 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.sync && {syncKey ? t(syncKey) : {item.sync.state}}} + {item.restart && + {restartKey ? t(restartKey) : {item.restart.outcome}} + } +
  • + ); + })} +
+
+ ); +} + +export function ChildPolicyPanel(props: { apiBase: string; busy: boolean; 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 { apiBase, onError } = props; + // Revision 6: load once per mount and after a save. A status poll never re-reads, so an edit survives it. + const load = useCallback(async (signal?: AbortSignal) => { + try { + const value = await getChildPolicy(apiBase, signal); + setRead(value); setDraft(value.policy); + } catch (error) { if (!signal?.aborted) onError(linkActionError(error)); } + }, [apiBase, onError]); + useEffect(() => { const controller = new AbortController(); void load(controller.signal); return () => controller.abort(); }, [load]); + if (!draft || !read) return null; + const changed = changedPolicyKeys(read.policy, draft); + const damaged = read.warning === "policy-file-invalid"; + const save = async () => { + 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 { 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..bd994e688fc --- /dev/null +++ b/gui/src/remote-link-child-ops-api.ts @@ -0,0 +1,154 @@ +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" }; +export async function postChildOperation(apiBase: string, body: ChildOperationBody): Promise { + const out = await requestLinkJson<{ job?: unknown }>(apiBase, "/api/link/children/operations", { method: "POST", headers: json, body: JSON.stringify(body) }); + return parseChildJob(out.job); +} +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"; } +export const CHILD_JOB_POLL_MS = 2_000; + +/** 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/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx new file mode 100644 index 00000000000..c95a9d2baa7 --- /dev/null +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -0,0 +1,392 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import type { Root } from "react-dom/client"; +import { act } from "react"; +import RemoteLink from "../src/pages/RemoteLink"; +import { LanguageProvider } from "../src/i18n/provider"; +import { parseChildJob, parseChildOps } 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): 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; +} + +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; +} + +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 = host.querySelector(".remote-link-confirm-dialog")!; + expect(dialog.querySelectorAll("input[type=radio]")[0]?.hasAttribute("checked")).toBe(true as unknown as boolean); + 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 = host.querySelector(".remote-link-confirm-dialog")!; + 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("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: "all" }); + + const firstRow = host.querySelectorAll(".remote-link-row")[0]!; + await click([...firstRow.querySelectorAll("button")].find(item => item.textContent === "Update")!); + const rowDialog = firstRow.querySelector("dialog")!; + 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("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(); + await settleUntil(() => host.querySelector(".remote-link-job")?.textContent?.includes("Succeeded") ?? false); + const finished = host.querySelector(".remote-link-job")!; + expect(finished.textContent).toContain("Succeeded"); + expect(finished.textContent).toContain("Synced"); + expect(finished.textContent).toContain("Restart requested"); + await settleUntil(() => calls.filter(call => call.path.includes("/api/link/status")).length > statusReads); + expect(calls.filter(call => call.path.includes("/api/link/status")).length).toBe(statusReads + 1); + expect(jobPolls).toBeGreaterThan(0); +}); + +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(); + 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."); + const polls = calls.filter(call => call.path.startsWith("/api/link/children/jobs/")).length; + await pollJobs(); + expect(calls.filter(call => call.path.startsWith("/api/link/children/jobs/")).length).toBe(polls); +}); + +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", state: "done", items: [] }) }); + 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("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 withheld restart says a request was in flight", 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({ 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 }] }) }, 202); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + await click(button(host, "Restart Codex")); + await click([...host.querySelectorAll(".remote-link-confirm-dialog button")].find(item => item.textContent === "Restart")!); + 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 restart outcome and an unknown last-operation state render as code", async () => { + installFetch(call => { + if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock({ + lastOperation: { op: "update", jobId: "ljob_0123456789abcdef", state: "melted", reason: null, fromVersion: null, toVersion: null, finishedAt: null }, + }))])); + if (call.path === "/api/link/children/operations") return response({ job: job({ state: "done", items: [{ linkId: "l1", alias: "box-one", state: "succeeded", reason: null, fromVersion: null, toVersion: null, restart: { requested: "app-server", outcome: "done" }, startedAt: null, finishedAt: null }] }) }, 202); + return response({ error: { code: "not_found" } }, 404); + }); + const host = await mount(); + const badges = host.querySelector(".remote-link-child-badges")!; + expect(badges.querySelector("code")?.textContent).toBe("melted"); + await click(button(host, "Restart Codex")); + await click([...host.querySelectorAll(".remote-link-confirm-dialog button")].find(item => item.textContent === "Restart")!); + const panel = host.querySelector(".remote-link-job")!; + expect(panel.querySelector("code")?.textContent).toBe("done"); + expect(panel.textContent).not.toContain("Restart requested"); +}); + +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(); +}); From d1655ceebca2eab14b0df3163a1bb646d60f06c3 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 09:40:31 +0900 Subject: [PATCH 03/26] wip(gui): wp6 locale keys --- gui/src/i18n/de.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ gui/src/i18n/en.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ gui/src/i18n/fr.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ gui/src/i18n/ja.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ gui/src/i18n/ko.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ gui/src/i18n/pt.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ gui/src/i18n/ru.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ gui/src/i18n/tr.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ gui/src/i18n/vi.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ gui/src/i18n/zh-TW.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ gui/src/i18n/zh.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 913 insertions(+) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8adcec0f1ed..cd33708dbdb 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -4358,4 +4358,87 @@ export const de: Record = { "remoteLink.error.remote_plant_failed": "Der Leichtgewicht-Agent (ocx-child) konnte nicht per SSH bereitgestellt werden. Prüfen Sie Speicherplatz und ~/.cache-Berechtigungen und versuchen Sie es erneut.", "remoteLink.error.remote_plant_timeout": "Zeitüberschreitung beim Bereitstellen des Leichtgewicht-Agenten (ocx-child) per SSH. Prüfen Sie die SSH-Verbindung und versuchen Sie es erneut.", "remoteLink.error.remote_plant_unavailable": "Für die Architektur des Remote-Computers ist lokal keine verifizierte ocx-child-Binärdatei verfügbar.", + "remoteLink.child.skew.same": "Version wie Home", + "remoteLink.child.skew.behind": "Älter als Home ({version})", + "remoteLink.child.skew.ahead": "Neuer als Home", + "remoteLink.child.kind.desktopApp": "Desktop-App", + "remoteLink.child.kind.goat": "GOAT", + "remoteLink.child.kind.source": "Quellcode-Checkout", + "remoteLink.child.updateAvailable": "Update verfügbar: {version}", + "remoteLink.child.catalog.current": "Katalog aktuell", + "remoteLink.child.catalog.behind": "Katalog veraltet", + "remoteLink.child.codex.restartNeeded": "Codex-Neustart nötig ({count})", + "remoteLink.child.codex.notRunning": "Codex läuft nicht", + "remoteLink.child.ownerAction.legacy-ocx": "OpenCodex auf diesem Child von Hand aktualisieren", + "remoteLink.child.ownerAction.desktop-app-owned": "„Nach Updates suchen“ in OpenCodex Desktop auf diesem Child verwenden", + "remoteLink.child.ownerAction.goat": "GOAT hält diesen Child aktuell", + "remoteLink.child.ownerAction.launcher-node-missing": "Node.js fehlt auf diesem Child; installieren und dort aktualisieren", + "remoteLink.child.ownerAction.launcher-bypassed": "Der Launcher dieses Child umgeht den Updater von OpenCodex; von Hand aktualisieren", + "remoteLink.child.ownerAction.mise-managed": "mise verwaltet OpenCodex auf diesem Child; über mise aktualisieren", + "remoteLink.child.ownerAction.source-checkout": "Dieser Child läuft aus einem Quellcode-Checkout; von Hand aktualisieren", + "remoteLink.child.ownerAction.installer-unknown": "Die Installationsart dieses Child ist unbekannt; dort aktualisieren", + "remoteLink.child.check": "Prüfen", + "remoteLink.child.checkAll": "Alle prüfen", + "remoteLink.child.syncNow": "Jetzt synchronisieren", + "remoteLink.child.restartCodex": "Codex neu starten", + "remoteLink.child.update": "Aktualisieren", + "remoteLink.child.updateAll": "Alle aktualisieren", + "remoteLink.child.updateConfirm": "{alias} auf {version} aktualisieren? OpenCodex auf diesem Child startet neu, wodurch ein aktuell dafür weitergeleiteter Turn endet. Automatische Updates warten, bis ein Child im Leerlauf ist.", + "remoteLink.child.updateAllConfirm": "{count} Children nacheinander auf {version} aktualisieren. OpenCodex auf jedem Child startet neu, wodurch ein aktuell dafür weitergeleiteter Turn endet. Automatische Updates warten, bis ein Child im Leerlauf ist.", + "remoteLink.child.upToDate": "Auf dem neuesten Stand", + "remoteLink.child.disconnectBlocked": "Warten, bis der laufende Vorgang abgeschlossen ist", + "remoteLink.restart.scope.appServer": "Codex-App-Server", + "remoteLink.restart.scope.appServerBody": "Startet die Codex-Hintergrund-App-Server neu, damit sie den neuen Katalog laden. Der eigene App-Server der Codex-Desktop-App wird nicht aktualisiert; dafür die Desktop-App neu starten.", + "remoteLink.restart.scope.codex": "Codex-Desktop-App", + "remoteLink.restart.scope.codexBody": "Beendet die Codex-Desktop-App auf {alias} und startet sie neu. Offene Unterhaltungen enden. Dafür ist eine angemeldete Desktop-Sitzung auf diesem Computer nötig.", + "remoteLink.restart.confirm": "Neu starten", + "remoteLink.job.active.observe": "Prüfe…", + "remoteLink.job.active.sync": "Synchronisiere…", + "remoteLink.job.active.restartCodex": "Codex wird neu gestartet…", + "remoteLink.job.active.update": "Aktualisiere…", + "remoteLink.job.state.queued": "Wartend", + "remoteLink.job.state.running": "Läuft", + "remoteLink.job.state.succeeded": "Erfolgreich", + "remoteLink.job.state.upToDate": "Auf dem neuesten Stand", + "remoteLink.job.state.busy": "Beschäftigt", + "remoteLink.job.state.offline": "Offline", + "remoteLink.job.state.refused": "Abgelehnt", + "remoteLink.job.state.ownerAction": "Eingriff am Child nötig", + "remoteLink.job.state.unsupported": "Nicht unterstützt", + "remoteLink.job.state.failed": "Fehlgeschlagen", + "remoteLink.job.state.uncertain": "Ergebnis unbekannt", + "remoteLink.job.restart.requested": "Neustart angefordert", + "remoteLink.job.restart.unsupported": "Neustart nicht unterstützt", + "remoteLink.job.restart.notRun": "Neustart nicht ausgeführt", + "remoteLink.job.restart.skipped": "Neustart zurückgehalten: Auf diesem Child war eine Anfrage in Bearbeitung.", + "remoteLink.job.sync.synced": "Synchronisiert", + "remoteLink.job.sync.skipped": "Synchronisierung übersprungen", + "remoteLink.job.sync.failed": "Synchronisierung fehlgeschlagen", + "remoteLink.job.sync.timedOut": "Zeitüberschreitung bei der Synchronisierung", + "remoteLink.job.last": "Zuletzt {op}: {state}", + "remoteLink.job.notFound": "Dieses Home wurde neu gestartet, daher ist der Fortschritt des Vorgangs verloren. Die Zeilen unten zeigen den aktuellen Zustand jedes Child.", + "remoteLink.job.automatic": "Automatisch", + "remoteLink.policy.title": "Child-Automatisierung", + "remoteLink.policy.catalogFanout": "Katalog-Verteilung", + "remoteLink.policy.catalogFanout.auto": "Automatisch", + "remoteLink.policy.catalogFanout.manual": "Manuell", + "remoteLink.policy.childCodexRestart": "Codex-Neustart", + "remoteLink.policy.childCodexRestart.never": "Nie", + "remoteLink.policy.childCodexRestart.whenIdle": "App-Server im Leerlauf", + "remoteLink.policy.updates": "Updates", + "remoteLink.policy.updates.off": "Aus", + "remoteLink.policy.updates.notify": "Nur benachrichtigen", + "remoteLink.policy.updates.autoWhenIdle": "Automatisch im Leerlauf", + "remoteLink.policy.idleMinutes": "Leerlauf nach (Minuten)", + "remoteLink.policy.save": "Speichern", + "remoteLink.policy.saved": "Gespeichert", + "remoteLink.policy.updatesInactive": "Wird jetzt nur gespeichert; automatische Updates beginnen, sobald der Update-Lauf ausgeliefert ist, daher aktualisiert sich noch nichts von selbst.", + "remoteLink.policy.invalid": "Die Richtliniendatei war beschädigt, daher sind die automatische Katalog-Verteilung und automatische Updates aus. Wählen Sie jeden automatischen Modus erneut und speichern Sie, um sie wieder einzuschalten.", + "remoteLink.error.invalid_job_id": "Die Vorgangs-ID ist ungültig.", + "remoteLink.error.job_not_found": "Der Vorgang ist nicht mehr bekannt; das Home wurde möglicherweise neu gestartet.", + "remoteLink.error.link_operation_in_progress": "Auf diesem Child läuft bereits ein anderer Vorgang.", + "remoteLink.error.no_eligible_links": "Kein vom Home gestarteter Child ist dafür verfügbar.", + "remoteLink.error.policy_write_failed": "Die Child-Richtlinie konnte nicht gespeichert werden.", + "remoteLink.error.runtime_role_client": "Dieser Computer ist ein Child; nur ein Home kann Children bedienen.", + "remoteLink.error.link_not_home_initiated": "Dieser Child hat sich selbst verbunden; bedienen Sie ihn von seinem eigenen Computer aus.", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0d2cf818ce6..d32034724ed 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -4392,6 +4392,89 @@ export const en = { "remoteLink.error.remote_plant_failed": "Could not plant the lightweight ocx-child agent over SSH. Check remote disk space and ~/.cache permissions, then retry.", "remoteLink.error.remote_plant_timeout": "Planting the lightweight ocx-child agent over SSH timed out. Check the SSH connection and retry.", "remoteLink.error.remote_plant_unavailable": "No verified ocx-child binary is available locally for the remote computer's architecture.", + "remoteLink.child.skew.same": "Same version as Home", + "remoteLink.child.skew.behind": "Behind Home ({version})", + "remoteLink.child.skew.ahead": "Newer than Home", + "remoteLink.child.kind.desktopApp": "Desktop app", + "remoteLink.child.kind.goat": "GOAT", + "remoteLink.child.kind.source": "Source checkout", + "remoteLink.child.updateAvailable": "Update available: {version}", + "remoteLink.child.catalog.current": "Catalog current", + "remoteLink.child.catalog.behind": "Catalog out of date", + "remoteLink.child.codex.restartNeeded": "Codex restart needed ({count})", + "remoteLink.child.codex.notRunning": "Codex not running", + "remoteLink.child.ownerAction.legacy-ocx": "Update OpenCodex on this Child by hand", + "remoteLink.child.ownerAction.desktop-app-owned": "Use Check for Updates in OpenCodex Desktop on this Child", + "remoteLink.child.ownerAction.goat": "GOAT keeps this Child updated", + "remoteLink.child.ownerAction.launcher-node-missing": "Node.js is missing on this Child; install it, then update there", + "remoteLink.child.ownerAction.launcher-bypassed": "This Child's launcher bypasses OpenCodex's updater; update it by hand", + "remoteLink.child.ownerAction.mise-managed": "mise manages OpenCodex on this Child; update it through mise", + "remoteLink.child.ownerAction.source-checkout": "This Child runs from a source checkout; update it by hand", + "remoteLink.child.ownerAction.installer-unknown": "This Child's install method is unknown; update it there", + "remoteLink.child.check": "Check", + "remoteLink.child.checkAll": "Check all", + "remoteLink.child.syncNow": "Sync now", + "remoteLink.child.restartCodex": "Restart Codex", + "remoteLink.child.update": "Update", + "remoteLink.child.updateAll": "Update all", + "remoteLink.child.updateConfirm": "Update {alias} to {version}? OpenCodex on that Child restarts, which ends any turn currently relayed for it. Automatic updates wait until a Child is idle.", + "remoteLink.child.updateAllConfirm": "Update {count} Children to {version}, one at a time. Each Child's OpenCodex restarts, which ends any turn currently relayed for it. Automatic updates wait until a Child is idle.", + "remoteLink.child.upToDate": "Up to date", + "remoteLink.child.disconnectBlocked": "Wait for the running operation to finish", + "remoteLink.restart.scope.appServer": "Codex app-servers", + "remoteLink.restart.scope.appServerBody": "Restarts Codex background app-servers so they load the new catalog. The Codex desktop app's own app-server is not refreshed; restart the desktop app for that.", + "remoteLink.restart.scope.codex": "Codex desktop app", + "remoteLink.restart.scope.codexBody": "Quits and relaunches the Codex desktop app on {alias}. Open conversations end. It needs a signed-in desktop session on that computer.", + "remoteLink.restart.confirm": "Restart", + "remoteLink.job.active.observe": "Checking…", + "remoteLink.job.active.sync": "Syncing…", + "remoteLink.job.active.restartCodex": "Restarting Codex…", + "remoteLink.job.active.update": "Updating…", + "remoteLink.job.state.queued": "Queued", + "remoteLink.job.state.running": "Running", + "remoteLink.job.state.succeeded": "Succeeded", + "remoteLink.job.state.upToDate": "Up to date", + "remoteLink.job.state.busy": "Busy", + "remoteLink.job.state.offline": "Offline", + "remoteLink.job.state.refused": "Refused", + "remoteLink.job.state.ownerAction": "Needs owner action", + "remoteLink.job.state.unsupported": "Not supported", + "remoteLink.job.state.failed": "Failed", + "remoteLink.job.state.uncertain": "Outcome unknown", + "remoteLink.job.restart.requested": "Restart requested", + "remoteLink.job.restart.unsupported": "Restart not supported", + "remoteLink.job.restart.notRun": "Restart not run", + "remoteLink.job.restart.skipped": "Restart withheld: a request was in flight on this Child.", + "remoteLink.job.sync.synced": "Synced", + "remoteLink.job.sync.skipped": "Sync skipped", + "remoteLink.job.sync.failed": "Sync failed", + "remoteLink.job.sync.timedOut": "Sync timed out", + "remoteLink.job.last": "Last {op}: {state}", + "remoteLink.job.notFound": "This Home restarted, so the job's progress is gone. The rows below show each Child's current state.", + "remoteLink.job.automatic": "Automatic", + "remoteLink.policy.title": "Child automation", + "remoteLink.policy.catalogFanout": "Catalog fan-out", + "remoteLink.policy.catalogFanout.auto": "Automatic", + "remoteLink.policy.catalogFanout.manual": "Manual", + "remoteLink.policy.childCodexRestart": "Codex restart", + "remoteLink.policy.childCodexRestart.never": "Never", + "remoteLink.policy.childCodexRestart.whenIdle": "App-servers when idle", + "remoteLink.policy.updates": "Updates", + "remoteLink.policy.updates.off": "Off", + "remoteLink.policy.updates.notify": "Notify only", + "remoteLink.policy.updates.autoWhenIdle": "Automatic when idle", + "remoteLink.policy.idleMinutes": "Idle after (minutes)", + "remoteLink.policy.save": "Save", + "remoteLink.policy.saved": "Saved", + "remoteLink.policy.updatesInactive": "Stored now; automatic updates start when the update sweep ships, so nothing updates on its own yet.", + "remoteLink.policy.invalid": "The policy file was damaged, so automatic catalog fan-out and automatic updates are off. Choose each automatic mode again and save to turn them back on.", + "remoteLink.error.invalid_job_id": "The operation id is invalid.", + "remoteLink.error.job_not_found": "The operation is no longer known; the Home may have restarted.", + "remoteLink.error.link_operation_in_progress": "Another operation is running on this Child.", + "remoteLink.error.no_eligible_links": "No Home-initiated Child is available for this.", + "remoteLink.error.policy_write_failed": "The Child policy could not be saved.", + "remoteLink.error.runtime_role_client": "This computer is a Child; only a Home can operate Children.", + "remoteLink.error.link_not_home_initiated": "This Child linked itself, so operate it from its own computer.", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4ba56af3cbd..278dfcf132e 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4347,4 +4347,87 @@ export const fr: Record = { "remoteLink.error.remote_plant_failed": "Impossible de déployer l’agent léger (ocx-child) via SSH. Vérifiez l’espace disque distant et les permissions de ~/.cache puis réessayez.", "remoteLink.error.remote_plant_timeout": "Délai dépassé lors du déploiement de l’agent léger (ocx-child) via SSH. Vérifiez la connexion SSH et réessayez.", "remoteLink.error.remote_plant_unavailable": "Aucun binaire ocx-child vérifié n’est disponible localement pour l’architecture de l’ordinateur distant.", + "remoteLink.child.skew.same": "Version identique à Home", + "remoteLink.child.skew.behind": "Plus ancienne que Home ({version})", + "remoteLink.child.skew.ahead": "Plus récente que Home", + "remoteLink.child.kind.desktopApp": "Application de bureau", + "remoteLink.child.kind.goat": "GOAT", + "remoteLink.child.kind.source": "Copie du code source", + "remoteLink.child.updateAvailable": "Mise à jour disponible : {version}", + "remoteLink.child.catalog.current": "Catalogue à jour", + "remoteLink.child.catalog.behind": "Catalogue obsolète", + "remoteLink.child.codex.restartNeeded": "Redémarrage de Codex nécessaire ({count})", + "remoteLink.child.codex.notRunning": "Codex n’est pas lancé", + "remoteLink.child.ownerAction.legacy-ocx": "Mettre à jour OpenCodex à la main sur cet Enfant", + "remoteLink.child.ownerAction.desktop-app-owned": "Utilisez « Rechercher les mises à jour » dans OpenCodex Desktop sur cet Enfant", + "remoteLink.child.ownerAction.goat": "GOAT tient cet Enfant à jour", + "remoteLink.child.ownerAction.launcher-node-missing": "Node.js manque sur cet Enfant ; installez-le, puis mettez à jour sur place", + "remoteLink.child.ownerAction.launcher-bypassed": "Le lanceur de cet Enfant contourne le programme de mise à jour d’OpenCodex ; mettez-le à jour à la main", + "remoteLink.child.ownerAction.mise-managed": "mise gère OpenCodex sur cet Enfant ; mettez-le à jour via mise", + "remoteLink.child.ownerAction.source-checkout": "Cet Enfant s’exécute depuis une copie du code source ; mettez-le à jour à la main", + "remoteLink.child.ownerAction.installer-unknown": "La méthode d’installation de cet Enfant est inconnue ; mettez-le à jour sur place", + "remoteLink.child.check": "Vérifier", + "remoteLink.child.checkAll": "Tout vérifier", + "remoteLink.child.syncNow": "Synchroniser maintenant", + "remoteLink.child.restartCodex": "Redémarrer Codex", + "remoteLink.child.update": "Mettre à jour", + "remoteLink.child.updateAll": "Tout mettre à jour", + "remoteLink.child.updateConfirm": "Mettre {alias} à jour vers {version} ? OpenCodex sur cet Enfant redémarre, ce qui met fin à tout tour actuellement relayé pour lui. Les mises à jour automatiques attendent que l’Enfant soit inactif.", + "remoteLink.child.updateAllConfirm": "Mettre {count} Enfants à jour vers {version}, un par un. OpenCodex de chaque Enfant redémarre, ce qui met fin à tout tour actuellement relayé pour lui. Les mises à jour automatiques attendent que l’Enfant soit inactif.", + "remoteLink.child.upToDate": "À jour", + "remoteLink.child.disconnectBlocked": "Attendez la fin de l’opération en cours", + "remoteLink.restart.scope.appServer": "Serveurs d’application Codex", + "remoteLink.restart.scope.appServerBody": "Redémarre les serveurs d’application Codex en arrière-plan afin qu’ils chargent le nouveau catalogue. Le serveur d’application propre à l’application de bureau Codex n’est pas actualisé ; redémarrez l’application de bureau pour cela.", + "remoteLink.restart.scope.codex": "Application de bureau Codex", + "remoteLink.restart.scope.codexBody": "Quitte et relance l’application de bureau Codex sur {alias}. Les conversations ouvertes se terminent. Une session de bureau connectée est requise sur cet ordinateur.", + "remoteLink.restart.confirm": "Redémarrer", + "remoteLink.job.active.observe": "Vérification…", + "remoteLink.job.active.sync": "Synchronisation…", + "remoteLink.job.active.restartCodex": "Redémarrage de Codex…", + "remoteLink.job.active.update": "Mise à jour…", + "remoteLink.job.state.queued": "En attente", + "remoteLink.job.state.running": "En cours", + "remoteLink.job.state.succeeded": "Réussi", + "remoteLink.job.state.upToDate": "À jour", + "remoteLink.job.state.busy": "Occupé", + "remoteLink.job.state.offline": "Hors ligne", + "remoteLink.job.state.refused": "Refusé", + "remoteLink.job.state.ownerAction": "Action du propriétaire requise", + "remoteLink.job.state.unsupported": "Non pris en charge", + "remoteLink.job.state.failed": "Échec", + "remoteLink.job.state.uncertain": "Résultat inconnu", + "remoteLink.job.restart.requested": "Redémarrage demandé", + "remoteLink.job.restart.unsupported": "Redémarrage non pris en charge", + "remoteLink.job.restart.notRun": "Redémarrage non exécuté", + "remoteLink.job.restart.skipped": "Redémarrage retenu : une requête était en cours sur cet Enfant.", + "remoteLink.job.sync.synced": "Synchronisé", + "remoteLink.job.sync.skipped": "Synchronisation ignorée", + "remoteLink.job.sync.failed": "Échec de la synchronisation", + "remoteLink.job.sync.timedOut": "Délai de synchronisation dépassé", + "remoteLink.job.last": "Dernière {op} : {state}", + "remoteLink.job.notFound": "Ce Home a redémarré, donc la progression de l’opération est perdue. Les lignes ci-dessous montrent l’état actuel de chaque Enfant.", + "remoteLink.job.automatic": "Automatique", + "remoteLink.policy.title": "Automatisation des Enfants", + "remoteLink.policy.catalogFanout": "Diffusion du catalogue", + "remoteLink.policy.catalogFanout.auto": "Automatique", + "remoteLink.policy.catalogFanout.manual": "Manuelle", + "remoteLink.policy.childCodexRestart": "Redémarrage de Codex", + "remoteLink.policy.childCodexRestart.never": "Jamais", + "remoteLink.policy.childCodexRestart.whenIdle": "Serveurs d’application si inactif", + "remoteLink.policy.updates": "Mises à jour", + "remoteLink.policy.updates.off": "Désactivées", + "remoteLink.policy.updates.notify": "Notification uniquement", + "remoteLink.policy.updates.autoWhenIdle": "Automatiques si inactif", + "remoteLink.policy.idleMinutes": "Inactif après (minutes)", + "remoteLink.policy.save": "Enregistrer", + "remoteLink.policy.saved": "Enregistré", + "remoteLink.policy.updatesInactive": "Enregistré pour l’instant ; les mises à jour automatiques commenceront quand le balayage des mises à jour sera livré, donc rien ne se met à jour tout seul pour le moment.", + "remoteLink.policy.invalid": "Le fichier de politique était endommagé : la diffusion automatique du catalogue et les mises à jour automatiques sont désactivées. Choisissez à nouveau chaque mode automatique et enregistrez pour les réactiver.", + "remoteLink.error.invalid_job_id": "L’identifiant de l’opération est invalide.", + "remoteLink.error.job_not_found": "L’opération n’est plus connue ; le Home a peut-être redémarré.", + "remoteLink.error.link_operation_in_progress": "Une autre opération est en cours sur cet Enfant.", + "remoteLink.error.no_eligible_links": "Aucun Enfant initié par le Home n’est disponible pour cela.", + "remoteLink.error.policy_write_failed": "La politique de l’Enfant n’a pas pu être enregistrée.", + "remoteLink.error.runtime_role_client": "Cet ordinateur est un Enfant ; seul un Home peut piloter des Enfants.", + "remoteLink.error.link_not_home_initiated": "Cet Enfant s’est connecté lui-même ; pilotez-le depuis son propre ordinateur.", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a80e64d7258..0ee2d88d837 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4380,4 +4380,87 @@ export const ja: Record = { "remoteLink.error.remote_plant_failed": "SSH 経由で軽量エージェント(ocx-child)を配置できませんでした。リモートの空き容量と ~/.cache の権限を確認して再試行してください。", "remoteLink.error.remote_plant_timeout": "SSH 経由での軽量エージェント(ocx-child)配置がタイムアウトしました。SSH 接続を確認して再試行してください。", "remoteLink.error.remote_plant_unavailable": "リモートコンピュータのアーキテクチャに対応する検証済み ocx-child バイナリがありません。", + "remoteLink.child.skew.same": "Home と同じバージョン", + "remoteLink.child.skew.behind": "Home より古い ({version})", + "remoteLink.child.skew.ahead": "Home より新しい", + "remoteLink.child.kind.desktopApp": "デスクトップアプリ", + "remoteLink.child.kind.goat": "GOAT", + "remoteLink.child.kind.source": "ソースチェックアウト", + "remoteLink.child.updateAvailable": "更新可能: {version}", + "remoteLink.child.catalog.current": "カタログは最新", + "remoteLink.child.catalog.behind": "カタログが古い", + "remoteLink.child.codex.restartNeeded": "Codex の再起動が必要 ({count})", + "remoteLink.child.codex.notRunning": "Codex は実行されていません", + "remoteLink.child.ownerAction.legacy-ocx": "この Child の OpenCodex を手動で更新してください", + "remoteLink.child.ownerAction.desktop-app-owned": "この Child の OpenCodex Desktop で「アップデートを確認」を使用してください", + "remoteLink.child.ownerAction.goat": "GOAT がこの Child を最新に保ちます", + "remoteLink.child.ownerAction.launcher-node-missing": "この Child に Node.js がありません。インストールしてからそこで更新してください", + "remoteLink.child.ownerAction.launcher-bypassed": "この Child の起動方法は OpenCodex の更新処理を通りません。手動で更新してください", + "remoteLink.child.ownerAction.mise-managed": "この Child の OpenCodex は mise が管理しています。mise で更新してください", + "remoteLink.child.ownerAction.source-checkout": "この Child はソースチェックアウトから実行されています。手動で更新してください", + "remoteLink.child.ownerAction.installer-unknown": "この Child のインストール方法は不明です。そこで更新してください", + "remoteLink.child.check": "確認", + "remoteLink.child.checkAll": "すべて確認", + "remoteLink.child.syncNow": "今すぐ同期", + "remoteLink.child.restartCodex": "Codex を再起動", + "remoteLink.child.update": "更新", + "remoteLink.child.updateAll": "すべて更新", + "remoteLink.child.updateConfirm": "{alias} を {version} に更新しますか? この Child の OpenCodex が再起動し、現在中継中のターンは終了します。自動更新は Child がアイドルになるまで待機します。", + "remoteLink.child.updateAllConfirm": "{count} 台の Child を 1 台ずつ {version} に更新します。各 Child の OpenCodex が再起動し、現在中継中のターンは終了します。自動更新は Child がアイドルになるまで待機します。", + "remoteLink.child.upToDate": "最新です", + "remoteLink.child.disconnectBlocked": "実行中の操作が終わるまでお待ちください", + "remoteLink.restart.scope.appServer": "Codex アプリサーバー", + "remoteLink.restart.scope.appServerBody": "Codex のバックグラウンドアプリサーバーを再起動して新しいカタログを読み込ませます。Codex デスクトップアプリ自体のアプリサーバーは更新されません。その場合はデスクトップアプリを再起動してください。", + "remoteLink.restart.scope.codex": "Codex デスクトップアプリ", + "remoteLink.restart.scope.codexBody": "{alias} の Codex デスクトップアプリを終了して再起動します。開いている会話は終了します。このコンピュータにサインイン済みのデスクトップセッションが必要です。", + "remoteLink.restart.confirm": "再起動", + "remoteLink.job.active.observe": "確認中…", + "remoteLink.job.active.sync": "同期中…", + "remoteLink.job.active.restartCodex": "Codex を再起動中…", + "remoteLink.job.active.update": "更新中…", + "remoteLink.job.state.queued": "待機中", + "remoteLink.job.state.running": "実行中", + "remoteLink.job.state.succeeded": "成功", + "remoteLink.job.state.upToDate": "最新です", + "remoteLink.job.state.busy": "使用中", + "remoteLink.job.state.offline": "オフライン", + "remoteLink.job.state.refused": "拒否されました", + "remoteLink.job.state.ownerAction": "所有者の対応が必要", + "remoteLink.job.state.unsupported": "未対応", + "remoteLink.job.state.failed": "失敗", + "remoteLink.job.state.uncertain": "結果不明", + "remoteLink.job.restart.requested": "再起動を要求しました", + "remoteLink.job.restart.unsupported": "再起動は未対応です", + "remoteLink.job.restart.notRun": "再起動は実行されませんでした", + "remoteLink.job.restart.skipped": "再起動を保留: この Child でリクエストが処理中でした。", + "remoteLink.job.sync.synced": "同期済み", + "remoteLink.job.sync.skipped": "同期をスキップ", + "remoteLink.job.sync.failed": "同期に失敗", + "remoteLink.job.sync.timedOut": "同期がタイムアウト", + "remoteLink.job.last": "最後の {op}: {state}", + "remoteLink.job.notFound": "この Home が再起動したため、操作の進行状況は失われました。下の行に各 Child の現在の状態が表示されます。", + "remoteLink.job.automatic": "自動", + "remoteLink.policy.title": "Child の自動化", + "remoteLink.policy.catalogFanout": "カタログ配布", + "remoteLink.policy.catalogFanout.auto": "自動", + "remoteLink.policy.catalogFanout.manual": "手動", + "remoteLink.policy.childCodexRestart": "Codex の再起動", + "remoteLink.policy.childCodexRestart.never": "しない", + "remoteLink.policy.childCodexRestart.whenIdle": "アイドル時にアプリサーバー", + "remoteLink.policy.updates": "更新", + "remoteLink.policy.updates.off": "オフ", + "remoteLink.policy.updates.notify": "通知のみ", + "remoteLink.policy.updates.autoWhenIdle": "アイドル時に自動", + "remoteLink.policy.idleMinutes": "アイドル判定 (分)", + "remoteLink.policy.save": "保存", + "remoteLink.policy.saved": "保存しました", + "remoteLink.policy.updatesInactive": "現在は設定を保存するだけです。自動更新は更新スイープの提供開始後に動き出すため、まだ自動では更新されません。", + "remoteLink.policy.invalid": "ポリシーファイルが破損しているため、カタログの自動配布と自動更新はオフです。各自動モードを選び直して保存すると再びオンになります。", + "remoteLink.error.invalid_job_id": "操作 ID が正しくありません。", + "remoteLink.error.job_not_found": "操作が見つかりません。Home が再起動した可能性があります。", + "remoteLink.error.link_operation_in_progress": "この Child では別の操作が実行中です。", + "remoteLink.error.no_eligible_links": "これに使用できる Home 起点の Child がありません。", + "remoteLink.error.policy_write_failed": "Child ポリシーを保存できませんでした。", + "remoteLink.error.runtime_role_client": "このコンピュータは Child です。Child を操作できるのは Home だけです。", + "remoteLink.error.link_not_home_initiated": "この Child は自身で接続したため、そのコンピュータから操作してください。", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 80254af58f4..2d49ef93fee 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4380,4 +4380,87 @@ export const ko: Record = { "remoteLink.error.remote_plant_failed": "SSH를 통해 경량 에이전트(ocx-child)를 심지 못했습니다. 원격 디스크 여유 공간과 ~/.cache 권한을 확인한 뒤 다시 시도하세요.", "remoteLink.error.remote_plant_timeout": "SSH를 통해 경량 에이전트(ocx-child)를 전송하는 중 시간이 초과되었습니다. SSH 연결 상태를 확인한 뒤 다시 시도하세요.", "remoteLink.error.remote_plant_unavailable": "원격 컴퓨터 아키텍처에 맞는 검증된 경량 에이전트 파일이 없습니다.", + "remoteLink.child.skew.same": "Home과 같은 버전", + "remoteLink.child.skew.behind": "Home({version})보다 이전", + "remoteLink.child.skew.ahead": "Home보다 최신", + "remoteLink.child.kind.desktopApp": "데스크톱 앱", + "remoteLink.child.kind.goat": "GOAT", + "remoteLink.child.kind.source": "소스 체크아웃", + "remoteLink.child.updateAvailable": "업데이트 가능: {version}", + "remoteLink.child.catalog.current": "카탈로그 최신", + "remoteLink.child.catalog.behind": "카탈로그 오래됨", + "remoteLink.child.codex.restartNeeded": "Codex 재시작 필요 ({count})", + "remoteLink.child.codex.notRunning": "Codex 실행 안 함", + "remoteLink.child.ownerAction.legacy-ocx": "이 Child의 OpenCodex를 직접 업데이트하세요", + "remoteLink.child.ownerAction.desktop-app-owned": "이 Child의 OpenCodex Desktop에서 업데이트 확인을 사용하세요", + "remoteLink.child.ownerAction.goat": "GOAT가 이 Child를 업데이트합니다", + "remoteLink.child.ownerAction.launcher-node-missing": "이 Child에 Node.js가 없습니다. 설치한 뒤 그곳에서 업데이트하세요", + "remoteLink.child.ownerAction.launcher-bypassed": "이 Child의 실행 경로가 OpenCodex 업데이트를 거치지 않습니다. 직접 업데이트하세요", + "remoteLink.child.ownerAction.mise-managed": "이 Child의 OpenCodex는 mise가 관리합니다. mise로 업데이트하세요", + "remoteLink.child.ownerAction.source-checkout": "이 Child는 소스 체크아웃에서 실행됩니다. 직접 업데이트하세요", + "remoteLink.child.ownerAction.installer-unknown": "이 Child의 설치 방식을 알 수 없습니다. 그곳에서 업데이트하세요", + "remoteLink.child.check": "확인", + "remoteLink.child.checkAll": "모두 확인", + "remoteLink.child.syncNow": "지금 동기화", + "remoteLink.child.restartCodex": "Codex 재시작", + "remoteLink.child.update": "업데이트", + "remoteLink.child.updateAll": "모두 업데이트", + "remoteLink.child.updateConfirm": "{alias}을(를) {version}(으)로 업데이트할까요? 해당 Child의 OpenCodex가 재시작되어 지금 중계 중인 작업이 끝납니다. 자동 업데이트는 Child가 유휴 상태일 때까지 기다립니다.", + "remoteLink.child.updateAllConfirm": "Child {count}대를 {version}(으)로 한 대씩 업데이트합니다. 각 Child의 OpenCodex가 재시작되어 지금 중계 중인 작업이 끝납니다. 자동 업데이트는 Child가 유휴 상태일 때까지 기다립니다.", + "remoteLink.child.upToDate": "최신 상태", + "remoteLink.child.disconnectBlocked": "진행 중인 작업이 끝날 때까지 기다리세요", + "remoteLink.restart.scope.appServer": "Codex 앱 서버", + "remoteLink.restart.scope.appServerBody": "Codex 백그라운드 앱 서버를 재시작해 새 카탈로그를 읽게 합니다. Codex 데스크톱 앱 자체의 앱 서버는 새로 고쳐지지 않으므로, 그쪽은 데스크톱 앱을 재시작하세요.", + "remoteLink.restart.scope.codex": "Codex 데스크톱 앱", + "remoteLink.restart.scope.codexBody": "{alias}에서 Codex 데스크톱 앱을 종료하고 다시 실행합니다. 열린 대화는 끝납니다. 해당 컴퓨터에 로그인된 데스크톱 세션이 필요합니다.", + "remoteLink.restart.confirm": "재시작", + "remoteLink.job.active.observe": "확인 중…", + "remoteLink.job.active.sync": "동기화 중…", + "remoteLink.job.active.restartCodex": "Codex 재시작 중…", + "remoteLink.job.active.update": "업데이트 중…", + "remoteLink.job.state.queued": "대기 중", + "remoteLink.job.state.running": "실행 중", + "remoteLink.job.state.succeeded": "성공", + "remoteLink.job.state.upToDate": "최신 상태", + "remoteLink.job.state.busy": "사용 중", + "remoteLink.job.state.offline": "오프라인", + "remoteLink.job.state.refused": "거부됨", + "remoteLink.job.state.ownerAction": "소유자 조치 필요", + "remoteLink.job.state.unsupported": "지원 안 함", + "remoteLink.job.state.failed": "실패", + "remoteLink.job.state.uncertain": "결과 불확실", + "remoteLink.job.restart.requested": "재시작 요청됨", + "remoteLink.job.restart.unsupported": "재시작 미지원", + "remoteLink.job.restart.notRun": "재시작 안 함", + "remoteLink.job.restart.skipped": "재시작 보류: 이 Child에서 요청이 처리 중이었습니다.", + "remoteLink.job.sync.synced": "동기화됨", + "remoteLink.job.sync.skipped": "동기화 건너뜀", + "remoteLink.job.sync.failed": "동기화 실패", + "remoteLink.job.sync.timedOut": "동기화 시간 초과", + "remoteLink.job.last": "마지막 {op}: {state}", + "remoteLink.job.notFound": "이 Home이 다시 시작되어 작업 진행 상황이 사라졌습니다. 아래 행에 각 Child의 현재 상태가 표시됩니다.", + "remoteLink.job.automatic": "자동", + "remoteLink.policy.title": "Child 자동화", + "remoteLink.policy.catalogFanout": "카탈로그 전파", + "remoteLink.policy.catalogFanout.auto": "자동", + "remoteLink.policy.catalogFanout.manual": "수동", + "remoteLink.policy.childCodexRestart": "Codex 재시작", + "remoteLink.policy.childCodexRestart.never": "안 함", + "remoteLink.policy.childCodexRestart.whenIdle": "유휴 시 앱 서버", + "remoteLink.policy.updates": "업데이트", + "remoteLink.policy.updates.off": "끔", + "remoteLink.policy.updates.notify": "알림만", + "remoteLink.policy.updates.autoWhenIdle": "유휴 시 자동", + "remoteLink.policy.idleMinutes": "유휴 판단 시간(분)", + "remoteLink.policy.save": "저장", + "remoteLink.policy.saved": "저장됨", + "remoteLink.policy.updatesInactive": "지금은 설정만 저장됩니다. 자동 업데이트는 업데이트 일괄 처리가 출시된 뒤에 시작되므로 아직 스스로 업데이트하지 않습니다.", + "remoteLink.policy.invalid": "정책 파일이 손상되어 자동 카탈로그 배포와 자동 업데이트가 꺼져 있습니다. 각 자동 모드를 다시 선택하고 저장해야 다시 켜집니다.", + "remoteLink.error.invalid_job_id": "작업 ID가 올바르지 않습니다.", + "remoteLink.error.job_not_found": "작업을 찾을 수 없습니다. Home이 재시작되었을 수 있습니다.", + "remoteLink.error.link_operation_in_progress": "이 Child에서 다른 작업이 진행 중입니다.", + "remoteLink.error.no_eligible_links": "이 작업에 사용할 수 있는 Home 시작 Child가 없습니다.", + "remoteLink.error.policy_write_failed": "Child 정책을 저장하지 못했습니다.", + "remoteLink.error.runtime_role_client": "이 컴퓨터는 Child이므로 Home만 Child를 조작할 수 있습니다.", + "remoteLink.error.link_not_home_initiated": "이 Child가 직접 연결했으므로 해당 Child 컴퓨터에서 조작하세요.", }; diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 45150e17472..cc88ba57311 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -4353,4 +4353,87 @@ export const pt: Record = { "remoteLink.error.remote_plant_failed": "Não foi possível enviar o agente leve (ocx-child) via SSH. Verifique o espaço em disco remoto e as permissões de ~/.cache e tente novamente.", "remoteLink.error.remote_plant_timeout": "Tempo esgotado ao enviar o agente leve (ocx-child) via SSH. Verifique a conexão SSH e tente novamente.", "remoteLink.error.remote_plant_unavailable": "Não há binário ocx-child verificado disponível localmente para a arquitetura do computador remoto.", + "remoteLink.child.skew.same": "Mesma versão do Home", + "remoteLink.child.skew.behind": "Anterior ao Home ({version})", + "remoteLink.child.skew.ahead": "Mais recente que o Home", + "remoteLink.child.kind.desktopApp": "Aplicativo de desktop", + "remoteLink.child.kind.goat": "GOAT", + "remoteLink.child.kind.source": "Cópia do código-fonte", + "remoteLink.child.updateAvailable": "Atualização disponível: {version}", + "remoteLink.child.catalog.current": "Catálogo atualizado", + "remoteLink.child.catalog.behind": "Catálogo desatualizado", + "remoteLink.child.codex.restartNeeded": "Reinício do Codex necessário ({count})", + "remoteLink.child.codex.notRunning": "Codex não está em execução", + "remoteLink.child.ownerAction.legacy-ocx": "Atualize o OpenCodex neste Child manualmente", + "remoteLink.child.ownerAction.desktop-app-owned": "Use “Verificar atualizações” no OpenCodex Desktop deste Child", + "remoteLink.child.ownerAction.goat": "O GOAT mantém este Child atualizado", + "remoteLink.child.ownerAction.launcher-node-missing": "Falta o Node.js neste Child; instale-o e atualize lá", + "remoteLink.child.ownerAction.launcher-bypassed": "O iniciador deste Child ignora o atualizador do OpenCodex; atualize manualmente", + "remoteLink.child.ownerAction.mise-managed": "O mise gerencia o OpenCodex neste Child; atualize pelo mise", + "remoteLink.child.ownerAction.source-checkout": "Este Child roda a partir de uma cópia do código-fonte; atualize manualmente", + "remoteLink.child.ownerAction.installer-unknown": "O método de instalação deste Child é desconhecido; atualize lá", + "remoteLink.child.check": "Verificar", + "remoteLink.child.checkAll": "Verificar todos", + "remoteLink.child.syncNow": "Sincronizar agora", + "remoteLink.child.restartCodex": "Reiniciar o Codex", + "remoteLink.child.update": "Atualizar", + "remoteLink.child.updateAll": "Atualizar todos", + "remoteLink.child.updateConfirm": "Atualizar {alias} para {version}? O OpenCodex nesse Child reinicia, o que encerra qualquer turno atualmente retransmitido por ele. As atualizações automáticas esperam até o Child ficar ocioso.", + "remoteLink.child.updateAllConfirm": "Atualizar {count} Children para {version}, um de cada vez. O OpenCodex de cada Child reinicia, o que encerra qualquer turno atualmente retransmitido por ele. As atualizações automáticas esperam até o Child ficar ocioso.", + "remoteLink.child.upToDate": "Atualizado", + "remoteLink.child.disconnectBlocked": "Aguarde a operação em andamento terminar", + "remoteLink.restart.scope.appServer": "Servidores de aplicativo do Codex", + "remoteLink.restart.scope.appServerBody": "Reinicia os servidores de aplicativo do Codex em segundo plano para que carreguem o novo catálogo. O servidor de aplicativo próprio do aplicativo Codex para desktop não é atualizado; reinicie o aplicativo para isso.", + "remoteLink.restart.scope.codex": "Aplicativo Codex para desktop", + "remoteLink.restart.scope.codexBody": "Encerra e reinicia o aplicativo Codex para desktop em {alias}. As conversas abertas terminam. Isso exige uma sessão de desktop conectada nesse computador.", + "remoteLink.restart.confirm": "Reiniciar", + "remoteLink.job.active.observe": "Verificando…", + "remoteLink.job.active.sync": "Sincronizando…", + "remoteLink.job.active.restartCodex": "Reiniciando o Codex…", + "remoteLink.job.active.update": "Atualizando…", + "remoteLink.job.state.queued": "Na fila", + "remoteLink.job.state.running": "Em execução", + "remoteLink.job.state.succeeded": "Concluído", + "remoteLink.job.state.upToDate": "Atualizado", + "remoteLink.job.state.busy": "Ocupado", + "remoteLink.job.state.offline": "Offline", + "remoteLink.job.state.refused": "Recusado", + "remoteLink.job.state.ownerAction": "Ação do proprietário necessária", + "remoteLink.job.state.unsupported": "Não suportado", + "remoteLink.job.state.failed": "Falhou", + "remoteLink.job.state.uncertain": "Resultado desconhecido", + "remoteLink.job.restart.requested": "Reinício solicitado", + "remoteLink.job.restart.unsupported": "Reinício não suportado", + "remoteLink.job.restart.notRun": "Reinício não executado", + "remoteLink.job.restart.skipped": "Reinício retido: havia uma requisição em andamento neste Child.", + "remoteLink.job.sync.synced": "Sincronizado", + "remoteLink.job.sync.skipped": "Sincronização ignorada", + "remoteLink.job.sync.failed": "Falha na sincronização", + "remoteLink.job.sync.timedOut": "Tempo esgotado na sincronização", + "remoteLink.job.last": "Última {op}: {state}", + "remoteLink.job.notFound": "Este Home reiniciou, então o progresso da operação foi perdido. As linhas abaixo mostram o estado atual de cada Child.", + "remoteLink.job.automatic": "Automático", + "remoteLink.policy.title": "Automação de Children", + "remoteLink.policy.catalogFanout": "Distribuição do catálogo", + "remoteLink.policy.catalogFanout.auto": "Automática", + "remoteLink.policy.catalogFanout.manual": "Manual", + "remoteLink.policy.childCodexRestart": "Reinício do Codex", + "remoteLink.policy.childCodexRestart.never": "Nunca", + "remoteLink.policy.childCodexRestart.whenIdle": "Servidores de aplicativo quando ocioso", + "remoteLink.policy.updates": "Atualizações", + "remoteLink.policy.updates.off": "Desativadas", + "remoteLink.policy.updates.notify": "Somente notificar", + "remoteLink.policy.updates.autoWhenIdle": "Automáticas quando ocioso", + "remoteLink.policy.idleMinutes": "Ocioso após (minutos)", + "remoteLink.policy.save": "Salvar", + "remoteLink.policy.saved": "Salvo", + "remoteLink.policy.updatesInactive": "Apenas armazenado por enquanto; as atualizações automáticas começam quando a varredura de atualizações for lançada, então nada se atualiza sozinho ainda.", + "remoteLink.policy.invalid": "O arquivo de política estava danificado, então a distribuição automática do catálogo e as atualizações automáticas estão desativadas. Escolha cada modo automático novamente e salve para reativá-los.", + "remoteLink.error.invalid_job_id": "O id da operação é inválido.", + "remoteLink.error.job_not_found": "A operação não é mais conhecida; o Home pode ter reiniciado.", + "remoteLink.error.link_operation_in_progress": "Outra operação está em execução neste Child.", + "remoteLink.error.no_eligible_links": "Nenhum Child iniciado pelo Home está disponível para isso.", + "remoteLink.error.policy_write_failed": "Não foi possível salvar a política do Child.", + "remoteLink.error.runtime_role_client": "Este computador é um Child; apenas um Home pode operar Children.", + "remoteLink.error.link_not_home_initiated": "Este Child se conectou sozinho, então opere-o a partir do próprio computador dele.", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b935bd8c470..4aaa850d0c2 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4381,4 +4381,87 @@ export const ru: Record = { "remoteLink.error.remote_plant_failed": "Не удалось передать лёгкий агент (ocx-child) по SSH. Проверьте свободное место и права на ~/.cache на удалённой машине и повторите попытку.", "remoteLink.error.remote_plant_timeout": "Превышено время ожидания при передаче лёгкого агента (ocx-child) по SSH. Проверьте подключение SSH и повторите попытку.", "remoteLink.error.remote_plant_unavailable": "Локально нет проверенного бинарного файла ocx-child для архитектуры удалённого компьютера.", + "remoteLink.child.skew.same": "Та же версия, что и Home", + "remoteLink.child.skew.behind": "Старее Home ({version})", + "remoteLink.child.skew.ahead": "Новее Home", + "remoteLink.child.kind.desktopApp": "Настольное приложение", + "remoteLink.child.kind.goat": "GOAT", + "remoteLink.child.kind.source": "Исходный код из репозитория", + "remoteLink.child.updateAvailable": "Доступно обновление: {version}", + "remoteLink.child.catalog.current": "Каталог актуален", + "remoteLink.child.catalog.behind": "Каталог устарел", + "remoteLink.child.codex.restartNeeded": "Требуется перезапуск Codex ({count})", + "remoteLink.child.codex.notRunning": "Codex не запущен", + "remoteLink.child.ownerAction.legacy-ocx": "Обновите OpenCodex на этом Child вручную", + "remoteLink.child.ownerAction.desktop-app-owned": "Используйте «Проверить обновления» в OpenCodex Desktop на этом Child", + "remoteLink.child.ownerAction.goat": "GOAT поддерживает этот Child в актуальном состоянии", + "remoteLink.child.ownerAction.launcher-node-missing": "На этом Child нет Node.js; установите его и обновите там", + "remoteLink.child.ownerAction.launcher-bypassed": "Запуск этого Child обходит механизм обновления OpenCodex; обновите вручную", + "remoteLink.child.ownerAction.mise-managed": "OpenCodex на этом Child управляется mise; обновите через mise", + "remoteLink.child.ownerAction.source-checkout": "Этот Child запущен из копии исходного кода; обновите вручную", + "remoteLink.child.ownerAction.installer-unknown": "Способ установки этого Child неизвестен; обновите его там", + "remoteLink.child.check": "Проверить", + "remoteLink.child.checkAll": "Проверить все", + "remoteLink.child.syncNow": "Синхронизировать сейчас", + "remoteLink.child.restartCodex": "Перезапустить Codex", + "remoteLink.child.update": "Обновить", + "remoteLink.child.updateAll": "Обновить все", + "remoteLink.child.updateConfirm": "Обновить {alias} до {version}? OpenCodex на этом Child перезапустится, что завершит любой ход, который сейчас через него передаётся. Автоматические обновления ждут, пока Child не станет простаивать.", + "remoteLink.child.updateAllConfirm": "Обновить {count} Child по одному до {version}. OpenCodex каждого Child перезапустится, что завершит любой ход, который сейчас через него передаётся. Автоматические обновления ждут, пока Child не станет простаивать.", + "remoteLink.child.upToDate": "Актуально", + "remoteLink.child.disconnectBlocked": "Дождитесь завершения текущей операции", + "remoteLink.restart.scope.appServer": "Серверы приложений Codex", + "remoteLink.restart.scope.appServerBody": "Перезапускает фоновые серверы приложений Codex, чтобы они загрузили новый каталог. Собственный сервер приложения настольного Codex не обновляется; для этого перезапустите настольное приложение.", + "remoteLink.restart.scope.codex": "Настольное приложение Codex", + "remoteLink.restart.scope.codexBody": "Закрывает и снова запускает настольное приложение Codex на {alias}. Открытые беседы завершатся. Для этого нужен выполненный вход в настольную сессию на этом компьютере.", + "remoteLink.restart.confirm": "Перезапустить", + "remoteLink.job.active.observe": "Проверка…", + "remoteLink.job.active.sync": "Синхронизация…", + "remoteLink.job.active.restartCodex": "Перезапуск Codex…", + "remoteLink.job.active.update": "Обновление…", + "remoteLink.job.state.queued": "В очереди", + "remoteLink.job.state.running": "Выполняется", + "remoteLink.job.state.succeeded": "Успешно", + "remoteLink.job.state.upToDate": "Актуально", + "remoteLink.job.state.busy": "Занято", + "remoteLink.job.state.offline": "Не в сети", + "remoteLink.job.state.refused": "Отказано", + "remoteLink.job.state.ownerAction": "Нужно действие владельца", + "remoteLink.job.state.unsupported": "Не поддерживается", + "remoteLink.job.state.failed": "Ошибка", + "remoteLink.job.state.uncertain": "Исход неизвестен", + "remoteLink.job.restart.requested": "Перезапуск запрошен", + "remoteLink.job.restart.unsupported": "Перезапуск не поддерживается", + "remoteLink.job.restart.notRun": "Перезапуск не выполнен", + "remoteLink.job.restart.skipped": "Перезапуск отложен: на этом Child выполнялся запрос.", + "remoteLink.job.sync.synced": "Синхронизировано", + "remoteLink.job.sync.skipped": "Синхронизация пропущена", + "remoteLink.job.sync.failed": "Ошибка синхронизации", + "remoteLink.job.sync.timedOut": "Время синхронизации истекло", + "remoteLink.job.last": "Последняя {op}: {state}", + "remoteLink.job.notFound": "Home перезапустился, поэтому ход операции утрачен. Строки ниже показывают текущее состояние каждого Child.", + "remoteLink.job.automatic": "Автоматически", + "remoteLink.policy.title": "Автоматизация Child", + "remoteLink.policy.catalogFanout": "Распространение каталога", + "remoteLink.policy.catalogFanout.auto": "Автоматически", + "remoteLink.policy.catalogFanout.manual": "Вручную", + "remoteLink.policy.childCodexRestart": "Перезапуск Codex", + "remoteLink.policy.childCodexRestart.never": "Никогда", + "remoteLink.policy.childCodexRestart.whenIdle": "Серверы приложений в простое", + "remoteLink.policy.updates": "Обновления", + "remoteLink.policy.updates.off": "Выкл.", + "remoteLink.policy.updates.notify": "Только уведомлять", + "remoteLink.policy.updates.autoWhenIdle": "Автоматически в простое", + "remoteLink.policy.idleMinutes": "Простой после (минут)", + "remoteLink.policy.save": "Сохранить", + "remoteLink.policy.saved": "Сохранено", + "remoteLink.policy.updatesInactive": "Пока только сохраняется; автоматические обновления начнут работать после выпуска цикла обновлений, поэтому пока ничего не обновляется само.", + "remoteLink.policy.invalid": "Файл политики был повреждён, поэтому автоматическое распространение каталога и автоматические обновления отключены. Выберите каждый автоматический режим заново и сохраните, чтобы включить их.", + "remoteLink.error.invalid_job_id": "Идентификатор операции недопустим.", + "remoteLink.error.job_not_found": "Операция больше не известна; возможно, Home перезапустился.", + "remoteLink.error.link_operation_in_progress": "На этом Child уже выполняется другая операция.", + "remoteLink.error.no_eligible_links": "Для этого нет Child, инициированного Home.", + "remoteLink.error.policy_write_failed": "Не удалось сохранить политику Child.", + "remoteLink.error.runtime_role_client": "Этот компьютер — Child; управлять Children может только Home.", + "remoteLink.error.link_not_home_initiated": "Этот Child подключился сам, поэтому управляйте им с его собственного компьютера.", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index c1795f2f0a3..a1736f75859 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -4381,4 +4381,87 @@ export const tr: Record = { "remoteLink.error.remote_plant_failed": "Hafif aracı (ocx-child) SSH üzerinden yerleştirilemedi. Uzak disk alanını ve ~/.cache izinlerini kontrol edip yeniden deneyin.", "remoteLink.error.remote_plant_timeout": "Hafif aracının (ocx-child) SSH üzerinden yerleştirilmesi zaman aşımına uğradı. SSH bağlantısını kontrol edip yeniden deneyin.", "remoteLink.error.remote_plant_unavailable": "Uzak bilgisayarın mimarisi için yerel olarak doğrulanmış ocx-child ikili dosyası bulunmuyor.", + "remoteLink.child.skew.same": "Home ile aynı sürüm", + "remoteLink.child.skew.behind": "Home sürümünden eski ({version})", + "remoteLink.child.skew.ahead": "Home sürümünden yeni", + "remoteLink.child.kind.desktopApp": "Masaüstü uygulaması", + "remoteLink.child.kind.goat": "GOAT", + "remoteLink.child.kind.source": "Kaynak kodu kopyası", + "remoteLink.child.updateAvailable": "Güncelleme mevcut: {version}", + "remoteLink.child.catalog.current": "Katalog güncel", + "remoteLink.child.catalog.behind": "Katalog eski", + "remoteLink.child.codex.restartNeeded": "Codex yeniden başlatması gerekiyor ({count})", + "remoteLink.child.codex.notRunning": "Codex çalışmıyor", + "remoteLink.child.ownerAction.legacy-ocx": "Bu Child üzerindeki OpenCodex’i elle güncelleyin", + "remoteLink.child.ownerAction.desktop-app-owned": "Bu Child üzerindeki OpenCodex Desktop’ta “Güncellemeleri denetle”yi kullanın", + "remoteLink.child.ownerAction.goat": "GOAT bu Child’ı güncel tutar", + "remoteLink.child.ownerAction.launcher-node-missing": "Bu Child üzerinde Node.js yok; kurup orada güncelleyin", + "remoteLink.child.ownerAction.launcher-bypassed": "Bu Child’ın başlatıcısı OpenCodex güncelleyicisini atlıyor; elle güncelleyin", + "remoteLink.child.ownerAction.mise-managed": "Bu Child üzerindeki OpenCodex’i mise yönetiyor; mise ile güncelleyin", + "remoteLink.child.ownerAction.source-checkout": "Bu Child bir kaynak kodu kopyasından çalışıyor; elle güncelleyin", + "remoteLink.child.ownerAction.installer-unknown": "Bu Child’ın kurulum yöntemi bilinmiyor; orada güncelleyin", + "remoteLink.child.check": "Denetle", + "remoteLink.child.checkAll": "Tümünü denetle", + "remoteLink.child.syncNow": "Şimdi eşitle", + "remoteLink.child.restartCodex": "Codex’i yeniden başlat", + "remoteLink.child.update": "Güncelle", + "remoteLink.child.updateAll": "Tümünü güncelle", + "remoteLink.child.updateConfirm": "{alias} {version} sürümüne güncellensin mi? Bu Child üzerindeki OpenCodex yeniden başlar ve o an onun için aktarılan tur sona erer. Otomatik güncellemeler Child boşta olana kadar bekler.", + "remoteLink.child.updateAllConfirm": "{count} Child {version} sürümüne tek tek güncellensin. Her Child’ın OpenCodex’i yeniden başlar ve o an onun için aktarılan tur sona erer. Otomatik güncellemeler Child boşta olana kadar bekler.", + "remoteLink.child.upToDate": "Güncel", + "remoteLink.child.disconnectBlocked": "Sürmekte olan işlemin bitmesini bekleyin", + "remoteLink.restart.scope.appServer": "Codex uygulama sunucuları", + "remoteLink.restart.scope.appServerBody": "Codex arka plan uygulama sunucularını yeni kataloğu yüklemeleri için yeniden başlatır. Codex masaüstü uygulamasının kendi uygulama sunucusu yenilenmez; bunun için masaüstü uygulamasını yeniden başlatın.", + "remoteLink.restart.scope.codex": "Codex masaüstü uygulaması", + "remoteLink.restart.scope.codexBody": "{alias} üzerindeki Codex masaüstü uygulamasını kapatıp yeniden başlatır. Açık sohbetler sona erer. Bunun için o bilgisayarda oturum açmış bir masaüstü oturumu gerekir.", + "remoteLink.restart.confirm": "Yeniden başlat", + "remoteLink.job.active.observe": "Denetleniyor…", + "remoteLink.job.active.sync": "Eşitleniyor…", + "remoteLink.job.active.restartCodex": "Codex yeniden başlatılıyor…", + "remoteLink.job.active.update": "Güncelleniyor…", + "remoteLink.job.state.queued": "Sırada", + "remoteLink.job.state.running": "Çalışıyor", + "remoteLink.job.state.succeeded": "Başarılı", + "remoteLink.job.state.upToDate": "Güncel", + "remoteLink.job.state.busy": "Meşgul", + "remoteLink.job.state.offline": "Çevrimdışı", + "remoteLink.job.state.refused": "Reddedildi", + "remoteLink.job.state.ownerAction": "Sahip işlemi gerekli", + "remoteLink.job.state.unsupported": "Desteklenmiyor", + "remoteLink.job.state.failed": "Başarısız", + "remoteLink.job.state.uncertain": "Sonuç bilinmiyor", + "remoteLink.job.restart.requested": "Yeniden başlatma istendi", + "remoteLink.job.restart.unsupported": "Yeniden başlatma desteklenmiyor", + "remoteLink.job.restart.notRun": "Yeniden başlatma çalıştırılmadı", + "remoteLink.job.restart.skipped": "Yeniden başlatma bekletildi: Bu Child üzerinde bir istek işleniyordu.", + "remoteLink.job.sync.synced": "Eşitlendi", + "remoteLink.job.sync.skipped": "Eşitleme atlandı", + "remoteLink.job.sync.failed": "Eşitleme başarısız", + "remoteLink.job.sync.timedOut": "Eşitleme zaman aşımına uğradı", + "remoteLink.job.last": "Son {op}: {state}", + "remoteLink.job.notFound": "Bu Home yeniden başladı, bu yüzden işlemin ilerlemesi kayboldu. Aşağıdaki satırlar her Child’ın güncel durumunu gösterir.", + "remoteLink.job.automatic": "Otomatik", + "remoteLink.policy.title": "Child otomasyonu", + "remoteLink.policy.catalogFanout": "Katalog dağıtımı", + "remoteLink.policy.catalogFanout.auto": "Otomatik", + "remoteLink.policy.catalogFanout.manual": "Elle", + "remoteLink.policy.childCodexRestart": "Codex yeniden başlatma", + "remoteLink.policy.childCodexRestart.never": "Asla", + "remoteLink.policy.childCodexRestart.whenIdle": "Boştayken uygulama sunucuları", + "remoteLink.policy.updates": "Güncellemeler", + "remoteLink.policy.updates.off": "Kapalı", + "remoteLink.policy.updates.notify": "Yalnızca bildir", + "remoteLink.policy.updates.autoWhenIdle": "Boştayken otomatik", + "remoteLink.policy.idleMinutes": "Boşta sayılma (dakika)", + "remoteLink.policy.save": "Kaydet", + "remoteLink.policy.saved": "Kaydedildi", + "remoteLink.policy.updatesInactive": "Şimdilik yalnızca saklanır; otomatik güncellemeler güncelleme taraması yayınlandığında başlar, bu yüzden henüz kendi kendine güncellenmez.", + "remoteLink.policy.invalid": "İlke dosyası bozulmuş, bu yüzden otomatik katalog dağıtımı ve otomatik güncellemeler kapalı. Her otomatik modu yeniden seçip kaydedin, böylece yeniden açılsın.", + "remoteLink.error.invalid_job_id": "İşlem kimliği geçersiz.", + "remoteLink.error.job_not_found": "İşlem artık bilinmiyor; Home yeniden başlamış olabilir.", + "remoteLink.error.link_operation_in_progress": "Bu Child üzerinde başka bir işlem çalışıyor.", + "remoteLink.error.no_eligible_links": "Bunun için Home tarafından başlatılmış bir Child yok.", + "remoteLink.error.policy_write_failed": "Child ilkesi kaydedilemedi.", + "remoteLink.error.runtime_role_client": "Bu bilgisayar bir Child; Child’ları yalnızca bir Home yönetebilir.", + "remoteLink.error.link_not_home_initiated": "Bu Child kendini bağladı, bu yüzden onu kendi bilgisayarından yönetin.", }; diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 45e40556263..aae34921baf 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -4350,4 +4350,87 @@ export const vi: Record = { "remoteLink.error.remote_plant_failed": "Không thể cài tác nhân gọn nhẹ (ocx-child) qua SSH. Hãy kiểm tra dung lượng đĩa từ xa và quyền ~/.cache rồi thử lại.", "remoteLink.error.remote_plant_timeout": "Quá thời gian khi cài tác nhân gọn nhẹ (ocx-child) qua SSH. Hãy kiểm tra kết nối SSH rồi thử lại.", "remoteLink.error.remote_plant_unavailable": "Không có tệp ocx-child đã xác minh trên máy cục bộ cho kiến trúc của máy tính từ xa.", + "remoteLink.child.skew.same": "Cùng phiên bản với Home", + "remoteLink.child.skew.behind": "Cũ hơn Home ({version})", + "remoteLink.child.skew.ahead": "Mới hơn Home", + "remoteLink.child.kind.desktopApp": "Ứng dụng máy tính", + "remoteLink.child.kind.goat": "GOAT", + "remoteLink.child.kind.source": "Bản sao mã nguồn", + "remoteLink.child.updateAvailable": "Có bản cập nhật: {version}", + "remoteLink.child.catalog.current": "Danh mục đã mới nhất", + "remoteLink.child.catalog.behind": "Danh mục đã cũ", + "remoteLink.child.codex.restartNeeded": "Cần khởi động lại Codex ({count})", + "remoteLink.child.codex.notRunning": "Codex không chạy", + "remoteLink.child.ownerAction.legacy-ocx": "Cập nhật OpenCodex trên máy con này thủ công", + "remoteLink.child.ownerAction.desktop-app-owned": "Dùng “Kiểm tra cập nhật” trong OpenCodex Desktop trên máy con này", + "remoteLink.child.ownerAction.goat": "GOAT giữ máy con này luôn cập nhật", + "remoteLink.child.ownerAction.launcher-node-missing": "Máy con này thiếu Node.js; hãy cài rồi cập nhật tại đó", + "remoteLink.child.ownerAction.launcher-bypassed": "Trình khởi chạy của máy con này bỏ qua trình cập nhật của OpenCodex; hãy cập nhật thủ công", + "remoteLink.child.ownerAction.mise-managed": "mise quản lý OpenCodex trên máy con này; hãy cập nhật qua mise", + "remoteLink.child.ownerAction.source-checkout": "Máy con này chạy từ bản sao mã nguồn; hãy cập nhật thủ công", + "remoteLink.child.ownerAction.installer-unknown": "Không rõ cách cài đặt của máy con này; hãy cập nhật tại đó", + "remoteLink.child.check": "Kiểm tra", + "remoteLink.child.checkAll": "Kiểm tra tất cả", + "remoteLink.child.syncNow": "Đồng bộ ngay", + "remoteLink.child.restartCodex": "Khởi động lại Codex", + "remoteLink.child.update": "Cập nhật", + "remoteLink.child.updateAll": "Cập nhật tất cả", + "remoteLink.child.updateConfirm": "Cập nhật {alias} lên {version}? OpenCodex trên máy con đó sẽ khởi động lại, kết thúc mọi lượt đang được chuyển tiếp cho máy đó. Cập nhật tự động sẽ chờ đến khi máy con rảnh.", + "remoteLink.child.updateAllConfirm": "Cập nhật {count} máy con lên {version}, lần lượt từng máy. OpenCodex trên mỗi máy con sẽ khởi động lại, kết thúc mọi lượt đang được chuyển tiếp cho máy đó. Cập nhật tự động sẽ chờ đến khi máy con rảnh.", + "remoteLink.child.upToDate": "Đã mới nhất", + "remoteLink.child.disconnectBlocked": "Hãy chờ thao tác đang chạy kết thúc", + "remoteLink.restart.scope.appServer": "Máy chủ ứng dụng Codex", + "remoteLink.restart.scope.appServerBody": "Khởi động lại các máy chủ ứng dụng Codex chạy nền để chúng tải danh mục mới. Máy chủ ứng dụng riêng của ứng dụng Codex trên máy tính không được làm mới; hãy khởi động lại ứng dụng máy tính cho việc đó.", + "remoteLink.restart.scope.codex": "Ứng dụng Codex trên máy tính", + "remoteLink.restart.scope.codexBody": "Thoát và khởi chạy lại ứng dụng Codex trên máy tính tại {alias}. Các cuộc trò chuyện đang mở sẽ kết thúc. Việc này cần một phiên máy tính đã đăng nhập trên máy đó.", + "remoteLink.restart.confirm": "Khởi động lại", + "remoteLink.job.active.observe": "Đang kiểm tra…", + "remoteLink.job.active.sync": "Đang đồng bộ…", + "remoteLink.job.active.restartCodex": "Đang khởi động lại Codex…", + "remoteLink.job.active.update": "Đang cập nhật…", + "remoteLink.job.state.queued": "Đang chờ", + "remoteLink.job.state.running": "Đang chạy", + "remoteLink.job.state.succeeded": "Thành công", + "remoteLink.job.state.upToDate": "Đã mới nhất", + "remoteLink.job.state.busy": "Đang bận", + "remoteLink.job.state.offline": "Ngoại tuyến", + "remoteLink.job.state.refused": "Bị từ chối", + "remoteLink.job.state.ownerAction": "Cần chủ sở hữu xử lý", + "remoteLink.job.state.unsupported": "Không được hỗ trợ", + "remoteLink.job.state.failed": "Thất bại", + "remoteLink.job.state.uncertain": "Không rõ kết quả", + "remoteLink.job.restart.requested": "Đã yêu cầu khởi động lại", + "remoteLink.job.restart.unsupported": "Không hỗ trợ khởi động lại", + "remoteLink.job.restart.notRun": "Không chạy khởi động lại", + "remoteLink.job.restart.skipped": "Tạm hoãn khởi động lại: đang có yêu cầu xử lý trên máy con này.", + "remoteLink.job.sync.synced": "Đã đồng bộ", + "remoteLink.job.sync.skipped": "Bỏ qua đồng bộ", + "remoteLink.job.sync.failed": "Đồng bộ thất bại", + "remoteLink.job.sync.timedOut": "Đồng bộ quá thời gian", + "remoteLink.job.last": "Lần cuối {op}: {state}", + "remoteLink.job.notFound": "Home này đã khởi động lại nên tiến trình thao tác đã mất. Các hàng bên dưới hiển thị trạng thái hiện tại của từng máy con.", + "remoteLink.job.automatic": "Tự động", + "remoteLink.policy.title": "Tự động hóa máy con", + "remoteLink.policy.catalogFanout": "Phân phối danh mục", + "remoteLink.policy.catalogFanout.auto": "Tự động", + "remoteLink.policy.catalogFanout.manual": "Thủ công", + "remoteLink.policy.childCodexRestart": "Khởi động lại Codex", + "remoteLink.policy.childCodexRestart.never": "Không bao giờ", + "remoteLink.policy.childCodexRestart.whenIdle": "Máy chủ ứng dụng khi rảnh", + "remoteLink.policy.updates": "Cập nhật", + "remoteLink.policy.updates.off": "Tắt", + "remoteLink.policy.updates.notify": "Chỉ thông báo", + "remoteLink.policy.updates.autoWhenIdle": "Tự động khi rảnh", + "remoteLink.policy.idleMinutes": "Rảnh sau (phút)", + "remoteLink.policy.save": "Lưu", + "remoteLink.policy.saved": "Đã lưu", + "remoteLink.policy.updatesInactive": "Hiện chỉ lưu cài đặt; cập nhật tự động sẽ bắt đầu khi đợt quét cập nhật được phát hành, nên hiện chưa có gì tự cập nhật.", + "remoteLink.policy.invalid": "Tệp chính sách đã bị hỏng nên phân phối danh mục tự động và cập nhật tự động đang tắt. Hãy chọn lại từng chế độ tự động và lưu để bật lại.", + "remoteLink.error.invalid_job_id": "ID thao tác không hợp lệ.", + "remoteLink.error.job_not_found": "Không còn biết thao tác này; có thể Home đã khởi động lại.", + "remoteLink.error.link_operation_in_progress": "Đang có thao tác khác chạy trên máy con này.", + "remoteLink.error.no_eligible_links": "Không có máy con do Home khởi tạo nào cho việc này.", + "remoteLink.error.policy_write_failed": "Không lưu được chính sách máy con.", + "remoteLink.error.runtime_role_client": "Máy này là máy con; chỉ Home mới điều khiển được máy con.", + "remoteLink.error.link_not_home_initiated": "Máy con này tự kết nối nên hãy điều khiển nó từ chính máy đó.", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index d7de19e38ab..3b7d5997a45 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -4344,4 +4344,87 @@ export const zhTW: Record = { "remoteLink.error.remote_plant_failed": "無法透過 SSH 植入輕量代理(ocx-child)。請檢查遠端磁碟空間與 ~/.cache 權限後重試。", "remoteLink.error.remote_plant_timeout": "透過 SSH 植入輕量代理(ocx-child)逾時。請檢查 SSH 連線後重試。", "remoteLink.error.remote_plant_unavailable": "本機沒有適用於該遠端電腦架構的已驗證 ocx-child 執行檔。", + "remoteLink.child.skew.same": "與 Home 版本相同", + "remoteLink.child.skew.behind": "低於 Home ({version})", + "remoteLink.child.skew.ahead": "高於 Home", + "remoteLink.child.kind.desktopApp": "桌面應用程式", + "remoteLink.child.kind.goat": "GOAT", + "remoteLink.child.kind.source": "原始碼檢出", + "remoteLink.child.updateAvailable": "有可用更新:{version}", + "remoteLink.child.catalog.current": "目錄已是最新", + "remoteLink.child.catalog.behind": "目錄已過期", + "remoteLink.child.codex.restartNeeded": "需要重新啟動 Codex({count})", + "remoteLink.child.codex.notRunning": "Codex 未執行", + "remoteLink.child.ownerAction.legacy-ocx": "請在此子裝置上手動更新 OpenCodex", + "remoteLink.child.ownerAction.desktop-app-owned": "請在此子裝置的 OpenCodex Desktop 中使用「檢查更新」", + "remoteLink.child.ownerAction.goat": "GOAT 會讓此子裝置保持最新", + "remoteLink.child.ownerAction.launcher-node-missing": "此子裝置缺少 Node.js;請安裝後在該裝置上更新", + "remoteLink.child.ownerAction.launcher-bypassed": "此子裝置的啟動器略過了 OpenCodex 的更新程式;請手動更新", + "remoteLink.child.ownerAction.mise-managed": "此子裝置的 OpenCodex 由 mise 管理;請透過 mise 更新", + "remoteLink.child.ownerAction.source-checkout": "此子裝置從原始碼檢出執行;請手動更新", + "remoteLink.child.ownerAction.installer-unknown": "此子裝置的安裝方式不明;請在該裝置上更新", + "remoteLink.child.check": "檢查", + "remoteLink.child.checkAll": "全部檢查", + "remoteLink.child.syncNow": "立即同步", + "remoteLink.child.restartCodex": "重新啟動 Codex", + "remoteLink.child.update": "更新", + "remoteLink.child.updateAll": "全部更新", + "remoteLink.child.updateConfirm": "要將 {alias} 更新至 {version} 嗎?該子裝置上的 OpenCodex 會重新啟動,因而結束目前為其轉送的任何回合。自動更新會等到子裝置閒置時再進行。", + "remoteLink.child.updateAllConfirm": "將 {count} 個子裝置逐一更新至 {version}。每個子裝置的 OpenCodex 都會重新啟動,因而結束目前為其轉送的任何回合。自動更新會等到子裝置閒置時再進行。", + "remoteLink.child.upToDate": "已是最新", + "remoteLink.child.disconnectBlocked": "請等待目前的作業完成", + "remoteLink.restart.scope.appServer": "Codex 應用程式伺服器", + "remoteLink.restart.scope.appServerBody": "重新啟動 Codex 背景應用程式伺服器,使其載入新目錄。Codex 桌面應用程式本身的應用程式伺服器不會更新;如需更新,請重新啟動桌面應用程式。", + "remoteLink.restart.scope.codex": "Codex 桌面應用程式", + "remoteLink.restart.scope.codexBody": "在 {alias} 上結束並重新啟動 Codex 桌面應用程式。開啟的交談會結束。這需要該電腦上有已登入的桌面工作階段。", + "remoteLink.restart.confirm": "重新啟動", + "remoteLink.job.active.observe": "檢查中…", + "remoteLink.job.active.sync": "同步中…", + "remoteLink.job.active.restartCodex": "正在重新啟動 Codex…", + "remoteLink.job.active.update": "更新中…", + "remoteLink.job.state.queued": "排隊中", + "remoteLink.job.state.running": "執行中", + "remoteLink.job.state.succeeded": "成功", + "remoteLink.job.state.upToDate": "已是最新", + "remoteLink.job.state.busy": "忙碌", + "remoteLink.job.state.offline": "離線", + "remoteLink.job.state.refused": "已拒絕", + "remoteLink.job.state.ownerAction": "需要擁有者操作", + "remoteLink.job.state.unsupported": "不支援", + "remoteLink.job.state.failed": "失敗", + "remoteLink.job.state.uncertain": "結果不明", + "remoteLink.job.restart.requested": "已要求重新啟動", + "remoteLink.job.restart.unsupported": "不支援重新啟動", + "remoteLink.job.restart.notRun": "未執行重新啟動", + "remoteLink.job.restart.skipped": "重新啟動暫緩:此子裝置上有要求正在處理。", + "remoteLink.job.sync.synced": "已同步", + "remoteLink.job.sync.skipped": "已略過同步", + "remoteLink.job.sync.failed": "同步失敗", + "remoteLink.job.sync.timedOut": "同步逾時", + "remoteLink.job.last": "上次{op}:{state}", + "remoteLink.job.notFound": "此 Home 已重新啟動,因此作業進度已遺失。下列列會顯示每個子裝置的目前狀態。", + "remoteLink.job.automatic": "自動", + "remoteLink.policy.title": "子裝置自動化", + "remoteLink.policy.catalogFanout": "目錄散佈", + "remoteLink.policy.catalogFanout.auto": "自動", + "remoteLink.policy.catalogFanout.manual": "手動", + "remoteLink.policy.childCodexRestart": "Codex 重新啟動", + "remoteLink.policy.childCodexRestart.never": "從不", + "remoteLink.policy.childCodexRestart.whenIdle": "閒置時重新啟動應用程式伺服器", + "remoteLink.policy.updates": "更新", + "remoteLink.policy.updates.off": "關閉", + "remoteLink.policy.updates.notify": "僅通知", + "remoteLink.policy.updates.autoWhenIdle": "閒置時自動", + "remoteLink.policy.idleMinutes": "閒置判定(分鐘)", + "remoteLink.policy.save": "儲存", + "remoteLink.policy.saved": "已儲存", + "remoteLink.policy.updatesInactive": "目前只會儲存設定;自動更新會在更新批次作業推出後開始,因此目前還不會自行更新。", + "remoteLink.policy.invalid": "原則檔案已損毀,因此自動目錄散佈與自動更新已關閉。請重新選擇每個自動模式並儲存,以重新開啟。", + "remoteLink.error.invalid_job_id": "作業 ID 無效。", + "remoteLink.error.job_not_found": "已找不到該作業;Home 可能已重新啟動。", + "remoteLink.error.link_operation_in_progress": "此子裝置上已有其他作業正在執行。", + "remoteLink.error.no_eligible_links": "沒有可用於此作業的 Home 發起子裝置。", + "remoteLink.error.policy_write_failed": "無法儲存子裝置原則。", + "remoteLink.error.runtime_role_client": "此電腦是子裝置;只有 Home 能操作子裝置。", + "remoteLink.error.link_not_home_initiated": "此子裝置是自行連線的,請在其自己的電腦上操作。", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 3f1b8450ba8..cda2bbf1dce 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4379,4 +4379,87 @@ export const zh: Record = { "remoteLink.error.remote_plant_failed": "无法通过 SSH 植入轻量代理(ocx-child)。请检查远程磁盘空间与 ~/.cache 权限后重试。", "remoteLink.error.remote_plant_timeout": "通过 SSH 植入轻量代理(ocx-child)超时。请检查 SSH 连接后重试。", "remoteLink.error.remote_plant_unavailable": "本地没有适用于该远程计算机架构的已验证 ocx-child 二进制文件。", + "remoteLink.child.skew.same": "与 Home 版本相同", + "remoteLink.child.skew.behind": "低于 Home ({version})", + "remoteLink.child.skew.ahead": "高于 Home", + "remoteLink.child.kind.desktopApp": "桌面应用", + "remoteLink.child.kind.goat": "GOAT", + "remoteLink.child.kind.source": "源码检出", + "remoteLink.child.updateAvailable": "有可用更新:{version}", + "remoteLink.child.catalog.current": "目录已是最新", + "remoteLink.child.catalog.behind": "目录已过期", + "remoteLink.child.codex.restartNeeded": "需要重启 Codex({count})", + "remoteLink.child.codex.notRunning": "Codex 未运行", + "remoteLink.child.ownerAction.legacy-ocx": "请在此子设备上手动更新 OpenCodex", + "remoteLink.child.ownerAction.desktop-app-owned": "请在此子设备的 OpenCodex Desktop 中使用“检查更新”", + "remoteLink.child.ownerAction.goat": "GOAT 会保持此子设备为最新", + "remoteLink.child.ownerAction.launcher-node-missing": "此子设备缺少 Node.js;请安装后在该设备上更新", + "remoteLink.child.ownerAction.launcher-bypassed": "此子设备的启动器绕过了 OpenCodex 的更新程序;请手动更新", + "remoteLink.child.ownerAction.mise-managed": "此子设备的 OpenCodex 由 mise 管理;请通过 mise 更新", + "remoteLink.child.ownerAction.source-checkout": "此子设备从源码检出运行;请手动更新", + "remoteLink.child.ownerAction.installer-unknown": "此子设备的安装方式未知;请在该设备上更新", + "remoteLink.child.check": "检查", + "remoteLink.child.checkAll": "全部检查", + "remoteLink.child.syncNow": "立即同步", + "remoteLink.child.restartCodex": "重启 Codex", + "remoteLink.child.update": "更新", + "remoteLink.child.updateAll": "全部更新", + "remoteLink.child.updateConfirm": "将 {alias} 更新到 {version}?该子设备上的 OpenCodex 会重启,从而结束当前为其转发的任何回合。自动更新会等到子设备空闲时进行。", + "remoteLink.child.updateAllConfirm": "将 {count} 个子设备逐一更新到 {version}。每个子设备的 OpenCodex 都会重启,从而结束当前为其转发的任何回合。自动更新会等到子设备空闲时进行。", + "remoteLink.child.upToDate": "已是最新", + "remoteLink.child.disconnectBlocked": "请等待当前操作完成", + "remoteLink.restart.scope.appServer": "Codex 应用服务器", + "remoteLink.restart.scope.appServerBody": "重启 Codex 后台应用服务器,使其加载新目录。Codex 桌面应用自身的应用服务器不会刷新;如需刷新,请重启桌面应用。", + "remoteLink.restart.scope.codex": "Codex 桌面应用", + "remoteLink.restart.scope.codexBody": "在 {alias} 上退出并重新启动 Codex 桌面应用。打开的会话会结束。这需要该计算机上有已登录的桌面会话。", + "remoteLink.restart.confirm": "重启", + "remoteLink.job.active.observe": "检查中…", + "remoteLink.job.active.sync": "同步中…", + "remoteLink.job.active.restartCodex": "正在重启 Codex…", + "remoteLink.job.active.update": "更新中…", + "remoteLink.job.state.queued": "排队中", + "remoteLink.job.state.running": "运行中", + "remoteLink.job.state.succeeded": "成功", + "remoteLink.job.state.upToDate": "已是最新", + "remoteLink.job.state.busy": "忙碌", + "remoteLink.job.state.offline": "离线", + "remoteLink.job.state.refused": "已拒绝", + "remoteLink.job.state.ownerAction": "需要所有者操作", + "remoteLink.job.state.unsupported": "不支持", + "remoteLink.job.state.failed": "失败", + "remoteLink.job.state.uncertain": "结果未知", + "remoteLink.job.restart.requested": "已请求重启", + "remoteLink.job.restart.unsupported": "不支持重启", + "remoteLink.job.restart.notRun": "未执行重启", + "remoteLink.job.restart.skipped": "重启暂缓:该子设备上有请求正在处理。", + "remoteLink.job.sync.synced": "已同步", + "remoteLink.job.sync.skipped": "已跳过同步", + "remoteLink.job.sync.failed": "同步失败", + "remoteLink.job.sync.timedOut": "同步超时", + "remoteLink.job.last": "上次{op}:{state}", + "remoteLink.job.notFound": "此 Home 已重启,因此操作进度已丢失。下面的行显示每个子设备的当前状态。", + "remoteLink.job.automatic": "自动", + "remoteLink.policy.title": "子设备自动化", + "remoteLink.policy.catalogFanout": "目录分发", + "remoteLink.policy.catalogFanout.auto": "自动", + "remoteLink.policy.catalogFanout.manual": "手动", + "remoteLink.policy.childCodexRestart": "Codex 重启", + "remoteLink.policy.childCodexRestart.never": "从不", + "remoteLink.policy.childCodexRestart.whenIdle": "空闲时重启应用服务器", + "remoteLink.policy.updates": "更新", + "remoteLink.policy.updates.off": "关闭", + "remoteLink.policy.updates.notify": "仅通知", + "remoteLink.policy.updates.autoWhenIdle": "空闲时自动", + "remoteLink.policy.idleMinutes": "空闲判定(分钟)", + "remoteLink.policy.save": "保存", + "remoteLink.policy.saved": "已保存", + "remoteLink.policy.updatesInactive": "目前只会保存设置;自动更新将在更新批处理发布后开始,因此现在还不会自行更新。", + "remoteLink.policy.invalid": "策略文件已损坏,因此自动目录分发和自动更新已关闭。请重新选择每个自动模式并保存以重新开启。", + "remoteLink.error.invalid_job_id": "操作 ID 无效。", + "remoteLink.error.job_not_found": "该操作已不存在;Home 可能已重启。", + "remoteLink.error.link_operation_in_progress": "该子设备上已有其他操作正在运行。", + "remoteLink.error.no_eligible_links": "没有可用于此操作的 Home 发起的子设备。", + "remoteLink.error.policy_write_failed": "无法保存子设备策略。", + "remoteLink.error.runtime_role_client": "此计算机是子设备;只有 Home 才能操作子设备。", + "remoteLink.error.link_not_home_initiated": "此子设备是自行连接的,请在其自己的计算机上操作。", }; From c816d4cf72b14c795ca9dcec170de9faf885131d Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 10:03:50 +0900 Subject: [PATCH 04/26] test(gui): wp6 child-ops coverage green --- gui/tests/remote-link-child-ops.test.tsx | 111 +++++++++++++++-------- 1 file changed, 73 insertions(+), 38 deletions(-) diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index c95a9d2baa7..bd1c2228589 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -1,8 +1,9 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import type { Root } from "react-dom/client"; -import { act } from "react"; +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 { parseChildJob, parseChildOps } from "../src/remote-link-child-ops-api"; @@ -89,6 +90,16 @@ async function mount(): Promise { 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}`); @@ -112,6 +123,13 @@ function openSelect(host: HTMLElement, index: number): HTMLButtonElement { 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(); @@ -168,14 +186,14 @@ test("Restart Codex picks a scope and states the app-server caveat", async () => }); const host = await mount(); await click(button(host, "Restart Codex")); - const dialog = host.querySelector(".remote-link-confirm-dialog")!; - expect(dialog.querySelectorAll("input[type=radio]")[0]?.hasAttribute("checked")).toBe(true as unknown as boolean); + 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 = host.querySelector(".remote-link-confirm-dialog")!; + 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")!); @@ -205,7 +223,7 @@ test("Update and Update all confirm the disruption and post their bodies", async const firstRow = host.querySelectorAll(".remote-link-row")[0]!; await click([...firstRow.querySelectorAll("button")].find(item => item.textContent === "Update")!); - const rowDialog = firstRow.querySelector("dialog")!; + 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")!); @@ -231,16 +249,29 @@ test("a running job shows per-item progress and refreshes status once when done" expect(panel.textContent).toContain("Running"); const statusReads = calls.filter(call => call.path.includes("/api/link/status")).length; await pollJobs(); - await settleUntil(() => host.querySelector(".remote-link-job")?.textContent?.includes("Succeeded") ?? false); - const finished = host.querySelector(".remote-link-job")!; - expect(finished.textContent).toContain("Succeeded"); - expect(finished.textContent).toContain("Synced"); - expect(finished.textContent).toContain("Restart requested"); - await settleUntil(() => calls.filter(call => call.path.includes("/api/link/status")).length > statusReads); - expect(calls.filter(call => call.path.includes("/api/link/status")).length).toBe(statusReads + 1); + // 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())])); @@ -252,13 +283,20 @@ test("a job_not_found answer stops polling and marks running items uncertain", a 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."); - const polls = calls.filter(call => call.path.startsWith("/api/link/children/jobs/")).length; - await pollJobs(); - expect(calls.filter(call => call.path.startsWith("/api/link/children/jobs/")).length).toBe(polls); }); test("a 409 link_operation_in_progress is shown translated", async () => { @@ -279,7 +317,7 @@ test("an active job holds the row and its progress is adopted on load", async () 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", state: "done", items: [] }) }); + 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(); @@ -350,35 +388,32 @@ test("a damaged policy file warns and a save writes every knob", async () => { }); test("a withheld restart says a request was in flight", 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({ 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 }] }) }, 202); - return response({ error: { code: "not_found" } }, 404); - }); - const host = await mount(); - await click(button(host, "Restart Codex")); - await click([...host.querySelectorAll(".remote-link-confirm-dialog button")].find(item => item.textContent === "Restart")!); + 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 restart outcome and an unknown last-operation state render as code", async () => { - installFetch(call => { - if (call.path.startsWith("/api/link/status")) return response(status([row("l1", "box-one", childBlock({ - lastOperation: { op: "update", jobId: "ljob_0123456789abcdef", state: "melted", reason: null, fromVersion: null, toVersion: null, finishedAt: null }, - }))])); - if (call.path === "/api/link/children/operations") return response({ job: job({ state: "done", items: [{ linkId: "l1", alias: "box-one", state: "succeeded", reason: null, fromVersion: null, toVersion: null, restart: { requested: "app-server", outcome: "done" }, startedAt: null, finishedAt: null }] }) }, 202); - return response({ error: { code: "not_found" } }, 404); - }); +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.querySelector("code")?.textContent).toBe("melted"); - await click(button(host, "Restart Codex")); - await click([...host.querySelectorAll(".remote-link-confirm-dialog button")].find(item => item.textContent === "Restart")!); - const panel = host.querySelector(".remote-link-job")!; - expect(panel.querySelector("code")?.textContent).toBe("done"); - expect(panel.textContent).not.toContain("Restart requested"); + expect([...badges.querySelectorAll("code")].map(item => item.textContent)).toContain("melted"); + // The plan's `remoteLink.job.last` reuses the active label with the ellipsis trimmed. + expect(badges.textContent).toContain("Last Updating: 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("parsers reject a malformed child block and keep an unknown job state", () => { From 75513621e954a0c93135157fda10cc96e4dcf74a Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 10:18:57 +0900 Subject: [PATCH 05/26] test(gui): wp6 route scan, zh-TW allowlist --- gui/tests/locale-parity.test.ts | 3 +++ gui/tests/remote-link.test.tsx | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) 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.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 From 5fc694ca0912ed9552355a16dfe05fa16971166a Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 10:32:14 +0900 Subject: [PATCH 06/26] docs(remote-link): wp6 child versions and actions section --- .../src/content/docs/guides/remote-link.md | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index e7c963cccf8..c7b2938e453 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -230,12 +230,36 @@ From the Home you can also sync a Home-initiated Child, restart Codex on it, and - `ocx link restart-codex --link-id --yes` syncs first and then restarts the Child's Codex app-servers, so a running Codex loads the current catalog. `--scope codex` also quits and relaunches the Codex desktop app, which needs a signed-in desktop session on the Child. `ocx link sync --link-id ` with `--restart-app-server-only` or `--restart-codex --yes` does the same. A restart ends the Codex turns running on that Child, so it names exactly one link. It needs 2.93.0 or later. - `ocx link update (--link-id | --all) --yes` brings the Child's OpenCodex to this Home's version, never to another one, through the Child's own updater. The Home first confirms that the Child still belongs to this link, then asks the Child for an update plan, applies it with a deadline counted on the Child's own clock, and syncs again after a successful, failed or rolled-back apply. An apply whose result is unknown is neither retried nor resynced; check that Child with `ocx link check`. npm and pnpm installs are updated through their Node launcher and Bun installs through Bun; `ocx link check` on an npm or pnpm Child therefore also runs `node --version` there, read-only. Updates need 2.97.0 or later on the Child; an older Child receives no update command and reports `owner-action` (`legacy-ocx`). Updates run one Child at a time and restart the Child's OpenCodex, which ends the requests the Home is relaying for it, so `--yes` is required. -The Home does not restart Codex on a Child while it is relaying a model request for that Child. `ocx sync --restart-children` (or `--restart-children=codex --yes`) applies the same rule when a Home sync fans out, to every Child or to those named with `--child `: each Child is synced, and only its restart is skipped (`restart-skipped:link-active`) while a relayed request is in flight. If a request is already in flight when `ocx link restart-codex` starts, nothing is sent and the item is `busy` (`restart-skipped:link-active`). If one starts during the sync that precedes the restart, or the restart was requested with `ocx link sync`, the sync still runs and only the restart is skipped (`restart-skipped:link-active`). This protection is best-effort: a request that starts between that check and the restart can still be ended, so pause new requests on that Child when its turns must survive. A Child older than 2.69 is seen as busy only once its response starts for a non-streaming request, so a restart can still end such a request. Windows Children support `sync`; restarts and updates from the Home are refused there (`windows-restart`, `windows-apply`). `ocx-child` Children report restarts and updates as unsupported. One operation runs on a link at a time. Removing a link while an operation runs on it is refused with `409 link_operation_in_progress`; remove it once the operation has finished. While a link is being removed, a new operation that names only that link is refused with the same 409, and a job that names several links reports that link's item as `busy` (`link-operation-in-progress`) while its other links still run. +The Home does not restart Codex on a Child while it is relaying a model request for that Child. `ocx sync --restart-children` (or `--restart-children=codex --yes`) applies the same rule when a Home sync fans out, to every Child or to those named with `--child `: each Child is synced, and only its restart is skipped (`restart-skipped:link-active`) while a relayed request is in flight. If a request is already in flight when `ocx link restart-codex` starts, nothing is sent and the item is `busy` (`restart-skipped:link-active`). If one starts during the sync that precedes the restart, or the restart was requested with `ocx link sync`, the sync still runs and only the restart is skipped (`restart-skipped:link-active`). This protection is best-effort: a request that starts between that check and the restart can still be ended, so pause new requests on that Child when its turns must survive. The Home also cannot tell whether Codex is in the middle of a turn that is running a tool rather than a model request, so let active Codex turns on that Child finish before restarting Codex or updating OpenCodex there. A manual update restarts that Child's OpenCodex and ends any turn still relayed for it; automatic updates wait until the Child is idle. A Child older than 2.69 is seen as busy only once its response starts for a non-streaming request, so a restart can still end such a request. Windows Children support `sync`; restarts and updates from the Home are refused there (`windows-restart`, `windows-apply`). `ocx-child` Children report restarts and updates as unsupported. One operation runs on a link at a time. Removing a link while an operation runs on it is refused with `409 link_operation_in_progress`; remove it once the operation has finished. While a link is being removed, a new operation that names only that link is refused with the same 409, and a job that names several links reports that link's item as `busy` (`link-operation-in-progress`) while its other links still run. -The commands exit 0 when every Child succeeded or was already up to date (with `--no-wait`, 0 only means the job was accepted; it may still be running. The printed job carries a `jobId`; read the result with `GET /api/link/children/jobs/` on the Home, using the admin token on loopback. Finished jobs stay readable for one hour, at most 32 of them, and are lost when the Home restarts, so read the result promptly), 6 when a Child needs attention (busy, offline, refused, an owner action, unsupported, a skipped restart, or an update that was kept but whose follow-up sync failed; run `ocx link sync` for that link), 1 on a failure or an unconfirmed result (check the Child again with `ocx link check` before retrying), 4 for an unknown link or a Home without these commands, and 5 when another operation is running on that link, the only link named was started from the Child, the Home has no links, or the command ran on a Child; the error message names which. The CLI needs a Home bound to loopback; on any other Home these commands are refused (exit 1), and the dashboard has no controls for them. +The commands exit 0 when every Child succeeded or was already up to date (with `--no-wait`, 0 only means the job was accepted; it may still be running. The printed job carries a `jobId`; read the result with `GET /api/link/children/jobs/` on the Home, using the admin token on loopback. Finished jobs stay readable for one hour, at most 32 of them, and are lost when the Home restarts, so read the result promptly), 6 when a Child needs attention (busy, offline, refused, an owner action, unsupported, a skipped restart, or an update that was kept but whose follow-up sync failed; run `ocx link sync` for that link), 1 on a failure or an unconfirmed result (check the Child again with `ocx link check` before retrying), 4 for an unknown link or a Home without these commands, and 5 when another operation is running on that link, the only link named was started from the Child, the Home has no links, or the command ran on a Child; the error message names which. The CLI needs a Home bound to loopback; on any other Home these commands are refused (exit 1), and the dashboard's Remote Link page offers the same actions for a Home-initiated Child. Do not link a machine you do not own: the Home runs commands there with your SSH key. +### Child versions and actions + +On a Home, every Remote Link row carries what the Home last learned about that Child, and a Home-initiated row also carries the controls for it. The badges read: + +- the Child's version, with `Behind Home ()`, `Same version as Home` or `Newer than Home` beside it, plus `Update available: ` when an update can be applied. A Child started from its own dashboard shows its version without a skew badge, and an `ocx-child` Child always reads `unknown`. +- the install kind (`Desktop app`, `GOAT`, `Source checkout`), or the raw token in monospace when this release does not name it. +- `Catalog current` or `Catalog out of date`, `Codex restart needed ()` when Codex on the Child still holds an older catalog, and `Codex not running` when it has no app-server up. +- the last operation, as `Last : ` with its reason in monospace, and the running one while a job holds the link. + +When the Home cannot update that Child itself, the row says what to do instead: use Check for Updates in OpenCodex Desktop there, update by hand, through `mise`, or let GOAT do it. An unknown reason prints as a token rather than a guessed sentence. + +The four actions start the same job as the CLI, so they need the same versions on the Child: + +- **Check** reads the Child's install facts, like `ocx link check`. +- **Sync now** runs the pinned `ocx sync --expect-link` there, like `ocx link sync`. +- **Restart Codex** opens a scope choice. `Codex app-servers` restarts the Child's background app-servers so they load the current catalog; it does not refresh the Codex desktop app's own app-server, so restart the desktop app for that. `Codex desktop app` also quits and relaunches it, which ends the conversations open there. +- **Update** brings the Child's OpenCodex to this Home's version through the Child's own updater, like `ocx link update`. The confirmation says that the Child's OpenCodex restarts and any turn still relayed for it ends, and that automatic updates wait until a Child is idle. + +**Check all** and **Update all** act on every eligible Home-initiated link; Update all still runs one Child at a time and never targets another version. A running job shows one line per Child with its state, the version it moved from and to, its catalog sync result and its restart result. The panel disappears when the job finishes and the rows refresh; a job the Home no longer knows, because it restarted, says so and leaves each row's own state as the answer. While a job holds a link, its actions and Disconnect are disabled; the server refuses the request anyway. + +Below the list, the **Child automation** panel shows and edits the same policy file as `ocx link policy`: catalog fan-out (automatic by default), Codex restart (never by default), updates (automatic when idle by default, not yet active in this release) and the idle period. Saving writes only what you changed. If the policy file was damaged, the panel says that automatic catalog fan-out and automatic updates are off, and saving then writes every setting so the modes you chose are the ones that persist. These controls never bypass the server: it re-checks the policy, the link state and the Child's activity on every job. + +Do not link a machine you do not own: the Home runs these commands there with your SSH key. + ### Automatic catalog fan-out and the Child policy Catalog fan-out is on by default. When the Home's catalog changes (the catalog auto-refresh reports a change, or a check every 5 minutes notices a dashboard or management edit), the Home waits 60 seconds for edits to settle (at most 5 minutes after the first change). Then, with no action from you, it opens a background SSH session to each eligible Child and runs the same pinned `ocx sync --expect-link` that `ocx link sync` runs, without a Codex restart unless you enable one (`childCodexRestart`, below). A Child is eligible when the Home started its link, its tunnel is connected, no other operation runs on it, and the last sync this Home ran there predates the change; a Child that only downloaded the catalog still counts as behind. A Child that is relaying model requests is synced only when its Codex settings already match the Home's and its OpenCodex client runtime has restarted since those settings were applied, because otherwise the sync restarts that runtime; otherwise it is synced once it is idle. A failed sync is retried after 5 minutes, doubling up to 6 hours, and a Child older than 2.93.0 is not retried until it reports another version. These jobs look like any other job, with `trigger: "auto"`. A Home with no links runs none of this. From caa53fe30220535e0d653b8ef01d8821bce9adcd Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 11:11:18 +0900 Subject: [PATCH 07/26] docs(remote-link): translate wp6 child versions and actions --- .../src/content/docs/fr/guides/remote-link.md | 26 ++++++++++++++++++- .../src/content/docs/ja/guides/remote-link.md | 26 ++++++++++++++++++- .../src/content/docs/ko/guides/remote-link.md | 26 ++++++++++++++++++- .../src/content/docs/ru/guides/remote-link.md | 26 ++++++++++++++++++- .../src/content/docs/tr/guides/remote-link.md | 26 ++++++++++++++++++- .../content/docs/zh-cn/guides/remote-link.md | 26 ++++++++++++++++++- .../content/docs/zh-tw/guides/remote-link.md | 26 ++++++++++++++++++- 7 files changed, 175 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/remote-link.md b/docs-site/src/content/docs/fr/guides/remote-link.md index 74ac0f6b60f..ef44d1d73c5 100644 --- a/docs-site/src/content/docs/fr/guides/remote-link.md +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -98,7 +98,31 @@ Sur le Home, `ocx link check (--link-id | --all)` lit pour chaque enfant la ### Opérer les enfants depuis le Home -`ocx link sync`, `ocx link restart-codex` et `ocx link update` s’exécutent sur les enfants lancés par le Home via la connexion SSH du lien et affichent la tâche en JSON. `sync` exécute le `ocx sync --expect-link` épinglé (enfant 2.93.0 ou plus récent). `restart-codex` synchronise, puis redémarre les app-servers Codex de l’enfant pour qu’un Codex en cours charge le catalogue actuel ; `--scope codex` relance aussi l’application de bureau. Un redémarrage vise un seul lien ; `restart-codex` et `--restart-codex` exigent `--yes`, tandis que `ocx link sync --link-id --restart-app-server-only` redémarre les app-servers sans confirmation. Le redémarrage est ignoré (`restart-skipped:link-active`) tant que le Home relaie une requête de modèle pour cet enfant. Pour un enfant antérieur à 2.69, une requête non diffusée en flux n’est visible qu’au début de sa réponse. `update --yes` met l’OpenCodex de l’enfant à la version de ce Home, jamais à une autre, avec le propre outil de mise à jour de l’enfant (enfant 2.97.0 ou plus récent ; npm et pnpm passent par leur lanceur Node, si bien que `ocx link check` y exécute aussi `node --version` en lecture seule), un enfant à la fois, puis resynchronise après une application réussie, échouée ou annulée ; une application au résultat inconnu n’est ni relancée ni resynchronisée, vérifiez-la avec `ocx link check`. La mise à jour interrompt les requêtes relayées pour cet enfant. Les enfants Windows ne prennent en charge que `sync`, et les enfants `ocx-child` signalent redémarrages et mises à jour comme non pris en charge. La CLI exige un Home lié à loopback ; sur tout autre Home, ces opérations ne sont pas disponibles, et le tableau de bord ne les propose pas. Détails et codes de sortie : [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (en anglais). Cette protection est au mieux : une requête qui démarre entre la vérification et le redémarrage peut encore être interrompue ; suspendez les nouvelles requêtes sur cet enfant si ses tours doivent être préservés. `ocx sync --restart-children` (ou `--restart-children=codex --yes`) applique la même règle lors de la propagation d’une synchronisation du Home, à tous les enfants ou à ceux désignés par `--child ` : chaque enfant est synchronisé, et seul son redémarrage est sauté (`restart-skipped:link-active`) tant qu’une requête relayée est en cours. +`ocx link sync`, `ocx link restart-codex` et `ocx link update` s’exécutent sur les enfants lancés par le Home via la connexion SSH du lien et affichent la tâche en JSON. `sync` exécute le `ocx sync --expect-link` épinglé (enfant 2.93.0 ou plus récent). `restart-codex` synchronise, puis redémarre les app-servers Codex de l’enfant pour qu’un Codex en cours charge le catalogue actuel ; `--scope codex` relance aussi l’application de bureau. Un redémarrage vise un seul lien ; `restart-codex` et `--restart-codex` exigent `--yes`, tandis que `ocx link sync --link-id --restart-app-server-only` redémarre les app-servers sans confirmation. Le redémarrage est ignoré (`restart-skipped:link-active`) tant que le Home relaie une requête de modèle pour cet enfant. Pour un enfant antérieur à 2.69, une requête non diffusée en flux n’est visible qu’au début de sa réponse. `update --yes` met l’OpenCodex de l’enfant à la version de ce Home, jamais à une autre, avec le propre outil de mise à jour de l’enfant (enfant 2.97.0 ou plus récent ; npm et pnpm passent par leur lanceur Node, si bien que `ocx link check` y exécute aussi `node --version` en lecture seule), un enfant à la fois, puis resynchronise après une application réussie, échouée ou annulée ; une application au résultat inconnu n’est ni relancée ni resynchronisée, vérifiez-la avec `ocx link check`. La mise à jour interrompt les requêtes relayées pour cet enfant. Les enfants Windows ne prennent en charge que `sync`, et les enfants `ocx-child` signalent redémarrages et mises à jour comme non pris en charge. La CLI exige un Home lié à loopback ; sur tout autre Home, ces opérations ne sont pas disponibles, et la page Remote Link du tableau de bord propose les mêmes actions pour un enfant lancé par le Home. Détails et codes de sortie : [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (en anglais). Cette protection est au mieux : une requête qui démarre entre la vérification et le redémarrage peut encore être interrompue ; suspendez les nouvelles requêtes sur cet enfant si ses tours doivent être préservés. Le Home ne peut pas non plus savoir si Codex est au milieu d'un tour qui exécute un outil plutôt qu'une requête de modèle ; laissez donc les tours Codex actifs sur cet enfant se terminer avant de redémarrer Codex ou de mettre à jour OpenCodex là-bas. Une mise à jour manuelle redémarre l'OpenCodex de cet enfant et termine tout tour encore relayé pour lui ; les mises à jour automatiques attendent que l'enfant soit inactif. `ocx sync --restart-children` (ou `--restart-children=codex --yes`) applique la même règle lors de la propagation d’une synchronisation du Home, à tous les enfants ou à ceux désignés par `--child ` : chaque enfant est synchronisé, et seul son redémarrage est sauté (`restart-skipped:link-active`) tant qu’une requête relayée est en cours. + +### Versions et actions des enfants + +Sur le Home, chaque ligne Remote Link porte ce que le Home a appris en dernier sur cet enfant, et une ligne lancée par le Home porte aussi ses contrôles. Les badges indiquent : + +- la version de l'enfant, avec `Behind Home ()`, `Same version as Home` ou `Newer than Home` à côté, et `Update available: ` quand une mise à jour peut être appliquée. Un enfant lancé depuis son propre tableau de bord affiche sa version sans badge d'écart, et un enfant `ocx-child` affiche toujours `unknown`. +- le type d'installation (`Desktop app`, `GOAT`, `Source checkout`), ou le jeton brut en police à chasse fixe quand cette version ne le nomme pas. +- `Catalog current` ou `Catalog out of date`, `Codex restart needed ()` quand le Codex de l'enfant détient encore un catalogue plus ancien, et `Codex not running` quand aucun app-server n'y tourne. +- la dernière opération, sous la forme `Last : ` avec sa raison en police à chasse fixe, et celle en cours tant qu'une tâche occupe le lien. + +Quand le Home ne peut pas mettre cet enfant à jour lui-même, la ligne indique quoi faire à la place : utiliser Check for Updates dans OpenCodex Desktop sur cet enfant, mettre à jour à la main, via `mise`, ou laisser GOAT s'en charger. Une raison inconnue s'affiche comme un jeton, jamais comme une phrase devinée. + +Les quatre actions démarrent la même tâche que la CLI ; elles exigent donc les mêmes versions sur l'enfant : + +- **Check** lit les informations d'installation de l'enfant, comme `ocx link check`. +- **Sync now** y exécute le `ocx sync --expect-link` épinglé, comme `ocx link sync`. +- **Restart Codex** ouvre un choix de portée. `Codex app-servers` redémarre les app-servers d'arrière-plan de l'enfant pour qu'ils chargent le catalogue actuel ; cela ne rafraîchit pas l'app-server propre à l'application Codex de bureau, redémarrez donc cette application pour cela. `Codex desktop app` la quitte et la relance aussi, ce qui met fin aux conversations qui y sont ouvertes. +- **Update** amène l'OpenCodex de l'enfant à la version de ce Home avec le propre outil de mise à jour de l'enfant, comme `ocx link update`. La confirmation indique que l'OpenCodex de l'enfant redémarre et que tout tour encore relayé pour lui se termine, et que les mises à jour automatiques attendent que l'enfant soit inactif. + +**Check all** et **Update all** agissent sur tous les liens éligibles lancés par le Home ; Update all traite toujours un enfant à la fois et ne vise jamais une autre version. Une tâche en cours affiche une ligne par enfant avec son état, la version de départ et d'arrivée, le résultat de la synchronisation du catalogue et celui du redémarrage. Le panneau disparaît quand la tâche se termine et les lignes se rafraîchissent ; une tâche que le Home ne connaît plus, parce qu'il a redémarré, le dit et laisse l'état de chaque ligne répondre. Tant qu'une tâche occupe un lien, ses actions et Disconnect sont désactivés ; le serveur refuse de toute façon la requête. + +Sous la liste, le panneau **Child automation** affiche et modifie le même fichier de politique que `ocx link policy` : propagation du catalogue (automatique par défaut), redémarrage de Codex (jamais par défaut), mises à jour (automatiques quand inactif par défaut, pas encore actives dans cette version) et la période d'inactivité. L'enregistrement n'écrit que ce que vous avez changé. Si le fichier de politique était endommagé, le panneau indique que la propagation automatique du catalogue et les mises à jour automatiques sont désactivées, et l'enregistrement écrit alors tous les réglages pour que les modes que vous avez choisis soient ceux qui persistent. Ces contrôles ne contournent jamais le serveur : il revérifie la politique, l'état du lien et l'activité de l'enfant à chaque tâche. + +Ne liez pas une machine que vous ne possédez pas : le Home y exécute des commandes avec votre clé SSH. ### Propagation automatique du catalogue et politique des enfants diff --git a/docs-site/src/content/docs/ja/guides/remote-link.md b/docs-site/src/content/docs/ja/guides/remote-link.md index 9b6b45508cb..c9a32eecd87 100644 --- a/docs-site/src/content/docs/ja/guides/remote-link.md +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -98,7 +98,31 @@ Home で `ocx link check (--link-id | --all)` を実行すると、Home か ### Home から Child を操作する -`ocx link sync`、`ocx link restart-codex`、`ocx link update` は、Home から追加した Child に対してリンクの SSH 接続経由で実行され、ジョブを JSON で出力します。`sync` は固定された `ocx sync --expect-link` を実行します(Child は 2.93.0 以降)。`restart-codex` は同期してから Child の Codex app-server を再起動し、実行中の Codex に現在のカタログを読み込ませます。`--scope codex` はデスクトップアプリも再起動します。再起動は 1 つのリンクだけを指定します。`restart-codex` と `--restart-codex` には `--yes` が必要ですが、`ocx link sync --link-id --restart-app-server-only` は確認なしで app-server を再起動します。Home がその Child のモデルリクエストを中継している間は再起動を見送ります(`restart-skipped:link-active`)。2.69 より古い Child では、ストリーミングでないリクエストは応答が始まるまで検出されません。`update --yes` は Child 自身の更新機構で Child の OpenCodex をこの Home のバージョンに更新し、他のバージョンにはしません(Child は 2.97.0 以降。npm と pnpm は Node ランチャー経由で適用するため、`ocx link check` もそこで読み取り専用の `node --version` を実行します)。1 台ずつ処理し、適用が成功、失敗、ロールバックした後に再同期します。結果が不明な適用は再実行も再同期もしないため、`ocx link check` で確認してください。その Child に中継中のリクエストは終了します。Windows の Child は `sync` のみに対応し、`ocx-child` の Child は再起動と更新を非対応として報告します。CLI にはループバックにバインドされた Home が必要です。それ以外の Home ではこれらの操作は使えず、ダッシュボードにもその操作はありません。詳細と終了コードは [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英語)を参照してください。 この保護はベストエフォートです。確認から再起動までの間に始まったリクエストは終了することがあるため、ターンを残す必要がある場合はその Child で新しいリクエストを止めてください。 `ocx sync --restart-children`(または `--restart-children=codex --yes`)は、Home の同期を伝播するときに、すべての Child または `--child ` で指定した Child に同じ規則を適用します。各 Child は同期され、中継中のリクエストがある間は再起動だけがスキップされます(`restart-skipped:link-active`)。 +`ocx link sync`、`ocx link restart-codex`、`ocx link update` は、Home から追加した Child に対してリンクの SSH 接続経由で実行され、ジョブを JSON で出力します。`sync` は固定された `ocx sync --expect-link` を実行します(Child は 2.93.0 以降)。`restart-codex` は同期してから Child の Codex app-server を再起動し、実行中の Codex に現在のカタログを読み込ませます。`--scope codex` はデスクトップアプリも再起動します。再起動は 1 つのリンクだけを指定します。`restart-codex` と `--restart-codex` には `--yes` が必要ですが、`ocx link sync --link-id --restart-app-server-only` は確認なしで app-server を再起動します。Home がその Child のモデルリクエストを中継している間は再起動を見送ります(`restart-skipped:link-active`)。2.69 より古い Child では、ストリーミングでないリクエストは応答が始まるまで検出されません。`update --yes` は Child 自身の更新機構で Child の OpenCodex をこの Home のバージョンに更新し、他のバージョンにはしません(Child は 2.97.0 以降。npm と pnpm は Node ランチャー経由で適用するため、`ocx link check` もそこで読み取り専用の `node --version` を実行します)。1 台ずつ処理し、適用が成功、失敗、ロールバックした後に再同期します。結果が不明な適用は再実行も再同期もしないため、`ocx link check` で確認してください。その Child に中継中のリクエストは終了します。Windows の Child は `sync` のみに対応し、`ocx-child` の Child は再起動と更新を非対応として報告します。CLI にはループバックにバインドされた Home が必要です。それ以外の Home ではこれらの操作は使えず、ダッシュボードの Remote Link ページでは、Home から追加した Child に対して同じ操作を提供します。詳細と終了コードは [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英語)を参照してください。 この保護はベストエフォートです。確認から再起動までの間に始まったリクエストは終了することがあるため、ターンを残す必要がある場合はその Child で新しいリクエストを止めてください。 Home は、Codex がモデルリクエストではなくツールを実行しているターンの途中かどうかも判別できないため、その Child で Codex を再起動したり OpenCodex を更新したりする前に、実行中の Codex ターンが終わるのを待ってください。手動更新はその Child の OpenCodex を再起動し、その Child のために中継中のターンを終了します。自動更新は Child がアイドルになるまで待ちます。 `ocx sync --restart-children`(または `--restart-children=codex --yes`)は、Home の同期を伝播するときに、すべての Child または `--child ` で指定した Child に同じ規則を適用します。各 Child は同期され、中継中のリクエストがある間は再起動だけがスキップされます(`restart-skipped:link-active`)。 + +### Child のバージョンと操作 + +Home では、Remote Link の各行に Home がその Child について最後に把握した内容が表示され、Home から追加した行にはその Child を操作するボタンも表示されます。バッジの意味は次のとおりです。 + +- Child のバージョンと、その横の `Behind Home ()`、`Same version as Home`、`Newer than Home`。更新を適用できる場合は `Update available: ` も付きます。Child 自身のダッシュボードから開始したリンクはバージョンのみを表示し、バージョン差のバッジは付きません。`ocx-child` の Child は常に `unknown` と表示されます。 +- インストール種別(`Desktop app`、`GOAT`、`Source checkout`)。このリリースで名前のない種類は等幅のトークンで表示されます。 +- `Catalog current` または `Catalog out of date`、Child の Codex がまだ古いカタログを保持している場合は `Codex restart needed ()`、app-server が起動していない場合は `Codex not running`。 +- 最後の操作を `Last : ` として理由を等幅で示し、ジョブがリンクを占有している間は実行中の操作を示します。 + +Home がその Child を自分で更新できない場合、行には代わりに何をすべきかが表示されます。その Child の OpenCodex Desktop で Check for Updates を使う、手動で更新する、`mise` 経由で更新する、または GOAT に任せる、のいずれかです。不明な理由は推測した文ではなくトークンとして表示されます。 + +4 つの操作は CLI と同じジョブを開始するため、Child にも同じバージョンが必要です。 + +- **Check** は `ocx link check` と同様に Child のインストール情報を読み取ります。 +- **Sync now** は `ocx link sync` と同様に固定された `ocx sync --expect-link` をそこで実行します。 +- **Restart Codex** は範囲の選択を開きます。`Codex app-servers` は Child のバックグラウンド app-server を再起動して現在のカタログを読み込ませます。Codex デスクトップアプリ自身の app-server は更新されないため、その場合はデスクトップアプリを再起動してください。`Codex desktop app` はデスクトップアプリも終了して再起動し、そこで開いている会話が終了します。 +- **Update** は `ocx link update` と同様に、Child 自身の更新機構で Child の OpenCodex をこの Home のバージョンにします。確認には、Child の OpenCodex が再起動してその Child のために中継中のターンが終了すること、自動更新は Child がアイドルになるまで待つことが示されます。 + +**Check all** と **Update all** は対象となるすべての Home 起点リンクに作用します。Update all も 1 台ずつ実行し、別のバージョンを対象にすることはありません。実行中のジョブは Child ごとに 1 行で、状態、移動したバージョン(元と先)、カタログ同期の結果、再起動の結果を表示します。ジョブが終わるとパネルは消え、行が更新されます。Home がもう把握していないジョブ(Home が再起動した場合)はその旨を表示し、各行自身の状態が答えになります。ジョブがリンクを占有している間は、その行の操作と Disconnect は無効になります。サーバーもいずれにせよその要求を拒否します。 + +一覧の下にある **Child automation** パネルは、`ocx link policy` と同じポリシーファイルを表示・編集します。カタログ配信(既定は自動)、Codex の再起動(既定はなし)、更新(既定はアイドル時に自動、このリリースではまだ有効ではありません)、アイドル期間です。保存では変更した値だけを書き込みます。ポリシーファイルが破損している場合、パネルは自動カタログ配信と自動更新が無効であると表示し、その状態での保存は選択したモードがそのまま残るようすべての設定を書き込みます。これらのコントロールがサーバーを迂回することはありません。サーバーはジョブごとにポリシー、リンク状態、Child の活動を再確認します。 + +所有していないマシンはリンクしないでください。Home はあなたの SSH キーでそこにコマンドを実行します。 ### カタログの自動配信と Child ポリシー diff --git a/docs-site/src/content/docs/ko/guides/remote-link.md b/docs-site/src/content/docs/ko/guides/remote-link.md index cf9097f7ab6..b8f5e753f12 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -98,7 +98,31 @@ Home에서 `ocx link check (--link-id | --all)`를 실행하면 Home이 추 ### Home에서 Child 운영하기 -`ocx link sync`, `ocx link restart-codex`, `ocx link update`는 Home이 추가한 Child에서 링크의 SSH 연결로 실행되며 작업을 JSON으로 출력합니다. `sync`는 고정된 `ocx sync --expect-link`를 실행합니다(Child 2.93.0 이상). `restart-codex`는 동기화한 뒤 Child의 Codex app-server를 재시작해 실행 중인 Codex가 현재 카탈로그를 읽게 합니다. `--scope codex`는 데스크톱 앱도 다시 실행합니다. 재시작은 링크 하나만 지정합니다. `restart-codex`와 `--restart-codex`에는 `--yes`가 필요하지만 `ocx link sync --link-id --restart-app-server-only`는 확인 없이 app-server를 재시작합니다. Home이 그 Child의 모델 요청을 중계하는 동안에는 재시작을 건너뜁니다(`restart-skipped:link-active`). 2.69보다 오래된 Child에서는 스트리밍이 아닌 요청이 응답이 시작된 뒤에야 보입니다. `update --yes`는 Child 자체 업데이트 도구로 Child의 OpenCodex를 이 Home의 버전으로만 업데이트합니다(Child 2.97.0 이상. npm과 pnpm은 Node 런처로 적용하므로 `ocx link check`도 그곳에서 읽기 전용 `node --version`을 실행합니다). 한 번에 Child 하나씩 처리하며, 적용이 성공, 실패, 롤백된 뒤에는 다시 동기화합니다. 결과를 알 수 없는 적용은 다시 실행하거나 동기화하지 않으므로 `ocx link check`로 확인하세요. 그 Child로 중계 중인 요청은 끝납니다. Windows Child는 `sync`만 지원하고, `ocx-child` Child는 재시작과 업데이트를 지원하지 않는다고 보고합니다. CLI는 루프백에 바인딩된 Home이 필요합니다. 그렇지 않은 Home에서는 이 작업을 실행할 수 없으며, 대시보드에도 이 작업은 없습니다. 자세한 내용과 종료 코드는 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(영문)를 참고하세요. 이 보호는 최선의 노력일 뿐입니다. 확인과 재시작 사이에 시작된 요청은 끝날 수 있으므로, 턴을 보존해야 하면 그 Child에서 새 요청을 멈추세요. `ocx sync --restart-children`(또는 `--restart-children=codex --yes`)는 Home 동기화를 전파할 때 모든 Child 또는 `--child `로 지정한 Child에 같은 규칙을 적용합니다. 각 Child는 동기화되고, 중계 중인 요청이 있는 동안에는 재시작만 건너뜁니다(`restart-skipped:link-active`). +`ocx link sync`, `ocx link restart-codex`, `ocx link update`는 Home이 추가한 Child에서 링크의 SSH 연결로 실행되며 작업을 JSON으로 출력합니다. `sync`는 고정된 `ocx sync --expect-link`를 실행합니다(Child 2.93.0 이상). `restart-codex`는 동기화한 뒤 Child의 Codex app-server를 재시작해 실행 중인 Codex가 현재 카탈로그를 읽게 합니다. `--scope codex`는 데스크톱 앱도 다시 실행합니다. 재시작은 링크 하나만 지정합니다. `restart-codex`와 `--restart-codex`에는 `--yes`가 필요하지만 `ocx link sync --link-id --restart-app-server-only`는 확인 없이 app-server를 재시작합니다. Home이 그 Child의 모델 요청을 중계하는 동안에는 재시작을 건너뜁니다(`restart-skipped:link-active`). 2.69보다 오래된 Child에서는 스트리밍이 아닌 요청이 응답이 시작된 뒤에야 보입니다. `update --yes`는 Child 자체 업데이트 도구로 Child의 OpenCodex를 이 Home의 버전으로만 업데이트합니다(Child 2.97.0 이상. npm과 pnpm은 Node 런처로 적용하므로 `ocx link check`도 그곳에서 읽기 전용 `node --version`을 실행합니다). 한 번에 Child 하나씩 처리하며, 적용이 성공, 실패, 롤백된 뒤에는 다시 동기화합니다. 결과를 알 수 없는 적용은 다시 실행하거나 동기화하지 않으므로 `ocx link check`로 확인하세요. 그 Child로 중계 중인 요청은 끝납니다. Windows Child는 `sync`만 지원하고, `ocx-child` Child는 재시작과 업데이트를 지원하지 않는다고 보고합니다. CLI는 루프백에 바인딩된 Home이 필요합니다. 그렇지 않은 Home에서는 이 작업을 실행할 수 없으며, 대시보드의 Remote Link 페이지에서는 Home이 추가한 Child에 대해 같은 작업을 제공합니다. 자세한 내용과 종료 코드는 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(영문)를 참고하세요. 이 보호는 최선의 노력일 뿐입니다. 확인과 재시작 사이에 시작된 요청은 끝날 수 있으므로, 턴을 보존해야 하면 그 Child에서 새 요청을 멈추세요. Home은 Codex가 모델 요청이 아니라 도구를 실행하는 턴 중인지도 알 수 없으므로, 그 Child에서 Codex를 재시작하거나 OpenCodex를 업데이트하기 전에 진행 중인 Codex 턴이 끝나기를 기다리십시오. 수동 업데이트는 그 Child의 OpenCodex를 재시작하며 그 Child를 위해 아직 중계 중인 턴을 끝냅니다. 자동 업데이트는 Child가 유휴 상태가 될 때까지 기다립니다. `ocx sync --restart-children`(또는 `--restart-children=codex --yes`)는 Home 동기화를 전파할 때 모든 Child 또는 `--child `로 지정한 Child에 같은 규칙을 적용합니다. 각 Child는 동기화되고, 중계 중인 요청이 있는 동안에는 재시작만 건너뜁니다(`restart-skipped:link-active`). + +### Child 버전과 작업 + +Home에서는 모든 Remote Link 행에 Home이 그 Child에 대해 마지막으로 확인한 내용이 표시되며, Home이 시작한 행에는 그 Child를 제어하는 버튼도 함께 표시됩니다. 배지는 다음을 나타냅니다. + +- Child의 버전과 그 옆의 `Behind Home ()`, `Same version as Home`, `Newer than Home`, 그리고 업데이트를 적용할 수 있을 때의 `Update available: `. Child가 자체 대시보드에서 시작한 링크라면 버전만 표시되고 버전 차이 배지는 없으며, `ocx-child` Child는 항상 `unknown`으로 표시됩니다. +- 설치 종류(`Desktop app`, `GOAT`, `Source checkout`), 또는 이 릴리스에서 이름을 붙이지 않는 토큰은 고정폭 글꼴로 표시됩니다. +- `Catalog current` 또는 `Catalog out of date`, Child의 Codex가 아직 이전 카탈로그를 들고 있으면 `Codex restart needed ()`, app-server가 떠 있지 않으면 `Codex not running`. +- 마지막 작업을 `Last : `로 표시하고 그 사유는 고정폭으로 표시하며, 작업이 링크를 점유하는 동안에는 실행 중인 작업을 표시합니다. + +Home이 그 Child를 직접 업데이트할 수 없으면 행에 대신 무엇을 해야 하는지 표시됩니다. 그 Child의 OpenCodex Desktop에서 Check for Updates를 사용하거나, 직접 업데이트하거나, `mise`를 통해 업데이트하거나, GOAT가 처리하도록 두십시오. 알 수 없는 사유는 추측한 문장 대신 토큰으로 표시됩니다. + +네 가지 작업은 CLI와 같은 작업을 시작하므로 Child에도 같은 버전이 필요합니다. + +- **Check**는 `ocx link check`처럼 Child의 설치 정보를 읽습니다. +- **Sync now**는 `ocx link sync`처럼 고정된 `ocx sync --expect-link`를 실행합니다. +- **Restart Codex**는 범위를 선택하게 합니다. `Codex app-servers`는 Child의 백그라운드 app-server를 재시작해 현재 카탈로그를 읽게 하며, Codex 데스크톱 앱 자체의 app-server는 새로 고치지 않으므로 그 경우에는 데스크톱 앱을 재시작하십시오. `Codex desktop app`은 데스크톱 앱도 종료하고 다시 실행하며, 그곳에서 열린 대화가 끝납니다. +- **Update**는 `ocx link update`처럼 Child 자체 업데이트 도구로 Child의 OpenCodex를 이 Home의 버전으로 올립니다. 확인 문구에는 Child의 OpenCodex가 재시작되어 그 Child를 위해 아직 중계 중인 턴이 끝난다는 점과, 자동 업데이트는 Child가 유휴 상태가 될 때까지 기다린다는 점이 나옵니다. + +**Check all**과 **Update all**은 대상이 되는 모든 Home 시작 링크에 적용됩니다. Update all도 한 번에 Child 하나씩 실행하며 다른 버전을 대상으로 삼지 않습니다. 실행 중인 작업은 Child마다 한 줄로 상태, 옮겨 간 버전(이전과 이후), 카탈로그 동기화 결과, 재시작 결과를 보여 줍니다. 작업이 끝나면 패널이 사라지고 행이 새로 고쳐집니다. Home이 더 이상 알지 못하는 작업(Home이 재시작된 경우)은 그렇다고 표시하고 각 행의 상태를 답으로 남깁니다. 작업이 링크를 점유하는 동안에는 그 링크의 작업과 Disconnect가 비활성화됩니다. 서버도 어차피 그 요청을 거부합니다. + +목록 아래의 **Child automation** 패널은 `ocx link policy`와 같은 정책 파일을 보여 주고 편집합니다. 카탈로그 전파(기본값 자동), Codex 재시작(기본값 안 함), 업데이트(기본값 유휴 시 자동, 이 릴리스에서는 아직 활성화되지 않음), 유휴 기간이 있습니다. 저장할 때는 변경한 값만 씁니다. 정책 파일이 손상된 경우 패널은 자동 카탈로그 전파와 자동 업데이트가 꺼져 있다고 알리고, 그때 저장하면 선택한 모드가 그대로 유지되도록 모든 설정을 씁니다. 이 컨트롤은 서버를 우회하지 않습니다. 서버는 작업마다 정책, 링크 상태, Child 활동을 다시 확인합니다. + +소유하지 않은 컴퓨터는 링크하지 마십시오. Home은 여러분의 SSH 키로 그곳에서 명령을 실행합니다. ### 카탈로그 자동 전파와 Child 정책 diff --git a/docs-site/src/content/docs/ru/guides/remote-link.md b/docs-site/src/content/docs/ru/guides/remote-link.md index c463a8fef54..3949e3b859f 100644 --- a/docs-site/src/content/docs/ru/guides/remote-link.md +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -98,7 +98,31 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai ### Управление Child с Home -`ocx link sync`, `ocx link restart-codex` и `ocx link update` выполняются на Child, добавленных с Home, через SSH-соединение ссылки и выводят задание в формате JSON. `sync` запускает закреплённый `ocx sync --expect-link` (Child версии 2.93.0 или новее). `restart-codex` выполняет синхронизацию, а затем перезапускает app-server Codex на Child, чтобы работающий Codex загрузил текущий каталог; `--scope codex` также перезапускает настольное приложение. Перезапуск указывает ровно одну ссылку; `restart-codex` и `--restart-codex` требуют `--yes`, а `ocx link sync --link-id --restart-app-server-only` перезапускает app-server без подтверждения. Перезапуск пропускается (`restart-skipped:link-active`), пока Home ретранслирует запрос к модели для этого Child. У Child старше 2.69 непотоковый запрос виден только с начала ответа. `update --yes` обновляет OpenCodex на Child до версии этого Home, и ни до какой другой, собственным механизмом обновления Child (Child версии 2.97.0 или новее; установки npm и pnpm обновляются через свой Node-лаунчер, поэтому `ocx link check` там также выполняет `node --version` только для чтения), по одному Child за раз, и снова синхронизирует после успешного, неудачного или откатанного применения; применение с неизвестным результатом не повторяется и не синхронизируется, проверьте его через `ocx link check`. Запросы, ретранслируемые для этого Child, прерываются. Child на Windows поддерживают только `sync`, а Child `ocx-child` сообщают, что перезапуск и обновление не поддерживаются. CLI требует Home, привязанный к loopback; на любом другом Home эти операции недоступны, и в панели управления их нет. Подробности и коды выхода: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (на английском). Эта защита работает по возможности: запрос, начавшийся между проверкой и перезапуском, всё равно может быть прерван, поэтому приостановите новые запросы на этом Child, если его ходы нужно сохранить. `ocx sync --restart-children` (или `--restart-children=codex --yes`) применяет то же правило при рассылке синхронизации Home — ко всем Child или к указанным через `--child `: каждый Child синхронизируется, а пропускается только его перезапуск (`restart-skipped:link-active`), пока выполняется ретранслируемый запрос. +`ocx link sync`, `ocx link restart-codex` и `ocx link update` выполняются на Child, добавленных с Home, через SSH-соединение ссылки и выводят задание в формате JSON. `sync` запускает закреплённый `ocx sync --expect-link` (Child версии 2.93.0 или новее). `restart-codex` выполняет синхронизацию, а затем перезапускает app-server Codex на Child, чтобы работающий Codex загрузил текущий каталог; `--scope codex` также перезапускает настольное приложение. Перезапуск указывает ровно одну ссылку; `restart-codex` и `--restart-codex` требуют `--yes`, а `ocx link sync --link-id --restart-app-server-only` перезапускает app-server без подтверждения. Перезапуск пропускается (`restart-skipped:link-active`), пока Home ретранслирует запрос к модели для этого Child. У Child старше 2.69 непотоковый запрос виден только с начала ответа. `update --yes` обновляет OpenCodex на Child до версии этого Home, и ни до какой другой, собственным механизмом обновления Child (Child версии 2.97.0 или новее; установки npm и pnpm обновляются через свой Node-лаунчер, поэтому `ocx link check` там также выполняет `node --version` только для чтения), по одному Child за раз, и снова синхронизирует после успешного, неудачного или откатанного применения; применение с неизвестным результатом не повторяется и не синхронизируется, проверьте его через `ocx link check`. Запросы, ретранслируемые для этого Child, прерываются. Child на Windows поддерживают только `sync`, а Child `ocx-child` сообщают, что перезапуск и обновление не поддерживаются. CLI требует Home, привязанный к loopback; на любом другом Home эти операции недоступны, а страница Remote Link в панели управления предлагает те же действия для Child, добавленного с Home. Подробности и коды выхода: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (на английском). Эта защита работает по возможности: запрос, начавшийся между проверкой и перезапуском, всё равно может быть прерван, поэтому приостановите новые запросы на этом Child, если его ходы нужно сохранить. Home также не может определить, находится ли Codex посреди хода, выполняющего инструмент, а не запрос к модели, поэтому дождитесь завершения активных ходов Codex на этом Child, прежде чем перезапускать Codex или обновлять там OpenCodex. Обновление вручную перезапускает OpenCodex этого Child и завершает любой ещё ретранслируемый для него ход; автоматические обновления ждут, пока Child не станет простаивать. `ocx sync --restart-children` (или `--restart-children=codex --yes`) применяет то же правило при рассылке синхронизации Home — ко всем Child или к указанным через `--child `: каждый Child синхронизируется, а пропускается только его перезапуск (`restart-skipped:link-active`), пока выполняется ретранслируемый запрос. + +### Версии и действия Child + +На Home каждая строка Remote Link показывает последнее, что Home узнал об этом Child, а строка, добавленная с Home, ещё и элементы управления им. Значки означают: + +- версию Child и рядом `Behind Home ()`, `Same version as Home` или `Newer than Home`, а также `Update available: `, когда обновление можно применить. Child, ссылку которого начали с его собственной панели, показывает версию без значка расхождения, а Child `ocx-child` всегда показывает `unknown`. +- тип установки (`Desktop app`, `GOAT`, `Source checkout`) или необработанный токен моноширинным шрифтом, если этот выпуск его не называет. +- `Catalog current` или `Catalog out of date`, `Codex restart needed ()`, когда Codex на Child всё ещё держит более старый каталог, и `Codex not running`, когда app-server там не запущен. +- последнюю операцию в виде `Last : ` с причиной моноширинным шрифтом, а пока задание занимает связь — выполняющуюся. + +Когда Home не может обновить этот Child сам, строка говорит, что делать вместо этого: воспользоваться Check for Updates в OpenCodex Desktop на этом Child, обновить вручную, через `mise` или дать это сделать GOAT. Неизвестная причина выводится как токен, а не как выдуманная фраза. + +Четыре действия запускают то же задание, что и CLI, поэтому им нужны те же версии на Child: + +- **Check** читает сведения об установке Child, как `ocx link check`. +- **Sync now** запускает там закреплённый `ocx sync --expect-link`, как `ocx link sync`. +- **Restart Codex** открывает выбор области. `Codex app-servers` перезапускает фоновые app-server Child, чтобы они загрузили текущий каталог; app-server самого настольного приложения Codex при этом не обновляется, поэтому для него перезапустите приложение. `Codex desktop app` также закрывает и заново запускает настольное приложение, что завершает открытые там беседы. +- **Update** приводит OpenCodex на Child к версии этого Home собственным механизмом обновления Child, как `ocx link update`. Подтверждение сообщает, что OpenCodex Child перезапустится и любой ещё ретранслируемый для него ход завершится, а автоматические обновления ждут, пока Child не станет простаивать. + +**Check all** и **Update all** действуют на все подходящие ссылки, начатые с Home; Update all всё равно обрабатывает по одному Child за раз и никогда не нацеливается на другую версию. Выполняющееся задание показывает по строке на Child с его состоянием, версией до и после, результатом синхронизации каталога и результатом перезапуска. Когда задание завершается, панель исчезает, а строки обновляются; задание, которое Home больше не знает (потому что перезапустился), так и сообщает, и ответом остаётся состояние каждой строки. Пока задание занимает связь, её действия и Disconnect отключены; сервер в любом случае откажет в запросе. + +Под списком панель **Child automation** показывает и изменяет тот же файл политики, что и `ocx link policy`: рассылка каталога (по умолчанию автоматически), перезапуск Codex (по умолчанию никогда), обновления (по умолчанию автоматически в простое, в этом выпуске ещё не действуют) и период простоя. Сохранение записывает только изменённое. Если файл политики повреждён, панель сообщает, что автоматическая рассылка каталога и автоматические обновления выключены, и сохранение тогда записывает все параметры, чтобы выбранные режимы и остались. Эти элементы управления никогда не обходят сервер: он заново проверяет политику, состояние связи и активность Child для каждого задания. + +Не связывайте машину, которой не владеете: Home выполняет там команды вашим SSH-ключом. ### Автоматическая рассылка каталога и политика Child diff --git a/docs-site/src/content/docs/tr/guides/remote-link.md b/docs-site/src/content/docs/tr/guides/remote-link.md index b038d1fb832..e8cfca8bdb1 100644 --- a/docs-site/src/content/docs/tr/guides/remote-link.md +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -98,7 +98,31 @@ Home üzerinde `ocx link check (--link-id | --all)`, Home'un eklediği her ### Child'ları Home'dan yönetmek -`ocx link sync`, `ocx link restart-codex` ve `ocx link update`, Home'un eklediği Child'larda bağlantının SSH bağlantısı üzerinden çalışır ve işi JSON olarak yazdırır. `sync`, sabitlenmiş `ocx sync --expect-link` komutunu çalıştırır (Child 2.93.0 veya üstü). `restart-codex` önce eşitler, ardından çalışan Codex'in güncel kataloğu yüklemesi için Child'daki Codex app-server süreçlerini yeniden başlatır; `--scope codex` masaüstü uygulamasını da yeniden açar. Yeniden başlatma tek bir bağlantıyı hedefler; `restart-codex` ve `--restart-codex` için `--yes` gerekir, `ocx link sync --link-id --restart-app-server-only` ise app-server süreçlerini onay istemeden yeniden başlatır. Home o Child için bir model isteği aktarırken yeniden başlatma atlanır (`restart-skipped:link-active`). 2.69'dan eski bir Child'da akış olmayan bir istek ancak yanıtı başladığında görülür. `update --yes`, Child'ın kendi güncelleyicisiyle Child'ın OpenCodex sürümünü bu Home'un sürümüne getirir, asla başka bir sürüme değil (Child 2.97.0 veya üstü; npm ve pnpm kurulumları Node başlatıcısı üzerinden güncellenir, bu yüzden `ocx link check` orada salt okunur `node --version` da çalıştırır). Her seferinde bir Child güncellenir; başarılı, başarısız veya geri alınmış bir uygulamadan sonra yeniden eşitlenir. Sonucu bilinmeyen bir uygulama tekrarlanmaz ve eşitlenmez; `ocx link check` ile denetleyin. O Child için aktarılan istekler sona erer. Windows Child'ları yalnızca `sync` destekler; `ocx-child` Child'ları yeniden başlatmayı ve güncellemeyi desteklenmiyor olarak bildirir. CLI, loopback'e bağlı bir Home gerektirir; başka bir Home'da bu işlemler kullanılamaz ve panoda da bu işlemler yoktur. Ayrıntılar ve çıkış kodları: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (İngilizce). Bu koruma en iyi çaba esaslıdır: kontrol ile yeniden başlatma arasında başlayan bir istek yine de sonlandırılabilir; turların korunması gerekiyorsa o Child'da yeni istekleri duraklatın. `ocx sync --restart-children` (veya `--restart-children=codex --yes`), bir Home eşitlemesi dağıtılırken aynı kuralı tüm Child'lara ya da `--child ` ile belirtilenlere uygular: her Child eşitlenir ve aktarılan bir istek sürerken yalnızca yeniden başlatması atlanır (`restart-skipped:link-active`). +`ocx link sync`, `ocx link restart-codex` ve `ocx link update`, Home'un eklediği Child'larda bağlantının SSH bağlantısı üzerinden çalışır ve işi JSON olarak yazdırır. `sync`, sabitlenmiş `ocx sync --expect-link` komutunu çalıştırır (Child 2.93.0 veya üstü). `restart-codex` önce eşitler, ardından çalışan Codex'in güncel kataloğu yüklemesi için Child'daki Codex app-server süreçlerini yeniden başlatır; `--scope codex` masaüstü uygulamasını da yeniden açar. Yeniden başlatma tek bir bağlantıyı hedefler; `restart-codex` ve `--restart-codex` için `--yes` gerekir, `ocx link sync --link-id --restart-app-server-only` ise app-server süreçlerini onay istemeden yeniden başlatır. Home o Child için bir model isteği aktarırken yeniden başlatma atlanır (`restart-skipped:link-active`). 2.69'dan eski bir Child'da akış olmayan bir istek ancak yanıtı başladığında görülür. `update --yes`, Child'ın kendi güncelleyicisiyle Child'ın OpenCodex sürümünü bu Home'un sürümüne getirir, asla başka bir sürüme değil (Child 2.97.0 veya üstü; npm ve pnpm kurulumları Node başlatıcısı üzerinden güncellenir, bu yüzden `ocx link check` orada salt okunur `node --version` da çalıştırır). Her seferinde bir Child güncellenir; başarılı, başarısız veya geri alınmış bir uygulamadan sonra yeniden eşitlenir. Sonucu bilinmeyen bir uygulama tekrarlanmaz ve eşitlenmez; `ocx link check` ile denetleyin. O Child için aktarılan istekler sona erer. Windows Child'ları yalnızca `sync` destekler; `ocx-child` Child'ları yeniden başlatmayı ve güncellemeyi desteklenmiyor olarak bildirir. CLI, loopback'e bağlı bir Home gerektirir; başka bir Home'da bu işlemler kullanılamaz ve panonun Remote Link sayfası Home'un eklediği Child için aynı işlemleri sunar. Ayrıntılar ve çıkış kodları: [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home) (İngilizce). Bu koruma en iyi çaba esaslıdır: kontrol ile yeniden başlatma arasında başlayan bir istek yine de sonlandırılabilir; turların korunması gerekiyorsa o Child'da yeni istekleri duraklatın. Home ayrıca Codex'in model isteği yerine bir araç çalıştıran bir turun ortasında olup olmadığını bilemez; bu yüzden o Child'da Codex'i yeniden başlatmadan veya OpenCodex'i güncellemeden önce etkin Codex turlarının bitmesini bekleyin. Elle güncelleme o Child'ın OpenCodex'ini yeniden başlatır ve onun için hâlâ aktarılan her turu sona erdirir; otomatik güncellemeler Child boşta olana kadar bekler. `ocx sync --restart-children` (veya `--restart-children=codex --yes`), bir Home eşitlemesi dağıtılırken aynı kuralı tüm Child'lara ya da `--child ` ile belirtilenlere uygular: her Child eşitlenir ve aktarılan bir istek sürerken yalnızca yeniden başlatması atlanır (`restart-skipped:link-active`). + +### Child sürümleri ve işlemleri + +Home üzerinde her Remote Link satırı, Home'un o Child hakkında son öğrendiğini taşır; Home'un başlattığı bir satır ayrıca onun denetimlerini taşır. Rozetler şunları gösterir: + +- Child'ın sürümü ve yanında `Behind Home ()`, `Same version as Home` veya `Newer than Home`, ayrıca güncelleme uygulanabiliyorsa `Update available: `. Kendi panosundan başlatılan bir Child sürümünü sürüm farkı rozeti olmadan gösterir ve `ocx-child` bir Child her zaman `unknown` okur. +- kurulum türü (`Desktop app`, `GOAT`, `Source checkout`) veya bu sürüm adlandırmıyorsa ham belirteç eş aralıklı yazıyla. +- `Catalog current` veya `Catalog out of date`, Child'daki Codex hâlâ daha eski bir katalog tutuyorsa `Codex restart needed ()` ve orada app-server çalışmıyorsa `Codex not running`. +- son işlemi `Last : ` olarak, nedeni eş aralıklı yazıyla; bir iş bağlantıyı tutarken de çalışanı. + +Home o Child'ı kendisi güncelleyemediğinde satır bunun yerine ne yapılacağını söyler: o Child'daki OpenCodex Desktop'ta Check for Updates kullanın, elle güncelleyin, `mise` üzerinden güncelleyin veya GOAT yapsın. Bilinmeyen bir neden tahmin edilmiş bir cümle yerine belirteç olarak yazılır. + +Dört işlem CLI ile aynı işi başlatır, bu yüzden Child'da aynı sürümlere ihtiyaç duyar: + +- **Check**, `ocx link check` gibi Child'ın kurulum bilgilerini okur. +- **Sync now**, `ocx link sync` gibi sabitlenmiş `ocx sync --expect-link` komutunu orada çalıştırır. +- **Restart Codex** bir kapsam seçimi açar. `Codex app-servers` Child'ın arka plan app-server süreçlerini yeniden başlatır, böylece güncel kataloğu yükler; Codex masaüstü uygulamasının kendi app-server'ını yenilemez, bunun için masaüstü uygulamasını yeniden başlatın. `Codex desktop app` masaüstü uygulamasını da kapatıp yeniden açar; bu, orada açık konuşmaları sona erdirir. +- **Update**, `ocx link update` gibi Child'ın kendi güncelleyicisiyle Child'ın OpenCodex'ini bu Home'un sürümüne getirir. Onay, Child'ın OpenCodex'inin yeniden başlayacağını ve onun için hâlâ aktarılan her turun sona ereceğini, otomatik güncellemelerin ise Child boşta olana kadar beklediğini söyler. + +**Check all** ve **Update all** uygun tüm Home başlatmalı bağlantılara uygulanır; Update all yine de her seferinde bir Child çalıştırır ve asla başka bir sürümü hedeflemez. Çalışan bir iş, Child başına bir satırda durumunu, geçtiği sürümü (öncesi ve sonrası), katalog eşitleme sonucunu ve yeniden başlatma sonucunu gösterir. İş bitince panel kaybolur ve satırlar yenilenir; Home'un artık bilmediği bir iş (Home yeniden başladığı için) bunu söyler ve her satırın kendi durumu yanıt olur. Bir iş bir bağlantıyı tutarken o bağlantının işlemleri ve Disconnect devre dışıdır; sunucu isteği zaten reddeder. + +Listenin altındaki **Child automation** paneli, `ocx link policy` ile aynı politika dosyasını gösterir ve düzenler: katalog dağıtımı (varsayılan otomatik), Codex yeniden başlatma (varsayılan asla), güncellemeler (varsayılan boştayken otomatik, bu sürümde henüz etkin değil) ve boşta kalma süresi. Kaydetme yalnızca değiştirdiklerinizi yazar. Politika dosyası bozulmuşsa panel otomatik katalog dağıtımının ve otomatik güncellemelerin kapalı olduğunu söyler ve kaydetme o durumda tüm ayarları yazar, böylece seçtiğiniz modlar kalıcı olur. Bu denetimler sunucuyu asla atlamaz: sunucu her işte politikayı, bağlantı durumunu ve Child'ın etkinliğini yeniden denetler. + +Sahibi olmadığınız bir makineyi bağlamayın: Home orada komutları sizin SSH anahtarınızla çalıştırır. ### Otomatik katalog dağıtımı ve Child politikası diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-link.md b/docs-site/src/content/docs/zh-cn/guides/remote-link.md index 33e082ff128..60114661749 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-link.md @@ -98,7 +98,31 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai ### 从 Home 操作子机 -`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 通过链接的 SSH 连接在由 Home 添加的子机上运行,并以 JSON 输出任务。`sync` 运行固定的 `ocx sync --expect-link`(子机需 2.93.0 或更高版本)。`restart-codex` 先同步,再重启子机上的 Codex app-server,使正在运行的 Codex 加载当前目录;`--scope codex` 还会重新启动桌面应用。重启只能指定一个链接;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 无需确认即可重启 app-server。当 Home 正在为该子机中继模型请求时,重启会被跳过(`restart-skipped:link-active`)。对于早于 2.69 的子机,非流式请求要到响应开始后才能被看到。`update --yes` 使用子机自己的更新程序把子机的 OpenCodex 更新到本 Home 的版本,绝不会更新到其他版本(子机需 2.97.0 或更高版本;npm 和 pnpm 安装通过其 Node 启动器应用更新,因此 `ocx link check` 也会在那里以只读方式运行 `node --version`)。每次更新一台子机,应用成功、失败或回滚后会再次同步;结果未知的应用不会重试也不会同步,请用 `ocx link check` 检查。正在为该子机中继的请求会中断。Windows 子机只支持 `sync`,`ocx-child` 子机会将重启和更新报告为不支持。CLI 需要绑定到 loopback 的 Home;在其他 Home 上无法执行这些操作,仪表盘也不提供这些操作。详情和退出码见 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 此保护为尽力而为:在检查与重启之间开始的请求仍可能被结束,因此需要保留轮次时,请先暂停该子机上的新请求。 `ocx sync --restart-children`(或 `--restart-children=codex --yes`)在 Home 同步向外传播时,对所有子机或用 `--child ` 指定的子机应用同一规则:每台子机都会同步,只有在有中继请求进行时才跳过其重启(`restart-skipped:link-active`)。 +`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 通过链接的 SSH 连接在由 Home 添加的子机上运行,并以 JSON 输出任务。`sync` 运行固定的 `ocx sync --expect-link`(子机需 2.93.0 或更高版本)。`restart-codex` 先同步,再重启子机上的 Codex app-server,使正在运行的 Codex 加载当前目录;`--scope codex` 还会重新启动桌面应用。重启只能指定一个链接;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 无需确认即可重启 app-server。当 Home 正在为该子机中继模型请求时,重启会被跳过(`restart-skipped:link-active`)。对于早于 2.69 的子机,非流式请求要到响应开始后才能被看到。`update --yes` 使用子机自己的更新程序把子机的 OpenCodex 更新到本 Home 的版本,绝不会更新到其他版本(子机需 2.97.0 或更高版本;npm 和 pnpm 安装通过其 Node 启动器应用更新,因此 `ocx link check` 也会在那里以只读方式运行 `node --version`)。每次更新一台子机,应用成功、失败或回滚后会再次同步;结果未知的应用不会重试也不会同步,请用 `ocx link check` 检查。正在为该子机中继的请求会中断。Windows 子机只支持 `sync`,`ocx-child` 子机会将重启和更新报告为不支持。CLI 需要绑定到 loopback 的 Home;在其他 Home 上无法执行这些操作,仪表盘的 Remote Link 页面会为由 Home 添加的子机提供相同的操作。详情和退出码见 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 此保护为尽力而为:在检查与重启之间开始的请求仍可能被结束,因此需要保留轮次时,请先暂停该子机上的新请求。 Home 也无法判断 Codex 是否正处在运行工具而非模型请求的轮次中,因此在重启该子机上的 Codex 或更新其 OpenCodex 之前,请等待进行中的 Codex 轮次结束。手动更新会重启该子机的 OpenCodex,并结束仍为其中继的任何轮次;自动更新会等到子机空闲。 `ocx sync --restart-children`(或 `--restart-children=codex --yes`)在 Home 同步向外传播时,对所有子机或用 `--child ` 指定的子机应用同一规则:每台子机都会同步,只有在有中继请求进行时才跳过其重启(`restart-skipped:link-active`)。 + +### 子机版本与操作 + +在 Home 上,每个 Remote Link 行都会显示 Home 最后获知的该子机信息,由 Home 添加的行还带有对该子机的控件。徽标含义如下: + +- 子机版本,以及旁边的 `Behind Home ()`、`Same version as Home` 或 `Newer than Home`;可以应用更新时还会显示 `Update available: `。从子机自身仪表盘启动的链接只显示版本,没有版本差异徽标;`ocx-child` 子机始终显示 `unknown`。 +- 安装类型(`Desktop app`、`GOAT`、`Source checkout`),本版本未命名的类型以等宽字体显示原始标记。 +- `Catalog current` 或 `Catalog out of date`;子机上的 Codex 仍持有较旧目录时显示 `Codex restart needed ()`,没有运行 app-server 时显示 `Codex not running`。 +- 上一次操作,显示为 `Last : `,原因以等宽字体显示;任务占用该链接期间显示正在运行的操作。 + +当 Home 无法自行更新该子机时,该行会说明应改做什么:在该子机的 OpenCodex Desktop 中使用 Check for Updates、手动更新、通过 `mise` 更新,或交给 GOAT 处理。未知原因会以标记显示,而不是猜测的句子。 + +这四个操作启动与 CLI 相同的任务,因此子机也需要相同的版本: + +- **Check** 像 `ocx link check` 一样读取子机的安装信息。 +- **Sync now** 在该处像 `ocx link sync` 一样运行固定版本的 `ocx sync --expect-link`。 +- **Restart Codex** 会打开范围选择。`Codex app-servers` 重启子机的后台 app-server,使其加载当前目录;它不会刷新 Codex 桌面应用自身的 app-server,因此这种情况请重启桌面应用。`Codex desktop app` 还会退出并重新启动桌面应用,其中打开的对话会结束。 +- **Update** 像 `ocx link update` 一样,用子机自身的更新程序把子机的 OpenCodex 更新到本 Home 的版本。确认文字会说明子机的 OpenCodex 会重启、仍为该子机中继的任何轮次会结束,并且自动更新会等到子机空闲。 + +**Check all** 和 **Update all** 作用于所有符合条件的 Home 添加链接;Update all 仍然一次只处理一台子机,且绝不针对其他版本。正在运行的任务会为每台子机显示一行,包含状态、更新前后的版本、目录同步结果和重启结果。任务结束后面板消失,各行刷新;Home 已不再知道的任务(因为 Home 重启过)会如此说明,并以各行自身的状态作为答案。任务占用链接期间,该行的操作和 Disconnect 会被禁用;服务器本来也会拒绝该请求。 + +列表下方的 **Child automation** 面板显示并编辑与 `ocx link policy` 相同的策略文件:目录分发(默认自动)、Codex 重启(默认从不)、更新(默认空闲时自动,本版本尚未启用)以及空闲时长。保存时只写入你更改过的值。如果策略文件已损坏,面板会说明自动目录分发和自动更新已关闭,此时保存会写入全部设置,使你选择的模式得以保留。这些控件不会绕过服务器:服务器会在每个任务中重新检查策略、链接状态和子机活动。 + +不要链接你不拥有的机器:Home 会用你的 SSH 密钥在那里运行命令。 ### 目录自动分发与子机策略 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-link.md b/docs-site/src/content/docs/zh-tw/guides/remote-link.md index f87d243faa5..f0c20317f54 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-link.md @@ -98,7 +98,31 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai ### 從 Home 操作 Child -`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 會透過連結的 SSH 連線在由 Home 新增的 Child 上執行,並以 JSON 輸出工作。`sync` 執行固定的 `ocx sync --expect-link`(Child 需為 2.93.0 或更新版本)。`restart-codex` 會先同步,再重新啟動 Child 上的 Codex app-server,讓執行中的 Codex 載入目前的目錄;`--scope codex` 也會重新啟動桌面應用程式。重新啟動只能指定一個連結;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 不需確認即可重新啟動 app-server。當 Home 正在為該 Child 中繼模型請求時,會略過重新啟動(`restart-skipped:link-active`)。對於早於 2.69 的 Child,非串流請求要到回應開始後才看得到。`update --yes` 會使用 Child 自己的更新程式,把 Child 的 OpenCodex 更新到這個 Home 的版本,絕不會更新到其他版本(Child 需為 2.97.0 或更新版本;npm 和 pnpm 安裝透過其 Node 啟動器套用更新,因此 `ocx link check` 也會在那裡以唯讀方式執行 `node --version`)。一次更新一台 Child,套用成功、失敗或回復後會再次同步;結果未知的套用不會重試也不會同步,請用 `ocx link check` 檢查。正在為該 Child 中繼的請求會中斷。Windows Child 只支援 `sync`,`ocx-child` Child 會將重新啟動和更新回報為不支援。CLI 需要繫結到 loopback 的 Home;在其他 Home 上無法執行這些操作,儀表板也不提供這些操作。詳情與結束代碼請見 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 此保護僅為盡力而為:在檢查與重新啟動之間開始的請求仍可能被結束,因此需要保留回合時,請先暫停該 Child 上的新請求。 `ocx sync --restart-children`(或 `--restart-children=codex --yes`)在 Home 同步向外傳播時,對所有 Child 或以 `--child ` 指定的 Child 套用同一規則:每台 Child 都會同步,只有在有中繼請求進行時才略過其重新啟動(`restart-skipped:link-active`)。 +`ocx link sync`、`ocx link restart-codex` 和 `ocx link update` 會透過連結的 SSH 連線在由 Home 新增的 Child 上執行,並以 JSON 輸出工作。`sync` 執行固定的 `ocx sync --expect-link`(Child 需為 2.93.0 或更新版本)。`restart-codex` 會先同步,再重新啟動 Child 上的 Codex app-server,讓執行中的 Codex 載入目前的目錄;`--scope codex` 也會重新啟動桌面應用程式。重新啟動只能指定一個連結;`restart-codex` 和 `--restart-codex` 需要 `--yes`,而 `ocx link sync --link-id --restart-app-server-only` 不需確認即可重新啟動 app-server。當 Home 正在為該 Child 中繼模型請求時,會略過重新啟動(`restart-skipped:link-active`)。對於早於 2.69 的 Child,非串流請求要到回應開始後才看得到。`update --yes` 會使用 Child 自己的更新程式,把 Child 的 OpenCodex 更新到這個 Home 的版本,絕不會更新到其他版本(Child 需為 2.97.0 或更新版本;npm 和 pnpm 安裝透過其 Node 啟動器套用更新,因此 `ocx link check` 也會在那裡以唯讀方式執行 `node --version`)。一次更新一台 Child,套用成功、失敗或回復後會再次同步;結果未知的套用不會重試也不會同步,請用 `ocx link check` 檢查。正在為該 Child 中繼的請求會中斷。Windows Child 只支援 `sync`,`ocx-child` Child 會將重新啟動和更新回報為不支援。CLI 需要繫結到 loopback 的 Home;在其他 Home 上無法執行這些操作,儀表板的 Remote Link 頁面會為由 Home 新增的 Child 提供相同的操作。詳情與結束代碼請見 [Operating Children from the Home](/guides/remote-link/#operating-children-from-the-home)(英文)。 此保護僅為盡力而為:在檢查與重新啟動之間開始的請求仍可能被結束,因此需要保留回合時,請先暫停該 Child 上的新請求。 Home 也無法判斷 Codex 是否正處在執行工具而非模型請求的回合中,因此在重新啟動該 Child 上的 Codex 或更新其 OpenCodex 之前,請等待進行中的 Codex 回合結束。手動更新會重新啟動該 Child 的 OpenCodex,並結束仍為其轉送的任何回合;自動更新會等到 Child 閒置。 `ocx sync --restart-children`(或 `--restart-children=codex --yes`)在 Home 同步向外傳播時,對所有 Child 或以 `--child ` 指定的 Child 套用同一規則:每台 Child 都會同步,只有在有中繼請求進行時才略過其重新啟動(`restart-skipped:link-active`)。 + +### Child 版本與操作 + +在 Home 上,每個 Remote Link 列都會顯示 Home 最後得知的該 Child 資訊,由 Home 新增的列還帶有控制該 Child 的按鈕。徽章代表: + +- Child 的版本,以及旁邊的 `Behind Home ()`、`Same version as Home` 或 `Newer than Home`;可以套用更新時還會顯示 `Update available: `。從 Child 自身儀表板開始的連結只顯示版本,沒有版本差異徽章;`ocx-child` Child 一律顯示 `unknown`。 +- 安裝類型(`Desktop app`、`GOAT`、`Source checkout`),本版本未命名的類型以等寬字型顯示原始權杖。 +- `Catalog current` 或 `Catalog out of date`;Child 上的 Codex 仍持有較舊目錄時顯示 `Codex restart needed ()`,沒有執行 app-server 時顯示 `Codex not running`。 +- 上一次操作,顯示為 `Last : `,原因以等寬字型顯示;工作佔用該連結期間則顯示正在執行的操作。 + +當 Home 無法自行更新該 Child 時,該列會說明應該改做什麼:在該 Child 的 OpenCodex Desktop 中使用 Check for Updates、手動更新、透過 `mise` 更新,或交給 GOAT 處理。未知原因會以權杖顯示,而不是猜測的句子。 + +這四個操作會啟動與 CLI 相同的工作,因此 Child 也需要相同的版本: + +- **Check** 像 `ocx link check` 一樣讀取 Child 的安裝資訊。 +- **Sync now** 在該處像 `ocx link sync` 一樣執行固定版本的 `ocx sync --expect-link`。 +- **Restart Codex** 會開啟範圍選擇。`Codex app-servers` 重新啟動 Child 的背景 app-server,使其載入目前目錄;它不會重新整理 Codex 桌面應用程式本身的 app-server,因此這種情況請重新啟動桌面應用程式。`Codex desktop app` 也會結束並重新啟動桌面應用程式,其中開啟的對話會結束。 +- **Update** 像 `ocx link update` 一樣,用 Child 自身的更新程式把 Child 的 OpenCodex 更新到本 Home 的版本。確認文字會說明 Child 的 OpenCodex 會重新啟動、仍為該 Child 轉送的任何回合會結束,且自動更新會等到 Child 閒置。 + +**Check all** 和 **Update all** 作用於所有符合條件的 Home 新增連結;Update all 仍然一次只處理一台 Child,且絕不針對其他版本。執行中的工作會為每台 Child 顯示一列,包含狀態、更新前後的版本、目錄同步結果與重新啟動結果。工作結束後面板消失,各列重新整理;Home 已不再知道的工作(因為 Home 重新啟動過)會如此說明,並以各列自身的狀態作為答案。工作佔用連結期間,該列的動作與 Disconnect 會停用;伺服器本來也會拒絕該請求。 + +列表下方的 **Child automation** 面板顯示並編輯與 `ocx link policy` 相同的原則檔案:目錄分發(預設自動)、Codex 重新啟動(預設從不)、更新(預設閒置時自動,本版本尚未啟用)以及閒置時間。儲存時只寫入你變更過的值。如果原則檔案已損毀,面板會說明自動目錄分發與自動更新已關閉,此時儲存會寫入全部設定,使你選擇的模式得以保留。這些控制項不會繞過伺服器:伺服器會在每個工作中重新檢查原則、連結狀態與 Child 活動。 + +不要連結你不擁有的機器:Home 會用你的 SSH 金鑰在那裡執行命令。 ### 目錄自動分發與 Child 政策 From a38188e1d3149186f4810b000802a71c94761359 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 11:21:55 +0900 Subject: [PATCH 08/26] docs(structure): wp6 child versions, actions and policy panel --- structure/remote-link.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/structure/remote-link.md b/structure/remote-link.md index f0b57d1579d..d8b1f51841d 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 `failed`, 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. 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 `linkIds:"all"`; 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. While a job holds a link its actions and Disconnect are disabled, and 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. From 48d56bdf0a29fb13c36c0004ed55c004d7c6e801 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 11:52:42 +0900 Subject: [PATCH 09/26] fix(gui): wp6 lint clean child job polling --- gui/src/pages/RemoteLinkChildActions.tsx | 27 ++++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/gui/src/pages/RemoteLinkChildActions.tsx b/gui/src/pages/RemoteLinkChildActions.tsx index 9aaa2fe4b7b..dea15d96781 100644 --- a/gui/src/pages/RemoteLinkChildActions.tsx +++ b/gui/src/pages/RemoteLinkChildActions.tsx @@ -21,7 +21,7 @@ const ERROR_TKEY: Record = { runtime_role_client: "remoteLink.error.runtime_role_client", }; -export function linkActionError(error: unknown): LinkActionErrorView { +function linkActionError(error: unknown): LinkActionErrorView { const key = error instanceof LinkApiError && error.code in ERROR_TKEY ? ERROR_TKEY[error.code]! : "remoteLink.error.generic"; return { key, hint: error instanceof LinkApiError ? error.hint : null }; } @@ -253,12 +253,15 @@ export function ChildBulkBar(props: { apiBase: string; rows: readonly LinkRowWir ); } -export function useChildJob(apiBase: string, job: ChildJobView): { job: ChildJobView; missing: boolean } { - const [snapshot, setSnapshot] = useState(job); - const [missing, setMissing] = useState(false); +function useChildJob(apiBase: string, job: ChildJobView): { 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; useEffect(() => { - setSnapshot(job); - setMissing(false); if (job.state === "done") return; let stopped = false; const controller = new AbortController(); @@ -266,13 +269,13 @@ export function useChildJob(apiBase: string, job: ChildJobView): { job: ChildJob if (document.visibilityState === "hidden") return; try { const next = await getChildJob(apiBase, job.jobId, controller.signal); - if (!stopped) setSnapshot(next); + if (!stopped) setPolled({ jobId: job.jobId, value: 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); + setMissingFor(job.jobId); } } }; @@ -334,7 +337,13 @@ export function ChildPolicyPanel(props: { apiBase: string; busy: boolean; onBusy setRead(value); setDraft(value.policy); } catch (error) { if (!signal?.aborted) onError(linkActionError(error)); } }, [apiBase, onError]); - useEffect(() => { const controller = new AbortController(); void load(controller.signal); return () => controller.abort(); }, [load]); + // 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(() => { + const controller = new AbortController(); + void Promise.resolve().then(() => load(controller.signal)); + return () => controller.abort(); + }, [load]); if (!draft || !read) return null; const changed = changedPolicyKeys(read.policy, draft); const damaged = read.warning === "policy-file-invalid"; From afc57aaf53d6687f4e28479ef15370cff2cce1bf Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 11:54:26 +0900 Subject: [PATCH 10/26] fix(gui): wp6 code token child build badge --- gui/src/pages/RemoteLinkChildActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/pages/RemoteLinkChildActions.tsx b/gui/src/pages/RemoteLinkChildActions.tsx index dea15d96781..bc95321b80c 100644 --- a/gui/src/pages/RemoteLinkChildActions.tsx +++ b/gui/src/pages/RemoteLinkChildActions.tsx @@ -96,7 +96,7 @@ export function ChildRowBadges({ row }: { row: ChildOpsRowView }): ReactElement {version && v{version}} {kindKey && {t(kindKey)}} {row.installKind !== null && !kindKey && {row.installKind}} - {row.childBuild && ocx-child {row.childBuild}} + {row.childBuild && {`ocx-child ${row.childBuild}`}} {row.versionSkew === "same" && {t("remoteLink.child.skew.same")}} {row.versionSkew === "behind" && {t("remoteLink.child.skew.behind", { version: row.homeVersion })}} {row.versionSkew === "ahead" && {t("remoteLink.child.skew.ahead")}} From 3f1c9de3c79272967052a288f2d406f7e88425ce Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 11:59:25 +0900 Subject: [PATCH 11/26] test(gui): allow GOAT product name in French catalog --- gui/tests/fr-localization.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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", From c74e5620f4ae5218268b56a92c55074fe08911fe Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 12:08:34 +0900 Subject: [PATCH 12/26] fix(gui): name the last Child operation with a noun, not the in-progress label --- gui/src/i18n/de.ts | 6 +++++- gui/src/i18n/en.ts | 6 +++++- gui/src/i18n/fr.ts | 6 +++++- gui/src/i18n/ja.ts | 6 +++++- gui/src/i18n/ko.ts | 6 +++++- gui/src/i18n/pt.ts | 6 +++++- gui/src/i18n/ru.ts | 6 +++++- gui/src/i18n/tr.ts | 6 +++++- gui/src/i18n/vi.ts | 6 +++++- gui/src/i18n/zh-TW.ts | 6 +++++- gui/src/i18n/zh.ts | 6 +++++- gui/src/pages/RemoteLinkChildActions.tsx | 9 ++++++++- gui/tests/remote-link-child-ops.test.tsx | 4 ++-- 13 files changed, 65 insertions(+), 14 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index cd33708dbdb..f454dc86851 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -4415,7 +4415,11 @@ export const de: Record = { "remoteLink.job.sync.skipped": "Synchronisierung übersprungen", "remoteLink.job.sync.failed": "Synchronisierung fehlgeschlagen", "remoteLink.job.sync.timedOut": "Zeitüberschreitung bei der Synchronisierung", - "remoteLink.job.last": "Zuletzt {op}: {state}", + "remoteLink.job.last": "Letzter Vorgang: {op} — {state}", + "remoteLink.job.op.observe": "Prüfung", + "remoteLink.job.op.sync": "Synchronisierung", + "remoteLink.job.op.restartCodex": "Codex-Neustart", + "remoteLink.job.op.update": "Update", "remoteLink.job.notFound": "Dieses Home wurde neu gestartet, daher ist der Fortschritt des Vorgangs verloren. Die Zeilen unten zeigen den aktuellen Zustand jedes Child.", "remoteLink.job.automatic": "Automatisch", "remoteLink.policy.title": "Child-Automatisierung", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d32034724ed..9e67a191386 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -4449,7 +4449,11 @@ export const en = { "remoteLink.job.sync.skipped": "Sync skipped", "remoteLink.job.sync.failed": "Sync failed", "remoteLink.job.sync.timedOut": "Sync timed out", - "remoteLink.job.last": "Last {op}: {state}", + "remoteLink.job.last": "Last operation: {op} — {state}", + "remoteLink.job.op.observe": "Check", + "remoteLink.job.op.sync": "Sync", + "remoteLink.job.op.restartCodex": "Codex restart", + "remoteLink.job.op.update": "Update", "remoteLink.job.notFound": "This Home restarted, so the job's progress is gone. The rows below show each Child's current state.", "remoteLink.job.automatic": "Automatic", "remoteLink.policy.title": "Child automation", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 278dfcf132e..50a4b4dfb84 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4404,7 +4404,11 @@ export const fr: Record = { "remoteLink.job.sync.skipped": "Synchronisation ignorée", "remoteLink.job.sync.failed": "Échec de la synchronisation", "remoteLink.job.sync.timedOut": "Délai de synchronisation dépassé", - "remoteLink.job.last": "Dernière {op} : {state}", + "remoteLink.job.last": "Dernière opération : {op} — {state}", + "remoteLink.job.op.observe": "Vérification", + "remoteLink.job.op.sync": "Synchronisation", + "remoteLink.job.op.restartCodex": "Redémarrage de Codex", + "remoteLink.job.op.update": "Mise à jour", "remoteLink.job.notFound": "Ce Home a redémarré, donc la progression de l’opération est perdue. Les lignes ci-dessous montrent l’état actuel de chaque Enfant.", "remoteLink.job.automatic": "Automatique", "remoteLink.policy.title": "Automatisation des Enfants", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0ee2d88d837..0ceafe1df44 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4437,7 +4437,11 @@ export const ja: Record = { "remoteLink.job.sync.skipped": "同期をスキップ", "remoteLink.job.sync.failed": "同期に失敗", "remoteLink.job.sync.timedOut": "同期がタイムアウト", - "remoteLink.job.last": "最後の {op}: {state}", + "remoteLink.job.last": "最後の操作: {op} — {state}", + "remoteLink.job.op.observe": "確認", + "remoteLink.job.op.sync": "同期", + "remoteLink.job.op.restartCodex": "Codex の再起動", + "remoteLink.job.op.update": "更新", "remoteLink.job.notFound": "この Home が再起動したため、操作の進行状況は失われました。下の行に各 Child の現在の状態が表示されます。", "remoteLink.job.automatic": "自動", "remoteLink.policy.title": "Child の自動化", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 2d49ef93fee..93905f81711 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4437,7 +4437,11 @@ export const ko: Record = { "remoteLink.job.sync.skipped": "동기화 건너뜀", "remoteLink.job.sync.failed": "동기화 실패", "remoteLink.job.sync.timedOut": "동기화 시간 초과", - "remoteLink.job.last": "마지막 {op}: {state}", + "remoteLink.job.last": "마지막 작업: {op} — {state}", + "remoteLink.job.op.observe": "확인", + "remoteLink.job.op.sync": "동기화", + "remoteLink.job.op.restartCodex": "Codex 재시작", + "remoteLink.job.op.update": "업데이트", "remoteLink.job.notFound": "이 Home이 다시 시작되어 작업 진행 상황이 사라졌습니다. 아래 행에 각 Child의 현재 상태가 표시됩니다.", "remoteLink.job.automatic": "자동", "remoteLink.policy.title": "Child 자동화", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index cc88ba57311..bcffc9e16b1 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -4410,7 +4410,11 @@ export const pt: Record = { "remoteLink.job.sync.skipped": "Sincronização ignorada", "remoteLink.job.sync.failed": "Falha na sincronização", "remoteLink.job.sync.timedOut": "Tempo esgotado na sincronização", - "remoteLink.job.last": "Última {op}: {state}", + "remoteLink.job.last": "Última operação: {op} — {state}", + "remoteLink.job.op.observe": "Verificação", + "remoteLink.job.op.sync": "Sincronização", + "remoteLink.job.op.restartCodex": "Reinício do Codex", + "remoteLink.job.op.update": "Atualização", "remoteLink.job.notFound": "Este Home reiniciou, então o progresso da operação foi perdido. As linhas abaixo mostram o estado atual de cada Child.", "remoteLink.job.automatic": "Automático", "remoteLink.policy.title": "Automação de Children", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 4aaa850d0c2..1f21683fe43 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4438,7 +4438,11 @@ export const ru: Record = { "remoteLink.job.sync.skipped": "Синхронизация пропущена", "remoteLink.job.sync.failed": "Ошибка синхронизации", "remoteLink.job.sync.timedOut": "Время синхронизации истекло", - "remoteLink.job.last": "Последняя {op}: {state}", + "remoteLink.job.last": "Последняя операция: {op} — {state}", + "remoteLink.job.op.observe": "Проверка", + "remoteLink.job.op.sync": "Синхронизация", + "remoteLink.job.op.restartCodex": "Перезапуск Codex", + "remoteLink.job.op.update": "Обновление", "remoteLink.job.notFound": "Home перезапустился, поэтому ход операции утрачен. Строки ниже показывают текущее состояние каждого Child.", "remoteLink.job.automatic": "Автоматически", "remoteLink.policy.title": "Автоматизация Child", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index a1736f75859..2c2716ceb2b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -4438,7 +4438,11 @@ export const tr: Record = { "remoteLink.job.sync.skipped": "Eşitleme atlandı", "remoteLink.job.sync.failed": "Eşitleme başarısız", "remoteLink.job.sync.timedOut": "Eşitleme zaman aşımına uğradı", - "remoteLink.job.last": "Son {op}: {state}", + "remoteLink.job.last": "Son işlem: {op} — {state}", + "remoteLink.job.op.observe": "Kontrol", + "remoteLink.job.op.sync": "Eşitleme", + "remoteLink.job.op.restartCodex": "Codex yeniden başlatma", + "remoteLink.job.op.update": "Güncelleme", "remoteLink.job.notFound": "Bu Home yeniden başladı, bu yüzden işlemin ilerlemesi kayboldu. Aşağıdaki satırlar her Child’ın güncel durumunu gösterir.", "remoteLink.job.automatic": "Otomatik", "remoteLink.policy.title": "Child otomasyonu", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index aae34921baf..509f2318c9a 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -4407,7 +4407,11 @@ export const vi: Record = { "remoteLink.job.sync.skipped": "Bỏ qua đồng bộ", "remoteLink.job.sync.failed": "Đồng bộ thất bại", "remoteLink.job.sync.timedOut": "Đồng bộ quá thời gian", - "remoteLink.job.last": "Lần cuối {op}: {state}", + "remoteLink.job.last": "Thao tác gần nhất: {op} — {state}", + "remoteLink.job.op.observe": "Kiểm tra", + "remoteLink.job.op.sync": "Đồng bộ", + "remoteLink.job.op.restartCodex": "Khởi động lại Codex", + "remoteLink.job.op.update": "Cập nhật", "remoteLink.job.notFound": "Home này đã khởi động lại nên tiến trình thao tác đã mất. Các hàng bên dưới hiển thị trạng thái hiện tại của từng máy con.", "remoteLink.job.automatic": "Tự động", "remoteLink.policy.title": "Tự động hóa máy con", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 3b7d5997a45..41df24c99ab 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -4401,7 +4401,11 @@ export const zhTW: Record = { "remoteLink.job.sync.skipped": "已略過同步", "remoteLink.job.sync.failed": "同步失敗", "remoteLink.job.sync.timedOut": "同步逾時", - "remoteLink.job.last": "上次{op}:{state}", + "remoteLink.job.last": "上次操作:{op} — {state}", + "remoteLink.job.op.observe": "檢查", + "remoteLink.job.op.sync": "同步", + "remoteLink.job.op.restartCodex": "Codex 重新啟動", + "remoteLink.job.op.update": "更新", "remoteLink.job.notFound": "此 Home 已重新啟動,因此作業進度已遺失。下列列會顯示每個子裝置的目前狀態。", "remoteLink.job.automatic": "自動", "remoteLink.policy.title": "子裝置自動化", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cda2bbf1dce..351835983ac 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4436,7 +4436,11 @@ export const zh: Record = { "remoteLink.job.sync.skipped": "已跳过同步", "remoteLink.job.sync.failed": "同步失败", "remoteLink.job.sync.timedOut": "同步超时", - "remoteLink.job.last": "上次{op}:{state}", + "remoteLink.job.last": "上次操作:{op} — {state}", + "remoteLink.job.op.observe": "检查", + "remoteLink.job.op.sync": "同步", + "remoteLink.job.op.restartCodex": "Codex 重启", + "remoteLink.job.op.update": "更新", "remoteLink.job.notFound": "此 Home 已重启,因此操作进度已丢失。下面的行显示每个子设备的当前状态。", "remoteLink.job.automatic": "自动", "remoteLink.policy.title": "子设备自动化", diff --git a/gui/src/pages/RemoteLinkChildActions.tsx b/gui/src/pages/RemoteLinkChildActions.tsx index bc95321b80c..6e0be974af4 100644 --- a/gui/src/pages/RemoteLinkChildActions.tsx +++ b/gui/src/pages/RemoteLinkChildActions.tsx @@ -26,6 +26,13 @@ function linkActionError(error: unknown): LinkActionErrorView { return { key, hint: error instanceof LinkApiError ? error.hint : null }; } +const OP_NOUN_KEY: Record = { + observe: "remoteLink.job.op.observe", + sync: "remoteLink.job.op.sync", + "restart-codex": "remoteLink.job.op.restartCodex", + update: "remoteLink.job.op.update", +}; + const ACTIVE_KEY: Record = { observe: "remoteLink.job.active.observe", sync: "remoteLink.job.active.sync", @@ -110,7 +117,7 @@ export function ChildRowBadges({ row }: { row: ChildOpsRowView }): ReactElement {row.activeJob && {t(ACTIVE_KEY[row.activeJob.op])}} {row.lastOperation && ( - {t("remoteLink.job.last", { op: t(ACTIVE_KEY[row.lastOperation.op]).replace("…", ""), + {t("remoteLink.job.last", { op: t(OP_NOUN_KEY[row.lastOperation.op]), state: lastStateKey ? t(lastStateKey) : row.lastOperation.state })} {!lastStateKey && <> {row.lastOperation.state}} {row.lastOperation.reason && <> {row.lastOperation.reason}} diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index bd1c2228589..f4a6b5d16f1 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -403,8 +403,8 @@ test("an unknown last-operation state renders as code", async () => { const host = await mount(); const badges = host.querySelector(".remote-link-child-badges")!; expect([...badges.querySelectorAll("code")].map(item => item.textContent)).toContain("melted"); - // The plan's `remoteLink.job.last` reuses the active label with the ellipsis trimmed. - expect(badges.textContent).toContain("Last Updating: 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 () => { From 995b2168e69f5f2bf33259a757195e5ec26aca41 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 13:10:46 +0900 Subject: [PATCH 13/26] fix(gui): wp6 bulk actions name their exact links --- gui/src/pages/RemoteLinkChildActions.tsx | 8 ++++++-- gui/tests/remote-link-child-ops.test.tsx | 25 +++++++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/gui/src/pages/RemoteLinkChildActions.tsx b/gui/src/pages/RemoteLinkChildActions.tsx index 6e0be974af4..fc2cc07218b 100644 --- a/gui/src/pages/RemoteLinkChildActions.tsx +++ b/gui/src/pages/RemoteLinkChildActions.tsx @@ -240,6 +240,10 @@ export function ChildBulkBar(props: { apiBase: string; rows: readonly LinkRowWir 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. + const ids = (rows: readonly LinkRowWire[]) => rows.map(row => row.id); const run = async (body: ChildOperationBody) => { props.onError(null); props.onRun({ linkId: "all", op: body.op }); @@ -249,13 +253,13 @@ export function ChildBulkBar(props: { apiBase: string; rows: readonly LinkRowWir }; return (
- + setConfirming(null)} - onConfirm={() => { setConfirming(null); void run({ op: "update", linkIds: "all" }); }} /> + onConfirm={() => { setConfirming(null); void run({ op: "update", linkIds: ids(updatable) }); }} />
); } diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index f4a6b5d16f1..07d5b6cb5a9 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -219,7 +219,7 @@ test("Update and Update all confirm the disruption and post their bodies", async 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: "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")!); @@ -230,6 +230,29 @@ test("Update and Update all confirm the disruption and post their bodies", async 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("a running job shows per-item progress and refreshes status once when done", async () => { let jobPolls = 0; const calls = installFetch(call => { From 8969a99d3ab34b43ce2fb14922924063c8c93198 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 13:14:17 +0900 Subject: [PATCH 14/26] fix(gui): wp6 tracked job holds the row before the next poll --- gui/src/pages/RemoteLink.tsx | 9 ++++--- gui/src/remote-link-child-ops-api.ts | 5 ++++ gui/tests/remote-link-child-ops.test.tsx | 30 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index 8a544f6f10f..57c3bd44abb 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -14,7 +14,7 @@ import { LinkIntro, LinkStatus, LinkSwitchRow, Monogram, RoleCard } from "../com import { RemoteLinkDisconnectDialog } from "./RemoteLinkDialogs"; import { RemoteLinkDiagnostics } from "./RemoteLinkDiagnostics"; import { LinkErrorNotice, RemoteLinkSheet, type LinkActionErrorView } from "./RemoteLinkSheet"; -import { getChildJob, type ChildJobView, type ChildOp } from "../remote-link-child-ops-api"; +import { getChildJob, jobHoldsLink, type ChildJobView, type ChildOp } from "../remote-link-child-ops-api"; import { ChildBulkBar, ChildJobProgress, ChildPolicyPanel, ChildRowActions, ChildRowBadges } from "./RemoteLinkChildActions"; import "../styles-remote-link.css"; @@ -448,6 +448,9 @@ export default function RemoteLink({ apiBase, diagnosticsApiBase = apiBase, sess }; const statusRows = status?.links ?? []; + // A job accepted by this page disables its links' actions and Disconnect at once. The status row's + // activeJob only appears on the next poll, so the tracked childJobs are the earlier signal. + const heldLinks = new Set(statusRows.filter(row => jobHoldsLink(childJobs, row.id)).map(row => row.id)); // A job the page did not start itself: running before a reload, in another tab, or started by the scheduler. const trackJob = useCallback((job: ChildJobView) => { if (childJobIds.current.has(job.jobId)) return; @@ -583,11 +586,11 @@ export default function RemoteLink({ apiBase, diagnosticsApiBase = apiBase, sess {row.child && row.direction === "hub-initiated" && ( - setChildOpBusy(open ? { linkId: row.id, op: "observe" } : null)} onRun={setChildOpBusy} /> )} - diff --git a/gui/src/remote-link-child-ops-api.ts b/gui/src/remote-link-child-ops-api.ts index bd994e688fc..8ea9e71a48e 100644 --- a/gui/src/remote-link-child-ops-api.ts +++ b/gui/src/remote-link-child-ops-api.ts @@ -142,6 +142,11 @@ export function rowOperable(direction: "hub-initiated" | "client-initiated", row } /** "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 keys the operator changed, so a save never rewrites a mode they left alone (Revision 6: full PUT over damage). */ diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index 07d5b6cb5a9..00c877ab040 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -354,6 +354,36 @@ test("an active job holds the row and its progress is adopted on load", async () 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(); From 886cd60ada927e8b8bb7da80cc29d3685bc37672 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 13:17:49 +0900 Subject: [PATCH 15/26] fix(gui): wp6 restart dialog reopens at the app-server default --- gui/src/pages/RemoteLinkChildActions.tsx | 42 ++++++++++++++---------- gui/tests/remote-link-child-ops.test.tsx | 17 ++++++++++ 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/gui/src/pages/RemoteLinkChildActions.tsx b/gui/src/pages/RemoteLinkChildActions.tsx index fc2cc07218b..be37882fe69 100644 --- a/gui/src/pages/RemoteLinkChildActions.tsx +++ b/gui/src/pages/RemoteLinkChildActions.tsx @@ -154,10 +154,32 @@ function ChildConfirmDialog(props: { open: boolean; title: string; body: string; ); } -export function RestartCodexDialog(props: { alias: string; open: boolean; onCancel(): void; +function RestartCodexBody(props: { alias: string; onCancel(): void; onConfirm(scope: "app-server" | "codex"): void }): ReactElement { const t = useT(); + // The body unmounts with the dialog, so every open starts from the per-click default scope. const [scope, setScope] = useState<"app-server" | "codex">("app-server"); + return <> +
+ + + +
+
+ + +
+ ; +} + +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; @@ -168,23 +190,7 @@ export function RestartCodexDialog(props: { alias: string; open: boolean; onCanc 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 && <> -
- - - -
-
- - -
- } + {props.open && }
); } diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index 00c877ab040..d905af66331 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -200,6 +200,23 @@ test("Restart Codex picks a scope and states the app-server caveat", async () => 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([ From 37a15c708a4c01a180fbcf4bb6da3d0861ab3797 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 13:20:25 +0900 Subject: [PATCH 16/26] fix(gui): wp6 job progress renders parsed item hints --- gui/src/pages/RemoteLinkChildActions.tsx | 1 + gui/tests/remote-link-child-ops.test.tsx | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/gui/src/pages/RemoteLinkChildActions.tsx b/gui/src/pages/RemoteLinkChildActions.tsx index be37882fe69..f06df8b62b5 100644 --- a/gui/src/pages/RemoteLinkChildActions.tsx +++ b/gui/src/pages/RemoteLinkChildActions.tsx @@ -328,6 +328,7 @@ export function ChildJobProgress(props: { apiBase: string; job: ChildJobView; on {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}} diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index d905af66331..8add4056792 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -486,6 +486,17 @@ test("an unknown restart outcome renders as code instead of a guessed sentence", 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(); From ed5f668ccf4a65c438b8933dc33a0d3b8b354fd2 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 13:32:08 +0900 Subject: [PATCH 17/26] fix(gui): wp6 policy load failure offers Retry and page Refresh re-reads it --- gui/src/i18n/de.ts | 3 +- gui/src/i18n/en.ts | 3 +- gui/src/i18n/fr.ts | 3 +- gui/src/i18n/ja.ts | 3 +- gui/src/i18n/ko.ts | 3 +- gui/src/i18n/pt.ts | 3 +- gui/src/i18n/ru.ts | 3 +- gui/src/i18n/tr.ts | 3 +- gui/src/i18n/vi.ts | 3 +- gui/src/i18n/zh-TW.ts | 3 +- gui/src/i18n/zh.ts | 3 +- gui/src/pages/RemoteLink.tsx | 6 ++-- gui/src/pages/RemoteLinkChildActions.tsx | 29 +++++++++++++---- gui/tests/remote-link-child-ops.test.tsx | 41 ++++++++++++++++++++++++ 14 files changed, 89 insertions(+), 20 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index f454dc86851..7fccc8146dc 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -4437,7 +4437,8 @@ export const de: Record = { "remoteLink.policy.save": "Speichern", "remoteLink.policy.saved": "Gespeichert", "remoteLink.policy.updatesInactive": "Wird jetzt nur gespeichert; automatische Updates beginnen, sobald der Update-Lauf ausgeliefert ist, daher aktualisiert sich noch nichts von selbst.", - "remoteLink.policy.invalid": "Die Richtliniendatei war beschädigt, daher sind die automatische Katalog-Verteilung und automatische Updates aus. Wählen Sie jeden automatischen Modus erneut und speichern Sie, um sie wieder einzuschalten.", + "remoteLink.policy.loadFailed": "Child-Automatisierungseinstellungen konnten nicht geladen werden.", +"remoteLink.policy.invalid": "Die Richtliniendatei war beschädigt, daher sind die automatische Katalog-Verteilung und automatische Updates aus. Wählen Sie jeden automatischen Modus erneut und speichern Sie, um sie wieder einzuschalten.", "remoteLink.error.invalid_job_id": "Die Vorgangs-ID ist ungültig.", "remoteLink.error.job_not_found": "Der Vorgang ist nicht mehr bekannt; das Home wurde möglicherweise neu gestartet.", "remoteLink.error.link_operation_in_progress": "Auf diesem Child läuft bereits ein anderer Vorgang.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9e67a191386..62596e41cc9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -4471,7 +4471,8 @@ export const en = { "remoteLink.policy.save": "Save", "remoteLink.policy.saved": "Saved", "remoteLink.policy.updatesInactive": "Stored now; automatic updates start when the update sweep ships, so nothing updates on its own yet.", - "remoteLink.policy.invalid": "The policy file was damaged, so automatic catalog fan-out and automatic updates are off. Choose each automatic mode again and save to turn them back on.", + "remoteLink.policy.loadFailed": "Could not load Child automation settings.", +"remoteLink.policy.invalid": "The policy file was damaged, so automatic catalog fan-out and automatic updates are off. Choose each automatic mode again and save to turn them back on.", "remoteLink.error.invalid_job_id": "The operation id is invalid.", "remoteLink.error.job_not_found": "The operation is no longer known; the Home may have restarted.", "remoteLink.error.link_operation_in_progress": "Another operation is running on this Child.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 50a4b4dfb84..f611a97e5c7 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4426,7 +4426,8 @@ export const fr: Record = { "remoteLink.policy.save": "Enregistrer", "remoteLink.policy.saved": "Enregistré", "remoteLink.policy.updatesInactive": "Enregistré pour l’instant ; les mises à jour automatiques commenceront quand le balayage des mises à jour sera livré, donc rien ne se met à jour tout seul pour le moment.", - "remoteLink.policy.invalid": "Le fichier de politique était endommagé : la diffusion automatique du catalogue et les mises à jour automatiques sont désactivées. Choisissez à nouveau chaque mode automatique et enregistrez pour les réactiver.", + "remoteLink.policy.loadFailed": "Impossible de charger les paramètres d'automatisation des Enfants.", +"remoteLink.policy.invalid": "Le fichier de politique était endommagé : la diffusion automatique du catalogue et les mises à jour automatiques sont désactivées. Choisissez à nouveau chaque mode automatique et enregistrez pour les réactiver.", "remoteLink.error.invalid_job_id": "L’identifiant de l’opération est invalide.", "remoteLink.error.job_not_found": "L’opération n’est plus connue ; le Home a peut-être redémarré.", "remoteLink.error.link_operation_in_progress": "Une autre opération est en cours sur cet Enfant.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0ceafe1df44..ef5e57186e5 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4459,7 +4459,8 @@ export const ja: Record = { "remoteLink.policy.save": "保存", "remoteLink.policy.saved": "保存しました", "remoteLink.policy.updatesInactive": "現在は設定を保存するだけです。自動更新は更新スイープの提供開始後に動き出すため、まだ自動では更新されません。", - "remoteLink.policy.invalid": "ポリシーファイルが破損しているため、カタログの自動配布と自動更新はオフです。各自動モードを選び直して保存すると再びオンになります。", + "remoteLink.policy.loadFailed": "Child の自動化設定を読み込めませんでした。", +"remoteLink.policy.invalid": "ポリシーファイルが破損しているため、カタログの自動配布と自動更新はオフです。各自動モードを選び直して保存すると再びオンになります。", "remoteLink.error.invalid_job_id": "操作 ID が正しくありません。", "remoteLink.error.job_not_found": "操作が見つかりません。Home が再起動した可能性があります。", "remoteLink.error.link_operation_in_progress": "この Child では別の操作が実行中です。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 93905f81711..118e7da728a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4459,7 +4459,8 @@ export const ko: Record = { "remoteLink.policy.save": "저장", "remoteLink.policy.saved": "저장됨", "remoteLink.policy.updatesInactive": "지금은 설정만 저장됩니다. 자동 업데이트는 업데이트 일괄 처리가 출시된 뒤에 시작되므로 아직 스스로 업데이트하지 않습니다.", - "remoteLink.policy.invalid": "정책 파일이 손상되어 자동 카탈로그 배포와 자동 업데이트가 꺼져 있습니다. 각 자동 모드를 다시 선택하고 저장해야 다시 켜집니다.", + "remoteLink.policy.loadFailed": "Child 자동화 설정을 불러오지 못했습니다.", +"remoteLink.policy.invalid": "정책 파일이 손상되어 자동 카탈로그 배포와 자동 업데이트가 꺼져 있습니다. 각 자동 모드를 다시 선택하고 저장해야 다시 켜집니다.", "remoteLink.error.invalid_job_id": "작업 ID가 올바르지 않습니다.", "remoteLink.error.job_not_found": "작업을 찾을 수 없습니다. Home이 재시작되었을 수 있습니다.", "remoteLink.error.link_operation_in_progress": "이 Child에서 다른 작업이 진행 중입니다.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index bcffc9e16b1..16b2147c652 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -4432,7 +4432,8 @@ export const pt: Record = { "remoteLink.policy.save": "Salvar", "remoteLink.policy.saved": "Salvo", "remoteLink.policy.updatesInactive": "Apenas armazenado por enquanto; as atualizações automáticas começam quando a varredura de atualizações for lançada, então nada se atualiza sozinho ainda.", - "remoteLink.policy.invalid": "O arquivo de política estava danificado, então a distribuição automática do catálogo e as atualizações automáticas estão desativadas. Escolha cada modo automático novamente e salve para reativá-los.", + "remoteLink.policy.loadFailed": "Não foi possível carregar as configurações de automação de Children.", +"remoteLink.policy.invalid": "O arquivo de política estava danificado, então a distribuição automática do catálogo e as atualizações automáticas estão desativadas. Escolha cada modo automático novamente e salve para reativá-los.", "remoteLink.error.invalid_job_id": "O id da operação é inválido.", "remoteLink.error.job_not_found": "A operação não é mais conhecida; o Home pode ter reiniciado.", "remoteLink.error.link_operation_in_progress": "Outra operação está em execução neste Child.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 1f21683fe43..91d5ec8e960 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4460,7 +4460,8 @@ export const ru: Record = { "remoteLink.policy.save": "Сохранить", "remoteLink.policy.saved": "Сохранено", "remoteLink.policy.updatesInactive": "Пока только сохраняется; автоматические обновления начнут работать после выпуска цикла обновлений, поэтому пока ничего не обновляется само.", - "remoteLink.policy.invalid": "Файл политики был повреждён, поэтому автоматическое распространение каталога и автоматические обновления отключены. Выберите каждый автоматический режим заново и сохраните, чтобы включить их.", + "remoteLink.policy.loadFailed": "Не удалось загрузить настройки автоматизации Child.", +"remoteLink.policy.invalid": "Файл политики был повреждён, поэтому автоматическое распространение каталога и автоматические обновления отключены. Выберите каждый автоматический режим заново и сохраните, чтобы включить их.", "remoteLink.error.invalid_job_id": "Идентификатор операции недопустим.", "remoteLink.error.job_not_found": "Операция больше не известна; возможно, Home перезапустился.", "remoteLink.error.link_operation_in_progress": "На этом Child уже выполняется другая операция.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 2c2716ceb2b..d67fc93be59 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -4460,7 +4460,8 @@ export const tr: Record = { "remoteLink.policy.save": "Kaydet", "remoteLink.policy.saved": "Kaydedildi", "remoteLink.policy.updatesInactive": "Şimdilik yalnızca saklanır; otomatik güncellemeler güncelleme taraması yayınlandığında başlar, bu yüzden henüz kendi kendine güncellenmez.", - "remoteLink.policy.invalid": "İlke dosyası bozulmuş, bu yüzden otomatik katalog dağıtımı ve otomatik güncellemeler kapalı. Her otomatik modu yeniden seçip kaydedin, böylece yeniden açılsın.", + "remoteLink.policy.loadFailed": "Child otomasyon ayarları yüklenemedi.", +"remoteLink.policy.invalid": "İlke dosyası bozulmuş, bu yüzden otomatik katalog dağıtımı ve otomatik güncellemeler kapalı. Her otomatik modu yeniden seçip kaydedin, böylece yeniden açılsın.", "remoteLink.error.invalid_job_id": "İşlem kimliği geçersiz.", "remoteLink.error.job_not_found": "İşlem artık bilinmiyor; Home yeniden başlamış olabilir.", "remoteLink.error.link_operation_in_progress": "Bu Child üzerinde başka bir işlem çalışıyor.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 509f2318c9a..2b6e4dfff33 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -4429,7 +4429,8 @@ export const vi: Record = { "remoteLink.policy.save": "Lưu", "remoteLink.policy.saved": "Đã lưu", "remoteLink.policy.updatesInactive": "Hiện chỉ lưu cài đặt; cập nhật tự động sẽ bắt đầu khi đợt quét cập nhật được phát hành, nên hiện chưa có gì tự cập nhật.", - "remoteLink.policy.invalid": "Tệp chính sách đã bị hỏng nên phân phối danh mục tự động và cập nhật tự động đang tắt. Hãy chọn lại từng chế độ tự động và lưu để bật lại.", + "remoteLink.policy.loadFailed": "Không thể tải cài đặt tự động hóa máy con.", +"remoteLink.policy.invalid": "Tệp chính sách đã bị hỏng nên phân phối danh mục tự động và cập nhật tự động đang tắt. Hãy chọn lại từng chế độ tự động và lưu để bật lại.", "remoteLink.error.invalid_job_id": "ID thao tác không hợp lệ.", "remoteLink.error.job_not_found": "Không còn biết thao tác này; có thể Home đã khởi động lại.", "remoteLink.error.link_operation_in_progress": "Đang có thao tác khác chạy trên máy con này.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 41df24c99ab..8a59e01dc5c 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -4423,7 +4423,8 @@ export const zhTW: Record = { "remoteLink.policy.save": "儲存", "remoteLink.policy.saved": "已儲存", "remoteLink.policy.updatesInactive": "目前只會儲存設定;自動更新會在更新批次作業推出後開始,因此目前還不會自行更新。", - "remoteLink.policy.invalid": "原則檔案已損毀,因此自動目錄散佈與自動更新已關閉。請重新選擇每個自動模式並儲存,以重新開啟。", + "remoteLink.policy.loadFailed": "無法載入子裝置自動化設定。", +"remoteLink.policy.invalid": "原則檔案已損毀,因此自動目錄散佈與自動更新已關閉。請重新選擇每個自動模式並儲存,以重新開啟。", "remoteLink.error.invalid_job_id": "作業 ID 無效。", "remoteLink.error.job_not_found": "已找不到該作業;Home 可能已重新啟動。", "remoteLink.error.link_operation_in_progress": "此子裝置上已有其他作業正在執行。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 351835983ac..659c511cebe 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4458,7 +4458,8 @@ export const zh: Record = { "remoteLink.policy.save": "保存", "remoteLink.policy.saved": "已保存", "remoteLink.policy.updatesInactive": "目前只会保存设置;自动更新将在更新批处理发布后开始,因此现在还不会自行更新。", - "remoteLink.policy.invalid": "策略文件已损坏,因此自动目录分发和自动更新已关闭。请重新选择每个自动模式并保存以重新开启。", + "remoteLink.policy.loadFailed": "无法加载子设备自动化设置。", +"remoteLink.policy.invalid": "策略文件已损坏,因此自动目录分发和自动更新已关闭。请重新选择每个自动模式并保存以重新开启。", "remoteLink.error.invalid_job_id": "操作 ID 无效。", "remoteLink.error.job_not_found": "该操作已不存在;Home 可能已重启。", "remoteLink.error.link_operation_in_progress": "该子设备上已有其他操作正在运行。", diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index 57c3bd44abb..cb4157579cc 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -164,6 +164,8 @@ export default function RemoteLink({ apiBase, diagnosticsApiBase = apiBase, sess // Revision 6: child operations and the policy save never enter `busy`, which RemoteLinkSheet types as a closed union. const [childOpBusy, setChildOpBusy] = useState<{ linkId: string | "all"; op: ChildOp } | null>(null); const [policyBusy, setPolicyBusy] = useState(false); + // Bumped by the page Refresh so the policy panel re-reads alongside the status; a poll never bumps it. + const [policyReload, setPolicyReload] = useState(0); const [childJobs, setChildJobs] = useState([]); const childJobIds = useRef(new Set()); const [actionError, setActionError] = useState(null); @@ -477,7 +479,7 @@ export default function RemoteLink({ apiBase, diagnosticsApiBase = apiBase, sess const roleLabel: TKey = status?.role === "home" || choseHome ? "remoteLink.role.home" : status?.role === "child" ? "remoteLink.role.child" : "remoteLink.role.standalone"; const primaryActionDisabled = role === "child" && !childSelectable; - const refreshButton = ; + const refreshButton = ; if (!sessionReady) return (
@@ -615,7 +617,7 @@ export default function RemoteLink({ apiBase, diagnosticsApiBase = apiBase, sess )} )} - {showChildOps && } + {showChildOps && } {(status?.role === "child" || statusRows.length > 0) && } diff --git a/gui/src/pages/RemoteLinkChildActions.tsx b/gui/src/pages/RemoteLinkChildActions.tsx index f06df8b62b5..50ef2e551ce 100644 --- a/gui/src/pages/RemoteLinkChildActions.tsx +++ b/gui/src/pages/RemoteLinkChildActions.tsx @@ -341,28 +341,43 @@ export function ChildJobProgress(props: { apiBase: string; job: ChildJobView; on ); } -export function ChildPolicyPanel(props: { apiBase: string; busy: boolean; onBusy(on: boolean): void; +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 { apiBase, onError } = props; - // Revision 6: load once per mount and after a save. A status poll never re-reads, so an edit survives it. + const [loadError, setLoadError] = useState(null); + const [attempt, setAttempt] = useState(0); + const { apiBase, reloadSignal } = props; + // 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) => { try { const value = await getChildPolicy(apiBase, signal); + if (signal?.aborted) return; setRead(value); setDraft(value.policy); - } catch (error) { if (!signal?.aborted) onError(linkActionError(error)); } - }, [apiBase, onError]); + 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(() => { const controller = new AbortController(); void Promise.resolve().then(() => load(controller.signal)); return () => controller.abort(); - }, [load]); - if (!draft || !read) return null; + }, [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 () => { diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index 8add4056792..c1709abf7be 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -457,6 +457,47 @@ test("a damaged policy file warns and a save writes every knob", async () => { 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("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( {}} />); From d8dcfc762867a7d2bc167f7cceab9778694bd9e3 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 13:36:32 +0900 Subject: [PATCH 18/26] fix(gui): wp6 idle-minutes input validates digits before parsing --- gui/src/pages/RemoteLinkChildActions.tsx | 17 ++++++++++++---- gui/tests/remote-link-child-ops.test.tsx | 26 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/gui/src/pages/RemoteLinkChildActions.tsx b/gui/src/pages/RemoteLinkChildActions.tsx index 50ef2e551ce..05827e3cadc 100644 --- a/gui/src/pages/RemoteLinkChildActions.tsx +++ b/gui/src/pages/RemoteLinkChildActions.tsx @@ -91,6 +91,9 @@ function CodeToken({ children }: { children: string }): ReactElement { return {children}; } +/** The exact link ids a bulk action names; the server expands "all" past the confirmed rows. */ +const linkIds = (rows: readonly LinkRowWire[]): string[] => rows.map(row => row.id); + export function ChildRowBadges({ row }: { row: ChildOpsRowView }): ReactElement { const t = useT(); const version = row.installedVersion ?? row.runningVersion; @@ -249,7 +252,6 @@ export function ChildBulkBar(props: { apiBase: string; rows: readonly LinkRowWir // 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. - const ids = (rows: readonly LinkRowWire[]) => rows.map(row => row.id); const run = async (body: ChildOperationBody) => { props.onError(null); props.onRun({ linkId: "all", op: body.op }); @@ -259,13 +261,13 @@ export function ChildBulkBar(props: { apiBase: string; rows: readonly LinkRowWir }; return (
- + setConfirming(null)} - onConfirm={() => { setConfirming(null); void run({ op: "update", linkIds: ids(updatable) }); }} /> + onConfirm={() => { setConfirming(null); void run({ op: "update", linkIds: linkIds(updatable) }); }} />
); } @@ -341,6 +343,13 @@ export function ChildJobProgress(props: { apiBase: string; job: ChildJobView; on ); } +/** 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(); @@ -416,7 +425,7 @@ export function ChildPolicyPanel(props: { apiBase: string; busy: boolean; reload
{saved && {t("remoteLink.policy.saved")}} diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index c1709abf7be..005c52ed59a 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -498,6 +498,32 @@ test("the page Refresh also reloads the policy", async () => { 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( {}} />); From edf5c3fdfd84d1488c9cfd39c08c4725cd57dd81 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 13:41:14 +0900 Subject: [PATCH 19/26] docs(remote-link): wp6 Codex badges await a Child that reports Codex state child.codex is always null until wp8, so "Codex restart needed" and "Codex not running" cannot render yet. Say so in all eight guides and in structure/remote-link.md, and describe the explicit-id bulk posts. --- docs-site/src/content/docs/fr/guides/remote-link.md | 2 +- docs-site/src/content/docs/guides/remote-link.md | 2 +- docs-site/src/content/docs/ja/guides/remote-link.md | 2 +- docs-site/src/content/docs/ko/guides/remote-link.md | 2 +- docs-site/src/content/docs/ru/guides/remote-link.md | 2 +- docs-site/src/content/docs/tr/guides/remote-link.md | 2 +- docs-site/src/content/docs/zh-cn/guides/remote-link.md | 2 +- docs-site/src/content/docs/zh-tw/guides/remote-link.md | 2 +- structure/remote-link.md | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/remote-link.md b/docs-site/src/content/docs/fr/guides/remote-link.md index ef44d1d73c5..6bf1f23851f 100644 --- a/docs-site/src/content/docs/fr/guides/remote-link.md +++ b/docs-site/src/content/docs/fr/guides/remote-link.md @@ -106,7 +106,7 @@ Sur le Home, chaque ligne Remote Link porte ce que le Home a appris en dernier s - la version de l'enfant, avec `Behind Home ()`, `Same version as Home` ou `Newer than Home` à côté, et `Update available: ` quand une mise à jour peut être appliquée. Un enfant lancé depuis son propre tableau de bord affiche sa version sans badge d'écart, et un enfant `ocx-child` affiche toujours `unknown`. - le type d'installation (`Desktop app`, `GOAT`, `Source checkout`), ou le jeton brut en police à chasse fixe quand cette version ne le nomme pas. -- `Catalog current` ou `Catalog out of date`, `Codex restart needed ()` quand le Codex de l'enfant détient encore un catalogue plus ancien, et `Codex not running` quand aucun app-server n'y tourne. +- `Catalog current` ou `Catalog out of date`. `Codex restart needed ()` et `Codex not running` n'apparaissent qu'une fois que l'enfant signale l'état de son Codex, ce qu'ajoutera une version ultérieure ; aucun enfant publié ne l'envoie encore, donc ces deux badges ne s'affichent pas aujourd'hui. - la dernière opération, sous la forme `Last : ` avec sa raison en police à chasse fixe, et celle en cours tant qu'une tâche occupe le lien. Quand le Home ne peut pas mettre cet enfant à jour lui-même, la ligne indique quoi faire à la place : utiliser Check for Updates dans OpenCodex Desktop sur cet enfant, mettre à jour à la main, via `mise`, ou laisser GOAT s'en charger. Une raison inconnue s'affiche comme un jeton, jamais comme une phrase devinée. diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index c7b2938e453..b503e2fe919 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -242,7 +242,7 @@ On a Home, every Remote Link row carries what the Home last learned about that C - the Child's version, with `Behind Home ()`, `Same version as Home` or `Newer than Home` beside it, plus `Update available: ` when an update can be applied. A Child started from its own dashboard shows its version without a skew badge, and an `ocx-child` Child always reads `unknown`. - the install kind (`Desktop app`, `GOAT`, `Source checkout`), or the raw token in monospace when this release does not name it. -- `Catalog current` or `Catalog out of date`, `Codex restart needed ()` when Codex on the Child still holds an older catalog, and `Codex not running` when it has no app-server up. +- `Catalog current` or `Catalog out of date`. `Codex restart needed ()` and `Codex not running` appear only once the Child reports its Codex state, which a later release adds; no released Child sends it yet, so those two badges do not appear today. - the last operation, as `Last : ` with its reason in monospace, and the running one while a job holds the link. When the Home cannot update that Child itself, the row says what to do instead: use Check for Updates in OpenCodex Desktop there, update by hand, through `mise`, or let GOAT do it. An unknown reason prints as a token rather than a guessed sentence. diff --git a/docs-site/src/content/docs/ja/guides/remote-link.md b/docs-site/src/content/docs/ja/guides/remote-link.md index c9a32eecd87..a9c959063b3 100644 --- a/docs-site/src/content/docs/ja/guides/remote-link.md +++ b/docs-site/src/content/docs/ja/guides/remote-link.md @@ -106,7 +106,7 @@ Home では、Remote Link の各行に Home がその Child について最後 - Child のバージョンと、その横の `Behind Home ()`、`Same version as Home`、`Newer than Home`。更新を適用できる場合は `Update available: ` も付きます。Child 自身のダッシュボードから開始したリンクはバージョンのみを表示し、バージョン差のバッジは付きません。`ocx-child` の Child は常に `unknown` と表示されます。 - インストール種別(`Desktop app`、`GOAT`、`Source checkout`)。このリリースで名前のない種類は等幅のトークンで表示されます。 -- `Catalog current` または `Catalog out of date`、Child の Codex がまだ古いカタログを保持している場合は `Codex restart needed ()`、app-server が起動していない場合は `Codex not running`。 +- `Catalog current` または `Catalog out of date`。`Codex restart needed ()` と `Codex not running` は、Child が自身の Codex 状態を報告して初めて表示されます。その報告は今後のリリースで追加されるため、まだ送信する Child はなく、現在この 2 つのバッジは表示されません。 - 最後の操作を `Last : ` として理由を等幅で示し、ジョブがリンクを占有している間は実行中の操作を示します。 Home がその Child を自分で更新できない場合、行には代わりに何をすべきかが表示されます。その Child の OpenCodex Desktop で Check for Updates を使う、手動で更新する、`mise` 経由で更新する、または GOAT に任せる、のいずれかです。不明な理由は推測した文ではなくトークンとして表示されます。 diff --git a/docs-site/src/content/docs/ko/guides/remote-link.md b/docs-site/src/content/docs/ko/guides/remote-link.md index b8f5e753f12..fc2b8f32e9d 100644 --- a/docs-site/src/content/docs/ko/guides/remote-link.md +++ b/docs-site/src/content/docs/ko/guides/remote-link.md @@ -106,7 +106,7 @@ Home에서는 모든 Remote Link 행에 Home이 그 Child에 대해 마지막으 - Child의 버전과 그 옆의 `Behind Home ()`, `Same version as Home`, `Newer than Home`, 그리고 업데이트를 적용할 수 있을 때의 `Update available: `. Child가 자체 대시보드에서 시작한 링크라면 버전만 표시되고 버전 차이 배지는 없으며, `ocx-child` Child는 항상 `unknown`으로 표시됩니다. - 설치 종류(`Desktop app`, `GOAT`, `Source checkout`), 또는 이 릴리스에서 이름을 붙이지 않는 토큰은 고정폭 글꼴로 표시됩니다. -- `Catalog current` 또는 `Catalog out of date`, Child의 Codex가 아직 이전 카탈로그를 들고 있으면 `Codex restart needed ()`, app-server가 떠 있지 않으면 `Codex not running`. +- `Catalog current` 또는 `Catalog out of date`. `Codex restart needed ()`와 `Codex not running` 배지는 Child가 자신의 Codex 상태를 보고한 뒤에만 표시되며, 그 보고는 이후 릴리스에서 추가됩니다. 아직 이를 보내는 Child 릴리스가 없으므로 지금은 이 두 배지가 나타나지 않습니다. - 마지막 작업을 `Last : `로 표시하고 그 사유는 고정폭으로 표시하며, 작업이 링크를 점유하는 동안에는 실행 중인 작업을 표시합니다. Home이 그 Child를 직접 업데이트할 수 없으면 행에 대신 무엇을 해야 하는지 표시됩니다. 그 Child의 OpenCodex Desktop에서 Check for Updates를 사용하거나, 직접 업데이트하거나, `mise`를 통해 업데이트하거나, GOAT가 처리하도록 두십시오. 알 수 없는 사유는 추측한 문장 대신 토큰으로 표시됩니다. diff --git a/docs-site/src/content/docs/ru/guides/remote-link.md b/docs-site/src/content/docs/ru/guides/remote-link.md index 3949e3b859f..e25c6032a2b 100644 --- a/docs-site/src/content/docs/ru/guides/remote-link.md +++ b/docs-site/src/content/docs/ru/guides/remote-link.md @@ -106,7 +106,7 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai - версию Child и рядом `Behind Home ()`, `Same version as Home` или `Newer than Home`, а также `Update available: `, когда обновление можно применить. Child, ссылку которого начали с его собственной панели, показывает версию без значка расхождения, а Child `ocx-child` всегда показывает `unknown`. - тип установки (`Desktop app`, `GOAT`, `Source checkout`) или необработанный токен моноширинным шрифтом, если этот выпуск его не называет. -- `Catalog current` или `Catalog out of date`, `Codex restart needed ()`, когда Codex на Child всё ещё держит более старый каталог, и `Codex not running`, когда app-server там не запущен. +- `Catalog current` или `Catalog out of date`. Значки `Codex restart needed ()` и `Codex not running` появляются только после того, как Child сообщит состояние своего Codex; это добавит более поздний выпуск. Пока ни один выпущенный Child его не присылает, поэтому сегодня эти два значка не отображаются. - последнюю операцию в виде `Last : ` с причиной моноширинным шрифтом, а пока задание занимает связь — выполняющуюся. Когда Home не может обновить этот Child сам, строка говорит, что делать вместо этого: воспользоваться Check for Updates в OpenCodex Desktop на этом Child, обновить вручную, через `mise` или дать это сделать GOAT. Неизвестная причина выводится как токен, а не как выдуманная фраза. diff --git a/docs-site/src/content/docs/tr/guides/remote-link.md b/docs-site/src/content/docs/tr/guides/remote-link.md index e8cfca8bdb1..6173ac0fc3b 100644 --- a/docs-site/src/content/docs/tr/guides/remote-link.md +++ b/docs-site/src/content/docs/tr/guides/remote-link.md @@ -106,7 +106,7 @@ Home üzerinde her Remote Link satırı, Home'un o Child hakkında son öğrendi - Child'ın sürümü ve yanında `Behind Home ()`, `Same version as Home` veya `Newer than Home`, ayrıca güncelleme uygulanabiliyorsa `Update available: `. Kendi panosundan başlatılan bir Child sürümünü sürüm farkı rozeti olmadan gösterir ve `ocx-child` bir Child her zaman `unknown` okur. - kurulum türü (`Desktop app`, `GOAT`, `Source checkout`) veya bu sürüm adlandırmıyorsa ham belirteç eş aralıklı yazıyla. -- `Catalog current` veya `Catalog out of date`, Child'daki Codex hâlâ daha eski bir katalog tutuyorsa `Codex restart needed ()` ve orada app-server çalışmıyorsa `Codex not running`. +- `Catalog current` veya `Catalog out of date`. `Codex restart needed ()` ve `Codex not running` rozetleri ancak Child kendi Codex durumunu bildirdikten sonra görünür; bu bildirimi daha sonraki bir sürüm ekleyecek. Henüz bunu gönderen bir Child sürümü olmadığı için bu iki rozet bugün görünmez. - son işlemi `Last : ` olarak, nedeni eş aralıklı yazıyla; bir iş bağlantıyı tutarken de çalışanı. Home o Child'ı kendisi güncelleyemediğinde satır bunun yerine ne yapılacağını söyler: o Child'daki OpenCodex Desktop'ta Check for Updates kullanın, elle güncelleyin, `mise` üzerinden güncelleyin veya GOAT yapsın. Bilinmeyen bir neden tahmin edilmiş bir cümle yerine belirteç olarak yazılır. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-link.md b/docs-site/src/content/docs/zh-cn/guides/remote-link.md index 60114661749..5e098d5c451 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-link.md @@ -106,7 +106,7 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai - 子机版本,以及旁边的 `Behind Home ()`、`Same version as Home` 或 `Newer than Home`;可以应用更新时还会显示 `Update available: `。从子机自身仪表盘启动的链接只显示版本,没有版本差异徽标;`ocx-child` 子机始终显示 `unknown`。 - 安装类型(`Desktop app`、`GOAT`、`Source checkout`),本版本未命名的类型以等宽字体显示原始标记。 -- `Catalog current` 或 `Catalog out of date`;子机上的 Codex 仍持有较旧目录时显示 `Codex restart needed ()`,没有运行 app-server 时显示 `Codex not running`。 +- `Catalog current` 或 `Catalog out of date`。`Codex restart needed ()` 与 `Codex not running` 徽章只有在 Child 报告其 Codex 状态后才会出现,该报告将由后续版本加入;目前还没有 Child 版本会发送它,因此这两个徽章现在还不会显示。 - 上一次操作,显示为 `Last : `,原因以等宽字体显示;任务占用该链接期间显示正在运行的操作。 当 Home 无法自行更新该子机时,该行会说明应改做什么:在该子机的 OpenCodex Desktop 中使用 Check for Updates、手动更新、通过 `mise` 更新,或交给 GOAT 处理。未知原因会以标记显示,而不是猜测的句子。 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-link.md b/docs-site/src/content/docs/zh-tw/guides/remote-link.md index f0c20317f54..2eecfe46de4 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-link.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-link.md @@ -106,7 +106,7 @@ ocx link restart-codex --link-id [--scope app-server|codex] --yes [--no-wai - Child 的版本,以及旁邊的 `Behind Home ()`、`Same version as Home` 或 `Newer than Home`;可以套用更新時還會顯示 `Update available: `。從 Child 自身儀表板開始的連結只顯示版本,沒有版本差異徽章;`ocx-child` Child 一律顯示 `unknown`。 - 安裝類型(`Desktop app`、`GOAT`、`Source checkout`),本版本未命名的類型以等寬字型顯示原始權杖。 -- `Catalog current` 或 `Catalog out of date`;Child 上的 Codex 仍持有較舊目錄時顯示 `Codex restart needed ()`,沒有執行 app-server 時顯示 `Codex not running`。 +- `Catalog current` 或 `Catalog out of date`。`Codex restart needed ()` 與 `Codex not running` 徽章只有在 Child 回報其 Codex 狀態後才會出現,該回報將由後續版本加入;目前還沒有 Child 版本會傳送它,因此這兩個徽章現在還不會顯示。 - 上一次操作,顯示為 `Last : `,原因以等寬字型顯示;工作佔用該連結期間則顯示正在執行的操作。 當 Home 無法自行更新該 Child 時,該列會說明應該改做什麼:在該 Child 的 OpenCodex Desktop 中使用 Check for Updates、手動更新、透過 `mise` 更新,或交給 GOAT 處理。未知原因會以權杖顯示,而不是猜測的句子。 diff --git a/structure/remote-link.md b/structure/remote-link.md index d8b1f51841d..a4897b41de5 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -52,7 +52,7 @@ 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 `failed`, 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. 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 `linkIds:"all"`; 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. While a job holds a link its actions and Disconnect are disabled, and 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. +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 `failed`, 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, 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. While a job holds a link its actions and Disconnect are disabled, and 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`. From de97ebb51158129d5b07d24a6d0c8f1ad180158d Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 15:12:41 +0900 Subject: [PATCH 20/26] fix(gui): wp6 release rows per item and serialize policy reloads A polled job snapshot now replaces the page's copy, so a row's actions and Disconnect come back when its own item finishes instead of when the whole bulk job ends. Policy reads and writes no longer race: each save bumps a generation so an earlier GET cannot apply afterwards, a reload skips the draft when the operator edited it while the GET was in flight, and no reload starts while a save is pending. --- gui/src/pages/RemoteLink.tsx | 7 +- gui/src/pages/RemoteLinkChildActions.tsx | 30 ++++++-- gui/tests/remote-link-child-ops.test.tsx | 91 +++++++++++++++++++++++- structure/remote-link.md | 2 +- 4 files changed, 121 insertions(+), 9 deletions(-) diff --git a/gui/src/pages/RemoteLink.tsx b/gui/src/pages/RemoteLink.tsx index cb4157579cc..431437ebc85 100644 --- a/gui/src/pages/RemoteLink.tsx +++ b/gui/src/pages/RemoteLink.tsx @@ -463,6 +463,11 @@ export default function RemoteLink({ apiBase, diagnosticsApiBase = apiBase, sess setChildJobs(current => current.filter(job => job.jobId !== jobId)); void refreshStatus(); }, [refreshStatus]); + // Each polled snapshot replaces the page's copy, so a row is released when its own item ends, not when the + // whole bulk job does. + const updateJob = useCallback((job: ChildJobView) => { + setChildJobs(current => current.map(item => item.jobId === job.jobId ? job : item)); + }, []); useEffect(() => { for (const row of status?.links ?? []) { const active = row.child?.activeJob; @@ -548,7 +553,7 @@ export default function RemoteLink({ apiBase, diagnosticsApiBase = apiBase, sess {showChildOps && ( <> - {childJobs.map(job => finishJob(job.jobId)} />)} + {childJobs.map(job => finishJob(job.jobId)} onJobUpdate={updateJob} />)} )} {uiState === "restart-waiting" ? ( diff --git a/gui/src/pages/RemoteLinkChildActions.tsx b/gui/src/pages/RemoteLinkChildActions.tsx index 05827e3cadc..cf458ad02bd 100644 --- a/gui/src/pages/RemoteLinkChildActions.tsx +++ b/gui/src/pages/RemoteLinkChildActions.tsx @@ -272,7 +272,7 @@ export function ChildBulkBar(props: { apiBase: string; rows: readonly LinkRowWir ); } -function useChildJob(apiBase: string, job: ChildJobView): { job: ChildJobView; missing: boolean } { +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. @@ -280,6 +280,10 @@ function useChildJob(apiBase: string, job: ChildJobView): { job: ChildJobView; m 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; @@ -288,7 +292,7 @@ function useChildJob(apiBase: string, job: ChildJobView): { job: ChildJobView; m if (document.visibilityState === "hidden") return; try { const next = await getChildJob(apiBase, job.jobId, controller.signal); - if (!stopped) setPolled({ jobId: job.jobId, value: next }); + if (!stopped) { setPolled({ jobId: job.jobId, value: next }); notify.current?.(next); } } catch (error) { if (stopped) return; if (error instanceof LinkApiError && (error.code === "job_not_found" || error.status === 404)) { @@ -306,9 +310,9 @@ function useChildJob(apiBase: string, job: ChildJobView): { job: ChildJobView; m return { job: snapshot, missing }; } -export function ChildJobProgress(props: { apiBase: string; job: ChildJobView; onDone(): void }): ReactElement { +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); + 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]); @@ -359,19 +363,31 @@ export function ChildPolicyPanel(props: { apiBase: string; busy: boolean; reload 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]); // 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; try { const value = await getChildPolicy(apiBase, signal); if (signal?.aborted) return; - setRead(value); setDraft(value.policy); + if (generation.current !== startedAt) return; // a save started after this GET began; it owns the values now + setRead(value); + // The operator changed a knob while this reload ran: keep their draft and take only the server's baseline. + if (draftRef.current === startedDraft) 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(); @@ -390,13 +406,15 @@ export function ChildPolicyPanel(props: { apiBase: string; busy: boolean; reload 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 { props.onBusy(false); } + 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 }); }; diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index 005c52ed59a..1ee73e51296 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -41,7 +41,7 @@ async function settleUntil(done: () => boolean, limitMs = 3_000): Promise 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): Call[] { +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)); @@ -573,3 +573,92 @@ test("parsers reject a malformed child block and keep an unknown job state", () 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" }); +}); diff --git a/structure/remote-link.md b/structure/remote-link.md index a4897b41de5..f8b0de5f609 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -52,7 +52,7 @@ 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 `failed`, 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, 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. While a job holds a link its actions and Disconnect are disabled, and 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. +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 `failed`, 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, 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`. From 93417668ee656aa9a9d8cdd2adb02f7934d3bfc9 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 16:08:16 +0900 Subject: [PATCH 21/26] fix(gui): wp6 order overlapping job polls and merge policy reloads per field A visibility-driven poll can overlap an interval one. Interval ticks now skip while a request is pending and every response carries a sequence number, so a stale snapshot cannot restore a hold a newer one released. A reload that lands after an operator edit now merges field by field: the fields they changed against the baseline keep their value and every other field adopts the server's answer, so Save still sends only theirs. structure/remote-link.md now says an unknown job state reads running, which is what the parsers do, rather than failed. --- gui/src/pages/RemoteLinkChildActions.tsx | 38 ++++++++++++---- gui/tests/remote-link-child-ops.test.tsx | 57 ++++++++++++++++++++++++ structure/remote-link.md | 2 +- 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/gui/src/pages/RemoteLinkChildActions.tsx b/gui/src/pages/RemoteLinkChildActions.tsx index cf458ad02bd..9074c4ed314 100644 --- a/gui/src/pages/RemoteLinkChildActions.tsx +++ b/gui/src/pages/RemoteLinkChildActions.tsx @@ -287,25 +287,37 @@ function useChildJob(apiBase: string, job: ChildJobView, onSnapshot?: (job: Chil 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(); - const tick = async () => { - if (document.visibilityState === "hidden") return; + // 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; try { const next = await getChildJob(apiBase, job.jobId, controller.signal); - if (!stopped) { setPolled({ jobId: job.jobId, value: next }); notify.current?.(next); } + if (!stopped && mine > applied) { + applied = mine; + setPolled({ jobId: job.jobId, value: next }); notify.current?.(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(); setMissingFor(job.jobId); } - } + } finally { inFlight = false; } }; const timer = window.setInterval(() => void tick(), CHILD_JOB_POLL_MS); - const onVisibility = () => { if (document.visibilityState !== "hidden") void tick(); }; + const onVisibility = () => { if (document.visibilityState !== "hidden") void tick(true); }; document.addEventListener("visibilitychange", onVisibility); - return () => { stopped = true; controller.abort(); window.clearInterval(timer); document.removeEventListener("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 }; } @@ -369,18 +381,26 @@ export function ChildPolicyPanel(props: { apiBase: string; busy: boolean; reload 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 a knob while this reload ran: keep their draft and take only the server's baseline. - if (draftRef.current === startedDraft) setDraft(value.policy); + // 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]); diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index 1ee73e51296..8312bd6950d 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -662,3 +662,60 @@ test("an edit made during a policy reload survives it", async () => { await click(button(host, "Save")); expect(calls.filter(call => call.method === "PUT").at(-1)?.body).toEqual({ catalogFanout: "manual" }); }); + +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/structure/remote-link.md b/structure/remote-link.md index f8b0de5f609..fcf49799712 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -52,7 +52,7 @@ 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 `failed`, 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, 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. +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, 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`. From 05e4a2676aaa7a419f8244020c64c557202d2f16 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 16:49:51 +0900 Subject: [PATCH 22/26] fix(gui): wp6 bound each job poll with a 15 s deadline --- gui/src/pages/RemoteLinkChildActions.tsx | 18 +++++++--- gui/src/remote-link-child-ops-api.ts | 12 +++++++ gui/tests/remote-link-child-ops.test.tsx | 43 +++++++++++++++++++++++- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/gui/src/pages/RemoteLinkChildActions.tsx b/gui/src/pages/RemoteLinkChildActions.tsx index 9074c4ed314..c32b5725886 100644 --- a/gui/src/pages/RemoteLinkChildActions.tsx +++ b/gui/src/pages/RemoteLinkChildActions.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useRef, useState, type ReactElement } from "react"; import { LinkApiError, type LinkRowWire } from "../remote-link-api"; import { - CHILD_JOB_POLL_MS, changedPolicyKeys, codexRestartNeeded, getChildJob, getChildPolicy, postChildOperation, putChildPolicy, - restartWithheld, rowOperable, updateAvailable, + CHILD_JOB_POLL_MS, CHILD_JOB_POLL_TIMEOUT_MS, changedPolicyKeys, codexRestartNeeded, getChildJob, getChildPolicy, postChildOperation, putChildPolicy, + restartWithheld, rowOperable, settleOrAbort, updateAvailable, type ChildJobView, type ChildOp, type ChildOperationBody, type ChildOpsRowView, type ChildPolicyReadView, type ChildPolicyView, } from "../remote-link-child-ops-api"; import { type TKey, useT } from "../i18n/shared"; @@ -300,8 +300,14 @@ function useChildJob(apiBase: string, job: ChildJobView, onSnapshot?: (job: Chil 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 getChildJob(apiBase, job.jobId, controller.signal); + 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); @@ -312,7 +318,11 @@ function useChildJob(apiBase: string, job: ChildJobView, onSnapshot?: (job: Chil controller.abort(); setMissingFor(job.jobId); } - } finally { inFlight = false; } + } 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); }; diff --git a/gui/src/remote-link-child-ops-api.ts b/gui/src/remote-link-child-ops-api.ts index 8ea9e71a48e..9e0f0c77097 100644 --- a/gui/src/remote-link-child-ops-api.ts +++ b/gui/src/remote-link-child-ops-api.ts @@ -148,6 +148,18 @@ export function jobHoldsLink(jobs: readonly ChildJobView[], linkId: string): boo && job.items.some(item => item.linkId === linkId && (item.state === "queued" || item.state === "running"))); } export const CHILD_JOB_POLL_MS = 2_000; +/** 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 { diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index 8312bd6950d..7eca6f43206 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -5,7 +5,7 @@ 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 { parseChildJob, parseChildOps } from "../src/remote-link-child-ops-api"; +import { CHILD_JOB_POLL_MS, CHILD_JOB_POLL_TIMEOUT_MS, parseChildJob, parseChildOps } from "../src/remote-link-child-ops-api"; const HOME = "http://fixture"; let win: Window; @@ -663,6 +663,47 @@ test("an edit made during a policy reload survives it", async () => { 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(); From c2e34103c87dfb95aaebe102017080a3dcdee03f Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 17:47:29 +0900 Subject: [PATCH 23/26] fix(gui): wp6 split bulk Child operations into groups of 64 ids --- .../060_dashboard.md | 25 +++++++++++++------ gui/src/pages/RemoteLinkChildActions.tsx | 16 +++++++----- gui/src/remote-link-child-ops-api.ts | 8 ++++++ gui/tests/remote-link-child-ops.test.tsx | 23 +++++++++++++++++ structure/remote-link.md | 2 +- 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/devlog/_plan/261111_link_child_updates/060_dashboard.md b/devlog/_plan/261111_link_child_updates/060_dashboard.md index c65b9690241..036147eb267 100644 --- a/devlog/_plan/261111_link_child_updates/060_dashboard.md +++ b/devlog/_plan/261111_link_child_updates/060_dashboard.md @@ -459,20 +459,22 @@ export function ChildBulkBar(props: { apiBase: string; rows: readonly LinkRowWir }; return (
- + setConfirming(null)} - onConfirm={() => { setConfirming(null); void run({ op: "update", linkIds: "all" }); }} /> + onConfirm={() => { setConfirming(null); void run("update", linkIds(updatable)); }} />
); } ``` -Check all names `"all"` because 004 D14 allows it for observe; the server still refuses a client-initiated link inside the -job, item by item, so a mixed list is safe. Update all is offered only when at least one row is actually updatable, and -the count/version in its confirmation describe exactly those rows. +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 group of 64 in order (`chunkLinkIds`); a refused group stops the rest and shows its error. ```tsx export function useChildJob(apiBase: string, job: ChildJobView): { job: ChildJobView; missing: boolean } { @@ -724,7 +726,7 @@ implementation because `../src/pages/RemoteLinkChildActions` does not exist. sentence); confirm posts `restart:"app-server"`; choosing the desktop scope shows `codexBody` with the alias and posts `restart:"codex"`. 6. *Update and Update all* — the row Update confirm names alias and version and contains the "ends any turn currently relayed" and "Automatic updates wait" sentences (English catalog), then posts `{op:"update",linkIds:[id]}`; Update all's - confirm names count and version with the same two sentences and posts `{op:"update",linkIds:"all"}`; disabled when no + confirm names count and version with the same two sentences and posts `{op:"update",linkIds:[]}`; disabled when no row has an update. An unknown `restart.outcome` string renders as `` text, not a crash (H5). 7. *job progress* — POST returns a running job; the stub answers `jobs/` running then done; the panel shows each item state and the restart outcome `done`, polls stop after done, and status refreshes once. @@ -810,7 +812,7 @@ counts. Locale coverage is `gui/tests/locale-parity.test.ts` and `i18n-locales.t 3. Check, Sync now, Restart Codex and Update each post exactly one `{op, linkIds:[id], restart?}` (tests 4–5). 4. The Restart Codex dialog states that the app-server scope does not refresh the Codex desktop app's own app-server, and the desktop scope warns that conversations end (test 5). -5. Update all and Check all post `linkIds:"all"`; Update and Update all need a confirmation that names the target +5. Update all and Check all post the explicit ids of the rows they cover, at most 64 per request; Update and Update all need a confirmation that names the target version and says that a manual update ends the turn relayed for that Child while automatic updates wait for idle (test 6). 6. A running job shows per-Child progress until done, then the status refreshes (test 7). 7. While a job holds a row, its actions and Disconnect are disabled (test 9); a 409 from the server is shown translated (test 8). @@ -903,3 +905,12 @@ Audit verdict GO-WITH-FIXES (blockers 3). All findings accepted; binding on the 8. Minor. `restart.outcome: "skipped"` copy (en): "Restart withheld: a request was in flight on this Child." (ko: "재시작 보류: 이 Child에서 요청이 처리 중이었습니다."); test 12 updated to match. + +## Revision 7 (fresh final review r5 on #8581) + +Bulk actions post explicit ids, never `"all"`; the code sample, test 6 and acceptance item 5 above now say so. The +operations route caps a body at 64 ids (`MAX_LINK_IDS`) while a Home's link count is unbounded, so Check all and Update +all split their list with `chunkLinkIds` and post one job per group of 64, in order; a refused group stops the rest and +shows its error. Test: "bulk actions split more than 64 Children into requests the server accepts" (65 rows, two +requests of 64 and 1 for each action). Job polls carry a 15 s deadline each (`CHILD_JOB_POLL_TIMEOUT_MS`, CodeRabbit +4547457599). diff --git a/gui/src/pages/RemoteLinkChildActions.tsx b/gui/src/pages/RemoteLinkChildActions.tsx index c32b5725886..9fbb3771381 100644 --- a/gui/src/pages/RemoteLinkChildActions.tsx +++ b/gui/src/pages/RemoteLinkChildActions.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type ReactElement } from "rea import { LinkApiError, type LinkRowWire } from "../remote-link-api"; import { CHILD_JOB_POLL_MS, CHILD_JOB_POLL_TIMEOUT_MS, changedPolicyKeys, codexRestartNeeded, getChildJob, getChildPolicy, postChildOperation, putChildPolicy, - restartWithheld, rowOperable, settleOrAbort, updateAvailable, + chunkLinkIds, restartWithheld, rowOperable, settleOrAbort, updateAvailable, type ChildJobView, type ChildOp, type ChildOperationBody, type ChildOpsRowView, type ChildPolicyReadView, type ChildPolicyView, } from "../remote-link-child-ops-api"; import { type TKey, useT } from "../i18n/shared"; @@ -252,22 +252,26 @@ export function ChildBulkBar(props: { apiBase: string; rows: readonly LinkRowWir // 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. - const run = async (body: ChildOperationBody) => { + // The route takes at most 64 ids per body, so a larger list goes out as one job per group, in order; a refused + // group stops the rest and its error shows, while groups already accepted keep running as their own jobs. + const run = async (op: ChildOp, ids: readonly string[]) => { props.onError(null); - props.onRun({ linkId: "all", op: body.op }); - try { props.onJob(await postChildOperation(props.apiBase, body)); } + props.onRun({ linkId: "all", op }); + try { + for (const group of chunkLinkIds(ids)) props.onJob(await postChildOperation(props.apiBase, { op, linkIds: group })); + } catch (error) { props.onError(linkActionError(error)); } finally { props.onRun(null); } }; return (
- + setConfirming(null)} - onConfirm={() => { setConfirming(null); void run({ op: "update", linkIds: linkIds(updatable) }); }} /> + onConfirm={() => { setConfirming(null); void run("update", linkIds(updatable)); }} />
); } diff --git a/gui/src/remote-link-child-ops-api.ts b/gui/src/remote-link-child-ops-api.ts index 9e0f0c77097..38477b5ab4f 100644 --- a/gui/src/remote-link-child-ops-api.ts +++ b/gui/src/remote-link-child-ops-api.ts @@ -148,6 +148,14 @@ export function jobHoldsLink(jobs: readonly ChildJobView[], linkId: string): boo && 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 (`MAX_LINK_IDS` in link-child-ops-routes.ts). */ +export const CHILD_OPERATION_MAX_LINK_IDS = 64; +/** Splits a bulk target list into request-sized groups; a Home's link count is unbounded. */ +export function chunkLinkIds(ids: readonly string[], size = CHILD_OPERATION_MAX_LINK_IDS): string[][] { + const chunks: string[][] = []; + for (let start = 0; start < ids.length; start += size) chunks.push(ids.slice(start, start + size)); + 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; diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index 7eca6f43206..80f12401f66 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -270,6 +270,29 @@ test("Update all and Check all name exactly the rows they cover", async () => { 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, 64) }, { op: "observe", linkIds: ids.slice(64) }]); + 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, 64) }, { op: "update", linkIds: ids.slice(64) }]); + expect(host.textContent).not.toContain("could not be completed"); +}); + test("a running job shows per-item progress and refreshes status once when done", async () => { let jobPolls = 0; const calls = installFetch(call => { diff --git a/structure/remote-link.md b/structure/remote-link.md index fcf49799712..07422cf46a6 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -52,7 +52,7 @@ 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, 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. +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 (one job per group, the route cap), 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`. From c242d789f2c2e32c2757fbe2f434405c43652b92 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 17:51:08 +0900 Subject: [PATCH 24/26] fix(gui): wp6 post bulk groups together (react-doctor async-await-in-loop) --- .../060_dashboard.md | 6 ++--- gui/src/pages/RemoteLinkChildActions.tsx | 10 +++++---- gui/tests/remote-link-child-ops.test.tsx | 22 +++++++++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/devlog/_plan/261111_link_child_updates/060_dashboard.md b/devlog/_plan/261111_link_child_updates/060_dashboard.md index 036147eb267..fa741e0bd18 100644 --- a/devlog/_plan/261111_link_child_updates/060_dashboard.md +++ b/devlog/_plan/261111_link_child_updates/060_dashboard.md @@ -474,7 +474,7 @@ Both bulk actions post explicit link ids, never `"all"` (Revision 7, #8581 revie 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 group of 64 in order (`chunkLinkIds`); a refused group stops the rest and shows its error. +`run` posts one job per group of 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 } { @@ -910,7 +910,7 @@ Audit verdict GO-WITH-FIXES (blockers 3). All findings accepted; binding on the Bulk actions post explicit ids, never `"all"`; the code sample, test 6 and acceptance item 5 above now say so. The operations route caps a body at 64 ids (`MAX_LINK_IDS`) while a Home's link count is unbounded, so Check all and Update -all split their list with `chunkLinkIds` and post one job per group of 64, in order; a refused group stops the rest and -shows its error. Test: "bulk actions split more than 64 Children into requests the server accepts" (65 rows, two +all split their list with `chunkLinkIds` and post one job per group of 64 at once; each accepted group is tracked as its own job and the first +refusal shows its error (react-doctor async-await-in-loop rules out a sequential loop). Test: "bulk actions split more than 64 Children into requests the server accepts" (65 rows, two requests of 64 and 1 for each action). Job polls carry a 15 s deadline each (`CHILD_JOB_POLL_TIMEOUT_MS`, CodeRabbit 4547457599). diff --git a/gui/src/pages/RemoteLinkChildActions.tsx b/gui/src/pages/RemoteLinkChildActions.tsx index 9fbb3771381..d702b5d93a3 100644 --- a/gui/src/pages/RemoteLinkChildActions.tsx +++ b/gui/src/pages/RemoteLinkChildActions.tsx @@ -252,15 +252,17 @@ export function ChildBulkBar(props: { apiBase: string; rows: readonly LinkRowWir // 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, in order; a refused - // group stops the rest and its error shows, while groups already accepted keep running as their own jobs. + // 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 { - for (const group of chunkLinkIds(ids)) props.onJob(await postChildOperation(props.apiBase, { op, linkIds: group })); + 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)); } - catch (error) { props.onError(linkActionError(error)); } finally { props.onRun(null); } }; return ( diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index 80f12401f66..ee5830fdf8f 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -293,6 +293,28 @@ test("bulk actions split more than 64 Children into requests the server accepts" expect(host.textContent).not.toContain("could not be completed"); }); +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.length === 1) 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 64-id group was accepted, so its job panel shows and its row stays held. + expect(host.querySelector(".remote-link-job")).not.toBeNull(); +}); + test("a running job shows per-item progress and refreshes status once when done", async () => { let jobPolls = 0; const calls = installFetch(call => { From 5fc0eadb6cb52887c18fbe784a82a0447487bab2 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 18:16:37 +0900 Subject: [PATCH 25/26] fix(gui): wp6 even bulk groups, submit deadline, cap parity with the route --- .../060_dashboard.md | 18 +++++--- gui/src/remote-link-child-ops-api.ts | 26 ++++++++--- gui/tests/remote-link-child-ops.test.tsx | 44 ++++++++++++++++--- src/link/child-ops-types.ts | 2 + .../management/link-child-ops-routes.ts | 4 +- structure/remote-link.md | 2 +- 6 files changed, 77 insertions(+), 19 deletions(-) diff --git a/devlog/_plan/261111_link_child_updates/060_dashboard.md b/devlog/_plan/261111_link_child_updates/060_dashboard.md index fa741e0bd18..bb27c937497 100644 --- a/devlog/_plan/261111_link_child_updates/060_dashboard.md +++ b/devlog/_plan/261111_link_child_updates/060_dashboard.md @@ -452,10 +452,16 @@ export function ChildBulkBar(props: { apiBase: string; rows: readonly LinkRowWir 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 (body: ChildOperationBody) => { + const run = async (op: ChildOp, ids: readonly string[]) => { props.onError(null); - try { props.onJob(await postChildOperation(props.apiBase, body)); } - catch (error) { props.onError(linkActionError(error)); } + 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 (
@@ -910,7 +916,9 @@ Audit verdict GO-WITH-FIXES (blockers 3). All findings accepted; binding on the Bulk actions post explicit ids, never `"all"`; the code sample, test 6 and acceptance item 5 above now say so. The operations route caps a body at 64 ids (`MAX_LINK_IDS`) while a Home's link count is unbounded, so Check all and Update -all split their list with `chunkLinkIds` and post one job per group of 64 at once; each accepted group is tracked as its own job and the first +all split their list with `chunkLinkIds` into even groups of at most 64 (65 ids go out as 33 + 32, so no one-id tail +turns a held link into a whole-request 409) and post one job per group at once; each accepted group is tracked as its own job and the first refusal shows its error (react-doctor async-await-in-loop rules out a sequential loop). Test: "bulk actions split more than 64 Children into requests the server accepts" (65 rows, two -requests of 64 and 1 for each action). Job polls carry a 15 s deadline each (`CHILD_JOB_POLL_TIMEOUT_MS`, CodeRabbit +requests of 33 and 32 for each action); the cap is pinned to `CHILD_OPS_MAX_LINK_IDS` in `src/link/child-ops-types.ts` by a +parity test. Submits carry a 30 s deadline (`CHILD_OPERATION_POST_TIMEOUT_MS`) and job polls a 15 s deadline each (`CHILD_JOB_POLL_TIMEOUT_MS`, CodeRabbit 4547457599). diff --git a/gui/src/remote-link-child-ops-api.ts b/gui/src/remote-link-child-ops-api.ts index 38477b5ab4f..728a186ccbc 100644 --- a/gui/src/remote-link-child-ops-api.ts +++ b/gui/src/remote-link-child-ops-api.ts @@ -114,9 +114,18 @@ export function parseChildPolicyRead(value: unknown): ChildPolicyReadView { } const json = { "content-type": "application/json" }; -export async function postChildOperation(apiBase: string, body: ChildOperationBody): Promise { - const out = await requestLinkJson<{ job?: unknown }>(apiBase, "/api/link/children/operations", { method: "POST", headers: json, body: JSON.stringify(body) }); - return parseChildJob(out.job); +/** 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 }); @@ -148,12 +157,17 @@ export function jobHoldsLink(jobs: readonly ChildJobView[], linkId: string): boo && 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 (`MAX_LINK_IDS` in link-child-ops-routes.ts). */ +/** 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 request-sized groups; a Home's link count is unbounded. */ +/** Splits a bulk target list into request-sized groups of even size; a Home's link count is unbounded. Even groups + * never leave a one-id tail, 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 each = groups > 0 ? Math.ceil(ids.length / groups) : size; const chunks: string[][] = []; - for (let start = 0; start < ids.length; start += size) chunks.push(ids.slice(start, start + size)); + for (let start = 0; start < ids.length; start += each) chunks.push(ids.slice(start, start + each)); return chunks; } /** One job poll may take this long; a GET that never settles would otherwise stop every later poll. */ diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index ee5830fdf8f..6597875902e 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -5,7 +5,10 @@ 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_JOB_POLL_MS, CHILD_JOB_POLL_TIMEOUT_MS, parseChildJob, parseChildOps } from "../src/remote-link-child-ops-api"; +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; @@ -282,24 +285,47 @@ test("bulk actions split more than 64 Children into requests the server accepts" 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, 64) }, { op: "observe", linkIds: ids.slice(64) }]); + 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, 64) }, { op: "update", linkIds: ids.slice(64) }]); + 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]) { + 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); + } +}); + +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.length === 1) return response({ error: { code: "link_operation_in_progress" } }, 409); + 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); @@ -311,8 +337,16 @@ test("a refused bulk group shows its error while the accepted group is still tra 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 64-id group was accepted, so its job panel shows and its row stays held. + // 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 () => { 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 07422cf46a6..f1152ebb53f 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -52,7 +52,7 @@ 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 (one job per group, the route cap), 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. +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`. From 0f7f47af5e4de1d0e2d2f8f26b4ec4fae74ba33b Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 12 Nov 2026 18:34:19 +0900 Subject: [PATCH 26/26] fix(gui): wp6 bulk groups differ by at most one id --- .../261111_link_child_updates/060_dashboard.md | 12 +++++++----- gui/src/remote-link-child-ops-api.ts | 16 +++++++++++----- gui/tests/remote-link-child-ops.test.tsx | 4 +++- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/devlog/_plan/261111_link_child_updates/060_dashboard.md b/devlog/_plan/261111_link_child_updates/060_dashboard.md index bb27c937497..c8e244bd26c 100644 --- a/devlog/_plan/261111_link_child_updates/060_dashboard.md +++ b/devlog/_plan/261111_link_child_updates/060_dashboard.md @@ -297,7 +297,7 @@ Disconnect (:544) becomes `disabled={busy !== null || row.child?.activeJob != nu Between the list heading and the `
    ` (:500-526), when `status?.role === "home" && statusRows.some(row => row.child)`: ```tsx - + {childJobs.map(job => finishJob(job.jobId)} />)} ``` The policy panel sits with the other panels below the list, immediately before `FallbackPanel` (`:569`): @@ -445,7 +445,8 @@ 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 }): ReactElement | null { + 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)); @@ -480,7 +481,7 @@ Both bulk actions post explicit link ids, never `"all"` (Revision 7, #8581 revie 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 group of 64 at once (`chunkLinkIds`, `Promise.allSettled`); each accepted group is tracked as its own job and the first refusal shows its error. +`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 } { @@ -916,8 +917,9 @@ Audit verdict GO-WITH-FIXES (blockers 3). All findings accepted; binding on the Bulk actions post explicit ids, never `"all"`; the code sample, test 6 and acceptance item 5 above now say so. The operations route caps a body at 64 ids (`MAX_LINK_IDS`) while a Home's link count is unbounded, so Check all and Update -all split their list with `chunkLinkIds` into even groups of at most 64 (65 ids go out as 33 + 32, so no one-id tail -turns a held link into a whole-request 409) and post one job per group at once; each accepted group is tracked as its own job and the first +all split their list with `chunkLinkIds` into the fewest groups of at most 64 whose sizes differ by at most one (65 ids go out as 33 + 32, +and 4033 as one group of 64 and 63 of 63), so no one-id tail +turns a held link into a whole-request 409 and post one job per group at once; each accepted group is tracked as its own job and the first refusal shows its error (react-doctor async-await-in-loop rules out a sequential loop). Test: "bulk actions split more than 64 Children into requests the server accepts" (65 rows, two requests of 33 and 32 for each action); the cap is pinned to `CHILD_OPS_MAX_LINK_IDS` in `src/link/child-ops-types.ts` by a parity test. Submits carry a 30 s deadline (`CHILD_OPERATION_POST_TIMEOUT_MS`) and job polls a 15 s deadline each (`CHILD_JOB_POLL_TIMEOUT_MS`, CodeRabbit diff --git a/gui/src/remote-link-child-ops-api.ts b/gui/src/remote-link-child-ops-api.ts index 728a186ccbc..d1518357e3f 100644 --- a/gui/src/remote-link-child-ops-api.ts +++ b/gui/src/remote-link-child-ops-api.ts @@ -160,14 +160,20 @@ 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 request-sized groups of even size; a Home's link count is unbounded. Even groups - * never leave a one-id tail, which the route would treat as a single-link request (a held link answers 409 for the - * whole request instead of a `busy` item). */ +/** 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 each = groups > 0 ? Math.ceil(ids.length / groups) : 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[][] = []; - for (let start = 0; start < ids.length; start += each) chunks.push(ids.slice(start, start + each)); + 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. */ diff --git a/gui/tests/remote-link-child-ops.test.tsx b/gui/tests/remote-link-child-ops.test.tsx index 6597875902e..a52eee3660c 100644 --- a/gui/tests/remote-link-child-ops.test.tsx +++ b/gui/tests/remote-link-child-ops.test.tsx @@ -304,10 +304,12 @@ test("the GUI copy of the per-request link cap equals the route's, and groups ar 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]) { + 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); } });