@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.
- package/README.md +3 -3
- package/dist/cli-registry.js +59 -84
- package/dist/cli.bundle.cjs +112 -109
- package/dist/cli.js +5 -4
- package/dist/commands/comments.js +44 -10
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +50 -2
- package/dist/commands/curated-list.d.ts +17 -0
- package/dist/commands/curated-list.js +25 -0
- package/dist/commands/daily.d.ts +7 -1
- package/dist/commands/daily.js +136 -57
- package/dist/commands/dashboard-cache.d.ts +69 -0
- package/dist/commands/dashboard-cache.js +219 -0
- package/dist/commands/dashboard-data.d.ts +18 -10
- package/dist/commands/dashboard-data.js +58 -8
- package/dist/commands/dashboard-gist-sync.d.ts +93 -0
- package/dist/commands/dashboard-gist-sync.js +237 -0
- package/dist/commands/dashboard-server.d.ts +6 -10
- package/dist/commands/dashboard-server.js +181 -347
- package/dist/commands/features.js +6 -0
- package/dist/commands/guidelines.d.ts +6 -0
- package/dist/commands/guidelines.js +7 -0
- package/dist/commands/index.d.ts +2 -5
- package/dist/commands/index.js +2 -4
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +7 -1
- package/dist/commands/list-mark-done.js +6 -21
- package/dist/commands/list-move-tier.js +3 -5
- package/dist/commands/locate-issue-list.d.ts +25 -0
- package/dist/commands/locate-issue-list.js +67 -0
- package/dist/commands/merge-loop.d.ts +63 -0
- package/dist/commands/merge-loop.js +157 -0
- package/dist/commands/repo-vet.js +40 -1
- package/dist/commands/scout-bridge.d.ts +35 -2
- package/dist/commands/scout-bridge.js +65 -13
- package/dist/commands/search.d.ts +4 -6
- package/dist/commands/search.js +58 -11
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +56 -2
- package/dist/commands/skip-file-parser.d.ts +23 -0
- package/dist/commands/skip-file-parser.js +23 -10
- package/dist/commands/startup.d.ts +1 -6
- package/dist/commands/startup.js +25 -59
- package/dist/commands/track.d.ts +2 -2
- package/dist/commands/track.js +2 -2
- package/dist/commands/vet-list.d.ts +6 -6
- package/dist/commands/vet-list.js +194 -65
- package/dist/core/config-registry.js +36 -0
- package/dist/core/daily-logic.d.ts +25 -2
- package/dist/core/daily-logic.js +58 -3
- package/dist/core/gist-health.d.ts +81 -0
- package/dist/core/gist-health.js +39 -0
- package/dist/core/gist-state-store.d.ts +3 -1
- package/dist/core/gist-state-store.js +7 -2
- package/dist/core/github-stats.d.ts +2 -2
- package/dist/core/github-stats.js +20 -4
- package/dist/core/index.d.ts +5 -4
- package/dist/core/index.js +5 -4
- package/dist/core/issue-conversation.js +8 -2
- package/dist/core/issue-grading.d.ts +9 -0
- package/dist/core/issue-grading.js +9 -0
- package/dist/core/issue-verification.d.ts +39 -0
- package/dist/core/issue-verification.js +48 -0
- package/dist/core/pagination.d.ts +27 -0
- package/dist/core/pagination.js +23 -5
- package/dist/core/pr-comments-fetcher.d.ts +7 -0
- package/dist/core/pr-comments-fetcher.js +19 -8
- package/dist/core/pr-monitor.d.ts +2 -0
- package/dist/core/pr-monitor.js +26 -9
- package/dist/core/repo-score-manager.d.ts +2 -2
- package/dist/core/repo-score-manager.js +3 -3
- package/dist/core/repo-vet.d.ts +2 -2
- package/dist/core/repo-vet.js +1 -1
- package/dist/core/review-analysis.d.ts +19 -0
- package/dist/core/review-analysis.js +28 -0
- package/dist/core/state-schema.d.ts +43 -6
- package/dist/core/state-schema.js +81 -4
- package/dist/core/state.d.ts +36 -5
- package/dist/core/state.js +177 -28
- package/dist/core/strategy.js +6 -5
- package/dist/core/types.d.ts +8 -0
- package/dist/core/untrusted-content.d.ts +45 -0
- package/dist/core/untrusted-content.js +54 -0
- package/dist/formatters/json.d.ts +120 -12
- package/dist/formatters/json.js +55 -2
- package/package.json +2 -2
- package/dist/commands/shelve.d.ts +0 -45
- 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 {
|
|
13
|
-
import { errorMessage, ValidationError, ConcurrencyError,
|
|
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
|
|
17
|
-
import { openInBrowser
|
|
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 {
|
|
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
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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,44 +249,34 @@ 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
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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;
|
|
504
|
-
}
|
|
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();
|
|
505
261
|
// Also rebuild when the vetted issue list file was edited outside this server (#924)
|
|
506
262
|
const currentIssueListMtimeMs = getIssueListMtimeMs();
|
|
507
|
-
const issueListChanged = currentIssueListMtimeMs !==
|
|
263
|
+
const issueListChanged = currentIssueListMtimeMs !== cache.issueListMtimeMs;
|
|
508
264
|
if (stateChanged || issueListChanged) {
|
|
509
265
|
try {
|
|
510
|
-
|
|
511
|
-
cachedIssueListMtimeMs = currentIssueListMtimeMs;
|
|
266
|
+
cache.rebuild(gistSync.stateManager.getState(), undefined, undefined, currentIssueListMtimeMs);
|
|
512
267
|
}
|
|
513
268
|
catch (error) {
|
|
514
269
|
warn(MODULE, `Failed to rebuild dashboard data after state reload: ${errorMessage(error)}`);
|
|
515
|
-
// Serve previous
|
|
270
|
+
// Serve previous cached payload rather than returning 500.
|
|
516
271
|
// Signal staleness via response header so clients can detect the degraded mode (#994).
|
|
517
272
|
res.setHeader('X-Dashboard-Stale', '1');
|
|
518
273
|
}
|
|
519
274
|
}
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
res.setHeader('X-Dashboard-Stale', '1');
|
|
526
|
-
res.setHeader('X-Dashboard-Stale-Reason', `background-refresh-failed: ${lastBackgroundRefreshError}`);
|
|
527
|
-
}
|
|
528
|
-
sendJson(res, 200, cachedJsonData);
|
|
275
|
+
// Gist-divergence + background-refresh staleness (#1446 item 2, #1205)
|
|
276
|
+
// via the shared helper so this GET and the POST mutators emit the
|
|
277
|
+
// same headers (#1487). The rebuild-failure flag above is GET-only.
|
|
278
|
+
applyStalenessHeaders(res);
|
|
279
|
+
sendJson(res, 200, cache.jsonData);
|
|
529
280
|
return;
|
|
530
281
|
}
|
|
531
282
|
if (url === '/api/action' && method === 'POST') {
|
|
@@ -583,22 +334,83 @@ export async function startDashboardServer(options) {
|
|
|
583
334
|
});
|
|
584
335
|
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
|
585
336
|
// ── POST /api/action handler ─────────────────────────────────────────────
|
|
586
|
-
/** Re-read state written by external processes (CLI) before mutating.
|
|
337
|
+
/** Re-read state written by external processes (CLI) before mutating.
|
|
338
|
+
* Returns true when the state source changed (gist pull refreshed, local
|
|
339
|
+
* file reloaded, or a gist recovery completed) — GET /api/data uses this
|
|
340
|
+
* to decide whether to rebuild the cached payload (#1446 item 5). */
|
|
587
341
|
async function reloadState() {
|
|
588
|
-
if (stateManager.isGistMode()) {
|
|
589
|
-
await flushPendingGistSync();
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
342
|
+
if (gistSync.stateManager.isGistMode()) {
|
|
343
|
+
await gistSync.flushPendingGistSync();
|
|
344
|
+
// Both post-pull steps consume the same refreshFromGist outcome, in
|
|
345
|
+
// this order: pullFromGist (#1443) settles the LOSS side first —
|
|
346
|
+
// still-pending push warnings become a loss notice at the instant the
|
|
347
|
+
// pull replaces state — then adoptNewerPulledDigest harvests the GAIN
|
|
348
|
+
// side (#1446 item 6) by reading the freshly replaced state, so it
|
|
349
|
+
// cannot run before the pull. Adoption stays out of pullFromGist
|
|
350
|
+
// because the background-refresh path also pulls but replaces
|
|
351
|
+
// cachedDigest unconditionally right after — adoption is a
|
|
352
|
+
// reloadState-path concern only.
|
|
353
|
+
const refreshed = await gistSync.pullFromGist();
|
|
354
|
+
if (refreshed)
|
|
355
|
+
cache.adoptNewerPulledDigest(gistSync.stateManager.getState().lastDigest);
|
|
356
|
+
if (gistSync.gistBootstrapDegraded()) {
|
|
357
|
+
// #1443: a degraded bootstrap keeps isGistMode() true while the
|
|
358
|
+
// store is disarmed, so refreshFromGist() short-circuits forever
|
|
359
|
+
// and the local-branch recovery below never sees it. Same
|
|
360
|
+
// throttle/halt machinery applies inside maybeRecoverGist.
|
|
361
|
+
await gistSync.maybeRecoverGist();
|
|
362
|
+
// A successful recovery replaced the singleton with state pulled
|
|
363
|
+
// from the Gist — report a change so callers rebuild and the
|
|
364
|
+
// stale-bootstrap banner clears now.
|
|
365
|
+
if (!gistSync.gistBootstrapDegraded())
|
|
366
|
+
return true;
|
|
598
367
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
368
|
+
return refreshed;
|
|
369
|
+
}
|
|
370
|
+
let changed = gistSync.stateManager.reloadIfChanged();
|
|
371
|
+
if (changed) {
|
|
372
|
+
// An external config edit may BE the fix for a permanently-halted
|
|
373
|
+
// recovery (new token scope, repaired gist id) — give it one fresh
|
|
374
|
+
// attempt cycle (#1433 pass-2).
|
|
375
|
+
gistSync.unhaltRecovery();
|
|
376
|
+
}
|
|
377
|
+
// reloadIfChanged may have just pulled a persistence=gist flip made
|
|
378
|
+
// from a terminal, and a degraded server heals here too (#1433).
|
|
379
|
+
await gistSync.maybeRecoverGist();
|
|
380
|
+
// A successful recovery is a state-source change: rebuild so the
|
|
381
|
+
// degraded banner clears without waiting for another edit.
|
|
382
|
+
if (gistSync.stateManager.isGistMode())
|
|
383
|
+
changed = true;
|
|
384
|
+
return changed;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Emit the X-Dashboard-Stale / X-Dashboard-Stale-Reason headers from the
|
|
388
|
+
* two server-lived staleness sources so EVERY response — GET /api/data and
|
|
389
|
+
* the POST mutators — reflects the same truth (#1487).
|
|
390
|
+
*
|
|
391
|
+
* Before this the setters were GET-only, but the SPA's applyResult clears
|
|
392
|
+
* its `stale` flag on ANY successfully-applied response. A dashboard whose
|
|
393
|
+
* gist pulls keep failing while POST /api/refresh and /api/action succeed
|
|
394
|
+
* would flip back to "healthy" until the next GET (mount or CSRF re-prime).
|
|
395
|
+
* Carrying the headers on the POST paths keeps the flag honest.
|
|
396
|
+
*
|
|
397
|
+
* Reads the same two probes the GET path used inline: the StateManager's
|
|
398
|
+
* gist-divergence marker (getStateStaleness, #1446 item 2) — set on a
|
|
399
|
+
* refresh failure / invalid payload / degraded bootstrap and cleared on a
|
|
400
|
+
* successful pull — and the cache's last background-refresh error (#1205).
|
|
401
|
+
* When both are set the background-refresh reason wins, matching the prior
|
|
402
|
+
* GET ordering. The GET path's third, transient source — an inline rebuild
|
|
403
|
+
* failure (#994) — is specific to that handler and stays inline there.
|
|
404
|
+
*/
|
|
405
|
+
function applyStalenessHeaders(res) {
|
|
406
|
+
const staleness = gistSync.stateManager.getStateStaleness();
|
|
407
|
+
if (staleness !== null) {
|
|
408
|
+
res.setHeader('X-Dashboard-Stale', '1');
|
|
409
|
+
res.setHeader('X-Dashboard-Stale-Reason', sanitizeHeaderValue(`state-stale: ${staleness.reason}`));
|
|
410
|
+
}
|
|
411
|
+
if (cache.lastBackgroundRefreshError !== null) {
|
|
412
|
+
res.setHeader('X-Dashboard-Stale', '1');
|
|
413
|
+
res.setHeader('X-Dashboard-Stale-Reason', sanitizeHeaderValue(`background-refresh-failed: ${cache.lastBackgroundRefreshError}`));
|
|
602
414
|
}
|
|
603
415
|
}
|
|
604
416
|
async function handleAction(req, res) {
|
|
@@ -659,11 +471,11 @@ export async function startDashboardServer(options) {
|
|
|
659
471
|
else {
|
|
660
472
|
// dismiss_issue_response
|
|
661
473
|
applyMutation = async () => {
|
|
662
|
-
stateManager.dismissIssue(body.url, new Date().toISOString());
|
|
474
|
+
gistSync.stateManager.dismissIssue(body.url, new Date().toISOString());
|
|
663
475
|
// Mirror runMove's contract: every mutating surface checkpoints to
|
|
664
476
|
// Gist and surfaces the warning. Never throws — failures come back
|
|
665
477
|
// as the warning string.
|
|
666
|
-
gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
|
|
478
|
+
gistSyncWarning = await maybeCheckpoint(gistSync.stateManager, MODULE);
|
|
667
479
|
};
|
|
668
480
|
}
|
|
669
481
|
// Reload state before mutating to avoid overwriting external CLI changes.
|
|
@@ -707,19 +519,21 @@ export async function startDashboardServer(options) {
|
|
|
707
519
|
// SPA already renders (#1417). Tracked in pendingGistSyncWarnings — NOT
|
|
708
520
|
// cachedPartialFailures — because gist warnings clear on a successful
|
|
709
521
|
// PUSH, while fetch failures clear on a successful pull/refresh.
|
|
710
|
-
recordGistSyncOutcome(gistSyncWarning);
|
|
522
|
+
gistSync.recordGistSyncOutcome(gistSyncWarning);
|
|
711
523
|
// Count mutations acknowledged while degraded (#1433): a later recovery
|
|
712
524
|
// bootstraps from the existing Gist and reverts them, and the loss
|
|
713
525
|
// notice needs to know whether there is anything to lose.
|
|
714
|
-
|
|
715
|
-
degradedMutationCount++;
|
|
526
|
+
gistSync.recordMutationWhileDegraded();
|
|
716
527
|
// Rebuild dashboard data from cached digest + updated state. Persist
|
|
717
528
|
// the last-known partialFailures across action rebuilds (#1035) so the
|
|
718
529
|
// SPA banner survives user interactions until the next successful
|
|
719
530
|
// refresh clears it.
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
531
|
+
cache.rebuild(gistSync.stateManager.getState());
|
|
532
|
+
// Carry the same staleness signal a GET would (#1487): a mutation that
|
|
533
|
+
// succeeds locally while the gist stays unreachable must not let the SPA
|
|
534
|
+
// clear a previously-stale flag.
|
|
535
|
+
applyStalenessHeaders(res);
|
|
536
|
+
sendJson(res, 200, cache.jsonData);
|
|
723
537
|
}
|
|
724
538
|
// ── POST /api/refresh handler ────────────────────────────────────────────
|
|
725
539
|
async function handleRefresh(_req, res) {
|
|
@@ -734,18 +548,21 @@ export async function startDashboardServer(options) {
|
|
|
734
548
|
// refresh's own reloadState may RECOVER and produce a fresh notice,
|
|
735
549
|
// which must survive into the rebuild below, not be wiped 10 lines
|
|
736
550
|
// after its creation (#1433 pass-2).
|
|
737
|
-
|
|
551
|
+
gistSync.clearRecoveryLossNotices();
|
|
738
552
|
await reloadState();
|
|
739
553
|
warn(MODULE, 'Refreshing dashboard data from GitHub...');
|
|
740
554
|
const result = await fetchDashboardData(currentToken);
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
555
|
+
cache.adoptFetchResult(result);
|
|
556
|
+
cache.rebuild(gistSync.stateManager.getState(), result.allMergedPRs, result.allClosedPRs);
|
|
557
|
+
// This manual refresh just fetched GitHub successfully, so a prior
|
|
558
|
+
// background-refresh failure is no longer current (#1487) — clear it as
|
|
559
|
+
// the background success path does, then emit the staleness headers. A
|
|
560
|
+
// gist-divergence marker that survived a failed pull inside reloadState
|
|
561
|
+
// still surfaces via getStateStaleness, so the SPA stays honest even
|
|
562
|
+
// when the GitHub fetch succeeds but the gist remains unreachable.
|
|
563
|
+
cache.lastBackgroundRefreshError = null;
|
|
564
|
+
applyStalenessHeaders(res);
|
|
565
|
+
sendJson(res, 200, cache.jsonData);
|
|
749
566
|
}
|
|
750
567
|
catch (error) {
|
|
751
568
|
// No server-side retry here (unlike handleAction): a refresh re-run is a
|
|
@@ -785,17 +602,28 @@ export async function startDashboardServer(options) {
|
|
|
785
602
|
sendError(res, 403, 'Forbidden');
|
|
786
603
|
return;
|
|
787
604
|
}
|
|
605
|
+
// Hashed build outputs live under /assets/ (Vite's content-addressed
|
|
606
|
+
// bundles). A missing file there must be a real 404, not the SPA
|
|
607
|
+
// fallback: serving index.html as 200 text/html makes the module loader
|
|
608
|
+
// reject the response, and after a plugin upgrade a cached index.html
|
|
609
|
+
// referencing deleted bundles would blank the dashboard (#1459).
|
|
610
|
+
const isAssetPath = urlPath.startsWith('/assets/');
|
|
611
|
+
const indexPath = path.join(resolvedAssetsDir, 'index.html');
|
|
788
612
|
// If file doesn't exist or is a directory, serve index.html for SPA routing
|
|
789
613
|
try {
|
|
790
614
|
const stat = fs.statSync(filePath);
|
|
791
615
|
if (stat.isDirectory()) {
|
|
792
|
-
filePath =
|
|
616
|
+
filePath = indexPath;
|
|
793
617
|
}
|
|
794
618
|
}
|
|
795
619
|
catch (err) {
|
|
796
620
|
const nodeErr = err;
|
|
797
621
|
if (nodeErr.code === 'ENOENT') {
|
|
798
|
-
|
|
622
|
+
if (isAssetPath) {
|
|
623
|
+
sendError(res, 404, 'Not found');
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
filePath = indexPath;
|
|
799
627
|
}
|
|
800
628
|
else {
|
|
801
629
|
warn(MODULE, `Failed to stat file: ${filePath}`);
|
|
@@ -805,13 +633,19 @@ export async function startDashboardServer(options) {
|
|
|
805
633
|
}
|
|
806
634
|
const ext = path.extname(filePath).toLowerCase();
|
|
807
635
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
636
|
+
// Honest caching (#1459): only hashed /assets/* files are safe to cache —
|
|
637
|
+
// their names change with their content. index.html (direct or SPA
|
|
638
|
+
// fallback) and other unhashed files (favicon, etc.) must revalidate so a
|
|
639
|
+
// plugin upgrade is picked up on the next navigation instead of serving a
|
|
640
|
+
// stale page that references deleted bundles for up to an hour.
|
|
641
|
+
const cacheControl = isAssetPath && filePath !== indexPath ? 'public, max-age=3600' : 'no-cache';
|
|
808
642
|
try {
|
|
809
643
|
const content = fs.readFileSync(filePath);
|
|
810
644
|
setSecurityHeaders(res);
|
|
811
645
|
res.writeHead(200, {
|
|
812
646
|
'Content-Type': contentType,
|
|
813
647
|
'Content-Length': content.length,
|
|
814
|
-
'Cache-Control':
|
|
648
|
+
'Cache-Control': cacheControl,
|
|
815
649
|
});
|
|
816
650
|
res.end(content);
|
|
817
651
|
}
|
|
@@ -864,30 +698,30 @@ export async function startDashboardServer(options) {
|
|
|
864
698
|
fetchDashboardData(token)
|
|
865
699
|
.then(async (result) => {
|
|
866
700
|
// Same clear-before-recover ordering as handleRefresh (#1433 pass-2).
|
|
867
|
-
|
|
868
|
-
if (stateManager.isGistMode()) {
|
|
869
|
-
await flushPendingGistSync();
|
|
870
|
-
await
|
|
701
|
+
gistSync.clearRecoveryLossNotices();
|
|
702
|
+
if (gistSync.stateManager.isGistMode()) {
|
|
703
|
+
await gistSync.flushPendingGistSync();
|
|
704
|
+
await gistSync.pullFromGist();
|
|
705
|
+
// Heal a degraded bootstrap from the background refresh too (#1443).
|
|
706
|
+
if (gistSync.gistBootstrapDegraded())
|
|
707
|
+
await gistSync.maybeRecoverGist();
|
|
871
708
|
}
|
|
872
709
|
else {
|
|
873
|
-
stateManager.reloadIfChanged();
|
|
874
|
-
await maybeRecoverGist();
|
|
710
|
+
gistSync.stateManager.reloadIfChanged();
|
|
711
|
+
await gistSync.maybeRecoverGist();
|
|
875
712
|
}
|
|
876
|
-
|
|
877
|
-
|
|
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();
|
|
713
|
+
cache.adoptFetchResult(result);
|
|
714
|
+
cache.rebuild(gistSync.stateManager.getState(), result.allMergedPRs, result.allClosedPRs);
|
|
881
715
|
// Successful refresh clears any prior failure signal (#1205).
|
|
882
|
-
lastBackgroundRefreshError = null;
|
|
716
|
+
cache.lastBackgroundRefreshError = null;
|
|
883
717
|
warn(MODULE, 'Background data refresh complete');
|
|
884
718
|
return;
|
|
885
719
|
})
|
|
886
720
|
.catch((error) => {
|
|
887
721
|
// Capture so /api/data can surface staleness via X-Dashboard-Stale
|
|
888
722
|
// header — previously the catch only logged to stderr (#1205).
|
|
889
|
-
lastBackgroundRefreshError = errorMessage(error);
|
|
890
|
-
warn(MODULE, `Background data refresh failed (serving cached data): ${lastBackgroundRefreshError}`);
|
|
723
|
+
cache.lastBackgroundRefreshError = errorMessage(error);
|
|
724
|
+
warn(MODULE, `Background data refresh failed (serving cached data): ${cache.lastBackgroundRefreshError}`);
|
|
891
725
|
});
|
|
892
726
|
}
|
|
893
727
|
// ── Open browser ─────────────────────────────────────────────────────────
|