@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.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
- const stateManager = getStateManager();
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
- if (pendingGistSyncWarnings.length === 0)
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, ...pendingGistSyncWarnings.filter((w) => !base.includes(w))];
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
  *
@@ -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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "3.13.3",
3
+ "version": "3.13.4",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {