Skip to content

Commit 01f1f86

Browse files
lukstaficlaude
andcommitted
feat(staging-ff): classify workflow-scope push rejection distinctly (task-35e74651)
GitHub rejects the outbound staging→upstream FF push when a commit in the range edits .github/workflows/ and the push token lacks the `workflow` scope. This was misclassified as `other` → `error` → touchSentinel, hiding the real cause behind a 24h throttle for a fix that takes seconds. - classifyPushFailure: new "workflow-scope" class, matched BEFORE credentials (a 403 can partially match credentials; the more specific/actionable class must win). Tolerates ASCII-quote/backtick `workflow` and the OAuth-App `create or update workflow` anchor; the bare word `workflow` is not matched. - syncUpstreamMainFromStaging step (E): new branch → outcome skipped-no-workflow-scope + staging_outbound_workflow_scope_missing event whose message names the cause + remedy (gh auth refresh -s workflow), and does NOT touch the sentinel (next tick retries, stale signal stays armed). - runStagingOutboundPushTick: log the new outcome to stderr; persist the structured `project` on outbound events (and, symmetrically, inbound) so the annotation lookup matches by field, not message-prefix parsing. - briefing-lag: stale outbound-sentinel note now appends cause + remedy read from the latest workflow-scope/credentials outbound event (new shared src/staging-event-meta.ts map + reader). Covers the symmetric credentials gap too (proposal Q2). AC4 health-check finding annotation is scoped to the briefing-lag surface; the health-check finding is inline skill bash with no src precompute to carry the data, so skills/ludics-health-check.md stays unchanged (proposal Scope lines 209-223). Noted as a documented gap, not closed this round. Tests: classifier quote/phrasing variants + 403-ordering + non-ff control; e2e positive (skipped-no-workflow-scope, event, sentinel NOT touched) + negative control (non-ff → error, sentinel touched); reader unit (ordering/project-filter/legacy-prefix/control); briefing-lag annotation (workflow/credentials/control); mag e2e project-field persistence + stderr. scope-expansion: new src/staging-event-meta.ts module (shared cause/remedy map + reader) — proposal floated events.ts; neutral module avoids the staging-ff↔briefing-lag import cycle and preserves staging-ff's events.ts decoupling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1bcbb80 commit 01f1f86

8 files changed

Lines changed: 541 additions & 8 deletions

src/briefing-lag.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,4 +269,82 @@ describe("briefing-lag", () => {
269269
);
270270
expect(sentinelDirNoFile).not.toContain("outbound sentinel is");
271271
});
272+
273+
// task-35e74651: stale outbound-sentinel note carries a cause + remedy
274+
// annotation read from the latest outbound push-auth event for the project.
275+
function staleOutboundSetup(): { dir: string; sentinelDir: string; rg: RunGit } {
276+
const dir = tmp();
277+
const sentinelDir = mkdtempSync("/tmp/outbound-sentinel-");
278+
mkdirSync(join(dir, ".git"), { recursive: true });
279+
const sentinel = join(sentinelDir, "last-outbound-fast-forward-ocannl.epoch");
280+
writeFileSync(sentinel, "");
281+
const fiftyHoursAgo = new Date(Date.now() - 50 * 3600 * 1000);
282+
utimesSync(sentinel, fiftyHoursAgo, fiftyHoursAgo);
283+
const rg = fakeGit([
284+
{ match: ["remote"], stdout: "origin\nupstream\n" },
285+
{ match: ["symbolic-ref", "refs/remotes/origin/HEAD"], stdout: "refs/remotes/origin/master\n" },
286+
{ match: ["symbolic-ref", "refs/remotes/upstream/HEAD"], stdout: "refs/remotes/upstream/master\n" },
287+
{ match: ["rev-list"], stdout: "0\t0\n" },
288+
{ match: ["log"], stdout: "abc 2026-01-01 hi\n" },
289+
]);
290+
return { dir, sentinelDir, rg };
291+
}
292+
293+
function writeEvents(lines: Record<string, unknown>[]): string {
294+
const evDir = mkdtempSync("/tmp/outbound-events-");
295+
const file = join(evDir, "events.jsonl");
296+
writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
297+
return file;
298+
}
299+
300+
test("formatUpstreamLagSection: stale outbound note includes workflow-scope cause/remedy", () => {
301+
const { dir, sentinelDir, rg } = staleOutboundSetup();
302+
const eventsFile = writeEvents([
303+
{ event_type: "staging_outbound_workflow_scope_missing", project: "ocannl", epoch: 100, message: "ocannl: x" },
304+
]);
305+
const out = formatUpstreamLagSection(
306+
[{ name: "ocannl", repo: "o/r", upstream_repo: "u/r", path: dir } as ProjectConfig],
307+
{ now: new Date(), runGit: rg, sentinelDir, eventsFile },
308+
);
309+
expect(out).toContain("outbound sentinel is");
310+
expect(out).toContain("cause: push token lacks `workflow` scope");
311+
expect(out).toContain("remedy: gh auth refresh -h github.com -s workflow");
312+
});
313+
314+
test("formatUpstreamLagSection: stale outbound note includes credentials cause/remedy", () => {
315+
const { dir, sentinelDir, rg } = staleOutboundSetup();
316+
const eventsFile = writeEvents([
317+
{ event_type: "staging_outbound_credentials_missing", project: "ocannl", epoch: 100, message: "ocannl: x" },
318+
]);
319+
const out = formatUpstreamLagSection(
320+
[{ name: "ocannl", repo: "o/r", upstream_repo: "u/r", path: dir } as ProjectConfig],
321+
{ now: new Date(), runGit: rg, sentinelDir, eventsFile },
322+
);
323+
expect(out).toContain("cause: missing/invalid push credentials");
324+
expect(out).toContain("remedy:");
325+
});
326+
327+
test("formatUpstreamLagSection: stale outbound note is unannotated when no relevant event / no eventsFile", () => {
328+
// Arm 1: eventsFile present but no matching event → base note only.
329+
const a = staleOutboundSetup();
330+
const eventsFile = writeEvents([
331+
{ event_type: "staging_outbound_fast_forwarded", project: "ocannl", epoch: 100, message: "ocannl: pushed" },
332+
]);
333+
const out1 = formatUpstreamLagSection(
334+
[{ name: "ocannl", repo: "o/r", upstream_repo: "u/r", path: a.dir } as ProjectConfig],
335+
{ now: new Date(), runGit: a.rg, sentinelDir: a.sentinelDir, eventsFile },
336+
);
337+
expect(out1).toContain("outbound sentinel is");
338+
expect(out1).not.toContain("cause:");
339+
expect(out1).not.toContain("remedy:");
340+
341+
// Arm 2: eventsFile omitted entirely → base note only (back-compat).
342+
const b = staleOutboundSetup();
343+
const out2 = formatUpstreamLagSection(
344+
[{ name: "ocannl", repo: "o/r", upstream_repo: "u/r", path: b.dir } as ProjectConfig],
345+
{ now: new Date(), runGit: b.rg, sentinelDir: b.sentinelDir },
346+
);
347+
expect(out2).toContain("outbound sentinel is");
348+
expect(out2).not.toContain("cause:");
349+
});
272350
});

src/briefing-lag.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { existsSync, statSync } from "fs";
1414
import { join } from "path";
1515
import type { ProjectConfig } from "./config.ts";
1616
import { detectDefaultBranches, expandHome, hasRemote, type RunGit } from "./git-runner.ts";
17+
import { latestOutboundCauseRemedy } from "./staging-event-meta.ts";
1718

