@oss-autopilot/core 3.13.3 → 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.
- package/README.md +3 -3
- package/dist/cli-registry.js +50 -83
- package/dist/cli.bundle.cjs +110 -107
- package/dist/cli.js +17 -3
- 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 +35 -7
- 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 +155 -222
- 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.js +4 -0
- 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 +4 -3
- package/dist/core/index.js +4 -3
- 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/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 +89 -6
- package/dist/formatters/json.js +65 -1
- package/package.json +2 -2
- package/dist/commands/shelve.d.ts +0 -45
- 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
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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>):
|
|
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
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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.
|
|
@@ -242,25 +252,42 @@ export async function fetchDashboardData(token) {
|
|
|
242
252
|
// partition here than on the CLI surface (#1416). Inside the batch
|
|
243
253
|
// because getStatusOverride may auto-clear stale overrides with a save,
|
|
244
254
|
// which must defer to this guarded boundary rather than throw here.
|
|
245
|
-
|
|
246
|
-
//
|
|
255
|
+
// Per-PR override failures (#1448) land in partialFailures so the SPA
|
|
256
|
+
// banner flags PRs silently showing their un-overridden status.
|
|
257
|
+
const overriddenPRs = applyStatusOverrides(prs, stateManager.getState(), partialFailures);
|
|
258
|
+
// Previous run's digest — read BEFORE setLastDigest below replaces it.
|
|
259
|
+
// Supplies best-effort firstMaintainerResponseAt for PRs that were
|
|
260
|
+
// enriched while open, mirroring daily's Phase 3 (#1461). openedAt is
|
|
261
|
+
// already on the entries (created_at from the search result).
|
|
262
|
+
const previousDigest = stateManager.getState().lastDigest;
|
|
263
|
+
// Store new merged PRs incrementally (dedupes by URL; re-seen entries
|
|
264
|
+
// upgrade missing ledger fields in place)
|
|
247
265
|
try {
|
|
248
|
-
const { dropped } = stateManager.addMergedPRs(newMergedPRs)
|
|
266
|
+
const { dropped } = stateManager.addMergedPRs(newMergedPRs.map((pr) => ({
|
|
267
|
+
...pr,
|
|
268
|
+
firstMaintainerResponseAt: pr.firstMaintainerResponseAt ?? firstMaintainerResponseFromDigest(previousDigest, pr.url),
|
|
269
|
+
})));
|
|
249
270
|
if (dropped > 0) {
|
|
250
271
|
partialFailures.push(`Dropped ${dropped} merged PR(s) with invalid URLs before persistence`);
|
|
251
272
|
}
|
|
252
273
|
}
|
|
253
274
|
catch (error) {
|
|
275
|
+
partialFailures.push('store merged PRs');
|
|
254
276
|
warn(MODULE, `Failed to store merged PRs: ${errorMessage(error)}`);
|
|
255
277
|
}
|
|
256
|
-
// Store new closed PRs incrementally (dedupes by URL
|
|
278
|
+
// Store new closed PRs incrementally (dedupes by URL; re-seen entries
|
|
279
|
+
// upgrade missing ledger fields in place)
|
|
257
280
|
try {
|
|
258
|
-
const { dropped } = stateManager.addClosedPRs(newClosedPRs)
|
|
281
|
+
const { dropped } = stateManager.addClosedPRs(newClosedPRs.map((pr) => ({
|
|
282
|
+
...pr,
|
|
283
|
+
firstMaintainerResponseAt: pr.firstMaintainerResponseAt ?? firstMaintainerResponseFromDigest(previousDigest, pr.url),
|
|
284
|
+
})));
|
|
259
285
|
if (dropped > 0) {
|
|
260
286
|
partialFailures.push(`Dropped ${dropped} closed PR(s) with invalid URLs before persistence`);
|
|
261
287
|
}
|
|
262
288
|
}
|
|
263
289
|
catch (error) {
|
|
290
|
+
partialFailures.push('store closed PRs');
|
|
264
291
|
warn(MODULE, `Failed to store closed PRs: ${errorMessage(error)}`);
|
|
265
292
|
}
|
|
266
293
|
// Store monthly chart data (opened/merged/closed) so charts have data.
|
|
@@ -268,7 +295,7 @@ export async function fetchDashboardData(token) {
|
|
|
268
295
|
// never the createdAt/mergedAt dates the monthly counts key off.
|
|
269
296
|
const { monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
|
|
270
297
|
const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
|
|
271
|
-
updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
|
|
298
|
+
partialFailures.push(...updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed));
|
|
272
299
|
// The digest keeps RAW statuses in openPRs: this digest becomes the
|
|
273
300
|
// server's cached/persisted rebuild source, and baking overridden
|
|
274
301
|
// statuses into it would make CLEARING an override (move target=auto) a
|
|
@@ -294,6 +321,7 @@ export async function fetchDashboardData(token) {
|
|
|
294
321
|
});
|
|
295
322
|
}
|
|
296
323
|
catch (error) {
|
|
324
|
+
partialFailures.push('persist dashboard state');
|
|
297
325
|
warn(MODULE, `Failed to persist dashboard state: ${errorMessage(error)}`);
|
|
298
326
|
}
|
|
299
327
|
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
|
-
|
|
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>;
|