diff --git a/src/adapters/webTools.test.ts b/src/adapters/webTools.test.ts index 5700c4a9..2bad1d45 100644 --- a/src/adapters/webTools.test.ts +++ b/src/adapters/webTools.test.ts @@ -157,7 +157,7 @@ describe('webSearch — backend selection', () => { vi.stubEnv('OPENSWARM_SEARXNG_KEY', 'secret-key'); expect(searchBackend()).toBe('searxng'); const f = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { - expect((init?.headers as Record)['X-VEGA-Key']).toBe('secret-key'); + expect((init?.headers as Record | undefined)?.['X-VEGA-Key']).toBe('secret-key'); return new Response(JSON.stringify({ results: [] }), { status: 200 }); }); vi.stubGlobal('fetch', f); diff --git a/src/automation/dailyReporter.ts b/src/automation/dailyReporter.ts index a4e5eebd..2cc41c30 100644 --- a/src/automation/dailyReporter.ts +++ b/src/automation/dailyReporter.ts @@ -25,10 +25,19 @@ const WATERMARK_FILE = join(homedir(), '.openswarm', 'daily-reporter-watermark.j function readWatermark(): string | null { try { - if (existsSync(WATERMARK_FILE)) { - return JSON.parse(readFileSync(WATERMARK_FILE, 'utf8')).date as string; + if (!existsSync(WATERMARK_FILE)) return null; + const parsed = JSON.parse(readFileSync(WATERMARK_FILE, 'utf8')) as unknown; + if ( + typeof parsed === 'object' && + parsed !== null && + 'date' in parsed && + typeof parsed.date === 'string' + ) { + return parsed.date; } - } catch { /* corrupt → treat as no watermark */ } + } catch { + // A corrupt watermark must never suppress a report. + } return null; } @@ -125,6 +134,12 @@ export async function generateDailyReports(): Promise { return; } + const today = new Date().toISOString().slice(0, 10); + if (readWatermark() === today) { + console.log(`[DailyReporter] Reports already completed for ${today}, skipping`); + return; + } + console.log('[DailyReporter] Generating daily reports...'); try { @@ -171,7 +186,6 @@ export async function generateDailyReports(): Promise { // If any failed, the watermark stays at the previous value so the next // run retries the same window instead of skipping it. if (failCount === 0) { - const today = new Date().toISOString().slice(0, 10); writeWatermark(today); console.log(`[DailyReporter] Watermark persisted: ${today}`); } else { @@ -216,4 +230,4 @@ async function sendDiscordSummary( } catch (err) { console.error('[DailyReporter] Failed to send Discord summary:', err); } -} \ No newline at end of file +} diff --git a/src/automation/dailyReporter.watermark.test.ts b/src/automation/dailyReporter.watermark.test.ts new file mode 100644 index 00000000..47b036a4 --- /dev/null +++ b/src/automation/dailyReporter.watermark.test.ts @@ -0,0 +1,80 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import type { LinearClient, Project } from '@linear/sdk'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const testState = vi.hoisted(() => ({ + home: `/tmp/openswarm-daily-reporter-${process.pid}`, + postStatusUpdate: vi.fn(), +})); + +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => testState.home, +})); + +vi.mock('../linear/index.js', () => ({ + postStatusUpdate: testState.postStatusUpdate, +})); + +import { + generateDailyReports, + setLinearClient, + setTeamId, +} from './dailyReporter.js'; + +const watermarkFile = join(testState.home, '.openswarm', 'daily-reporter-watermark.json'); + +function configureReporter(): { team: ReturnType } { + const project = { id: 'project-1', name: 'Project One', state: 'started' } as Project; + const team = { + projects: vi.fn().mockResolvedValue({ + nodes: [project], + pageInfo: { hasNextPage: false, endCursor: null }, + }), + }; + const client = { team: vi.fn().mockResolvedValue(team) } as unknown as LinearClient; + + setLinearClient(client); + setTeamId('team-1'); + return { team: client.team as unknown as ReturnType }; +} + +beforeEach(() => { + rmSync(testState.home, { recursive: true, force: true }); + testState.postStatusUpdate.mockReset(); +}); + +afterAll(() => { + rmSync(testState.home, { recursive: true, force: true }); +}); + +describe('daily reporter watermark', () => { + it('skips a duplicate run after a successful report', async () => { + testState.postStatusUpdate.mockResolvedValue(undefined); + const { team } = configureReporter(); + + await generateDailyReports(); + await generateDailyReports(); + + expect(testState.postStatusUpdate).toHaveBeenCalledTimes(1); + expect(team).toHaveBeenCalledTimes(1); + expect(JSON.parse(readFileSync(watermarkFile, 'utf8'))).toEqual({ + date: new Date().toISOString().slice(0, 10), + }); + }); + + it('retries after a partial failure because no watermark is written', async () => { + testState.postStatusUpdate + .mockRejectedValueOnce(new Error('temporary Linear failure')) + .mockResolvedValueOnce(undefined); + configureReporter(); + + await generateDailyReports(); + expect(existsSync(watermarkFile)).toBe(false); + + await generateDailyReports(); + expect(testState.postStatusUpdate).toHaveBeenCalledTimes(2); + expect(existsSync(watermarkFile)).toBe(true); + }); +}); diff --git a/src/support/web.tailscale.test.ts b/src/support/web.tailscale.test.ts index 31bef823..fb91056e 100644 --- a/src/support/web.tailscale.test.ts +++ b/src/support/web.tailscale.test.ts @@ -9,8 +9,6 @@ // literal never comes back. import { describe, expect, it, vi } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { isTailscaleAddress } from '../support/tailscaleNetwork'; async function withInterfaces(interfaces: Record) { vi.resetModules(); diff --git a/web/static/js/diffPanel.mjs b/web/static/js/diffPanel.mjs index 1ef6a385..c0e1c6b7 100644 --- a/web/static/js/diffPanel.mjs +++ b/web/static/js/diffPanel.mjs @@ -38,7 +38,6 @@ export function summarizeFiles(files) { export class DiffPanel { #el; #fetchDiff; - #taskId = null; /** * Only the newest request may paint. A taskId comparison is not enough — * re-opening the SAME session starts a second request with the same id, and @@ -56,7 +55,6 @@ export class DiffPanel { } async load(taskId) { - this.#taskId = taskId; const requestId = ++this.#requestId; this.#message('Loading diff…'); let payload; @@ -79,7 +77,6 @@ export class DiffPanel { } clear() { - this.#taskId = null; // Nothing in flight may paint over a cleared panel either. this.#requestId++; this.#el.replaceChildren(); diff --git a/web/static/js/sessionPanel.mjs b/web/static/js/sessionPanel.mjs index b1d7f68b..1af89b1f 100644 --- a/web/static/js/sessionPanel.mjs +++ b/web/static/js/sessionPanel.mjs @@ -25,7 +25,6 @@ export class SessionPanel { #diff; #fetchLog; #taskId = null; - #tab = 'transcript'; #seeded = new Set(); constructor(root, { store, transcripts, transcriptView, diffPanel, fetchLog }) { @@ -131,7 +130,6 @@ export class SessionPanel { } #setTab(tab) { - this.#tab = tab; for (const button of this.#root.querySelectorAll('.session-tab')) { button.classList.toggle('active', button.dataset.tab === tab); } diff --git a/web/static/js/themeBoot.js b/web/static/js/themeBoot.js index 9e4af5cc..a47645e7 100644 --- a/web/static/js/themeBoot.js +++ b/web/static/js/themeBoot.js @@ -13,6 +13,6 @@ } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) { theme = 'light'; } - } catch (_) { /* storage blocked — stay on the dark default */ } + } catch { /* storage blocked — stay on the dark default */ } document.documentElement.setAttribute('data-theme', theme); })(); diff --git a/web/static/js/threadBoard.mjs b/web/static/js/threadBoard.mjs index 2bea2604..10d08f10 100644 --- a/web/static/js/threadBoard.mjs +++ b/web/static/js/threadBoard.mjs @@ -14,7 +14,7 @@ function mutationKey(prefix) { async function request(fetchImpl, path, options = {}) { const response = await fetchImpl(path, { ...options, - headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) }, + headers: { 'Content-Type': 'application/json', ...options.headers }, }); let body = null; try { body = await response.json(); } catch { /* an error below still names the status */ } diff --git a/web/static/js/webToken.js b/web/static/js/webToken.js index c9db55c6..159ef9c8 100644 --- a/web/static/js/webToken.js +++ b/web/static/js/webToken.js @@ -43,7 +43,7 @@ function readToken() { try { return window.sessionStorage.getItem(TOKEN_KEY) || ''; - } catch (err) { + } catch { return ''; } } @@ -52,7 +52,7 @@ try { if (token) window.sessionStorage.setItem(TOKEN_KEY, token); else window.sessionStorage.removeItem(TOKEN_KEY); - } catch (err) { + } catch { // Non-fatal: the token still authorizes this page for its lifetime via // the in-memory value the caller holds. } @@ -77,7 +77,7 @@ // one would send `/api/x` to another host while this still called it // same-origin — attaching the token to a cross-origin request. url = new URL(raw, document.baseURI || window.location.href); - } catch (err) { + } catch { return null; } return url.origin === window.location.origin ? url.pathname : null; @@ -264,7 +264,7 @@ var nativeFetch = scope.fetch.bind(scope); try { Object.defineProperty(scope, INSTALLED, { value: true, enumerable: false, configurable: true }); - } catch (err) { scope[INSTALLED] = true; } + } catch { scope[INSTALLED] = true; } scope.fetch = function (input, init) { if (!isGatedRequest(input)) return nativeFetch(input, init); @@ -344,7 +344,7 @@ // to have aborted it, the connection would otherwise stay open while // the caller reconnects every three seconds, exhausting the per-host // connection budget. - try { response.body.cancel(); } catch (err) { /* already released */ } + try { response.body.cancel(); } catch { /* already released */ } return undefined; } self.readyState = 1; @@ -413,7 +413,7 @@ this._closed = true; this.readyState = 2; if (this._controller) { - try { this._controller.abort(); } catch (err) { /* already aborted */ } + try { this._controller.abort(); } catch { /* already aborted */ } } }; @@ -438,7 +438,7 @@ scope.EventSource = makeTokenEventSource(scope, Native); try { Object.defineProperty(scope, ES_INSTALLED, { value: true, enumerable: false, configurable: true }); - } catch (err) { scope[ES_INSTALLED] = true; } + } catch { scope[ES_INSTALLED] = true; } } // Exposed for tests and for pages that want to manage the token themselves.