gloomberb 0.19.1 → 0.19.2
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/package.json +2 -1
- package/src/api-client/errors.ts +38 -0
- package/src/app/runtime/startup.ts +3 -1
- package/src/capabilities/chart-series.ts +2 -1
- package/src/cli/commands/market.ts +9 -4
- package/src/cli/commands/overview.ts +5 -6
- package/src/cli/commands/search.ts +13 -13
- package/src/cli/context.ts +1 -0
- package/src/cli/glossary.ts +2 -1
- package/src/cli/history-rows.ts +35 -7
- package/src/cli/known-gaps.ts +11 -0
- package/src/cli/listing-arg.ts +17 -1
- package/src/cli/quote-answer.ts +29 -6
- package/src/components/command-bar/routes/root/runtime.ts +27 -5
- package/src/components/command-bar/selection-runtime.ts +12 -29
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/header.tsx +2 -2
- package/src/components/layout/pane/footer/index.tsx +1 -0
- package/src/components/layout/pane/footer/model.ts +33 -1
- package/src/components/layout/pane/header.tsx +2 -2
- package/src/components/layout/pane/status-footer.ts +2 -1
- package/src/components/onboarding/onboarding-frame.tsx +2 -1
- package/src/components/onboarding/onboarding-wizard.tsx +6 -3
- package/src/components/ui/display.tsx +16 -5
- package/src/components/ui/icon.tsx +8 -2
- package/src/core/app-services.ts +11 -2
- package/src/data/app-persistence.ts +9 -7
- package/src/data/plugin-cache.ts +15 -0
- package/src/data/plugin-state-store.ts +81 -24
- package/src/data/resource-store.ts +130 -58
- package/src/data/session-store.ts +31 -8
- package/src/data/sqlite/access.ts +313 -0
- package/src/data/sqlite/checkpointer.ts +198 -0
- package/src/data/sqlite/database.ts +47 -4
- package/src/data/sqlite/retry.ts +50 -9
- package/src/data/ticker-store.ts +57 -23
- package/src/market-data/realized-volatility.ts +4 -2
- package/src/plugins/auto-update-node.ts +10 -6
- package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
- package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
- package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
- package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
- package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
- package/src/plugins/builtin/commodity-balances/client.ts +4 -1
- package/src/plugins/builtin/cot/client.ts +28 -2
- package/src/plugins/builtin/cot/headless.ts +4 -1
- package/src/plugins/builtin/cot/index.tsx +4 -2
- package/src/plugins/builtin/cot/model.ts +58 -7
- package/src/plugins/builtin/cot/pane.tsx +6 -2
- package/src/plugins/builtin/cpi/client.ts +6 -5
- package/src/plugins/builtin/credit-boards/series.ts +3 -4
- package/src/plugins/builtin/credit-conditions/client.ts +22 -14
- package/src/plugins/builtin/crypto-board/client.ts +5 -5
- package/src/plugins/builtin/crypto-board/series.ts +4 -4
- package/src/plugins/builtin/debt-maturities/series.ts +3 -3
- package/src/plugins/builtin/doe/client.ts +6 -5
- package/src/plugins/builtin/doe/prices.ts +2 -3
- package/src/plugins/builtin/doe/series.ts +4 -4
- package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
- package/src/plugins/builtin/earnings-calls/data.ts +19 -11
- package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
- package/src/plugins/builtin/econ/detail-view.tsx +16 -2
- package/src/plugins/builtin/executives/data.ts +6 -1
- package/src/plugins/builtin/executives/pane.tsx +9 -21
- package/src/plugins/builtin/fear-greed/cache.ts +4 -0
- package/src/plugins/builtin/fear-greed/series.ts +6 -4
- package/src/plugins/builtin/gpu/client.ts +4 -1
- package/src/plugins/builtin/gpu/series.ts +2 -3
- package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
- package/src/plugins/builtin/jodi/client.ts +4 -1
- package/src/plugins/builtin/market-movers/client.ts +1 -0
- package/src/plugins/builtin/market-movers/index.tsx +10 -5
- package/src/plugins/builtin/market-movers/screener.ts +23 -6
- package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
- package/src/plugins/builtin/money-markets/series.ts +3 -5
- package/src/plugins/builtin/power/gas-client.ts +2 -1
- package/src/plugins/builtin/power/prices-client.ts +3 -2
- package/src/plugins/builtin/risk-factors/data.ts +8 -4
- package/src/plugins/builtin/risk-factors/index.tsx +3 -1
- package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
- package/src/plugins/builtin/scanner/lock-note.ts +26 -3
- package/src/plugins/builtin/shared/cloud-resource.ts +13 -8
- package/src/plugins/builtin/shared/fred-points.ts +6 -1
- package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
- package/src/plugins/builtin/shared/series-warning.ts +13 -4
- package/src/plugins/builtin/short-volume/series.ts +2 -3
- package/src/plugins/builtin/social-mentions/client.ts +15 -10
- package/src/plugins/builtin/social-mentions/series.ts +2 -3
- package/src/plugins/builtin/thirteenf/api.ts +11 -16
- package/src/plugins/builtin/thirteenf/data.ts +34 -6
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
- package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
- package/src/plugins/builtin/thirteenf/types.ts +2 -0
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
- package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
- package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
- package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
- package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
- package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
- package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
- package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
- package/src/plugins/builtin/volatility/client.ts +43 -16
- package/src/plugins/builtin/volatility/headless.ts +5 -2
- package/src/plugins/builtin/volatility/model.ts +17 -6
- package/src/plugins/builtin/volatility/pane.tsx +6 -2
- package/src/plugins/builtin/volatility/series.ts +2 -1
- package/src/plugins/builtin/yield-curve/client.ts +4 -6
- package/src/plugins/installer.ts +50 -39
- package/src/renderers/dom/desktop/icons.tsx +3 -2
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
- package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
- package/src/renderers/electrobun/bun/window/attention.ts +54 -0
- package/src/renderers/electrobun/bun/window/create.ts +2 -0
- package/src/renderers/electrobun/launcher/preload.ts +13 -0
- package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
- package/src/renderers/electrobun/view/ui-host.tsx +9 -2
- package/src/series/resolve.ts +2 -1
- package/src/series/types.ts +5 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
- package/src/telemetry/stall-reports-node.ts +13 -2
- package/src/telemetry/stall-reports.ts +189 -13
- package/src/test-support/outage.ts +8 -0
- package/src/tickers/company-name-listing.ts +59 -29
- package/src/tickers/company-name.ts +65 -1
- package/src/tickers/search/home-listing.ts +93 -8
- package/src/tickers/search/index.ts +1 -1
- package/src/time-series/market.ts +5 -3
- package/src/time-series/price-comparison.ts +20 -2
- package/src/time-series/resolve.ts +20 -1
- package/src/time-series/types.ts +7 -0
- package/src/types/financials.ts +18 -3
- package/src/ui/host.tsx +6 -1
- package/src/utils/financial-statements.ts +73 -16
- package/src/utils/fundamentals.ts +17 -5
- package/src/utils/lease-liabilities.ts +54 -0
- package/src/utils/main-thread-stall.ts +47 -2
- package/src/utils/perf-marks.ts +23 -11
- package/src/utils/price-history-integrity.ts +47 -1
|
@@ -30,6 +30,11 @@ export interface VolatilityHistoryInput {
|
|
|
30
30
|
stale?: boolean;
|
|
31
31
|
error?: string | null;
|
|
32
32
|
fetchedAt?: number | null;
|
|
33
|
+
/**
|
|
34
|
+
* When the copy shown was saved, when a failed refresh left it answering:
|
|
35
|
+
* the pane's footer says "saved copy from …" instead of the failure.
|
|
36
|
+
*/
|
|
37
|
+
savedAt?: number | null;
|
|
33
38
|
}
|
|
34
39
|
export interface VolatilitySeriesInput {
|
|
35
40
|
observations: CloudFredObservationPayload[];
|
|
@@ -37,6 +42,8 @@ export interface VolatilitySeriesInput {
|
|
|
37
42
|
stale?: boolean;
|
|
38
43
|
error?: string | null;
|
|
39
44
|
fetchedAt?: number | null;
|
|
45
|
+
/** As `VolatilityHistoryInput.savedAt`. */
|
|
46
|
+
savedAt?: number | null;
|
|
40
47
|
}
|
|
41
48
|
export interface VolatilityInputs {
|
|
42
49
|
history?: Partial<Record<VolatilityIndexId, VolatilityHistoryInput>>;
|
|
@@ -131,6 +138,8 @@ export interface VolatilityBoardRow {
|
|
|
131
138
|
status: "available" | "limited" | "unavailable";
|
|
132
139
|
stale: boolean;
|
|
133
140
|
error: string | null;
|
|
141
|
+
/** When the copy shown was saved, when a failed refresh left it answering. */
|
|
142
|
+
savedAt?: number;
|
|
134
143
|
warnings: string[];
|
|
135
144
|
}
|
|
136
145
|
export interface VolatilityData {
|
|
@@ -233,8 +242,9 @@ function fredHistory(inputs: VolatilityInputs["fred"]): FredVolatilityHistory {
|
|
|
233
242
|
const input = inputs?.[definition.seriesId];
|
|
234
243
|
const normalized = normalizeHistory(input?.observations ?? [], true);
|
|
235
244
|
warnings.push(...normalized.warnings.map((warning) => `${definition.seriesId}: ${warning}`));
|
|
236
|
-
|
|
237
|
-
if (input?.
|
|
245
|
+
// A saved copy is dated in the footer; its failed refresh is not repeated here.
|
|
246
|
+
if (input?.error && input.savedAt == null) warnings.push(`${definition.seriesId}: ${input.error}`);
|
|
247
|
+
if (input?.stale && input.savedAt == null) warnings.push(`${definition.seriesId}: cached source is stale`);
|
|
238
248
|
const latest = normalized.history.at(-1);
|
|
239
249
|
return { ...definition, title: input?.info?.title ?? definition.label, value: latest?.value ?? null,
|
|
240
250
|
date: latest?.date ?? null, history: normalized.history,
|
|
@@ -300,15 +310,16 @@ function boardRow(definition: typeof VOLATILITY_INDICES[number], input: Volatili
|
|
|
300
310
|
if (latest && latest.date !== normalized.rows.at(-1)?.date) warnings.push("Latest supplied close unavailable; showing last valid observation");
|
|
301
311
|
if (latest && !adjacent) warnings.push("1D change unavailable: previous daily close missing or too far apart");
|
|
302
312
|
if (latest && !broadCoverage) warnings.push(`1Y percentile unavailable: ${history.length} observations across ${coverageDays} days`);
|
|
303
|
-
|
|
304
|
-
if (input?.
|
|
313
|
+
// A saved copy is dated in the footer; its failed refresh is not repeated here.
|
|
314
|
+
if (input?.stale && input.savedAt == null) warnings.push("Cached daily history is stale");
|
|
315
|
+
if (input?.error && input.savedAt == null) warnings.push(input.error);
|
|
305
316
|
return { ...definition, unit: "index points", value: latest?.value ?? null, date: latest?.date ?? null,
|
|
306
317
|
source: input?.source ?? null, previousDate: previous?.date ?? null, change1d,
|
|
307
318
|
change1dPercent: change1d != null ? change1d / previous!.value! * 100 : null,
|
|
308
319
|
percentile1y, sampleSize: history.length, coverageDays, history,
|
|
309
320
|
missingDates: normalized.rows.filter((row) => row.value == null && Date.parse(row.date) > cutoff).map((row) => row.date),
|
|
310
321
|
status: !latest ? "unavailable" : warnings.length > 0 ? "limited" : "available",
|
|
311
|
-
stale: input?.stale ?? false, error: input?.error ?? null, warnings };
|
|
322
|
+
stale: input?.stale ?? false, error: input?.error ?? null, ...(input?.savedAt != null ? { savedAt: input.savedAt } : {}), warnings };
|
|
312
323
|
}
|
|
313
324
|
function alignedCurve(board: readonly VolatilityBoardRow[], fred: FredVolatilityHistory, term: VolatilityTermHistory): VolatilityCurve {
|
|
314
325
|
const rows = VOLATILITY_CURVE_INDICES.map((definition) => board.find((row) => row.id === definition.id)!);
|
|
@@ -337,7 +348,7 @@ function alignedCurve(board: readonly VolatilityBoardRow[], fred: FredVolatility
|
|
|
337
348
|
const spot = points[1]!.value, threeMonth = points[2]!.value;
|
|
338
349
|
const warnings = points.flatMap((point, index) => [
|
|
339
350
|
...(point.value == null ? [`${point.label}: no close aligned to ${date ?? "a common date"}`] : []),
|
|
340
|
-
...(point.value != null && rows[index]!.stale ? [`${point.label}: aligned close is from stale cached history`] : []),
|
|
351
|
+
...(point.value != null && rows[index]!.stale && rows[index]!.savedAt == null ? [`${point.label}: aligned close is from stale cached history`] : []),
|
|
341
352
|
]);
|
|
342
353
|
const ratio = spot != null && threeMonth != null ? threeMonth / spot : null;
|
|
343
354
|
const context = ratioPercentile(term, ratio, date);
|
|
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
2
2
|
import { ChartTableHeader, CurveSurface, curveGhostColors, DataTableView, formatPercentAxis, PaneStatusBody, useChartTableSelection,
|
|
3
3
|
usePaneFooter, usePaneNoticeFooter, usePaneTabs, type DataTableColumn, type StatItem } from "../../../components";
|
|
4
4
|
import { curveStrip, curveSurfaceMinRows } from "../../../components/chart/curve";
|
|
5
|
+
import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
|
|
6
|
+
import { STALE_SEGMENT } from "../../../components/layout/pane/status-footer";
|
|
5
7
|
import { dropColumnsToFit } from "../../../components/ui/table-layout";
|
|
6
8
|
import { useAsyncResource } from "../../../react/async-resource";
|
|
7
9
|
import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
|
|
@@ -170,13 +172,15 @@ export function VolatilityPane({ focused, width, height }: PaneProps) {
|
|
|
170
172
|
const observationBasis = intraday ? "intraday" : sparse ? "observation" : settled ? "close" : DAY_SO_FAR_TEXT;
|
|
171
173
|
usePaneFooter("volatility", () => ({ info: [
|
|
172
174
|
...(resource.loading ? [{ id: "loading", parts: [{ text: "loading volatility", tone: "muted" as const }] }] : []),
|
|
173
|
-
|
|
175
|
+
// A saved copy answering a failed refresh says when it was saved; other stale closes say only that.
|
|
176
|
+
...savedCopyFooterInfo([result?.savedAt]),
|
|
177
|
+
...(result?.stale && result.savedAt == null ? [STALE_SEGMENT] : []),
|
|
174
178
|
// One fact, "close 2026-09-25": the basis and its date read as one segment.
|
|
175
179
|
...(data || asOf ? [{ id: "basis", parts: [
|
|
176
180
|
...(data ? [{ text: observationBasis, tone: "muted" as const }] : []),
|
|
177
181
|
...(asOf ? [{ text: observationTime ? `${observationTime.slice(0, 16).replace("T", " ")} UTC` : asOf, tone: "muted" as const }] : []),
|
|
178
182
|
] }] : []),
|
|
179
|
-
], hints: [{ id: "view", key: "v", label: "iew", onPress: cycleTab }] }), [resource.loading, result?.stale, data, asOf, observationTime, observationBasis, cycleTab]);
|
|
183
|
+
], hints: [{ id: "view", key: "v", label: "iew", onPress: cycleTab }] }), [resource.loading, result?.stale, result?.savedAt, data, asOf, observationTime, observationBasis, cycleTab]);
|
|
180
184
|
const { strip: tabStrip, rows: tabRows } = usePaneTabs({ tabs: TABS, activeValue: tab, onSelect: setTab, focused, dense: true });
|
|
181
185
|
const contentHeight = Math.max(5, height - tabRows);
|
|
182
186
|
// The board needs only its rows; the selected index history takes the rest.
|
|
@@ -105,11 +105,12 @@ export const VOLATILITY_SERIES: readonly SeriesDefinition[] = [
|
|
|
105
105
|
tags: ["term structure", "contango", "backwardation", "VIX"],
|
|
106
106
|
description: "VIX 3M over VIX on each date both closed, from Cboe's daily closes and FRED's before them; above 1 is contango.",
|
|
107
107
|
load: async ({ start, signal }) => {
|
|
108
|
-
const { term, warning } = await loadVolatilityTermHistory({ ...(start ? { start } : {}), ...(signal ? { signal } : {}) });
|
|
108
|
+
const { term, warning, savedAt } = await loadVolatilityTermHistory({ ...(start ? { start } : {}), ...(signal ? { signal } : {}) });
|
|
109
109
|
const attribution = termAttribution(term);
|
|
110
110
|
return {
|
|
111
111
|
points: term.ratioHistory.map((point) => ({ date: point.date, value: point.value })),
|
|
112
112
|
...(warning ? { warning } : {}),
|
|
113
|
+
...(savedAt != null ? { savedAt } : {}),
|
|
113
114
|
...(attribution ? { attribution } : {}),
|
|
114
115
|
};
|
|
115
116
|
},
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { apiClient } from "../../../api-client";
|
|
2
2
|
import type { SwapCurvePayload, SwapTenor } from "../../../api-client/swap-curve";
|
|
3
|
-
import {
|
|
4
|
-
import { createPluginCache, type PluginCacheResult } from "../../../data/plugin-cache";
|
|
3
|
+
import { createPluginCache, savedCopyForTransientFailure, type PluginCacheResult } from "../../../data/plugin-cache";
|
|
5
4
|
import { cachedCloudResource, loadCloudResource, unavailableOnServer } from "../shared/cloud-resource";
|
|
6
5
|
import { validateSwapCurve } from "./swaps-model";
|
|
7
6
|
import { loadCurveData, type CurveData, type CurveId } from "./curves";
|
|
@@ -52,12 +51,11 @@ export interface SavedCopyAnswer<T> {
|
|
|
52
51
|
const curveKey = (curve: CurveId, requestedDate: string) => `${curve}:${requestedDate || "latest"}`;
|
|
53
52
|
/**
|
|
54
53
|
* A copy is the saved one by where it came from, whatever its failure said. A
|
|
55
|
-
* refusal
|
|
56
|
-
* said rather than covered by the copy, as `loadCloudResource` does.
|
|
54
|
+
* refusal (access denied, an unknown curve) or a reply that fails validation
|
|
55
|
+
* is said rather than covered by the copy, as `loadCloudResource` does.
|
|
57
56
|
*/
|
|
58
57
|
function answer<T>(result: PluginCacheResult<T>): SavedCopyAnswer<T> {
|
|
59
|
-
const saved = result.source === "stale-fallback";
|
|
60
|
-
if (saved && isPermanentClientError(result.error)) throw result.error;
|
|
58
|
+
const saved = savedCopyForTransientFailure(result).source === "stale-fallback";
|
|
61
59
|
return { data: result.data, savedAt: saved ? result.fetchedAt : null };
|
|
62
60
|
}
|
|
63
61
|
|
package/src/plugins/installer.ts
CHANGED
|
@@ -141,7 +141,13 @@ function run(
|
|
|
141
141
|
/** Whether a `git` command is on the PATH; plugins from GitHub need one to install or update. */
|
|
142
142
|
export function gitInstalled(): boolean {
|
|
143
143
|
if (typeof Bun !== "undefined") return Bun.which("git") !== null;
|
|
144
|
-
|
|
144
|
+
try {
|
|
145
|
+
execFileSync("git", ["--version"], { stdio: "ignore" });
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
// Intentional ignore: a git that does not start is the answer this asks for.
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
145
151
|
}
|
|
146
152
|
|
|
147
153
|
/** What a plugin install says when there is no git to run: what to install, then to try again. */
|
|
@@ -171,14 +177,9 @@ async function git(args: string[], cwd: string, quiet: boolean, failure: string)
|
|
|
171
177
|
return result.stdout.trim();
|
|
172
178
|
}
|
|
173
179
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return true;
|
|
178
|
-
} catch {
|
|
179
|
-
// Intentional ignore: Detached HEAD is expected after a pinned install.
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
180
|
+
/** False on a detached HEAD, which a pinned install leaves. */
|
|
181
|
+
function isOnBranch(dir: string): Promise<boolean> {
|
|
182
|
+
return gitSucceedsAsync(["symbolic-ref", "-q", "HEAD"], dir);
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
function pinMatches(commit: string | null, expected: string | undefined): boolean {
|
|
@@ -208,27 +209,27 @@ async function checkoutCommit(targetDir: string, commit: string, quiet: boolean)
|
|
|
208
209
|
await git(["checkout", "--detach", "--force", commit], targetDir, quiet, `Could not check out ${commit.slice(0, 7)}`);
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
212
|
+
|
|
213
|
+
/*
|
|
214
|
+
* The update path runs in the app (the terminal, the desktop's Bun process),
|
|
215
|
+
* where a sync git call holds the thread for the whole process start: tens of
|
|
216
|
+
* milliseconds each on Windows, several per plugin. These never block it.
|
|
217
|
+
*/
|
|
218
|
+
async function gitSucceedsAsync(args: string[], cwd: string): Promise<boolean> {
|
|
219
|
+
const result = await run("git", args, cwd, { stdout: "pipe", stderr: "pipe" })
|
|
220
|
+
.catch(logFailure("plugin-installer", "Could not run git", null));
|
|
221
|
+
return result?.code === 0;
|
|
219
222
|
}
|
|
220
223
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return null;
|
|
227
|
-
}
|
|
224
|
+
/** Null when git fails: shallow or manifest-free checkouts may lack historical metadata. */
|
|
225
|
+
async function gitOutputAsync(args: string[], cwd: string): Promise<string | null> {
|
|
226
|
+
const result = await run("git", args, cwd, { stdout: "pipe", stderr: "pipe" })
|
|
227
|
+
.catch(logFailure("plugin-installer", "Could not run git", null));
|
|
228
|
+
return result?.code === 0 ? result.stdout.trim() : null;
|
|
228
229
|
}
|
|
229
230
|
|
|
230
|
-
function versionAt(dir: string, commit: string): string | null {
|
|
231
|
-
const text =
|
|
231
|
+
async function versionAt(dir: string, commit: string): Promise<string | null> {
|
|
232
|
+
const text = await gitOutputAsync(["show", `${commit}:package.json`], dir);
|
|
232
233
|
if (!text) return null;
|
|
233
234
|
try {
|
|
234
235
|
const version = JSON.parse(text).version;
|
|
@@ -247,15 +248,15 @@ function versionAt(dir: string, commit: string): string | null {
|
|
|
247
248
|
* it; otherwise the versions the two commits declare decide, then, when
|
|
248
249
|
* those match, their commit dates.
|
|
249
250
|
*/
|
|
250
|
-
export function describeOlderCommit(dir: string, installed: string, candidate: string, label = candidate.slice(0, 7)): string | null {
|
|
251
|
-
if (installed === candidate ||
|
|
252
|
-
const installedVersion = versionAt(dir, installed);
|
|
253
|
-
const byVersion = compareSemver(versionAt(dir, candidate), installedVersion);
|
|
251
|
+
export async function describeOlderCommit(dir: string, installed: string, candidate: string, label = candidate.slice(0, 7)): Promise<string | null> {
|
|
252
|
+
if (installed === candidate || await gitSucceedsAsync(["merge-base", "--is-ancestor", installed, candidate], dir)) return null;
|
|
253
|
+
const installedVersion = await versionAt(dir, installed);
|
|
254
|
+
const byVersion = compareSemver(await versionAt(dir, candidate), installedVersion);
|
|
254
255
|
const older = () => `the registry's ${label} is older than the installed ${byVersion ? installedVersion : installed.slice(0, 7)}`;
|
|
255
|
-
if (
|
|
256
|
+
if (await gitSucceedsAsync(["merge-base", "--is-ancestor", candidate, installed], dir)) return older();
|
|
256
257
|
if (byVersion !== null && byVersion !== 0) return byVersion < 0 ? older() : null;
|
|
257
|
-
const candidateTime = Number(
|
|
258
|
-
const installedTime = Number(
|
|
258
|
+
const candidateTime = Number(await gitOutputAsync(["show", "-s", "--format=%ct", candidate], dir));
|
|
259
|
+
const installedTime = Number(await gitOutputAsync(["show", "-s", "--format=%ct", installed], dir));
|
|
259
260
|
return candidateTime > 0 && installedTime > 0 && candidateTime < installedTime ? older() : null;
|
|
260
261
|
}
|
|
261
262
|
|
|
@@ -403,19 +404,29 @@ export interface PluginUpdateResult extends PluginDirectoryInfo {
|
|
|
403
404
|
kept?: string;
|
|
404
405
|
}
|
|
405
406
|
|
|
407
|
+
function githubRepoFromUrl(url: string): string | null {
|
|
408
|
+
const match = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(url.trim());
|
|
409
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
410
|
+
}
|
|
411
|
+
|
|
406
412
|
/** `owner/repo` from the checkout's origin remote, or null for anything that is not a GitHub clone. */
|
|
407
413
|
export function readPluginRemote(pluginDir: string): string | null {
|
|
408
414
|
try {
|
|
409
|
-
|
|
410
|
-
.toString("utf-8")
|
|
411
|
-
const match = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(url);
|
|
412
|
-
return match ? `${match[1]}/${match[2]}` : null;
|
|
415
|
+
return githubRepoFromUrl(execFileSync("git", ["config", "--get", "remote.origin.url"], { cwd: pluginDir, stdio: ["ignore", "pipe", "ignore"] })
|
|
416
|
+
.toString("utf-8"));
|
|
413
417
|
} catch {
|
|
414
418
|
// Intentional ignore: Hand-copied plugins and local repositories may have no origin remote.
|
|
415
419
|
return null;
|
|
416
420
|
}
|
|
417
421
|
}
|
|
418
422
|
|
|
423
|
+
/** `readPluginRemote` without holding the thread, for the app's own update check. */
|
|
424
|
+
export async function readPluginRemoteAsync(pluginDir: string): Promise<string | null> {
|
|
425
|
+
// No origin remote (a hand-copied plugin, a local repository) is a failed git, and no repo.
|
|
426
|
+
const url = await gitOutputAsync(["config", "--get", "remote.origin.url"], pluginDir);
|
|
427
|
+
return url ? githubRepoFromUrl(url) : null;
|
|
428
|
+
}
|
|
429
|
+
|
|
419
430
|
/** The sha from `git ls-remote origin HEAD`; null when the answer is not one. */
|
|
420
431
|
export function parseRemoteHead(output: string): string | null {
|
|
421
432
|
const sha = output.trim().split(/\s+/)[0] ?? "";
|
|
@@ -500,7 +511,7 @@ export async function updatePlugin(name: string, options: PluginInstallOptions =
|
|
|
500
511
|
const commit = await fetchPin(targetDir, pin, quiet);
|
|
501
512
|
// Updates only move forward: a checkout installed from the default
|
|
502
513
|
// branch can be ahead of the tag the registry reviewed.
|
|
503
|
-
const kept = before ? describeOlderCommit(targetDir, before, commit, pin.ref ?? commit.slice(0, 7)) : null;
|
|
514
|
+
const kept = before ? await describeOlderCommit(targetDir, before, commit, pin.ref ?? commit.slice(0, 7)) : null;
|
|
504
515
|
if (kept) {
|
|
505
516
|
if (!quiet) console.log(cliStyles.muted(`Kept ${name}: ${kept}.`));
|
|
506
517
|
return { ...describe(targetDir, name, null), before, changed: false, kept };
|
|
@@ -510,7 +521,7 @@ export async function updatePlugin(name: string, options: PluginInstallOptions =
|
|
|
510
521
|
// Not in the registry, so there is nothing reviewed to land on: follow the
|
|
511
522
|
// remote's default branch, whether the checkout is on a branch or detached
|
|
512
523
|
// from an earlier pin.
|
|
513
|
-
if (isOnBranch(targetDir)) {
|
|
524
|
+
if (await isOnBranch(targetDir)) {
|
|
514
525
|
await git(["pull", "--ff-only"], targetDir, quiet, `Failed to update ${name}`);
|
|
515
526
|
} else {
|
|
516
527
|
await git(["fetch", "--depth", "1", "origin", "HEAD"], targetDir, quiet, `Failed to fetch ${name}`);
|
|
@@ -180,7 +180,7 @@ const ICONS: Record<IconName, ReactNode> = {
|
|
|
180
180
|
),
|
|
181
181
|
};
|
|
182
182
|
|
|
183
|
-
export function WebIcon({ name, size = 12, color }: IconProps) {
|
|
183
|
+
export function WebIcon({ name, size = 12, color, fullHeight }: IconProps) {
|
|
184
184
|
return (
|
|
185
185
|
<svg
|
|
186
186
|
viewBox="0 0 12 12"
|
|
@@ -189,7 +189,8 @@ export function WebIcon({ name, size = 12, color }: IconProps) {
|
|
|
189
189
|
fill="none"
|
|
190
190
|
aria-hidden="true"
|
|
191
191
|
data-gloom-icon={name}
|
|
192
|
-
|
|
192
|
+
// The viewBox keeps its size and sits in the middle of the taller box (preserveAspectRatio's default).
|
|
193
|
+
style={{ flex: "none", display: "block", color, ...(fullHeight ? { height: "100%" } : {}) }}
|
|
193
194
|
>
|
|
194
195
|
{ICONS[name]}
|
|
195
196
|
</svg>
|
|
@@ -18,6 +18,8 @@ import type { DesktopBackend } from "./backend";
|
|
|
18
18
|
|
|
19
19
|
const INITIAL_DOCK_SUPPRESSION_MS = 800;
|
|
20
20
|
const WINDOW_CONTROL_DOCK_SUPPRESSION_MS = 5_000;
|
|
21
|
+
/** How long after the main window has its data the popped-out windows of the last session open. */
|
|
22
|
+
const LAUNCH_WINDOWS_DELAY_MS = 300;
|
|
21
23
|
|
|
22
24
|
export class DesktopDetachedWindowManager {
|
|
23
25
|
private readonly windows = new Map<string, BrowserWindow>();
|
|
@@ -27,9 +29,29 @@ export class DesktopDetachedWindowManager {
|
|
|
27
29
|
private readonly pendingMoveFlush = new Set<string>();
|
|
28
30
|
private readonly suppressDockUntil = new Map<string, number>();
|
|
29
31
|
private readonly windowControlDockSuppressed = new Set<string>();
|
|
32
|
+
/**
|
|
33
|
+
* At launch, popped-out windows wait for the main window. Opening a window
|
|
34
|
+
* holds this process until the system has made it (on Windows, its
|
|
35
|
+
* browser too), and the main window's `init` used to wait behind each one.
|
|
36
|
+
*/
|
|
37
|
+
private windowsAllowed = false;
|
|
38
|
+
private windowsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
30
39
|
|
|
31
40
|
constructor(private readonly backend: DesktopBackend) {}
|
|
32
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The main window has its data: the popped-out windows open shortly
|
|
44
|
+
* after, once it has had a moment to draw and ask for what it shows.
|
|
45
|
+
*/
|
|
46
|
+
openAfterMainWindow(): void {
|
|
47
|
+
if (this.windowsAllowed || this.windowsTimer) return;
|
|
48
|
+
this.windowsTimer = setTimeout(() => {
|
|
49
|
+
this.windowsTimer = null;
|
|
50
|
+
this.windowsAllowed = true;
|
|
51
|
+
this.reconcile();
|
|
52
|
+
}, LAUNCH_WINDOWS_DELAY_MS);
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
focusDetachedPane(instanceId: string): boolean {
|
|
34
56
|
return this.focusWindowForRpcKey(detachedRpcKey(instanceId));
|
|
35
57
|
}
|
|
@@ -88,7 +110,7 @@ export class DesktopDetachedWindowManager {
|
|
|
88
110
|
for (const [instanceId, entry] of desiredEntries) {
|
|
89
111
|
const existingWindow = this.windows.get(instanceId);
|
|
90
112
|
if (!existingWindow) {
|
|
91
|
-
this.createWindow(instanceId, entry);
|
|
113
|
+
if (this.windowsAllowed) this.createWindow(instanceId, entry);
|
|
92
114
|
continue;
|
|
93
115
|
}
|
|
94
116
|
|
|
@@ -25,6 +25,7 @@ import { telemetryOptedOut } from "../../../../telemetry/crash-reports";
|
|
|
25
25
|
import { describeNodeOs, readOrCreateInstallId } from "../../../../telemetry/crash-reports-node";
|
|
26
26
|
import type { DesktopBackend, DesktopRpc } from "./backend";
|
|
27
27
|
import { readDesktopViewIds } from "./view-ids";
|
|
28
|
+
import { markStartupComplete } from "../../../../utils/main-thread-stall";
|
|
28
29
|
|
|
29
30
|
interface DesktopWindowTarget {
|
|
30
31
|
kind: "main" | "detached";
|
|
@@ -194,5 +195,12 @@ export async function initializeDesktopBackend(
|
|
|
194
195
|
backend.workspace = createDesktopWorkspace(config, backend.getSessionSnapshot());
|
|
195
196
|
backend.detachedWindows.reconcile();
|
|
196
197
|
}
|
|
197
|
-
|
|
198
|
+
const init = buildInitializationPayload(backend, config, services, windowTarget);
|
|
199
|
+
// The main window has what it needs to draw: this process is up, and the
|
|
200
|
+
// popped-out windows open next.
|
|
201
|
+
if (windowTarget.kind === "main") {
|
|
202
|
+
markStartupComplete();
|
|
203
|
+
backend.detachedWindows.openAfterMainWindow();
|
|
204
|
+
}
|
|
205
|
+
return init;
|
|
198
206
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { setStallAttentionProbe, type StallAttention } from "../../../../telemetry/stall-reports";
|
|
2
|
+
|
|
3
|
+
/** What this needs of a window: its id, its minimized state and its events. */
|
|
4
|
+
interface AttentionWindow {
|
|
5
|
+
id: number;
|
|
6
|
+
isMinimized(): boolean;
|
|
7
|
+
on?(name: string, handler: (event: unknown) => void): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const openWindows = new Map<number, AttentionWindow>();
|
|
11
|
+
let focusedWindowId: number | null = null;
|
|
12
|
+
let sawFocusChange = false;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether anyone was looking when the desktop app's Bun process froze. The
|
|
16
|
+
* system slows a hidden app's timers, which reads like a freeze, so a stall
|
|
17
|
+
* report from this process says whether one of the app's windows had the
|
|
18
|
+
* keyboard (the app was in front) and whether one was open and not
|
|
19
|
+
* minimized. Read only for a stall about to be reported.
|
|
20
|
+
*/
|
|
21
|
+
function desktopAttention(): StallAttention {
|
|
22
|
+
let visible: boolean | null = false;
|
|
23
|
+
try {
|
|
24
|
+
for (const window of openWindows.values()) {
|
|
25
|
+
if (!window.isMinimized()) {
|
|
26
|
+
visible = true;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
visible = null;
|
|
32
|
+
}
|
|
33
|
+
return { active: sawFocusChange ? focusedWindowId !== null : null, visible };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Follows a window the app opened until it closes; the first one installs the probe. */
|
|
37
|
+
export function trackWindowAttention(window: AttentionWindow): void {
|
|
38
|
+
if (openWindows.size === 0) setStallAttentionProbe(desktopAttention);
|
|
39
|
+
openWindows.set(window.id, window);
|
|
40
|
+
window.on?.("focus", () => {
|
|
41
|
+
focusedWindowId = window.id;
|
|
42
|
+
sawFocusChange = true;
|
|
43
|
+
});
|
|
44
|
+
window.on?.("blur", () => {
|
|
45
|
+
// Focus moving between two of the app's windows can report the new
|
|
46
|
+
// window's focus before the old one's blur.
|
|
47
|
+
if (focusedWindowId === window.id) focusedWindowId = null;
|
|
48
|
+
sawFocusChange = true;
|
|
49
|
+
});
|
|
50
|
+
window.on?.("close", () => {
|
|
51
|
+
openWindows.delete(window.id);
|
|
52
|
+
if (focusedWindowId === window.id) focusedWindowId = null;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BrowserWindow } from "electrobun/bun";
|
|
2
|
+
import { trackWindowAttention } from "./attention";
|
|
2
3
|
import {
|
|
3
4
|
applyNativeWindowFrame,
|
|
4
5
|
applyWindowMoveEvent,
|
|
@@ -105,6 +106,7 @@ export function createAppWindow({ title, frame, rpc, minSize, onFrameChange, pre
|
|
|
105
106
|
});
|
|
106
107
|
}
|
|
107
108
|
updateWindowFrameCache(window, frame, minSize);
|
|
109
|
+
trackWindowAttention(window);
|
|
108
110
|
const readsNativeFrame = process.platform === "win32";
|
|
109
111
|
(window as any).on?.("move", (event: WindowMoveEvent) => {
|
|
110
112
|
if (readsNativeFrame) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Runs first in Electrobun's launcher script (Resources/main.js) on Windows,
|
|
2
|
+
// before it starts the app's worker and the browser engine. The Windows build
|
|
3
|
+
// step puts it there (scripts/install-electrobun-tui-shim.ts).
|
|
4
|
+
import { leaveEmulationFaultsToWindows } from "./windows-emulation";
|
|
5
|
+
|
|
6
|
+
if (process.platform === "win32") {
|
|
7
|
+
try {
|
|
8
|
+
const result = leaveEmulationFaultsToWindows();
|
|
9
|
+
if (result !== "not emulated") console.log(`[launcher] x64 emulation: ${result}`);
|
|
10
|
+
} catch (error) {
|
|
11
|
+
console.error("[launcher] could not check for x64 emulation", error);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { dlopen, ptr, read, type Pointer } from "bun:ffi";
|
|
2
|
+
|
|
3
|
+
// Windows on Arm runs the x64 app under emulation. When a thread exits there,
|
|
4
|
+
// Windows' emulation layer can fault on memory it no longer reaches
|
|
5
|
+
// (ntdll!RtlEcContextToNativeContext) and recovers from that fault itself.
|
|
6
|
+
// Bun 1.3's crash handler is a vectored exception handler, so it sees the
|
|
7
|
+
// fault before Windows does and takes it for a crash. On the exiting thread,
|
|
8
|
+
// which has no thread-local storage left, the handler then faults inside
|
|
9
|
+
// itself until the thread's stack is gone, and the app closes some seconds
|
|
10
|
+
// after it opens. Bun 1.4 only claims faults inside its own code. Until the
|
|
11
|
+
// app runs on it, remove Bun's handler under emulation and leave faults to
|
|
12
|
+
// Windows, which still reports a real crash.
|
|
13
|
+
|
|
14
|
+
// The Bun build Electrobun ships, by link timestamp, and where that build
|
|
15
|
+
// keeps the handle of its vectored exception handler (from its symbols).
|
|
16
|
+
const BUN_HANDLER_HANDLES = new Map<number, number>([
|
|
17
|
+
[0x69e4847f, 0x68e3d40], // Bun 1.3.13, bun-windows-x64-baseline
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const IMAGE_FILE_MACHINE_ARM64 = 0xaa64;
|
|
21
|
+
const CURRENT_PROCESS = -1;
|
|
22
|
+
|
|
23
|
+
export type EmulationFaultsResult = "not emulated" | "other bun build" | "handler already gone" | "handler removed" | "handler not found";
|
|
24
|
+
|
|
25
|
+
export function leaveEmulationFaultsToWindows(): EmulationFaultsResult {
|
|
26
|
+
const kernel32 = dlopen("kernel32.dll", {
|
|
27
|
+
GetModuleHandleW: { args: ["ptr"], returns: "ptr" },
|
|
28
|
+
IsWow64Process2: { args: ["i64", "ptr", "ptr"], returns: "i32" },
|
|
29
|
+
RemoveVectoredExceptionHandler: { args: ["ptr"], returns: "u32" },
|
|
30
|
+
});
|
|
31
|
+
try {
|
|
32
|
+
const processMachine = new Uint16Array(1);
|
|
33
|
+
const nativeMachine = new Uint16Array(1);
|
|
34
|
+
if (!kernel32.symbols.IsWow64Process2(CURRENT_PROCESS, ptr(processMachine), ptr(nativeMachine))) return "not emulated";
|
|
35
|
+
if (nativeMachine[0] !== IMAGE_FILE_MACHINE_ARM64) return "not emulated";
|
|
36
|
+
|
|
37
|
+
const image = kernel32.symbols.GetModuleHandleW(null);
|
|
38
|
+
if (!image) return "other bun build";
|
|
39
|
+
const timestamp = read.u32(image, read.u32(image, 0x3c) + 8);
|
|
40
|
+
const handleOffset = BUN_HANDLER_HANDLES.get(timestamp);
|
|
41
|
+
if (handleOffset === undefined) return "other bun build";
|
|
42
|
+
|
|
43
|
+
const handle = read.ptr(image, handleOffset) as Pointer;
|
|
44
|
+
if (!handle) return "handler already gone";
|
|
45
|
+
// Windows only removes a handle it has registered, so a wrong value does nothing.
|
|
46
|
+
return kernel32.symbols.RemoveVectoredExceptionHandler(handle) ? "handler removed" : "handler not found";
|
|
47
|
+
} finally {
|
|
48
|
+
kernel32.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -25,9 +25,16 @@ export const webRendererHost: RendererHost = {
|
|
|
25
25
|
void backendRequest("host.exit").catch(() => window.close());
|
|
26
26
|
});
|
|
27
27
|
},
|
|
28
|
-
startWindowDrag() {
|
|
29
|
-
// Windows moves the window itself, as it does from a native title bar
|
|
28
|
+
startWindowDrag(press) {
|
|
29
|
+
// Windows moves the window itself, as it does from a native title bar, and
|
|
30
|
+
// a double-click there maximizes or restores it.
|
|
30
31
|
if (detectPlatform() === "win32") {
|
|
32
|
+
if (press?.detail === 2) {
|
|
33
|
+
void backendRequest("host.windowControl", { action: "toggle-maximize" }).catch((error: unknown) => {
|
|
34
|
+
console.error("[desktop] could not maximize the window", error instanceof Error ? error.message : String(error));
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
31
38
|
void backendRequest("host.startWindowDrag").catch((error: unknown) => {
|
|
32
39
|
console.error("[desktop] could not start moving the window", error instanceof Error ? error.message : String(error));
|
|
33
40
|
});
|
package/src/series/resolve.ts
CHANGED
|
@@ -195,7 +195,7 @@ export async function resolveLoadedSeries(
|
|
|
195
195
|
): Promise<ResolvedSeries> {
|
|
196
196
|
const ticker = request.ticker?.trim().toUpperCase() || undefined;
|
|
197
197
|
const line = definition.lines?.length ? request.line ?? definition.lines[0] : undefined;
|
|
198
|
-
const { points, edge, warning, attribution } = loadResult(await loadSeries(definition, {
|
|
198
|
+
const { points, edge, warning, savedAt, attribution } = loadResult(await loadSeries(definition, {
|
|
199
199
|
...seriesLoadWindow(request.viewport, now),
|
|
200
200
|
...(ticker ? { ticker } : {}),
|
|
201
201
|
...viewportBars(request.viewport, ticker),
|
|
@@ -221,6 +221,7 @@ export async function resolveLoadedSeries(
|
|
|
221
221
|
panelId: "main",
|
|
222
222
|
interpolation: style === "step" ? "step-after" : "none",
|
|
223
223
|
...(warning ? { warning } : {}),
|
|
224
|
+
...(savedAt != null && Number.isFinite(savedAt) ? { savedAt } : {}),
|
|
224
225
|
...(attribution ? { attribution: { ...attribution } } : {}),
|
|
225
226
|
points: normalizeSeriesPoints(points, edge),
|
|
226
227
|
};
|
package/src/series/types.ts
CHANGED
|
@@ -92,6 +92,11 @@ export interface SeriesLoadResult {
|
|
|
92
92
|
points: readonly SeriesPoint[];
|
|
93
93
|
/** A note the chart shows above the plot, such as "cached data is stale". */
|
|
94
94
|
warning?: string;
|
|
95
|
+
/**
|
|
96
|
+
* When the copy the points came from was saved (ms), when its refresh failed
|
|
97
|
+
* and that copy answered: the chart's footer says "saved copy from …".
|
|
98
|
+
*/
|
|
99
|
+
savedAt?: number;
|
|
95
100
|
/** What a report of this series cites in place of Gloom Cloud. */
|
|
96
101
|
attribution?: SeriesAttribution;
|
|
97
102
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createPluginCache } from "../../data/plugin-cache";
|
|
1
|
+
import { createPluginCache, savedCopyForTransientFailure } from "../../data/plugin-cache";
|
|
2
2
|
import { getPublishedUsEquityCalendarDay } from "../../market-data/published-us-sessions";
|
|
3
3
|
import { zonedDateKey } from "../../utils/zoned-date-time";
|
|
4
4
|
import type {
|
|
@@ -198,7 +198,9 @@ export async function loadCachedFredSeries(
|
|
|
198
198
|
const cached = stored ? withFredSourceFreshness(stored) : null;
|
|
199
199
|
if (!options?.force && !pending && cached && !cached.stale) return { ...cached, source: "cache" };
|
|
200
200
|
const result = await cache.load(cacheKey(request), loader, pending ? { ...options, force: true } : options);
|
|
201
|
-
// An early re-read is opportunistic: a failure keeps the still-fresh copy as it was.
|
|
201
|
+
// An early re-read is opportunistic: a failure keeps the still-fresh copy as it was, as not asking would have.
|
|
202
202
|
if (pending && result.source === "stale-fallback" && cached && !cached.stale && !options?.force) return { ...cached, source: "cache" };
|
|
203
|
-
|
|
203
|
+
// A refusal (a series no longer served, a signed-out session) or a reply that fails validation is thrown,
|
|
204
|
+
// whatever copy is saved: a saved copy answers only for a failure asking again may get past.
|
|
205
|
+
return withFredSourceFreshness(savedCopyForTransientFailure(result));
|
|
204
206
|
}
|
|
@@ -31,7 +31,18 @@ function parseStallReportState(text: string): StallReportState {
|
|
|
31
31
|
if (typeof at === "number" && Number.isFinite(at)) sent[key] = at;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
-
return { sent, pending: readCounts(record.pending) };
|
|
34
|
+
return { sent, pending: readCounts(record.pending), recent: readTimes(record.recent) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readTimes(value: unknown): NonNullable<StallReportState["recent"]> {
|
|
38
|
+
const recent: NonNullable<StallReportState["recent"]> = {};
|
|
39
|
+
if (!value || typeof value !== "object") return recent;
|
|
40
|
+
for (const [process, times] of Object.entries(value as Record<string, unknown>)) {
|
|
41
|
+
if (!Array.isArray(times)) continue;
|
|
42
|
+
const kept = times.filter((at): at is number => typeof at === "number" && Number.isFinite(at));
|
|
43
|
+
if (kept.length > 0) recent[process] = kept;
|
|
44
|
+
}
|
|
45
|
+
return recent;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
function trimLog(path: string): void {
|
|
@@ -62,7 +73,7 @@ export function createNodeStallReporterHost(options: {
|
|
|
62
73
|
},
|
|
63
74
|
load: () => {
|
|
64
75
|
const path = pathFor(STATE_FILE);
|
|
65
|
-
if (!path || !existsSync(path)) return { sent: {}, pending: {} };
|
|
76
|
+
if (!path || !existsSync(path)) return { sent: {}, pending: {}, recent: {} };
|
|
66
77
|
return parseStallReportState(readFileSync(path, "utf-8"));
|
|
67
78
|
},
|
|
68
79
|
save: (state) => {
|