@oss-autopilot/core 3.10.0 → 3.11.0

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 (38) hide show
  1. package/dist/cli-registry.d.ts +7 -0
  2. package/dist/cli-registry.js +29 -5
  3. package/dist/cli.bundle.cjs +114 -114
  4. package/dist/cli.js +11 -3
  5. package/dist/commands/comments.js +31 -15
  6. package/dist/commands/compliance-score.js +12 -4
  7. package/dist/commands/daily.js +47 -2
  8. package/dist/commands/dashboard-data.d.ts +17 -0
  9. package/dist/commands/dashboard-data.js +49 -0
  10. package/dist/commands/dashboard-server.js +93 -24
  11. package/dist/commands/dismiss.d.ts +4 -0
  12. package/dist/commands/dismiss.js +4 -4
  13. package/dist/commands/guidelines.d.ts +19 -0
  14. package/dist/commands/guidelines.js +23 -4
  15. package/dist/commands/index.d.ts +3 -1
  16. package/dist/commands/index.js +2 -0
  17. package/dist/commands/move.d.ts +2 -0
  18. package/dist/commands/move.js +12 -8
  19. package/dist/commands/repo-vet.js +30 -8
  20. package/dist/commands/shelve.d.ts +4 -0
  21. package/dist/commands/shelve.js +4 -4
  22. package/dist/core/gist-state-store.js +42 -7
  23. package/dist/core/index.d.ts +1 -1
  24. package/dist/core/index.js +1 -1
  25. package/dist/core/issue-conversation.js +15 -2
  26. package/dist/core/paths.d.ts +12 -0
  27. package/dist/core/paths.js +16 -0
  28. package/dist/core/pr-comments-fetcher.d.ts +10 -2
  29. package/dist/core/pr-comments-fetcher.js +22 -4
  30. package/dist/core/state-persistence.d.ts +31 -9
  31. package/dist/core/state-persistence.js +51 -16
  32. package/dist/core/state.d.ts +18 -1
  33. package/dist/core/state.js +35 -3
  34. package/dist/core/untrusted-content.d.ts +24 -3
  35. package/dist/core/untrusted-content.js +31 -3
  36. package/dist/formatters/json.d.ts +9 -1
  37. package/dist/formatters/json.js +12 -0
  38. package/package.json +7 -7
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { Command } from 'commander';
12
12
  import { getGitHubTokenAsync, enableDebug, debug, getCLIVersion, stateFileExists } from './core/index.js';
13
- import { commands } from './cli-registry.js';
13
+ import { commands, handleCommandError } from './cli-registry.js';
14
14
  const VERSION = getCLIVersion();
15
15
  const program = new Command();
16
16
  program
@@ -100,5 +100,13 @@ Run oss-autopilot --help for all commands.
100
100
  `);
101
101
  process.exit(0);
102
102
  }
103
- // Parse and execute
104
- program.parse();
103
+ // Parse and execute. parseAsync, not parse: the preAction hook is async, so
104
+ // with synchronous parse() a rejected hook promise (corrupt Gist, missing
105
+ // gist scope, rate limit during bootstrap — all of which ensureGistPersistence
106
+ // now propagates) became an UnhandledPromiseRejection and the user saw a raw
107
+ // stack instead of the actionable message (#1386). Command actions already
108
+ // route their errors through executeAction/handleCommandError, so this catch
109
+ // covers exactly the hook path (plus any handler that escapes the wrapper).
110
+ program.parseAsync().catch((err) => {
111
+ handleCommandError(err, process.argv.includes('--json'));
112
+ });
@@ -3,7 +3,8 @@
3
3
  * Handles GitHub comment interactions
4
4
  */
5
5
  import { getStateManager, getOctokit, parseGitHubUrl, requireGitHubToken, maybeCheckpoint } from '../core/index.js';
6
- import { ValidationError } from '../core/errors.js';
6
+ import { wrapUntrustedContent } from '../core/untrusted-content.js';
7
+ import { ValidationError, isRateLimitOrAuthError } from '../core/errors.js';
7
8
  import { warn } from '../core/logger.js';
8
9
  const MODULE = 'comments';
9
10
  import { paginateAll } from '../core/pagination.js';
@@ -77,6 +78,12 @@ export async function runComments(options) {
77
78
  const relevantReviews = reviews
78
79
  .filter((r) => filterComment(r) && r.body && r.body.trim())
79
80
  .sort((a, b) => new Date(b.submitted_at || 0).getTime() - new Date(a.submitted_at || 0).getTime());
81
+ // Fence every third-party body at this boundary: the output feeds agents
82
+ // directly (CLI --json, the MCP `comments` tool, and the `respond-to-pr`
83
+ // MCP prompt), so raw GitHub text must never reach a prompt unfenced
84
+ // (#1372). The CLI text display unwraps via safeExtractFromFence.
85
+ const fenceLabel = `${owner}/${repo}#${pull_number}`;
86
+ const fence = (body, source, author, association) => wrapUntrustedContent(body, fenceLabel, { author, association, source });
80
87
  const staleness = stateManager.getStateStaleness();
