agent-dealer 1.2.7 → 1.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
  3. package/bundle/server/dist/adapters/agent-health.js +14 -3
  4. package/bundle/server/dist/adapters/github.js +84 -5
  5. package/bundle/server/dist/adapters/github.test.js +83 -1
  6. package/bundle/server/dist/adapters/muse-capability.js +443 -24
  7. package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
  8. package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
  9. package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
  10. package/bundle/server/dist/adapters/worktree-deps.js +96 -0
  11. package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
  12. package/bundle/server/dist/capacity/muse-probe.js +56 -9
  13. package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
  14. package/bundle/server/dist/coordinator/admission.js +14 -1
  15. package/bundle/server/dist/coordinator/admission.test.js +197 -4
  16. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
  17. package/bundle/server/dist/coordinator/auto-merge.js +244 -4
  18. package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
  19. package/bundle/server/dist/coordinator/commands.js +284 -23
  20. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  21. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
  22. package/bundle/server/dist/coordinator/developer-effect.js +224 -4
  23. package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
  24. package/bundle/server/dist/coordinator/execution-report.js +6 -0
  25. package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
  26. package/bundle/server/dist/coordinator/failure-cause.js +64 -1
  27. package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
  28. package/bundle/server/dist/coordinator/failure-reason.js +7 -0
  29. package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
  30. package/bundle/server/dist/coordinator/human-resolution.js +36 -1
  31. package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
  32. package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
  33. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
  34. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
  35. package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
  36. package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
  37. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  38. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  39. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  40. package/bundle/server/dist/coordinator/playbook-feedback.js +693 -0
  41. package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
  42. package/bundle/server/dist/coordinator/projection.js +10 -4
  43. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  44. package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
  45. package/bundle/server/dist/coordinator/prompts.js +211 -2
  46. package/bundle/server/dist/coordinator/prompts.test.js +231 -0
  47. package/bundle/server/dist/coordinator/recovery.js +57 -3
  48. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  49. package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
  50. package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
  51. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  52. package/bundle/server/dist/coordinator/reviewer-effect.js +16 -1
  53. package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
  54. package/bundle/server/dist/coordinator/routing.js +45 -6
  55. package/bundle/server/dist/coordinator/routing.test.js +76 -15
  56. package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
  57. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  58. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
  59. package/bundle/server/dist/coordinator/worker-loop.js +18 -4
  60. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  61. package/bundle/server/dist/db/index.js +18 -0
  62. package/bundle/server/dist/db/schema.sql +5 -0
  63. package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
  64. package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
  65. package/bundle/server/dist/repository/human-actions.js +14 -0
  66. package/bundle/server/dist/repository/issues.js +67 -6
  67. package/bundle/server/dist/repository/work-items.js +7 -0
  68. package/bundle/server/dist/repository/worker-sessions.js +51 -2
  69. package/bundle/server/dist/routes/human-actions.js +10 -8
  70. package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
  71. package/bundle/server/dist/routes/issues.js +19 -2
  72. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  73. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  74. package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
  75. package/bundle/server/dist/runners/muse-config-core.js +18 -1
  76. package/bundle/server/dist/runners/muse-config.test.js +37 -0
  77. package/bundle/server/dist/runners/muse-serve-session.js +5 -0
  78. package/bundle/server/dist/runners/spawn-cli.js +116 -0
  79. package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
  80. package/bundle/server/package.json +2 -2
  81. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  82. package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
  83. package/bundle/server/static-ui/index.html +2 -2
  84. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  85. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  86. package/bundle/shared/dist/execution-contract.js +307 -0
  87. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  88. package/bundle/shared/dist/execution-contract.test.js +499 -0
  89. package/bundle/shared/dist/execution-report.d.ts +9 -9
  90. package/bundle/shared/dist/execution-report.js +2 -0
  91. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  92. package/bundle/shared/dist/failure-cause.js +2 -0
  93. package/bundle/shared/dist/human-actions.d.ts +4 -4
  94. package/bundle/shared/dist/human-actions.js +17 -0
  95. package/bundle/shared/dist/index.d.ts +85 -84
  96. package/bundle/shared/dist/index.js +3 -0
  97. package/bundle/shared/dist/issues.d.ts +176 -16
  98. package/bundle/shared/dist/issues.js +22 -0
  99. package/bundle/shared/dist/issues.test.js +3 -0
  100. package/bundle/shared/dist/outbound-draft.d.ts +12 -12
  101. package/bundle/shared/dist/worker-sessions.d.ts +10 -0
  102. package/bundle/shared/dist/worker-sessions.js +8 -0
  103. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  104. package/bundle/shared/dist/workflow.d.ts +4 -4
  105. package/bundle/shared/dist/workflow.js +16 -0
  106. package/bundle/shared/dist/workflow.test.js +3 -0
  107. package/bundle/shared/package.json +1 -1
  108. package/dist/action.js +4 -1
  109. package/dist/action.test.js +42 -0
  110. package/dist/index.js +1 -1
  111. package/package.json +1 -1
  112. package/bundle/server/static-ui/assets/index-yLyxRd-7.js +0 -64
