@oss-autopilot/core 3.13.4 → 3.14.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 (85) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-registry.js +50 -83
  3. package/dist/cli.bundle.cjs +110 -107
  4. package/dist/cli.js +5 -4
  5. package/dist/commands/comments.js +44 -10
  6. package/dist/commands/config.d.ts +2 -0
  7. package/dist/commands/config.js +50 -2
  8. package/dist/commands/curated-list.d.ts +17 -0
  9. package/dist/commands/curated-list.js +25 -0
  10. package/dist/commands/daily.d.ts +7 -1
  11. package/dist/commands/daily.js +136 -57
  12. package/dist/commands/dashboard-cache.d.ts +69 -0
  13. package/dist/commands/dashboard-cache.js +219 -0
  14. package/dist/commands/dashboard-data.d.ts +18 -10
  15. package/dist/commands/dashboard-data.js +35 -7
  16. package/dist/commands/dashboard-gist-sync.d.ts +93 -0
  17. package/dist/commands/dashboard-gist-sync.js +237 -0
  18. package/dist/commands/dashboard-server.d.ts +6 -10
  19. package/dist/commands/dashboard-server.js +148 -341
  20. package/dist/commands/features.js +6 -0
  21. package/dist/commands/guidelines.d.ts +6 -0
  22. package/dist/commands/guidelines.js +7 -0
  23. package/dist/commands/index.d.ts +2 -5
  24. package/dist/commands/index.js +2 -4
  25. package/dist/commands/init.d.ts +2 -0
  26. package/dist/commands/init.js +7 -1
  27. package/dist/commands/list-mark-done.js +6 -21
  28. package/dist/commands/list-move-tier.js +3 -5
  29. package/dist/commands/locate-issue-list.d.ts +25 -0
  30. package/dist/commands/locate-issue-list.js +67 -0
  31. package/dist/commands/merge-loop.d.ts +63 -0
  32. package/dist/commands/merge-loop.js +157 -0
  33. package/dist/commands/repo-vet.js +40 -1
  34. package/dist/commands/scout-bridge.d.ts +35 -2
  35. package/dist/commands/scout-bridge.js +65 -13
  36. package/dist/commands/search.d.ts +4 -6
  37. package/dist/commands/search.js +58 -11
  38. package/dist/commands/setup.d.ts +2 -0
  39. package/dist/commands/setup.js +56 -2
  40. package/dist/commands/skip-file-parser.d.ts +23 -0
  41. package/dist/commands/skip-file-parser.js +23 -10
  42. package/dist/commands/startup.d.ts +1 -6
  43. package/dist/commands/startup.js +25 -59
  44. package/dist/commands/track.d.ts +2 -2
  45. package/dist/commands/track.js +2 -2
  46. package/dist/commands/vet-list.js +4 -0
  47. package/dist/core/config-registry.js +36 -0
  48. package/dist/core/daily-logic.d.ts +25 -2
  49. package/dist/core/daily-logic.js +58 -3
  50. package/dist/core/gist-health.d.ts +81 -0
  51. package/dist/core/gist-health.js +39 -0
  52. package/dist/core/gist-state-store.d.ts +3 -1
  53. package/dist/core/gist-state-store.js +7 -2
  54. package/dist/core/github-stats.d.ts +2 -2
  55. package/dist/core/github-stats.js +20 -4
  56. package/dist/core/index.d.ts +4 -3
  57. package/dist/core/index.js +4 -3
  58. package/dist/core/issue-conversation.js +8 -2
  59. package/dist/core/issue-grading.d.ts +9 -0
  60. package/dist/core/issue-grading.js +9 -0
  61. package/dist/core/pagination.d.ts +27 -0
  62. package/dist/core/pagination.js +23 -5
  63. package/dist/core/pr-comments-fetcher.d.ts +7 -0
  64. package/dist/core/pr-comments-fetcher.js +19 -8
  65. package/dist/core/pr-monitor.d.ts +2 -0
  66. package/dist/core/pr-monitor.js +26 -9
  67. package/dist/core/repo-score-manager.d.ts +2 -2
  68. package/dist/core/repo-score-manager.js +3 -3
  69. package/dist/core/repo-vet.d.ts +2 -2
  70. package/dist/core/repo-vet.js +1 -1
  71. package/dist/core/review-analysis.d.ts +19 -0
  72. package/dist/core/review-analysis.js +28 -0
  73. package/dist/core/state-schema.d.ts +43 -6
  74. package/dist/core/state-schema.js +81 -4
  75. package/dist/core/state.d.ts +36 -5
  76. package/dist/core/state.js +177 -28
  77. package/dist/core/strategy.js +6 -5
  78. package/dist/core/types.d.ts +8 -0
  79. package/dist/core/untrusted-content.d.ts +45 -0
  80. package/dist/core/untrusted-content.js +54 -0
  81. package/dist/formatters/json.d.ts +81 -7
  82. package/dist/formatters/json.js +55 -2
  83. package/package.json +2 -2
  84. package/dist/commands/shelve.d.ts +0 -45
  85. package/dist/commands/shelve.js +0 -54
@@ -4,21 +4,30 @@
4
4
  * for live data fetching and state mutations (PR state transitions, issue dismiss).
5
5
  *
6
6
  * Uses Node's built-in http module — no Express/Fastify.
