@oss-autopilot/core 3.13.3 → 3.13.4
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/dist/cli.bundle.cjs +87 -87
- package/dist/cli.js +15 -2
- package/dist/commands/dashboard-server.js +132 -6
- package/dist/formatters/json.d.ts +9 -0
- package/dist/formatters/json.js +11 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -77,9 +77,17 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
|
77
77
|
}
|
|
78
78
|
// Activate Gist persistence if configured, before any command runs.
|
|
79
79
|
// Shared helper peeks at the state file and only pre-sets the singleton
|
|
80
|
-
// when Gist mode is the configured persistence (#1000).
|
|
80
|
+
// when Gist mode is the configured persistence (#1000). Hard errors
|
|
81
|
+
// still throw (#1202); the resolving degraded modes are surfaced in the
|
|
82
|
+
// JSON envelope so --json consumers see them too (#1433).
|
|
81
83
|
const { ensureGistPersistence } = await import('./core/index.js');
|
|
82
|
-
await ensureGistPersistence(token);
|
|
84
|
+
const status = await ensureGistPersistence(token);
|
|
85
|
+
if (status === 'degraded' || status === 'state-unreadable') {
|
|
86
|
+
const { setEnvelopeGistWarning } = await import('./formatters/json.js');
|
|
87
|
+
setEnvelopeGistWarning('Gist persistence is configured but this run is LOCAL-ONLY (' +
|
|
88
|
+
(status === 'degraded' ? 'transient network failure during init' : 'state file could not be read') +
|
|
89
|
+
'); changes may be overwritten by the next successful Gist sync.');
|
|
90
|
+
}
|
|
83
91
|
}
|
|
84
92
|
else {
|
|
85
93
|
// #1431: localOnly skips the AUTH GATE, not gist persistence. Mutating
|
|
@@ -93,6 +101,11 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
|
|
|
93
101
|
const localOnlyWarning = await bootstrapGistBestEffort(getGitHubTokenAsync);
|
|
94
102
|
if (localOnlyWarning) {
|
|
95
103
|
console.error(`Warning: ${localOnlyWarning}`);
|
|
104
|
+
// Also thread it into the JSON envelope: shelve/move/dismiss --json
|
|
105
|
+
// consumers (the agent harness) must see the mutation will not sync —
|
|
106
|
+
// stderr alone is invisible to them (#1433).
|
|
107
|
+
const { setEnvelopeGistWarning } = await import('./formatters/json.js');
|
|
108
|
+
setEnvelopeGistWarning(localOnlyWarning);
|
|
96
109
|
}
|
|
97
110
|
}
|
|
98
111
|
});
|
|
@@ -9,8 +9,8 @@ import * as http from 'node:http';
|
|
|
9
9
|
import * as fs from 'node:fs';
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import * as crypto from 'node:crypto';
|
|
12
|
-
import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides, classifyAttentionBucket, maybeCheckpoint, } from '../core/index.js';
|
|
13
|
-
import { errorMessage, ValidationError, ConcurrencyError, GistConcurrencyError } from '../core/errors.js';
|
|
12
|
+
import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides, classifyAttentionBucket, maybeCheckpoint, ensureGistPersistence, } from '../core/index.js';
|
|
13
|
+
import { errorMessage, ValidationError, ConcurrencyError, ConfigurationError, GistConcurrencyError, } from '../core/errors.js';
|
|
14
14
|
import { warn } from '../core/logger.js';
|
|
15
15
|
import { validateUrl, validateGitHubUrl, PR_URL_PATTERN, ISSUE_URL_PATTERN } from './validation.js';
|
|
16
16
|
import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, reconcileShelvePartition, storedToMergedPRs, storedToClosedPRs, } from './dashboard-data.js';
|
|
@@ -288,7 +288,10 @@ function sendConflict(res) {
|
|
|
288
288
|
// ── Server ─────────────────────────────────────────────────────────────────────
|
|
289
289
|
export async function startDashboardServer(options) {
|
|
290
290
|
const { port: requestedPort, assetsDir, token, open } = options;
|
|
291
|
-
|
|
291
|
+
// `let` (#1433): a degraded gist recovery replaces the core singleton, and
|
|
292
|
+
// this long-lived server must re-resolve its reference or every handler
|
|
293
|
+
// keeps using the orphaned local manager.
|
|
294
|
+
let stateManager = getStateManager();
|
|
292
295
|
const resolvedAssetsDir = path.resolve(assetsDir);
|
|
293
296
|
// ── CSRF token ──────────────────────────────────────────────────────────
|
|
294
297
|
// Fresh per server-start. Exposed to the SPA via X-CSRF-Token on every
|
|
@@ -336,10 +339,20 @@ export async function startDashboardServer(options) {
|
|
|
336
339
|
/** Merge pending gist-sync warnings into a partialFailures payload for the
|
|
337
340
|
* SPA banner without coupling their lifecycles. */
|
|
338
341
|
function withPendingGistWarnings(failures) {
|
|
339
|
-
|
|
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)
|
|
340
353
|
return failures;
|
|
341
354
|
const base = failures ?? [];
|
|
342
|
-
return [...base, ...
|
|
355
|
+
return [...base, ...extras.filter((w) => !base.includes(w))];
|
|
343
356
|
}
|
|
344
357
|
/** Push-before-pull (#1417): an un-pushed mutation would be silently
|
|
345
358
|
* reverted by the next Gist pull. Retry the checkpoint first so a recovered
|
|
@@ -350,6 +363,92 @@ export async function startDashboardServer(options) {
|
|
|
350
363
|
return;
|
|
351
364
|
recordGistSyncOutcome(await maybeCheckpoint(stateManager, MODULE));
|
|
352
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
|
+
}
|
|
353
452
|
if (!cachedDigest) {
|
|
354
453
|
throw new Error('No dashboard data available. Run the daily check first: GITHUB_TOKEN=$(gh auth token) npm start -- daily');
|
|
355
454
|
}
|
|
@@ -397,6 +496,11 @@ export async function startDashboardServer(options) {
|
|
|
397
496
|
}
|
|
398
497
|
else {
|
|
399
498
|
stateChanged = stateManager.reloadIfChanged();
|
|
499
|
+
await maybeRecoverGist();
|
|
500
|
+
// A successful recovery is a state-source change: rebuild so the
|
|
501
|
+
// degraded banner clears without waiting for another edit.
|
|
502
|
+
if (stateManager.isGistMode())
|
|
503
|
+
stateChanged = true;
|
|
400
504
|
}
|
|
401
505
|
// Also rebuild when the vetted issue list file was edited outside this server (#924)
|
|
402
506
|
const currentIssueListMtimeMs = getIssueListMtimeMs();
|
|
@@ -486,7 +590,15 @@ export async function startDashboardServer(options) {
|
|
|
486
590
|
await stateManager.refreshFromGist();
|
|
487
591
|
}
|
|
488
592
|
else {
|
|
489
|
-
stateManager.reloadIfChanged()
|
|
593
|
+
if (stateManager.reloadIfChanged()) {
|
|
594
|
+
// An external config edit may BE the fix for a permanently-halted
|
|
595
|
+
// recovery (new token scope, repaired gist id) — give it one fresh
|
|
596
|
+
// attempt cycle (#1433 pass-2).
|
|
597
|
+
recoveryHaltedReason = null;
|
|
598
|
+
}
|
|
599
|
+
// reloadIfChanged may have just pulled a persistence=gist flip made
|
|
600
|
+
// from a terminal, and a degraded server heals here too (#1433).
|
|
601
|
+
await maybeRecoverGist();
|
|
490
602
|
}
|
|
491
603
|
}
|
|
492
604
|
async function handleAction(req, res) {
|
|
@@ -596,6 +708,11 @@ export async function startDashboardServer(options) {
|
|
|
596
708
|
// cachedPartialFailures — because gist warnings clear on a successful
|
|
597
709
|
// PUSH, while fetch failures clear on a successful pull/refresh.
|
|
598
710
|
recordGistSyncOutcome(gistSyncWarning);
|
|
711
|
+
// Count mutations acknowledged while degraded (#1433): a later recovery
|
|
712
|
+
// bootstraps from the existing Gist and reverts them, and the loss
|
|
713
|
+
// notice needs to know whether there is anything to lose.
|
|
714
|
+
if (gistConfiguredButLocal())
|
|
715
|
+
degradedMutationCount++;
|
|
599
716
|
// Rebuild dashboard data from cached digest + updated state. Persist
|
|
600
717
|
// the last-known partialFailures across action rebuilds (#1035) so the
|
|
601
718
|
// SPA banner survives user interactions until the next successful
|
|
@@ -612,6 +729,12 @@ export async function startDashboardServer(options) {
|
|
|
612
729
|
return;
|
|
613
730
|
}
|
|
614
731
|
try {
|
|
732
|
+
// Clear PRE-EXISTING loss notices before the reload: by now they have
|
|
733
|
+
// been visible across the degraded window. Order matters — this
|
|
734
|
+
// refresh's own reloadState may RECOVER and produce a fresh notice,
|
|
735
|
+
// which must survive into the rebuild below, not be wiped 10 lines
|
|
736
|
+
// after its creation (#1433 pass-2).
|
|
737
|
+
recoveryLossNotices = [];
|
|
615
738
|
await reloadState();
|
|
616
739
|
warn(MODULE, 'Refreshing dashboard data from GitHub...');
|
|
617
740
|
const result = await fetchDashboardData(currentToken);
|
|
@@ -740,12 +863,15 @@ export async function startDashboardServer(options) {
|
|
|
740
863
|
if (token) {
|
|
741
864
|
fetchDashboardData(token)
|
|
742
865
|
.then(async (result) => {
|
|
866
|
+
// Same clear-before-recover ordering as handleRefresh (#1433 pass-2).
|
|
867
|
+
recoveryLossNotices = [];
|
|
743
868
|
if (stateManager.isGistMode()) {
|
|
744
869
|
await flushPendingGistSync();
|
|
745
870
|
await stateManager.refreshFromGist();
|
|
746
871
|
}
|
|
747
872
|
else {
|
|
748
873
|
stateManager.reloadIfChanged();
|
|
874
|
+
await maybeRecoverGist();
|
|
749
875
|
}
|
|
750
876
|
cachedDigest = result.digest;
|
|
751
877
|
cachedCommentedIssues = result.commentedIssues;
|
|
@@ -16,7 +16,16 @@ export interface JsonOutput<T = unknown> {
|
|
|
16
16
|
error?: string;
|
|
17
17
|
errorCode?: ErrorCode;
|
|
18
18
|
timestamp: string;
|
|
19
|
+
/** Present when the process is gist-configured but running local-only
|
|
20
|
+
* (#1433): machine consumers of mutating --json commands must see that
|
|
21
|
+
* the mutation will not sync. Set once per process from the CLI bootstrap
|
|
22
|
+
* via {@link setEnvelopeGistWarning}; envelope-level (not data-level) so
|
|
23
|
+
* per-command output schemas are untouched. */
|
|
24
|
+
gistInitWarning?: string;
|
|
19
25
|
}
|
|
26
|
+
/** Set (or clear) the gist degradation warning carried by every subsequent
|
|
27
|
+
* JSON envelope. Exported for the CLI bootstrap and for test isolation. */
|
|
28
|
+
export declare function setEnvelopeGistWarning(warning: string | null): void;
|
|
20
29
|
/**
|
|
21
30
|
* Deduplicated daily digest for JSON output (#287).
|
|
22
31
|
*
|
package/dist/formatters/json.js
CHANGED
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { fenceFetchedPR } from '../core/untrusted-content.js';
|
|
7
|
+
// Process-level gist degradation warning threaded into every JSON envelope
|
|
8
|
+
// (#1433). The CLI preAction bootstrap sets it; one-shot processes never
|
|
9
|
+
// clear it (the whole invocation runs degraded or it doesn't).
|
|
10
|
+
let envelopeGistWarning = null;
|
|
11
|
+
/** Set (or clear) the gist degradation warning carried by every subsequent
|
|
12
|
+
* JSON envelope. Exported for the CLI bootstrap and for test isolation. */
|
|
13
|
+
export function setEnvelopeGistWarning(warning) {
|
|
14
|
+
envelopeGistWarning = warning;
|
|
15
|
+
}
|
|
7
16
|
export function buildStalenessWarning(info) {
|
|
8
17
|
return {
|
|
9
18
|
phase: 'gist-staleness',
|
|
@@ -729,6 +738,7 @@ export function jsonSuccess(data) {
|
|
|
729
738
|
success: true,
|
|
730
739
|
data,
|
|
731
740
|
timestamp: new Date().toISOString(),
|
|
741
|
+
...(envelopeGistWarning ? { gistInitWarning: envelopeGistWarning } : {}),
|
|
732
742
|
};
|
|
733
743
|
}
|
|
734
744
|
/**
|
|
@@ -740,6 +750,7 @@ export function jsonError(message, errorCode) {
|
|
|
740
750
|
error: message,
|
|
741
751
|
errorCode,
|
|
742
752
|
timestamp: new Date().toISOString(),
|
|
753
|
+
...(envelopeGistWarning ? { gistInitWarning: envelopeGistWarning } : {}),
|
|
743
754
|
};
|
|
744
755
|
}
|
|
745
756
|
/**
|