@@ -153,20 +153,29 @@ function readAmbientCodexAuthPolicy(ambientCodexHome) {
153
153
  return {};
154
154
  }
155
155
  }
156
- function deckLaunchHeaders(deckId, worktreePath) {
157
- return {
156
+ /**
157
+ * NOT-305: header carrying the session's opaque Deck correlation UUID. Observability
158
+ * metadata only — Deck uses it to attribute its normalized playbook-fetch stream back
159
+ * to this durable Dealer session; it grants no authority.
160
+ */
161
+ export const DECK_CORRELATION_HEADER = "x-agent-deck-correlation-id";
162
+ function deckLaunchHeaders(deckId, worktreePath, correlationId) {
163
+ const headers = {
158
164
  "x-agent-deck-deck-id": deckId,
159
165
  "x-agent-deck-workspace": worktreePath,
160
166
  };
167
+ if (correlationId)
168
+ headers[DECK_CORRELATION_HEADER] = correlationId;
169
+ return headers;
161
170
  }
162
171
  /** claude's `--mcp-config` file schema — deck headers only, no Authorization. */
163
- function urlHeaderMcpConfig(mcpUrl, deckId, worktreePath) {
172
+ function urlHeaderMcpConfig(mcpUrl, deckId, worktreePath, correlationId) {
164
173
  return {
165
174
  mcpServers: {
166
175
  "agent-deck": {
167
176
  type: "http",
168
177
  url: mcpUrl,
169
- headers: deckLaunchHeaders(deckId, worktreePath),
178
+ headers: deckLaunchHeaders(deckId, worktreePath, correlationId),
170
179
  },
171
180
  },
172
181
  };
@@ -202,7 +211,7 @@ function ensureCursorMcpExcluded(worktreePath) {
202
211
  async function materializeWorkerMcpConfig(opts) {
203
212
  const mcpBase = getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "");
204
213
  const mcpUrl = `${mcpBase}/mcp`;
205
- const headers = deckLaunchHeaders(opts.deckId, opts.worktreePath);
214
+ const headers = deckLaunchHeaders(opts.deckId, opts.worktreePath, opts.correlationId);
206
215
  // NOT-278: Muse Code settings come only from `prepareMuseAttempt`
207
216
  // (runners/muse-config.ts) — never emit a Claude/Codex/Cursor config for Muse. Refuse
208
217
  // before anything is written rather than fall through to the Claude config below.
@@ -257,7 +266,7 @@ async function materializeWorkerMcpConfig(opts) {
257
266
  const dir = getWorkerMcpConfigDir();
258
267
  const filePath = path.join(dir, `claude-mcp-${opts.deckId.slice(0, 8)}-${randomUUID()}.json`);
259
268
  try {
260
- fs.writeFileSync(filePath, JSON.stringify(urlHeaderMcpConfig(mcpUrl, opts.deckId, opts.worktreePath)), { mode: 0o600 });
269
+ fs.writeFileSync(filePath, JSON.stringify(urlHeaderMcpConfig(mcpUrl, opts.deckId, opts.worktreePath, opts.correlationId)), { mode: 0o600 });
261
270
  }
262
271
  catch (err) {
263
272
  fs.rmSync(filePath, { force: true });
@@ -416,6 +425,7 @@ export async function prepareWorkerDeckConnection(opts) {
416
425
  deckId: opts.deckId,
417
426
  worktreePath: opts.worktreePath,
418
427
  policy: opts.policy,
428
+ correlationId: opts.correlationId,
419
429
  });
420
430
  }
421
431
  catch (err) {
@@ -528,3 +528,77 @@ test("a deck that loses the connection mid-playbook is deck_unavailable", async
528
528
  if (!result.ok)
529
529
  assert.equal(result.kind, "deck_unavailable");
530
530
  });
531
+ // NOT-305: every new worker session persists an opaque Deck correlation UUID before
532
+ // spawn, and every runtime launch config carries it as the exact
533
+ // `x-agent-deck-correlation-id` observability header (never authority).
534
+ const CORRELATION_ID = "aaaaaaaa-1111-4111-a111-aaaaaaaaaaaa";
535
+ test("prepareWorkerDeckConnection writes the exact correlation header for claude_code", async () => {
536
+ const result = await prepareWorkerDeckConnection({
537
+ policy: DENIED,
538
+ ...BASE_OPTS,
539
+ correlationId: CORRELATION_ID,
540
+ verifyCallTool: async (name) => {
541
+ assert.equal(name, "get_bound_deck");
542
+ return textResult({ id: DECK, name: "personal-dev" });
543
+ },
544
+ });
545
+ assert.equal(result.ok, true);
546
+ if (result.ok) {
547
+ const written = JSON.parse(fs.readFileSync(result.mcpConfigPath, "utf8"));
548
+ const headers = written.mcpServers["agent-deck"].headers;
549
+ assert.equal(headers["x-agent-deck-correlation-id"], CORRELATION_ID);
550
+ await releaseWorkerDeckConnection({ mcpConfigPath: result.mcpConfigPath });
551
+ }
552
+ });
553
+ test("prepareWorkerDeckConnection omits the correlation header when the session has none", async () => {
554
+ const result = await prepareWorkerDeckConnection({
555
+ policy: DENIED,
556
+ ...BASE_OPTS,
557
+ verifyCallTool: async () => textResult({ id: DECK, name: "personal-dev" }),
558
+ });
559
+ assert.equal(result.ok, true);
560
+ if (result.ok) {
561
+ const written = JSON.parse(fs.readFileSync(result.mcpConfigPath, "utf8"));
562
+ const headers = written.mcpServers["agent-deck"].headers;
563
+ assert.equal("x-agent-deck-correlation-id" in headers, false);
564
+ await releaseWorkerDeckConnection({ mcpConfigPath: result.mcpConfigPath });
565
+ }
566
+ });
567
+ test("prepareWorkerDeckConnection writes the exact correlation header for codex_local", async () => {
568
+ const result = await prepareWorkerDeckConnection({
569
+ policy: DENIED,
570
+ ...BASE_OPTS,
571
+ runtime: "codex_local",
572
+ correlationId: CORRELATION_ID,
573
+ verifyCallTool: async () => textResult({ id: DECK }),
574
+ });
575
+ assert.equal(result.ok, true);
576
+ if (result.ok) {
577
+ const toml = fs.readFileSync(path.join(result.mcpConfigPath, "config.toml"), "utf8");
578
+ assert.match(toml, new RegExp(`x-agent-deck-correlation-id = "${CORRELATION_ID}"`));
579
+ await releaseWorkerDeckConnection({ mcpConfigPath: result.mcpConfigPath });
580
+ }
581
+ });
582
+ test("prepareWorkerDeckConnection writes the exact correlation header for cursor_local", async () => {
583
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cursor-correlation-"));
584
+ initGitRepo(repo);
585
+ try {
586
+ const result = await prepareWorkerDeckConnection({
587
+ policy: DENIED,
588
+ deckId: DECK,
589
+ worktreePath: repo,
590
+ runtime: "cursor_local",
591
+ correlationId: CORRELATION_ID,
592
+ verifyCallTool: async () => textResult({ id: DECK }),
593
+ });
594
+ assert.equal(result.ok, true);
595
+ if (result.ok) {
596
+ const written = JSON.parse(fs.readFileSync(result.mcpConfigPath, "utf8"));
597
+ assert.equal(written.mcpServers["agent-deck"].headers["x-agent-deck-correlation-id"], CORRELATION_ID);
598
+ await releaseWorkerDeckConnection({ mcpConfigPath: result.mcpConfigPath });
599
+ }
600
+ }
601
+ finally {
602
+ fs.rmSync(repo, { recursive: true, force: true });
603
+ }
604
+ });
@@ -107,6 +107,15 @@ export function setRunCommandForTests(fn) {
107
107
  function runCommand(cmd, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS, env) {
108
108
  return runCommandImpl(cmd, args, timeoutMs, env);
109
109
  }
110
+ /**
111
+ * NOT-308: drop only the cached muse runtime issues — the `muse_capability` acknowledge
112
+ * path calls this so the lifted block is seen on the next health read instead of after
113
+ * the 60s TTL. Never touches capability state itself (unlike clearAgentHealthCaches,
114
+ * which resets it for tests).
115
+ */
116
+ export function invalidateMuseHealthCache() {
117
+ runtimeIssueCache.delete("muse_code");
118
+ }
110
119
  /** Exported for tests — clears the shared github + runtime health caches and soft-fail streak. */
111
120
  export function clearAgentHealthCaches() {
112
121
  runtimeIssueCache.clear();
@@ -199,9 +208,11 @@ async function cursorRuntimeIssues() {
199
208
  * existence only, never read.
200
209
  *
201
210
  * NOT-277: with CLI and credentials present, the reported version is handed to the capability
202
- * check — a version not yet checked runs one real developer-shell probe (muse-capability.ts);
203
- * `runtime_capability` blocks while it runs, when it finds shell/write missing, or when it could
204
- * not complete.
211
+ * check — a version not yet checked runs one real developer-shell probe (muse-capability.ts).
212
+ * NOT-308: `runtime_capability` blocks only on a confirmed shell/write loss (`missing`,
213
+ * not operator-overridden); while the version is unchecked or inconclusive the check
214
+ * runs in the background and admission proceeds on the last confirmed baseline
215
+ * (fresh installs with no baseline still fail closed).
205
216
  */
206
217
  async function museRuntimeIssues() {
207
218
  const bin = resolveMuseBin();
@@ -386,6 +386,14 @@ export function createGithubAdapter(exec = defaultExec) {
386
386
  return null;
387
387
  }
388
388
  },
389
+ async hasPullRequestWorkflow({ cwd, baseRef }) {
390
+ try {
391
+ return await baseRefHasPullRequestWorkflow(cwd, baseRef);
392
+ }
393
+ catch {
394
+ return null;
395
+ }
396
+ },
389
397
  async publishReview({ cwd, number, event, bodyFilePath }) {
390
398
  const result = await runReview(exec, cwd, number, event, bodyFilePath);
391
399
  if (result.ok || event === "COMMENT" || !OWN_PR_REVIEW_PATTERN.test(result.reason)) {
@@ -491,6 +499,64 @@ export async function fetchChecksFailureEvidence(exec, opts) {
491
499
  details,
492
500
  };
493
501
  }
502
+ /**
503
+ * NOT-311: does `origin/<baseRef>` contain a workflow triggered by `pull_request`?
504
+ * False when the tree has no workflow files at all; null when the tree itself cannot
505
+ * be read (unknown base ref, unparsable git output) so the caller waits fail-closed.
506
+ */
507
+ export async function baseRefHasPullRequestWorkflow(cwd, baseRef) {
508
+ let listing;
509
+ try {
510
+ // `-r`: without it ls-tree prints the workflows *directory* entry itself rather
511
+ // than the files under it, so the scan would never see a workflow (NOT-311).
512
+ listing = (await run("git", ["ls-tree", "-r", `origin/${baseRef}`, "--name-only", ".github/workflows"], { cwd })).stdout;
513
+ }
514
+ catch {
515
+ return null;
516
+ }
517
+ const files = listing
518
+ .split("\n")
519
+ .map((s) => s.trim())
520
+ .filter((f) => f && /\.(ya?ml)$/i.test(f));
521
+ if (files.length === 0)
522
+ return false;
523
+ for (const file of files) {
524
+ let content;
525
+ try {
526
+ content = (await run("git", ["show", `origin/${baseRef}:${file}`], { cwd })).stdout;
527
+ }
528
+ catch {
529
+ return null;
530
+ }
531
+ if (workflowHasPullRequestTrigger(content))
532
+ return true;
533
+ }
534
+ return false;
535
+ }
536
+ /**
537
+ * Heuristic `on:` trigger scan: matches inline (`on: pull_request`,
538
+ * `on: [push, pull_request]`) and block forms. Stops at the next top-level key so a
539
+ * later `pull_request` mention outside `on:` (a job name, a comment) cannot qualify.
540
+ * Leans toward waiting — a false positive only costs CI-wait backoff, while a false
541
+ * negative would send an unverified PR to review.
542
+ */
543
+ export function workflowHasPullRequestTrigger(content) {
544
+ const lines = content.split("\n");
545
+ const onIdx = lines.findIndex((l) => /^\s*on\s*:/.test(l));
546
+ if (onIdx < 0)
547
+ return false;
548
+ const onLine = lines[onIdx];
549
+ if (/\bpull_request\b/.test(onLine.slice(onLine.indexOf("on") + 2)))
550
+ return true;
551
+ for (let i = onIdx + 1; i < lines.length; i++) {
552
+ const line = lines[i];
553
+ if (/^\S/.test(line))
554
+ break;
555
+ if (/\bpull_request\b/.test(line.replace(/#.*$/, "")))
556
+ return true;
557
+ }
558
+ return false;
559
+ }
494
560
  export const realGithubAdapter = createGithubAdapter();
495
561
  const REVIEW_FLAG = {
496
562
  APPROVE: "--approve",
@@ -527,25 +593,38 @@ const NONE_STREAK_REQUIRED = 2;
527
593
  * between resets the streak); if the deadline is hit before that streak completes, the
528
594
  * poll fails closed as "timeout" rather than silently waving the handoff through.
529
595
  */
530
- export async function pollPrChecks(adapter, opts) {
596
+ export async function pollPrChecks(
597
+ // NOT-310: narrowed to what the poll actually calls — the merge-conflict sync
598
+ // passes only a checks reader, and full adapters still satisfy this shape.
599
+ adapter, opts) {
600
+ return (await pollPrChecksDetailed(adapter, opts)).result;
601
+ }
602
+ /**
603
+ * NOT-311: same bounded poll as {@link pollPrChecks}, but reporting what was last
604
+ * seen and whether an abort cut the wait short. `pollPrChecks` keeps the collapsed
605
+ * contract for callers that only branch on the result.
606
+ */
607
+ export async function pollPrChecksDetailed(adapter, opts) {
531
608
  const deadline = Date.now() + opts.timeoutMs;
532
609
  let noneStreak = 0;
610
+ let lastSnapshot = null;
533
611
  for (;;) {
534
612
  if (opts.signal?.aborted)
535
- return "timeout";
613
+ return { result: "timeout", lastSnapshot, aborted: true };
536
614
  const snapshot = await adapter.checksSnapshot({ cwd: opts.cwd, number: opts.number, branch: opts.branch });
615
+ lastSnapshot = snapshot;
537
616
  if (snapshot === "failure" || snapshot === "success")
538
- return snapshot;
617
+ return { result: snapshot, lastSnapshot, aborted: false };
539
618
  if (snapshot === "none") {
540
619
  noneStreak++;
541
620
  if (noneStreak >= NONE_STREAK_REQUIRED)
542
- return "none";
621
+ return { result: "none", lastSnapshot, aborted: false };
543
622
  }
544
623
  else {
545
624
  noneStreak = 0;
546
625
  }
547
626
  if (Date.now() >= deadline)
548
- return "timeout";
627
+ return { result: "timeout", lastSnapshot, aborted: false };
549
628
  await new Promise((resolve) => setTimeout(resolve, Math.min(opts.intervalMs, deadline - Date.now())));
550
629
  }
551
630
  }
@@ -1,7 +1,7 @@
1
1
  // packages/server/src/adapters/github.test.ts
2
2
  import { test } from "node:test";
3
3
  import assert from "node:assert/strict";
4
- import { parsePrView, summarizeChecks, pollPrChecks, createGithubAdapter, fetchChecksFailureEvidence, sanitizeCiText, sanitizeUrl, extractActionsRunId, buildFailureExcerpt, formatChecksFailureDetails, CHECKS_EVIDENCE_MAX_EXCERPT_CHARS, CHECKS_EVIDENCE_MAX_EXCERPT_LINES, CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN, CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER, CHECKS_FAILURE_GENERIC_REASON, PR_VIEW_FIELDS, } from "./github.js";
4
+ import { parsePrView, summarizeChecks, pollPrChecks, pollPrChecksDetailed, baseRefHasPullRequestWorkflow, workflowHasPullRequestTrigger, createGithubAdapter, fetchChecksFailureEvidence, sanitizeCiText, sanitizeUrl, extractActionsRunId, buildFailureExcerpt, formatChecksFailureDetails, CHECKS_EVIDENCE_MAX_EXCERPT_CHARS, CHECKS_EVIDENCE_MAX_EXCERPT_LINES, CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN, CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER, CHECKS_FAILURE_GENERIC_REASON, PR_VIEW_FIELDS, } from "./github.js";
5
5
  test("parsePrView extracts the ground-truth handoff fields, including draft status", () => {
6
6
  const view = parsePrView(JSON.stringify({
7
7
  number: 7,
@@ -205,6 +205,88 @@ test("pollPrChecks bails out as timeout immediately when the lease signal is alr
205
205
  });
206
206
  assert.equal(result, "timeout");
207
207
  });
208
+ test("NOT-311: pollPrChecksDetailed reports the last snapshot so a timeout can tell pending from abort", async () => {
209
+ const pending = await pollPrChecksDetailed(fakeAdapter(["pending", "pending", "pending", "pending"]), {
210
+ cwd: "/x",
211
+ timeoutMs: 20,
212
+ intervalMs: 10,
213
+ });
214
+ assert.deepEqual(pending, { result: "timeout", lastSnapshot: "pending", aborted: false });
215
+ const controller = new AbortController();
216
+ controller.abort();
217
+ const aborted = await pollPrChecksDetailed(fakeAdapter(["pending"]), {
218
+ cwd: "/x",
219
+ timeoutMs: 1000,
220
+ intervalMs: 5,
221
+ signal: controller.signal,
222
+ });
223
+ assert.deepEqual(aborted, { result: "timeout", lastSnapshot: null, aborted: true });
224
+ const none = await pollPrChecksDetailed(fakeAdapter(["none", "none"]), { cwd: "/x", timeoutMs: 1000, intervalMs: 5 });
225
+ assert.deepEqual(none, { result: "none", lastSnapshot: "none", aborted: false });
226
+ const incompleteNoneStreak = await pollPrChecksDetailed(fakeAdapter(["none"]), {
227
+ cwd: "/x",
228
+ timeoutMs: 0,
229
+ intervalMs: 10,
230
+ });
231
+ assert.deepEqual(incompleteNoneStreak, { result: "timeout", lastSnapshot: "none", aborted: false });
232
+ });
233
+ test("NOT-311: workflowHasPullRequestTrigger matches inline, list, and block on: forms", () => {
234
+ assert.equal(workflowHasPullRequestTrigger("on: pull_request\njobs:\n x:\n runs-on: ubuntu-latest\n"), true);
235
+ assert.equal(workflowHasPullRequestTrigger("on: [push, pull_request]\n"), true);
236
+ assert.equal(workflowHasPullRequestTrigger("on:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n"), true);
237
+ assert.equal(workflowHasPullRequestTrigger("on:\n push:\n branches: [main]\n"), false);
238
+ assert.equal(workflowHasPullRequestTrigger("on: push\n"), false);
239
+ assert.equal(workflowHasPullRequestTrigger("on:\n schedule:\n - cron: '0 0 * * *'\n"), false);
240
+ assert.equal(workflowHasPullRequestTrigger("jobs:\n x:\n runs-on: ubuntu-latest\n"), false);
241
+ // A pull_request mention outside `on:` (job name) must not qualify.
242
+ assert.equal(workflowHasPullRequestTrigger("on: push\njobs:\n pull_request_preview:\n runs-on: ubuntu-latest\n"), false);
243
+ });
244
+ test("NOT-311: baseRefHasPullRequestWorkflow reads the base ref tree, false without workflows, null on unknown ref", async () => {
245
+ const { execFileSync } = await import("node:child_process");
246
+ const fs = await import("node:fs");
247
+ const os = await import("node:os");
248
+ const path = await import("node:path");
249
+ const setup = (files) => {
250
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-gh-wf-repo-"));
251
+ const git = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8" });
252
+ git("init", "-q", "-b", "main");
253
+ git("config", "user.email", "test@example.com");
254
+ git("config", "user.name", "Test");
255
+ for (const [rel, content] of Object.entries(files)) {
256
+ fs.mkdirSync(path.join(repo, path.dirname(rel)), { recursive: true });
257
+ fs.writeFileSync(path.join(repo, rel), content);
258
+ }
259
+ git("add", ".");
260
+ git("commit", "-q", "-m", "init");
261
+ const remote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-gh-wf-remote-"));
262
+ execFileSync("git", ["init", "-q", "--bare", "-b", "main", remote]);
263
+ git("remote", "add", "origin", remote);
264
+ git("push", "-q", "origin", "main");
265
+ git("fetch", "-q", "origin");
266
+ return { repo, remote };
267
+ };
268
+ const withCi = setup({
269
+ "README.md": "hi\n",
270
+ ".github/workflows/ci.yml": "name: ci\non:\n pull_request:\n branches: [main]\njobs:\n x:\n runs-on: ubuntu-latest\n",
271
+ });
272
+ const pushOnly = setup({
273
+ "README.md": "hi\n",
274
+ ".github/workflows/nightly.yml": "name: nightly\non:\n schedule:\n - cron: '0 0 * * *'\njobs:\n x:\n runs-on: ubuntu-latest\n",
275
+ });
276
+ const bare = setup({ "README.md": "hi\n" });
277
+ try {
278
+ assert.equal(await baseRefHasPullRequestWorkflow(withCi.repo, "main"), true);
279
+ assert.equal(await baseRefHasPullRequestWorkflow(pushOnly.repo, "main"), false);
280
+ assert.equal(await baseRefHasPullRequestWorkflow(bare.repo, "main"), false);
281
+ assert.equal(await baseRefHasPullRequestWorkflow(bare.repo, "no-such-branch"), null);
282
+ }
283
+ finally {
284
+ for (const { repo, remote } of [withCi, pushOnly, bare]) {
285
+ fs.rmSync(repo, { recursive: true, force: true });
286
+ fs.rmSync(remote, { recursive: true, force: true });
287
+ }
288
+ }
289
+ });
208
290
  // --- NOT-252: terminal checks_failed enrichment ---
209
291
  const HEAD_SHA = "abc123def456abc123def456abc123def456abcd";
210
292
  function prViewWithRollup(rollup, headSha = HEAD_SHA) {