7
+ *
8
+ * Collaborators (#1457): the gist-sync lifecycle lives in
9
+ * GistSyncCoordinator (dashboard-gist-sync.ts) and the cached-response
10
+ * state in DashboardCache (dashboard-cache.ts); this file is wiring,
11
+ * routing and the HTTP handlers.
7
12
  */
8
13
  import * as http from 'node:http';
9
14
  import * as fs from 'node:fs';
10
15
  import * as path from 'node:path';
11
16
  import * as crypto from 'node:crypto';
12
- import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides, classifyAttentionBucket, maybeCheckpoint, ensureGistPersistence, } from '../core/index.js';
13
- import { errorMessage, ValidationError, ConcurrencyError, ConfigurationError, GistConcurrencyError, } from '../core/errors.js';
17
+ import { getGitHubToken, getCLIVersion, maybeCheckpoint } from '../core/index.js';
18
+ import { errorMessage, ValidationError, ConcurrencyError, GistConcurrencyError } from '../core/errors.js';
14
19
  import { warn } from '../core/logger.js';
15
20
  import { validateUrl, validateGitHubUrl, PR_URL_PATTERN, ISSUE_URL_PATTERN } from './validation.js';
16
- import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, reconcileShelvePartition, storedToMergedPRs, storedToClosedPRs, } from './dashboard-data.js';
17
- import { openInBrowser, detectIssueList } from './startup.js';
18
- import { parseIssueList } from './parse-list.js';
21
+ import { fetchDashboardData } from './dashboard-data.js';
22
+ import { openInBrowser } from './startup.js';
19
23
  import { writeDashboardServerInfo, removeDashboardServerInfo } from './dashboard-process.js';
20
24
  import { RateLimiter } from './rate-limiter.js';
21
- import { isBelowMinStars, } from '../core/types.js';
25
+ import { GistSyncCoordinator } from './dashboard-gist-sync.js';
26
+ import { DashboardCache, getIssueListMtimeMs } from './dashboard-cache.js';
27
+ // Re-export the payload builder for backward compatibility — it moved to
28
+ // dashboard-cache.ts with the cache extraction (#1457) but is unit-tested
29
+ // (and consumed) under this module's name.
30
+ export { buildDashboardJson } from './dashboard-cache.js';
22
31
  // Re-export process management functions for backward compatibility
23
32
  export { getDashboardPidPath, writeDashboardServerInfo, readDashboardServerInfo, removeDashboardServerInfo, isDashboardServerRunning, findRunningDashboardServer, } from './dashboard-process.js';
24
33
  // ── Constants ────────────────────────────────────────────────────────────────
@@ -36,120 +45,6 @@ const MIME_TYPES = {
36
45
  '.ico': 'image/x-icon',
37
46
  };
38
47
  // ── Helpers ────────────────────────────────────────────────────────────────────
39
- /**
40
- * Read and parse the vetted issue list file (non-fatal on failure).
41
- */
42
- function readVettedIssues() {
43
- try {
44
- const info = detectIssueList();
45
- if (!info)
46
- return null;
47
- const content = fs.readFileSync(info.path, 'utf8');
48
- return parseIssueList(content);
49
- }
50
- catch (error) {
51
- warn(MODULE, `Failed to read vetted issue list: ${errorMessage(error)}`);
52
- return null;
53
- }
54
- }
55
- /**
56
- * Get the mtime of the vetted issue list file in ms, or null if unknown.
57
- * Used to detect external edits and invalidate the cached dashboard payload.
58
- */
59
- function getIssueListMtimeMs() {
60
- try {
61
- const info = detectIssueList();
62
- if (!info)
63
- return null;
64
- return fs.statSync(info.path).mtimeMs;
65
- }
66
- catch {
67
- return null;
68
- }
69
- }
70
- /**
71
- * Build the JSON payload that the SPA expects from GET /api/data.
72
- *
73
- * Exported for unit testing of response-shape concerns that the full
74
- * handler harness can't reach (it bakes a stale cachedDigest at server
75
- * start-up, so tests that need a specific digest should call this directly).
76
- */
77
- export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClosedPRs, partialFailures) {
78
- // Apply status overrides ONCE, before the shelve partition is derived, so
79
- // the dashboard partitions on the same post-override status the CLI
80
- // partitions on (#1416). This also covers overrides set AFTER the digest
81
- // was cached (a dashboard move stores an override; the action path rebuilds
82
- // from the cached digest). Work on a copy — the caller's cached digest must
83
- // not accumulate per-request derivations.
84
- const overriddenDigest = {
85
- ...digest,
86
- openPRs: applyStatusOverrides(digest.openPRs || [], state),
87
- summary: { ...digest.summary },
88
- };
89
- // Re-derive the shelve partition from the CURRENT state before reading it.
90
- // The POST /api/action path rebuilds with a cached digest whose shelvedPRs
91
- // predates the shelve/unshelve, so without this the SPA action appears to do
92
- // nothing until the next full /api/refresh.
93
- reconcileShelvePartition(overriddenDigest, state);
94
- const prsByRepo = computePRsByRepo(overriddenDigest, state);
95
- const topRepos = computeTopRepos(prsByRepo);
96
- const { monthlyMerged, monthlyOpened, monthlyClosed } = getMonthlyData(state);
97
- // Derive from state if not provided (e.g. initial load from cached state)
98
- const mergedPRs = allMergedPRs ?? storedToMergedPRs(getStateManager().getMergedPRs());
99
- const closedPRs = allClosedPRs ?? storedToClosedPRs(getStateManager().getClosedPRs());
100
- // Filter out PRs from repos below the minStars threshold
101
- const minStars = state.config.minStars ?? 50;
102
- const repoScores = state.repoScores || {};
103
- const isAboveMinStars = (pr) => !isBelowMinStars(repoScores[pr.repo]?.stargazersCount, minStars);
104
- const filteredMergedPRs = mergedPRs.filter(isAboveMinStars);
105
- const filteredClosedPRs = closedPRs.filter(isAboveMinStars);
106
- const stats = buildDashboardStats(overriddenDigest, state, filteredMergedPRs.length, filteredClosedPRs.length);
107
- const dismissedIssues = state.config.dismissedIssues || {};
108
- const issueResponses = commentedIssues.filter((i) => i.status === 'new_response' && !(i.url in dismissedIssues));
109
- // Build repo metadata map from repoScores — omit repos without stars or language to avoid empty entries
110
- const repoMetadata = {};
111
- for (const [repo, score] of Object.entries(repoScores)) {
112
- if (score.stargazersCount !== undefined || score.language !== undefined) {
113
- repoMetadata[repo] = { stars: score.stargazersCount, language: score.language };
114
- }
115
- }
116
- const vettedIssues = readVettedIssues();
117
- if (vettedIssues) {
118
- stats.availableIssues = vettedIssues.availableCount;
119
- }
120
- return {
121
- stats,
122
- prsByRepo,
123
- topRepos: topRepos.map(([repo, data]) => ({ repo, ...data })),
124
- monthlyMerged,
125
- monthlyOpened,
126
- monthlyClosed,
127
- // #1352: stamp the unified attention bucket so the SPA renders the same
128
- // taxonomy the CLI brief counts (single classifier, no second opinion).
129
- // Overrides were already applied when overriddenDigest was built — a
130
- // second application here would be a no-op but obscures the single
131
- // apply-then-partition ordering (#1416).
132
- activePRs: (overriddenDigest.openPRs || []).map((pr) => ({
133
- ...pr,
134
- attentionBucket: classifyAttentionBucket(pr),
135
- })),
136
- // Source of truth is digest.shelvedPRs (union of explicitly-shelved URLs
137
- // and dormant-non-addressing PRs auto-shelved for display). Returning
138
- // only state.config.shelvedPRUrls would under-count and desync from
139
- // stats.shelvedPRs, which is already derived from digest.shelvedPRs. (#981)
140
- shelvedPRUrls: (overriddenDigest.shelvedPRs || []).map((ref) => ref.url),
141
- recentlyMergedPRs: overriddenDigest.recentlyMergedPRs || [],
142
- recentlyClosedPRs: overriddenDigest.recentlyClosedPRs || [],
143
- autoUnshelvedPRs: overriddenDigest.autoUnshelvedPRs || [],
144
- commentedIssues,
145
- issueResponses,
146
- allMergedPRs: filteredMergedPRs,
147
- allClosedPRs: filteredClosedPRs,
148
- repoMetadata,
149
- vettedIssues,
150
- partialFailures: partialFailures && partialFailures.length > 0 ? partialFailures : undefined,
151
- };
152
- }
153
48
  /**
154
49
  * Read the full request body as a UTF-8 string, with a size limit.
155
50
  */
@@ -264,6 +159,14 @@ function sendJson(res, statusCode, data) {
264
159
  function sendError(res, statusCode, message) {
265
160
  sendJson(res, statusCode, { error: message });
266
161
  }
162
+ /**
163
+ * Collapse CR/LF in a string destined for an HTTP header value — Node's
164
+ * setHeader throws on embedded newlines, and staleness reasons are built
165
+ * from arbitrary error messages (#1446 item 2).
166
+ */
167
+ function sanitizeHeaderValue(value) {
168
+ return value.replace(/[\r\n]+/g, ' ').trim();
169
+ }
267
170
  /**
268
171
  * True when an error is an optimistic-concurrency conflict on state.json
269
172
  * (local mtime CAS) or the state Gist (ETag CAS). Both carry the
@@ -288,10 +191,12 @@ function sendConflict(res) {
288
191
  // ── Server ─────────────────────────────────────────────────────────────────────
289
192
  export async function startDashboardServer(options) {
290
193
  const { port: requestedPort, assetsDir, token, open } = options;
291
- // `let` (#1433): a degraded gist recovery replaces the core singleton, and
292
- // this long-lived server must re-resolve its reference or every handler
293
- // keeps using the orphaned local manager.
294
- let stateManager = getStateManager();
194
+ // Gist-sync lifecycle state machine (#1457): pending push warnings (#1417),
195
+ // degraded recovery with throttle/halt (#1433), loss notices (#1443). It
196
+ // also owns the live StateManager reference — a degraded gist recovery
197
+ // replaces the core singleton, and this long-lived server must re-resolve
198
+ // its reference (#1433), so always read it via gistSync.stateManager.
199
+ const gistSync = new GistSyncCoordinator(token, MODULE);
295
200
  const resolvedAssetsDir = path.resolve(assetsDir);
296
201
  // ── CSRF token ──────────────────────────────────────────────────────────
297
202
  // Fresh per server-start. Exposed to the SPA via X-CSRF-Token on every
@@ -305,158 +210,14 @@ export async function startDashboardServer(options) {
305
210
  // Start immediately with state.json data (written by the daily check that
306
211
  // precedes this server launch). A background GitHub fetch refreshes the
307
212
  // cache after the port is bound, so the startup poller sees us in time.
308
- let cachedDigest = stateManager.getState().lastDigest;
309
- let cachedCommentedIssues = [];
310
- // Persist the last-known partialFailures across rebuild requests (#1035).
311
- // Cleared only when a fresh fetchDashboardData returns zero failures;
312
- // re-threaded into every buildDashboardJson call so the SPA banner does
313
- // not disappear when /api/data rebuilds after a state change or after a
314
- // POST /api/action completes.
315
- let cachedPartialFailures = undefined;
316
- // Gist checkpoint warnings from dashboard mutations (#1417). DELIBERATELY
317
- // separate from cachedPartialFailures: fetch failures clear on a successful
318
- // PULL, but a gist-sync warning means an un-pushed mutation, and a pull is
319
- // exactly the event that can destroy it (refreshFromGist wholesale-replaces
320
- // state). These clear only when a checkpoint PUSH succeeds.
321
- let pendingGistSyncWarnings = [];
322
- // Tracks the last background-refresh failure so /api/data can surface
323
- // staleness to the SPA via the X-Dashboard-Stale header (#1205). Cleared
324
- // when a refresh succeeds. Without this, token expiry / GitHub outage
325
- // produces silent stale data hours old with no client-visible signal.
326
- let lastBackgroundRefreshError = null;
327
- /** Record a mutation's checkpoint outcome. `null` means the push succeeded
328
- * (or local mode) — and a successful push carries the FULL current state,
329
- * so any previously pending warning is resolved with it. */
330
- function recordGistSyncOutcome(warning) {
331
- if (warning === null) {
332
- pendingGistSyncWarnings = [];
333
- return;
334
- }
335
- if (!pendingGistSyncWarnings.includes(warning)) {
336
- pendingGistSyncWarnings.push(warning);
337
- }
338
- }
339
- /** Merge pending gist-sync warnings into a partialFailures payload for the
340
- * SPA banner without coupling their lifecycles. */
341
- function withPendingGistWarnings(failures) {
342
- const extras = [...pendingGistSyncWarnings, ...recoveryLossNotices];
343
- if (gistConfiguredButLocal()) {
344
- extras.push(recoveryHaltedReason === null
345
- ? GIST_DEGRADED_WARNING
346
- : `Gist persistence is configured but recovery FAILED permanently: ${recoveryHaltedReason} — ` +
347
- 'fix the Gist setup (check the token gist scope, or run state-show), then restart the dashboard.');
348
- }
349
- else if (gistBootstrapDegraded()) {
350
- extras.push(GIST_STALE_BOOTSTRAP_WARNING);
351
- }
352
- if (extras.length === 0)
353
- return failures;
354
- const base = failures ?? [];
355
- return [...base, ...extras.filter((w) => !base.includes(w))];
356
- }
357
- /** Push-before-pull (#1417): an un-pushed mutation would be silently
358
- * reverted by the next Gist pull. Retry the checkpoint first so a recovered
359
- * network turns the pending warning into a real push before any pull runs.
360
- * No-op when nothing is pending. */
361
- async function flushPendingGistSync() {
362
- if (pendingGistSyncWarnings.length === 0)
363
- return;
364
- recordGistSyncOutcome(await maybeCheckpoint(stateManager, MODULE));
365
- }
366
- /** True while the config asks for gist but this process's manager is
367
- * local-only (#1433) — the degraded window in which every dashboard
368
- * mutation is acknowledged and then clobbered by the next pull. */
369
- function gistConfiguredButLocal() {
370
- // Defensive: this is an advisory check that runs on EVERY request path
371
- // (rebuilds, recovery probes). A getState failure has its own handling
372
- // wherever state is actually consumed — the degraded probe must not
373
- // become a new crash surface in front of it (#994's stale-serving path).
374
- try {
375
- return stateManager.getState().config.persistence === 'gist' && !stateManager.isGistMode();
376
- }
377
- catch (err) {
378
- warn(MODULE, `Degraded-gist probe failed (treating as not degraded): ${errorMessage(err)}`);
379
- return false;
380
- }
381
- }
382
- /** Gist-backed but the bootstrap itself fell back to the local cache —
383
- * reads may be stale even though isGistMode() is true (#1433 review). */
384
- function gistBootstrapDegraded() {
385
- try {
386
- return stateManager.isGistMode() && stateManager.isGistDegraded();
387
- }
388
- catch {
389
- return false;
390
- }
391
- }
392
- const GIST_DEGRADED_WARNING = 'Gist persistence is configured but this dashboard process is running LOCAL-ONLY; ' +
393
- 'changes made here will NOT sync and may be overwritten by the next successful Gist read. ' +
394
- 'Recovery is retried automatically.';
395
- const GIST_STALE_BOOTSTRAP_WARNING = 'Gist persistence is active but the last Gist read fell back to the local cache; ' +
396
- 'data shown may be stale until the next successful Gist read.';
397
- // Recovery throttling + halt (#1433 review): a PERMANENT failure (token
398
- // lacks gist scope, corrupt Gist) must not turn every dashboard poll into
399
- // a doomed GitHub round trip for the life of the server, and its root
400
- // cause must reach the banner — the detached spawn discards stderr.
401
- const RECOVERY_RETRY_INTERVAL_MS = 30_000;
402
- let lastRecoveryAttemptAt = 0;
403
- let recoveryHaltedReason = null;
404
- // Mutations acknowledged while degraded (#1433 review): a successful
405
- // recovery bootstraps FROM the existing Gist, which reverts them — the
406
- // user must get a retrospective notice, not just the prospective banner
407
- // that clears at the exact moment of the loss. Cleared on a successful
408
- // full refresh (by then the user has seen the notice across the window).
409
- let degradedMutationCount = 0;
410
- let recoveryLossNotices = [];
411
- /** Re-attempt gist init while degraded (#1433). The serve process used to
412
- * bootstrap exactly once at CLI preAction — with its stderr discarded by
413
- * the detached spawn — and never retry, so one blip at startup meant
414
- * local-only writes for the server's lifetime. Never throws. Transient
415
- * failures retry no more often than RECOVERY_RETRY_INTERVAL_MS; permanent
416
- * (ConfigurationError-class) failures halt retries and surface the reason
417
- * in the banner. */
418
- async function maybeRecoverGist() {
419
- if (!gistConfiguredButLocal())
420
- return;
421
- if (recoveryHaltedReason !== null)
422
- return;
423
- const now = Date.now();
424
- if (now - lastRecoveryAttemptAt < RECOVERY_RETRY_INTERVAL_MS)
425
- return;
426
- const currentToken = token || getGitHubToken();
427
- // A token-less probe is free (the auth cache answers instantly) and must
428
- // not burn the retry window — stamp only when a real attempt starts.
429
- if (!currentToken)
430
- return;
431
- lastRecoveryAttemptAt = now;
432
- try {
433
- await ensureGistPersistence(currentToken);
434
- // The upgrade replaced the core singleton — re-resolve our reference.
435
- stateManager = getStateManager();
436
- if (stateManager.isGistMode() && degradedMutationCount > 0) {
437
- recoveryLossNotices.push(`Gist persistence recovered, but ${degradedMutationCount} change(s) made while degraded were ` +
438
- 'saved locally only and were NOT merged into the Gist — they may have been reverted in this view. ' +
439
- 'Re-apply anything missing.');
440
- degradedMutationCount = 0;
441
- }
442
- }
443
- catch (err) {
444
- // ConfigurationError-class failures are permanent until the user acts
445
- // (#1202 semantics) — stop hammering GitHub and say WHY in the banner.
446
- if (err instanceof ConfigurationError) {
447
- recoveryHaltedReason = errorMessage(err);
448
- }
449
- warn(MODULE, `Gist recovery attempt failed: ${errorMessage(err)}`);
450
- }
451
- }
452
- if (!cachedDigest) {
213
+ const initialDigest = gistSync.stateManager.getState().lastDigest;
214
+ if (!initialDigest) {
453
215
  throw new Error('No dashboard data available. Run the daily check first: GITHUB_TOKEN=$(gh auth token) npm start -- daily');
454
216
  }
217
+ const cache = new DashboardCache(initialDigest, (failures) => gistSync.withPendingGistWarnings(failures));
455
218
  // ── Build cached JSON response ───────────────────────────────────────────
456
- let cachedJsonData;
457
- let cachedIssueListMtimeMs = getIssueListMtimeMs();
458
219
  try {
459
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, withPendingGistWarnings(cachedPartialFailures));
220
+ cache.rebuild(gistSync.stateManager.getState());
460
221
  }
461
222
  catch (error) {
462
223
  throw new Error(`Failed to build dashboard data: ${errorMessage(error)}. State data may be corrupted — try running: daily --json`, { cause: error });
@@ -488,31 +249,36 @@ export async function startDashboardServer(options) {
488
249
  // Expose the CSRF token to the SPA on every data fetch so the client
489
250
  // can attach it on subsequent POSTs. Fresh fetch → fresh token view.
490
251
  res.setHeader('X-CSRF-Token', csrfToken);
491
- // Re-read state if modified externally (file mtime for local, Gist API for Gist mode)
492
- let stateChanged = false;
493
- if (stateManager.isGistMode()) {
494
- await flushPendingGistSync();
495
- stateChanged = await stateManager.refreshFromGist();
496
- }
497
- else {
498
- stateChanged = stateManager.reloadIfChanged();
499
- await maybeRecoverGist();
500
- // A successful recovery is a state-source change: rebuild so the
501
- // degraded banner clears without waiting for another edit.
502
- if (stateManager.isGistMode())
503
- stateChanged = true;
252
+ // Re-read state if modified externally (file mtime for local, Gist
253
+ // API for Gist mode). Shared with the POST paths via reloadState()
254
+ // so the recoveryHaltedReason un-halt lives in exactly one place —
255
+ // this GET path used to hand-duplicate the sequence minus the
256
+ // un-halt and had already drifted once (#1446 item 5). The #1443
257
+ // degraded-bootstrap recovery (and its banner-clearing stateChanged
258
+ // signal) lives inside reloadState too — do NOT add a second
259
+ // maybeRecoverGist call here or recovery would double-run per poll.
260
+ const stateChanged = await reloadState();
261
+ // Gist-mode staleness (#1446 item 2): refreshFromGist() returns
262
+ // false on BOTH "no change" and "fetch failed", so the boolean can't
263
+ // signal failure. getStateStaleness() is the API built for exactly
264
+ // this — StateManager sets the marker when in-memory state diverged
265
+ // from the canonical Gist (refresh failure, invalid payload,
266
+ // degraded bootstrap) and clears it on a successful pull.
267
+ const staleness = gistSync.stateManager.getStateStaleness();
268
+ if (staleness !== null) {
269
+ res.setHeader('X-Dashboard-Stale', '1');
270
+ res.setHeader('X-Dashboard-Stale-Reason', sanitizeHeaderValue(`state-stale: ${staleness.reason}`));
504
271
  }
505
272
  // Also rebuild when the vetted issue list file was edited outside this server (#924)
506
273
  const currentIssueListMtimeMs = getIssueListMtimeMs();
507
- const issueListChanged = currentIssueListMtimeMs !== cachedIssueListMtimeMs;
274
+ const issueListChanged = currentIssueListMtimeMs !== cache.issueListMtimeMs;
508
275
  if (stateChanged || issueListChanged) {
509
276
  try {
510
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, withPendingGistWarnings(cachedPartialFailures));
511
- cachedIssueListMtimeMs = currentIssueListMtimeMs;
277
+ cache.rebuild(gistSync.stateManager.getState(), undefined, undefined, currentIssueListMtimeMs);
512
278
  }
513
279
  catch (error) {
514
280
  warn(MODULE, `Failed to rebuild dashboard data after state reload: ${errorMessage(error)}`);
515
- // Serve previous cachedJsonData rather than returning 500.
281
+ // Serve previous cached payload rather than returning 500.
516
282
  // Signal staleness via response header so clients can detect the degraded mode (#994).
517
283
  res.setHeader('X-Dashboard-Stale', '1');
518
284
  }
@@ -521,11 +287,11 @@ export async function startDashboardServer(options) {
521
287
  // token expiry / GitHub outage produces a client-visible signal
522
288
  // rather than silent stale data. Only set the header when a failure
523
289
  // is recorded — successful refreshes clear it.
524
- if (lastBackgroundRefreshError !== null) {
290
+ if (cache.lastBackgroundRefreshError !== null) {
525
291
  res.setHeader('X-Dashboard-Stale', '1');
526
- res.setHeader('X-Dashboard-Stale-Reason', `background-refresh-failed: ${lastBackgroundRefreshError}`);
292
+ res.setHeader('X-Dashboard-Stale-Reason', sanitizeHeaderValue(`background-refresh-failed: ${cache.lastBackgroundRefreshError}`));
527
293
  }
528
- sendJson(res, 200, cachedJsonData);
294
+ sendJson(res, 200, cache.jsonData);
529
295
  return;
530
296
  }
531
297
  if (url === '/api/action' && method === 'POST') {
@@ -583,23 +349,54 @@ export async function startDashboardServer(options) {
583
349
  });
584
350
  server.requestTimeout = REQUEST_TIMEOUT_MS;
585
351
  // ── POST /api/action handler ─────────────────────────────────────────────
586
- /** Re-read state written by external processes (CLI) before mutating. */
352
+ /** Re-read state written by external processes (CLI) before mutating.
353
+ * Returns true when the state source changed (gist pull refreshed, local
354
+ * file reloaded, or a gist recovery completed) — GET /api/data uses this
355
+ * to decide whether to rebuild the cached payload (#1446 item 5). */
587
356
  async function reloadState() {
588
- if (stateManager.isGistMode()) {
589
- await flushPendingGistSync();
590
- await stateManager.refreshFromGist();
591
- }
592
- else {
593
- if (stateManager.reloadIfChanged()) {
594
- // An external config edit may BE the fix for a permanently-halted
595
- // recovery (new token scope, repaired gist id) give it one fresh
596
- // attempt cycle (#1433 pass-2).
597
- recoveryHaltedReason = null;
357
+ if (gistSync.stateManager.isGistMode()) {
358
+ await gistSync.flushPendingGistSync();
359
+ // Both post-pull steps consume the same refreshFromGist outcome, in
360
+ // this order: pullFromGist (#1443) settles the LOSS side first —
361
+ // still-pending push warnings become a loss notice at the instant the
362
+ // pull replaces state — then adoptNewerPulledDigest harvests the GAIN
363
+ // side (#1446 item 6) by reading the freshly replaced state, so it
364
+ // cannot run before the pull. Adoption stays out of pullFromGist
365
+ // because the background-refresh path also pulls but replaces
366
+ // cachedDigest unconditionally right after — adoption is a
367
+ // reloadState-path concern only.
368
+ const refreshed = await gistSync.pullFromGist();
369
+ if (refreshed)
370
+ cache.adoptNewerPulledDigest(gistSync.stateManager.getState().lastDigest);
371
+ if (gistSync.gistBootstrapDegraded()) {
372
+ // #1443: a degraded bootstrap keeps isGistMode() true while the
373
+ // store is disarmed, so refreshFromGist() short-circuits forever
374
+ // and the local-branch recovery below never sees it. Same
375
+ // throttle/halt machinery applies inside maybeRecoverGist.
376
+ await gistSync.maybeRecoverGist();
377
+ // A successful recovery replaced the singleton with state pulled
378
+ // from the Gist — report a change so callers rebuild and the
379
+ // stale-bootstrap banner clears now.
380
+ if (!gistSync.gistBootstrapDegraded())
381
+ return true;
598
382
  }
599
- // reloadIfChanged may have just pulled a persistence=gist flip made
600
- // from a terminal, and a degraded server heals here too (#1433).
601
- await maybeRecoverGist();
602
- }
383
+ return refreshed;
384
+ }
385
+ let changed = gistSync.stateManager.reloadIfChanged();
386
+ if (changed) {
387
+ // An external config edit may BE the fix for a permanently-halted
388
+ // recovery (new token scope, repaired gist id) — give it one fresh
389
+ // attempt cycle (#1433 pass-2).
390
+ gistSync.unhaltRecovery();
391
+ }
392
+ // reloadIfChanged may have just pulled a persistence=gist flip made
393
+ // from a terminal, and a degraded server heals here too (#1433).
394
+ await gistSync.maybeRecoverGist();
395
+ // A successful recovery is a state-source change: rebuild so the
396
+ // degraded banner clears without waiting for another edit.
397
+ if (gistSync.stateManager.isGistMode())
398
+ changed = true;
399
+ return changed;
603
400
  }
604
401
  async function handleAction(req, res) {
605
402
  let body;
@@ -659,11 +456,11 @@ export async function startDashboardServer(options) {
659
456
  else {
660
457
  // dismiss_issue_response
661
458
  applyMutation = async () => {
662
- stateManager.dismissIssue(body.url, new Date().toISOString());
459
+ gistSync.stateManager.dismissIssue(body.url, new Date().toISOString());
663
460
  // Mirror runMove's contract: every mutating surface checkpoints to
664
461
  // Gist and surfaces the warning. Never throws — failures come back
665
462
  // as the warning string.
666
- gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
463
+ gistSyncWarning = await maybeCheckpoint(gistSync.stateManager, MODULE);
667
464
  };
668
465
  }
669
466
  // Reload state before mutating to avoid overwriting external CLI changes.
@@ -707,19 +504,17 @@ export async function startDashboardServer(options) {
707
504
  // SPA already renders (#1417). Tracked in pendingGistSyncWarnings — NOT
708
505
  // cachedPartialFailures — because gist warnings clear on a successful
709
506
  // PUSH, while fetch failures clear on a successful pull/refresh.
710
- recordGistSyncOutcome(gistSyncWarning);
507
+ gistSync.recordGistSyncOutcome(gistSyncWarning);
711
508
  // Count mutations acknowledged while degraded (#1433): a later recovery
712
509
  // bootstraps from the existing Gist and reverts them, and the loss
713
510
  // notice needs to know whether there is anything to lose.
714
- if (gistConfiguredButLocal())
715
- degradedMutationCount++;
511
+ gistSync.recordMutationWhileDegraded();
716
512
  // Rebuild dashboard data from cached digest + updated state. Persist
717
513
  // the last-known partialFailures across action rebuilds (#1035) so the
718
514
  // SPA banner survives user interactions until the next successful
719
515
  // refresh clears it.
720
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, withPendingGistWarnings(cachedPartialFailures));
721
- cachedIssueListMtimeMs = getIssueListMtimeMs();
722
- sendJson(res, 200, cachedJsonData);
516
+ cache.rebuild(gistSync.stateManager.getState());
517
+ sendJson(res, 200, cache.jsonData);
723
518
  }
724
519
  // ── POST /api/refresh handler ────────────────────────────────────────────
725
520
  async function handleRefresh(_req, res) {
@@ -734,18 +529,13 @@ export async function startDashboardServer(options) {
734
529
  // refresh's own reloadState may RECOVER and produce a fresh notice,
735
530
  // which must survive into the rebuild below, not be wiped 10 lines
736
531
  // after its creation (#1433 pass-2).
737
- recoveryLossNotices = [];
532
+ gistSync.clearRecoveryLossNotices();
738
533
  await reloadState();
739
534
  warn(MODULE, 'Refreshing dashboard data from GitHub...');
740
535
  const result = await fetchDashboardData(currentToken);
741
- cachedDigest = result.digest;
742
- cachedCommentedIssues = result.commentedIssues;
743
- // Update the persistent banner signal — clear on a clean refresh,
744
- // set when one or more sub-fetches degraded. See #1035.
745
- cachedPartialFailures = result.partialFailures.length > 0 ? result.partialFailures : undefined;
746
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, withPendingGistWarnings(cachedPartialFailures));
747
- cachedIssueListMtimeMs = getIssueListMtimeMs();
748
- sendJson(res, 200, cachedJsonData);
536
+ cache.adoptFetchResult(result);
537
+ cache.rebuild(gistSync.stateManager.getState(), result.allMergedPRs, result.allClosedPRs);
538
+ sendJson(res, 200, cache.jsonData);
749
539
  }
750
540
  catch (error) {
751
541
  // No server-side retry here (unlike handleAction): a refresh re-run is a
@@ -785,17 +575,28 @@ export async function startDashboardServer(options) {
785
575
  sendError(res, 403, 'Forbidden');
786
576
  return;
787
577
  }
578
+ // Hashed build outputs live under /assets/ (Vite's content-addressed
579
+ // bundles). A missing file there must be a real 404, not the SPA
580
+ // fallback: serving index.html as 200 text/html makes the module loader
581
+ // reject the response, and after a plugin upgrade a cached index.html
582
+ // referencing deleted bundles would blank the dashboard (#1459).
583
+ const isAssetPath = urlPath.startsWith('/assets/');
584
+ const indexPath = path.join(resolvedAssetsDir, 'index.html');
788
585
  // If file doesn't exist or is a directory, serve index.html for SPA routing
789
586
  try {
790
587
  const stat = fs.statSync(filePath);
791
588
  if (stat.isDirectory()) {
792
- filePath = path.join(resolvedAssetsDir, 'index.html');
589
+ filePath = indexPath;
793
590
  }
794
591
  }
795
592
  catch (err) {
796
593
  const nodeErr = err;
797
594
  if (nodeErr.code === 'ENOENT') {
798
- filePath = path.join(resolvedAssetsDir, 'index.html');
595
+ if (isAssetPath) {
596
+ sendError(res, 404, 'Not found');
597
+ return;
598
+ }
599
+ filePath = indexPath;
799
600
  }
800
601
  else {
801
602
  warn(MODULE, `Failed to stat file: ${filePath}`);
@@ -805,13 +606,19 @@ export async function startDashboardServer(options) {
805
606
  }
806
607
  const ext = path.extname(filePath).toLowerCase();
807
608
  const contentType = MIME_TYPES[ext] || 'application/octet-stream';
609
+ // Honest caching (#1459): only hashed /assets/* files are safe to cache —
610
+ // their names change with their content. index.html (direct or SPA
611
+ // fallback) and other unhashed files (favicon, etc.) must revalidate so a
612
+ // plugin upgrade is picked up on the next navigation instead of serving a
613
+ // stale page that references deleted bundles for up to an hour.
614
+ const cacheControl = isAssetPath && filePath !== indexPath ? 'public, max-age=3600' : 'no-cache';
808
615
  try {
809
616
  const content = fs.readFileSync(filePath);
810
617
  setSecurityHeaders(res);
811
618
  res.writeHead(200, {
812
619
  'Content-Type': contentType,
813
620
  'Content-Length': content.length,
814
- 'Cache-Control': 'public, max-age=3600',
621
+ 'Cache-Control': cacheControl,
815
622
  });
816
623
  res.end(content);
817
624
  }
@@ -864,30 +671,30 @@ export async function startDashboardServer(options) {
864
671
  fetchDashboardData(token)
865
672
  .then(async (result) => {
866
673
  // Same clear-before-recover ordering as handleRefresh (#1433 pass-2).
867
- recoveryLossNotices = [];
868
- if (stateManager.isGistMode()) {
869
- await flushPendingGistSync();
870
- await stateManager.refreshFromGist();
674
+ gistSync.clearRecoveryLossNotices();
675
+ if (gistSync.stateManager.isGistMode()) {
676
+ await gistSync.flushPendingGistSync();
677
+ await gistSync.pullFromGist();
678
+ // Heal a degraded bootstrap from the background refresh too (#1443).
679
+ if (gistSync.gistBootstrapDegraded())
680
+ await gistSync.maybeRecoverGist();
871
681
  }
872
682
  else {
873
- stateManager.reloadIfChanged();
874
- await maybeRecoverGist();
683
+ gistSync.stateManager.reloadIfChanged();
684
+ await gistSync.maybeRecoverGist();
875
685
  }
876
- cachedDigest = result.digest;
877
- cachedCommentedIssues = result.commentedIssues;
878
- cachedPartialFailures = result.partialFailures.length > 0 ? result.partialFailures : undefined;
879
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, withPendingGistWarnings(cachedPartialFailures));
880
- cachedIssueListMtimeMs = getIssueListMtimeMs();
686
+ cache.adoptFetchResult(result);
687
+ cache.rebuild(gistSync.stateManager.getState(), result.allMergedPRs, result.allClosedPRs);
881
688
  // Successful refresh clears any prior failure signal (#1205).
882
- lastBackgroundRefreshError = null;
689
+ cache.lastBackgroundRefreshError = null;
883
690
  warn(MODULE, 'Background data refresh complete');
884
691
  return;
885
692
  })
886
693
  .catch((error) => {
887
694
  // Capture so /api/data can surface staleness via X-Dashboard-Stale
888
695
  // header — previously the catch only logged to stderr (#1205).
889
- lastBackgroundRefreshError = errorMessage(error);
890
- warn(MODULE, `Background data refresh failed (serving cached data): ${lastBackgroundRefreshError}`);
696
+ cache.lastBackgroundRefreshError = errorMessage(error);
697
+ warn(MODULE, `Background data refresh failed (serving cached data): ${cache.lastBackgroundRefreshError}`);
891
698
  });
892
699
  }
893
700
  // ── Open browser ─────────────────────────────────────────────────────────