@oss-autopilot/core 0.51.0 → 0.51.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.
@@ -165,7 +165,7 @@ export async function startDashboardServer(options) {
165
165
  // ── Rate limiters ───────────────────────────────────────────────────────
166
166
  const dataLimiter = new RateLimiter({ maxRequests: 30, windowMs: 60_000 }); // 30/min
167
167
  const actionLimiter = new RateLimiter({ maxRequests: 10, windowMs: 60_000 }); // 10/min
168
- const refreshLimiter = new RateLimiter({ maxRequests: 2, windowMs: 60_000 }); // 2/min
168
+ const refreshLimiter = new RateLimiter({ maxRequests: 6, windowMs: 60_000 }); // 6/min
169
169
  // ── Request handler ──────────────────────────────────────────────────────
170
170
  const server = http.createServer(async (req, res) => {
171
171
  const method = req.method || 'GET';
@@ -179,6 +179,16 @@ export async function startDashboardServer(options) {
179
179
  sendError(res, 429, 'Too many requests');
180
180
  return;
181
181
  }
182
+ // Re-read state.json if CLI commands modified it externally
183
+ if (stateManager.reloadIfChanged()) {
184
+ try {
185
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
186
+ }
187
+ catch (error) {
188
+ warn(MODULE, `Failed to rebuild dashboard data after state reload: ${errorMessage(error)}`);
189
+ // Intentional: serve previous cachedJsonData rather than returning 500
190
+ }
191
+ }
182
192
  sendJson(res, 200, cachedJsonData);
183
193
  return;
184
194
  }
@@ -227,6 +237,8 @@ export async function startDashboardServer(options) {
227
237
  server.requestTimeout = REQUEST_TIMEOUT_MS;
228
238
  // ── POST /api/action handler ─────────────────────────────────────────────
229
239
  async function handleAction(req, res) {
240
+ // Reload state before mutating to avoid overwriting external CLI changes
241
+ stateManager.reloadIfChanged();
230
242
  let body;
231
243
  try {
232
244
  const raw = await readBody(req);
@@ -293,6 +305,7 @@ export async function startDashboardServer(options) {
293
305
  return;
294
306
  }
295
307
  try {
308
+ stateManager.reloadIfChanged();
296
309
  warn(MODULE, 'Refreshing dashboard data from GitHub...');
297
310
  const result = await fetchDashboardData(currentToken);
298
311
  cachedDigest = result.digest;
@@ -408,6 +421,7 @@ export async function startDashboardServer(options) {
408
421
  if (token) {
409
422
  fetchDashboardData(token)
410
423
  .then((result) => {
424
+ stateManager.reloadIfChanged();
411
425
  cachedDigest = result.digest;
412
426
  cachedCommentedIssues = result.commentedIssues;
413
427
  cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs);
@@ -31,6 +31,7 @@ export declare function atomicWriteFileSync(filePath: string, data: string, mode
31
31
  export declare class StateManager {
32
32
  private state;
33
33
  private readonly inMemoryOnly;
34
+ private lastLoadedMtimeMs;
34
35
  /**
35
36
  * Create a new StateManager instance.
36
37
  * @param inMemoryOnly - When true, state is held only in memory and never read from or
@@ -92,6 +93,12 @@ export declare class StateManager {
92
93
  * use the StateManager methods to make changes.
93
94
  */
94
95
  getState(): Readonly<AgentState>;
96
+ /**
97
+ * Re-read state from disk if the file has been modified since the last load/save.
98
+ * Uses mtime comparison (single statSync call) to avoid unnecessary JSON parsing.
99
+ * Returns true if state was reloaded, false if unchanged or in-memory mode.
100
+ */
101
+ reloadIfChanged(): boolean;
95
102
  /**
96
103
  * Store the latest daily digest for dashboard rendering.
97
104
  * @param digest - The freshly generated digest from the current daily run.
@@ -169,6 +169,7 @@ function migrateV1ToV2(rawState) {
169
169
  export class StateManager {
170
170
  state;
171
171
  inMemoryOnly;
172
+ lastLoadedMtimeMs = 0;
172
173
  /**
173
174
  * Create a new StateManager instance.
174
175
  * @param inMemoryOnly - When true, state is held only in memory and never read from or
@@ -369,6 +370,13 @@ export class StateManager {
369
370
  warn(MODULE, `Failed to clean up removed features from state: ${errorMessage(cleanupError)}`);
370
371
  // Continue with loaded state — cleanup will be retried on next load
371
372
  }
373
+ // Record file mtime so reloadIfChanged() can detect external writes
374
+ try {
375
+ this.lastLoadedMtimeMs = fs.statSync(getStatePath()).mtimeMs;
376
+ }
377
+ catch (error) {
378
+ debug(MODULE, `Could not read state file mtime (reload detection will always trigger): ${errorMessage(error)}`);
379
+ }
372
380
  // Log appropriate message based on version
373
381
  const repoCount = Object.keys(state.repoScores).length;
374
382
  debug(MODULE, `Loaded state v${state.version}: ${repoCount} repo scores tracked`);
@@ -499,6 +507,8 @@ export class StateManager {
499
507
  }
500
508
  // Atomic write: write to temp file then rename to prevent corruption on crash
501
509
  atomicWriteFileSync(statePath, JSON.stringify(this.state, null, 2), 0o600);
510
+ // Update mtime so own writes don't trigger reloadIfChanged()
511
+ this.lastLoadedMtimeMs = fs.statSync(statePath).mtimeMs;
502
512
  debug(MODULE, 'State saved successfully');
503
513
  }
504
514
  finally {
@@ -535,6 +545,40 @@ export class StateManager {
535
545
  getState() {
536
546
  return this.state;
537
547
  }
548
+ /**
549
+ * Re-read state from disk if the file has been modified since the last load/save.
550
+ * Uses mtime comparison (single statSync call) to avoid unnecessary JSON parsing.
551
+ * Returns true if state was reloaded, false if unchanged or in-memory mode.
552
+ */
553
+ reloadIfChanged() {
554
+ if (this.inMemoryOnly)
555
+ return false;
556
+ try {
557
+ const statePath = getStatePath();
558
+ const currentMtimeMs = fs.statSync(statePath).mtimeMs;
559
+ if (currentMtimeMs === this.lastLoadedMtimeMs)
560
+ return false;
561
+ this.state = this.load();
562
+ // load() only records lastLoadedMtimeMs on the happy path. Ensure it is
563
+ // always current after reload (covers backup-restore and fresh-state paths)
564
+ // to prevent repeated unnecessary reloads on every request.
565
+ try {
566
+ this.lastLoadedMtimeMs = fs.statSync(statePath).mtimeMs;
567
+ }
568
+ catch {
569
+ // If file was just loaded, stat should not fail. If it does,
570
+ // next reloadIfChanged() will simply trigger another reload.
571
+ }
572
+ return true;
573
+ }
574
+ catch (error) {
575
+ // statSync failure (file deleted) is benign — keep current in-memory state.
576
+ // load() failure should not happen (load() handles its own recovery),
577
+ // but if it does, keeping current state is the safest option.
578
+ warn(MODULE, `Failed to reload state from disk: ${errorMessage(error)}`);
579
+ return false;
580
+ }
581
+ }
538
582
  /**
539
583
  * Store the latest daily digest for dashboard rendering.
540
584
  * @param digest - The freshly generated digest from the current daily run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "0.51.0",
3
+ "version": "0.51.1",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {