@yemi33/minions 0.1.2087 → 0.1.2088

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.
package/docs/security.md CHANGED
@@ -71,6 +71,29 @@ system. Its threat model:
71
71
  - Baseline **security headers** (CSP, `X-Content-Type-Options`,
72
72
  `Referrer-Policy`, clickjacking protections) applied to every response
73
73
  via `shared.buildSecurityHeaders()`.
74
+ - **Narrowed `Access-Control-Allow-Origin` on GET/HEAD reads**
75
+ (P-bfa2c-cors-wildcard). Before this change the dashboard echoed
76
+ `Access-Control-Allow-Origin: *` on every read response, which let
77
+ any cross-origin browser page (e.g. `https://attacker.com`) issue
78
+ `fetch('http://localhost:7331/api/*')` and **read** the JSON body —
79
+ exposing operator-private state (config, PR data, work items, agent
80
+ transcripts). The GET/HEAD prelude now echoes ACAO **only** when the
81
+ request's `Origin` header matches the dashboard's own served origin
82
+ (`http://localhost:7331`) or an entry in
83
+ `config.engine.allowedDashboardOrigins` (default `[]`). Requests
84
+ without an `Origin` header (curl, uptime monitors, Node
85
+ `http.request`) receive **no** ACAO header — that path was already
86
+ cross-origin-unreadable and is preserved verbatim. To opt a
87
+ reverse-proxy origin in, set in `config.json`:
88
+
89
+ ```json
90
+ { "engine": { "allowedDashboardOrigins": ["https://minions.example.com"] } }
91
+ ```
92
+
93
+ Entries are matched verbatim against the request `Origin` header
94
+ (scheme + host + port; no path, no wildcards). See
95
+ `shared.isAllowedDashboardOrigin()` in
96
+ [`engine/shared.js`](../engine/shared.js).
74
97
 
75
98
  ### Residual risks tracked elsewhere
76
99
 
package/engine/ado.js CHANGED
@@ -6,7 +6,7 @@
6
6
  const path = require('path');
7
7
  const childProcess = require('child_process');
8
8
  const shared = require('./shared');
9
- const { exec, execAsync, getAdoOrgBase, log, ts, dateStamp, PR_STATUS, createThrottleTracker } = shared;
9
+ const { exec, execAsync, getAdoOrgBase, log, ts, dateStamp, PR_STATUS, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, createThrottleTracker } = shared;
10
10
  const { getPrs } = require('./queries');
11
11
  const { mutateJsonFileLocked } = shared;
12
12
  const { acquireAdoToken } = require('./ado-token');
@@ -396,16 +396,16 @@ function applyAdoPrMetadata(pr, prData) {
396
396
 
397
397
  /** Classify an array of ADO build records into a single status string. */
398
398
  function classifyBuildStatus(prBuilds) {
399
- if (!prBuilds.length) return 'none';
399
+ if (!prBuilds.length) return BUILD_STATUS.NONE;
400
400
  // partiallySucceeded = warnings, not failures — counts as passing
401
401
  const hasFailed = prBuilds.some(b => b.result === 'failed' || b.result === 'canceled');
402
402
  const allDone = prBuilds.every(b => b.status === 'completed');
403
403
  const allPassed = prBuilds.every(b => b.result === 'succeeded' || b.result === 'partiallySucceeded');
404
404
  const hasRunning = prBuilds.some(b => b.status === 'inProgress' || b.status === 'notStarted');
405
- if (hasFailed && allDone) return 'failing';
406
- if (allDone && allPassed) return 'passing';
407
- if (hasRunning) return 'running';
408
- return 'none';
405
+ if (hasFailed && allDone) return BUILD_STATUS.FAILING;
406
+ if (allDone && allPassed) return BUILD_STATUS.PASSING;
407
+ if (hasRunning) return BUILD_STATUS.RUNNING;
408
+ return BUILD_STATUS.NONE;
409
409
  }
410
410
 
411
411
  /**
@@ -424,10 +424,10 @@ function classifyBuildStatus(prBuilds) {
424
424
  */
425
425
  function classifyStaleBuilds(allBuilds, cachedBuildStatus) {
426
426
  const staleStatus = classifyBuildStatus(allBuilds);
427
- if (staleStatus === 'failing') {
428
- return { buildStatus: 'failing', buildStaleMergeCommit: true };
427
+ if (staleStatus === BUILD_STATUS.FAILING) {
428
+ return { buildStatus: BUILD_STATUS.FAILING, buildStaleMergeCommit: true };
429
429
  }
430
- return { buildStatus: cachedBuildStatus || 'none', buildStaleMergeCommit: false };
430
+ return { buildStatus: cachedBuildStatus || BUILD_STATUS.NONE, buildStaleMergeCommit: false };
431
431
  }
432
432
 
433
433
  /**
@@ -474,10 +474,10 @@ async function queueFreshAdoBuild({ orgBase, project, prNumber, definitionId, to
474
474
 
475
475
  /** Map ADO reviewer vote array to a review status string. */
476
476
  function votesToReviewStatus(votes) {
477
- if (votes.some(v => v === -10)) return 'changes-requested';
478
- if (votes.some(v => v >= 5)) return 'approved';
479
- if (votes.some(v => v === -5)) return 'waiting';
480
- return 'pending';
477
+ if (votes.some(v => v === -10)) return REVIEW_STATUS.CHANGES_REQUESTED;
478
+ if (votes.some(v => v >= 5)) return REVIEW_STATUS.APPROVED;
479
+ if (votes.some(v => v === -5)) return REVIEW_STATUS.WAITING;
480
+ return REVIEW_STATUS.PENDING;
481
481
  }
482
482
 
483
483
  // ─── Reviewer Vote Snapshots (W-mpg58wv3) ────────────────────────────────────
@@ -735,7 +735,7 @@ async function adoFetch(url, token, opts = {}) {
735
735
  const method = (typeof opts === 'object' && opts.method) || 'GET';
736
736
  const body = (typeof opts === 'object' && opts.body) || undefined;
737
737
  const timeout = (typeof opts === 'object' && Number.isFinite(opts.timeout)) ? opts.timeout : 30000;
738
- const MAX_RETRIES = 1;
738
+ const MAX_RETRIES = ADO_TOKEN_REFRESH_MAX_RETRIES;
739
739
  const res = await fetch(url, {
740
740
  method,
741
741
  headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
@@ -936,8 +936,8 @@ async function forEachActivePr(config, token, callback) {
936
936
  if (idx >= 0) {
937
937
  // Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
938
938
  // The disk version may have been set to 'approved' by another writer after we read
939
- if (currentPrs[idx].reviewStatus === 'approved' && after.reviewStatus !== 'approved') {
940
- after.reviewStatus = 'approved';
939
+ if (currentPrs[idx].reviewStatus === REVIEW_STATUS.APPROVED && after.reviewStatus !== REVIEW_STATUS.APPROVED) {
940
+ after.reviewStatus = REVIEW_STATUS.APPROVED;
941
941
  }
942
942
  shared.applyPrFieldDelta(currentPrs[idx], before, after);
943
943
  }
@@ -1016,12 +1016,12 @@ async function pollPrStatus(config) {
1016
1016
  }
1017
1017
 
1018
1018
  if (newStatus === PR_STATUS.MERGED || newStatus === PR_STATUS.ABANDONED) {
1019
- if (pr.reviewStatus === 'waiting') {
1020
- pr.reviewStatus = newStatus === PR_STATUS.MERGED ? 'approved' : 'pending';
1019
+ if (pr.reviewStatus === REVIEW_STATUS.WAITING) {
1020
+ pr.reviewStatus = newStatus === PR_STATUS.MERGED ? REVIEW_STATUS.APPROVED : REVIEW_STATUS.PENDING;
1021
1021
  log('info', `PR ${pr.id} reviewStatus: waiting → ${pr.reviewStatus} (${newStatus})`);
1022
1022
  }
1023
1023
  // Clear stale build status — checks won't be polled after close
1024
- if (pr.buildStatus && pr.buildStatus !== 'none') {
1024
+ if (pr.buildStatus && pr.buildStatus !== BUILD_STATUS.NONE) {
1025
1025
  delete pr.buildStatus;
1026
1026
  delete pr.buildFailReason;
1027
1027
  delete pr.buildErrorLog;
@@ -1063,7 +1063,7 @@ async function pollPrStatus(config) {
1063
1063
  // F6 not yet shipped (field absent).
1064
1064
  delete pr.humanFeedback.editsSeen;
1065
1065
  pr.fixDispatched = false;
1066
- if (pr.reviewStatus !== 'approved') pr.reviewStatus = 'pending';
1066
+ if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = REVIEW_STATUS.PENDING;
1067
1067
  log('info', `PR ${pr.id} reopened — reset transient state (reviewStatus=${pr.reviewStatus})`);
1068
1068
  }
1069
1069
  }
@@ -1156,10 +1156,10 @@ async function pollPrStatus(config) {
1156
1156
 
1157
1157
  const reviewers = prData.reviewers || [];
1158
1158
  const votes = reviewers.map(r => r.vote).filter(v => v !== undefined);
1159
- let newReviewStatus = pr.reviewStatus || 'pending';
1159
+ let newReviewStatus = pr.reviewStatus || REVIEW_STATUS.PENDING;
1160
1160
  // Once approved, it stays approved permanently
1161
- if (pr.reviewStatus === 'approved') {
1162
- newReviewStatus = 'approved';
1161
+ if (pr.reviewStatus === REVIEW_STATUS.APPROVED) {
1162
+ newReviewStatus = REVIEW_STATUS.APPROVED;
1163
1163
  // Re-approve: ADO resets votes when target branch (master) advances, even though
1164
1164
  // the source branch is unchanged. Re-apply the approval vote via API.
1165
1165
  if (!votes.some(v => v >= 5) && sourceCommit && pr._adoSourceCommit === sourceCommit) {
@@ -1176,15 +1176,15 @@ async function pollPrStatus(config) {
1176
1176
  }
1177
1177
  } else if (votes.length > 0) {
1178
1178
  if (votes.some(v => v === -10)) {
1179
- if (pr.reviewStatus === 'waiting' && pr.minionsReview?.fixedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.minionsReview.fixedAt)) {
1180
- newReviewStatus = 'waiting';
1179
+ if (pr.reviewStatus === REVIEW_STATUS.WAITING && pr.minionsReview?.fixedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.minionsReview.fixedAt)) {
1180
+ newReviewStatus = REVIEW_STATUS.WAITING;
1181
1181
  } else {
1182
- newReviewStatus = 'changes-requested';
1182
+ newReviewStatus = REVIEW_STATUS.CHANGES_REQUESTED;
1183
1183
  }
1184
1184
  }
1185
- else if (votes.some(v => v >= 5)) newReviewStatus = 'approved';
1186
- else if (votes.some(v => v === -5)) newReviewStatus = 'waiting';
1187
- else newReviewStatus = 'pending';
1185
+ else if (votes.some(v => v >= 5)) newReviewStatus = REVIEW_STATUS.APPROVED;
1186
+ else if (votes.some(v => v === -5)) newReviewStatus = REVIEW_STATUS.WAITING;
1187
+ else newReviewStatus = REVIEW_STATUS.PENDING;
1188
1188
  }
1189
1189
 
1190
1190
  // Store human reviewer names who approved or requested changes
@@ -1215,7 +1215,7 @@ async function pollPrStatus(config) {
1215
1215
  // inbox warning only; never touches the vote, never auto-dispatches,
1216
1216
  // never posts a comment. See engine/ado-vote-snapshots.json for storage.
1217
1217
  try {
1218
- const identityData = await adoFetch(`${orgBase}/_apis/connectionData?api-version=7.1`, token, { timeout: 4000 }).catch(() => null);
1218
+ const identityData = await adoFetch(`${orgBase}/_apis/connectionData?api-version=7.1`, token, { timeout: FETCH_TIMEOUT_MS.ADO_API }).catch(() => null);
1219
1219
  const myId = identityData?.authenticatedUser?.id;
1220
1220
  if (myId) {
1221
1221
  const myReviewer = reviewers.find(r => String(r?.id || '').toLowerCase() === String(myId).toLowerCase());
@@ -1257,7 +1257,7 @@ async function pollPrStatus(config) {
1257
1257
  // merge commit (same ref accumulates builds across all prior pushes to the PR).
1258
1258
  const prNumber = pr.prNumber;
1259
1259
  const mergeCommitId = prData.lastMergeCommit?.commitId;
1260
- let buildStatus = pr.buildStatus || 'none';
1260
+ let buildStatus = pr.buildStatus || BUILD_STATUS.NONE;
1261
1261
  let buildFailReason = pr.buildFailReason || '';
1262
1262
  let buildFailureSignature = pr.buildFailureSignature || '';
1263
1263
  let buildStatusResolved = true;
@@ -1277,12 +1277,12 @@ async function pollPrStatus(config) {
1277
1277
  const buildsData = await adoFetch(buildsUrl, token);
1278
1278
  const allBuilds = buildsData?.value || [];
1279
1279
  const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
1280
- buildStatus = 'none';
1280
+ buildStatus = BUILD_STATUS.NONE;
1281
1281
  buildFailReason = '';
1282
1282
 
1283
1283
  if (prBuilds.length > 0) {
1284
1284
  buildStatus = classifyBuildStatus(prBuilds);
1285
- if (buildStatus === 'failing') {
1285
+ if (buildStatus === BUILD_STATUS.FAILING) {
1286
1286
  const failed = prBuilds.find(b => b.result === 'failed');
1287
1287
  buildFailReason = failed?.definition?.name || 'Build failed';
1288
1288
  buildFailureSignature = shared.safeSlugComponent([
@@ -1332,11 +1332,11 @@ async function pollPrStatus(config) {
1332
1332
  } catch (e) {
1333
1333
  buildStatusResolved = false;
1334
1334
  buildStatusStaleDetail = `ADO build query failed: ${e.message}`;
1335
- log('warn', `ADO build query for ${pr.id}: ${e.message}; preserving previous buildStatus '${pr.buildStatus || 'none'}'`);
1335
+ log('warn', `ADO build query for ${pr.id}: ${e.message}; preserving previous buildStatus '${pr.buildStatus || BUILD_STATUS.NONE}'`);
1336
1336
  }
1337
1337
  }
1338
1338
  } else {
1339
- buildStatus = 'none';
1339
+ buildStatus = BUILD_STATUS.NONE;
1340
1340
  buildFailReason = '';
1341
1341
  }
1342
1342
 
@@ -1357,7 +1357,7 @@ async function pollPrStatus(config) {
1357
1357
 
1358
1358
  if (buildStatusResolved) {
1359
1359
  if (pr.buildStatus !== buildStatus) {
1360
- log('info', `PR ${pr.id} build: ${pr.buildStatus || 'none'} → ${buildStatus}${buildFailReason ? ' (' + buildFailReason + ')' : ''}`);
1360
+ log('info', `PR ${pr.id} build: ${pr.buildStatus || BUILD_STATUS.NONE} → ${buildStatus}${buildFailReason ? ' (' + buildFailReason + ')' : ''}`);
1361
1361
  pr.buildStatus = buildStatus;
1362
1362
  if (buildFailReason) pr.buildFailReason = buildFailReason;
1363
1363
  else delete pr.buildFailReason;
@@ -1365,8 +1365,8 @@ async function pollPrStatus(config) {
1365
1365
  else delete pr.buildFailureSignature;
1366
1366
  // Build transitioned — clear grace period and auto-complete flag
1367
1367
  delete pr._buildFixPushedAt;
1368
- if (buildStatus === 'failing') delete pr._autoCompleted;
1369
- if (buildStatus !== 'failing') {
1368
+ if (buildStatus === BUILD_STATUS.FAILING) delete pr._autoCompleted;
1369
+ if (buildStatus !== BUILD_STATUS.FAILING) {
1370
1370
  delete pr._buildFailNotified;
1371
1371
  delete pr._buildStatusStale;
1372
1372
  delete pr._buildStatusDetail;
@@ -1376,7 +1376,7 @@ async function pollPrStatus(config) {
1376
1376
  // update but no new builds have been triggered yet (filter by sourceVersion
1377
1377
  // returns []), which previously wiped the last known error log and caused
1378
1378
  // fix agents to be dispatched blind.
1379
- if (buildStatus === 'passing') {
1379
+ if (buildStatus === BUILD_STATUS.PASSING) {
1380
1380
  delete pr.buildErrorLog;
1381
1381
  delete pr.buildFailureSignature;
1382
1382
  // Reset build fix retry counter on recovery — allows fresh auto-fix cycles if build breaks again
@@ -1385,7 +1385,7 @@ async function pollPrStatus(config) {
1385
1385
  }
1386
1386
  updated = true;
1387
1387
  }
1388
- if (buildStatus === 'failing') {
1388
+ if (buildStatus === BUILD_STATUS.FAILING) {
1389
1389
  if (buildFailReason && pr.buildFailReason !== buildFailReason) {
1390
1390
  pr.buildFailReason = buildFailReason;
1391
1391
  updated = true;
@@ -1415,7 +1415,7 @@ async function pollPrStatus(config) {
1415
1415
  // one queue per PR per 30 min via pr._lastBuildRequeueAt. Only PR-
1416
1416
  // validation definitions are queued; non-PR builds are filtered out
1417
1417
  // upstream. Failures are non-fatal — logged warn and skipped.
1418
- if (buildStatusResolved && buildStatus === 'failing' && buildStaleMergeCommit && staleFailingDefinitions.length > 0) {
1418
+ if (buildStatusResolved && buildStatus === BUILD_STATUS.FAILING && buildStaleMergeCommit && staleFailingDefinitions.length > 0) {
1419
1419
  if (shouldRequeueStaleBuild(pr)) {
1420
1420
  let queuedCount = 0;
1421
1421
  for (const def of staleFailingDefinitions) {
@@ -1439,7 +1439,7 @@ async function pollPrStatus(config) {
1439
1439
  }
1440
1440
 
1441
1441
  // Auto-complete: set auto-complete on PR when builds green + review approved
1442
- if (pr.status === PR_STATUS.ACTIVE && pr.reviewStatus === 'approved' && pr.buildStatus === 'passing' && !pr._autoCompleted) {
1442
+ if (pr.status === PR_STATUS.ACTIVE && pr.reviewStatus === REVIEW_STATUS.APPROVED && pr.buildStatus === BUILD_STATUS.PASSING && !pr._autoCompleted) {
1443
1443
  const autoComplete = config.engine?.autoCompletePrs === true; // opt-in
1444
1444
  if (autoComplete) {
1445
1445
  try {
@@ -1806,7 +1806,7 @@ async function reconcilePrs(config) {
1806
1806
  title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
1807
1807
  agent: (linkedItem?.dispatched_to || adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
1808
1808
  branch,
1809
- reviewStatus: 'pending',
1809
+ reviewStatus: REVIEW_STATUS.PENDING,
1810
1810
  status: 'active',
1811
1811
  created: adoPr.creationDate || ts(),
1812
1812
  url: prUrl,
@@ -1832,7 +1832,7 @@ async function reconcilePrs(config) {
1832
1832
  title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
1833
1833
  agent: (linkedItem?.dispatched_to || adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
1834
1834
  branch,
1835
- reviewStatus: 'pending',
1835
+ reviewStatus: REVIEW_STATUS.PENDING,
1836
1836
  status: 'active',
1837
1837
  created: adoPr.creationDate || ts(),
1838
1838
  url: prUrl,
@@ -1900,7 +1900,7 @@ async function checkLiveReviewStatus(pr, project) {
1900
1900
  // SEC-02: use in-process adoFetch rather than a shell-out — keeps the bearer
1901
1901
  // token out of the process argv list where any local process could read it.
1902
1902
  // 4s timeout preserves the original request-cancellation semantics via AbortSignal.
1903
- const prData = await adoFetch(url, token, { timeout: 4000 });
1903
+ const prData = await adoFetch(url, token, { timeout: FETCH_TIMEOUT_MS.ADO_API });
1904
1904
  if (!prData) return null;
1905
1905
  const votes = (prData.reviewers || []).map(r => r.vote).filter(v => v !== undefined);
1906
1906
  if (votes.length === 0) return 'pending';
@@ -1965,10 +1965,10 @@ async function resetReviewerNegativeVote(pr, project) {
1965
1965
  const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodedRepoId}`;
1966
1966
  const prUrl = `${repoBase}/pullrequests/${prNum}?api-version=7.1`;
1967
1967
  // 4s timeout — same budget as checkLiveReviewStatus.
1968
- const prData = await adoFetch(prUrl, token, { timeout: 4000 });
1968
+ const prData = await adoFetch(prUrl, token, { timeout: FETCH_TIMEOUT_MS.ADO_API });
1969
1969
  if (!prData) return null;
1970
1970
  // Identify our authenticated reviewer entry.
1971
- const identityData = await adoFetch(`${orgBase}/_apis/connectionData?api-version=7.1`, token, { timeout: 4000 }).catch(() => null);
1971
+ const identityData = await adoFetch(`${orgBase}/_apis/connectionData?api-version=7.1`, token, { timeout: FETCH_TIMEOUT_MS.ADO_API }).catch(() => null);
1972
1972
  const myId = identityData?.authenticatedUser?.id;
1973
1973
  if (!myId) return null;
1974
1974
  const myReviewer = (prData.reviewers || []).find(r => String(r?.id || '').toLowerCase() === String(myId).toLowerCase());
@@ -1988,7 +1988,7 @@ async function resetReviewerNegativeVote(pr, project) {
1988
1988
  await adoFetch(`${repoBase}/pullrequests/${prNum}/reviewers/${myId}?api-version=7.1`, token, {
1989
1989
  method: 'PUT',
1990
1990
  body: JSON.stringify({ vote: 10 }),
1991
- timeout: 4000,
1991
+ timeout: FETCH_TIMEOUT_MS.ADO_API,
1992
1992
  });
1993
1993
  log('info', `PR ${pr.id}: reset reviewer vote ${myVote} → 10 on verdict flip`);
1994
1994
  return { attempted: true, changed: true, fromVote: myVote, toVote: 10 };
@@ -2041,7 +2041,7 @@ async function checkLiveBuildAndConflict(pr, project) {
2041
2041
  // 4s timeout — same budget as checkLiveReviewStatus. This is a pre-dispatch
2042
2042
  // gate; we'd rather miss a freshness signal and fall back to cache than
2043
2043
  // block dispatch on a slow ADO call.
2044
- const prData = await adoFetch(prUrl, token, { timeout: 4000 });
2044
+ const prData = await adoFetch(prUrl, token, { timeout: FETCH_TIMEOUT_MS.ADO_API });
2045
2045
  if (!prData) return null;
2046
2046
 
2047
2047
  // Conflict signal — ADO reports `mergeStatus: 'conflicts'` when the merge
@@ -2058,7 +2058,7 @@ async function checkLiveBuildAndConflict(pr, project) {
2058
2058
  if (prData.status === 'active') {
2059
2059
  const mergeCommitId = prData.lastMergeCommit?.commitId;
2060
2060
  if (mergeCommitId) {
2061
- const buildRepositoryGuid = await resolveAdoBuildRepositoryGuid(project, token, orgBase, 'ADO live build check', { timeout: 4000 });
2061
+ const buildRepositoryGuid = await resolveAdoBuildRepositoryGuid(project, token, orgBase, 'ADO live build check', { timeout: FETCH_TIMEOUT_MS.ADO_API });
2062
2062
  if (!buildRepositoryGuid) {
2063
2063
  buildStatusStale = true;
2064
2064
  buildStatusDetail = 'ADO Builds API requires a repository GUID; repository GUID could not be resolved from project.repositoryId/project.repoName';
@@ -2066,13 +2066,13 @@ async function checkLiveBuildAndConflict(pr, project) {
2066
2066
  try {
2067
2067
  const mergeRef = encodeURIComponent(`refs/pull/${prNum}/merge`);
2068
2068
  const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodeURIComponent(buildRepositoryGuid)}&repositoryType=TfsGit&$top=25&api-version=7.1`;
2069
- const buildsData = await adoFetch(buildsUrl, token, { timeout: 4000 });
2069
+ const buildsData = await adoFetch(buildsUrl, token, { timeout: FETCH_TIMEOUT_MS.ADO_API });
2070
2070
  const allBuilds = buildsData?.value || [];
2071
2071
  const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
2072
2072
  if (prBuilds.length > 0) {
2073
2073
  buildStatus = classifyBuildStatus(prBuilds);
2074
2074
  } else if (allBuilds.length === 0) {
2075
- buildStatus = 'none';
2075
+ buildStatus = BUILD_STATUS.NONE;
2076
2076
  } else {
2077
2077
  // Stale merge-commit (Issue #2747) — mirror pollPrStatus.
2078
2078
  // Stale-failing flips to 'failing' + buildStaleMergeCommit so
@@ -2095,7 +2095,7 @@ async function checkLiveBuildAndConflict(pr, project) {
2095
2095
  } else {
2096
2096
  // No merge commit yet — likely conflict or fresh PR. Treat as 'none'
2097
2097
  // so a stale 'failing' cache can be cleared by the caller.
2098
- buildStatus = 'none';
2098
+ buildStatus = BUILD_STATUS.NONE;
2099
2099
  }
2100
2100
  }
2101
2101
 
@@ -2182,7 +2182,7 @@ async function fetchSinglePrBuildStatus(project, prNumber) {
2182
2182
  let buildStatus = buildStatusStale ? null : classifyBuildStatus(prBuilds);
2183
2183
  let buildErrorLog = null;
2184
2184
 
2185
- if (buildStatus === 'failing') {
2185
+ if (buildStatus === BUILD_STATUS.FAILING) {
2186
2186
  try {
2187
2187
  const failedBuilds = prBuilds.filter(b => b.result === 'failed').map(b => ({
2188
2188
  state: 'failed', _buildId: String(b.id),
package/engine/cli.js CHANGED
@@ -6,7 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const shared = require('./shared');
9
- const { safeRead, safeJson, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
9
+ const { safeRead, safeJson, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT } = shared;
10
10
  const queries = require('./queries');
11
11
  const { getConfig, getControl, getDispatch, getAgentStatus,
12
12
  MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH, DISPATCH_PATH } = queries;
@@ -471,41 +471,6 @@ const commands = {
471
471
  try { shared.applyLegacyCcModelMigration(config, { logger: e.log }); }
472
472
  catch (err) { e.log('warn', `legacy ccModel migration failed: ${err.message}`); }
473
473
 
474
- // Drop persisted statusWorkItemsRetentionDays=7 (the prior baked-in default)
475
- // so the new default of 0 (no trim) reaches installs that opened Settings
476
- // before the flip. Explicit non-7 values are preserved. We mutate in-memory
477
- // AND rewrite config.json so the fix is permanent — the shim in shared.js
478
- // can then retire on schedule without users regressing.
479
- try {
480
- const applied = shared.applyStatusWorkItemsRetentionMigration(config, { logger: e.log });
481
- if (applied) {
482
- const configPath = path.join(shared.MINIONS_DIR, 'config.json');
483
- shared.mutateJsonFileLocked(configPath, (onDisk) => {
484
- if (onDisk && onDisk.engine && onDisk.engine.statusWorkItemsRetentionDays === 7) {
485
- delete onDisk.engine.statusWorkItemsRetentionDays;
486
- }
487
- return onDisk;
488
- }, { defaultValue: {}, skipWriteIfUnchanged: true });
489
- }
490
- }
491
- catch (err) { e.log('warn', `statusWorkItemsRetentionDays migration failed: ${err.message}`); }
492
-
493
- // Same treatment for statusMeetingsRetentionDays — the meetings slice had
494
- // the same 7-day baked-in default and the same data-loss UX.
495
- try {
496
- const applied = shared.applyStatusMeetingsRetentionMigration(config, { logger: e.log });
497
- if (applied) {
498
- const configPath = path.join(shared.MINIONS_DIR, 'config.json');
499
- shared.mutateJsonFileLocked(configPath, (onDisk) => {
500
- if (onDisk && onDisk.engine && onDisk.engine.statusMeetingsRetentionDays === 7) {
501
- delete onDisk.engine.statusMeetingsRetentionDays;
502
- }
503
- return onDisk;
504
- }, { defaultValue: {}, skipWriteIfUnchanged: true });
505
- }
506
- }
507
- catch (err) { e.log('warn', `statusMeetingsRetentionDays migration failed: ${err.message}`); }
508
-
509
474
  // Auto-heal projects missing workSources (cloned-repo / hand-rolled-config
510
475
  // footgun): without this block, discoverFromWorkItems / discoverFromPrs
511
476
  // bail silently and the engine looks healthy but never dispatches. The
@@ -1604,8 +1569,8 @@ const commands = {
1604
1569
  }
1605
1570
  if (exists && name === 'pullRequests') {
1606
1571
  const prs = safeJson(filePath) || [];
1607
- const pending = prs.filter(p => p.status === PR_STATUS.ACTIVE && (p.reviewStatus === 'pending' || p.reviewStatus === 'waiting'));
1608
- const needsFix = prs.filter(p => p.status === PR_STATUS.ACTIVE && p.reviewStatus === 'changes-requested');
1572
+ const pending = prs.filter(p => p.status === PR_STATUS.ACTIVE && (p.reviewStatus === REVIEW_STATUS.PENDING || p.reviewStatus === REVIEW_STATUS.WAITING));
1573
+ const needsFix = prs.filter(p => p.status === PR_STATUS.ACTIVE && p.reviewStatus === REVIEW_STATUS.CHANGES_REQUESTED);
1609
1574
  console.log(` PRs: ${pending.length} pending review, ${needsFix.length} need fixes`);
1610
1575
  }
1611
1576
  if (exists && name === 'workItems') {
@@ -25,7 +25,7 @@ function _resolveDbPath() {
25
25
  // Lazy-require shared/queries so this module can be safely required
26
26
  // before MINIONS_DIR is computed (e.g. in tests). Falls back to
27
27
  // process.env.MINIONS_HOME when available.
28
- const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
28
+ const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
29
29
  let minionsDir = envHome;
30
30
  if (!minionsDir) {
31
31
  try { minionsDir = require('../shared').MINIONS_DIR; } catch { /* shared not loaded */ }
@@ -24,7 +24,7 @@ const path = require('path');
24
24
  const fs = require('fs');
25
25
 
26
26
  function _resolveMinionsDir() {
27
- const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
27
+ const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
28
28
  if (envHome) return envHome;
29
29
  try { return require('../../shared').MINIONS_DIR; } catch { return null; }
30
30
  }
@@ -20,7 +20,7 @@ const path = require('path');
20
20
  const fs = require('fs');
21
21
 
22
22
  function _resolveMinionsDir() {
23
- const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
23
+ const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
24
24
  if (envHome) return envHome;
25
25
  try { return require('../../shared').MINIONS_DIR; } catch { return null; }
26
26
  }
@@ -21,7 +21,7 @@ const path = require('path');
21
21
  const fs = require('fs');
22
22
 
23
23
  function _resolveMinionsDir() {
24
- const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
24
+ const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
25
25
  if (envHome) return envHome;
26
26
  try { return require('../../shared').MINIONS_DIR; } catch { return null; }
27
27
  }
@@ -967,7 +967,13 @@ function cleanDispatchEntries(matchFn) {
967
967
  const filesToDelete = [];
968
968
  const dispatchDirsToRemove = [];
969
969
  try {
970
- mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
970
+ // Route through mutateDispatch so the SQL-backed store (Phase 1) and the
971
+ // dispatch.json mirror stay coherent. Writing dispatch.json directly with
972
+ // mutateJsonFileLocked here would leave stale rows in the `dispatches`
973
+ // table; the very next mutateDispatch call (e.g. removeProject's
974
+ // step 7.5 orphan-tagging pass) would then mirror SQL back to JSON and
975
+ // resurrect the entries we just drained.
976
+ mutateDispatch((dispatch) => {
971
977
  for (const queue of ['pending', 'active', 'completed']) {
972
978
  dispatch[queue] = Array.isArray(dispatch[queue]) ? dispatch[queue] : [];
973
979
  const before = dispatch[queue].length;
@@ -1000,7 +1006,7 @@ function cleanDispatchEntries(matchFn) {
1000
1006
  removed += before - dispatch[queue].length;
1001
1007
  }
1002
1008
  return dispatch;
1003
- }, { defaultValue: { pending: [], active: [], completed: [] } });
1009
+ });
1004
1010
  } catch { return 0; }
1005
1011
  // Kill processes outside the lock — taskkill on Windows can take hundreds of ms
1006
1012
  for (const pid of pidsToKill) {