diff --git a/.gitignore b/.gitignore index c1df70401a..bbb05dc1e3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist/ .artifacts/ artifacts/ dashboard/.wrangler/ +.wrangler/ .clawsweeper-repair/ .claude/ tmp/ diff --git a/dashboard/bay-page.ts b/dashboard/bay-page.ts index 31c3f33537..c63dcae5a2 100644 --- a/dashboard/bay-page.ts +++ b/dashboard/bay-page.ts @@ -31,7 +31,7 @@ button,input,a{font:inherit} button{color:inherit} a{color:inherit} .repo-bar{margin-top:10px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}.repo-bar>span{font:800 9px ui-monospace,monospace;color:var(--muted);text-transform:uppercase;letter-spacing:.12em}.repo-button{border:1px solid var(--line);background:rgba(255,255,255,.72);border-radius:999px;padding:6px 10px;font-size:10px;cursor:pointer}.repo-button:before{content:"";display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--repo-color,#788);margin-right:6px}.repo-button.active{border-color:var(--repo-color,var(--claw));box-shadow:inset 0 0 0 1px var(--repo-color,var(--claw));background:white} .notice{display:none;padding:8px 16px;background:#fff5d9;border-bottom:1px solid #e4c57d;color:#6d5421;font-size:11px}.notice.show{display:block} .beach{position:relative;min-height:690px;overflow:hidden;background:linear-gradient(180deg,var(--sand-light),var(--sand));isolation:isolate}.beach:before{content:"";position:absolute;z-index:-3;right:-2%;top:0;bottom:0;width:20%;background:repeating-radial-gradient(ellipse at 0 50%,transparent 0 21px,rgba(220,250,249,.38) 22px 24px,transparent 25px 42px),linear-gradient(90deg,#86c0bf,var(--sea))}.beach:after{content:"";position:absolute;z-index:-2;left:0;top:0;bottom:0;width:6.5%;background:repeating-linear-gradient(90deg,#8a603d 0 3px,#b98b61 3px 18px,#7f5839 18px 20px);clip-path:polygon(0 0,100% 7%,77% 100%,0 100%)} -.beach-inner{position:relative;min-height:690px;padding:64px 20% 22px 7.5%}.stage-grid{display:grid;grid-template-columns:repeat(5,minmax(120px,1fr));height:600px}.stage{position:relative;border-right:1px dashed rgba(100,72,41,.22);min-width:0}.stage:last-child{border-right:0}.stage h2,.pool h2{position:absolute;z-index:5;left:50%;top:0;translate:-50% 0;margin:0;padding:9px 12px;border-radius:4px;background:#ad7c50;color:#352417;box-shadow:0 4px 0 #765032,0 8px 16px rgba(66,45,25,.13);font:800 9px ui-monospace,monospace;letter-spacing:.13em;text-transform:uppercase;white-space:nowrap}.stage h2 span,.pool h2 span{margin-left:6px;opacity:.68}.stage-body{position:absolute;inset:58px 5px 0}.empty{position:absolute;left:12px;right:12px;top:48%;text-align:center;color:rgba(87,70,49,.52);font-size:10px}.overflow-note{position:absolute;z-index:7;left:50%;bottom:5px;translate:-50% 0;padding:5px 8px;border:1px solid rgba(100,72,41,.2);border-radius:999px;background:rgba(255,249,235,.92);color:#6f5a42;font:700 8px ui-monospace,monospace;white-space:nowrap}.loading{position:absolute;inset:0;display:grid;place-items:center;color:#765f42;font:700 12px ui-monospace,monospace}.loading i{display:block;width:34px;height:34px;margin:0 auto 10px;border:3px solid rgba(117,89,52,.2);border-top-color:var(--claw);border-radius:50%;animation:spin .8s linear infinite} +.beach-inner{position:relative;min-height:690px;padding:64px 20% 22px 7.5%}.stage-grid{display:grid;grid-template-columns:repeat(6,minmax(120px,1fr));height:600px}.stage{position:relative;border-right:1px dashed rgba(100,72,41,.22);min-width:0}.stage:last-child{border-right:0}.stage h2,.pool h2{position:absolute;z-index:5;left:50%;top:0;translate:-50% 0;margin:0;padding:9px 12px;border-radius:4px;background:#ad7c50;color:#352417;box-shadow:0 4px 0 #765032,0 8px 16px rgba(66,45,25,.13);font:800 9px ui-monospace,monospace;letter-spacing:.13em;text-transform:uppercase;white-space:nowrap}.stage h2 span,.pool h2 span{margin-left:6px;opacity:.68}.stage-body{position:absolute;inset:58px 5px 0}.empty{position:absolute;left:12px;right:12px;top:48%;text-align:center;color:rgba(87,70,49,.52);font-size:10px}.overflow-note{position:absolute;z-index:7;left:50%;bottom:5px;translate:-50% 0;padding:5px 8px;border:1px solid rgba(100,72,41,.2);border-radius:999px;background:rgba(255,249,235,.92);color:#6f5a42;font:700 8px ui-monospace,monospace;white-space:nowrap}.loading{position:absolute;inset:0;display:grid;place-items:center;color:#765f42;font:700 12px ui-monospace,monospace}.loading i{display:block;width:34px;height:34px;margin:0 auto 10px;border:3px solid rgba(117,89,52,.2);border-top-color:var(--claw);border-radius:50%;animation:spin .8s linear infinite} .terminal-stack{position:absolute;right:2.8%;top:64px;bottom:24px;width:15.5%;display:flex;flex-direction:column;gap:11px}.pool{position:relative;min-height:130px;border:1px dashed rgba(94,66,37,.25);border-radius:50%;background:rgba(204,210,190,.52);overflow:visible}.pool.failed{background:rgba(236,190,157,.54);border-color:rgba(180,73,45,.45)}.pool.cancelled{background:rgba(206,199,179,.52)}.pool-body{position:absolute;inset:47px 8px 8px}.pool .empty{top:45%} .critter{position:absolute;left:var(--x);top:var(--y);width:76px;height:72px;border:0;background:transparent;padding:18px 3px 0;cursor:pointer;filter:drop-shadow(0 6px 4px rgba(76,53,29,.23));transition:transform .22s,filter .22s;animation:breathe 3.4s ease-in-out infinite;animation-delay:var(--delay)}.critter:hover,.critter:focus-visible{z-index:25;transform:translateY(-5px) scale(1.1);filter:drop-shadow(0 10px 7px rgba(76,53,29,.3));outline:none}.critter svg{width:67px;height:49px;overflow:visible;filter:hue-rotate(var(--hue)) saturate(var(--sat)) brightness(var(--light));transform:scale(var(--sx),var(--sy)) rotate(var(--tilt));transform-origin:center bottom}.critter .ref{position:absolute;z-index:4;left:50%;top:0;translate:-50% 0;white-space:nowrap;padding:4px 6px;border:1px solid var(--repo-color);border-radius:5px;background:rgba(255,255,255,.94);box-shadow:0 3px 8px rgba(60,45,28,.12);color:#51463a;font:700 8px ui-monospace,monospace}.repo-pin{position:absolute;right:3px;bottom:5px;width:7px;height:7px;border-radius:50%;background:var(--repo-color);border:1px solid white}.pool .critter{width:65px;height:60px;padding-top:16px}.pool .critter svg{width:54px;height:40px}.pool .critter .ref{font-size:7px}.critter.located{z-index:50;animation:found .7s ease-in-out infinite;filter:drop-shadow(0 0 5px white) drop-shadow(0 0 15px #f2a12d)}.find-arrow{display:none;position:absolute;z-index:6;left:50%;top:-45px;translate:-50% 0;padding:5px 7px;border:2px solid #96301f;border-radius:999px;background:var(--claw);color:white;box-shadow:0 4px 0 #96301f;font:900 8px ui-monospace,monospace;white-space:nowrap}.find-arrow:after{content:"↓";position:absolute;left:50%;top:20px;translate:-50%;color:var(--claw);font-size:25px}.critter.located .find-arrow{display:block}.critter.retriggered{animation:emerge 1.7s cubic-bezier(.2,.8,.2,1),breathe 3.4s 1.7s ease-in-out infinite}.critter.terminal-completed .claw{transform-origin:center;animation:wave-claw 1.5s ease-in-out infinite}.tide-imminent .terminal-completed .claw{animation-duration:.38s}.critter.terminal-failed{filter:drop-shadow(0 0 8px rgba(190,65,38,.4))}.critter.terminal-cancelled{filter:grayscale(.55) sepia(.2) drop-shadow(0 5px 4px rgba(76,53,29,.18))} .master{position:absolute;z-index:14;left:7%;top:50%;width:215px;height:100px;pointer-events:none;transition:left 3.4s cubic-bezier(.42,.04,.22,1),top 3.4s cubic-bezier(.42,.04,.22,1),transform .5s}.master svg{position:absolute;left:0;top:5px;width:135px;height:79px;filter:drop-shadow(0 7px 5px rgba(76,53,29,.25))}.brush{position:absolute;left:116px;top:49px;width:88px;height:9px;border-radius:8px;background:linear-gradient(#775032,#4e321f);transform:rotate(2deg);transform-origin:left center}.brush:after{content:"";position:absolute;right:-3px;top:-18px;width:27px;height:45px;border-radius:5px 10px 10px 5px;background:repeating-linear-gradient(90deg,#c59041 0 3px,#e5be6e 3px 6px);border:1px solid #98682d;transform:rotate(-3deg)}.master.sweeping .brush:after{animation:brush 1s ease-in-out infinite}.station{position:absolute;z-index:2;left:1.5%;top:43%;width:72px;height:155px;color:#206b70}.station strong{position:absolute;top:-22px;left:-3px;background:#28767a;color:white;border-radius:4px;padding:5px 7px;font:800 7px ui-monospace,monospace;text-transform:uppercase;white-space:nowrap}.station:before{content:"";position:absolute;left:17px;top:8px;width:42px;height:61px;border:5px solid #f4eee0;background:repeating-linear-gradient(0deg,#367f82 0 6px,#e9e3d6 6px 12px)}.station:after{content:"";position:absolute;left:9px;top:70px;width:58px;height:72px;border-left:5px solid #eee5d5;border-right:5px solid #eee5d5;clip-path:polygon(20% 0,80% 0,100% 100%,80% 100%,62% 28%,38% 28%,20% 100%,0 100%)} @@ -87,6 +87,34 @@ dialog{border:0;padding:0;margin:0 0 0 auto;width:min(580px,94vw);height:100vh;m @media((max-width:820px) and (orientation:portrait)),(max-width:600px){.masthead{min-height:0;gap:7px;padding:8px 11px}.brand{min-width:0}.brand small,.demo-chip,.brush-control>span{display:none}.nav{order:0;flex:1;width:auto;justify-content:flex-end;gap:1px}.nav a{padding:7px 8px;font-size:10px}.brush-control{gap:4px}.brush-control button{padding:6px 8px;font-size:10px}.hero{padding:10px 12px 8px}.title-row{align-items:center;gap:7px;flex-wrap:wrap}.title-row h1{font-size:clamp(29px,9vw,37px)}.title-row p{display:none}.live-chip{padding:4px 7px;font-size:10px}.overall-average{order:4;flex-basis:100%;margin-left:0;align-self:flex-start;padding:6px 9px}.overall-average strong{font-size:12px}.overall-average small{font-size:8px}.tools{margin-top:8px;gap:7px}.finder{padding:7px;flex-wrap:wrap;gap:6px}.finder label{font-size:8px}.finder input{width:auto;min-width:0;flex:1;padding:7px 8px}.finder-status{flex-basis:100%;min-width:0;padding-left:1px}.tide-panel{width:100%;padding:7px 9px}.repo-bar{max-height:72px;overflow:auto;padding-bottom:2px}.repo-bar>span{flex-basis:100%}.beach{min-height:0;overflow:hidden;background-color:#efd8a8;background-image:linear-gradient(180deg,rgba(248,236,204,.08),rgba(106,72,37,.1)),url('/bay-assets/bay-background-portrait.webp?v=1');background-size:100% 100%,cover;background-position:center,center top;background-attachment:scroll,fixed}.beach:after{background:linear-gradient(90deg,rgba(54,34,18,.1),transparent 30%,transparent 74%,rgba(21,104,113,.12))}.beach-inner{min-width:0;min-height:0;padding:142px 13px 0}.stage-grid{grid-template-columns:1fr!important;gap:0;height:auto}.lane-lines{display:none}.stage{min-height:195px;border:0}.stage:before{content:"";position:absolute;z-index:1;left:5%;right:5%;bottom:0;height:10px;border-bottom:3px dashed rgba(103,69,36,.43);filter:drop-shadow(0 2px 0 rgba(255,239,196,.65));transform:rotate(var(--lane-divider-tilt,-.4deg))}.stage:nth-child(even):before{--lane-divider-tilt:.55deg}.stage:after{right:auto;left:50%;bottom:-7px;width:72px;height:14px;transform:translateX(-50%) rotate(-1.8deg)}.stage:nth-child(even):after{transform:translateX(-50%) rotate(2deg)}.stage h2{top:8px;bottom:auto}.stage-body{inset:52px 10px 24px}.overflow-note{bottom:3px}.station{left:2%;top:22px;transform:scale(.78);transform-origin:left top}.master{z-index:15}.terminal-stack{position:relative;right:auto;top:auto;bottom:auto;width:auto;min-width:0;margin:28px 13px 24px;display:grid;grid-template-columns:1fr;gap:18px}.terminal-stack:before{display:none}.pool{min-height:205px!important;flex-grow:0!important;flex-basis:auto!important}.pool.completed{min-height:340px!important}.pool h2{top:12px}.pool-body{inset:54px 12px 16px}.sample-note{position:relative;left:auto;right:auto;bottom:auto;margin:0 13px 12px;padding-bottom:5px}.chat-overlay{z-index:90}.overlay-speech{width:min(220px,calc(100vw - 28px))}.overlay-speech.answer{width:min(250px,calc(100vw - 28px))}} @media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition:none!important}.beach.tide-active .wave{animation:none!important;transform:translate3d(2%,0,0);opacity:.44}.beach.tide-active .tide-wet-sheen{animation:none!important;opacity:.3}.beach.tide-active .tide-wet-gleam{animation:none!important;opacity:.32}.beach.preview-tide-cleared .pool .critter{opacity:.2;transform:none;filter:grayscale(.15)}} .bay-control-head{display:flex;align-items:center;justify-content:space-between;gap:9px;margin-top:8px}.bay-control-head>span{color:#6f604d;font:800 8px ui-monospace,monospace;letter-spacing:.12em;text-transform:uppercase}.bay-range-controls{display:flex;gap:4px}.bay-range-controls button{border:1px solid rgba(83,66,43,.22);border-radius:999px;background:rgba(255,252,245,.74);color:#665847;padding:4px 7px;font:800 8px ui-monospace,monospace;cursor:pointer}.bay-range-controls button:hover{background:#fff7e8}.bay-range-controls button.active{border-color:#d95534;background:#fff1eb;color:#a33c29}.bay-range-controls button:focus-visible{outline:2px solid #186d72;outline-offset:2px}.bay-control-board{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin-top:5px}.bay-control-card{min-width:0;padding:8px 10px;border:1px solid rgba(83,66,43,.18);border-radius:10px;background:rgba(255,252,245,.84);box-shadow:0 5px 15px rgba(48,45,34,.06)}.bay-control-card h2{margin:0;color:#6f604d;font:800 8px ui-monospace,monospace;letter-spacing:.12em;text-transform:uppercase}.bay-control-summary{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin-top:4px}.bay-control-summary strong{font:800 16px ui-monospace,monospace;color:#264f51;letter-spacing:-.04em}.bay-control-summary span{font-size:9px;color:var(--muted);text-align:right}.bay-control-charts{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:6px}.bay-control-chart{min-width:0}.bay-control-chart small{display:block;margin-bottom:2px;color:#6f604d;font:800 7px ui-monospace,monospace;letter-spacing:.06em;text-transform:uppercase}.bay-control-chart svg{display:block;width:100%;height:44px;overflow:visible}.bay-control-grid{stroke:rgba(70,88,78,.16);stroke-width:1}.bay-control-axis-label{fill:#766956;font:800 7px ui-monospace,monospace}.bay-control-line{fill:none;stroke:#df552f;stroke-width:2.1;stroke-linejoin:round;stroke-linecap:round}.bay-control-line.rate{stroke:#6859c7}.bay-control-point{fill:#df552f;stroke:#fffaf0;stroke-width:1.1}.bay-control-point.rate{fill:#6859c7}.bay-control-rate{margin-top:5px;color:#287456;font-size:9px;font-weight:800;line-height:1.3}.bay-control-rate.rising{color:#b74331}.bay-control-rate.collecting{color:var(--muted);font-weight:650}.bay-handoff{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px;margin-top:7px}.bay-handoff div{min-width:0}.bay-handoff span{display:block;color:var(--muted);font:800 7px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em}.bay-handoff strong{display:block;margin-top:2px;font:800 13px ui-monospace,monospace;color:#294a4b}.bay-handoff-status{display:inline-block;margin-top:7px;padding:3px 5px;border-radius:999px;background:#e8f4ea;color:#287456;font:800 8px ui-monospace,monospace;letter-spacing:.05em;text-transform:uppercase}.bay-handoff-status.degraded,.bay-handoff-status.stalled{background:#fff0e9;color:#a5402c}.bay-control-empty{margin:6px 0 0;color:var(--muted);font-size:10px}.queue-sample-copy{margin:0 0 14px;color:#5d5041;font-size:12px;line-height:1.5}.queue-sample-list{list-style:none;margin:0;padding:0}.queue-sample-list li{display:flex;align-items:baseline;justify-content:space-between;gap:10px;padding:9px 0;border-bottom:1px solid #e5daca;font-size:12px}.queue-sample-list a{color:#a9402c;font-weight:750;text-decoration:none}.queue-sample-list span{color:var(--muted);font-size:10px;text-align:right}@media(max-width:900px){.bay-control-board{grid-template-columns:1fr 1fr}.bay-control-card:last-child{grid-column:1/-1}}@media((max-width:820px) and (orientation:portrait)),(max-width:600px){.bay-control-head{align-items:flex-start}.bay-control-head>span{padding-top:5px}.bay-control-board{grid-template-columns:1fr;margin-top:7px}.bay-control-card:last-child{grid-column:auto}.bay-control-summary strong{font-size:15px}.lane-help-copy{position:fixed;left:14px;right:14px;bottom:18px;translate:0;width:auto;max-width:none}.overflow-note{font-size:10px}} +.bay-state-writer-stats{display:grid;align-content:center;gap:2px;min-height:44px}.bay-state-writer-stats strong{color:#294a4b;font:800 13px ui-monospace,monospace}.bay-state-writer-stats span{color:var(--muted);font-size:9px;line-height:1.25}@media(min-width:1301px){.bay-control-board{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(min-width:901px) and (max-width:1300px){.bay-control-board{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.bay-control-board{grid-template-columns:1fr 1fr}.bay-control-card:last-child{grid-column:auto}} +@media (max-width:970px) and (orientation:landscape){ + .beach{min-height:0;overflow:hidden} + .beach-inner{min-width:0;min-height:0;padding:142px 13px 0} + .stage-grid{grid-template-columns:1fr!important;gap:0;height:auto} + .lane-lines{display:none} + .stage{min-height:195px;border:0} + .stage:before{content:"";position:absolute;z-index:1;left:5%;right:5%;bottom:0;height:10px;border-bottom:3px dashed rgba(103,69,36,.43);filter:drop-shadow(0 2px 0 rgba(255,239,196,.65));transform:rotate(var(--lane-divider-tilt,-.4deg))} + .stage:nth-child(even):before{--lane-divider-tilt:.55deg} + .stage:after{right:auto;left:50%;bottom:-7px;width:72px;height:14px;transform:translateX(-50%) rotate(-1.8deg)} + .stage:nth-child(even):after{transform:translateX(-50%) rotate(2deg)} + .stage h2{top:8px;bottom:auto} + .stage-body{inset:52px 10px 24px} + .overflow-note{bottom:3px} + .station{left:2%;top:22px;transform:scale(.78);transform-origin:left top} + .master{z-index:15} + .terminal-stack{position:relative;right:auto;top:auto;bottom:auto;width:auto;min-width:0;margin:28px 13px 24px;display:grid;grid-template-columns:1fr;gap:18px} + .terminal-stack:before{display:none} + .pool{min-height:205px!important;flex-grow:0!important;flex-basis:auto!important} + .pool.completed{min-height:340px!important} + .pool h2{top:12px} + .pool-body{inset:54px 12px 16px} + .sample-note{position:relative;left:auto;right:auto;bottom:auto;margin:0 13px 12px;padding-bottom:5px} +} +@media((max-width:820px) and (orientation:portrait)),(max-width:600px){ + .bay-control-board{grid-template-columns:1fr} + .bay-control-card:last-child{grid-column:auto} +} @@ -168,9 +196,9 @@ dialog{border:0;padding:0;margin:0 0 0 auto;width:min(580px,94vw);height:100vh;m diff --git a/dashboard/exact-review-publication-batches.ts b/dashboard/exact-review-publication-batches.ts index b962f23414..2a2936528f 100644 --- a/dashboard/exact-review-publication-batches.ts +++ b/dashboard/exact-review-publication-batches.ts @@ -56,6 +56,10 @@ export type PublicationBatchStats = { expired: number; activeItems: number; activeItemKeys: string[]; + // This contains only unfinished, currently leased membership. It lets the + // read-only Bay projection identify the bounded batch that owns an item + // without retaining a separate event history or looking it up through GitHub. + activeItemBatches: Array<{ itemKey: string; batchId: string }>; nextLeaseExpiresAt: number | null; oldestActiveAt: number | null; reclaimedItemsRetained: number; @@ -424,6 +428,7 @@ export class ExactReviewPublicationBatchStore { expired: counts.get("expired") ?? 0, activeItems: activeLease.itemKeys.length, activeItemKeys: activeLease.itemKeys, + activeItemBatches: activeLease.items, nextLeaseExpiresAt: activeLease.nextLeaseExpiresAt, oldestActiveAt: leased ? Number(leased.oldest_at) : null, reclaimedItemsRetained, @@ -470,6 +475,10 @@ export class ExactReviewPublicationBatchStore { ), ); return { + items: rows.map((row) => ({ + itemKey: String(row.item_key), + batchId: String(row.batch_id), + })), itemKeys: rows.map((row) => String(row.item_key)), activeBatches: new Set(rows.map((row) => String(row.batch_id))).size, nextLeaseExpiresAt: rows.length diff --git a/dashboard/exact-review-queue.ts b/dashboard/exact-review-queue.ts index 9bc96188c2..46c0ef74f3 100644 --- a/dashboard/exact-review-queue.ts +++ b/dashboard/exact-review-queue.ts @@ -1880,7 +1880,10 @@ export class ExactReviewQueue { stateWriterCoordinatorQueuedStaleMs(this.env), ); const publicationBatches = this.batchStore.stats(now); - const batchOwnedItemKeys = new Set(publicationBatches.activeItemKeys); + const batchByItemKey = new Map( + publicationBatches.activeItemBatches.map((batch) => [batch.itemKey, batch] as const), + ); + const batchOwnedItemKeys = new Set(batchByItemKey.keys()); const freshPublicationItemKeys = this.freshPublicationItemKeysSync(state, now); const legacyExcludedItemKeys = new Set(batchOwnedItemKeys); if (exactReviewPublicationBatchingEnabled(this.env)) { @@ -1920,7 +1923,11 @@ export class ExactReviewQueue { return json({ ...stats, pressure: elevateExactReviewPressureForPublication(stats.pressure, publicationHealth), - bay_projection: exactReviewQueueBayProjection(Object.values(state.items), bayPriorityKeys), + bay_projection: exactReviewQueueBayProjection( + Object.values(state.items), + bayPriorityKeys, + batchByItemKey, + ), lanes: { review: { ...stats.lanes.review, @@ -7257,7 +7264,9 @@ function exactReviewQueueLane(item: ExactReviewQueueItem) { // state. Keep this representation bounded and scrubbed: it is public dashboard // data, not a queue-inspection API. Live workers remain the authority for the // reviewing stage; these records only make the otherwise invisible admission, -// setup, publication, and recovery phases visible. +// setup, publication, and recovery phases visible. Publication is distinct +// from the publisher workflow's deterministic follow-up, which the Bay shows +// from the live worker as Applying. const EXACT_REVIEW_BAY_SAMPLE_LIMIT = 24; // The dashboard can retain both a terminal-buffer card and its washed card // while their live queue retry is pending. Accept all bounded Bay candidates @@ -7267,6 +7276,7 @@ const EXACT_REVIEW_BAY_STAGES = [ "arriving", "setting-up", "reviewing", + "publishing", "applying", "repairing", ] as const; @@ -7280,10 +7290,28 @@ type ExactReviewBayProjectionItem = { created_at: string; updated_at: string; next_attempt_at: string; + batch_id?: string; + batch_created_at?: string; +}; + +type ExactReviewBayBatchOwner = { + batchId: string; }; -function exactReviewQueueBayStage(item: ExactReviewQueueItem): ExactReviewBayStage { - if (exactReviewQueueIsPublication(item)) return "applying"; +function exactReviewQueueBayStage( + item: ExactReviewQueueItem, + batchByItemKey: ReadonlyMap = new Map(), +): ExactReviewBayStage { + // A parked item is deliberately no longer making normal queue progress. This + // includes bounded review-retry exhaustion, permanent dispatch rejection, + // and a publication that needs its dead-letter/recovery path. Keep it in the + // exception cove instead of making it look like an active setup or publisher. + if (item.state === "parked") return "repairing"; + // The batch publisher's GitHub job is intentionally targetless. Its durable + // batch membership is the authoritative bounded source for the individual + // items it is currently applying, without another GitHub lookup. + if (batchByItemKey.has(item.key)) return "applying"; + if (exactReviewQueueIsPublication(item)) return "publishing"; if (isLowPriorityExactReviewDecision(item.decision)) return "repairing"; return item.state === "pending" ? "arriving" : "setting-up"; } @@ -7306,22 +7334,31 @@ function exactReviewQueueBayPriorityKeys(values: string[]) { function exactReviewQueueBayProjection( items: ExactReviewQueueItem[], priorityItemKeys: string[] = [], + batchByItemKey: ReadonlyMap = new Map(), ) { const projected = new Map(); for (const item of items) { - if (item.state === "parked") continue; + // Parked records are not terminal outcomes: they remain bounded durable + // queue work that needs recovery. Keep their already-scrubbed identity in + // the projection so Bay shows the exception rather than a false empty lane. const repository = String(item.decision.targetRepo || "").trim(); const itemNumber = Number(item.decision.itemNumber); if (!repository || !Number.isSafeInteger(itemNumber) || itemNumber <= 0) continue; + const batch = batchByItemKey.get(item.key); const candidate: ExactReviewBayProjectionItem = { item_key: `${repository}#${itemNumber}`, repository, item_number: itemNumber, - stage: exactReviewQueueBayStage(item), + stage: exactReviewQueueBayStage(item, batchByItemKey), queue_state: item.state, created_at: new Date(item.createdAt).toISOString(), updated_at: new Date(item.updatedAt).toISOString(), next_attempt_at: new Date(item.nextAttemptAt).toISOString(), + ...(batch + ? { + batch_id: batch.batchId, + } + : {}), }; const previous = projected.get(candidate.item_key); const candidateUpdatedAt = Date.parse(candidate.updated_at); diff --git a/dashboard/worker.ts b/dashboard/worker.ts index 0fc116c08a..0412309724 100644 --- a/dashboard/worker.ts +++ b/dashboard/worker.ts @@ -3255,19 +3255,22 @@ async function activeWorkerSnapshot( const detailRuns: WorkflowRunSummary[] = runs.slice(0, detailRunLimit); const results = await mapWithConcurrency(detailRuns, fetchConcurrency, async (run) => { try { - const jobs = await workflowJobsForRun(env, repo, run.id, github); + const jobs = await workflowJobsForRun(env, repo, run.id, github, run); + const activeJobs = jobs.filter((job) => isActiveWorkflowJob(job)); return { run, - workers: jobs - .filter((job) => isActiveWorkflowJob(job) && isCodexWorkerJob(job)) + workers: activeJobs + .filter((job) => isDashboardWorkerJob(job, run)) .map((job) => normalizeWorkerJob(run, job)), - hasWorkerJobs: jobs.some((job) => isCodexWorkerJob(job)), + codexWorkers: activeJobs.filter((job) => isCodexWorkerJob(job)).length, + hasWorkerJobs: jobs.some((job) => isDashboardWorkerJob(job, run)), error: null, }; } catch (error) { return { run, workers: [], + codexWorkers: 0, hasWorkerJobs: false, error: error instanceof Error ? error.message : String(error), }; @@ -3276,12 +3279,15 @@ async function activeWorkerSnapshot( const workers = []; const errors = []; let fallbacks = 0; + let codexWorkers = 0; for (const result of results) { + codexWorkers += result.codexWorkers; if (result.error) { errors.push(`workflow jobs ${result.run.id}: ${result.error}`); if (isCodexWorkflowFallback(result.run)) { workers.push(normalizeFallbackWorker(result.run)); fallbacks += 1; + codexWorkers += 1; } continue; } @@ -3290,12 +3296,14 @@ async function activeWorkerSnapshot( } else if (!result.hasWorkerJobs && isCodexWorkflowFallback(result.run)) { workers.push(normalizeFallbackWorker(result.run)); fallbacks += 1; + codexWorkers += 1; } } for (const run of runs.slice(detailRunLimit)) { if (!isCodexWorkflowFallback(run)) continue; workers.push(normalizeFallbackWorker(run)); fallbacks += 1; + codexWorkers += 1; } workers.sort( (left, right) => @@ -3305,7 +3313,7 @@ async function activeWorkerSnapshot( ); await attachWorkerTargets(env, workers, errors); return { - count: workers.length, + count: codexWorkers, workers, detailRuns: detailRuns.length, fallbacks, @@ -3348,7 +3356,7 @@ async function recentWorkerHealth( const results = await mapWithConcurrency(completedRuns, fetchConcurrency, async (run) => { try { return { - attempts: (await workflowJobsForRun(env, repo, run.id, github)) + attempts: (await workflowJobsForRun(env, repo, run.id, github, run)) .filter((job) => isCodexWorkerJob(job)) .map((job) => workerHealthAttempt(run, job)) .filter(Boolean), @@ -4244,6 +4252,7 @@ async function workflowJobsForRun( repo, runId, github: GithubJsonReader = (path) => githubJson(env, path), + run?: WorkflowRunSummary, ) { const key = `workflow-jobs:${repo}:${runId}`; const cached = await readStoredJson(env, key); @@ -4263,7 +4272,9 @@ async function workflowJobsForRun( break; } } - const hasActiveWorker = jobs.some((job) => isActiveWorkflowJob(job) && isCodexWorkerJob(job)); + const hasActiveWorker = jobs.some( + (job) => isActiveWorkflowJob(job) && isDashboardWorkerJob(job, run), + ); await writeStoredJson( env, key, @@ -4288,6 +4299,25 @@ function isCodexWorkerJob(job) { ); } +function isExactReviewPublicationJob(job, run?: WorkflowRunSummary) { + const name = String(job?.name || ""); + const steps = Array.isArray(job?.steps) ? job.steps : []; + const workflow = `${run?.name || ""} ${run?.display_title || ""}`; + return ( + /publish (?:exact )?review artifacts?/i.test(name) || + (/publish exact review batch/i.test(workflow) && /^publish$/i.test(name)) || + steps.some((step) => + /claim durable exact review publication|claim one durable publication batch|finalize healthy members under a fenced heartbeat|publish event result and apply safe close|complete durable exact review publication|apply review artifacts|publish review artifact action ledger|commit review records/i.test( + String(step?.name || ""), + ), + ) + ); +} + +function isDashboardWorkerJob(job, run?: WorkflowRunSummary) { + return isCodexWorkerJob(job) || isExactReviewPublicationJob(job, run); +} + function normalizeWorkerJob(run, job) { const runItem = classifyRun(run); const target = workerTargetFromJob(runItem, job.name); @@ -4310,6 +4340,7 @@ function normalizeWorkerJob(run, job) { return { id: job.id, source: "job", + is_codex_worker: isCodexWorkerJob(job), name: String(job.name || runItem.title || "Codex worker"), mode, work_kind: workKind, @@ -4369,6 +4400,7 @@ function normalizeFallbackWorker(run) { return { id: `run-${run.id}`, source: "workflow-fallback", + is_codex_worker: true, name: item.title || item.workflow || "Codex worker", mode: item.mode, work_kind: workerWorkKind(item, ""), @@ -6212,7 +6244,7 @@ function workflowRunSummary(run) { function isCodexWorkflowFallback(run) { const name = `${run?.name || ""} ${run?.display_title || ""}`; if ( - /repair comment router|clawsweeper_comment|@publish:|publish exact review artifact|exact.review publication|reconcile exact.review lease|sync codex review comments/i.test( + /repair comment router|clawsweeper_comment|@publish:|publish (?:exact )?review (?:artifacts?|batch)|exact.review publication|reconcile exact.review lease|sync codex review comments/i.test( name, ) ) { @@ -6231,13 +6263,16 @@ function controlPlaneSnapshot(runs) { }; for (const run of runs) { const name = `${run?.name || ""} ${run?.display_title || ""}`; - const lane = /@publish:|publish exact review artifact|exact.review publication/i.test(name) - ? snapshot.publishers - : /repair comment router|clawsweeper_comment|sync codex review comments/i.test(name) - ? snapshot.comment_routers - : /reconcile exact.review lease/i.test(name) - ? snapshot.reconcilers - : null; + const lane = + /@publish:|publish (?:exact )?review (?:artifacts?|batch)|exact.review publication/i.test( + name, + ) + ? snapshot.publishers + : /repair comment router|clawsweeper_comment|sync codex review comments/i.test(name) + ? snapshot.comment_routers + : /reconcile exact.review lease/i.test(name) + ? snapshot.reconcilers + : null; if (!lane) continue; if (run.status === "in_progress") lane.running += 1; else lane.waiting += 1; @@ -8994,6 +9029,7 @@ function laneFlowDetails(laneKey, flow) { } function renderSystemMap(data) { const workers = data.workers || []; + const codexWorkers = workers.filter(worker => worker.is_codex_worker !== false); const pipeline = data.pipeline || []; const fleet = data.fleet || {}; const workerRunIds = new Set(workers.map(worker => String(worker.run_id))); @@ -9003,7 +9039,7 @@ function renderSystemMap(data) { const nodes = [ ["01 · Intake", fleet.queued_workflow_runs || 0, "Events and scheduled sweeps waiting to start"], ["02 · Plan", planning, "Runs selecting work or expanding a matrix"], - ["03 · Workers", workers.length, "Codex jobs reviewing, repairing, or assisting"], + ["03 · Workers", codexWorkers.length, "Codex jobs reviewing, repairing, or assisting"], ["04 · Apply", applying, "Deterministic comment, close, merge, and publish lanes"], ["05 · Results", closed, (data.recent?.closed_stats?.window_hours || 24) + "h ClawSweeper closes"] ]; @@ -9011,8 +9047,8 @@ function renderSystemMap(data) { '
' + esc(node[0]) + '' + fmt.format(node[1]) + '

' + esc(node[2]) + '

' ).join(""); const budget = Math.max(0, fleet.worker_budget || 0); - const running = workers.filter(worker => worker.status === "in_progress").length; - const waiting = workers.length - running; + const running = codexWorkers.filter(worker => worker.status === "in_progress").length; + const waiting = codexWorkers.length - running; const free = Math.max(0, budget - running - waiting); const overflow = Math.max(0, running + waiting - budget); const share = value => budget ? Math.min(100, (value / budget) * 100) : 0; @@ -9532,7 +9568,7 @@ function renderDashboard(data, note) { ); const severity = serverHealth?.severity || (handoffStatus === "stalled" || operationalStatus === "stalled" ? "red" : needsAttention ? "amber" : "green"); - const workerCount = (data.workers || []).length; + const workerCount = (data.workers || []).filter(worker => worker.is_codex_worker !== false).length; const repoCount = (data.source.target_repositories || []).length; document.getElementById("hero-dot").className = "hero-dot " + (severity === "green" ? "ok" : severity); document.getElementById("hero-headline").textContent = diff --git a/docs/proof/openclaw-bay/README.md b/docs/proof/openclaw-bay/README.md index db7688f0f1..1cfe11ea09 100644 --- a/docs/proof/openclaw-bay/README.md +++ b/docs/proof/openclaw-bay/README.md @@ -10,7 +10,7 @@ The sequence proves: - visible partial-telemetry diagnostics; - the Bay timing badge naming its bounded **review trigger → final review** measurement, completed by the command-status update emitted after the durable review summary; -- a 390px portrait layout that stacks Arriving through Applying vertically, keeps the terminal pools at the waterline, and has no horizontal page overflow; +- a 390px portrait layout that stacks Arriving through Publishing and Applying vertically, keeps the terminal pools at the waterline, and has no horizontal page overflow; - advancing crustacean-claw and master-sweeper animations; - a READY flag followed by a physical forward sweep and landing; - a changed run ID using the retrigger tunnel and resurfacing path; @@ -18,7 +18,7 @@ The sequence proves: - repository filtering; - the read-only drawer's safe GitHub item, job, and workflow-run links; - readable overflow controls that open the known queue sample and explicitly explain when aggregate queue IDs are outside the bounded public projection; -- compact review-admission and result-publication charts with labelled y-axes, exact point hover labels, and cached 6-hour, 24-hour, and 7-day range controls; +- compact review-admission, result-publication, and State writer charts with labelled y-axes, exact point hover labels, and cached 6-hour, 24-hour, and 7-day range controls; - lightweight hover/focus explanations on the beach lane signs; - the local-only tide preview advancing through incoming, crest, backwash, and restored states while preserving terminal keys and count; - the short static reduced-motion tide cue preserving the same preview state; diff --git a/docs/proof/openclaw-bay/run-proof.mjs b/docs/proof/openclaw-bay/run-proof.mjs index a98c1b946a..b0fb07e3a5 100644 --- a/docs/proof/openclaw-bay/run-proof.mjs +++ b/docs/proof/openclaw-bay/run-proof.mjs @@ -247,15 +247,30 @@ let healthHistory = Array.from({ length: 73 }, (_, index) => { completed_total: 318 + index * 3, }, }, + state_writer: { + collection_ok: true, + mode: "batch", + tracked_holding: index % 5 === 0 ? 1 : 0, + tracked_waiting: 7 + (index % 4), + tracked_releasing: 0, + accepted_operations_total: 320 + index, + state_commits_total: 120 + index, + materialized_items_total: 360 + index * 2, + contention_timeouts_total: 0, + wait_ms: { p50: 1200, p95: 2800, samples: 4 }, + hold_ms: { p50: 400, p95: 900, samples: 4 }, + last_successful_materialization_at: at, + }, }; }); let healthHistoryFailure = false; +let stateWriterTerminalFresh = true; function queueProjection() { const bayStages = [ { stage: "arriving", queue_state: "pending" }, { stage: "setting-up", queue_state: "leased" }, - { stage: "applying", queue_state: "dispatching" }, + { stage: "publishing", queue_state: "dispatching" }, { stage: "repairing", queue_state: "pending" }, ]; const items = Array.from({ length: 6 }, (_, batch) => @@ -290,10 +305,24 @@ function queueProjection() { leased: { count: 33 }, }, }, + state_writer: { + collection: { status: "fresh" }, + mode: "batch", + live: { tracked_holding: 1, tracked_waiting: 8 }, + coordinator: { leased: 1, queued: 8 }, + last_60_minutes: { state_commits: 9, materialized_items: 17 }, + }, bay_projection: { sample_limit: 24, total: 36, - stages: { arriving: 9, "setting-up": 9, reviewing: 0, applying: 9, repairing: 9 }, + stages: { + arriving: 9, + "setting-up": 9, + reviewing: 0, + publishing: 9, + applying: 0, + repairing: 9, + }, items, }, }; @@ -320,7 +349,14 @@ function denseFilteredQueueProjection() { bay_projection: { sample_limit: 24, total: 24, - stages: { arriving: 24, "setting-up": 0, reviewing: 0, applying: 0, repairing: 0 }, + stages: { + arriving: 24, + "setting-up": 0, + reviewing: 0, + publishing: 0, + applying: 0, + repairing: 0, + }, items, }, }; @@ -406,6 +442,52 @@ const bayRetryLiveSnapshot = { }, }; proofSnapshots.push(bayRetryTerminalSnapshot, bayRetryLiveSnapshot); +const batchApplyingProjection = queueProjection(); +const batchApplyingItemKey = "openclaw/openclaw#108003"; +const batchApplyingItems = batchApplyingProjection.bay_projection.items.map((item) => + item.item_key === batchApplyingItemKey + ? { + ...item, + stage: "applying", + queue_state: "pending", + batch_id: "exact-review-batch:9001", + } + : item, +); +proofSnapshots.push({ + ...snapshots[0], + exact_review_queue: { + ...batchApplyingProjection, + bay_projection: { + ...batchApplyingProjection.bay_projection, + stages: { + ...batchApplyingProjection.bay_projection.stages, + publishing: batchApplyingProjection.bay_projection.stages.publishing - 1, + applying: batchApplyingProjection.bay_projection.stages.applying + 1, + }, + items: batchApplyingItems, + }, + }, +}); +const legacyApplyingProjection = queueProjection(); +const { publishing: legacyPublishingCount, ...legacyStages } = + legacyApplyingProjection.bay_projection.stages; +proofSnapshots.push({ + ...snapshots[0], + exact_review_queue: { + ...legacyApplyingProjection, + bay_projection: { + ...legacyApplyingProjection.bay_projection, + stages: { + ...legacyStages, + applying: legacyPublishingCount, + }, + items: legacyApplyingProjection.bay_projection.items.map((item) => + item.stage === "publishing" ? { ...item, stage: "applying" } : item, + ), + }, + }, +}); let fixtureIndex = 0; const requests = []; @@ -522,11 +604,15 @@ await page.route("**/*", async (route) => { const request = route.request(); const url = new URL(request.url()); if (url.pathname === "/api/status") { + const status = structuredClone(proofSnapshots[fixtureIndex]); + if (!stateWriterTerminalFresh) { + status.exact_review_queue.state_writer.collection.status = "stale"; + } await route.fulfill({ status: 200, contentType: "application/json; charset=utf-8", headers: { "cache-control": "no-store", "x-clawsweeper-cache": "synthetic-proof" }, - body: JSON.stringify(proofSnapshots[fixtureIndex]), + body: JSON.stringify(status), }); return; } @@ -647,7 +733,14 @@ try { arriving_queue_samples: await page .locator('[data-stage="arriving"] [data-item^="queue:"]') .count(), + publishing_queue_samples: await page + .locator('[data-stage="publishing"] [data-item^="queue:"]') + .count(), + applying_queue_samples: await page + .locator('[data-stage="applying"] [data-item^="queue:"]') + .count(), queue_header: await page.locator('[data-stage="arriving"] h2').innerText(), + publishing_header: await page.locator('[data-stage="publishing"] h2').innerText(), queue_omission: await page.locator('[data-stage="arriving"] .overflow-note').count(), queue_omission_label: await page.locator('[data-stage="arriving"] .overflow-note').innerText(), queue_omission_role: await page @@ -658,16 +751,20 @@ try { lane_help: await page.locator('[data-stage="arriving"] .lane-help summary').count(), }; assertProof( - "Bay mirrors cached exact-review admission, publication, and handoff telemetry", - bayControl.cards === 3 && + "Bay mirrors cached exact-review admission, publication, state-writer, and handoff telemetry", + bayControl.cards === 4 && /Review admission/i.test(bayControl.review) && /Result publication/i.test(bayControl.review) && + /State writer/i.test(bayControl.review) && /Queue handoff/i.test(bayControl.review) && /waiting/.test(bayControl.waiting_hover_label) && /\/ hour/.test(bayControl.rate_hover_label) && bayControl.queue_items === 1 && bayControl.arriving_queue_samples === 6 && + bayControl.publishing_queue_samples === 6 && + bayControl.applying_queue_samples === 0 && /^ARRIVING 9/.test(bayControl.queue_header) && + /^PUBLISHING 9/.test(bayControl.publishing_header) && bayControl.queue_omission === 1 && bayControl.queue_omission_label === "+3 queued IDs not shown" && bayControl.queue_omission_role === null && @@ -676,6 +773,25 @@ try { bayControl.lane_help === 1, bayControl, ); + + stateWriterTerminalFresh = false; + await page.evaluate(() => window.__bayProofPoll()); + await page.waitForFunction(() => + /terminal metrics unavailable/.test( + document.querySelector("#bay-control-board")?.textContent || "", + ), + ); + const staleStateWriter = await page.locator("#bay-control-board").innerText(); + assertProof( + "Bay does not present stale State writer terminal totals as current", + /State writer/i.test(staleStateWriter) && + /unknown items/.test(staleStateWriter) && + /terminal metrics unavailable/.test(staleStateWriter) && + !/17 items/.test(staleStateWriter), + { state_writer: staleStateWriter }, + ); + stateWriterTerminalFresh = true; + await page.evaluate(() => window.__bayProofPoll()); const terminalPoolCounts = { completed: await page.locator('[data-stage="completed"] .critter').count(), attention: await page.locator(".pool.attention .critter").count(), @@ -705,7 +821,7 @@ try { await capture( "01a-mini-control-board", "Mini queue control board", - "Bay reuses the dashboard’s cached six-hour review-admission, publication, and handoff telemetry; each sparkline point has an exact hover label.", + "Bay reuses the dashboard’s cached six-hour review-admission, publication, state-writer, and handoff telemetry; each sparkline point has an exact hover label.", ); await page.locator('[data-stage="arriving"] .overflow-note').click(); @@ -859,7 +975,11 @@ try { const failedCollectionAt = originalHistory.at(-2)?.at; healthHistory = [ originalHistory.at(-3), - { at: failedCollectionAt, exact_review: { collection_ok: false } }, + { + at: failedCollectionAt, + exact_review: { collection_ok: false }, + state_writer: { collection_ok: false }, + }, originalHistory.at(-1), ]; const failedCollectionHistory = page.waitForResponse( @@ -886,6 +1006,52 @@ try { (failedCollectionPath?.match(/M/g) || []).length === 2, { review_pending_path: failedCollectionPath }, ); + const failedStateWriterPath = await page + .locator("#bay-control-board .bay-control-card") + .filter({ hasText: "State writer" }) + .locator(".bay-control-chart svg path") + .first() + .getAttribute("d"); + assertProof( + "Bay renders failed State writer history collections as a gap", + (failedStateWriterPath?.match(/M/g) || []).length === 2, + { state_writer_pending_path: failedStateWriterPath }, + ); + healthHistory = [ + originalHistory.at(-3), + originalHistory.at(-2), + { + at: originalHistory.at(-1)?.at, + exact_review: originalHistory.at(-1)?.exact_review, + state_writer: { collection_ok: false }, + }, + ]; + const currentStateWriterFailure = page.waitForResponse( + (response) => + new URL(response.url()).pathname === "/api/health-history" && response.status() === 200, + ); + await page.evaluate(() => { + window.__bayProofSetNow(Date.parse("2026-07-11T18:50:05.000Z")); + window.__bayProofPoll(); + }); + await currentStateWriterFailure; + await page.waitForFunction(() => + /history gap · awaiting current sample/i.test( + [...document.querySelectorAll("#bay-control-board .bay-control-card")].find((card) => + /State writer/i.test(card.textContent || ""), + )?.textContent || "", + ), + ); + const failedStateWriterCopy = await page + .locator("#bay-control-board .bay-control-card") + .filter({ hasText: "State writer" }) + .innerText(); + assertProof( + "Bay labels a current failed State writer collection as a history gap", + /history gap · awaiting current sample/i.test(failedStateWriterCopy) && + !/history stale/i.test(failedStateWriterCopy), + { state_writer: failedStateWriterCopy }, + ); healthHistory = [ originalHistory.at(-3), @@ -904,7 +1070,7 @@ try { new URL(response.url()).pathname === "/api/health-history" && response.status() === 200, ); await page.evaluate(() => { - window.__bayProofSetNow(Date.parse("2026-07-11T18:49:04.000Z")); + window.__bayProofSetNow(Date.parse("2026-07-11T18:51:06.000Z")); window.__bayProofPoll(); }); await pendingOnlyHistory; @@ -925,6 +1091,41 @@ try { ); healthHistory = originalHistory; + await page.setViewportSize({ width: 900, height: 700 }); + await page.waitForFunction(() => + document.getElementById("stage-grid")?.classList.contains("portrait-stack"), + ); + await page.waitForTimeout(140); + const compactLandscapeLayout = await page.evaluate(() => { + const stages = Array.from(document.querySelectorAll("#stage-grid .stage")).map((stage) => { + const rect = stage.getBoundingClientRect(); + return { + left: Math.round(rect.left), + top: Math.round(rect.top), + bottom: Math.round(rect.bottom), + }; + }); + const terminal = document.getElementById("terminal-stack")?.getBoundingClientRect(); + return { + scroll_width: document.documentElement.scrollWidth, + viewport_width: window.innerWidth, + stages, + terminal_top: Math.round(terminal?.top || 0), + }; + }); + assertProof( + "intermediate landscape stacks six Bay lanes before they reach terminal pools", + compactLandscapeLayout.scroll_width <= compactLandscapeLayout.viewport_width + 1 && + compactLandscapeLayout.stages.length === 6 && + compactLandscapeLayout.stages.every((stage, index, stages) => + index === 0 + ? stage.left >= 0 + : Math.abs(stage.left - stages[0].left) <= 2 && stage.top >= stages[index - 1].bottom, + ) && + compactLandscapeLayout.terminal_top >= compactLandscapeLayout.stages.at(-1).bottom, + compactLandscapeLayout, + ); + await page.setViewportSize({ width: 390, height: 844 }); await page.waitForFunction(() => document.getElementById("stage-grid")?.classList.contains("portrait-stack"), @@ -941,10 +1142,12 @@ try { }); const terminal = document.getElementById("terminal-stack")?.getBoundingClientRect(); const grid = document.getElementById("stage-grid"); + const controlBoard = document.getElementById("bay-control-board"); return { scroll_width: document.documentElement.scrollWidth, viewport_width: window.innerWidth, grid_columns: getComputedStyle(grid).gridTemplateColumns, + control_columns: getComputedStyle(controlBoard).gridTemplateColumns, stages, terminal_top: Math.round(terminal?.top || 0), }; @@ -952,7 +1155,8 @@ try { assertProof( "portrait layout stacks the workflow from sand to waterline without horizontal overflow", portraitLayout.scroll_width <= portraitLayout.viewport_width + 1 && - portraitLayout.stages.length === 5 && + portraitLayout.stages.length === 6 && + portraitLayout.control_columns.trim().split(/\s+/).length === 1 && portraitLayout.stages.every((stage, index, stages) => index === 0 ? stage.left >= 0 @@ -964,7 +1168,7 @@ try { await capture( "01b-portrait-workflow", "Portrait workflow: top to bottom", - "Phone portrait mode stacks Arriving through Applying vertically, then places the terminal pools at the waterline without horizontal scrolling.", + "Phone portrait mode stacks Arriving through Publishing and Applying vertically, then places the terminal pools at the waterline without horizontal scrolling.", ); await page.setViewportSize({ width: 1900, height: 1000 }); await page.waitForFunction( @@ -1529,6 +1733,40 @@ try { "The stale failed card is absent and the same GitHub reference is visible as queued exact-review work in the earlier lane.", ); + fixtureIndex = 9; + await page.evaluate(async () => { + await window.__bayProofPoll(); + }); + const batchPublisherLane = page.locator( + `[data-stage="applying"] [data-key="${batchApplyingItemKey}"]`, + ); + await batchPublisherLane.waitFor({ state: "visible", timeout: 5_000 }); + assertProof( + "durable batch ownership places known publication items in Applying & writing", + (await batchPublisherLane.count()) === 1 && + /APPLYING & WRITING 1/.test(await page.locator('[data-stage="applying"] h2').innerText()), + { + item_key: batchApplyingItemKey, + stage: "applying", + batch_id: "exact-review-batch:9001", + }, + ); + + fixtureIndex = 10; + await page.evaluate(async () => { + await window.__bayProofPoll(); + }); + const legacyQueuePublisher = page.locator( + `[data-stage="publishing"] [data-key="${batchApplyingItemKey}"]`, + ); + await legacyQueuePublisher.waitFor({ state: "visible", timeout: 5_000 }); + assertProof( + "legacy Applying queue records are normalized into Publishing without duplicate Applying cards", + (await legacyQueuePublisher.count()) === 1 && + (await page.locator('[data-stage="applying"] [data-item^="queue:"]').count()) === 0, + { item_key: batchApplyingItemKey, legacy_stage: "applying", rendered_stage: "publishing" }, + ); + const totalStatusGets = requests.filter((request) => request.path === "/api/status").length; const healthHistoryGets = requests.filter( (request) => request.path === "/api/health-history", @@ -1565,7 +1803,7 @@ try { }); assertProof( "mini control board caches each selected dashboard history range", - healthHistoryGets === 10 && + healthHistoryGets === 11 && healthHistoryRanges.filter((range) => range === "24h").length === 1 && healthHistoryRanges.filter((range) => range === "7d").length === 1, { health_history_gets: healthHistoryGets, ranges: healthHistoryRanges }, diff --git a/test/dashboard-worker.test.ts b/test/dashboard-worker.test.ts index c5c9aa9923..417038c0d2 100644 --- a/test/dashboard-worker.test.ts +++ b/test/dashboard-worker.test.ts @@ -2067,7 +2067,8 @@ test("dashboard status reads the exact-review handoff model from the durable que arriving: 2, "setting-up": 1, reviewing: 0, - applying: 1, + publishing: 1, + applying: 0, repairing: 1, }); assert.deepEqual( @@ -2079,7 +2080,7 @@ test("dashboard status reads the exact-review handoff model from the durable que [ { item_key: "openclaw/gogcli#597", stage: "arriving", queue_state: "pending" }, { item_key: "openclaw/gogcli#600", stage: "setting-up", queue_state: "leased" }, - { item_key: "openclaw/gogcli#599", stage: "applying", queue_state: "leased" }, + { item_key: "openclaw/gogcli#599", stage: "publishing", queue_state: "leased" }, { item_key: "openclaw/gogcli#601", stage: "repairing", queue_state: "pending" }, { item_key: "openclaw/gogcli#598", stage: "arriving", queue_state: "pending" }, ], @@ -2097,6 +2098,112 @@ test("dashboard status reads the exact-review handoff model from the durable que assert.equal(await exactReviewQueueStatusSnapshot({}), null); }); +test("Bay queue projection maps durable batch-owned publication items to Applying", async () => { + const storage = new MemoryDurableStorage(); + const queue = new ExactReviewQueue( + { storage }, + { + EXACT_REVIEW_DISPATCH_DEBOUNCE_MS: "0", + EXACT_REVIEW_PUBLICATION_BATCHING_ENABLED: "1", + EXACT_REVIEW_PUBLICATION_BATCH_SIZE: "1", + }, + ); + await queue.fetch( + buildExactReviewQueueRequest( + "bay-batch-applying", + 602, + "exact_review_artifact_publish", + "issue", + undefined, + exactReviewPublicationOverrides(602, "6020"), + ), + ); + const claim = await queue.fetch( + new Request("https://clawsweeper-exact-review-queue/publication-batches/claim", { + method: "POST", + body: JSON.stringify({ + claim_id: "bay-applying-batch", + lease_owner: "bay-applying-owner", + max_items: 1, + }), + }), + ); + assert.equal((await claim.json()).claimed, true); + + const status = await exactReviewQueueStatusSnapshot({ + EXACT_REVIEW_QUEUE: new MemoryDurableNamespace(queue), + }); + + assert.ok(status); + assert.deepEqual(status.bay_projection.stages, { + arriving: 0, + "setting-up": 0, + reviewing: 0, + publishing: 0, + applying: 1, + repairing: 0, + }); + assert.deepEqual( + status.bay_projection.items.map((item) => ({ + item_key: item.item_key, + stage: item.stage, + batch_id: item.batch_id, + })), + [ + { + item_key: "openclaw/gogcli#602", + stage: "applying", + batch_id: "bay-applying-batch", + }, + ], + ); +}); + +test("Bay queue projection sends parked review and publication work to Repair Cove", async () => { + const storage = new MemoryDurableStorage(); + const queue = new ExactReviewQueue({ storage }, { EXACT_REVIEW_DISPATCH_DEBOUNCE_MS: "0" }); + await queue.fetch(buildExactReviewQueueRequest("bay-parked-review", 603, "opened")); + await queue.fetch( + buildExactReviewQueueRequest( + "bay-parked-publication", + 604, + "exact_review_artifact_publish", + "issue", + undefined, + exactReviewPublicationOverrides(604, "6040"), + ), + ); + const state = (await storage.get("exact-review-queue")) as { + items: Record; + }; + state.items["openclaw/gogcli#603"].state = "parked"; + state.items["openclaw/gogcli#603"].parkedReason = "review_retry_exhausted"; + state.items["openclaw/gogcli#604@publish:6040:1"].state = "parked"; + state.items["openclaw/gogcli#604@publish:6040:1"].parkedReason = "dead_letter_capacity"; + await storage.put("exact-review-queue", state); + + const status = await exactReviewQueueStatusSnapshot({ + EXACT_REVIEW_QUEUE: new MemoryDurableNamespace(queue), + }); + + assert.ok(status); + assert.deepEqual(status.bay_projection.stages, { + arriving: 0, + "setting-up": 0, + reviewing: 0, + publishing: 0, + applying: 0, + repairing: 2, + }); + assert.deepEqual( + status.bay_projection.items.map((item) => ({ item_key: item.item_key, stage: item.stage })), + [ + { item_key: "openclaw/gogcli#603", stage: "repairing" }, + { item_key: "openclaw/gogcli#604", stage: "repairing" }, + ], + ); +}); + test("Bay queue projection applies its public sample cap across all stages", async () => { const storage = new MemoryDurableStorage(); const queue = new ExactReviewQueue({ storage }, { EXACT_REVIEW_DISPATCH_DEBOUNCE_MS: "0" }); @@ -2139,6 +2246,7 @@ test("Bay queue projection applies its public sample cap across all stages", asy arriving: 9, "setting-up": 9, reviewing: 0, + publishing: 0, applying: 0, repairing: 9, }); @@ -2267,16 +2375,29 @@ test("OpenClaw Bay is an unlisted, hardened demo route", async () => { assert.match(body, /id="bay-control-board"/); assert.match(body, /Review admission/); assert.match(body, /Result publication/); + assert.match(body, /State writer/); assert.match(body, /Queue handoff/); assert.match(body, /function loadBayHistory/); assert.match(body, /function bayRateSparkline/); + assert.match(body, /function bayStateWriterCard/); + assert.match(body, /function bayStateWriterHistory/); + assert.match(body, /max-width:970px\) and \(orientation:landscape/); assert.match(body, /net throughput over .*bayRangeLabel/); assert.match(body, /data-bay-history-range="24h"/); assert.match(body, /data-bay-history-range="7d"/); assert.match(body, /bay-control-axis-label/); assert.match(body, /api\/health-history\?range="\+encodeURIComponent\(range\)/); assert.match(body, /function expandQueue/); + assert.match(body, /function queueProjectionStage/); assert.match(body, /Repair cove/); + assert.match(body, /"publishing":"Publishing"/); + assert.match(body, /Waiting to publish the final review/); + assert.match(body, /bounded result-publication queue/); + assert.match( + body, + /var hasPublishing=Object\.prototype\.hasOwnProperty\.call\(stages,"publishing"\)/, + ); + assert.match(body, /if\(stage==="applying"&&!hasPublishing\)reported=0/); assert.match(body, /id="tunnel-layer"/); assert.match(body, /function startTunnelJourney/); assert.doesNotMatch(body, /function drawTunnels/); @@ -2361,17 +2482,76 @@ test("OpenClaw Bay is an unlisted, hardened demo route", async () => { assert.ok(new Set(chatContext.copies.map((copy) => copy.answer)).size > 1); assert.ok(chatContext.copies.every((copy) => copy.answer.includes("7m"))); const runChangedSource = body.match(/function runChanged\([^}]+\}/)?.[0]; + const stageForSource = body.match(/function stageFor\([^]*?return "arriving";\}/)?.[0]; + const queueProjectionStageSource = body.match( + /function queueProjectionStage\([^]*?return STAGES\.indexOf\(stage\)>=0\?stage:"arriving";\}/, + )?.[0]; const transitionKindSource = body.match( /function transitionKind\([^]*?return oldIndex>=0&&nextIndex>oldIndex\?"forward":null;\}/, )?.[0]; assert.ok(runChangedSource); + assert.ok(stageForSource); + assert.ok(queueProjectionStageSource); assert.ok(transitionKindSource); + const normalizeQueueStage = new Script( + `${queueProjectionStageSource};queueProjectionStage`, + ).runInNewContext({ + STAGES: ["arriving", "setting-up", "reviewing", "publishing", "applying", "repairing"], + }); + assert.equal(normalizeQueueStage("applying", false), "applying"); + assert.equal(normalizeQueueStage("applying", true), "publishing"); + assert.equal(normalizeQueueStage("publishing"), "publishing"); + const classifyBayStage = new Script(`${stageForSource};stageFor`).runInNewContext({ + STAGES: ["arriving", "setting-up", "reviewing", "publishing", "applying", "repairing"], + }); + const publicationSteps = [{ name: "Apply review artifacts" }]; + assert.equal( + classifyBayStage({ + status: "in_progress", + current_step: "Validate exact review artifact bundle", + steps: publicationSteps, + }), + "publishing", + ); + assert.equal( + classifyBayStage({ + status: "queued", + current_step: "Waiting for runner", + steps: publicationSteps, + }), + "publishing", + ); + assert.equal( + classifyBayStage({ + name: "Publish review artifacts", + status: "queued", + current_step: "Waiting for runner", + steps: [], + }), + "publishing", + ); + assert.equal( + classifyBayStage({ + status: "in_progress", + current_step: "Publish review artifact action ledger", + steps: publicationSteps, + }), + "publishing", + ); + assert.equal( + classifyBayStage({ + status: "in_progress", + current_step: "Sync selected review comments", + steps: publicationSteps, + }), + "applying", + ); const classifyTransition = new Script( `${runChangedSource};(${transitionKindSource})`, ).runInNewContext({ - MAIN_STAGES: ["arriving", "setting-up", "reviewing", "applying"], + MAIN_STAGES: ["arriving", "setting-up", "reviewing", "publishing", "applying"], }); - for (const stage of ["setting-up", "reviewing", "applying"]) { + for (const stage of ["setting-up", "reviewing", "publishing", "applying"]) { assert.equal( classifyTransition({ run_id: "old", stage: "reviewing" }, { run_id: "new", stage }), "retrigger", @@ -2387,6 +2567,13 @@ test("OpenClaw Bay is an unlisted, hardened demo route", async () => { assert.equal( classifyTransition( { run_id: "same", stage: "reviewing" }, + { run_id: "same", stage: "publishing" }, + ), + "forward", + ); + assert.equal( + classifyTransition( + { run_id: "same", stage: "publishing" }, { run_id: "same", stage: "applying" }, ), "forward", @@ -10585,10 +10772,21 @@ test("dashboard hero treats apply and exact-review handoff health as attention", /target 32 · pressure ceiling 24 after GitHub rate limit/, ); assert.match(elementFor("exact-review-lanes").innerHTML, /No backlog history in this range/); - status.workers = Array.from({ length: 130 }, (_, id) => ({ id, status: "in_progress" })); + status.workers = [ + ...Array.from({ length: 128 }, (_, id) => ({ + id, + status: "in_progress", + is_codex_worker: true, + })), + { id: 128, status: "in_progress", is_codex_worker: false }, + { id: 129, status: "in_progress", is_codex_worker: false }, + ]; context.renderSystemMap(status); - assert.match(elementFor("capacity-rail").innerHTML, /130 running/); - assert.match(elementFor("capacity-rail").innerHTML, /2 over budget/); + assert.match(elementFor("capacity-rail").innerHTML, /128 running/); + assert.doesNotMatch(elementFor("capacity-rail").innerHTML, /over budget/); + status.workers = [{ id: 130, status: "in_progress", is_codex_worker: false }]; + context.renderDashboard(status, ""); + assert.match(elementFor("hero-headline").textContent, /0 claw workers sweeping/); status.workers = []; status.recent.apply_health.items = []; @@ -11023,10 +11221,11 @@ test("dashboard preserves issue titles across generated PR repair events", () => test("dashboard exposes active worker jobs and their current steps", async () => { const originalFetch = globalThis.fetch; const originalCaches = globalThis.caches; + const cache = new MemoryCache(); Object.defineProperty(globalThis, "caches", { configurable: true, value: { - default: new MemoryCache(), + default: cache, }, }); const run = { @@ -11049,6 +11248,16 @@ test("dashboard exposes active worker jobs and their current steps", async () => created_at: isoAgo(30_000), updated_at: isoAgo(5_000), }; + const queuedBatchRun = { + id: 44, + name: "Publish exact review batch", + display_title: "Publish exact review batch", + status: "queued", + conclusion: null, + html_url: "https://github.com/openclaw/clawsweeper/actions/runs/44", + created_at: isoAgo(20_000), + updated_at: isoAgo(2_000), + }; let graphqlRequests = 0; globalThis.fetch = async (input) => { const url = new URL(String(input)); @@ -11056,11 +11265,11 @@ test("dashboard exposes active worker jobs and their current steps", async () => const status = url.searchParams.get("status"); return jsonResponse({ workflow_runs: !status - ? [run, queuedRun] + ? [run, queuedRun, queuedBatchRun] : status === "in_progress" ? [run] : status === "queued" - ? [queuedRun] + ? [queuedRun, queuedBatchRun] : [], }); } @@ -11098,9 +11307,46 @@ test("dashboard exposes active worker jobs and their current steps", async () => { id: 4202, name: "Publish review artifacts", - status: "queued", + status: "in_progress", conclusion: null, - steps: [], + html_url: "https://github.com/openclaw/clawsweeper/actions/runs/42/job/4202", + started_at: isoAgo(60_000), + steps: [ + { + number: 1, + name: "Apply review artifacts", + status: "completed", + conclusion: "success", + }, + { + number: 2, + name: "Publish review artifact action ledger", + status: "in_progress", + conclusion: null, + }, + ], + }, + { + id: 4203, + name: "publish", + status: "in_progress", + conclusion: null, + html_url: "https://github.com/openclaw/clawsweeper/actions/runs/42/job/4203", + started_at: isoAgo(55_000), + steps: [ + { + number: 1, + name: "Claim one durable publication batch", + status: "completed", + conclusion: "success", + }, + { + number: 2, + name: "Finalize healthy members under a fenced heartbeat", + status: "in_progress", + conclusion: null, + }, + ], }, ], }); @@ -11108,6 +11354,21 @@ test("dashboard exposes active worker jobs and their current steps", async () => if (url.pathname === "/repos/openclaw/clawsweeper/actions/runs/43/jobs") { return jsonResponse({ jobs: [] }); } + if (url.pathname === "/repos/openclaw/clawsweeper/actions/runs/44/jobs") { + return jsonResponse({ + jobs: [ + { + id: 4401, + name: "publish", + status: "queued", + conclusion: null, + html_url: "https://github.com/openclaw/clawsweeper/actions/runs/44/job/4401", + started_at: null, + steps: [], + }, + ], + }); + } if (url.pathname === "/graphql") { graphqlRequests += 1; return jsonResponse({ @@ -11164,9 +11425,9 @@ test("dashboard exposes active worker jobs and their current steps", async () => ); const status = await response.json(); assert.equal(status.fleet.active_codex_jobs, 2); - assert.equal(status.fleet.worker_detail_runs, 2); + assert.equal(status.fleet.worker_detail_runs, 3); assert.equal(status.fleet.worker_detail_fallbacks, 1); - assert.equal(status.workers.length, 2); + assert.equal(status.workers.length, 5); assert.equal(status.workers[0].id, 4201); assert.equal(status.workers[0].name, "Review shard 0 · openclaw/openclaw#92521,92522"); assert.equal(status.workers[0].repository, "openclaw/openclaw"); @@ -11191,10 +11452,33 @@ test("dashboard exposes active worker jobs and their current steps", async () => type: "pull_request", }, ]); - assert.equal(status.workers[1].id, "run-43"); - assert.equal(status.workers[1].source, "workflow-fallback"); - assert.equal(status.workers[1].current_step, "reviewing"); - assert.equal(status.workers[1].target_items[0].title, "Queued terminal resize follow-up"); + assert.equal(status.workers[1].id, 4202); + assert.equal(status.workers[1].name, "Publish review artifacts"); + assert.equal(status.workers[1].is_codex_worker, false); + assert.equal(status.workers[1].item_number, 92521); + assert.equal(status.workers[1].current_step, "Publish review artifact action ledger"); + assert.equal(status.workers[1].steps[0].name, "Apply review artifacts"); + assert.equal(status.workers[2].id, 4203); + assert.equal(status.workers[2].name, "publish"); + assert.equal(status.workers[2].is_codex_worker, false); + assert.equal( + status.workers[2].current_step, + "Finalize healthy members under a fenced heartbeat", + ); + const cachedPublisherJobs = await cache.match( + new Request("https://clawsweeper.internal/store/workflow-jobs%3Aopenclaw%2Fclawsweeper%3A42"), + ); + assert.equal(cachedPublisherJobs?.headers.get("cache-control"), "public, max-age=60"); + assert.equal(status.workers[3].id, "run-43"); + assert.equal(status.workers[3].source, "workflow-fallback"); + assert.equal(status.workers[3].current_step, "reviewing"); + assert.equal(status.workers[3].target_items[0].title, "Queued terminal resize follow-up"); + const queuedBatchPublisher = status.workers.find((entry) => entry.id === 4401); + assert.ok(queuedBatchPublisher); + assert.equal(queuedBatchPublisher.name, "publish"); + assert.equal(queuedBatchPublisher.is_codex_worker, false); + assert.equal(queuedBatchPublisher.workflow_title, "Publish exact review batch"); + assert.equal(queuedBatchPublisher.current_step, "Waiting for runner"); const cachedResponse = await worker.fetch( new Request("https://clawsweeper.openclaw.ai/api/status"), diff --git a/test/exact-review-publication-batches.test.ts b/test/exact-review-publication-batches.test.ts index 686f226afc..ed88235cb6 100644 --- a/test/exact-review-publication-batches.test.ts +++ b/test/exact-review-publication-batches.test.ts @@ -133,6 +133,10 @@ test("publication batches atomically select ready items without duplicate active assert.equal(second, null); assert.equal(first?.configuredBatchSize, 2); assert.deepEqual(batches.activeLeaseSnapshot(1_500), { + items: candidates.slice(0, 2).map((candidate) => ({ + itemKey: candidate.itemKey, + batchId: "batch-1", + })), itemKeys: candidates.slice(0, 2).map((item) => item.itemKey), activeBatches: 1, nextLeaseExpiresAt: 2_000, @@ -183,6 +187,16 @@ test("publication batches allow a bounded number of disjoint active owners", () ); assert.equal(third, null); assert.deepEqual(batches.activeLeaseSnapshot(1_500), { + items: [ + ...candidates.slice(0, 2).map((candidate) => ({ + itemKey: candidate.itemKey, + batchId: "parallel-1", + })), + ...candidates.slice(2).map((candidate) => ({ + itemKey: candidate.itemKey, + batchId: "parallel-2", + })), + ], itemKeys: candidates.map((item) => item.itemKey), activeBatches: 2, nextLeaseExpiresAt: 2_000,