Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion dashboard/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3580,6 +3580,7 @@ async function readApplyHealthMarker(env, targetRepo) {
skipped: numberOrNull(health.skipped),
skip_reasons: skipReasons,
cursor_required: health.cursor_required === true,
lanes: applyHealthLanes(health.lanes),
attention_reasons: Array.isArray(health.attention_reasons)
? health.attention_reasons
.map((reason) => String(reason))
Expand Down Expand Up @@ -3613,6 +3614,7 @@ async function readApplyHealthMarker(env, targetRepo) {
skipped: null,
skip_reasons: {},
cursor_required: false,
lanes: emptyApplyHealthLanes(),
attention_reasons: [],
cursor: null,
};
Expand All @@ -3628,6 +3630,7 @@ function emptyApplyHealthStatus(targetRepos) {
updated_at: null,
skip_reasons: {},
cursor_required: false,
lanes: emptyApplyHealthLanes(),
attention_reasons: [],
cursor: null,
})),
Expand All @@ -3642,6 +3645,32 @@ function applyHealthNeedsAttention(status) {
);
}

function applyHealthLanes(value) {
const source = objectValue(value);
return {
closure: applyHealthLane(source.closure),
comment_sync: applyHealthLane(source.comment_sync),
};
}

function emptyApplyHealthLanes() {
return {
closure: applyHealthLane(null),
comment_sync: applyHealthLane(null),
};
}

function applyHealthLane(value) {
const source = objectValue(value);
return {
processed: numberOrNull(source.processed),
closed: numberOrNull(source.closed),
comment_synced: numberOrNull(source.comment_synced),
skipped: numberOrNull(source.skipped),
skip_reasons: numericRecord(source.skip_reasons),
};
}

