Merged
Keep Admin maintenance target and intent synchronized #147
urandonAdd on Oct 10
Keep Admin maintenance target and intent synchronized #147
Merged
Merged fromcodex/issue-150-maintenance-targetintomain
Total: 7 files changed+347-31
| @@ -123,6 +123,18 @@ The observation UI is organized around product state rather than CLI command gro | |||
| 123 | 123 | ||
| 124 | Local deep links use URL search parameters: `view`, opaque `cache`, public repository id `repo`, canonical or legacy `registration`, repository `tab`, diagnostic state, public `job`, job state/type/cache/repository/failure filters, and Search Lab `q`, `kind`, `provenance`, and `limit`. A legacy registration deep link is replaced with its canonical public id and an in-page redirect explanation. No cache path, credential, provider endpoint, raw source body, or session material is included. Browser reload and back/forward navigation preserve this state. The UI explicitly renders loading, empty, partial/degraded, stale, recovered, interrupted, waiting, and API-version-mismatch states; status meaning is always present as text rather than color alone. | 124 | Local deep links use URL search parameters: `view`, opaque `cache`, public repository id `repo`, canonical or legacy `registration`, repository `tab`, diagnostic state, public `job`, job state/type/cache/repository/failure filters, and Search Lab `q`, `kind`, `provenance`, and `limit`. A legacy registration deep link is replaced with its canonical public id and an in-page redirect explanation. No cache path, credential, provider endpoint, raw source body, or session material is included. Browser reload and back/forward navigation preserve this state. The UI explicitly renders loading, empty, partial/degraded, stale, recovered, interrupted, waiting, and API-version-mismatch states; status meaning is always present as text rather than color alone. |
| 125 | 125 | ||
| 126 | +Maintenance selection and submitted intent share one opaque cache/repository | ||
| 127 | +identity across Caches navigation, registration links, reload and history. The | ||
| 128 | +target remains URL-addressable before enrollment too. A same-target snapshot | ||
| 129 | +refresh preserves unsaved policy edits; changing target clears its old plan, | ||
| 130 | +receipt and target-bound confirmation. A replaced or unavailable registration ID | ||
| 131 | +also invalidates its pending confirmation, even within the same cache/repository. | ||
| 132 | +Late planning or control responses cannot | ||
| 133 | +replace the current target or edited policy's state. An explicit unavailable | ||
| 134 | +repository, cache or registration remains unavailable until the operator selects | ||
| 135 | +a current target; it never silently falls back to another repository. Immutable | ||
| 136 | +conflict-resolution plans retain their durable key for lost-response replay. | ||
| 137 | + | ||
| 126 | ## Themes | 138 | ## Themes |
| 127 | 139 | ||
| 128 | The visible selector has exactly three choices: **Light**, **Dark**, and **System**. **System is the default** when no preference is saved or an invalid value is found. It follows `prefers-color-scheme`; explicit Light and Dark choices are stored only in browser-local storage. A small external head script applies the choice before the application starts, avoiding a first-paint theme flash while keeping the CSP free of inline script allowances. | 140 | The visible selector has exactly three choices: **Light**, **Dark**, and **System**. **System is the default** when no preference is saved or an invalid value is found. It follows `prefers-color-scheme`; explicit Light and Dark choices are stored only in browser-local storage. A small external head script applies the choice before the application starts, avoiding a first-paint theme flash while keeping the CSP free of inline script allowances. |
Rinternal/adminui/assets/_app/immutable/entry/app.D3MXzpdv.js→internal/adminui/assets/_app/immutable/entry/app.C3DQiGbd.js+2-2
| @@ -1,2 +1,2 @@ | |||
| 1 | -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["../nodes/0.BpU76GJV.js","../chunks/DLaOuNhp.js","../chunks/Vw_9FM2e.js","../chunks/CF2b_dDT.js","../chunks/BojSzMqI.js","../nodes/1.J3pyGi2p.js","../chunks/B030zhyR.js","../chunks/CGe5bBV9.js","../chunks/CDSuRcmi.js","../nodes/2.DyqNvmtI.js","../chunks/D5ZZnG0e.js","../assets/2.45OGHqsa.css"])))=>i.map(i=>d[i]); | 1 | +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["../nodes/0.BpU76GJV.js","../chunks/DLaOuNhp.js","../chunks/Vw_9FM2e.js","../chunks/CF2b_dDT.js","../chunks/BojSzMqI.js","../nodes/1.J3pyGi2p.js","../chunks/B030zhyR.js","../chunks/CGe5bBV9.js","../chunks/CDSuRcmi.js","../nodes/2.DDMJuMk3.js","../chunks/D5ZZnG0e.js","../assets/2.45OGHqsa.css"])))=>i.map(i=>d[i]); |
| 2 | -import{s as p,L as G,g as f,f as W,d as Y,m as z,p as F,u as H,a as J,t as K,b as k,c as Q,e as X,h as x,i as Z,r as $,j as ee,k as L}from"../chunks/Vw_9FM2e.js";import{h as te,m as re,u as se,s as ne}from"../chunks/B030zhyR.js";import{a as b,c as j,f as V,t as ae}from"../chunks/DLaOuNhp.js";import{o as oe}from"../chunks/CDSuRcmi.js";import{p as S,i as A,c as C,b as T}from"../chunks/D5ZZnG0e.js";const ce="modulepreload",ie=function(a,e){return new URL(a,e).href},I={},O=function(e,n,i){let l=Promise.resolve();if(n&&n.length>0){let R=function(c){return Promise.all(c.map(m=>Promise.resolve(m).then(d=>({status:"fulfilled",value:d}),d=>({status:"rejected",reason:d}))))};const t=document.getElementsByTagName("link"),s=document.querySelector("meta[property=csp-nonce]"),w=s?.nonce||s?.getAttribute("nonce");l=R(n.map(c=>{if(c=ie(c,i),c in I)return;I[c]=!0;const m=c.endsWith(".css"),d=m?'[rel="stylesheet"]':"";if(i)for(let h=t.length-1;h>=0;h--){const o=t[h];if(o.href===c&&(!m||o.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${c}"]${d}`))return;const u=document.createElement("link");if(u.rel=m?"stylesheet":ce,m||(u.as="script"),u.crossOrigin="",u.href=c,w&&u.setAttribute("nonce",w),document.head.appendChild(u),m)return new Promise((h,o)=>{u.addEventListener("load",h),u.addEventListener("error",()=>o(new Error(`Unable to preload CSS for ${c}`)))})}))}function r(t){const s=new Event("vite:preloadError",{cancelable:!0});if(s.payload=t,window.dispatchEvent(s),!s.defaultPrevented)throw t}return l.then(t=>{for(const s of t||[])s.status==="rejected"&&r(s.reason);return e().catch(r)})},Pe={};function le(a){return class extends ue{constructor(e){super({component:a,...e})}}}class ue{#t;#e;constructor(e){var n=new Map,i=(r,t)=>{var s=z(t,!1,!1);return n.set(r,s),s};const l=new Proxy({...e.props||{},$$events:{}},{get(r,t){return f(n.get(t)??i(t,Reflect.get(r,t)))},has(r,t){return t===G?!0:(f(n.get(t)??i(t,Reflect.get(r,t))),Reflect.has(r,t))},set(r,t,s){return p(n.get(t)??i(t,s),s),Reflect.set(r,t,s)}});this.#e=(e.hydrate?te:re)(e.component,{target:e.target,anchor:e.anchor,props:l,context:e.context,intro:e.intro??!1,recover:e.recover,transformError:e.transformError}),(!e?.props?.$$host||e.sync===!1)&&W(),this.#t=l.$$events;for(const r of Object.keys(this.#e))r==="$set"||r==="$destroy"||r==="$on"||Y(this,r,{get(){return this.#e[r]},set(t){this.#e[r]=t},enumerable:!0});this.#e.$set=r=>{Object.assign(l,r)},this.#e.$destroy=()=>{se(this.#e)}}$set(e){this.#e.$set(e)}$on(e,n){this.#t[e]=this.#t[e]||[];const i=(...l)=>n.call(this,...l);return this.#t[e].push(i),()=>{this.#t[e]=this.#t[e].filter(l=>l!==i)}}$destroy(){this.#e.$destroy()}}var me=V('<div id="svelte-announcer" aria-live="assertive" aria-atomic="true" style="position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px"><!></div>'),fe=V("<!> <!>",1);function de(a,e){F(e,!0);let n=S(e,"components",23,()=>[]),i=S(e,"data_0",3,null),l=S(e,"data_1",3,null);H(()=>e.stores.page.set(e.page)),J(()=>{e.stores,e.page,e.constructors,n(),e.form,i(),l(),e.stores.page.notify()});let r=x(!1),t=x(!1),s=x(null);oe(()=>{const o=e.stores.page.subscribe(()=>{f(r)&&(p(t,!0),K().then(()=>{p(s,document.title||"untitled page",!0)}))});return p(r,!0),o});const w=L(()=>e.constructors[1]);var R=fe(),c=k(R);{var m=o=>{const _=L(()=>e.constructors[0]);var v=j(),E=k(v);C(E,()=>f(_),(g,y)=>{T(y(g,{get data(){return i()},get form(){return e.form},get params(){return e.page.params},children:(P,_e)=>{var D=j(),N=k(D);C(N,()=>f(w),(q,U)=>{T(U(q,{get data(){return l()},get form(){return e.form},get params(){return e.page.params}}),B=>n()[1]=B,()=>n()?.[1])}),b(P,D)},$$slots:{default:!0}}),P=>n()[0]=P,()=>n()?.[0])}),b(o,v)},d=o=>{const _=L(()=>e.constructors[0]);var v=j(),E=k(v);C(E,()=>f(_),(g,y)=>{T(y(g,{get data(){return i()},get form(){return e.form},get params(){return e.page.params}}),P=>n()[0]=P,()=>n()?.[0])}),b(o,v)};A(c,o=>{e.constructors[1]?o(m):o(d,-1)})}var u=Q(c,2);{var h=o=>{var _=me(),v=Z(_);{var E=g=>{var y=ae();ee(()=>ne(y,f(s))),b(g,y)};A(v,g=>{f(t)&&g(E)})}$(_),b(o,_)};A(u,o=>{f(r)&&o(h)})}b(a,R),X()}const we=le(de),Re=[()=>O(()=>import("../nodes/0.BpU76GJV.js"),__vite__mapDeps([0,1,2,3,4]),import.meta.url),()=>O(()=>import("../nodes/1.J3pyGi2p.js"),__vite__mapDeps([5,1,2,6,7,8]),import.meta.url),()=>O(()=>import("../nodes/2.DyqNvmtI.js"),__vite__mapDeps([9,1,2,8,6,10,4,3,11]),import.meta.url)],ke=[],pe={"/":[2]},M={handleError:(({error:a})=>{console.error(a)}),reroute:(()=>{}),transport:{}},he=Object.fromEntries(Object.entries(M.transport).map(([a,e])=>[a,e.decode])),Oe=Object.fromEntries(Object.entries(M.transport).map(([a,e])=>[a,e.encode])),xe=!1,Le=(a,e)=>he[a](e),je=()=>O(()=>import("../chunks/wbPk3Yxo.js"),[],import.meta.url).then(a=>a.default);export{Le as decode,he as decoders,pe as dictionary,Oe as encoders,je as get_error_template,xe as hash,M as hooks,Pe as matchers,Re as nodes,we as root,ke as server_loads}; | 2 | +import{s as p,L as G,g as f,f as W,d as Y,m as z,p as F,u as H,a as J,t as K,b as k,c as Q,e as X,h as x,i as Z,r as $,j as ee,k as L}from"../chunks/Vw_9FM2e.js";import{h as te,m as re,u as se,s as ne}from"../chunks/B030zhyR.js";import{a as b,c as j,f as V,t as ae}from"../chunks/DLaOuNhp.js";import{o as oe}from"../chunks/CDSuRcmi.js";import{p as S,i as A,c as C,b as T}from"../chunks/D5ZZnG0e.js";const ce="modulepreload",ie=function(a,e){return new URL(a,e).href},I={},O=function(e,n,i){let l=Promise.resolve();if(n&&n.length>0){let R=function(c){return Promise.all(c.map(m=>Promise.resolve(m).then(d=>({status:"fulfilled",value:d}),d=>({status:"rejected",reason:d}))))};const t=document.getElementsByTagName("link"),s=document.querySelector("meta[property=csp-nonce]"),w=s?.nonce||s?.getAttribute("nonce");l=R(n.map(c=>{if(c=ie(c,i),c in I)return;I[c]=!0;const m=c.endsWith(".css"),d=m?'[rel="stylesheet"]':"";if(i)for(let h=t.length-1;h>=0;h--){const o=t[h];if(o.href===c&&(!m||o.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${c}"]${d}`))return;const u=document.createElement("link");if(u.rel=m?"stylesheet":ce,m||(u.as="script"),u.crossOrigin="",u.href=c,w&&u.setAttribute("nonce",w),document.head.appendChild(u),m)return new Promise((h,o)=>{u.addEventListener("load",h),u.addEventListener("error",()=>o(new Error(`Unable to preload CSS for ${c}`)))})}))}function r(t){const s=new Event("vite:preloadError",{cancelable:!0});if(s.payload=t,window.dispatchEvent(s),!s.defaultPrevented)throw t}return l.then(t=>{for(const s of t||[])s.status==="rejected"&&r(s.reason);return e().catch(r)})},Pe={};function le(a){return class extends ue{constructor(e){super({component:a,...e})}}}class ue{#t;#e;constructor(e){var n=new Map,i=(r,t)=>{var s=z(t,!1,!1);return n.set(r,s),s};const l=new Proxy({...e.props||{},$$events:{}},{get(r,t){return f(n.get(t)??i(t,Reflect.get(r,t)))},has(r,t){return t===G?!0:(f(n.get(t)??i(t,Reflect.get(r,t))),Reflect.has(r,t))},set(r,t,s){return p(n.get(t)??i(t,s),s),Reflect.set(r,t,s)}});this.#e=(e.hydrate?te:re)(e.component,{target:e.target,anchor:e.anchor,props:l,context:e.context,intro:e.intro??!1,recover:e.recover,transformError:e.transformError}),(!e?.props?.$$host||e.sync===!1)&&W(),this.#t=l.$$events;for(const r of Object.keys(this.#e))r==="$set"||r==="$destroy"||r==="$on"||Y(this,r,{get(){return this.#e[r]},set(t){this.#e[r]=t},enumerable:!0});this.#e.$set=r=>{Object.assign(l,r)},this.#e.$destroy=()=>{se(this.#e)}}$set(e){this.#e.$set(e)}$on(e,n){this.#t[e]=this.#t[e]||[];const i=(...l)=>n.call(this,...l);return this.#t[e].push(i),()=>{this.#t[e]=this.#t[e].filter(l=>l!==i)}}$destroy(){this.#e.$destroy()}}var me=V('<div id="svelte-announcer" aria-live="assertive" aria-atomic="true" style="position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px"><!></div>'),fe=V("<!> <!>",1);function de(a,e){F(e,!0);let n=S(e,"components",23,()=>[]),i=S(e,"data_0",3,null),l=S(e,"data_1",3,null);H(()=>e.stores.page.set(e.page)),J(()=>{e.stores,e.page,e.constructors,n(),e.form,i(),l(),e.stores.page.notify()});let r=x(!1),t=x(!1),s=x(null);oe(()=>{const o=e.stores.page.subscribe(()=>{f(r)&&(p(t,!0),K().then(()=>{p(s,document.title||"untitled page",!0)}))});return p(r,!0),o});const w=L(()=>e.constructors[1]);var R=fe(),c=k(R);{var m=o=>{const _=L(()=>e.constructors[0]);var v=j(),E=k(v);C(E,()=>f(_),(g,y)=>{T(y(g,{get data(){return i()},get form(){return e.form},get params(){return e.page.params},children:(P,_e)=>{var D=j(),N=k(D);C(N,()=>f(w),(q,U)=>{T(U(q,{get data(){return l()},get form(){return e.form},get params(){return e.page.params}}),B=>n()[1]=B,()=>n()?.[1])}),b(P,D)},$$slots:{default:!0}}),P=>n()[0]=P,()=>n()?.[0])}),b(o,v)},d=o=>{const _=L(()=>e.constructors[0]);var v=j(),E=k(v);C(E,()=>f(_),(g,y)=>{T(y(g,{get data(){return i()},get form(){return e.form},get params(){return e.page.params}}),P=>n()[0]=P,()=>n()?.[0])}),b(o,v)};A(c,o=>{e.constructors[1]?o(m):o(d,-1)})}var u=Q(c,2);{var h=o=>{var _=me(),v=Z(_);{var E=g=>{var y=ae();ee(()=>ne(y,f(s))),b(g,y)};A(v,g=>{f(t)&&g(E)})}$(_),b(o,_)};A(u,o=>{f(r)&&o(h)})}b(a,R),X()}const we=le(de),Re=[()=>O(()=>import("../nodes/0.BpU76GJV.js"),__vite__mapDeps([0,1,2,3,4]),import.meta.url),()=>O(()=>import("../nodes/1.J3pyGi2p.js"),__vite__mapDeps([5,1,2,6,7,8]),import.meta.url),()=>O(()=>import("../nodes/2.DDMJuMk3.js"),__vite__mapDeps([9,1,2,8,6,10,4,3,11]),import.meta.url)],ke=[],pe={"/":[2]},M={handleError:(({error:a})=>{console.error(a)}),reroute:(()=>{}),transport:{}},he=Object.fromEntries(Object.entries(M.transport).map(([a,e])=>[a,e.decode])),Oe=Object.fromEntries(Object.entries(M.transport).map(([a,e])=>[a,e.encode])),xe=!1,Le=(a,e)=>he[a](e),je=()=>O(()=>import("../chunks/wbPk3Yxo.js"),[],import.meta.url).then(a=>a.default);export{Le as decode,he as decoders,pe as dictionary,Oe as encoders,je as get_error_template,xe as hash,M as hooks,Pe as matchers,Re as nodes,we as root,ke as server_loads}; |
| @@ -6,12 +6,12 @@ | |||
| 6 | <meta name="color-scheme" content="light dark" /> | 6 | <meta name="color-scheme" content="light dark" /> |
| 7 | <link rel="icon" href="data:," /> | 7 | <link rel="icon" href="data:," /> |
| 8 | <script src="/theme-init.js"></script> | 8 | <script src="/theme-init.js"></script> |
| 9 | - <meta http-equiv="content-security-policy" content="default-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; script-src 'self' 'sha256-f3suqcTmkaUfUdFkMsfh5shbxt5AN3HOKJvadNz0mdM='; style-src 'self'; base-uri 'none'; form-action 'self'"> | 9 | + <meta http-equiv="content-security-policy" content="default-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; script-src 'self' 'sha256-W+gs+xBH14sxetxNgf9ilPYnxUVukbFm4buq3Ya+aGA='; style-src 'self'; base-uri 'none'; form-action 'self'"> |
| 10 | <link href="/_app/immutable/entry/start.zyF_fiPG.js" rel="modulepreload"> | 10 | <link href="/_app/immutable/entry/start.zyF_fiPG.js" rel="modulepreload"> |
| 11 | <link href="/_app/immutable/chunks/Vw_9FM2e.js" rel="modulepreload"> | 11 | <link href="/_app/immutable/chunks/Vw_9FM2e.js" rel="modulepreload"> |
| 12 | <link href="/_app/immutable/chunks/CGe5bBV9.js" rel="modulepreload"> | 12 | <link href="/_app/immutable/chunks/CGe5bBV9.js" rel="modulepreload"> |
| 13 | <link href="/_app/immutable/chunks/CDSuRcmi.js" rel="modulepreload"> | 13 | <link href="/_app/immutable/chunks/CDSuRcmi.js" rel="modulepreload"> |
| 14 | - <link href="/_app/immutable/entry/app.D3MXzpdv.js" rel="modulepreload"> | 14 | + <link href="/_app/immutable/entry/app.C3DQiGbd.js" rel="modulepreload"> |
| 15 | <link href="/_app/immutable/chunks/B030zhyR.js" rel="modulepreload"> | 15 | <link href="/_app/immutable/chunks/B030zhyR.js" rel="modulepreload"> |
| 16 | <link href="/_app/immutable/chunks/DLaOuNhp.js" rel="modulepreload"> | 16 | <link href="/_app/immutable/chunks/DLaOuNhp.js" rel="modulepreload"> |
| 17 | <link href="/_app/immutable/chunks/D5ZZnG0e.js" rel="modulepreload"> | 17 | <link href="/_app/immutable/chunks/D5ZZnG0e.js" rel="modulepreload"> |
| @@ -32,7 +32,7 @@ | |||
| 32 | 32 | ||
| 33 | Promise.all([ | 33 | Promise.all([ |
| 34 | import("/_app/immutable/entry/start.zyF_fiPG.js"), | 34 | import("/_app/immutable/entry/start.zyF_fiPG.js"), |
| 35 | - import("/_app/immutable/entry/app.D3MXzpdv.js") | 35 | + import("/_app/immutable/entry/app.C3DQiGbd.js") |
| 36 | ]).then(([kit, app]) => { | 36 | ]).then(([kit, app]) => { |
| 37 | kit.start(app, element); | 37 | kit.start(app, element); |
| 38 | }); | 38 | }); |
| @@ -66,6 +66,7 @@ | |||
| 66 | let stale = false; | 66 | let stale = false; |
| 67 | let maintenanceTargetKey = ''; | 67 | let maintenanceTargetKey = ''; |
| 68 | let maintenanceIntent: MaintenanceIntent = { cache_ref: '', repo_id: '', sync_mode: 'head-and-backfill', collections: ['issues', 'wiki'], rag_mode: 'off' }; | 68 | let maintenanceIntent: MaintenanceIntent = { cache_ref: '', repo_id: '', sync_mode: 'head-and-backfill', collections: ['issues', 'wiki'], rag_mode: 'off' }; |
| 69 | + let maintenanceIntentGeneration = 0; | ||
| 69 | let maintenancePlan: MaintenancePlan | undefined; | 70 | let maintenancePlan: MaintenancePlan | undefined; |
| 70 | let maintenanceReceipt: ControlReceipt | undefined; | 71 | let maintenanceReceipt: ControlReceipt | undefined; |
| 71 | let maintenanceError = ''; | 72 | let maintenanceError = ''; |
| @@ -78,6 +79,7 @@ | |||
| 78 | let controlRunning = false; | 79 | let controlRunning = false; |
| 79 | let pendingControl: 'maintenance_apply' | 'binding_apply' | 'rag_repair_apply' | 'conflict_resolution_apply' | 'disable' | 'reconcile' | 'repository_docs_index' | '' = ''; | 80 | let pendingControl: 'maintenance_apply' | 'binding_apply' | 'rag_repair_apply' | 'conflict_resolution_apply' | 'disable' | 'reconcile' | 'repository_docs_index' | '' = ''; |
| 80 | let pendingControlKey = ''; | 81 | let pendingControlKey = ''; |
| 82 | + let pendingControlRegistrationID = ''; | ||
| 81 | let controlDialog: HTMLDialogElement | undefined; | 83 | let controlDialog: HTMLDialogElement | undefined; |
| 82 | let controlConfirmButton: HTMLButtonElement | undefined; | 84 | let controlConfirmButton: HTMLButtonElement | undefined; |
| 83 | let controlTriggerButton: HTMLButtonElement | undefined; | 85 | let controlTriggerButton: HTMLButtonElement | undefined; |
| @@ -146,6 +148,7 @@ | |||
| 146 | $: if (!repositoryDocsSources.some((source) => source.source_registration_id === repositoryDocsSourceID)) repositoryDocsSourceID = repositoryDocsSources[0]?.source_registration_id || ''; | 148 | $: if (!repositoryDocsSources.some((source) => source.source_registration_id === repositoryDocsSourceID)) repositoryDocsSourceID = repositoryDocsSources[0]?.source_registration_id || ''; |
| 147 | $: selectedRepositoryDocsSource = repositoryDocsSources.find((source) => source.source_registration_id === repositoryDocsSourceID); | 149 | $: selectedRepositoryDocsSource = repositoryDocsSources.find((source) => source.source_registration_id === repositoryDocsSourceID); |
| 148 | $: repoTargets = snapshot.caches.flatMap((cache) => cache.repositories.map((repo) => ({ key: `${cache.cache_ref}\u0000${repo.repo_id}`, cache, repo }))); | 150 | $: repoTargets = snapshot.caches.flatMap((cache) => cache.repositories.map((repo) => ({ key: `${cache.cache_ref}\u0000${repo.repo_id}`, cache, repo }))); |
| 151 | + $: maintenanceTargetAvailable = repoTargets.some((target) => target.key === maintenanceTargetKey) && (!requestedRegistrationID || snapshot.maintenance.some((item) => item.registration_id === requestedRegistrationID || item.legacy_registration_ids?.includes(requestedRegistrationID))); | ||
| 149 | 152 | ||
| 150 | function normalizeSnapshot(value: ObservationSnapshot): ObservationSnapshot { | 153 | function normalizeSnapshot(value: ObservationSnapshot): ObservationSnapshot { |
| 151 | value.attention ||= []; value.caches ||= []; value.jobs ||= []; value.maintenance ||= []; value.diagnostics ||= []; value.capabilities ||= []; | 154 | value.attention ||= []; value.caches ||= []; value.jobs ||= []; value.maintenance ||= []; value.diagnostics ||= []; value.capabilities ||= []; |
| @@ -227,7 +230,7 @@ | |||
| 227 | if (!response.ok) throw new Error(response.status === 401 ? 'Admin session required. Run admin open again.' : 'Observation is unavailable.'); | 230 | if (!response.ok) throw new Error(response.status === 401 ? 'Admin session required. Run admin open again.' : 'Observation is unavailable.'); |
| 228 | const nextSnapshot = normalizeSnapshot(await response.json()); | 231 | const nextSnapshot = normalizeSnapshot(await response.json()); |
| 229 | snapshot = nextSnapshot; | 232 | snapshot = nextSnapshot; |
| 230 | - if (selectedCacheRef && selectedRepoID) maintenanceTargetKey = `${selectedCacheRef}\u0000${selectedRepoID}`; | 233 | + if (selectedCacheRef && selectedRepoID) synchronizeMaintenanceTarget(`${selectedCacheRef}\u0000${selectedRepoID}`); |
| 231 | ensureControlSelections(); | 234 | ensureControlSelections(); |
| 232 | canonicalizeRegistrationDeepLink(); | 235 | canonicalizeRegistrationDeepLink(); |
| 233 | } catch (value) { error = value instanceof Error ? value.message : 'Observation is unavailable.'; } | 236 | } catch (value) { error = value instanceof Error ? value.message : 'Observation is unavailable.'; } |
| @@ -289,12 +292,15 @@ | |||
| 289 | 292 | ||
| 290 | function selectView(view: AdminView): void { | 293 | function selectView(view: AdminView): void { |
| 291 | active = view; | 294 | active = view; |
| 292 | - if (view !== 'Caches') { selectedCacheRef = ''; selectedRepoID = ''; } | 295 | + if (view === 'Maintenance') { |
| 296 | + selectedCacheRef = maintenanceIntent.cache_ref; selectedRepoID = maintenanceIntent.repo_id; | ||
| 297 | + requestedRegistrationID = snapshot.maintenance.find((item) => item.cache_ref === selectedCacheRef && item.repo_id === selectedRepoID)?.registration_id || ''; | ||
| 298 | + } else if (view !== 'Caches') { selectedCacheRef = ''; selectedRepoID = ''; } | ||
| 293 | if (view !== 'Jobs') selectedJobID = ''; | 299 | if (view !== 'Jobs') selectedJobID = ''; |
| 294 | if (view !== 'Maintenance') { requestedRegistrationID = ''; registrationRedirectNotice = ''; } | 300 | if (view !== 'Maintenance') { requestedRegistrationID = ''; registrationRedirectNotice = ''; } |
| 295 | updateLocation(); | 301 | updateLocation(); |
| 296 | } | 302 | } |
| 297 | - function openRepository(cache: CacheObservation, repo: Repository): void { active = 'Caches'; selectedCacheRef = cache.cache_ref; selectedRepoID = repo.repo_id; maintenanceTargetKey = `${cache.cache_ref}\u0000${repo.repo_id}`; repoTab = 'coverage'; searchComparison = undefined; searchError = ''; providerSmoke = undefined; repairPlan = undefined; repairReceipt = undefined; repositoryDocsResult = undefined; repositoryDocsPlan = undefined; repositoryDocsError = ''; updateLocation(); } | 303 | + function openRepository(cache: CacheObservation, repo: Repository): void { active = 'Caches'; selectedCacheRef = cache.cache_ref; selectedRepoID = repo.repo_id; synchronizeMaintenanceTarget(`${cache.cache_ref}\u0000${repo.repo_id}`); repoTab = 'coverage'; searchComparison = undefined; searchError = ''; providerSmoke = undefined; repairPlan = undefined; repairReceipt = undefined; repositoryDocsResult = undefined; repositoryDocsPlan = undefined; repositoryDocsError = ''; updateLocation(); } |
| 298 | function closeRepository(): void { selectedRepoID = ''; repoTab = 'coverage'; updateLocation(); } | 304 | function closeRepository(): void { selectedRepoID = ''; repoTab = 'coverage'; updateLocation(); } |
| 299 | function selectRepositoryTab(value: RepositoryTab): void { repoTab = value; updateLocation(); } | 305 | function selectRepositoryTab(value: RepositoryTab): void { repoTab = value; updateLocation(); } |
| 300 | function selectDiagnosticFilter(value: 'current' | 'recovered' | 'all'): void { diagnosticsFilter = value; updateLocation(); } | 306 | function selectDiagnosticFilter(value: 'current' | 'recovered' | 'all'): void { diagnosticsFilter = value; updateLocation(); } |
| @@ -422,10 +428,20 @@ | |||
| 422 | 428 | ||
| 423 | function ensureControlSelections(): void { | 429 | function ensureControlSelections(): void { |
| 424 | const targets = snapshot.caches.flatMap((cache) => cache.repositories.map((repo) => ({ key: `${cache.cache_ref}\u0000${repo.repo_id}`, cache, repo }))); | 430 | const targets = snapshot.caches.flatMap((cache) => cache.repositories.map((repo) => ({ key: `${cache.cache_ref}\u0000${repo.repo_id}`, cache, repo }))); |
| 425 | - if (!targets.some((target) => target.key === maintenanceTargetKey)) { | 431 | + let target = targets.find((target) => target.key === maintenanceTargetKey); |
| 432 | + if (!target && !maintenanceTargetKey && !requestedRegistrationID) { | ||
| 426 | const registration = snapshot.maintenance[0]; | 433 | const registration = snapshot.maintenance[0]; |
| 427 | - const target = registration ? targets.find((item) => item.cache.cache_ref === registration.cache_ref && item.repo.repo_id === registration.repo_id) : targets[0]; | 434 | + target = registration ? targets.find((item) => item.cache.cache_ref === registration.cache_ref && item.repo.repo_id === registration.repo_id) : targets[0]; |
| 428 | - if (target) loadMaintenanceTarget(target.key, false); | 435 | + } |
| 436 | + const registrationAvailable = !requestedRegistrationID || snapshot.maintenance.some((item) => item.registration_id === requestedRegistrationID || item.legacy_registration_ids?.includes(requestedRegistrationID)); | ||
| 437 | + const currentRegistration = snapshot.maintenance.find((item) => `${item.cache_ref}\u0000${item.repo_id}` === maintenanceTargetKey); | ||
| 438 | + const confirmationReplaced = isTargetBoundControl(pendingControl) && pendingControlRegistrationID !== (currentRegistration?.registration_id || ''); | ||
| 439 | + if (target) synchronizeMaintenanceTarget(target.key); | ||
| 440 | + if (!target || !registrationAvailable || confirmationReplaced) { | ||
| 441 | + // An explicit target can disappear during refresh. Keep its identity, | ||
| 442 | + // but discard reviewed work instead of selecting a different repository. | ||
| 443 | + cancelTargetConfirmation(); | ||
| 444 | + invalidateMaintenancePlan(); | ||
| 429 | } | 445 | } |
| 430 | if (!bindingIntent.cache_ref && snapshot.caches[0]) bindingIntent = { ...bindingIntent, cache_ref: snapshot.caches[0].cache_ref }; | 446 | if (!bindingIntent.cache_ref && snapshot.caches[0]) bindingIntent = { ...bindingIntent, cache_ref: snapshot.caches[0].cache_ref }; |
| 431 | } | 447 | } |
| @@ -439,15 +455,27 @@ | |||
| 439 | const nextTargetKey = `${registration.cache_ref}\u0000${registration.repo_id}`; | 455 | const nextTargetKey = `${registration.cache_ref}\u0000${registration.repo_id}`; |
| 440 | requestedRegistrationID = registration.registration_id; | 456 | requestedRegistrationID = registration.registration_id; |
| 441 | registrationRedirectNotice = previous === registration.registration_id ? '' : `Redirected legacy registration ${previous} to canonical ${registration.registration_id}.`; | 457 | registrationRedirectNotice = previous === registration.registration_id ? '' : `Redirected legacy registration ${previous} to canonical ${registration.registration_id}.`; |
| 442 | - if (maintenanceTargetKey !== nextTargetKey) loadMaintenanceTarget(nextTargetKey, false); | 458 | + synchronizeMaintenanceTarget(nextTargetKey); |
| 443 | if (previous !== requestedRegistrationID) updateLocation(true); | 459 | if (previous !== requestedRegistrationID) updateLocation(true); |
| 444 | } | 460 | } |
| 445 | 461 | ||
| 462 | + function maintenanceIntentMatches(key: string): boolean { | ||
| 463 | + return `${maintenanceIntent.cache_ref}\u0000${maintenanceIntent.repo_id}` === key; | ||
| 464 | + } | ||
| 465 | + | ||
| 466 | + function synchronizeMaintenanceTarget(key: string): void { | ||
| 467 | + // Selection and submitted identity move together. A same-target refresh | ||
| 468 | + // must not overwrite the operator's unsaved policy edits or reviewed plan. | ||
| 469 | + if (maintenanceTargetKey !== key || !maintenanceIntentMatches(key)) loadMaintenanceTarget(key, false); | ||
| 470 | + } | ||
| 471 | + | ||
| 446 | function loadMaintenanceTarget(key: string, updateDeepLink = true): void { | 472 | function loadMaintenanceTarget(key: string, updateDeepLink = true): void { |
| 473 | + cancelTargetConfirmation(); | ||
| 447 | maintenanceTargetKey = key; | 474 | maintenanceTargetKey = key; |
| 448 | const [cacheRef, repoID] = key.split('\u0000'); | 475 | const [cacheRef, repoID] = key.split('\u0000'); |
| 449 | const registration = snapshot.maintenance.find((item) => item.cache_ref === cacheRef && item.repo_id === repoID); | 476 | const registration = snapshot.maintenance.find((item) => item.cache_ref === cacheRef && item.repo_id === repoID); |
| 450 | const repo = snapshot.caches.find((item) => item.cache_ref === cacheRef)?.repositories.find((item) => item.repo_id === repoID); | 477 | const repo = snapshot.caches.find((item) => item.cache_ref === cacheRef)?.repositories.find((item) => item.repo_id === repoID); |
| 478 | + if (active === 'Maintenance') { selectedCacheRef = cacheRef; selectedRepoID = repoID; } | ||
| 451 | maintenanceIntent = { | 479 | maintenanceIntent = { |
| 452 | cache_ref: cacheRef, repo_id: repoID, | 480 | cache_ref: cacheRef, repo_id: repoID, |
| 453 | sync_mode: registration?.policy.sync_enabled === false ? 'off' : registration?.policy.sync_mode || 'head-and-backfill', | 481 | sync_mode: registration?.policy.sync_enabled === false ? 'off' : registration?.policy.sync_mode || 'head-and-backfill', |
| @@ -456,10 +484,10 @@ | |||
| 456 | head_interval_seconds: registration?.policy.head_interval_seconds || 0, rag_interval_seconds: registration?.policy.rag_interval_seconds || 0, | 484 | head_interval_seconds: registration?.policy.head_interval_seconds || 0, rag_interval_seconds: registration?.policy.rag_interval_seconds || 0, |
| 457 | head_max_pages: registration?.policy.head_max_pages || 0, tail_slice_pages: registration?.policy.tail_slice_pages || 0, per_page: registration?.policy.per_page || 0 | 485 | head_max_pages: registration?.policy.head_max_pages || 0, tail_slice_pages: registration?.policy.tail_slice_pages || 0, per_page: registration?.policy.per_page || 0 |
| 458 | }; | 486 | }; |
| 459 | - maintenancePlan = undefined; maintenanceReceipt = undefined; maintenanceError = ''; maintenanceFailure = undefined; | 487 | + invalidateMaintenancePlan(); |
| 460 | selectedConflictCandidateRef = ''; conflictResolutionPlan = undefined; conflictResolutionReceipt = undefined; conflictResolutionError = ''; | 488 | selectedConflictCandidateRef = ''; conflictResolutionPlan = undefined; conflictResolutionReceipt = undefined; conflictResolutionError = ''; |
| 461 | - if (updateDeepLink && registration) { | 489 | + if (updateDeepLink) { |
| 462 | - requestedRegistrationID = registration.registration_id; | 490 | + requestedRegistrationID = registration?.registration_id || ''; |
| 463 | registrationRedirectNotice = ''; | 491 | registrationRedirectNotice = ''; |
| 464 | updateLocation(); | 492 | updateLocation(); |
| 465 | } | 493 | } |
| @@ -473,9 +501,21 @@ | |||
| 473 | } | 501 | } |
| 474 | 502 | ||
| 475 | function invalidateMaintenancePlan(): void { | 503 | function invalidateMaintenancePlan(): void { |
| 504 | + maintenanceIntentGeneration++; | ||
| 476 | maintenancePlan = undefined; maintenanceReceipt = undefined; maintenanceError = ''; maintenanceFailure = undefined; | 505 | maintenancePlan = undefined; maintenanceReceipt = undefined; maintenanceError = ''; maintenanceFailure = undefined; |
| 477 | } | 506 | } |
| 478 | 507 | ||
| 508 | + function isTargetBoundControl(kind: typeof pendingControl): boolean { | ||
| 509 | + return kind === 'maintenance_apply' || kind === 'disable' || kind === 'reconcile' || kind === 'repository_docs_index' || kind === 'rag_repair_apply'; | ||
| 510 | + } | ||
| 511 | + | ||
| 512 | + function cancelTargetConfirmation(): void { | ||
| 513 | + // Conflict resolution already uses its immutable reviewed registration and | ||
| 514 | + // must retain a lost-response retry key even when SSE replaces that row. | ||
| 515 | + if (!isTargetBoundControl(pendingControl)) return; | ||
| 516 | + controlDialog?.close(); pendingControl = ''; pendingControlKey = ''; pendingControlRegistrationID = ''; | ||
| 517 | + } | ||
| 518 | + | ||
| 479 | function invalidateBindingPlan(): void { | 519 | function invalidateBindingPlan(): void { |
| 480 | bindingPlan = undefined; bindingReceipt = undefined; bindingError = ''; | 520 | bindingPlan = undefined; bindingReceipt = undefined; bindingError = ''; |
| 481 | } | 521 | } |
| @@ -499,9 +539,18 @@ | |||
| 499 | 539 | ||
| 500 | async function renderMaintenancePlan(): Promise<void> { | 540 | async function renderMaintenancePlan(): Promise<void> { |
| 501 | if (!csrfToken || !maintenanceControlsEnabled) return; | 541 | if (!csrfToken || !maintenanceControlsEnabled) return; |
| 542 | + if (!maintenanceIntentMatches(maintenanceTargetKey) || !maintenanceTargetAvailable) { | ||
| 543 | + maintenanceError = 'Select a current repository in the managed cache.'; | ||
| 544 | + return; | ||
| 545 | + } | ||
| 546 | + const generation = maintenanceIntentGeneration; | ||
| 502 | controlRunning = true; maintenanceError = ''; maintenanceFailure = undefined; maintenanceReceipt = undefined; | 547 | controlRunning = true; maintenanceError = ''; maintenanceFailure = undefined; maintenanceReceipt = undefined; |
| 503 | - try { maintenancePlan = await controlPost<MaintenancePlan>('/api/admin/v1/maintenance/plan', maintenanceIntent); } | 548 | + try { |
| 549 | + const plan = await controlPost<MaintenancePlan>('/api/admin/v1/maintenance/plan', maintenanceIntent); | ||
| 550 | + if (generation === maintenanceIntentGeneration) maintenancePlan = plan; | ||
| 551 | + } | ||
| 504 | catch (value) { | 552 | catch (value) { |
| 553 | + if (generation !== maintenanceIntentGeneration) return; | ||
| 505 | maintenancePlan = undefined; | 554 | maintenancePlan = undefined; |
| 506 | maintenanceFailure = value instanceof Error && 'failure' in value ? (value as Error & { failure: ControlFailure }).failure : undefined; | 555 | maintenanceFailure = value instanceof Error && 'failure' in value ? (value as Error & { failure: ControlFailure }).failure : undefined; |
| 507 | maintenanceError = maintenanceFailure?.message || (value instanceof Error ? value.message : 'Maintenance planning failed.'); | 556 | maintenanceError = maintenanceFailure?.message || (value instanceof Error ? value.message : 'Maintenance planning failed.'); |
| @@ -576,26 +625,37 @@ | |||
| 576 | } | 625 | } |
| 577 | 626 | ||
| 578 | async function confirmControl(kind: typeof pendingControl, trigger: HTMLButtonElement): Promise<void> { | 627 | async function confirmControl(kind: typeof pendingControl, trigger: HTMLButtonElement): Promise<void> { |
| 628 | + if (isTargetBoundControl(kind) && !maintenanceTargetAvailable) return; | ||
| 579 | controlTriggerButton = trigger; pendingControl = kind; pendingControlKey = `admin-${kind}-${crypto.randomUUID()}`; | 629 | controlTriggerButton = trigger; pendingControl = kind; pendingControlKey = `admin-${kind}-${crypto.randomUUID()}`; |
| 630 | + pendingControlRegistrationID = isTargetBoundControl(kind) ? selectedMaintenance?.registration_id || '' : ''; | ||
| 580 | maintenanceError = ''; maintenanceFailure = undefined; bindingError = ''; await tick(); controlDialog?.showModal(); controlConfirmButton?.focus(); | 631 | maintenanceError = ''; maintenanceFailure = undefined; bindingError = ''; await tick(); controlDialog?.showModal(); controlConfirmButton?.focus(); |
| 581 | } | 632 | } |
| 582 | 633 | ||
| 583 | async function cancelControlConfirmation(): Promise<void> { | 634 | async function cancelControlConfirmation(): Promise<void> { |
| 584 | - controlDialog?.close(); pendingControl = ''; pendingControlKey = ''; await tick(); controlTriggerButton?.focus(); | 635 | + controlDialog?.close(); pendingControl = ''; pendingControlKey = ''; pendingControlRegistrationID = ''; await tick(); controlTriggerButton?.focus(); |
| 585 | } | 636 | } |
| 586 | 637 | ||
| 587 | async function executeControl(): Promise<void> { | 638 | async function executeControl(): Promise<void> { |
| 588 | if (!pendingControl || !csrfToken) return; | 639 | if (!pendingControl || !csrfToken) return; |
| 640 | + if (isTargetBoundControl(pendingControl) && (!maintenanceTargetAvailable || pendingControlRegistrationID !== (selectedMaintenance?.registration_id || ''))) { | ||
| 641 | + cancelTargetConfirmation(); invalidateMaintenancePlan(); return; | ||
| 642 | + } | ||
| 643 | + const kind = pendingControl; | ||
| 644 | + const generation = maintenanceIntentGeneration; | ||
| 589 | controlRunning = true; | 645 | controlRunning = true; |
| 590 | if (pendingControl === 'conflict_resolution_apply') conflictResolutionError = ''; | 646 | if (pendingControl === 'conflict_resolution_apply') conflictResolutionError = ''; |
| 591 | let retainedConflictReceipt: ControlReceipt | undefined; | 647 | let retainedConflictReceipt: ControlReceipt | undefined; |
| 592 | try { | 648 | try { |
| 593 | if (pendingControl === 'maintenance_apply' && maintenancePlan) { | 649 | if (pendingControl === 'maintenance_apply' && maintenancePlan) { |
| 594 | - maintenanceReceipt = await controlPost<ControlReceipt>('/api/admin/v1/maintenance/apply', { ...maintenanceIntent, plan_id: maintenancePlan.plan_id, idempotency_key: pendingControlKey }); | 650 | + const receipt = await controlPost<ControlReceipt>('/api/admin/v1/maintenance/apply', { ...maintenanceIntent, plan_id: maintenancePlan.plan_id, idempotency_key: pendingControlKey }); |
| 651 | + if (generation !== maintenanceIntentGeneration) return; | ||
| 652 | + maintenanceReceipt = receipt; | ||
| 595 | } else if (pendingControl === 'binding_apply' && bindingPlan) { | 653 | } else if (pendingControl === 'binding_apply' && bindingPlan) { |
| 596 | bindingReceipt = await controlPost<ControlReceipt>('/api/admin/v1/bindings/apply', { ...bindingIntent, plan_id: bindingPlan.plan_id, idempotency_key: pendingControlKey }); | 654 | bindingReceipt = await controlPost<ControlReceipt>('/api/admin/v1/bindings/apply', { ...bindingIntent, plan_id: bindingPlan.plan_id, idempotency_key: pendingControlKey }); |
| 597 | } else if (pendingControl === 'rag_repair_apply' && repairPlan && selectedCache && selectedRepo) { | 655 | } else if (pendingControl === 'rag_repair_apply' && repairPlan && selectedCache && selectedRepo) { |
| 598 | - repairReceipt = await controlPost<ControlReceipt>('/api/admin/v1/rag/repair/apply', { cache_ref: selectedCache.cache_ref, repo_id: selectedRepo.repo_id, profile: repairProfile, max_chunks: repairMaxChunks, plan_id: repairPlan.plan_id, idempotency_key: pendingControlKey }); | 656 | + const receipt = await controlPost<ControlReceipt>('/api/admin/v1/rag/repair/apply', { cache_ref: selectedCache.cache_ref, repo_id: selectedRepo.repo_id, profile: repairProfile, max_chunks: repairMaxChunks, plan_id: repairPlan.plan_id, idempotency_key: pendingControlKey }); |
| 657 | + if (generation !== maintenanceIntentGeneration) return; | ||
| 658 | + repairReceipt = receipt; | ||
| 599 | } else if (pendingControl === 'conflict_resolution_apply' && conflictResolutionPlan) { | 659 | } else if (pendingControl === 'conflict_resolution_apply' && conflictResolutionPlan) { |
| 600 | // The reviewed registration is part of the durable intent. A clone | 660 | // The reviewed registration is part of the durable intent. A clone |
| 601 | // resolution can replace the selected row through SSE before a lost | 661 | // resolution can replace the selected row through SSE before a lost |
| @@ -603,14 +663,19 @@ | |||
| 603 | conflictResolutionReceipt = await controlPost<ControlReceipt>(`/api/admin/v1/maintenance/${encodeURIComponent(conflictResolutionPlan.registration_id)}/conflict-resolution/apply`, { candidate_ref: conflictResolutionPlan.selected.candidate_ref, expected_generation: conflictResolutionPlan.expected_generation, plan_id: conflictResolutionPlan.plan_id, idempotency_key: pendingControlKey }); | 663 | conflictResolutionReceipt = await controlPost<ControlReceipt>(`/api/admin/v1/maintenance/${encodeURIComponent(conflictResolutionPlan.registration_id)}/conflict-resolution/apply`, { candidate_ref: conflictResolutionPlan.selected.candidate_ref, expected_generation: conflictResolutionPlan.expected_generation, plan_id: conflictResolutionPlan.plan_id, idempotency_key: pendingControlKey }); |
| 604 | retainedConflictReceipt = conflictResolutionReceipt; | 664 | retainedConflictReceipt = conflictResolutionReceipt; |
| 605 | } else if ((pendingControl === 'disable' || pendingControl === 'reconcile') && selectedMaintenance) { | 665 | } else if ((pendingControl === 'disable' || pendingControl === 'reconcile') && selectedMaintenance) { |
| 606 | - maintenanceReceipt = await controlPost<ControlReceipt>(`/api/admin/v1/maintenance/${encodeURIComponent(selectedMaintenance.registration_id)}/${pendingControl}`, { idempotency_key: pendingControlKey }); | 666 | + const receipt = await controlPost<ControlReceipt>(`/api/admin/v1/maintenance/${encodeURIComponent(selectedMaintenance.registration_id)}/${pendingControl}`, { idempotency_key: pendingControlKey }); |
| 667 | + if (generation !== maintenanceIntentGeneration) return; | ||
| 668 | + maintenanceReceipt = receipt; | ||
| 607 | } else if (pendingControl === 'repository_docs_index' && selectedMaintenance) { | 669 | } else if (pendingControl === 'repository_docs_index' && selectedMaintenance) { |
| 608 | - maintenanceReceipt = await controlPost<ControlReceipt>(`/api/admin/v1/repository-docs/${encodeURIComponent(selectedMaintenance.registration_id)}/index`, { idempotency_key: pendingControlKey, ...repositoryDocsSelector() }); | 670 | + const receipt = await controlPost<ControlReceipt>(`/api/admin/v1/repository-docs/${encodeURIComponent(selectedMaintenance.registration_id)}/index`, { idempotency_key: pendingControlKey, ...repositoryDocsSelector() }); |
| 671 | + if (generation !== maintenanceIntentGeneration) return; | ||
| 672 | + maintenanceReceipt = receipt; | ||
| 609 | } | 673 | } |
| 610 | controlDialog?.close(); pendingControl = ''; pendingControlKey = ''; await refresh(); | 674 | controlDialog?.close(); pendingControl = ''; pendingControlKey = ''; await refresh(); |
| 611 | if (retainedConflictReceipt) conflictResolutionReceipt = retainedConflictReceipt; | 675 | if (retainedConflictReceipt) conflictResolutionReceipt = retainedConflictReceipt; |
| 612 | await tick(); controlTriggerButton?.focus(); | 676 | await tick(); controlTriggerButton?.focus(); |
| 613 | } catch (value) { | 677 | } catch (value) { |
| 678 | + if (isTargetBoundControl(kind) && generation !== maintenanceIntentGeneration) return; | ||
| 614 | const message = value instanceof Error ? value.message : 'The confirmed control failed.'; | 679 | const message = value instanceof Error ? value.message : 'The confirmed control failed.'; |
| 615 | const failure = value instanceof Error && 'failure' in value ? (value as Error & { failure: ControlFailure }).failure : undefined; | 680 | const failure = value instanceof Error && 'failure' in value ? (value as Error & { failure: ControlFailure }).failure : undefined; |
| 616 | if (pendingControl !== 'binding_apply' && pendingControl !== 'rag_repair_apply' && pendingControl !== 'conflict_resolution_apply') maintenanceFailure = failure; | 681 | if (pendingControl !== 'binding_apply' && pendingControl !== 'rag_repair_apply' && pendingControl !== 'conflict_resolution_apply') maintenanceFailure = failure; |
| @@ -631,7 +696,12 @@ | |||
| 631 | } finally { controlRunning = false; } | 696 | } finally { controlRunning = false; } |
| 632 | } | 697 | } |
| 633 | function selectTheme(value: Theme): void { theme = value; applyTheme(value); } | 698 | function selectTheme(value: Theme): void { theme = value; applyTheme(value); } |
| 634 | - function onPopState(): void { hydrateLocation(); } | 699 | + function onPopState(): void { |
| 700 | + hydrateLocation(); | ||
| 701 | + if (selectedCacheRef && selectedRepoID) synchronizeMaintenanceTarget(`${selectedCacheRef}\u0000${selectedRepoID}`); | ||
| 702 | + ensureControlSelections(); | ||
| 703 | + canonicalizeRegistrationDeepLink(); | ||
| 704 | + } | ||
| 635 | 705 | ||
| 636 | onMount(async () => { | 706 | onMount(async () => { |
| 637 | theme = normalizeTheme(localStorage.getItem(themeStorageKey)); applyTheme(theme); hydrateLocation(); window.addEventListener('popstate', onPopState); | 707 | theme = normalizeTheme(localStorage.getItem(themeStorageKey)); applyTheme(theme); hydrateLocation(); window.addEventListener('popstate', onPopState); |
| @@ -911,7 +981,8 @@ | |||
| 911 | <div class="control-heading"><div><span class="large-icon"><SlidersHorizontal size={22} /></span><div><p class="section-kicker">PLAN → CONFIRM → APPLY</p><h2 id="policy-editor-title">Maintenance policy</h2><p>Change a managed repository policy, review the effect ledger, then confirm the exact plan id.</p></div></div><StatusChip value={maintenancePlan?.status || selectedMaintenance?.state || 'not_planned'} label={maintenancePlan ? humanize(maintenancePlan.status) : selectedMaintenance ? humanize(selectedMaintenance.state) : 'Not planned'} /></div> | 981 | <div class="control-heading"><div><span class="large-icon"><SlidersHorizontal size={22} /></span><div><p class="section-kicker">PLAN → CONFIRM → APPLY</p><h2 id="policy-editor-title">Maintenance policy</h2><p>Change a managed repository policy, review the effect ledger, then confirm the exact plan id.</p></div></div><StatusChip value={maintenancePlan?.status || selectedMaintenance?.state || 'not_planned'} label={maintenancePlan ? humanize(maintenancePlan.status) : selectedMaintenance ? humanize(selectedMaintenance.state) : 'Not planned'} /></div> |
| 912 | {#if repoTargets.length === 0}<div class="empty-state"><Database size={23} /><h3>No bound repositories</h3><p>Add a repository binding below before planning maintenance.</p></div>{:else} | 982 | {#if repoTargets.length === 0}<div class="empty-state"><Database size={23} /><h3>No bound repositories</h3><p>Add a repository binding below before planning maintenance.</p></div>{:else} |
| 913 | <form class="control-form" oninput={invalidateMaintenancePlan} onsubmit={(event) => { event.preventDefault(); void renderMaintenancePlan(); }}> | 983 | <form class="control-form" oninput={invalidateMaintenancePlan} onsubmit={(event) => { event.preventDefault(); void renderMaintenancePlan(); }}> |
| 914 | - <label class="span-two"><span>Managed target</span><select value={maintenanceTargetKey} onchange={(event) => loadMaintenanceTarget(event.currentTarget.value)}>{#each repoTargets as target}<option value={target.key}>{target.repo.repo_id} · {target.cache.cache_ref}</option>{/each}</select><small>Opaque cache identity only; no filesystem path crosses the browser boundary.</small></label> | 984 | + <label class="span-two"><span>Managed target</span><select value={maintenanceTargetKey} onchange={(event) => loadMaintenanceTarget(event.currentTarget.value)}>{#if !maintenanceTargetAvailable}<option value={maintenanceTargetKey} disabled>{maintenanceIntent.repo_id || requestedRegistrationID || 'No target selected'} · unavailable</option>{/if}{#each repoTargets as target}<option value={target.key}>{target.repo.repo_id} · {target.cache.cache_ref}</option>{/each}</select><small>Opaque cache identity only; no filesystem path crosses the browser boundary.</small></label> |
| 985 | + {#if !maintenanceTargetAvailable}<div class="span-two" role="alert">Selected maintenance target is unavailable. Select a current repository in the managed cache.</div>{/if} | ||
| 915 | <label><span>Sync mode</span><select bind:value={maintenanceIntent.sync_mode}><option value="off">Off</option><option value="head">Head only</option><option value="head-and-backfill">Head + bounded backfill</option></select></label> | 986 | <label><span>Sync mode</span><select bind:value={maintenanceIntent.sync_mode}><option value="off">Off</option><option value="head">Head only</option><option value="head-and-backfill">Head + bounded backfill</option></select></label> |
| 916 | <label><span>RAG mode</span><select bind:value={maintenanceIntent.rag_mode}><option value="off">Off</option><option value="maintain">Maintain index</option></select></label> | 987 | <label><span>RAG mode</span><select bind:value={maintenanceIntent.rag_mode}><option value="off">Off</option><option value="maintain">Maintain index</option></select></label> |
| 917 | <fieldset class="span-two collection-field"><legend>Collections</legend>{#each ['issues', 'issue-comments', 'wiki', 'pulls', 'pr-comments'] as collection}<label><input type="checkbox" checked={maintenanceIntent.collections.includes(collection)} onchange={(event) => toggleCollection(collection, event.currentTarget.checked)} />{humanize(collection)}</label>{/each}</fieldset> | 988 | <fieldset class="span-two collection-field"><legend>Collections</legend>{#each ['issues', 'issue-comments', 'wiki', 'pulls', 'pr-comments'] as collection}<label><input type="checkbox" checked={maintenanceIntent.collections.includes(collection)} onchange={(event) => toggleCollection(collection, event.currentTarget.checked)} />{humanize(collection)}</label>{/each}</fieldset> |
| @@ -921,14 +992,14 @@ | |||
| 921 | <label><span>Head pages</span><input type="number" min="0" max="1000" bind:value={maintenanceIntent.head_max_pages} /></label> | 992 | <label><span>Head pages</span><input type="number" min="0" max="1000" bind:value={maintenanceIntent.head_max_pages} /></label> |
| 922 | <label><span>Tail slice</span><input type="number" min="0" max="1000" bind:value={maintenanceIntent.tail_slice_pages} /></label> | 993 | <label><span>Tail slice</span><input type="number" min="0" max="1000" bind:value={maintenanceIntent.tail_slice_pages} /></label> |
| 923 | <label><span>Per page</span><input type="number" min="0" max="100" bind:value={maintenanceIntent.per_page} /></label> | 994 | <label><span>Per page</span><input type="number" min="0" max="100" bind:value={maintenanceIntent.per_page} /></label> |
| 924 | - <div class="form-actions span-two"><span>{maintenanceControlsEnabled ? 'Capability available in this daemon.' : 'Capability registry does not expose maintenance controls.'}</span><button class="primary-action" type="submit" disabled={!csrfToken || !maintenanceControlsEnabled || !!selectedMaintenance?.identity_conflict || controlRunning}><FileCheck2 size={16} />{controlRunning ? 'Planning…' : 'Render plan'}</button></div> | 995 | + <div class="form-actions span-two"><span>{maintenanceControlsEnabled ? 'Capability available in this daemon.' : 'Capability registry does not expose maintenance controls.'}</span><button class="primary-action" type="submit" disabled={!csrfToken || !maintenanceControlsEnabled || !maintenanceTargetAvailable || !!selectedMaintenance?.identity_conflict || controlRunning}><FileCheck2 size={16} />{controlRunning ? 'Planning…' : 'Render plan'}</button></div> |
| 925 | </form> | 996 | </form> |
| 926 | {/if} | 997 | {/if} |
| 927 | 998 | ||
| 928 | {#if maintenanceError}<div class="action-result error" role="alert"><AlertTriangle size={17} /><div><strong>Maintenance control failed{maintenanceFailure?.field ? ` · ${humanize(maintenanceFailure.field)}` : ''}</strong><span>{maintenanceError}</span>{#if maintenanceFailure?.remediation}<span>{maintenanceFailure.remediation}</span>{/if}{#if maintenanceFailure?.blockers?.length}<ul class="blocker-list">{#each maintenanceFailure.blockers as blocker}<li>{blocker}</li>{/each}</ul>{/if}{#if maintenanceFailure?.cli_handoff}<code>{maintenanceFailure.cli_handoff}</code>{/if}</div></div>{/if} | 999 | {#if maintenanceError}<div class="action-result error" role="alert"><AlertTriangle size={17} /><div><strong>Maintenance control failed{maintenanceFailure?.field ? ` · ${humanize(maintenanceFailure.field)}` : ''}</strong><span>{maintenanceError}</span>{#if maintenanceFailure?.remediation}<span>{maintenanceFailure.remediation}</span>{/if}{#if maintenanceFailure?.blockers?.length}<ul class="blocker-list">{#each maintenanceFailure.blockers as blocker}<li>{blocker}</li>{/each}</ul>{/if}{#if maintenanceFailure?.cli_handoff}<code>{maintenanceFailure.cli_handoff}</code>{/if}</div></div>{/if} |
| 929 | {#if maintenancePlan}<div class="plan-panel"><div class="plan-summary"><div><p class="section-kicker">REVIEWED INTENT</p><h3>{maintenancePlan.repo_id}</h3><code>{maintenancePlan.plan_id}</code></div><StatusChip value={maintenancePlan.status} /></div>{#if maintenancePlan.blockers?.length}<ul class="blocker-list">{#each maintenancePlan.blockers as blocker}<li><AlertTriangle size={15} />{blocker}</li>{/each}</ul>{/if}<div class="effect-ledger">{#each maintenancePlan.actions as effect}<article><span class="effect-icon"><Zap size={15} /></span><div><strong>{effect.summary}</strong><small>{humanize(effect.class)}{effect.data_boundary ? ` · ${humanize(effect.data_boundary)}` : ''}</small>{#if effect.handoff}<code>{effect.handoff}</code>{/if}</div><StatusChip value={effect.status} /></article>{/each}</div><div class="plan-footer"><div><strong>Next safe action</strong><span>{maintenancePlan.next_action || 'Confirm this exact plan.'}</span></div><button class="primary-action" disabled={maintenancePlan.status === 'blocked' || controlRunning} onclick={(event) => void confirmControl('maintenance_apply', event.currentTarget)}><Power size={16} />Confirm & apply</button></div></div>{/if} | 1000 | {#if maintenancePlan}<div class="plan-panel"><div class="plan-summary"><div><p class="section-kicker">REVIEWED INTENT</p><h3>{maintenancePlan.repo_id}</h3><code>{maintenancePlan.plan_id}</code></div><StatusChip value={maintenancePlan.status} /></div>{#if maintenancePlan.blockers?.length}<ul class="blocker-list">{#each maintenancePlan.blockers as blocker}<li><AlertTriangle size={15} />{blocker}</li>{/each}</ul>{/if}<div class="effect-ledger">{#each maintenancePlan.actions as effect}<article><span class="effect-icon"><Zap size={15} /></span><div><strong>{effect.summary}</strong><small>{humanize(effect.class)}{effect.data_boundary ? ` · ${humanize(effect.data_boundary)}` : ''}</small>{#if effect.handoff}<code>{effect.handoff}</code>{/if}</div><StatusChip value={effect.status} /></article>{/each}</div><div class="plan-footer"><div><strong>Next safe action</strong><span>{maintenancePlan.next_action || 'Confirm this exact plan.'}</span></div><button class="primary-action" disabled={maintenancePlan.status === 'blocked' || controlRunning} onclick={(event) => void confirmControl('maintenance_apply', event.currentTarget)}><Power size={16} />Confirm & apply</button></div></div>{/if} |
| 930 | {#if maintenanceReceipt}<div class="action-result" role="status"><CheckCircle2 size={17} /><div><strong>{humanize(maintenanceReceipt.outcome || maintenanceReceipt.status || 'applied')}</strong><span>{maintenanceReceipt.receipt_id ? `Receipt ${maintenanceReceipt.receipt_id}` : maintenanceReceipt.audit_receipt ? `Audit ${maintenanceReceipt.audit_receipt}` : 'The confirmed plan was accepted.'}{maintenanceReceipt.replayed ? ' · replayed safely' : ''}{maintenanceReceipt.jobs_started?.length ? ` · jobs ${maintenanceReceipt.jobs_started.join(', ')}` : ''}</span></div></div>{/if} | 1001 | {#if maintenanceReceipt}<div class="action-result" role="status"><CheckCircle2 size={17} /><div><strong>{humanize(maintenanceReceipt.outcome || maintenanceReceipt.status || 'applied')}</strong><span>{maintenanceReceipt.receipt_id ? `Receipt ${maintenanceReceipt.receipt_id}` : maintenanceReceipt.audit_receipt ? `Audit ${maintenanceReceipt.audit_receipt}` : 'The confirmed plan was accepted.'}{maintenanceReceipt.replayed ? ' · replayed safely' : ''}{maintenanceReceipt.jobs_started?.length ? ` · jobs ${maintenanceReceipt.jobs_started.join(', ')}` : ''}</span></div></div>{/if} |
| 931 | - {#if selectedMaintenance}<div class="registration-actions"><div><strong>Registration {selectedMaintenance.registration_id}</strong><span>Generation {selectedMaintenance.generation} · {selectedMaintenance.enabled ? 'enabled' : 'disabled'} · {registrationControlsEnabled ? 'reconcile is coalesced with active work.' : 'registration controls are unavailable in this daemon.'}</span></div><button disabled={!csrfToken || !registrationControlsEnabled || !!selectedMaintenance.identity_conflict || controlRunning} onclick={(event) => void confirmControl('reconcile', event.currentTarget)}><RotateCcw size={15} />Reconcile now</button><button class="danger-action" disabled={!csrfToken || !registrationControlsEnabled || !selectedMaintenance.enabled || !!selectedMaintenance.identity_conflict || controlRunning} onclick={(event) => void confirmControl('disable', event.currentTarget)}><Power size={15} />Disable</button></div>{/if} | 1002 | + {#if selectedMaintenance}<div class="registration-actions"><div><strong>Registration {selectedMaintenance.registration_id}</strong><span>Generation {selectedMaintenance.generation} · {selectedMaintenance.enabled ? 'enabled' : 'disabled'} · {registrationControlsEnabled ? 'reconcile is coalesced with active work.' : 'registration controls are unavailable in this daemon.'}</span></div><button disabled={!csrfToken || !registrationControlsEnabled || !maintenanceTargetAvailable || !!selectedMaintenance.identity_conflict || controlRunning} onclick={(event) => void confirmControl('reconcile', event.currentTarget)}><RotateCcw size={15} />Reconcile now</button><button class="danger-action" disabled={!csrfToken || !registrationControlsEnabled || !maintenanceTargetAvailable || !selectedMaintenance.enabled || !!selectedMaintenance.identity_conflict || controlRunning} onclick={(event) => void confirmControl('disable', event.currentTarget)}><Power size={15} />Disable</button></div>{/if} |
| 932 | </section> | 1003 | </section> |
| 933 | 1004 | ||
| 934 | <section class="control-workbench" aria-labelledby="binding-editor-title"> | 1005 | <section class="control-workbench" aria-labelledby="binding-editor-title"> |
| @@ -909,6 +909,239 @@ test('active retry backoff is explicit and blocks whole and collection retry', a | |||
| 909 | await expect(page.getByRole('button', { name: 'Retry Issues' })).toHaveCount(0); | 909 | await expect(page.getByRole('button', { name: 'Retry Issues' })).toHaveCount(0); |
| 910 | }); | 910 | }); |
| 911 | 911 | ||
| 912 | +function maintenanceNavigationSnapshot(): any { | ||
| 913 | + const state: any = structuredClone(snapshot); | ||
| 914 | + const secondRepo = structuredClone(state.caches[0].repositories[0]); | ||
| 915 | + Object.assign(secondRepo, { repo_id: 'example/other', display_name: 'Other repository' }); | ||
| 916 | + state.caches[0].repositories.push(secondRepo); | ||
| 917 | + const secondRegistration = structuredClone(state.maintenance[0]); | ||
| 918 | + Object.assign(secondRegistration, { registration_id: 'reg-other', repo_id: 'example/other' }); | ||
| 919 | + secondRegistration.policy.profile = 'other-profile'; | ||
| 920 | + state.maintenance.push(secondRegistration); | ||
| 921 | + const otherCache = structuredClone(snapshot.caches[0]); | ||
| 922 | + otherCache.cache_ref = 'cache-other'; | ||
| 923 | + otherCache.repositories[0].display_name = 'Other cache repository'; | ||
| 924 | + state.caches.push(otherCache); | ||
| 925 | + const otherCacheRegistration = structuredClone(snapshot.maintenance[0]); | ||
| 926 | + Object.assign(otherCacheRegistration, { registration_id: 'reg-other-cache', cache_ref: 'cache-other' }); | ||
| 927 | + otherCacheRegistration.policy.profile = 'other-cache-profile'; | ||
| 928 | + state.maintenance.push(otherCacheRegistration); | ||
| 929 | + return state; | ||
| 930 | +} | ||
| 931 | + | ||
| 932 | +for (const entry of ['repository-list', 'repository-deep-link', 'maintenance-deep-link', 'same-repository-other-cache', 'unenrolled-repository']) { | ||
| 933 | + test(`maintenance target identity matches the submitted intent after ${entry} navigation`, async ({ page }) => { | ||
| 934 | + const state = maintenanceNavigationSnapshot(); | ||
| 935 | + const expected = entry === 'same-repository-other-cache' | ||
| 936 | + ? { cache_ref: 'cache-other', repo_id: 'example/repo', profile: 'other-cache-profile' } | ||
| 937 | + : { cache_ref: 'cache-111111112222', repo_id: 'example/other', profile: entry === 'unenrolled-repository' ? '' : 'other-profile' }; | ||
| 938 | + if (entry === 'unenrolled-repository') state.maintenance = state.maintenance.filter((item: any) => item.repo_id !== 'example/other'); | ||
| 939 | + await mockAdmin(page, state); | ||
| 940 | + let plannedBody: Record<string, unknown> | undefined; | ||
| 941 | + await page.route('**/api/admin/v1/maintenance/plan', async (route) => { | ||
| 942 | + plannedBody = route.request().postDataJSON(); | ||
| 943 | + await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ api_version: '1', result: { | ||
| 944 | + schema_version: 'gitcode-mcp.maintenance-plan.v1', plan_id: 'maintenance-plan-selected-target', status: 'blocked', | ||
| 945 | + repo_id: expected.repo_id, cache: { cache_ref: expected.cache_ref }, actions: [], blockers: ['Fixture inspection only.'] | ||
| 946 | + } }) }); | ||
| 947 | + }); | ||
| 948 | + if (entry === 'repository-list') { | ||
| 949 | + await page.goto('/?view=Caches'); | ||
| 950 | + await page.getByRole('button', { name: /Other repository/ }).click(); | ||
| 951 | + } else if (entry === 'maintenance-deep-link') { | ||
| 952 | + await page.goto('/?view=Maintenance®istration=reg-other'); | ||
| 953 | + } else { | ||
| 954 | + await page.goto(`/?view=Caches&cache=${expected.cache_ref}&repo=${encodeURIComponent(expected.repo_id)}`); | ||
| 955 | + } | ||
| 956 | + await page.getByRole('button', { name: 'Maintenance', exact: true }).click(); | ||
| 957 | + await expect(page.getByLabel('Managed target')).toHaveValue(`${expected.cache_ref}\u0000${expected.repo_id}`); | ||
| 958 | + await page.reload(); | ||
| 959 | + await expect(page.getByLabel('Managed target')).toHaveValue(`${expected.cache_ref}\u0000${expected.repo_id}`); | ||
| 960 | + await page.getByRole('button', { name: 'Render plan', exact: true }).click(); | ||
| 961 | + await expect.poll(() => plannedBody).toMatchObject(expected); | ||
| 962 | + }); | ||
| 963 | +} | ||
| 964 | + | ||
| 965 | +test('maintenance target history restores identity and invalidates the previous plan', async ({ page }) => { | ||
| 966 | + await mockAdmin(page, maintenanceNavigationSnapshot()); | ||
| 967 | + let plannedBody: Record<string, unknown> | undefined; | ||
| 968 | + await page.route('**/api/admin/v1/maintenance/plan', async (route) => { | ||
| 969 | + plannedBody = route.request().postDataJSON(); | ||
| 970 | + await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ api_version: '1', result: { | ||
| 971 | + plan_id: 'maintenance-plan-history', status: 'blocked', actions: [], blockers: [], repo_id: plannedBody!.repo_id | ||
| 972 | + } }) }); | ||
| 973 | + }); | ||
| 974 | + await page.goto('/?view=Maintenance®istration=reg-1'); | ||
| 975 | + const target = page.getByLabel('Managed target'); | ||
| 976 | + await expect(target).toHaveValue('cache-111111112222\u0000example/repo'); | ||
| 977 | + await target.selectOption('cache-111111112222\u0000example/other'); | ||
| 978 | + await page.goBack(); | ||
| 979 | + await expect(target).toHaveValue('cache-111111112222\u0000example/repo'); | ||
| 980 | + await expect(page.getByLabel('RAG profile')).toHaveValue('easy-rag'); | ||
| 981 | + await page.goForward(); | ||
| 982 | + await expect(target).toHaveValue('cache-111111112222\u0000example/other'); | ||
| 983 | + await page.getByRole('button', { name: 'Render plan', exact: true }).click(); | ||
| 984 | + await expect.poll(() => plannedBody).toMatchObject({ cache_ref: 'cache-111111112222', repo_id: 'example/other', profile: 'other-profile' }); | ||
| 985 | + await expect(page.getByText('maintenance-plan-history')).toBeVisible(); | ||
| 986 | + await page.goBack(); | ||
| 987 | + await expect(target).toHaveValue('cache-111111112222\u0000example/repo'); | ||
| 988 | + await expect(page.getByText('maintenance-plan-history')).toHaveCount(0); | ||
| 989 | +}); | ||
| 990 | + | ||
| 991 | +for (const action of ['Disable', 'Reconcile now']) { | ||
| 992 | + test(`maintenance target history cancels a pending ${action} confirmation`, async ({ page }) => { | ||
| 993 | + await mockAdmin(page, maintenanceNavigationSnapshot()); | ||
| 994 | + const writes: string[] = []; | ||
| 995 | + await page.route('**/api/admin/v1/maintenance/*/disable', async (route) => { writes.push(route.request().url()); await route.abort(); }); | ||
| 996 | + await page.route('**/api/admin/v1/maintenance/*/reconcile', async (route) => { writes.push(route.request().url()); await route.abort(); }); | ||
| 997 | + await page.goto('/?view=Maintenance®istration=reg-1'); | ||
| 998 | + await page.getByLabel('Managed target').selectOption('cache-111111112222\u0000example/other'); | ||
| 999 | + await page.getByRole('button', { name: action, exact: true }).click(); | ||
| 1000 | + await expect(page.getByRole('dialog')).toContainText('example/other'); | ||
| 1001 | + await page.goBack(); | ||
| 1002 | + await expect(page.getByLabel('Managed target')).toHaveValue('cache-111111112222\u0000example/repo'); | ||
| 1003 | + await expect(page.getByRole('dialog')).toHaveCount(0); | ||
| 1004 | + await page.goForward(); | ||
| 1005 | + await expect(page.getByRole('dialog')).toHaveCount(0); | ||
| 1006 | + expect(writes).toEqual([]); | ||
| 1007 | + }); | ||
| 1008 | +} | ||
| 1009 | + | ||
| 1010 | +for (const explicitRegistration of [true, false]) { | ||
| 1011 | + test(`maintenance registration replacement cancels confirmation with explicit registration ${explicitRegistration}`, async ({ page }) => { | ||
| 1012 | + const state = maintenanceNavigationSnapshot(); | ||
| 1013 | + let emitSnapshotChanged!: () => void; | ||
| 1014 | + const snapshotChanged = new Promise<void>((resolve) => { emitSnapshotChanged = resolve; }); | ||
| 1015 | + await mockAdmin(page, state, snapshotChanged); | ||
| 1016 | + const writes: string[] = []; | ||
| 1017 | + await page.route('**/api/admin/v1/maintenance/*/disable', async (route) => { writes.push(route.request().url()); await route.abort(); }); | ||
| 1018 | + await page.goto(explicitRegistration ? '/?view=Maintenance®istration=reg-1' : '/?view=Maintenance'); | ||
| 1019 | + await page.getByRole('button', { name: 'Disable', exact: true }).click(); | ||
| 1020 | + await expect(page.getByRole('dialog')).toContainText('reg-1'); | ||
| 1021 | + state.maintenance[0].registration_id = 'reg-replacement'; | ||
| 1022 | + const refreshed = page.waitForResponse('**/api/admin/v1/snapshot'); | ||
| 1023 | + emitSnapshotChanged(); | ||
| 1024 | + await refreshed; | ||
| 1025 | + await expect(page.getByRole('dialog')).toHaveCount(0); | ||
| 1026 | + expect(writes).toEqual([]); | ||
| 1027 | + if (explicitRegistration) { | ||
| 1028 | + await expect(page.getByText('Selected maintenance target is unavailable.')).toBeVisible(); | ||
| 1029 | + await expect(page.getByRole('button', { name: 'Disable', exact: true })).toBeDisabled(); | ||
| 1030 | + await expect(page.getByRole('button', { name: 'Reconcile now', exact: true })).toBeDisabled(); | ||
| 1031 | + await page.getByLabel('Managed target').selectOption('cache-111111112222\u0000example/other'); | ||
| 1032 | + await expect(page.getByRole('button', { name: 'Disable', exact: true })).toBeEnabled(); | ||
| 1033 | + } | ||
| 1034 | + }); | ||
| 1035 | +} | ||
| 1036 | + | ||
| 1037 | +for (const outcome of ['success', 'failure']) { | ||
| 1038 | + test(`maintenance ignores a late control ${outcome} after history changes target`, async ({ page }) => { | ||
| 1039 | + await mockAdmin(page, maintenanceNavigationSnapshot()); | ||
| 1040 | + let release!: () => void; | ||
| 1041 | + const gate = new Promise<void>((resolve) => { release = resolve; }); | ||
| 1042 | + const requests: string[] = []; | ||
| 1043 | + await page.route('**/api/admin/v1/maintenance/*/reconcile', async (route) => { | ||
| 1044 | + requests.push(route.request().url()); | ||
| 1045 | + await gate; | ||
| 1046 | + await route.fulfill({ status: outcome === 'success' ? 200 : 422, contentType: 'application/json', body: JSON.stringify(outcome === 'success' | ||
| 1047 | + ? { api_version: '1', result: { outcome: 'reconciled', receipt_id: 'obsolete-control-receipt' } } | ||
| 1048 | + : { error: { code: 'stale_registration', message: 'Obsolete control failure.' } }) }); | ||
| 1049 | + }); | ||
| 1050 | + await page.goto('/?view=Maintenance®istration=reg-1'); | ||
| 1051 | + await page.getByLabel('Managed target').selectOption('cache-111111112222\u0000example/other'); | ||
| 1052 | + await page.getByRole('button', { name: 'Reconcile now', exact: true }).click(); | ||
| 1053 | + const submitted = page.waitForRequest('**/api/admin/v1/maintenance/*/reconcile'); | ||
| 1054 | + await page.getByRole('button', { name: 'Confirm action', exact: true }).click(); | ||
| 1055 | + await submitted; | ||
| 1056 | + await page.goBack(); | ||
| 1057 | + await expect(page.getByLabel('Managed target')).toHaveValue('cache-111111112222\u0000example/repo'); | ||
| 1058 | + const response = page.waitForResponse('**/api/admin/v1/maintenance/*/reconcile'); | ||
| 1059 | + release(); | ||
| 1060 | + await response; | ||
| 1061 | + await expect(page.getByRole('button', { name: 'Render plan', exact: true })).toBeEnabled(); | ||
| 1062 | + await expect(page.getByRole('dialog')).toHaveCount(0); | ||
| 1063 | + await expect(page.getByText(/obsolete-control-receipt|Obsolete control failure/)).toHaveCount(0); | ||
| 1064 | + expect(requests).toEqual(['http://127.0.0.1:4173/api/admin/v1/maintenance/reg-other/reconcile']); | ||
| 1065 | + }); | ||
| 1066 | +} | ||
| 1067 | + | ||
| 1068 | +for (const missing of ['repository', 'cache', 'registration', 'removed-repository']) { | ||
| 1069 | + test(`maintenance preserves a missing explicit ${missing} instead of retargeting`, async ({ page }) => { | ||
| 1070 | + const state = maintenanceNavigationSnapshot(); | ||
| 1071 | + await mockAdmin(page, state); | ||
| 1072 | + const requests: unknown[] = []; | ||
| 1073 | + await page.route('**/api/admin/v1/maintenance/plan', async (route) => { requests.push(route.request().postDataJSON()); await route.abort(); }); | ||
| 1074 | + const key = missing === 'cache' ? 'cache-missing\u0000example/repo' : missing === 'registration' ? '' : `cache-111111112222\u0000example/${missing === 'removed-repository' ? 'other' : 'missing'}`; | ||
| 1075 | + await page.goto(missing === 'registration' ? '/?view=Maintenance®istration=reg-missing' : `/?view=Maintenance&cache=${key.split('\u0000')[0]}&repo=${encodeURIComponent(key.split('\u0000')[1])}`); | ||
| 1076 | + if (missing === 'removed-repository') { | ||
| 1077 | + await expect(page.getByLabel('Managed target')).toHaveValue(key); | ||
| 1078 | + state.caches[0].repositories = state.caches[0].repositories.filter((repo: any) => repo.repo_id !== 'example/other'); | ||
| 1079 | + state.maintenance = state.maintenance.filter((item: any) => item.registration_id !== 'reg-other'); | ||
| 1080 | + const refreshed = page.waitForResponse('**/api/admin/v1/snapshot'); | ||
| 1081 | + await page.getByRole('button', { name: 'Refresh snapshot', exact: true }).first().click(); | ||
| 1082 | + await refreshed; | ||
| 1083 | + } | ||
| 1084 | + await expect(page.getByLabel('Managed target')).toHaveValue(key); | ||
| 1085 | + await expect(page.getByText('Selected maintenance target is unavailable.')).toBeVisible(); | ||
| 1086 | + await expect(page.getByRole('button', { name: 'Render plan', exact: true })).toBeDisabled(); | ||
| 1087 | + expect(requests).toEqual([]); | ||
| 1088 | + await page.getByLabel('Managed target').selectOption('cache-111111112222\u0000example/repo'); | ||
| 1089 | + await expect(page.getByRole('button', { name: 'Render plan', exact: true })).toBeEnabled(); | ||
| 1090 | + await expect(page.getByText('Selected maintenance target is unavailable.')).toHaveCount(0); | ||
| 1091 | + }); | ||
| 1092 | +} | ||
| 1093 | + | ||
| 1094 | +test('maintenance same-target refresh preserves unsaved policy edits', async ({ page }) => { | ||
| 1095 | + await mockAdmin(page, maintenanceNavigationSnapshot()); | ||
| 1096 | + let plannedBody: Record<string, unknown> | undefined; | ||
| 1097 | + await page.route('**/api/admin/v1/maintenance/plan', async (route) => { | ||
| 1098 | + plannedBody = route.request().postDataJSON(); | ||
| 1099 | + await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ api_version: '1', result: { | ||
| 1100 | + plan_id: 'maintenance-plan-edited', status: 'blocked', actions: [], blockers: [] | ||
| 1101 | + } }) }); | ||
| 1102 | + }); | ||
| 1103 | + await page.goto('/?view=Maintenance®istration=reg-other'); | ||
| 1104 | + await page.getByLabel('RAG profile').fill('draft-profile'); | ||
| 1105 | + await page.getByLabel('Head pages').fill('7'); | ||
| 1106 | + await page.getByRole('button', { name: 'Overview', exact: true }).click(); | ||
| 1107 | + const refreshed = page.waitForResponse('**/api/admin/v1/snapshot'); | ||
| 1108 | + await page.getByRole('button', { name: 'Refresh snapshot', exact: true }).first().click(); | ||
| 1109 | + await refreshed; | ||
| 1110 | + await page.getByRole('button', { name: 'Maintenance', exact: true }).click(); | ||
| 1111 | + await expect(page.getByLabel('Managed target')).toHaveValue('cache-111111112222\u0000example/other'); | ||
| 1112 | + await expect(page.getByLabel('RAG profile')).toHaveValue('draft-profile'); | ||
| 1113 | + await expect(page.getByLabel('Head pages')).toHaveValue('7'); | ||
| 1114 | + await page.getByRole('button', { name: 'Render plan', exact: true }).click(); | ||
| 1115 | + await expect.poll(() => plannedBody).toMatchObject({ cache_ref: 'cache-111111112222', repo_id: 'example/other', profile: 'draft-profile', head_max_pages: 7 }); | ||
| 1116 | +}); | ||
| 1117 | + | ||
| 1118 | +for (const outcome of ['success', 'failure']) { | ||
| 1119 | + for (const change of ['target', 'policy']) { | ||
| 1120 | + test(`maintenance ignores a late ${outcome} after ${change} changes`, async ({ page }) => { | ||
| 1121 | + await mockAdmin(page, maintenanceNavigationSnapshot()); | ||
| 1122 | + let release!: () => void; | ||
| 1123 | + const gate = new Promise<void>((resolve) => { release = resolve; }); | ||
| 1124 | + await page.route('**/api/admin/v1/maintenance/plan', async (route) => { | ||
| 1125 | + await gate; | ||
| 1126 | + await route.fulfill({ status: outcome === 'failure' ? 422 : 200, contentType: 'application/json', body: JSON.stringify(outcome === 'failure' | ||
| 1127 | + ? { error: { code: 'invalid_policy', message: 'Obsolete target error.' } } | ||
| 1128 | + : { api_version: '1', result: { plan_id: 'maintenance-plan-obsolete', status: 'blocked', actions: [], blockers: [] } }) }); | ||
| 1129 | + }); | ||
| 1130 | + await page.goto('/?view=Maintenance'); | ||
| 1131 | + const submitted = page.waitForRequest('**/api/admin/v1/maintenance/plan'); | ||
| 1132 | + await page.getByRole('button', { name: 'Render plan', exact: true }).click(); | ||
| 1133 | + await submitted; | ||
| 1134 | + if (change === 'target') await page.getByLabel('Managed target').selectOption('cache-111111112222\u0000example/other'); | ||
| 1135 | + else await page.getByLabel('RAG profile').fill('edited-while-planning'); | ||
| 1136 | + release(); | ||
| 1137 | + await expect(page.getByRole('button', { name: 'Render plan', exact: true })).toBeEnabled(); | ||
| 1138 | + await expect(page.getByText('maintenance-plan-obsolete')).toHaveCount(0); | ||
| 1139 | + await expect(page.getByText('Obsolete target error.')).toHaveCount(0); | ||
| 1140 | + await expect(page.getByLabel('RAG profile')).toHaveValue(change === 'target' ? 'other-profile' : 'edited-while-planning'); | ||
| 1141 | + }); | ||
| 1142 | + } | ||
| 1143 | +} | ||
| 1144 | + | ||
| 912 | test('maintenance scope validation identifies collections and omits an unavailable CLI handoff', async ({ page }) => { | 1145 | test('maintenance scope validation identifies collections and omits an unavailable CLI handoff', async ({ page }) => { |
| 913 | await mockAdmin(page); | 1146 | await mockAdmin(page); |
| 914 | await page.route('**/api/admin/v1/maintenance/plan', async (route) => { | 1147 | await page.route('**/api/admin/v1/maintenance/plan', async (route) => { |