1819
export interface FormatLagOptions {
1920
now: Date;
@@ -30,6 +31,15 @@ export interface FormatLagOptions {
3031
sentinelDir?: string;
3132
/** Outbound sentinel age threshold for the stale annotation. Default 48h. gh-ludics-540. */
3233
outboundSentinelStaleSeconds?: number;
34+
/**
35+
* Path to the JSONL events file (`journal/events.jsonl`). When set, a stale
36+
* outbound-sentinel note is annotated with the cause + remedy of the most
37+
* recent outbound push-auth event for the project (workflow-scope /
38+
* credentials). When omitted, the note is emitted without the annotation —
39+
* backward-compatible with callers/tests that don't surface event data.
40+
* task-35e74651.
41+
*/
42+
eventsFile?: string;
3343
}
3444

3545
/**
@@ -79,6 +89,7 @@ function outboundSentinelStaleNote(
7989
project: string,
8090
now: Date,
8191
stale: number,
92+
eventsFile?: string,
8293
): string | null {
8394
const sentinel = join(sentinelDir, `last-outbound-fast-forward-${project}.epoch`);
8495
if (!existsSync(sentinel)) return null;
@@ -87,7 +98,17 @@ function outboundSentinelStaleNote(
8798
const ageSec = Math.max(0, Math.floor((now.getTime() - mtime) / 1000));
8899
if (ageSec < stale) return null;
89100
const hours = Math.round(ageSec / 3600);
90-
return `(outbound sentinel is ~${hours}h old; upstream push may be overdue)`;
101+
let note = `(outbound sentinel is ~${hours}h old; upstream push may be overdue)`;
102+
// task-35e74651: when the latest outbound push-auth event names a cause +
103+
// remedy (workflow-scope / credentials), append it so the operator sees
104+
// the copy-pasteable fix next to the stale-sentinel warning.
105+
if (eventsFile) {
106+
const annotation = latestOutboundCauseRemedy(eventsFile, project);
107+
if (annotation) {
108+
note += ` — cause: ${annotation.cause}; remedy: ${annotation.remedy}`;
109+
}
110+
}
111+
return note;
91112
} catch {
92113
return null;
93114
}
@@ -156,7 +177,7 @@ export function formatUpstreamLagSection(
156177
// only creates it for opt-in projects).
157178
if (opts.sentinelDir) {
158179
const outboundStale = opts.outboundSentinelStaleSeconds ?? 48 * 3600;
159-
const outboundNote = outboundSentinelStaleNote(opts.sentinelDir, name, opts.now, outboundStale);
180+
const outboundNote = outboundSentinelStaleNote(opts.sentinelDir, name, opts.now, outboundStale, opts.eventsFile);
160181
if (outboundNote) lines.push(`- ${outboundNote}`);
161182
}
162183
lines.push("");

src/mag.test.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
1+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
22
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "fs";
33
import { tmpdir } from "os";
44
import { join } from "path";
@@ -1302,7 +1302,114 @@ function ocannlProject(opts: { enabled?: boolean | undefined; path?: string }):
13021302
return base;
13031303
}
13041304

1305+
// task-35e74651: RunGit driver that reaches step (E)'s push and returns a
1306+
// given push result. remote/status/branch-detect/fetch/local-ff/ancestry all
1307+
// succeed so the push is the only failure point.
1308+
function pushPathRunGit(push: { stdout?: string; stderr?: string; exitCode?: number }): RunGit {
1309+
return (args) => {
1310+
const key = args[0] ?? "";
1311+
if (key === "remote") return { stdout: "origin\nupstream\n", exitCode: 0 };
1312+
if (key === "status") return { stdout: "", exitCode: 0 };
1313+
if (key === "symbolic-ref") {
1314+
const ref = args[1] ?? "";
1315+
if (ref.endsWith("/origin/HEAD")) return { stdout: "refs/remotes/origin/master\n", exitCode: 0 };
1316+
if (ref.endsWith("/upstream/HEAD")) return { stdout: "refs/remotes/upstream/master\n", exitCode: 0 };
1317+
return { stdout: "", exitCode: 128 };
1318+
}
1319+
if (key === "rev-parse" && args[1] === "--abbrev-ref") return { stdout: "master\n", exitCode: 0 };
1320+
if (key === "checkout") return { stdout: "", exitCode: 0 };
1321+
if (key === "fetch") return { stdout: "", exitCode: 0 };
1322+
if (key === "merge" && args[1] === "--ff-only") return { stdout: "Already up to date.\n", exitCode: 0 };
1323+
if (key === "rev-list" && args[1] === "--count") return { stdout: "5\n", exitCode: 0 };
1324+
if (key === "merge-base" && args[1] === "--is-ancestor") return { stdout: "", exitCode: 0 };
1325+
if (key === "push") return { stdout: push.stdout ?? "", stderr: push.stderr, exitCode: push.exitCode ?? 0 };
1326+
return { stdout: "", exitCode: 0 };
1327+
};
1328+
}
1329+
1330+
const WORKFLOW_SCOPE_STDERR =
1331+
"! [remote rejected] origin/master -> master (refusing to allow an OAuth App to create or update workflow `.github/workflows/gh-pages-docs.yml` without `workflow` scope)";
1332+
13051333
describe("runStagingOutboundPushTick", () => {
1334+
test("task-35e74651: workflow-scope push rejection persists event with structured project + remedy", () => {
1335+
const harnessRoot = mkdtempSync("/tmp/mag-outbound-wfscope-harness-");
1336+
const checkoutDir = mkdtempSync("/tmp/mag-outbound-wfscope-checkout-");
1337+
const ORIGINAL_HARNESS_DIR = process.env.LUDICS_HARNESS_DIR;
1338+
process.env.LUDICS_HARNESS_DIR = harnessRoot;
1339+
try {
1340+
const cfg = {
1341+
projects: [{
1342+
name: "ocannl",
1343+
repo: "lukstafi/ocannl-staging",
1344+
upstream_repo: "ahrefs/ocannl",
1345+
outbound_sync_enabled: true,
1346+
path: checkoutDir,
1347+
}],
1348+
} as unknown as LudicsFullConfig;
1349+
const results = runStagingOutboundPushTick({
1350+
isController: () => true,
1351+
runGit: pushPathRunGit({ stderr: WORKFLOW_SCOPE_STDERR, exitCode: 128 }),
1352+
config: cfg,
1353+
now: new Date(),
1354+
// sentinelDir omitted → emitEvent writes under env-overridden harnessRoot.
1355+
});
1356+
expect(results).toHaveLength(1);
1357+
expect(results[0]!.outcome).toBe("skipped-no-workflow-scope");
1358+
1359+
const eventsFile = join(harnessRoot, "journal", "events.jsonl");
1360+
expect(existsSync(eventsFile)).toBe(true);
1361+
const lines = readFileSync(eventsFile, "utf-8").trim().split("\n").filter(Boolean);
1362+
const wf = lines
1363+
.map((l) => JSON.parse(l) as Record<string, unknown>)
1364+
.filter((e) => e.event_type === "staging_outbound_workflow_scope_missing");
1365+
expect(wf).toHaveLength(1);
1366+
// Mutation guard for the new `project: ev.project` adapter line: drop it
1367+
// and this assertion fails (the annotation lookup would then have to
1368+
// parse the message prefix).
1369+
expect(wf[0]!.project).toBe("ocannl");
1370+
expect(String(wf[0]!.message)).toContain("gh auth refresh -h github.com -s workflow");
1371+
} finally {
1372+
if (ORIGINAL_HARNESS_DIR === undefined) delete process.env.LUDICS_HARNESS_DIR;
1373+
else process.env.LUDICS_HARNESS_DIR = ORIGINAL_HARNESS_DIR;
1374+
rmSync(harnessRoot, { recursive: true, force: true });
1375+
rmSync(checkoutDir, { recursive: true, force: true });
1376+
}
1377+
});
1378+
1379+
test("task-35e74651: skipped-no-workflow-scope outcome is logged to stderr", () => {
1380+
const sentinelDir = mkdtempSync("/tmp/outbound-wfscope-stderr-");
1381+
const checkoutDir = mkdtempSync("/tmp/outbound-wfscope-stderr-checkout-");
1382+
const cfg = {
1383+
projects: [{
1384+
name: "ocannl",
1385+
repo: "lukstafi/ocannl-staging",
1386+
upstream_repo: "ahrefs/ocannl",
1387+
outbound_sync_enabled: true,
1388+
path: checkoutDir,
1389+
}],
1390+
} as unknown as LudicsFullConfig;
1391+
const spy = spyOn(console, "error").mockImplementation(() => {});
1392+
let logged: string[];
1393+
try {
1394+
const results = runStagingOutboundPushTick({
1395+
isController: () => true,
1396+
runGit: pushPathRunGit({ stderr: WORKFLOW_SCOPE_STDERR, exitCode: 128 }),
1397+
config: cfg,
1398+
sentinelDir,
1399+
now: new Date(),
1400+
});
1401+
expect(results[0]!.outcome).toBe("skipped-no-workflow-scope");
1402+
// Capture before restore (bun:test mockRestore wipes call history).
1403+
logged = spy.mock.calls.map((c) => String(c[0]));
1404+
} finally {
1405+
spy.mockRestore();
1406+
rmSync(sentinelDir, { recursive: true, force: true });
1407+
rmSync(checkoutDir, { recursive: true, force: true });
1408+
}
1409+
expect(logged.some((l) => l.includes("outbound-staging-ff ocannl: skipped-no-workflow-scope"))).toBe(true);
1410+
});
1411+
1412+
13061413
test("controller-gate: short-circuits with zero git invocations when isController() returns false", () => {
13071414
const { run, calls } = recordingRunGit();
13081415
const sentinelDir = mkdtempSync("/tmp/outbound-gate-");

src/mag.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2071,13 +2071,14 @@ function runStagingFastForwardTick(): void {
20712071
runGit: defaultRunGit,
20722072
sentinelDir,
20732073
emitEvent: (ev) => {
2074-
// Same `extra` forwarding as outbound — keeps the inbound and
2075-
// outbound adapter paths symmetric so future structured fields
2076-
// on the inbound flow (none today) don't get silently dropped.
2074+
// Same `extra` + `project` forwarding as outbound — keeps the inbound
2075+
// and outbound adapter paths symmetric so structured fields don't get
2076+
// silently dropped.
20772077
emitEvent({
20782078
event_type: ev.type,
20792079
source: "mag",
20802080
scope: "project",
2081+
project: ev.project,
20812082
message: ev.message,
20822083
...(ev.extra ?? {}),
20832084
});
@@ -2138,6 +2139,10 @@ export function runStagingOutboundPushTick(opts?: {
21382139
event_type: ev.type,
21392140
source: "mag",
21402141
scope: "project",
2142+
// task-35e74651: persist the structured project so the briefing-lag
2143+
// cause/remedy annotation can match the latest outbound auth event
2144+
// by field instead of parsing the `${project}:` message prefix.
2145+
project: ev.project,
21412146
message: ev.message,
21422147
...(ev.extra ?? {}),
21432148
});
@@ -2148,6 +2153,7 @@ export function runStagingOutboundPushTick(opts?: {
21482153
r.outcome === "pushed" ||
21492154
r.outcome === "skipped-not-fast-forward" ||
21502155
r.outcome === "skipped-no-push-credentials" ||
2156+
r.outcome === "skipped-no-workflow-scope" ||
21512157
r.outcome === "skipped-local-staging-behind" ||
21522158
r.outcome === "error"
21532159
) {
@@ -2275,6 +2281,10 @@ export async function briefingPrecomputeContext(opts?: { runGit?: RunGit }): Pro
22752281
// "outbound sentinel stale > 48h" annotation alongside the
22762282
// existing FETCH_HEAD freshness note.
22772283
sentinelDir: join(harnessDir(), "mag"),
2284+
// task-35e74651: source for the cause/remedy annotation appended to a
2285+
// stale outbound-sentinel note (latest workflow-scope / credentials
2286+
// outbound event for the project).
2287+
eventsFile: join(harnessDir(), "journal", "events.jsonl"),
22782288
});
22792289
const upstreamLagSection = upstreamLag
22802290
? `## Upstream vs Staging Lag\n\n${upstreamLag.trimEnd()}\n\n`

0 commit comments

Comments
 (0)