diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml index b325df963f..920b3fb243 100644 --- a/.github/workflows/sweep.yml +++ b/.github/workflows/sweep.yml @@ -2326,6 +2326,28 @@ jobs: git restore results/sweep-status || true fi } + write_apply_health() { + report_path="$1" + output_path="$2" + health_mode="$3" + health_processed_limit="$4" + health_cursor_path="${5:-}" + health_cursor_required="${6:-false}" + health_args=( + --target-repo "$TARGET_REPO" + --report "$report_path" + --mode "$health_mode" + --processed-limit "$health_processed_limit" + --close-limit "$limit" + ) + if [ -n "$health_cursor_path" ]; then + health_args+=(--cursor-path "$health_cursor_path") + fi + if [ "$health_cursor_required" = "true" ]; then + health_args+=(--cursor-required true) + fi + pnpm run --silent workflow -- summarize-apply-report "${health_args[@]}" > "$output_path" + } reconcile_args=(--target-repo "$TARGET_REPO" --skip-closed-at) if [ -n "$item_numbers" ]; then reconcile_args+=(--item-numbers "$item_numbers") @@ -2396,6 +2418,12 @@ jobs: fi if [ "$sync_comments_only" != "true" ] && [ -z "$item_numbers" ]; then echo "No unchanged high-confidence close proposals are awaiting apply. Scheduled apply wakes every 15 minutes and exits without scanning unrelated keep-open records when there is no close work." + pnpm run status -- \ + --target-repo "$TARGET_REPO" \ + --state "Apply idle" \ + --detail "No unchanged high-confidence close proposals are awaiting apply. Scheduled apply wakes every 15 minutes and exits without scanning unrelated keep-open records when there is no close work." \ + --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + publish_status "chore: update idle sweep apply status" { echo "APPLY_CLOSED_TOTAL=0" echo "APPLY_LIMIT=1" @@ -2455,11 +2483,21 @@ jobs: --target-repo "$TARGET_REPO" fi publish_changes "chore: sync sweep review comments checkpoint $checkpoint" records apply-report.json results/comment-sync-cursors + comment_sync_health_cursor_path="" + comment_sync_health_cursor_required="false" + comment_sync_health_processed_limit="$comment_sync_processed_limit" + if [ "$sync_open_pr_batch" = "true" ]; then + comment_sync_health_cursor_path="$cursor_path" + comment_sync_health_cursor_required="true" + comment_sync_health_processed_limit="$sync_batch_size" + fi + write_apply_health ".artifacts/apply-reports/apply-report-$checkpoint.json" ".artifacts/apply-health-$checkpoint.json" "comment_sync" "$comment_sync_health_processed_limit" "$comment_sync_health_cursor_path" "$comment_sync_health_cursor_required" pnpm run status -- \ --target-repo "$TARGET_REPO" \ --state "Apply comments synced" \ --detail "Comment-only apply checkpoint $checkpoint finished. Synced durable review comments: $synced_count. Result records: $result_count. Item numbers: ${item_numbers:-all}. Next cursor: ${next_cursor:-none}." \ - --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --apply-health-file ".artifacts/apply-health-$checkpoint.json" publish_status "chore: update sweep comment sync status" echo "::endgroup::" fi @@ -2506,11 +2544,17 @@ jobs: apply_publish_paths+=(results/apply-cursors) fi publish_changes "chore: apply sweep decisions checkpoint $checkpoint" "${apply_publish_paths[@]}" + close_health_cursor_path="" + if [ "$auto_selected_apply_batch" = "true" ]; then + close_health_cursor_path="$apply_cursor_path" + fi + write_apply_health ".artifacts/apply-reports/apply-report-$checkpoint.json" ".artifacts/apply-health-$checkpoint.json" "close" "$close_processed_limit" "$close_health_cursor_path" "$auto_selected_apply_batch" pnpm run status -- \ --target-repo "$TARGET_REPO" \ --state "Apply in progress" \ --detail "Checkpoint $checkpoint finished. Fresh closes in checkpoint: $closed_in_chunk. Total fresh closes in this run: $closed_total/$limit. Result records in checkpoint: $result_count, including durable review comment syncs." \ - --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --apply-health-file ".artifacts/apply-health-$checkpoint.json" publish_status "chore: update sweep apply checkpoint $checkpoint status" echo "::endgroup::" if [ "$result_count" -ge "$close_processed_limit" ]; then @@ -2535,11 +2579,30 @@ jobs: continue_apply=true break done + final_health_mode="close" + final_health_processed_limit="$close_processed_limit" + final_health_cursor_path="" + final_health_cursor_required="$auto_selected_apply_batch" + if [ "$auto_selected_apply_batch" = "true" ]; then + final_health_cursor_path="$apply_cursor_path" + fi + if [ "$sync_comments_only" = "true" ]; then + final_health_mode="comment_sync" + final_health_processed_limit="$comment_sync_processed_limit" + final_health_cursor_required="false" + if [ "$sync_open_pr_batch" = "true" ]; then + final_health_cursor_path="$cursor_path" + final_health_cursor_required="true" + final_health_processed_limit="$sync_batch_size" + fi + fi + write_apply_health "apply-report.json" ".artifacts/apply-health-final.json" "$final_health_mode" "$final_health_processed_limit" "$final_health_cursor_path" "$final_health_cursor_required" pnpm run status -- \ --target-repo "$TARGET_REPO" \ --state "Apply finished" \ --detail "Apply/comment-sync run finished with $closed_total fresh closes out of requested limit $limit. See apply-report.json for per-item results." \ - --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --apply-health-file ".artifacts/apply-health-final.json" publish_status "chore: mark sweep apply finished" next_apply_item_numbers="$item_numbers" if [ "$auto_selected_apply_batch" = "true" ] && [ -z "$explicit_item_numbers" ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index 4219692364..760d782848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ checkpoint, and status-only commits are intentionally omitted. ### Added +- Added apply-health telemetry and a quiet-by-default dashboard alert for stalled, cursorless, or fully blocked pruning windows. Thanks @brokemac79. - Added author-wide PR repair intake across configured public repositories, with private and unsupported repositories excluded before job generation. Thanks @Jhacarreiro. - Added a system, light, and dark theme switcher to the generated documentation site. Thanks @joshka. diff --git a/dashboard/worker.ts b/dashboard/worker.ts index 75cc7e77bf..423e55217f 100644 --- a/dashboard/worker.ts +++ b/dashboard/worker.ts @@ -1764,7 +1764,7 @@ async function statusSnapshot(env) { TERMINAL_BAD_CONCLUSIONS.has(String(run.conclusion)), ); const activeJobs = await activeWorkerSnapshot(env, repo, workerRuns); - const [workerHealth, pipeline, clusterRepair, automerge, closed, storedEvents] = + const [workerHealth, pipeline, clusterRepair, applyHealth, automerge, closed, storedEvents] = await Promise.all([ withTimeout( recentWorkerHealth(env, repo, completedWorkflowRuns), @@ -1790,6 +1790,14 @@ async function statusSnapshot(env) { errors.push(error.message); return emptyClusterRepairStatus(targetRepos); }), + withTimeout( + applyHealthStatus(env, targetRepos), + OPTIONAL_SECTION_TIMEOUT_MS, + "apply health", + ).catch((error) => { + errors.push(error.message); + return emptyApplyHealthStatus(targetRepos); + }), withTimeout( recentAutomerge(env, targetRepos[0] || "openclaw/openclaw"), OPTIONAL_SECTION_TIMEOUT_MS, @@ -1844,6 +1852,7 @@ async function statusSnapshot(env) { pipeline, recent: { cluster_repair: clusterRepair, + apply_health: applyHealth, automerge: automerge.items, closed_items: closed.items, closed_stats: closed.stats, @@ -3527,6 +3536,131 @@ async function clusterRepairStatus(env, repo, targetRepos, activeRuns) { }; } +async function applyHealthStatus(env, targetRepos) { + const items = await Promise.all( + targetRepos.map((targetRepo) => readApplyHealthMarker(env, targetRepo)), + ); + const attention = items.filter((item) => applyHealthNeedsAttention(item.status)); + return { + items, + attention_count: attention.length, + latest_attention_at: latestIso(attention.map((item) => item.updated_at)), + }; +} + +async function readApplyHealthMarker(env, targetRepo) { + const stateRepo = String(env.CLAWSWEEPER_STATE_REPO || CLAWSWEEPER_STATE_REPO); + const stateRef = String(env.CLAWSWEEPER_STATE_REF || CLAWSWEEPER_STATE_REF); + const repoSlug = String(targetRepo || "").replace(/\//g, "-"); + const statusPath = `results/sweep-status/${repoSlug}.json`; + try { + const content = await githubJson( + env, + `/repos/${stateRepo}/contents/${githubPath(statusPath)}?ref=${encodeURIComponent(stateRef)}`, + ); + const status = parseJsonObject(decodeGithubContent(content?.content)) || {}; + const health = objectValue(status.apply_health); + const skipReasons = numericRecord(health.skip_reasons); + const cursor = objectValue(health.cursor); + return { + target_repo: nullableString(status.target_repo) || targetRepo, + status_path: statusPath, + state: nullableString(status.state), + detail: nullableString(status.detail), + run_url: nullableString(status.run_url), + updated_at: nullableString(health.generated_at) || nullableString(status.updated_at), + mode: nullableString(health.mode), + status: nullableString(health.status) || "unavailable", + summary: nullableString(health.summary), + processed: numberOrNull(health.processed), + processed_limit: numberOrNull(health.processed_limit), + close_limit: numberOrNull(health.close_limit), + closed: numberOrNull(health.closed), + comment_synced: numberOrNull(health.comment_synced), + skipped: numberOrNull(health.skipped), + skip_reasons: skipReasons, + cursor_required: health.cursor_required === true, + attention_reasons: Array.isArray(health.attention_reasons) + ? health.attention_reasons + .map((reason) => String(reason)) + .filter(Boolean) + .slice(0, 8) + : [], + cursor: cursor.next_after_number + ? { + next_after_number: numberOrNull(cursor.next_after_number), + next_after_apply_checked_at: nullableString(cursor.next_after_apply_checked_at), + updated_at: nullableString(cursor.updated_at), + } + : null, + }; + } catch { + return { + target_repo: targetRepo, + status_path: statusPath, + state: null, + detail: null, + run_url: null, + updated_at: null, + mode: null, + status: "unavailable", + summary: null, + processed: null, + processed_limit: null, + close_limit: null, + closed: null, + comment_synced: null, + skipped: null, + skip_reasons: {}, + cursor_required: false, + attention_reasons: [], + cursor: null, + }; + } +} + +function emptyApplyHealthStatus(targetRepos) { + return { + items: targetRepos.map((targetRepo) => ({ + target_repo: targetRepo, + status_path: `results/sweep-status/${String(targetRepo || "").replace(/\//g, "-")}.json`, + status: "unavailable", + updated_at: null, + skip_reasons: {}, + cursor_required: false, + attention_reasons: [], + cursor: null, + })), + attention_count: 0, + latest_attention_at: null, + }; +} + +function applyHealthNeedsAttention(status) { + return ["attention", "blocked", "degraded", "failed", "needs_attention", "warning"].includes( + String(status || "").toLowerCase(), + ); +} + +function latestIso(values) { + const timestamps = values + .map((value) => Date.parse(value || "")) + .filter((value) => Number.isFinite(value)); + if (!timestamps.length) return null; + return new Date(Math.max(...timestamps)).toISOString(); +} + +function numericRecord(value) { + const record = objectValue(value); + return Object.fromEntries( + Object.entries(record) + .map(([key, count]) => ({ key, count: numberOrNull(count) })) + .filter((entry) => entry.count !== null && entry.count > 0) + .sort((left, right) => left.key.localeCompare(right.key)) + .map((entry) => [entry.key, entry.count]), + ); +} + async function readClusterRepairMarker(env, targetRepo) { const stateRepo = String(env.CLAWSWEEPER_STATE_REPO || CLAWSWEEPER_STATE_REPO); const stateRef = String(env.CLAWSWEEPER_STATE_REF || CLAWSWEEPER_STATE_REF); @@ -5717,6 +5851,63 @@ h2 { .status-dot.waiting { background: var(--amber); } .status-dot.done { background: var(--green); } .status-dot.failed { background: var(--red); } +.apply-health-alert { + display: grid; + gap: 8px; + margin-top: 14px; + padding: 11px 12px; + border: 1px solid rgba(243,183,89,0.5); + border-left: 3px solid var(--amber); + border-radius: 8px; + background: rgba(243,183,89,0.08); +} +.apply-health-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.apply-health-heading strong { color: #ffe0a8; } +.apply-health-alert p { margin: 0; color: var(--muted); } +.apply-health-next strong { color: var(--text); } +.apply-health-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.apply-health-meta .pill { + min-height: 22px; + padding: 2px 8px; + font-size: 11px; +} +.apply-health-reason { + cursor: help; + border-color: rgba(243,183,89,0.35); +} +.apply-health-action { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 6px; + align-items: center; +} +.apply-health-command { + min-width: 0; + padding: 5px 8px; + color: #dce7f5; + overflow-wrap: anywhere; + white-space: normal; + background: rgba(6,10,15,0.45); + border: 1px solid var(--line); + border-radius: 8px; + line-height: 1.45; +} +.apply-health-copy { + min-height: 27px; +} +@media (max-width: 740px) { + .apply-health-action { grid-template-columns: 1fr; } +} .automatic-head { margin-top: 24px; } .automatic-grid { display: grid; @@ -6224,6 +6415,7 @@ a:hover { color: #89c8ff; text-decoration: underline; } waiting available +

Automatic Builds

@@ -6596,6 +6788,7 @@ function renderDashboard(data, note) { metric("🎯 Capacity", fleet.budget_used_percent + "%", "fleet utilization", fleet.budget_used_percent, "var(--green)") ].join(""); renderSystemMap(data); + renderApplyHealth(data); renderAutomaticWork(data.automatic_work || []); renderWorkers(data.workers || []); openWorkerFromHash(); @@ -6608,6 +6801,225 @@ function renderDashboard(data, note) { renderOperations(data.recent.operation_counts); renderEvents(data.recent.events || []); } +function renderApplyHealth(data) { + const target = document.getElementById("apply-health"); + if (!target) return; + const items = (data.recent?.apply_health?.items || []).filter(item => applyHealthNeedsAttention(item.status)); + if (!items.length) { + target.innerHTML = ""; + return; + } + target.innerHTML = items.map(item => { + const topReason = applyHealthPrimaryReason(item); + const topInfo = applyHealthReasonInfo(topReason); + const action = applyHealthRecommendedAction(item, topReason); + const reasons = applyHealthReasonEntries(item) + .slice(0, 4) + .map(([reason, count]) => applyHealthReasonPill(reason, count)) + .join(""); + const showCursor = item.cursor_required || Boolean(item.cursor?.next_after_number); + const cursor = item.cursor?.next_after_number ? "cursor #" + item.cursor.next_after_number : "cursor missing"; + const cursorTitle = item.cursor?.next_after_number + ? "Rotation cursor was recorded; the next pruning run should continue after this item." + : "No rotation cursor was recorded. If this was a full scan window, the next pruning run can repeat the same records."; + const cursorPill = showCursor + ? '' + esc(cursor) + '' + : ""; + const processed = Number.isFinite(item.processed) ? fmt.format(item.processed) : "unknown"; + const closed = Number.isFinite(item.closed) ? fmt.format(item.closed) : "unknown"; + const synced = Number.isFinite(item.comment_synced) ? fmt.format(item.comment_synced) : "unknown"; + return '
' + + '
Pruning sweep ' + esc(applyHealthStatusLabel(item.status)) + " - " + esc(item.target_repo || "target repo") + '' + esc(applyHealthModeLabel(item.mode)) + '
' + + '

' + esc(applyHealthOperatorSummary(item, topInfo)) + '

' + + '

Next check: ' + esc(topInfo.action) + '

' + + applyHealthActionHtml(action) + + '
' + esc(processed) + ' processed' + esc(closed) + ' closed' + esc(synced) + ' comments synced' + cursorPill + reasons + linkClass(item.run_url, "workflow run", "pill run-link") + '
'; + }).join(""); +} +function applyHealthNeedsAttention(status) { + return ["attention", "blocked", "degraded", "failed", "needs_attention", "warning"].includes(String(status || "").toLowerCase()); +} +function applyHealthStatusLabel(status) { + const value = String(status || "").toLowerCase(); + if (value === "failed") return "failed"; + if (value === "degraded" || value === "warning" || value === "attention") return "degraded"; + return "blocked"; +} +function applyHealthModeLabel(mode) { + const value = String(mode || "").toLowerCase(); + if (value === "comment_sync") return "comment-sync lane"; + if (value === "close") return "close lane"; + return "pruning lane"; +} +function applyHealthReasonEntries(item) { + const entries = []; + const seen = new Set(); + const skipReasons = item.skip_reasons || {}; + for (const reason of item.attention_reasons || []) { + if (!reason || seen.has(reason)) continue; + seen.add(reason); + const skipCount = skipReasons[reason]; + entries.push([reason, Number.isFinite(skipCount) ? skipCount : null]); + } + for (const entry of Object.entries(skipReasons).sort((left, right) => Number(right[1]) - Number(left[1]))) { + if (seen.has(entry[0])) continue; + seen.add(entry[0]); + entries.push(entry); + } + return entries; +} +function applyHealthPrimaryReason(item) { + return applyHealthReasonEntries(item)[0]?.[0] || item.status || ""; +} +function applyHealthReasonPill(reason, count) { + const info = applyHealthReasonInfo(reason); + const countText = Number.isFinite(count) ? " " + fmt.format(count) : ""; + return '' + esc(info.label + countText) + ''; +} +function applyHealthActionHtml(action) { + if (!action) return ""; + const command = action.command || ""; + const commandHtml = command + ? '' + esc(command) + '' + : '' + esc(action.detail || "No safe automatic action is available from the dashboard.") + ''; + return '
' + commandHtml + linkClass(action.url, action.linkLabel || "open workflow", "pill run-link") + '
'; +} +function applyHealthRecommendedAction(item, reason) { + const targetRepo = String(item.target_repo || "openclaw/openclaw"); + const mode = String(item.mode || "").toLowerCase(); + const workflowUrl = "https://github.com/openclaw/clawsweeper/actions/workflows/sweep.yml"; + if (reason === "cursor_required_but_missing_after_full_window") { + return { + title: "Maintainer action: inspect the current run before rerunning, because a missing cursor can make the next run repeat the same window.", + detail: "Inspect the cursor-write and state-publish steps; rerun only after the cursor write failure is understood.", + url: item.run_url || workflowUrl, + linkLabel: item.run_url ? "open run" : "open workflow", + }; + } + if (reason === "skipped_changed_since_review") { + return { + title: "Maintainer action: refresh review records before trying to close changed items.", + command: "gh workflow run sweep.yml --repo openclaw/clawsweeper -f target_repo=" + targetRepo + " -f apply_existing=false", + url: workflowUrl, + linkLabel: "open workflow", + }; + } + if (reason === "skipped_pr_close_coverage_proof") { + return { + title: "Maintainer action: add close-coverage proof before retrying PR pruning.", + detail: "Add or refresh close-coverage proof, then rerun the close lane.", + url: item.run_url || workflowUrl, + linkLabel: item.run_url ? "open run" : "open workflow", + }; + } + if (mode === "comment_sync") { + return { + title: "Maintainer action: run the next comment-sync cursor window. GitHub permissions control who can run it.", + command: "gh workflow run sweep.yml --repo openclaw/clawsweeper -f target_repo=" + targetRepo + " -f apply_existing=true -f apply_sync_comments_only=true -f apply_item_numbers=__cursor__ -f apply_limit=25", + url: workflowUrl, + linkLabel: "open workflow", + }; + } + const closeLimit = Number.isFinite(item.close_limit) && item.close_limit > 0 ? item.close_limit : 5; + return { + title: "Maintainer action: rerun the bounded close lane. GitHub permissions control who can run it.", + command: "gh workflow run sweep.yml --repo openclaw/clawsweeper -f target_repo=" + targetRepo + " -f apply_existing=true -f apply_limit=" + closeLimit + " -f apply_kind=all -f apply_close_reasons=all", + url: workflowUrl, + linkLabel: "open workflow", + }; +} +function applyHealthReasonInfo(reason) { + const value = String(reason || ""); + if (value === "cursor_required_but_missing_after_full_window") { + return { + label: "Rotation cursor missing", + summary: "The pruning sweep processed the full bounded window but did not publish the next cursor.", + action: "Open the workflow run and check the cursor-write step; until the cursor is written, the next run can repeat this window.", + }; + } + if (value === "skipped_runtime_budget") { + return { + label: "Runtime budget hit", + summary: "The workflow stopped processing because it reached its bounded runtime.", + action: "Let the next scheduled sweep continue; if this repeats, reduce the batch size or raise the apply runtime budget.", + }; + } + if (value === "skipped_live_fetch_failed") { + return { + label: "GitHub live check failed", + summary: "ClawSweeper could not confirm live GitHub state before mutating an item.", + action: "Inspect the workflow run for GitHub API, auth, or rate-limit failures, then rerun after live checks recover.", + }; + } + if (value === "skipped_changed_since_review") { + return { + label: "Changed since review", + summary: "The item changed after the ClawSweeper review that proposed the close.", + action: "Refresh the ClawSweeper review for those items before closing; this skip is a safety guard.", + }; + } + if (value === "skipped_pr_close_coverage_proof") { + return { + label: "PR close proof needed", + summary: "The PR needs coverage proof before ClawSweeper can close it as duplicate or superseded.", + action: "Add or refresh close-coverage proof, then rerun the sweep.", + }; + } + if (value === "skipped_open_closing_pr") { + return { + label: "Closing PR still open", + summary: "The issue appears covered by an open pull request, so ClawSweeper avoided closing it early.", + action: "Review or land the linked closing PR before expecting the issue to close.", + }; + } + if (value === "skipped_maintainer_authored") { + return { + label: "Maintainer-authored item", + summary: "Automation will not close this maintainer-authored item without human review.", + action: "Have a maintainer decide whether to close it manually or update the review policy.", + }; + } + if (value === "skipped_policy_exempt" || value === "skipped_protected_label") { + return { + label: "Policy-protected item", + summary: "A label or policy exemption blocked automated pruning.", + action: "Check the policy or label before taking manual action.", + }; + } + if (value === "skipped_not_open" || value === "skipped_already_closed" || value === "skipped_closed") { + return { + label: "Already closed", + summary: "The item was no longer open by the time ClawSweeper checked it.", + action: "No action is usually needed; investigate only if already-closed records dominate repeated runs.", + }; + } + return { + label: applyHealthReasonLabel(value || "blocked_condition"), + summary: "ClawSweeper reported this skip bucket while checking whether it could safely prune an item.", + action: "Open the workflow run and inspect this skip bucket before rerunning or changing limits.", + }; +} +function applyHealthReasonLabel(reason) { + return String(reason || "") + .replace(/^skipped_/, "") + .replace(/_/g, " ") + .replace(/\\b\\w/g, letter => letter.toUpperCase()); +} +function applyHealthOperatorSummary(item, reasonInfo) { + const processed = applyHealthCount(item.processed, "record", "records"); + const skipped = Number.isFinite(item.skipped) ? "; " + applyHealthCount(item.skipped, "record", "records") + " skipped" : ""; + const closed = Number.isFinite(item.closed) ? item.closed : 0; + const synced = Number.isFinite(item.comment_synced) ? item.comment_synced : 0; + const useful = closed + synced; + const result = useful > 0 + ? "ClawSweeper processed " + processed + " and completed " + applyHealthCount(useful, "close/comment update", "close/comment updates") + : "ClawSweeper processed " + processed + " without closing or syncing anything"; + return result + skipped + ". Main signal: " + reasonInfo.label + "."; +} +function applyHealthCount(value, singular, plural) { + if (!Number.isFinite(value)) return "unknown " + plural; + return fmt.format(value) + " " + (value === 1 ? singular : plural); +} function renderPipeline(rows) { if (!rows.length) { document.getElementById("pipeline").innerHTML = '
All quiet in the depths... no active sweeps
'; @@ -6701,6 +7113,21 @@ document.getElementById("automatic-work").addEventListener("click", event => { const row = automaticIndex.get(String(button.dataset.automaticId)); if (row) renderAutomaticDialog(row); }); +document.addEventListener("click", event => { + const button = event.target.closest("button[data-copy-command]"); + if (!button) return; + const command = String(button.dataset.copyCommand || ""); + if (!command) return; + const copied = navigator.clipboard?.writeText(command); + if (!copied) return; + copied.then(() => { + const original = button.textContent; + button.textContent = "Copied"; + setTimeout(() => { + button.textContent = original || "Copy command"; + }, 1500); + }).catch(() => undefined); +}); document.getElementById("worker-dialog-close").addEventListener("click", closeWorkerDialog); document.getElementById("worker-dialog").addEventListener("click", event => { const linkedWorker = event.target.closest("button[data-linked-worker-id]"); diff --git a/docs/live-dashboard.md b/docs/live-dashboard.md index 94527ed7b4..6d96e6bb50 100644 --- a/docs/live-dashboard.md +++ b/docs/live-dashboard.md @@ -125,6 +125,9 @@ is absent or a cache event lands in another Cloudflare colo. - recent automerge command-to-merge timing samples - explicit workflow status events posted to the ingest API when KV ingest is enabled +- problem-focused pruning alerts from latest sweep status files when apply runs + report blocked or degraded progress, with reason tooltips and maintainer + workflow commands for safe follow-up The Worker fetches job details only for the bounded active-run set, limits that GitHub fanout to 12 concurrent requests, and caches each run's jobs for 60 diff --git a/docs/proof/pr-391/apply-health-alert.png b/docs/proof/pr-391/apply-health-alert.png new file mode 100644 index 0000000000..51d2080ce6 Binary files /dev/null and b/docs/proof/pr-391/apply-health-alert.png differ diff --git a/docs/proof/pr-391/apply-health-quiet.png b/docs/proof/pr-391/apply-health-quiet.png new file mode 100644 index 0000000000..905ca2a40c Binary files /dev/null and b/docs/proof/pr-391/apply-health-quiet.png differ diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index b57f99873c..aa6dc6dcd1 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -759,6 +759,7 @@ interface WorkflowStatusSummary { state: string; detail: string; runUrl: string | undefined; + applyHealth: Record | undefined; plannedCount: number | undefined; plannedCapacity: number | undefined; plannedShards: number | undefined; @@ -1918,9 +1919,14 @@ function writeSweepStatus(options: { failedReviewRetryExhaustions?: number; botOwnedProofDecisionsRequested?: number; botOwnedProofDispatches?: number; + applyHealth?: Record | null; }): void { const profile = options.profile ?? targetProfile(); const updatedAt = new Date().toISOString(); + const applyHealth = + options.applyHealth === undefined + ? readSweepStatusSummary(profile)?.applyHealth + : options.applyHealth; const payload = { schema_version: 1, slug: profile.slug, @@ -1942,6 +1948,7 @@ function writeSweepStatus(options: { failed_review_retry_exhaustions: options.failedReviewRetryExhaustions ?? null, bot_owned_proof_decisions_requested: options.botOwnedProofDecisionsRequested ?? null, bot_owned_proof_dispatches: options.botOwnedProofDispatches ?? null, + apply_health: applyHealth ?? null, updated_at: updatedAt, }; const outputPath = sweepStatusPath(profile); @@ -8286,6 +8293,7 @@ function readSweepStatusSummary(profile = targetProfile()): WorkflowStatusSummar state: stringOrUndefined(parsed.state) ?? "Idle", detail: stringOrUndefined(parsed.detail) ?? "No workflow status has been published yet.", runUrl: stringOrUndefined(parsed.run_url), + applyHealth: recordOrUndefined(parsed.apply_health), plannedCount: numberOrUndefined(parsed.planned_count), plannedCapacity: numberOrUndefined(parsed.planned_capacity), plannedShards: numberOrUndefined(parsed.planned_shards), @@ -8316,6 +8324,12 @@ function numberOrUndefined(value: unknown): number | undefined { return Number.isFinite(number) ? number : undefined; } +function recordOrUndefined(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + function currentWorkflowStatusBlock(readme: string, profile = targetProfile()): string { const statusSummary = readSweepStatusSummary(profile); if (statusSummary) return workflowStatusBlock({ ...statusSummary, profile }); @@ -8373,6 +8387,7 @@ function workflowStatusSummary(block: string): WorkflowStatusSummary { state, detail, runUrl, + applyHealth: undefined, plannedCount: numberOrUndefined(planMatch?.[1]), plannedShards: numberOrUndefined(planMatch?.[2]), plannedCapacity: numberOrUndefined(planMatch?.[3]), @@ -20386,6 +20401,13 @@ function statusCommand(args: Args): void { args.bot_owned_proof_decisions_requested, ); const botOwnedProofDispatches = optionalNumberArg(args.bot_owned_proof_dispatches); + const applyHealthArg = applyHealthStatusArg(args); + const applyHealth = + applyHealthArg === undefined + ? state.startsWith("Apply ") + ? null + : readSweepStatusSummary(profile)?.applyHealth + : applyHealthArg; const statusOptions: Parameters[0] = { state, detail, @@ -20410,10 +20432,26 @@ function statusCommand(args: Args): void { statusOptions.botOwnedProofDecisionsRequested = botOwnedProofDecisionsRequested; if (botOwnedProofDispatches !== undefined) statusOptions.botOwnedProofDispatches = botOwnedProofDispatches; + if (applyHealth !== undefined) statusOptions.applyHealth = applyHealth; writeSweepStatus(statusOptions); console.log(JSON.stringify({ status_path: sweepStatusRelativePath(profile), state, detail })); } +function applyHealthStatusArg(args: Args): Record | undefined { + const filePath = stringArg(args.apply_health_file, ""); + const jsonText = stringArg(args.apply_health_json, ""); + if (filePath && jsonText) { + throw new Error("--apply-health-file and --apply-health-json are mutually exclusive"); + } + const text = filePath ? readFileSync(resolve(filePath), "utf8") : jsonText; + if (!text.trim()) return undefined; + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("apply health status must be a JSON object"); + } + return parsed as Record; +} + function assistCommand(args: Args): void { repoFromArgs(args); const itemNumber = numberArg(args.item_number, 0); diff --git a/src/repair/workflow-utils.ts b/src/repair/workflow-utils.ts index 4ba5f207a4..e9c01b6e14 100644 --- a/src/repair/workflow-utils.ts +++ b/src/repair/workflow-utils.ts @@ -10,6 +10,41 @@ import { AUTOMATION_LIMITS, WORKER_CONFIG, workerLimit, type WorkerLane } from " type ApplyAction = { action: string; number?: number; + reason?: string; +}; + +type ApplyReportSummaryOptions = { + reportPath: string; + targetRepo: string; + mode: string; + processedLimit: number; + closeLimit: number | null; + cursorPath: string; + cursorRequired: boolean; +}; + +type ApplyReportSummary = { + schema_version: 1; + generated_at: string; + target_repo: string; + mode: string; + status: "ok" | "idle" | "needs_attention"; + summary: string; + processed: number; + processed_limit: number | null; + close_limit: number | null; + closed: number; + comment_synced: number; + skipped: number; + skip_reasons: Record; + attention_reasons: string[]; + cursor_required: boolean; + cursor: { + path: string; + next_after_number: number; + next_after_apply_checked_at: string | null; + updated_at: string | null; + } | null; }; const args = parseArgs(process.argv.slice(2)); @@ -37,6 +72,23 @@ function runCli(): void { case "count-actions": console.log(countActions(requiredString("report"), requiredString("action"))); break; + case "summarize-apply-report": + process.stdout.write( + `${JSON.stringify( + summarizeApplyReport({ + reportPath: requiredString("report"), + targetRepo: requiredString("target-repo"), + mode: optionalString("mode") || "close", + processedLimit: numberArg("processed-limit", 0), + closeLimit: optionalString("close-limit") ? numberArg("close-limit", 0) : null, + cursorPath: optionalString("cursor-path"), + cursorRequired: booleanArg("cursor-required", false), + }), + null, + 2, + )}\n`, + ); + break; case "count-command-actions": console.log( countCommandActions( @@ -227,6 +279,110 @@ export function countActions(reportPath: string, action: string): number { return readApplyActions(reportPath).filter((entry) => entry.action === action).length; } +export function summarizeApplyReport(options: ApplyReportSummaryOptions): ApplyReportSummary { + const actions = readApplyActions(options.reportPath); + const skipReasons: Record = {}; + let closed = 0; + let commentSynced = 0; + let skipped = 0; + for (const entry of actions) { + if (entry.action === "closed") closed += 1; + if (reportsReviewCommentSync(entry)) commentSynced += 1; + const productive = + entry.action === "closed" || + entry.action === "review_comment_synced" || + (entry.action === "kept_open" && isSuccessfulLabelSyncReason(entry.reason)); + if (!productive) { + skipped += 1; + skipReasons[entry.action] = (skipReasons[entry.action] || 0) + 1; + } + } + + const cursor = readApplyCursorForSummary(options.cursorPath); + const processedLimit = options.processedLimit > 0 ? options.processedLimit : null; + const attentionReasons: string[] = []; + if ( + options.cursorRequired && + processedLimit !== null && + actions.length >= processedLimit && + !cursor + ) { + attentionReasons.push("cursor_required_but_missing_after_full_window"); + } + for (const reason of ["skipped_runtime_budget", "skipped_live_fetch_failed"]) { + if ((skipReasons[reason] || 0) > 0) attentionReasons.push(reason); + } + if (actions.length > 0 && skipped === actions.length) { + const benignSkipReasons = new Set([ + "skipped_already_closed", + "skipped_closed", + "skipped_not_open", + ]); + for (const reason of Object.keys(skipReasons).sort()) { + if (!benignSkipReasons.has(reason) && !attentionReasons.includes(reason)) { + attentionReasons.push(reason); + } + } + } + + const status = + actions.length === 0 ? "idle" : attentionReasons.length > 0 ? "needs_attention" : "ok"; + const summary = applyReportHealthSummary({ + status, + processed: actions.length, + processedLimit, + closed, + commentSynced, + skipped, + cursor, + attentionReasons, + }); + + return { + schema_version: 1, + generated_at: new Date().toISOString(), + target_repo: options.targetRepo, + mode: options.mode, + status, + summary, + processed: actions.length, + processed_limit: processedLimit, + close_limit: options.closeLimit, + closed, + comment_synced: commentSynced, + skipped, + skip_reasons: Object.fromEntries( + Object.entries(skipReasons).sort(([left], [right]) => left.localeCompare(right)), + ), + attention_reasons: attentionReasons, + cursor_required: options.cursorRequired, + cursor, + }; +} + +function applyReportHealthSummary(options: { + status: ApplyReportSummary["status"]; + processed: number; + processedLimit: number | null; + closed: number; + commentSynced: number; + skipped: number; + cursor: ApplyReportSummary["cursor"]; + attentionReasons: string[]; +}): string { + if (options.status === "idle") return "Apply processed no records in this run."; + const budget = + options.processedLimit === null + ? `${options.processed} processed` + : `${options.processed}/${options.processedLimit} processed`; + const cursorText = options.cursor + ? `cursor at #${options.cursor.next_after_number}` + : "no cursor recorded"; + const base = `${budget}; ${options.closed} closed, ${options.commentSynced} comments synced, ${options.skipped} skipped; ${cursorText}.`; + if (options.attentionReasons.length === 0) return base; + return `${base} Attention: ${options.attentionReasons.join(", ")}.`; +} + export function countCommandActions(reportPath: string, action: string, status = ""): number { const report = readJsonObject(reportPath); const commands: JsonValue[] = Array.isArray(report.commands) ? report.commands : []; @@ -574,6 +730,7 @@ export function writeCommentSyncCursor( type ApplyCursor = { applyCheckedAt: string; number: number; + updatedAt: string | null; }; function readApplyCursor(cursorPath: string): ApplyCursor | null { @@ -586,7 +743,20 @@ function readApplyCursor(cursorPath: string): ApplyCursor | null { typeof parsed.next_after_apply_checked_at === "string" ? parsed.next_after_apply_checked_at : ""; - return { number, applyCheckedAt }; + const updatedAt = typeof parsed.updated_at === "string" ? parsed.updated_at : null; + return { number, applyCheckedAt, updatedAt }; +} + +function readApplyCursorForSummary(cursorPath: string): ApplyReportSummary["cursor"] { + if (!cursorPath) return null; + const cursor = readApplyCursor(cursorPath); + if (!cursor) return null; + return { + path: cursorPath, + next_after_number: cursor.number, + next_after_apply_checked_at: cursor.applyCheckedAt || null, + updated_at: cursor.updatedAt, + }; } export function writeApplyCursor( @@ -707,12 +877,27 @@ function readApplyActions(reportPath: string): ApplyAction[] { if (!Array.isArray(parsed)) throw new Error(`${reportPath} must contain an array`); return parsed.map((entry) => { if (!isJsonObject(entry) || typeof entry.action !== "string") return { action: "" }; + const action: ApplyAction = { action: entry.action }; + if (typeof entry.reason === "string") action.reason = entry.reason; const number = Number(entry.number); - if (!Number.isInteger(number) || number <= 0) return { action: entry.action }; - return { action: entry.action, number }; + if (Number.isInteger(number) && number > 0) action.number = number; + return action; }); } +function isSuccessfulLabelSyncReason(reason: string | undefined): boolean { + return /^(?:synced|dry-run: would sync) (?:advisory issue|ClawSweeper) labels$/.test( + reason || "", + ); +} + +function reportsReviewCommentSync(entry: ApplyAction): boolean { + return ( + entry.action === "review_comment_synced" || + (entry.reason || "").split("; ").includes("updated durable Codex review comment") + ); +} + function resultFiles(reportDir: string): string[] { if (!fs.existsSync(reportDir)) return []; return fs @@ -762,6 +947,14 @@ function numberArg(name: string, fallback: number): number { return parsed; } +function booleanArg(name: string, fallback: boolean): boolean { + const value = optionalString(name).toLowerCase(); + if (!value) return fallback; + if (["1", "true", "yes", "on"].includes(value)) return true; + if (["0", "false", "no", "off"].includes(value)) return false; + throw new Error(`--${name} must be boolean`); +} + function positiveNumber(value: string, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; diff --git a/test/clawsweeper.test.ts b/test/clawsweeper.test.ts index 18bc50ce77..c23b411bdf 100644 --- a/test/clawsweeper.test.ts +++ b/test/clawsweeper.test.ts @@ -1988,6 +1988,16 @@ test("sweep dashboard status writes are scoped to the target repository", () => } }); +test("sweep status writer preserves non-apply health and clears stale apply updates", () => { + const source = readText("src/clawsweeper.ts"); + + assert.match( + source, + /state\.startsWith\("Apply "\)\s+\?\s+null\s+:\s+readSweepStatusSummary\(profile\)\?\.applyHealth/, + ); + assert.match(source, /apply_health: applyHealth \?\? null/); +}); + test("review parser strips environment access caveats from risks", () => { const parsed = parseDecision( closeDecision({ diff --git a/test/dashboard-worker.test.ts b/test/dashboard-worker.test.ts index b81957dc7c..5173cf0680 100644 --- a/test/dashboard-worker.test.ts +++ b/test/dashboard-worker.test.ts @@ -746,6 +746,15 @@ test("dashboard HTML preserves UTF-8 emoji labels", async () => { assert.match(html, /href="https:\/\/fleet\.example\.test\/terminal\?view=live&mode=all"/); assert.match(html, /🌊 Loading pipeline state/); assert.match(html, /System Overview/); + assert.match(html, /id="apply-health"/); + assert.match(html, /function renderApplyHealth/); + assert.match(html, /Pruning sweep/); + assert.match(html, /Copy command/); + assert.match(html, /applyHealthRecommendedAction/); + assert.match(html, /Rotation cursor missing/); + assert.match(html, /Inspect the cursor-write and state-publish steps/); + assert.match(html, /const skipCount = skipReasons\[reason\]/); + assert.doesNotMatch(html, /Apply needs attention/); assert.match(html, /Automatic Builds/); assert.match(html, /id="automatic-work"/); assert.match(html, /Lifecycle Timeline/); @@ -1484,6 +1493,89 @@ test("dashboard exposes scheduled cluster intake markers and runs", async () => } }); +test("dashboard exposes apply health from sweep status without broad scans", async () => { + const originalFetch = globalThis.fetch; + const originalCaches = globalThis.caches; + Object.defineProperty(globalThis, "caches", { + configurable: true, + value: { + default: { + match: async () => undefined, + put: async () => undefined, + }, + }, + }); + const sweepStatus = { + target_repo: "openclaw/openclaw", + state: "Apply finished", + run_url: "https://github.com/openclaw/clawsweeper/actions/runs/99", + updated_at: "2026-07-03T10:15:00Z", + apply_health: { + mode: "close", + status: "needs_attention", + summary: "2/2 processed; 0 closed, 0 comments synced, 2 skipped; no cursor recorded.", + processed: 2, + processed_limit: 2, + close_limit: 5, + closed: 0, + comment_synced: 0, + skipped: 2, + cursor_required: true, + skip_reasons: { + skipped_changed_since_review: 2, + }, + attention_reasons: ["cursor_required_but_missing_after_full_window"], + cursor: null, + }, + }; + globalThis.fetch = async (input) => { + const url = new URL(String(input)); + if (url.pathname === "/repos/openclaw/clawsweeper/actions/runs") { + return jsonResponse({ workflow_runs: [] }); + } + if ( + url.pathname === + "/repos/openclaw/clawsweeper/actions/workflows/repair-cluster-intake.yml/runs" + ) { + return jsonResponse({ workflow_runs: [] }); + } + if ( + url.pathname === + "/repos/openclaw/clawsweeper-state/contents/results/sweep-status/openclaw-openclaw.json" + ) { + assert.equal(url.searchParams.get("ref"), "state"); + return jsonResponse({ + content: Buffer.from(JSON.stringify(sweepStatus)).toString("base64"), + }); + } + if (url.pathname === "/search/issues") return jsonResponse({ items: [] }); + if (url.pathname === "/repos/openclaw/openclaw/issues") return jsonResponse([]); + throw new Error(`unexpected fetch ${url}`); + }; + + try { + const response = await worker.fetch(new Request("https://clawsweeper.openclaw.ai/api/status"), { + STATUS_STORE: new MemoryKv(), + CLAWSWEEPER_REPO: "openclaw/clawsweeper", + TARGET_REPOS: "openclaw/openclaw", + CACHE_TTL_SECONDS: "0", + }); + assert.equal(response.status, 200); + const status = await response.json(); + assert.equal(status.recent.apply_health.attention_count, 1); + assert.equal(status.recent.apply_health.items[0].status, "needs_attention"); + assert.equal(status.recent.apply_health.items[0].processed, 2); + assert.equal(status.recent.apply_health.items[0].cursor_required, true); + assert.deepEqual(status.recent.apply_health.items[0].skip_reasons, { + skipped_changed_since_review: 2, + }); + assert.equal(status.recent.apply_health.items[0].cursor, null); + } finally { + globalThis.fetch = originalFetch; + Object.defineProperty(globalThis, "caches", { configurable: true, value: originalCaches }); + } +}); + test("dashboard reads stored CI status for active PR rows", async () => { const originalFetch = globalThis.fetch; const originalCaches = globalThis.caches; diff --git a/test/repair/workflow-utils.test.ts b/test/repair/workflow-utils.test.ts index ab2cd60867..3d07418f1e 100644 --- a/test/repair/workflow-utils.test.ts +++ b/test/repair/workflow-utils.test.ts @@ -17,6 +17,7 @@ import { plannedItemNumberCsv, proposedItemNumbers, proposedPrCloseCoverageItemNumbers, + summarizeApplyReport, writeApplyCursor, writeCommentSyncCursor, } from "../../dist/repair/workflow-utils.js"; @@ -174,6 +175,186 @@ test("workflow utilities derive artifact item numbers and action counts", () => assert.equal(countActions(path.join(root, "apply-report.json"), "closed"), 1); }); +test("workflow utilities summarize apply health with skip buckets and cursor", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-")); + const reportPath = path.join(root, "apply-report.json"); + const cursorPath = path.join(root, "results/apply-cursors/openclaw-openclaw.json"); + write( + reportPath, + JSON.stringify([ + { number: 10, action: "closed" }, + { number: 20, action: "review_comment_synced" }, + { number: 30, action: "skipped_changed_since_review" }, + { number: 40, action: "skipped_changed_since_review" }, + ]), + ); + write( + cursorPath, + JSON.stringify({ + next_after_number: 40, + next_after_apply_checked_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-03T10:00:00Z", + }), + ); + + const summary = summarizeApplyReport({ + reportPath, + targetRepo: "openclaw/openclaw", + mode: "close", + processedLimit: 300, + closeLimit: 5, + cursorPath, + cursorRequired: true, + }); + + assert.equal(summary.status, "ok"); + assert.equal(summary.processed, 4); + assert.equal(summary.closed, 1); + assert.equal(summary.comment_synced, 1); + assert.deepEqual(summary.skip_reasons, { skipped_changed_since_review: 2 }); + assert.equal(summary.cursor?.next_after_number, 40); +}); + +test("workflow utilities flag full-window close scans without the required cursor", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-")); + const reportPath = path.join(root, "apply-report.json"); + write( + reportPath, + JSON.stringify([ + { number: 10, action: "skipped_changed_since_review" }, + { number: 20, action: "skipped_changed_since_review" }, + ]), + ); + + const summary = summarizeApplyReport({ + reportPath, + targetRepo: "openclaw/openclaw", + mode: "close", + processedLimit: 2, + closeLimit: 5, + cursorPath: path.join(root, "missing-cursor.json"), + cursorRequired: true, + }); + + assert.equal(summary.status, "needs_attention"); + assert.deepEqual(summary.attention_reasons, [ + "cursor_required_but_missing_after_full_window", + "skipped_changed_since_review", + ]); + assert.match(summary.summary, /Attention:/); +}); + +test("workflow utilities require the cursor after a full window that closed an item", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-")); + const reportPath = path.join(root, "apply-report.json"); + write( + reportPath, + JSON.stringify([ + { number: 10, action: "closed" }, + { number: 20, action: "skipped_changed_since_review" }, + ]), + ); + + const summary = summarizeApplyReport({ + reportPath, + targetRepo: "openclaw/openclaw", + mode: "close", + processedLimit: 2, + closeLimit: 5, + cursorPath: path.join(root, "missing-cursor.json"), + cursorRequired: true, + }); + + assert.equal(summary.status, "needs_attention"); + assert.equal(summary.closed, 1); + assert.deepEqual(summary.attention_reasons, ["cursor_required_but_missing_after_full_window"]); +}); + +test("workflow utilities flag operator-action skips when every result is blocked", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-")); + const reportPath = path.join(root, "apply-report.json"); + write( + reportPath, + JSON.stringify([ + { number: 10, action: "skipped_changed_since_review" }, + { + number: 20, + action: "skipped_pr_close_coverage_proof", + reason: "close proof kept this open; updated durable Codex review comment", + }, + { number: 30, action: "skipped_maintainer_authored" }, + { number: 40, action: "skipped_invalid_decision" }, + { number: 50, action: "skipped_open_closing_pr" }, + { number: 60, action: "skipped_same_author_pair" }, + { number: 70, action: "skipped_protected_label" }, + { number: 80, action: "skipped_already_closed" }, + { + number: 90, + action: "kept_open", + reason: "review lacks verified local checkout access", + }, + { + number: 100, + action: "retry_pr_close_coverage_proof", + reason: "linked canonical PR changed after coverage proof", + }, + ]), + ); + + const summary = summarizeApplyReport({ + reportPath, + targetRepo: "openclaw/openclaw", + mode: "close", + processedLimit: 300, + closeLimit: 5, + cursorRequired: false, + }); + + assert.equal(summary.status, "needs_attention"); + assert.equal(summary.comment_synced, 1); + assert.deepEqual(summary.attention_reasons, [ + "kept_open", + "retry_pr_close_coverage_proof", + "skipped_changed_since_review", + "skipped_invalid_decision", + "skipped_maintainer_authored", + "skipped_open_closing_pr", + "skipped_pr_close_coverage_proof", + "skipped_protected_label", + "skipped_same_author_pair", + ]); +}); + +test("workflow utilities keep all-benign skip windows quiet", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-")); + const reportPath = path.join(root, "apply-report.json"); + write( + reportPath, + JSON.stringify([ + { number: 10, action: "skipped_already_closed" }, + { number: 20, action: "skipped_not_open" }, + { number: 30, action: "kept_open", reason: "synced ClawSweeper labels" }, + ]), + ); + + const summary = summarizeApplyReport({ + reportPath, + targetRepo: "openclaw/openclaw", + mode: "close", + processedLimit: 300, + closeLimit: 5, + cursorRequired: false, + }); + + assert.equal(summary.status, "ok"); + assert.equal(summary.skipped, 2); + assert.deepEqual(summary.skip_reasons, { + skipped_already_closed: 1, + skipped_not_open: 1, + }); + assert.deepEqual(summary.attention_reasons, []); +}); + test("workflow utilities count nested command actions by status", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-")); const report = path.join(root, "comment-router-latest.json"); diff --git a/test/sweep-workflow.test.ts b/test/sweep-workflow.test.ts index 8a20fd487c..4b95dd9914 100644 --- a/test/sweep-workflow.test.ts +++ b/test/sweep-workflow.test.ts @@ -147,6 +147,16 @@ test("apply workflow bounds checkpoints and requeues with a fresh token", () => assert.ok(applyFlagInit > applyStep.indexOf('item_numbers="${{')); assert.ok(applyFlagInit < applyStep.indexOf("auto_selected_apply_batch=true")); assert.match(applyStep, /apply_cursor_path="results\/apply-cursors\/\$\{target_slug\}\.json"/); + assert.match(applyStep, /write_apply_health\(\)/); + assert.match(applyStep, /pnpm run --silent workflow -- summarize-apply-report/); + assert.match(applyStep, /health_cursor_path="\$\{5:-\}"/); + assert.match(applyStep, /comment_sync_health_cursor_path="\$cursor_path"/); + assert.match(applyStep, /comment_sync_health_cursor_required="true"/); + assert.match(applyStep, /comment_sync_health_processed_limit="\$sync_batch_size"/); + assert.match(applyStep, /close_health_cursor_path="\$apply_cursor_path"/); + assert.match(applyStep, /--apply-health-file "\.artifacts\/apply-health-\$checkpoint\.json"/); + assert.match(applyStep, /--apply-health-file "\.artifacts\/apply-health-final\.json"/); + assert.match(applyStep, /--state "Apply idle"/); assert.match(applyStep, /--batch-size "\$close_processed_limit"/); assert.match(applyStep, /--cursor-path "\$apply_cursor_path"/); assert.match(applyStep, /write-apply-cursor/);