81
88
  return {
82
89
  pr: {
@@ -90,18 +97,18 @@ export async function runComments(options) {
90
97
  reviews: relevantReviews.map((r) => ({
91
98
  user: r.user?.login,
92
99
  state: r.state,
93
- body: r.body ?? null,
100
+ body: r.body == null ? null : fence(r.body, 'pr-review', r.user?.login, r.author_association),
94
101
  submittedAt: r.submitted_at ?? null,
95
102
  })),
96
103
  reviewComments: relevantReviewComments.map((c) => ({
97
104
  user: c.user?.login,
98
- body: c.body,
105
+ body: fence(c.body, 'pr-review-comment', c.user?.login, c.author_association),
99
106
  path: c.path,
100
107
  createdAt: c.created_at,
101
108
  })),
102
109
  issueComments: relevantIssueComments.map((c) => ({
103
110
  user: c.user?.login,
104
- body: c.body,
111
+ body: c.body == null ? c.body : fence(c.body, 'pr-issue-comment', c.user?.login, c.author_association),
105
112
  createdAt: c.created_at,
106
113
  })),
107
114
  summary: {
@@ -170,16 +177,15 @@ export async function runClaim(options) {
170
177
  }
171
178
  const { owner, repo, number } = parsed;
172
179
  const octokit = getOctokit(token);
173
- const { data: comment } = await octokit.issues.createComment({
174
- owner,
175
- repo,
176
- issue_number: number,
177
- body: message,
178
- });
179
180
  // Fetch the real issue title + labels so the tracked entry has useful metadata
180
181
  // rather than a permanent "(claimed)" placeholder that never gets backfilled
181
182
  // (#1056 M24). Best-effort: if the fetch fails, fall back to the placeholder
182
183
  // so state still records the claim.
184
+ //
185
+ // Ordering matters: enrichment runs BEFORE the claim comment is posted
186
+ // (#1391). Rate-limit / auth failures rethrow here, and aborting after the
187
+ // comment landed would leave an orphaned claim on GitHub that local state
188
+ // never tracked — aborting before it means a clean no-op the user can retry.
183
189
  let issueTitle = '(claimed)';
184
190
  let issueLabels = [];
185
191
  let issueCreatedAt = new Date().toISOString();
@@ -194,9 +200,18 @@ export async function runClaim(options) {
194
200
  issueCreatedAt = issue.created_at;
195
201
  }
196
202
  catch (error) {
197
- warn(MODULE, `Claimed ${options.issueUrl} but failed to enrich issue metadata (title/labels): ${error instanceof Error ? error.message : error}`);
203
+ if (isRateLimitOrAuthError(error))
204
+ throw error;
205
+ warn(MODULE, `Failed to enrich issue metadata (title/labels) for ${options.issueUrl}; claiming with placeholder: ${error instanceof Error ? error.message : error}`);
198
206
  }
207
+ const { data: comment } = await octokit.issues.createComment({
208
+ owner,
209
+ repo,
210
+ issue_number: number,
211
+ body: message,
212
+ });
199
213
  // Add to tracked issues — non-fatal if state save fails (comment already posted)
214
+ let gistSyncWarning = null;
200
215
  try {
201
216
  const stateManager = getStateManager();
202
217
  stateManager.addIssue({
@@ -211,10 +226,10 @@ export async function runClaim(options) {
211
226
  updatedAt: new Date().toISOString(),
212
227
  vetted: false,
213
228
  });
214
- // Push state to Gist if in Gist mode. Best-effort — logs on failure
215
- // rather than silently swallowing, so operators see the degraded-sync
216
- // signal (#1036 audit H1).
217
- await maybeCheckpoint(stateManager, MODULE);
229
+ // Push state to Gist if in Gist mode. Best-effort — the warning is
230
+ // threaded into the structured output so MCP/dashboard consumers see
231
+ // the degraded-sync signal, not just the stderr log (#1036 audit H1, #1370).
232
+ gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
218
233
  }
219
234
  catch (error) {
220
235
  // Structured warning instead of bare console.error so the breadcrumb shows
@@ -224,5 +239,6 @@ export async function runClaim(options) {
224
239
  return {
225
240
  commentUrl: comment.html_url,
226
241
  issueUrl: options.issueUrl,
242
+ ...(gistSyncWarning ? { gistSyncWarning } : {}),
227
243
  };
228
244
  }
@@ -11,15 +11,20 @@
11
11
  * mutation, runs against a public PR URL.
12
12
  */
13
13
  import { getOctokit, requireGitHubToken } from '../core/index.js';
14
- import { ValidationError } from '../core/errors.js';
14
+ import { ValidationError, errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
15
+ import { warn } from '../core/logger.js';
15
16
  import { validateUrl, PR_URL_PATTERN, validateGitHubUrl } from './validation.js';
16
17
  import { parseGitHubUrl } from '../core/urls.js';
17
18
  import { computeComplianceScore, } from '../core/compliance-score.js';
19
+ const MODULE = 'compliance-score';
18
20
  /**
19
21
  * Detect whether the target repo has visible test infrastructure. Looks
20
22
  * for the well-known directories at the repo root in a single contents
21
- * call. Failures (missing repo, rate limit, network) surface as
22
- * `undefined` so the score function falls back to its strict default.
23
+ * call. Non-fatal failures (missing repo, 5xx, network) surface as
24
+ * `undefined` so the score function falls back to its strict default,
25
+ * with a warning so "couldn't look" is distinguishable from "no test
26
+ * dir" in logs. Rate-limit / auth errors propagate — swallowing them
27
+ * here would mask a systemic problem affecting the whole run (#1373).
23
28
  */
24
29
  async function detectTestInfrastructure(octokit, owner, repo) {
25
30
  try {
@@ -29,7 +34,10 @@ async function detectTestInfrastructure(octokit, owner, repo) {
29
34
  const TEST_DIR = /^(?:tests?|__tests__|spec)$/i;
30
35
  return data.some((entry) => entry.type === 'dir' && TEST_DIR.test(entry.name));
31
36
  }
32
- catch {
37
+ catch (err) {
38
+ if (isRateLimitOrAuthError(err))
39
+ throw err;
40
+ warn(MODULE, `test-infrastructure probe for ${owner}/${repo} failed: ${errorMessage(err)}`);
33
41
  return undefined;
34
42
  }
35
43
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { getStateManager, PRMonitor, IssueConversationMonitor, requireGitHubToken, CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsByRepo, assessCapacity, collectActionableIssues, computeActionMenu, toShelvedPRRef, formatBriefSummary, formatSummary, } from '../core/index.js';
10
10
  import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
11
+ import { wrapUntrustedContent } from '../core/untrusted-content.js';
11
12
  import { computeStrategy, shouldComputeStrategy } from '../core/strategy.js';
12
13
  import { warn } from '../core/logger.js';
13
14
  import { emptyPRCountsResult } from '../core/github-stats.js';
@@ -457,6 +458,35 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
457
458
  // ---------------------------------------------------------------------------
458
459
  // Public API
459
460
  // ---------------------------------------------------------------------------
461
+ /**
462
+ * Fence the GitHub-sourced comment excerpts in a CommentedIssue for
463
+ * agent-facing JSON (#1372). This happens HERE — not in
464
+ * `issue-conversation.ts` — because the producer's objects also feed the
465
+ * dashboard SPA and the CLI text renderers (`daily-render.ts`), which must
466
+ * not display raw fence tags, and the producer's internal classification
467
+ * (acknowledgment / @mention matching) string-matches on raw bodies.
468
+ *
469
+ * `userLastCommentBody` is fenced too: it is the user's own comment, but
470
+ * repo maintainers can edit any comment in their repos, so it is still
471
+ * GitHub-controlled text by the time we re-fetch it.
472
+ */
473
+ function fenceCommentedIssue(issue) {
474
+ const label = `${issue.repo}#${issue.number}`;
475
+ if (issue.status === 'new_response') {
476
+ return {
477
+ ...issue,
478
+ userLastCommentBody: wrapUntrustedContent(issue.userLastCommentBody, label, { source: 'issue-comment' }),
479
+ lastResponseBody: wrapUntrustedContent(issue.lastResponseBody, label, {
480
+ author: issue.lastResponseAuthor,
481
+ source: 'issue-comment',
482
+ }),
483
+ };
484
+ }
485
+ return {
486
+ ...issue,
487
+ userLastCommentBody: wrapUntrustedContent(issue.userLastCommentBody, label, { source: 'issue-comment' }),
488
+ };
489
+ }
460
490
  /**
461
491
  * Convert a full DailyCheckResult to the compact DailyOutput for JSON serialization (#287).
462
492
  * Deduplicates PR objects: category arrays become PR URL references,
@@ -473,7 +503,7 @@ export function toDailyOutput(result) {
473
503
  briefSummary: result.briefSummary,
474
504
  actionableIssues: compactActionableIssues(result.actionableIssues),
475
505
  actionMenu: result.actionMenu,
476
- commentedIssues: result.commentedIssues,
506
+ commentedIssues: result.commentedIssues.map(fenceCommentedIssue),
477
507
  repoGroups: compactRepoGroups(result.repoGroups),
478
508
  failures: result.failures,
479
509
  warnings: result.warnings,
@@ -516,6 +546,13 @@ async function executeDailyCheckInternal(token) {
516
546
  if (staleness) {
517
547
  warnings.push(buildStalenessWarning(staleness));
518
548
  }
549
+ // Surface a state-load recovery (corrupt state.json restored from backup or
550
+ // replaced with fresh state) so it's machine-visible, not just stderr (#1371).
551
+ const loadRecovery = getStateManager().getLoadRecovery();
552
+ if (loadRecovery) {
553
+ recordWarning(warnings, 'state-load', 'State file recovery', new Error(`${loadRecovery.reason}; recovered from ${loadRecovery.source}` +
554
+ (loadRecovery.rejectedPath ? `; rejected file preserved at ${loadRecovery.rejectedPath}` : '')));
555
+ }
519
556
  // Phase 1: Fetch all PR data from GitHub
520
557
  const { prs, failures, mergedCounts, closedCounts, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed, recentlyClosedPRs, recentlyMergedPRs, commentedIssues, } = await fetchPRData(prMonitor, token, warnings);
521
558
  // Phase 2: Update repo scores (signals, star counts, trust sync)
@@ -566,10 +603,18 @@ async function executeDailyCheckInternal(token) {
566
603
  // Checkpoint: push state to Gist if in Gist mode.
567
604
  // If getStateManagerAsync was not called before this command ran,
568
605
  // isGistMode() will be false and checkpoint is correctly skipped.
606
+ // `warnings` is the same array referenced by `result`, so warnings
607
+ // recorded here still reach the structured output.
569
608
  try {
570
609
  const sm = getStateManager();
571
610
  if (sm.isGistMode()) {
572
- await sm.checkpoint();
611
+ // checkpoint() resolves false (no throw) when the push failed after
612
+ // its retry — previously discarded, reporting clean success while
613
+ // cross-machine state silently drifted (#1370).
614
+ const pushed = await sm.checkpoint();
615
+ if (!pushed) {
616
+ recordWarning(warnings, 'gist-checkpoint', 'Gist checkpoint', new Error('push failed after retry; state not synced to Gist this run'));
617
+ }
573
618
  }
574
619
  }
575
620
  catch (err) {
@@ -67,6 +67,23 @@ export interface ActionRequest {
67
67
  target?: 'attention' | 'waiting' | 'shelved' | 'auto';
68
68
  }
69
69
  export declare function buildDashboardStats(digest: DailyDigest, state: Readonly<AgentState>, storedMergedCount?: number, storedClosedCount?: number): DashboardStats;
70
+ /**
71
+ * Re-derive `digest.shelvedPRs` and `summary.totalActivePRs` from the CURRENT
72
+ * `state.config.shelvedPRUrls` so a shelve/unshelve issued from the dashboard
73
+ * SPA is reflected immediately.
74
+ *
75
+ * Why this exists: POST /api/action → runMove only mutates
76
+ * `state.config.shelvedPRUrls`; it never touches the cached digest.
77
+ * buildDashboardJson derives both `shelvedPRUrls` and `stats.shelvedPRs` from
78
+ * `digest.shelvedPRs`, so without this reconciliation a dashboard shelve/unshelve
79
+ * appears to do nothing until the next full /api/refresh rebuilds the digest.
80
+ *
81
+ * Baked shelved entries that are NOT among the current open PRs (the daily-check
82
+ * dormant partition, #981) are preserved as-is — the SPA cannot act on those —
83
+ * while explicit shelve/unshelve of open PRs and the dormant-auto-shelve rule
84
+ * are honored.
85
+ */
86
+ export declare function reconcileShelvePartition(digest: DailyDigest, state: Readonly<AgentState>): void;
70
87
  /**
71
88
  * Merge fresh API counts into existing stored counts.
72
89
  * Months present in the fresh data are updated; months only in the existing data are preserved.
@@ -35,6 +35,55 @@ export function buildDashboardStats(digest, state, storedMergedCount, storedClos
35
35
  mergeRate: `${(summary.mergeRate ?? 0).toFixed(1)}%`,
36
36
  };
37
37
  }
38
+ /**
39
+ * A PR is shelved for display when the user explicitly shelved it, or the
40
+ * dormant-auto-shelve rule applies (dormant + not needing attention). Mirrors
41
+ * the partition built in fetchDashboardData (see the `freshShelved` filter).
42
+ */
43
+ function isShelvedForDisplay(pr, explicitlyShelved) {
44
+ return explicitlyShelved.has(pr.url) || (pr.stalenessTier === 'dormant' && pr.status !== 'needs_addressing');
45
+ }
46
+ /**
47
+ * Re-derive `digest.shelvedPRs` and `summary.totalActivePRs` from the CURRENT
48
+ * `state.config.shelvedPRUrls` so a shelve/unshelve issued from the dashboard
49
+ * SPA is reflected immediately.
50
+ *
51
+ * Why this exists: POST /api/action → runMove only mutates
52
+ * `state.config.shelvedPRUrls`; it never touches the cached digest.
53
+ * buildDashboardJson derives both `shelvedPRUrls` and `stats.shelvedPRs` from
54
+ * `digest.shelvedPRs`, so without this reconciliation a dashboard shelve/unshelve
55
+ * appears to do nothing until the next full /api/refresh rebuilds the digest.
56
+ *
57
+ * Baked shelved entries that are NOT among the current open PRs (the daily-check
58
+ * dormant partition, #981) are preserved as-is — the SPA cannot act on those —
59
+ * while explicit shelve/unshelve of open PRs and the dormant-auto-shelve rule
60
+ * are honored.
61
+ */
62
+ export function reconcileShelvePartition(digest, state) {
63
+ const openPRs = (digest.openPRs || []);
64
+ const openByUrl = new Map(openPRs.map((pr) => [pr.url, pr]));
65
+ const explicitlyShelved = new Set(state.config.shelvedPRUrls || []);
66
+ // Keep baked entries that are either off the open-PR list (daily dormant
67
+ // partition we can't recompute here) or still shelved under current state.
68
+ const reconciled = (digest.shelvedPRs || []).filter((ref) => {
69
+ const pr = openByUrl.get(ref.url);
70
+ return !pr || isShelvedForDisplay(pr, explicitlyShelved);
71
+ });
72
+ // Add open PRs that became shelved but aren't represented in the baked list yet.
73
+ const present = new Set(reconciled.map((ref) => ref.url));
74
+ for (const pr of openPRs) {
75
+ if (!present.has(pr.url) && isShelvedForDisplay(pr, explicitlyShelved)) {
76
+ reconciled.push(toShelvedPRRef(pr));
77
+ }
78
+ }
79
+ digest.shelvedPRs = reconciled;
80
+ // Only re-derive the active count when we actually have the open-PR list;
81
+ // some digests carry an authoritative summary with an empty openPRs fixture.
82
+ if (openPRs.length > 0) {
83
+ const shelvedOpen = reconciled.filter((ref) => openByUrl.has(ref.url)).length;
84
+ digest.summary.totalActivePRs = openPRs.length - shelvedOpen;
85
+ }
86
+ }
38
87
  /**
39
88
  * Merge fresh API counts into existing stored counts.
40
89
  * Months present in the fresh data are updated; months only in the existing data are preserved.
@@ -10,10 +10,10 @@ import * as fs from 'node:fs';
10
10
  import * as path from 'node:path';
11
11
  import * as crypto from 'node:crypto';
12
12
  import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides } from '../core/index.js';
13
- import { errorMessage, ValidationError } from '../core/errors.js';
13
+ import { errorMessage, ValidationError, ConcurrencyError, GistConcurrencyError } from '../core/errors.js';
14
14
  import { warn } from '../core/logger.js';
15
15
  import { validateUrl, validateGitHubUrl, PR_URL_PATTERN, ISSUE_URL_PATTERN } from './validation.js';
16
- import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, storedToMergedPRs, storedToClosedPRs, } from './dashboard-data.js';
16
+ import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, reconcileShelvePartition, storedToMergedPRs, storedToClosedPRs, } from './dashboard-data.js';
17
17
  import { openInBrowser, detectIssueList } from './startup.js';
18
18
  import { parseIssueList } from './parse-list.js';
19
19
  import { writeDashboardServerInfo, removeDashboardServerInfo } from './dashboard-process.js';
@@ -75,6 +75,11 @@ function getIssueListMtimeMs() {
75
75
  * start-up, so tests that need a specific digest should call this directly).
76
76
  */
77
77
  export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClosedPRs, partialFailures) {
78
+ // Re-derive the shelve partition from the CURRENT state before reading it.
79
+ // The POST /api/action path rebuilds with a cached digest whose shelvedPRs
80
+ // predates the shelve/unshelve, so without this the SPA action appears to do
81
+ // nothing until the next full /api/refresh.
82
+ reconcileShelvePartition(digest, state);
78
83
  const prsByRepo = computePRsByRepo(digest, state);
79
84
  const topRepos = computeTopRepos(prsByRepo);
80
85
  const { monthlyMerged, monthlyOpened, monthlyClosed } = getMonthlyData(state);
@@ -240,6 +245,27 @@ function sendJson(res, statusCode, data) {
240
245
  function sendError(res, statusCode, message) {
241
246
  sendJson(res, statusCode, { error: message });
242
247
  }
248
+ /**
249
+ * True when an error is an optimistic-concurrency conflict on state.json
250
+ * (local mtime CAS) or the state Gist (ETag CAS). Both carry the
251
+ * CONCURRENCY_ERROR code and the same recovery contract: reload, re-apply,
252
+ * save. See state-concurrency.test.ts (#1378) and errors.ts.
253
+ */
254
+ function isConcurrencyConflict(error) {
255
+ return error instanceof ConcurrencyError || error instanceof GistConcurrencyError;
256
+ }
257
+ /**
258
+ * Send the machine-readable 409 for a concurrency conflict (#1397).
259
+ * `retryable: true` tells the SPA it can re-prime via GET /api/data and
260
+ * retry the POST; `code` matches the structured code on ConcurrencyError.
261
+ */
262
+ function sendConflict(res) {
263
+ sendJson(res, 409, {
264
+ error: 'Another process wrote state concurrently — retry the request',
265
+ code: 'CONCURRENCY_ERROR',
266
+ retryable: true,
267
+ });
268
+ }
243
269
  // ── Server ─────────────────────────────────────────────────────────────────────
244
270
  export async function startDashboardServer(options) {
245
271
  const { port: requestedPort, assetsDir, token, open } = options;
@@ -398,14 +424,16 @@ export async function startDashboardServer(options) {
398
424
  });
399
425
  server.requestTimeout = REQUEST_TIMEOUT_MS;
400
426
  // ── POST /api/action handler ─────────────────────────────────────────────
401
- async function handleAction(req, res) {
402
- // Reload state before mutating to avoid overwriting external CLI changes
427
+ /** Re-read state written by external processes (CLI) before mutating. */
428
+ async function reloadState() {
403
429
  if (stateManager.isGistMode()) {
404
430
  await stateManager.refreshFromGist();
405
431
  }
406
432
  else {
407
433
  stateManager.reloadIfChanged();
408
434
  }
435
+ }
436
+ async function handleAction(req, res) {
409
437
  let body;
410
438
  try {
411
439
  const raw = await readBody(req);
@@ -440,24 +468,62 @@ export async function startDashboardServer(options) {
440
468
  }
441
469
  return;
442
470
  }
443
- try {
444
- if (body.action === 'move') {
445
- const { VALID_TARGETS, runMove } = await import('./move.js');
446
- if (!body.target || !VALID_TARGETS.includes(body.target)) {
447
- sendError(res, 400, `move requires a valid "target" field (${VALID_TARGETS.join(', ')})`);
448
- return;
449
- }
450
- await runMove({ prUrl: body.url, target: body.target });
471
+ // Resolve the mutation up-front so target validation happens before the
472
+ // state reload keeps the reload-to-save freshness window minimal.
473
+ let applyMutation;
474
+ if (body.action === 'move') {
475
+ const { VALID_TARGETS, runMove } = await import('./move.js');
476
+ if (!body.target || !VALID_TARGETS.includes(body.target)) {
477
+ sendError(res, 400, `move requires a valid "target" field (${VALID_TARGETS.join(', ')})`);
478
+ return;
451
479
  }
452
- else {
453
- // dismiss_issue_response
480
+ const target = body.target;
481
+ applyMutation = async () => {
482
+ await runMove({ prUrl: body.url, target });
483
+ };
484
+ }
485
+ else {
486
+ // dismiss_issue_response
487
+ applyMutation = () => {
454
488
  stateManager.dismissIssue(body.url, new Date().toISOString());
455
- }
489
+ };
490
+ }
491
+ // Reload state before mutating to avoid overwriting external CLI changes.
492
+ // Runs AFTER body parsing/validation (which only inspects the request,
493
+ // never the loaded state) so the read-modify-write window excludes the
494
+ // body-streaming time (#1397).
495
+ await reloadState();
496
+ try {
497
+ await applyMutation();
456
498
  }
457
499
  catch (error) {
458
- warn(MODULE, `Action failed: ${body.action} ${body.url} ${errorMessage(error)}`);
459
- sendError(res, 500, 'Action failed');
460
- return;
500
+ if (!isConcurrencyConflict(error)) {
501
+ warn(MODULE, `Action failed: ${body.action} ${body.url} ${errorMessage(error)}`);
502
+ sendError(res, 500, 'Action failed');
503
+ return;
504
+ }
505
+ // Concurrency conflict: an external write landed between our reload and
506
+ // save. Decision (#1397): retry ONCE server-side instead of bouncing the
507
+ // first conflict to the client. Both mutations (move targets, dismiss)
508
+ // are absolute set operations, so re-applying them on a freshly reloaded
509
+ // baseline is safe — exactly the reload-reapply recovery contract pinned
510
+ // in state-concurrency.test.ts. A second consecutive conflict means
511
+ // sustained contention; surface it as a retryable 409 and let the SPA
512
+ // re-prime and retry.
513
+ try {
514
+ await reloadState();
515
+ await applyMutation();
516
+ }
517
+ catch (retryError) {
518
+ if (isConcurrencyConflict(retryError)) {
519
+ warn(MODULE, `Action conflicted twice: ${body.action} ${body.url} ${errorMessage(retryError)}`);
520
+ sendConflict(res);
521
+ return;
522
+ }
523
+ warn(MODULE, `Action failed on conflict retry: ${body.action} ${body.url} ${errorMessage(retryError)}`);
524
+ sendError(res, 500, 'Action failed');
525
+ return;
526
+ }
461
527
  }
462
528
  // Rebuild dashboard data from cached digest + updated state. Persist
463
529
  // the last-known partialFailures across action rebuilds (#1035) so the
@@ -475,12 +541,7 @@ export async function startDashboardServer(options) {
475
541
  return;
476
542
  }
477
543
  try {
478
- if (stateManager.isGistMode()) {
479
- await stateManager.refreshFromGist();
480
- }
481
- else {
482
- stateManager.reloadIfChanged();
483
- }
544
+ await reloadState();
484
545
  warn(MODULE, 'Refreshing dashboard data from GitHub...');
485
546
  const result = await fetchDashboardData(currentToken);
486
547
  cachedDigest = result.digest;
@@ -493,6 +554,14 @@ export async function startDashboardServer(options) {
493
554
  sendJson(res, 200, cachedJsonData);
494
555
  }
495
556
  catch (error) {
557
+ // No server-side retry here (unlike handleAction): a refresh re-run is a
558
+ // full GitHub fetch — expensive and rate-limited. The 409 is retryable
559
+ // by the client, which re-POSTs /api/refresh on its own schedule (#1397).
560
+ if (isConcurrencyConflict(error)) {
561
+ warn(MODULE, `Dashboard refresh hit a concurrent state write: ${errorMessage(error)}`);
562
+ sendConflict(res);
563
+ return;
564
+ }
496
565
  warn(MODULE, `Dashboard refresh failed: ${errorMessage(error)}`);
497
566
  sendError(res, 500, 'Refresh failed');
498
567
  }
@@ -6,10 +6,14 @@
6
6
  export interface DismissOutput {
7
7
  dismissed: boolean;
8
8
  url: string;
9
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
10
+ gistSyncWarning?: string;
9
11
  }
10
12
  export interface UndismissOutput {
11
13
  undismissed: boolean;
12
14
  url: string;
15
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
16
+ gistSyncWarning?: string;
13
17
  }
14
18
  /**
15
19
  * Dismiss an issue's reply notifications without posting a comment.
@@ -20,8 +20,8 @@ export async function runDismiss(options) {
20
20
  validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
21
21
  const stateManager = getStateManager();
22
22
  const added = stateManager.dismissIssue(options.url, new Date().toISOString());
23
- await maybeCheckpoint(stateManager, MODULE);
24
- return { dismissed: added, url: options.url };
23
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
24
+ return { dismissed: added, url: options.url, ...(gistSyncWarning ? { gistSyncWarning } : {}) };
25
25
  }
26
26
  /**
27
27
  * Restore a dismissed issue to notifications.
@@ -36,6 +36,6 @@ export async function runUndismiss(options) {
36
36
  validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
37
37
  const stateManager = getStateManager();
38
38
  const removed = stateManager.undismissIssue(options.url);
39
- await maybeCheckpoint(stateManager, MODULE);
40
- return { undismissed: removed, url: options.url };
39
+ const gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
40
+ return { undismissed: removed, url: options.url, ...(gistSyncWarning ? { gistSyncWarning } : {}) };
41
41
  }
@@ -10,15 +10,26 @@ export interface GuidelinesViewOutput {
10
10
  /** Where the guidelines would be persisted if a write happened now. */
11
11
  storageMode: 'gist' | 'local-unavailable';
12
12
  }
13
+ export interface GuidelinesListOutput {
14
+ /** Repos with non-empty guidelines stored, sorted alphabetically. Always empty in local mode. */
15
+ repos: string[];
16
+ count: number;
17
+ /** Where guidelines are persisted. `local-unavailable` always yields an empty list. */
18
+ storageMode: 'gist' | 'local-unavailable';
19
+ }
13
20
  export interface GuidelinesStoreOutput {
14
21
  repo: string;
15
22
  byteSize: number;
16
23
  stored: boolean;
24
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
25
+ gistSyncWarning?: string;
17
26
  }
18
27
  export interface GuidelinesResetOutput {
19
28
  repo: string;
20
29
  /** True when an existing file was tombstoned, false when no file existed. */
21
30
  deleted: boolean;
31
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
32
+ gistSyncWarning?: string;
22
33
  }
23
34
  export interface FetchCorpusOutput {
24
35
  repo: string;
@@ -37,6 +48,8 @@ export interface FetchCorpusOutput {
37
48
  prUrl: string;
38
49
  error: string;
39
50
  }>;
51
+ /** Set when the post-mutation Gist checkpoint failed; the local mutation succeeded (#1370). */
52
+ gistSyncWarning?: string;
40
53
  }
41
54
  interface RepoOption {
42
55
  repo: string;
@@ -52,6 +65,12 @@ interface StoreOptions extends RepoOption {
52
65
  }
53
66
  /** Read the per-repo guidelines for `repo`. Returns a `local-unavailable` envelope in non-Gist mode. */
54
67
  export declare function runGuidelinesView(options: RepoOption): Promise<GuidelinesViewOutput>;
68
+ /**
69
+ * List every repo with non-empty stored guidelines. Never throws in local
70
+ * mode — returns an empty list with `storageMode: 'local-unavailable'` so
71
+ * hosts can distinguish "nothing stored" from "storage not configured".
72
+ */
73
+ export declare function runGuidelinesList(): Promise<GuidelinesListOutput>;
55
74
  /**
56
75
  * Persist per-repo guidelines for `repo`. Throws when not in Gist mode or
57
76
  * when content exceeds the byte budget — the CLI surface relies on the