function latestIso(values) {
const timestamps = values
.map((value) => Date.parse(value || ""))
Expand Down Expand Up @@ -6828,12 +6857,16 @@ function renderApplyHealth(data) {
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";
const closureProcessed = Number.isFinite(item.lanes?.closure?.processed) ? fmt.format(item.lanes.closure.processed) : processed;
const syncProcessed = Number.isFinite(item.lanes?.comment_sync?.processed) ? fmt.format(item.lanes.comment_sync.processed) : processed;
const closureSynced = Number.isFinite(item.lanes?.closure?.comment_synced) ? fmt.format(item.lanes.closure.comment_synced) : "0";
const syncLaneSynced = Number.isFinite(item.lanes?.comment_sync?.comment_synced) ? fmt.format(item.lanes.comment_sync.comment_synced) : "0";
return '<div class="apply-health-alert" role="status" title="' + esc(topInfo.summary + " Next: " + topInfo.action) + '">' +
'<div class="apply-health-heading"><strong>Pruning sweep ' + esc(applyHealthStatusLabel(item.status)) + " - " + esc(item.target_repo || "target repo") + '</strong><span class="pill" title="' + esc("Latest " + applyHealthModeLabel(item.mode) + " status from the sweep-status marker.") + '">' + esc(applyHealthModeLabel(item.mode)) + '</span></div>' +
'<p>' + esc(applyHealthOperatorSummary(item, topInfo)) + '</p>' +
'<p class="apply-health-next"><strong>Next check:</strong> ' + esc(topInfo.action) + '</p>' +
applyHealthActionHtml(action) +
'<div class="apply-health-meta"><span class="pill" title="Records checked in this pruning window.">' + esc(processed) + ' processed</span><span class="pill" title="Issues or pull requests closed by this window.">' + esc(closed) + ' closed</span><span class="pill" title="Review comments refreshed by this window.">' + esc(synced) + ' comments synced</span>' + cursorPill + reasons + linkClass(item.run_url, "workflow run", "pill run-link") + '</div></div>';
'<div class="apply-health-meta"><span class="pill" title="Records checked in this pruning window.">' + esc(processed) + ' processed</span><span class="pill" title="' + esc("Closure lane: " + closureProcessed + " records processed; " + closed + " closed.") + '">' + esc(closed) + ' closed</span><span class="pill" title="' + esc("Durable review comments refreshed across lanes: " + synced + ". Closure lane refreshed " + closureSynced + "; comment-sync lane refreshed " + syncLaneSynced + " from " + syncProcessed + " records.") + '">' + esc(synced) + ' comments synced</span>' + cursorPill + reasons + linkClass(item.run_url, "workflow run", "pill run-link") + '</div></div>';
}).join("");
}
function applyHealthNeedsAttention(status) {
Expand Down
3 changes: 3 additions & 0 deletions docs/live-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ is absent or a cache event lands in another Cloudflare colo.
- 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
- lane-level apply health in status JSON so closure processing and durable
review-comment sync are reported separately even when they share the same
applicator

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
Expand Down
85 changes: 80 additions & 5 deletions src/repair/workflow-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ type ApplyReportSummary = {
comment_synced: number;
skipped: number;
skip_reasons: Record<string, number>;
lanes: {
closure: ApplyLaneSummary;
comment_sync: ApplyLaneSummary;
};
attention_reasons: string[];
cursor_required: boolean;
cursor: {
Expand All @@ -47,6 +51,14 @@ type ApplyReportSummary = {
} | null;
};

type ApplyLaneSummary = {
processed: number;
closed: number;
comment_synced: number;
skipped: number;
skip_reasons: Record<string, number>;
};

const args = parseArgs(process.argv.slice(2));

function runCli(): void {
Expand Down Expand Up @@ -281,18 +293,15 @@ export function countActions(reportPath: string, action: string): number {

export function summarizeApplyReport(options: ApplyReportSummaryOptions): ApplyReportSummary {
const actions = readApplyActions(options.reportPath);
const lanes = summarizeApplyLanes(actions, options.mode);
const skipReasons: Record<string, number> = {};
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) {
if (!isProductiveApplyAction(entry)) {
skipped += 1;
skipReasons[entry.action] = (skipReasons[entry.action] || 0) + 1;
}
Expand Down Expand Up @@ -354,12 +363,78 @@ export function summarizeApplyReport(options: ApplyReportSummaryOptions): ApplyR
skip_reasons: Object.fromEntries(
Object.entries(skipReasons).sort(([left], [right]) => left.localeCompare(right)),
),
lanes,
attention_reasons: attentionReasons,
cursor_required: options.cursorRequired,
cursor,
};
}

function summarizeApplyLanes(
actions: ApplyAction[],
mode: string,
): { closure: ApplyLaneSummary; comment_sync: ApplyLaneSummary } {
const lanes = {
closure: emptyApplyLaneSummary(),
comment_sync: emptyApplyLaneSummary(),
};
for (const entry of actions) {
const laneName = applyActionLane(entry.action, mode);
const lane = lanes[laneName];
lane.processed += 1;
if (entry.action === "closed") lane.closed += 1;
if (reportsReviewCommentSync(entry)) lane.comment_synced += 1;
if (!isProductiveApplyAction(entry)) {
lane.skipped += 1;
lane.skip_reasons[entry.action] = (lane.skip_reasons[entry.action] || 0) + 1;
}
}
return {
closure: sortApplyLaneSummary(lanes.closure),
comment_sync: sortApplyLaneSummary(lanes.comment_sync),
};
}

function emptyApplyLaneSummary(): ApplyLaneSummary {
return {
processed: 0,
closed: 0,
comment_synced: 0,
skipped: 0,
skip_reasons: {},
};
}

function sortApplyLaneSummary(lane: ApplyLaneSummary): ApplyLaneSummary {
return {
...lane,
skip_reasons: Object.fromEntries(
Object.entries(lane.skip_reasons).sort(([left], [right]) => left.localeCompare(right)),
),
};
}

function applyActionLane(action: string, mode: string): "closure" | "comment_sync" {
if (
String(mode || "").toLowerCase() === "comment_sync" ||
action === "review_comment_synced" ||
action === "skipped_comment_auth" ||
action === "skipped_locked_conversation" ||
action === "skipped_stale_review_comment_sync"
) {
return "comment_sync";
}
return "closure";
}

function isProductiveApplyAction(entry: ApplyAction): boolean {
return (
entry.action === "closed" ||
entry.action === "review_comment_synced" ||
(entry.action === "kept_open" && isSuccessfulLabelSyncReason(entry.reason))
);
}

function applyReportHealthSummary(options: {
status: ApplyReportSummary["status"];
processed: number;
Expand Down
28 changes: 28 additions & 0 deletions test/dashboard-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1524,6 +1524,24 @@ test("dashboard exposes apply health from sweep status without broad scans", asy
skip_reasons: {
skipped_changed_since_review: 2,
},
lanes: {
closure: {
processed: 2,
closed: 0,
comment_synced: 0,
skipped: 2,
skip_reasons: {
skipped_changed_since_review: 2,
},
},
comment_sync: {
processed: 0,
closed: 0,
comment_synced: 0,
skipped: 0,
skip_reasons: {},
},
},
attention_reasons: ["cursor_required_but_missing_after_full_window"],
cursor: null,
},
Expand Down Expand Up @@ -1569,6 +1587,16 @@ test("dashboard exposes apply health from sweep status without broad scans", asy
assert.deepEqual(status.recent.apply_health.items[0].skip_reasons, {
skipped_changed_since_review: 2,
});
assert.deepEqual(status.recent.apply_health.items[0].lanes.closure, {
processed: 2,
closed: 0,
comment_synced: 0,
skipped: 2,
skip_reasons: {
skipped_changed_since_review: 2,
},
});
assert.equal(status.recent.apply_health.items[0].lanes.comment_sync.processed, 0);
assert.equal(status.recent.apply_health.items[0].cursor, null);
} finally {
globalThis.fetch = originalFetch;
Expand Down
89 changes: 87 additions & 2 deletions test/repair/workflow-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,16 @@ test("workflow utilities summarize apply health with skip buckets and cursor", (
{ number: 20, action: "review_comment_synced" },
{ number: 30, action: "skipped_changed_since_review" },
{ number: 40, action: "skipped_changed_since_review" },
{
number: 50,
action: "skipped_comment_auth",
reason: "GitHub rejected durable review comment write with Requires authentication",
},
{
number: 60,
action: "skipped_locked_conversation",
reason: "conversation was locked while syncing review comment",
},
]),
);
write(
Expand All @@ -208,13 +218,78 @@ test("workflow utilities summarize apply health with skip buckets and cursor", (
});

assert.equal(summary.status, "ok");
assert.equal(summary.processed, 4);
assert.equal(summary.processed, 6);
assert.equal(summary.closed, 1);
assert.equal(summary.comment_synced, 1);
assert.deepEqual(summary.skip_reasons, { skipped_changed_since_review: 2 });
assert.deepEqual(summary.skip_reasons, {
skipped_changed_since_review: 2,
skipped_comment_auth: 1,
skipped_locked_conversation: 1,
});
assert.deepEqual(summary.lanes.closure, {
processed: 3,
closed: 1,
comment_synced: 0,
skipped: 2,
skip_reasons: { skipped_changed_since_review: 2 },
});
assert.deepEqual(summary.lanes.comment_sync, {
processed: 3,
closed: 0,
comment_synced: 1,
skipped: 2,
skip_reasons: {
skipped_comment_auth: 1,
skipped_locked_conversation: 1,
},
});
assert.equal(summary.cursor?.next_after_number, 40);
});

test("workflow utilities summarize comment-sync apply reports separately from closure", () => {
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: "review_comment_synced" },
{ number: 20, action: "skipped_stale_review_comment_sync" },
{ number: 30, action: "skipped_pr_close_coverage_proof" },
]),
);

const summary = summarizeApplyReport({
reportPath,
targetRepo: "openclaw/openclaw",
mode: "comment_sync",
processedLimit: 25,
closeLimit: null,
cursorPath: path.join(root, "missing-cursor.json"),
cursorRequired: false,
});

assert.equal(summary.mode, "comment_sync");
assert.equal(summary.closed, 0);
assert.equal(summary.comment_synced, 1);
assert.deepEqual(summary.lanes.closure, {
processed: 0,
closed: 0,
comment_synced: 0,
skipped: 0,
skip_reasons: {},
});
assert.deepEqual(summary.lanes.comment_sync, {
processed: 3,
closed: 0,
comment_synced: 1,
skipped: 2,
skip_reasons: {
skipped_pr_close_coverage_proof: 1,
skipped_stale_review_comment_sync: 1,
},
});
});

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");
Expand Down Expand Up @@ -323,6 +398,11 @@ test("workflow utilities flag operator-action skips when every result is blocked
"skipped_protected_label",
"skipped_same_author_pair",
]);
assert.equal(summary.lanes.closure.skipped, 10);
assert.equal(summary.lanes.closure.comment_synced, 1);
assert.equal(summary.lanes.closure.skip_reasons.kept_open, 1);
assert.equal(summary.lanes.closure.skip_reasons.retry_pr_close_coverage_proof, 1);
assert.equal(summary.lanes.closure.skip_reasons.skipped_pr_close_coverage_proof, 1);
});

test("workflow utilities keep all-benign skip windows quiet", () => {
Expand Down Expand Up @@ -352,6 +432,11 @@ test("workflow utilities keep all-benign skip windows quiet", () => {
skipped_already_closed: 1,
skipped_not_open: 1,
});
assert.equal(summary.lanes.closure.skipped, 2);
assert.deepEqual(summary.lanes.closure.skip_reasons, {
skipped_already_closed: 1,
skipped_not_open: 1,
});
assert.deepEqual(summary.attention_reasons, []);
});

Expand Down