@oss-autopilot/core 3.13.4 → 3.14.1

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 (88) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-registry.js +59 -84
  3. package/dist/cli.bundle.cjs +112 -109
  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 +58 -8
  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 +181 -347
  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.d.ts +6 -6
  47. package/dist/commands/vet-list.js +194 -65
  48. package/dist/core/config-registry.js +36 -0
  49. package/dist/core/daily-logic.d.ts +25 -2
  50. package/dist/core/daily-logic.js +58 -3
  51. package/dist/core/gist-health.d.ts +81 -0
  52. package/dist/core/gist-health.js +39 -0
  53. package/dist/core/gist-state-store.d.ts +3 -1
  54. package/dist/core/gist-state-store.js +7 -2
  55. package/dist/core/github-stats.d.ts +2 -2
  56. package/dist/core/github-stats.js +20 -4
  57. package/dist/core/index.d.ts +5 -4
  58. package/dist/core/index.js +5 -4
  59. package/dist/core/issue-conversation.js +8 -2
  60. package/dist/core/issue-grading.d.ts +9 -0
  61. package/dist/core/issue-grading.js +9 -0
  62. package/dist/core/issue-verification.d.ts +39 -0
  63. package/dist/core/issue-verification.js +48 -0
  64. package/dist/core/pagination.d.ts +27 -0
  65. package/dist/core/pagination.js +23 -5
  66. package/dist/core/pr-comments-fetcher.d.ts +7 -0
  67. package/dist/core/pr-comments-fetcher.js +19 -8
  68. package/dist/core/pr-monitor.d.ts +2 -0
  69. package/dist/core/pr-monitor.js +26 -9
  70. package/dist/core/repo-score-manager.d.ts +2 -2
  71. package/dist/core/repo-score-manager.js +3 -3
  72. package/dist/core/repo-vet.d.ts +2 -2
  73. package/dist/core/repo-vet.js +1 -1
  74. package/dist/core/review-analysis.d.ts +19 -0
  75. package/dist/core/review-analysis.js +28 -0
  76. package/dist/core/state-schema.d.ts +43 -6
  77. package/dist/core/state-schema.js +81 -4
  78. package/dist/core/state.d.ts +36 -5
  79. package/dist/core/state.js +177 -28
  80. package/dist/core/strategy.js +6 -5
  81. package/dist/core/types.d.ts +8 -0
  82. package/dist/core/untrusted-content.d.ts +45 -0
  83. package/dist/core/untrusted-content.js +54 -0
  84. package/dist/formatters/json.d.ts +120 -12
  85. package/dist/formatters/json.js +55 -2
  86. package/package.json +2 -2
  87. package/dist/commands/shelve.d.ts +0 -45
  88. package/dist/commands/shelve.js +0 -54
@@ -50,10 +50,11 @@ export interface DashboardJsonData {
50
50
  lastUpdated?: string;
51
51
  /**
52
52
  * Labels of sub-fetches that degraded to empty fallbacks during this data
53
- * build. Non-empty means one or more background calls failed and the
54
- * corresponding sections of the response are approximations (stale or
55
- * zero'd) rather than authoritative. The SPA surfaces this as a banner
56
- * so users know the dashboard is showing partial data. See #1035.
53
+ * build, plus persistence steps that failed to save (#1447). Non-empty
54
+ * means one or more background calls failed and the corresponding sections
55
+ * of the response are approximations (stale or zero'd) rather than
56
+ * authoritative. The SPA surfaces this as a banner so users know the
57
+ * dashboard is showing partial data. See #1035.
57
58
  */
58
59
  partialFailures?: string[];
59
60
  }
@@ -103,10 +104,15 @@ export declare function mergeMonthlyCounts(existing: Record<string, number>, fre
103
104
  * Each metric is isolated so partial failures don't produce inconsistent state.
104
105
  * Fresh API results are merged into existing data so historical months are preserved.
105
106
  * Skips updating when fresh data is empty to avoid wiping chart data on transient API failures.
107
+ *
108
+ * Returns the labels of any metrics that failed to persist (#1447) so callers
109
+ * with a partialFailures surface (fetchDashboardData) can push them into the
110
+ * SPA banner instead of the failure living only in stderr. Callers without
111
+ * that surface (daily.ts) may ignore the return value.
106
112
  */
107
113
  export declare function updateMonthlyAnalytics(prs: Array<{
108
114
  createdAt?: string;
109
- }>, monthlyCounts: Record<string, number>, monthlyClosedCounts: Record<string, number>, openedFromMerged: Record<string, number>, openedFromClosed: Record<string, number>): void;
115
+ }>, monthlyCounts: Record<string, number>, monthlyClosedCounts: Record<string, number>, openedFromMerged: Record<string, number>, openedFromClosed: Record<string, number>): string[];
110
116
  export interface DashboardFetchResult {
111
117
  digest: DailyDigest;
112
118
  commentedIssues: CommentedIssue[];
@@ -114,11 +120,13 @@ export interface DashboardFetchResult {
114
120
  allClosedPRs: ClosedPR[];
115
121
  /**
116
122
  * Labels of non-critical sub-fetches that degraded to empty fallbacks
117
- * during this run. Empty array means every fetch succeeded. Non-empty
118
- * means one or more slices of the returned data are approximations —
119
- * callers surface this to the user so "0 recently merged" does not look
120
- * authoritative when it is actually "fetch failed, fell back to empty".
121
- * See #1035.
123
+ * during this run, plus persistence steps that failed to save (#1447:
124
+ * monthly chart analytics, stored merged/closed PRs, the cached digest).
125
+ * Empty array means every fetch and persist succeeded. Non-empty means
126
+ * one or more slices of the returned data are approximations — callers
127
+ * surface this to the user so "0 recently merged" does not look
128
+ * authoritative when it is actually "fetch failed, fell back to empty",
129
+ * and so silently-stale charts/digest are flagged. See #1035.
122
130
  */
123
131
  partialFailures: string[];
124
132
  }
@@ -9,7 +9,7 @@ import { warn } from '../core/logger.js';
9
9
  import { emptyPRCountsResult, fetchMergedPRsSince, fetchClosedPRsSince } from '../core/github-stats.js';
10
10
  import { parseGitHubUrl } from '../core/urls.js';
11
11
  import { isBelowMinStars, } from '../core/types.js';
12
- import { toShelvedPRRef, buildStarFilter } from '../core/index.js';
12
+ import { toShelvedPRRef, buildStarFilter, firstMaintainerResponseFromDigest } from '../core/index.js';
13
13
  const MODULE = 'dashboard-data';
14
14
  export function buildDashboardStats(digest, state, storedMergedCount, storedClosedCount) {
15
15
  const summary = digest.summary || {
@@ -117,16 +117,23 @@ export function mergeMonthlyCounts(existing, fresh) {
117
117
  * Each metric is isolated so partial failures don't produce inconsistent state.
118
118
  * Fresh API results are merged into existing data so historical months are preserved.
119
119
  * Skips updating when fresh data is empty to avoid wiping chart data on transient API failures.
120
+ *
121
+ * Returns the labels of any metrics that failed to persist (#1447) so callers
122
+ * with a partialFailures surface (fetchDashboardData) can push them into the
123
+ * SPA banner instead of the failure living only in stderr. Callers without
124
+ * that surface (daily.ts) may ignore the return value.
120
125
  */
121
126
  export function updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed) {
122
127
  const stateManager = getStateManager();
123
128
  const state = stateManager.getState();
129
+ const failures = [];
124
130
  try {
125
131
  if (Object.keys(monthlyCounts).length > 0) {
126
132
  stateManager.setMonthlyMergedCounts(mergeMonthlyCounts(state.monthlyMergedCounts || {}, monthlyCounts));
127
133
  }
128
134
  }
129
135
  catch (error) {
136
+ failures.push('store monthly merged counts');
130
137
  warn(MODULE, `Failed to store monthly merged counts: ${errorMessage(error)}`);
131
138
  }
132
139
  try {
@@ -135,6 +142,7 @@ export function updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts,
135
142
  }
136
143
  }
137
144
  catch (error) {
145
+ failures.push('store monthly closed counts');
138
146
  warn(MODULE, `Failed to store monthly closed counts: ${errorMessage(error)}`);
139
147
  }
140
148
  try {
@@ -153,8 +161,10 @@ export function updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts,
153
161
  }
154
162
  }
155
163
  catch (error) {
164
+ failures.push('store monthly opened counts');
156
165
  warn(MODULE, `Failed to store monthly opened counts: ${errorMessage(error)}`);
157
166
  }
167
+ return failures;
158
168
  }
159
169
  /**
160
170
  * Fetch fresh dashboard data from GitHub.
@@ -172,6 +182,28 @@ export async function fetchDashboardData(token) {
172
182
  // Get watermarks for incremental PR fetch
173
183
  const watermark = stateManager.getMergedPRWatermark();
174
184
  const closedWatermark = stateManager.getClosedPRWatermark();
185
+ // Self-heal a ledger stranded behind a recent watermark (#1504). The
186
+ // incremental fetch only asks for merges newer than the newest stored one,
187
+ // so once the recent-merge feed (daily's recentlyMergedPRs) seeds the ledger
188
+ // before any full historical fetch ran, the older history can never be
189
+ // recovered. When the stored detail count is materially below the known
190
+ // all-time merged count, drop the watermark for a one-shot full backfill
191
+ // (bounded by the existing pagination cap); once the ledger catches up the
192
+ // watermark resumes incremental fetching. Closed PRs are not seeded ahead of
193
+ // their backfill in practice, so only merged needs the guard.
194
+ const storedMergedCount = stateManager.getMergedPRs().length;
195
+ const knownMergedAllTime = stateManager.getState().lastDigest?.summary?.totalMergedAllTime ?? 0;
196
+ // A full backfill can only ever return up to fetchMergedPRsSince's pagination
197
+ // cap (MAX_PAGINATION_PAGES * 100 = 300). Stop forcing one once the ledger has
198
+ // reached that ceiling, so a contributor whose star-filtered all-time count
199
+ // legitimately exceeds 300 doesn't trigger a full refetch on every poll.
200
+ const MERGED_BACKFILL_CEILING = 300;
201
+ const mergedNeedsBackfill = storedMergedCount < knownMergedAllTime && storedMergedCount < MERGED_BACKFILL_CEILING;
202
+ const mergedFetchSince = mergedNeedsBackfill ? undefined : watermark;
203
+ if (mergedNeedsBackfill) {
204
+ warn(MODULE, `Merged-PR ledger has ${storedMergedCount} of ~${knownMergedAllTime} known merges; ` +
205
+ 'running a full backfill (watermark dropped) to recover history (#1504).');
206
+ }
175
207
  // Track which non-critical sub-fetches degraded to fallbacks so the SPA
176
208
  // can surface a "partial data" banner instead of silently showing zeros.
177
209
  // Rate-limit / auth errors still rethrow via isRateLimitOrAuthError —
@@ -213,7 +245,7 @@ export async function fetchDashboardData(token) {
213
245
  failures: [{ issueUrl: 'N/A', error: `Issue conversation fetch failed: ${msg}` }],
214
246
  };
215
247
  }),
216
- fetchMergedPRsSince(octokit, config, watermark).catch(trackingCatch('fetch merged PRs for storage', [])),
248
+ fetchMergedPRsSince(octokit, config, mergedFetchSince).catch(trackingCatch('fetch merged PRs for storage', [])),
217
249
  fetchClosedPRsSince(octokit, config, closedWatermark).catch(trackingCatch('fetch closed PRs for storage', [])),
218
250
  ]);
219
251
  const commentedIssues = fetchedIssues.issues;
@@ -242,25 +274,42 @@ export async function fetchDashboardData(token) {
242
274
  // partition here than on the CLI surface (#1416). Inside the batch
243
275
  // because getStatusOverride may auto-clear stale overrides with a save,
244
276
  // which must defer to this guarded boundary rather than throw here.
245
- const overriddenPRs = applyStatusOverrides(prs, stateManager.getState());
246
- // Store new merged PRs incrementally (dedupes by URL)
277
+ // Per-PR override failures (#1448) land in partialFailures so the SPA
278
+ // banner flags PRs silently showing their un-overridden status.
279
+ const overriddenPRs = applyStatusOverrides(prs, stateManager.getState(), partialFailures);
280
+ // Previous run's digest — read BEFORE setLastDigest below replaces it.
281
+ // Supplies best-effort firstMaintainerResponseAt for PRs that were
282
+ // enriched while open, mirroring daily's Phase 3 (#1461). openedAt is
283
+ // already on the entries (created_at from the search result).
284
+ const previousDigest = stateManager.getState().lastDigest;
285
+ // Store new merged PRs incrementally (dedupes by URL; re-seen entries
286
+ // upgrade missing ledger fields in place)
247
287
  try {
248
- const { dropped } = stateManager.addMergedPRs(newMergedPRs);
288
+ const { dropped } = stateManager.addMergedPRs(newMergedPRs.map((pr) => ({
289
+ ...pr,
290
+ firstMaintainerResponseAt: pr.firstMaintainerResponseAt ?? firstMaintainerResponseFromDigest(previousDigest, pr.url),
291
+ })));
249
292
  if (dropped > 0) {
250
293
  partialFailures.push(`Dropped ${dropped} merged PR(s) with invalid URLs before persistence`);
251
294
  }
252
295
  }
253
296
  catch (error) {
297
+ partialFailures.push('store merged PRs');
254
298
  warn(MODULE, `Failed to store merged PRs: ${errorMessage(error)}`);
255
299
  }
256
- // Store new closed PRs incrementally (dedupes by URL)
300
+ // Store new closed PRs incrementally (dedupes by URL; re-seen entries
301
+ // upgrade missing ledger fields in place)
257
302
  try {
258
- const { dropped } = stateManager.addClosedPRs(newClosedPRs);
303
+ const { dropped } = stateManager.addClosedPRs(newClosedPRs.map((pr) => ({
304
+ ...pr,
305
+ firstMaintainerResponseAt: pr.firstMaintainerResponseAt ?? firstMaintainerResponseFromDigest(previousDigest, pr.url),
306
+ })));
259
307
  if (dropped > 0) {
260
308
  partialFailures.push(`Dropped ${dropped} closed PR(s) with invalid URLs before persistence`);
261
309
  }
262
310
  }
263
311
  catch (error) {
312
+ partialFailures.push('store closed PRs');
264
313
  warn(MODULE, `Failed to store closed PRs: ${errorMessage(error)}`);
265
314
  }
266
315
  // Store monthly chart data (opened/merged/closed) so charts have data.
@@ -268,7 +317,7 @@ export async function fetchDashboardData(token) {
268
317
  // never the createdAt/mergedAt dates the monthly counts key off.
269
318
  const { monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
270
319
  const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
271
- updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
320
+ partialFailures.push(...updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed));
272
321
  // The digest keeps RAW statuses in openPRs: this digest becomes the
273
322
  // server's cached/persisted rebuild source, and baking overridden
274
323
  // statuses into it would make CLEARING an override (move target=auto) a
@@ -294,6 +343,7 @@ export async function fetchDashboardData(token) {
294
343
  });
295
344
  }
296
345
  catch (error) {
346
+ partialFailures.push('persist dashboard state');
297
347
  warn(MODULE, `Failed to persist dashboard state: ${errorMessage(error)}`);
298
348
  }
299
349
  warn(MODULE, `Refreshed: ${prs.length} PRs fetched`);
@@ -0,0 +1,93 @@
1
+ /**
2
+ * GistSyncCoordinator — the dashboard server's gist-sync lifecycle state machine.
3
+ *
4
+ * Extracted from dashboard-server.ts (#1457). Owns the five pieces of gist
5
+ * lifecycle state (pending push warnings, loss notices, degraded mutation
6
+ * count, recovery halt reason, recovery throttle stamp) plus the live
7
+ * StateManager reference, which a degraded recovery replaces (#1433). The
8
+ * HTTP handlers consume it as a collaborator; nothing here touches req/res.
9
+ */
10
+ import { type StateManager, type GistHealth } from '../core/index.js';
11
+ export declare class GistSyncCoordinator {
12
+ /** Token from server options; falls back to getGitHubToken() per recovery attempt. */
13
+ private readonly token;
14
+ /** Logger module tag (kept identical to the server's so log output is unchanged). */
15
+ private readonly module;
16
+ private manager;
17
+ private pendingGistSyncWarnings;
18
+ private lastRecoveryAttemptAt;
19
+ private recoveryHaltedReason;
20
+ private degradedMutationCount;
21
+ private recoveryLossNotices;
22
+ constructor(
23
+ /** Token from server options; falls back to getGitHubToken() per recovery attempt. */
24
+ token: string | null,
25
+ /** Logger module tag (kept identical to the server's so log output is unchanged). */
26
+ module: string);
27
+ /** The live state manager. Always read through this accessor — a degraded
28
+ * gist recovery replaces the core singleton mid-flight (#1433). */
29
+ get stateManager(): StateManager;
30
+ /** Record a mutation's checkpoint outcome. `null` means the push succeeded
31
+ * (or local mode) — and a successful push carries the FULL current state,
32
+ * so any previously pending warning is resolved with it. */
33
+ recordGistSyncOutcome(warning: string | null): void;
34
+ /** Merge pending gist-sync warnings into a partialFailures payload for the
35
+ * SPA banner without coupling their lifecycles. */
36
+ withPendingGistWarnings(failures: string[] | undefined): string[] | undefined;
37
+ /** Push-before-pull (#1417): an un-pushed mutation would be silently
38
+ * reverted by the next Gist pull. Retry the checkpoint first so a recovered
39
+ * network turns the pending warning into a real push before any pull runs.
40
+ * No-op when nothing is pending. */
41
+ flushPendingGistSync(): Promise<void>;
42
+ /** One health snapshot from the one source (#1444). Defensive: this is an
43
+ * advisory probe that runs on EVERY request path (rebuilds, recovery
44
+ * gates). A state failure has its own handling wherever state is actually
45
+ * consumed — the probe must not become a new crash surface in front of it
46
+ * (#994's stale-serving path). */
47
+ probeGistHealth(): GistHealth | null;
48
+ /** True while the config asks for gist but this process's manager is
49
+ * local-only (#1433) — the degraded window in which every dashboard
50
+ * mutation is acknowledged and then clobbered by the next pull. */
51
+ gistConfiguredButLocal(): boolean;
52
+ /** Gist-backed but the bootstrap itself fell back to the local cache —
53
+ * reads may be stale even though isGistMode() is true (#1433 review). */
54
+ gistBootstrapDegraded(): boolean;
55
+ /** A successful PULL (refresh, or a recovery re-bootstrap) wholesale-
56
+ * replaces in-memory state. If push warnings are still pending at that
57
+ * moment, the mutations they describe are gone from the working state and
58
+ * a LATER push can never carry them — so an unrelated future push must not
59
+ * be allowed to "resolve" them (#1443). Convert the prospective warnings
60
+ * into a retrospective loss notice. Lifecycle invariants from #1417/#1433
61
+ * are unchanged: pending warnings still clear only via recordGistSyncOutcome
62
+ * on a successful push, and loss notices still clear at the start of the
63
+ * next refresh cycle. */
64
+ convertPendingWarningsToLossNotices(): void;
65
+ /** Pull from the Gist, converting still-pending push warnings into a loss
66
+ * notice when the pull actually replaced state (#1443). All dashboard pull
67
+ * sites go through here; callers flush pending pushes first (#1417), so a
68
+ * warning that is still pending at pull time means the flush failed. */
69
+ pullFromGist(): Promise<boolean>;
70
+ /** Re-attempt gist init while degraded (#1433). The serve process used to
71
+ * bootstrap exactly once at CLI preAction — with its stderr discarded by
72
+ * the detached spawn — and never retry, so one blip at startup meant
73
+ * local-only writes for the server's lifetime. Never throws. Transient
74
+ * failures retry no more often than RECOVERY_RETRY_INTERVAL_MS; permanent
75
+ * (ConfigurationError-class) failures halt retries and surface the reason
76
+ * in the banner.
77
+ *
78
+ * Covers BOTH degraded shapes (#1443): a local-only manager under a gist
79
+ * config, and a gist-backed manager whose bootstrap fell back to the local
80
+ * cache (disarmed store — refreshFromGist can never heal it on its own). */
81
+ maybeRecoverGist(): Promise<void>;
82
+ /** An external config edit may BE the fix for a permanently-halted
83
+ * recovery (new token scope, repaired gist id) — give it one fresh
84
+ * attempt cycle (#1433 pass-2). */
85
+ unhaltRecovery(): void;
86
+ /** Count mutations acknowledged while degraded (#1433): a later recovery
87
+ * bootstraps from the existing Gist and reverts them, and the loss
88
+ * notice needs to know whether there is anything to lose. */
89
+ recordMutationWhileDegraded(): void;
90
+ /** Retire pre-existing loss notices — by the time a refresh cycle starts
91
+ * they have been visible across the degraded window (#1433 pass-2). */
92
+ clearRecoveryLossNotices(): void;
93
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * GistSyncCoordinator — the dashboard server's gist-sync lifecycle state machine.
3
+ *
4
+ * Extracted from dashboard-server.ts (#1457). Owns the five pieces of gist
5
+ * lifecycle state (pending push warnings, loss notices, degraded mutation
6
+ * count, recovery halt reason, recovery throttle stamp) plus the live
7
+ * StateManager reference, which a degraded recovery replaces (#1433). The
8
+ * HTTP handlers consume it as a collaborator; nothing here touches req/res.
9
+ */
10
+ import { getStateManager, getGitHubToken, maybeCheckpoint, ensureGistPersistence, } from '../core/index.js';
11
+ import { errorMessage, ConfigurationError } from '../core/errors.js';
12
+ import { warn } from '../core/logger.js';
13
+ const GIST_DEGRADED_WARNING = 'Gist persistence is configured but this dashboard process is running LOCAL-ONLY; ' +
14
+ 'changes made here will NOT sync and may be overwritten by the next successful Gist read. ' +
15
+ 'Recovery is retried automatically.';
16
+ // #1443: a degraded bootstrap disarms the store, so "the next successful
17
+ // Gist read" can never arrive on its own — maybeRecoverGist re-bootstraps
18
+ // (the gate below treats this state as recoverable), which is the retry
19
+ // this banner now promises.
20
+ const GIST_STALE_BOOTSTRAP_WARNING = 'Gist persistence is active but the last Gist read fell back to the local cache; ' +
21
+ 'data shown may be stale. Recovery is retried automatically.';
22
+ // Recovery throttling + halt (#1433 review): a PERMANENT failure (token
23
+ // lacks gist scope, corrupt Gist) must not turn every dashboard poll into
24
+ // a doomed GitHub round trip for the life of the server, and its root
25
+ // cause must reach the banner — the detached spawn discards stderr.
26
+ const RECOVERY_RETRY_INTERVAL_MS = 30_000;
27
+ export class GistSyncCoordinator {
28
+ token;
29
+ module;
30
+ // Mutable (#1433): a degraded gist recovery replaces the core singleton, and
31
+ // this long-lived server must re-resolve its reference or every handler
32
+ // keeps using the orphaned local manager.
33
+ manager;
34
+ // Gist checkpoint warnings from dashboard mutations (#1417). DELIBERATELY
35
+ // separate from cachedPartialFailures: fetch failures clear on a successful
36
+ // PULL, but a gist-sync warning means an un-pushed mutation, and a pull is
37
+ // exactly the event that can destroy it (refreshFromGist wholesale-replaces
38
+ // state). These clear only when a checkpoint PUSH succeeds.
39
+ pendingGistSyncWarnings = [];
40
+ lastRecoveryAttemptAt = 0;
41
+ recoveryHaltedReason = null;
42
+ // Mutations acknowledged while degraded (#1433 review): a successful
43
+ // recovery bootstraps FROM the existing Gist, which reverts them — the
44
+ // user must get a retrospective notice, not just the prospective banner
45
+ // that clears at the exact moment of the loss. Cleared on a successful
46
+ // full refresh (by then the user has seen the notice across the window).
47
+ degradedMutationCount = 0;
48
+ recoveryLossNotices = [];
49
+ constructor(
50
+ /** Token from server options; falls back to getGitHubToken() per recovery attempt. */
51
+ token,
52
+ /** Logger module tag (kept identical to the server's so log output is unchanged). */
53
+ module) {
54
+ this.token = token;
55
+ this.module = module;
56
+ this.manager = getStateManager();
57
+ }
58
+ /** The live state manager. Always read through this accessor — a degraded
59
+ * gist recovery replaces the core singleton mid-flight (#1433). */
60
+ get stateManager() {
61
+ return this.manager;
62
+ }
63
+ /** Record a mutation's checkpoint outcome. `null` means the push succeeded
64
+ * (or local mode) — and a successful push carries the FULL current state,
65
+ * so any previously pending warning is resolved with it. */
66
+ recordGistSyncOutcome(warning) {
67
+ if (warning === null) {
68
+ this.pendingGistSyncWarnings = [];
69
+ return;
70
+ }
71
+ if (!this.pendingGistSyncWarnings.includes(warning)) {
72
+ this.pendingGistSyncWarnings.push(warning);
73
+ }
74
+ }
75
+ /** Merge pending gist-sync warnings into a partialFailures payload for the
76
+ * SPA banner without coupling their lifecycles. */
77
+ withPendingGistWarnings(failures) {
78
+ const extras = [...this.pendingGistSyncWarnings, ...this.recoveryLossNotices];
79
+ if (this.gistConfiguredButLocal()) {
80
+ extras.push(this.recoveryHaltedReason === null
81
+ ? GIST_DEGRADED_WARNING
82
+ : `Gist persistence is configured but recovery FAILED permanently: ${this.recoveryHaltedReason} — ` +
83
+ 'fix the Gist setup (check the token gist scope, or run state-show), then restart the dashboard.');
84
+ }
85
+ else if (this.gistBootstrapDegraded()) {
86
+ // Same halt surfacing as the local-only branch (#1443): a permanent
87
+ // recovery failure must reach the banner here too, or the stale-data
88
+ // retry promise in GIST_STALE_BOOTSTRAP_WARNING would dangle forever
89
+ // with no visible reason.
90
+ extras.push(this.recoveryHaltedReason === null
91
+ ? GIST_STALE_BOOTSTRAP_WARNING
92
+ : `Gist persistence is degraded and recovery FAILED permanently: ${this.recoveryHaltedReason} — ` +
93
+ 'fix the Gist setup (check the token gist scope, or run state-show), then restart the dashboard.');
94
+ }
95
+ if (extras.length === 0)
96
+ return failures;
97
+ const base = failures ?? [];
98
+ return [...base, ...extras.filter((w) => !base.includes(w))];
99
+ }
100
+ /** Push-before-pull (#1417): an un-pushed mutation would be silently
101
+ * reverted by the next Gist pull. Retry the checkpoint first so a recovered
102
+ * network turns the pending warning into a real push before any pull runs.
103
+ * No-op when nothing is pending. */
104
+ async flushPendingGistSync() {
105
+ if (this.pendingGistSyncWarnings.length === 0)
106
+ return;
107
+ this.recordGistSyncOutcome(await maybeCheckpoint(this.manager, this.module));
108
+ }
109
+ /** One health snapshot from the one source (#1444). Defensive: this is an
110
+ * advisory probe that runs on EVERY request path (rebuilds, recovery
111
+ * gates). A state failure has its own handling wherever state is actually
112
+ * consumed — the probe must not become a new crash surface in front of it
113
+ * (#994's stale-serving path). */
114
+ probeGistHealth() {
115
+ try {
116
+ return this.manager.getGistHealth();
117
+ }
118
+ catch (err) {
119
+ warn(this.module, `Degraded-gist probe failed (treating as not degraded): ${errorMessage(err)}`);
120
+ return null;
121
+ }
122
+ }
123
+ /** True while the config asks for gist but this process's manager is
124
+ * local-only (#1433) — the degraded window in which every dashboard
125
+ * mutation is acknowledged and then clobbered by the next pull. */
126
+ gistConfiguredButLocal() {
127
+ const health = this.probeGistHealth();
128
+ return health !== null && health.mode === 'local' && health.degraded !== null;
129
+ }
130
+ /** Gist-backed but the bootstrap itself fell back to the local cache —
131
+ * reads may be stale even though isGistMode() is true (#1433 review). */
132
+ gistBootstrapDegraded() {
133
+ const health = this.probeGistHealth();
134
+ return health !== null && health.mode === 'gist' && health.degraded !== null;
135
+ }
136
+ /** A successful PULL (refresh, or a recovery re-bootstrap) wholesale-
137
+ * replaces in-memory state. If push warnings are still pending at that
138
+ * moment, the mutations they describe are gone from the working state and
139
+ * a LATER push can never carry them — so an unrelated future push must not
140
+ * be allowed to "resolve" them (#1443). Convert the prospective warnings
141
+ * into a retrospective loss notice. Lifecycle invariants from #1417/#1433
142
+ * are unchanged: pending warnings still clear only via recordGistSyncOutcome
143
+ * on a successful push, and loss notices still clear at the start of the
144
+ * next refresh cycle. */
145
+ convertPendingWarningsToLossNotices() {
146
+ if (this.pendingGistSyncWarnings.length === 0)
147
+ return;
148
+ this.recoveryLossNotices.push(`A Gist read replaced local state while ${this.pendingGistSyncWarnings.length} change(s) were still un-pushed — ` +
149
+ 'they were NOT synced to the Gist and may have been reverted in this view. Re-apply anything missing.');
150
+ this.pendingGistSyncWarnings = [];
151
+ }
152
+ /** Pull from the Gist, converting still-pending push warnings into a loss
153
+ * notice when the pull actually replaced state (#1443). All dashboard pull
154
+ * sites go through here; callers flush pending pushes first (#1417), so a
155
+ * warning that is still pending at pull time means the flush failed. */
156
+ async pullFromGist() {
157
+ const refreshed = await this.manager.refreshFromGist();
158
+ if (refreshed)
159
+ this.convertPendingWarningsToLossNotices();
160
+ return refreshed;
161
+ }
162
+ /** Re-attempt gist init while degraded (#1433). The serve process used to
163
+ * bootstrap exactly once at CLI preAction — with its stderr discarded by
164
+ * the detached spawn — and never retry, so one blip at startup meant
165
+ * local-only writes for the server's lifetime. Never throws. Transient
166
+ * failures retry no more often than RECOVERY_RETRY_INTERVAL_MS; permanent
167
+ * (ConfigurationError-class) failures halt retries and surface the reason
168
+ * in the banner.
169
+ *
170
+ * Covers BOTH degraded shapes (#1443): a local-only manager under a gist
171
+ * config, and a gist-backed manager whose bootstrap fell back to the local
172
+ * cache (disarmed store — refreshFromGist can never heal it on its own). */
173
+ async maybeRecoverGist() {
174
+ // One probe covers both degraded shapes — health.degraded is non-null
175
+ // for configured-but-local AND bootstrap-degraded (#1444).
176
+ if (this.probeGistHealth()?.degraded == null)
177
+ return;
178
+ if (this.recoveryHaltedReason !== null)
179
+ return;
180
+ const now = Date.now();
181
+ if (now - this.lastRecoveryAttemptAt < RECOVERY_RETRY_INTERVAL_MS)
182
+ return;
183
+ const currentToken = this.token || getGitHubToken();
184
+ // A token-less probe is free (the auth cache answers instantly) and must
185
+ // not burn the retry window — stamp only when a real attempt starts.
186
+ if (!currentToken)
187
+ return;
188
+ this.lastRecoveryAttemptAt = now;
189
+ try {
190
+ await ensureGistPersistence(currentToken);
191
+ // The upgrade replaced the core singleton — re-resolve our reference.
192
+ this.manager = getStateManager();
193
+ // Genuinely armed only: a retry can resolve via ANOTHER degraded
194
+ // bootstrap (gist-backed, store disarmed) — that is not a recovery
195
+ // and must not produce a recovery notice (#1443).
196
+ const health = this.manager.getGistHealth();
197
+ const recovered = health.mode === 'gist' && health.degraded === null;
198
+ if (recovered) {
199
+ // The re-bootstrap pulled the existing Gist: un-pushed mutations
200
+ // from the degraded window are not in the replacement state.
201
+ this.convertPendingWarningsToLossNotices();
202
+ }
203
+ if (recovered && this.degradedMutationCount > 0) {
204
+ this.recoveryLossNotices.push(`Gist persistence recovered, but ${this.degradedMutationCount} change(s) made while degraded were ` +
205
+ 'saved locally only and were NOT merged into the Gist — they may have been reverted in this view. ' +
206
+ 'Re-apply anything missing.');
207
+ this.degradedMutationCount = 0;
208
+ }
209
+ }
210
+ catch (err) {
211
+ // ConfigurationError-class failures are permanent until the user acts
212
+ // (#1202 semantics) — stop hammering GitHub and say WHY in the banner.
213
+ if (err instanceof ConfigurationError) {
214
+ this.recoveryHaltedReason = errorMessage(err);
215
+ }
216
+ warn(this.module, `Gist recovery attempt failed: ${errorMessage(err)}`);
217
+ }
218
+ }
219
+ /** An external config edit may BE the fix for a permanently-halted
220
+ * recovery (new token scope, repaired gist id) — give it one fresh
221
+ * attempt cycle (#1433 pass-2). */
222
+ unhaltRecovery() {
223
+ this.recoveryHaltedReason = null;
224
+ }
225
+ /** Count mutations acknowledged while degraded (#1433): a later recovery
226
+ * bootstraps from the existing Gist and reverts them, and the loss
227
+ * notice needs to know whether there is anything to lose. */
228
+ recordMutationWhileDegraded() {
229
+ if (this.gistConfiguredButLocal())
230
+ this.degradedMutationCount++;
231
+ }
232
+ /** Retire pre-existing loss notices — by the time a refresh cycle starts
233
+ * they have been visible across the degraded window (#1433 pass-2). */
234
+ clearRecoveryLossNotices() {
235
+ this.recoveryLossNotices = [];
236
+ }
237
+ }
@@ -4,9 +4,13 @@
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
- import { type DashboardJsonData } from './dashboard-data.js';
9
- import { type DailyDigest, type AgentState, type CommentedIssue, type MergedPR, type ClosedPR } from '../core/types.js';
13
+ export { buildDashboardJson } from './dashboard-cache.js';
10
14
  export { getDashboardPidPath, writeDashboardServerInfo, readDashboardServerInfo, removeDashboardServerInfo, isDashboardServerRunning, findRunningDashboardServer, type DashboardServerInfo, } from './dashboard-process.js';
11
15
  export interface DashboardServerOptions {
12
16
  port: number;
@@ -14,12 +18,4 @@ export interface DashboardServerOptions {
14
18
  token: string | null;
15
19
  open: boolean;
16
20
  }
17
- /**
18
- * Build the JSON payload that the SPA expects from GET /api/data.
19
- *
20
- * Exported for unit testing of response-shape concerns that the full
21
- * handler harness can't reach (it bakes a stale cachedDigest at server
22
- * start-up, so tests that need a specific digest should call this directly).
23
- */
24
- export declare function buildDashboardJson(digest: DailyDigest, state: Readonly<AgentState>, commentedIssues: CommentedIssue[], allMergedPRs?: MergedPR[], allClosedPRs?: ClosedPR[], partialFailures?: string[]): DashboardJsonData;
25
21
  export declare function startDashboardServer(options: DashboardServerOptions): Promise<void>;