@gloom-sh/terminal 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.
Files changed (138) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/errors.ts +38 -0
  3. package/src/app/runtime/startup.ts +3 -1
  4. package/src/capabilities/chart-series.ts +2 -1
  5. package/src/cli/commands/market.ts +9 -4
  6. package/src/cli/commands/overview.ts +5 -6
  7. package/src/cli/commands/search.ts +13 -13
  8. package/src/cli/context.ts +1 -0
  9. package/src/cli/glossary.ts +2 -1
  10. package/src/cli/history-rows.ts +35 -7
  11. package/src/cli/known-gaps.ts +11 -0
  12. package/src/cli/listing-arg.ts +17 -1
  13. package/src/cli/quote-answer.ts +29 -6
  14. package/src/components/command-bar/routes/root/runtime.ts +27 -5
  15. package/src/components/command-bar/selection-runtime.ts +12 -29
  16. package/src/components/layout/detached-pane-shell.tsx +2 -2
  17. package/src/components/layout/header.tsx +2 -2
  18. package/src/components/layout/pane/footer/index.tsx +1 -0
  19. package/src/components/layout/pane/footer/model.ts +33 -1
  20. package/src/components/layout/pane/header.tsx +2 -2
  21. package/src/components/layout/pane/status-footer.ts +2 -1
  22. package/src/components/onboarding/onboarding-frame.tsx +2 -1
  23. package/src/components/onboarding/onboarding-wizard.tsx +6 -3
  24. package/src/components/ui/display.tsx +16 -5
  25. package/src/components/ui/icon.tsx +8 -2
  26. package/src/core/app-services.ts +11 -2
  27. package/src/data/app-persistence.ts +9 -7
  28. package/src/data/plugin-cache.ts +15 -0
  29. package/src/data/plugin-state-store.ts +81 -24
  30. package/src/data/resource-store.ts +130 -58
  31. package/src/data/session-store.ts +31 -8
  32. package/src/data/sqlite/access.ts +313 -0
  33. package/src/data/sqlite/checkpointer.ts +198 -0
  34. package/src/data/sqlite/database.ts +47 -4
  35. package/src/data/sqlite/retry.ts +50 -9
  36. package/src/data/ticker-store.ts +57 -23
  37. package/src/market-data/realized-volatility.ts +4 -2
  38. package/src/plugins/auto-update-node.ts +10 -6
  39. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  40. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  41. package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
  42. package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
  43. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  44. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  45. package/src/plugins/builtin/cot/client.ts +28 -2
  46. package/src/plugins/builtin/cot/headless.ts +4 -1
  47. package/src/plugins/builtin/cot/index.tsx +4 -2
  48. package/src/plugins/builtin/cot/model.ts +58 -7
  49. package/src/plugins/builtin/cot/pane.tsx +6 -2
  50. package/src/plugins/builtin/cpi/client.ts +6 -5
  51. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  52. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  53. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  54. package/src/plugins/builtin/crypto-board/series.ts +4 -4
  55. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  56. package/src/plugins/builtin/doe/client.ts +6 -5
  57. package/src/plugins/builtin/doe/prices.ts +2 -3
  58. package/src/plugins/builtin/doe/series.ts +4 -4
  59. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  60. package/src/plugins/builtin/earnings-calls/data.ts +19 -11
  61. package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
  62. package/src/plugins/builtin/econ/detail-view.tsx +16 -2
  63. package/src/plugins/builtin/executives/data.ts +6 -1
  64. package/src/plugins/builtin/executives/pane.tsx +9 -21
  65. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  66. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  67. package/src/plugins/builtin/gpu/client.ts +4 -1
  68. package/src/plugins/builtin/gpu/series.ts +2 -3
  69. package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
  70. package/src/plugins/builtin/jodi/client.ts +4 -1
  71. package/src/plugins/builtin/market-movers/client.ts +1 -0
  72. package/src/plugins/builtin/market-movers/index.tsx +10 -5
  73. package/src/plugins/builtin/market-movers/screener.ts +23 -6
  74. package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
  75. package/src/plugins/builtin/money-markets/series.ts +3 -5
  76. package/src/plugins/builtin/power/gas-client.ts +2 -1
  77. package/src/plugins/builtin/power/prices-client.ts +3 -2
  78. package/src/plugins/builtin/risk-factors/data.ts +8 -4
  79. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  80. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  81. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  82. package/src/plugins/builtin/shared/cloud-resource.ts +13 -8
  83. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  84. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  85. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  86. package/src/plugins/builtin/short-volume/series.ts +2 -3
  87. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  88. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  89. package/src/plugins/builtin/thirteenf/api.ts +11 -16
  90. package/src/plugins/builtin/thirteenf/data.ts +34 -6
  91. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  92. package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
  93. package/src/plugins/builtin/thirteenf/types.ts +2 -0
  94. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
  95. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  96. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  97. package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
  98. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  99. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  100. package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
  101. package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
  102. package/src/plugins/builtin/volatility/client.ts +43 -16
  103. package/src/plugins/builtin/volatility/headless.ts +5 -2
  104. package/src/plugins/builtin/volatility/model.ts +17 -6
  105. package/src/plugins/builtin/volatility/pane.tsx +6 -2
  106. package/src/plugins/builtin/volatility/series.ts +2 -1
  107. package/src/plugins/builtin/yield-curve/client.ts +4 -6
  108. package/src/plugins/installer.ts +50 -39
  109. package/src/renderers/dom/desktop/icons.tsx +3 -2
  110. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  111. package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
  112. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  113. package/src/renderers/electrobun/bun/window/create.ts +2 -0
  114. package/src/renderers/electrobun/launcher/preload.ts +13 -0
  115. package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
  116. package/src/renderers/electrobun/view/ui-host.tsx +9 -2
  117. package/src/series/resolve.ts +2 -1
  118. package/src/series/types.ts +5 -0
  119. package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
  120. package/src/telemetry/stall-reports-node.ts +13 -2
  121. package/src/telemetry/stall-reports.ts +189 -13
  122. package/src/test-support/outage.ts +8 -0
  123. package/src/tickers/company-name-listing.ts +59 -29
  124. package/src/tickers/company-name.ts +65 -1
  125. package/src/tickers/search/home-listing.ts +93 -8
  126. package/src/tickers/search/index.ts +1 -1
  127. package/src/time-series/market.ts +5 -3
  128. package/src/time-series/price-comparison.ts +20 -2
  129. package/src/time-series/resolve.ts +20 -1
  130. package/src/time-series/types.ts +7 -0
  131. package/src/types/financials.ts +18 -3
  132. package/src/ui/host.tsx +6 -1
  133. package/src/utils/financial-statements.ts +73 -16
  134. package/src/utils/fundamentals.ts +17 -5
  135. package/src/utils/lease-liabilities.ts +54 -0
  136. package/src/utils/main-thread-stall.ts +47 -2
  137. package/src/utils/perf-marks.ts +23 -11
  138. package/src/utils/price-history-integrity.ts +47 -1
@@ -22,8 +22,18 @@ import { reportCrash } from "./crash-reports";
22
22
  * stall and how many there were. No free text: a section name keeps only
23
23
  * its fixed leading part, so a ticker in a queue key never leaves.
24
24
  *
25
+ * A report also says when in the process's life the stall came (still
26
+ * starting or up, and an uptime bucket), how long before it the named section
27
+ * ended (or that it ended inside it, which makes it the likely cause), and,
28
+ * for the desktop app's own process, whether the app was in front and had a
29
+ * window on screen: a hidden app's slowed timers read like a freeze. A stall
30
+ * after a SQLite statement names the statement (a fixed name), its tries and
31
+ * whether it waited on another program's lock. All of it is buckets and
32
+ * flags.
33
+ *
25
34
  * One report per process, bucket, section and app version a day; stalls of
26
- * the same kind after that are counted and carried in the next report.
35
+ * the same kind after that are counted and carried in the next report, and
36
+ * every report says how many stalls the process had in the last day.
27
37
  */
28
38
 
29
39
  /** Which process stalled. */
@@ -41,9 +51,34 @@ export interface StallReport {
41
51
  count: number;
42
52
  /** The longest of them, rounded to 100 ms. */
43
53
  longestMs: number;
54
+ /** This process's stalls of a second or more in the last 24 hours, this one included. */
55
+ day: number;
56
+ /** Whether the process had finished starting. */
57
+ phase: "startup" | "steady" | "unknown";
58
+ uptime: UptimeBucket | "unknown";
59
+ /** How long before the stall began the section ended; "in" when it ended inside it. */
60
+ gap: GapBucket;
61
+ /** The desktop app's own process: whether the app was in front (null: not known yet). */
62
+ active?: boolean | null;
63
+ /** The desktop app's own process: whether a window was open and not minimized. */
64
+ visible?: boolean | null;
65
+ /** After a SQLite statement: how many tries it took. */
66
+ attempts?: number;
67
+ /** After a SQLite statement: whether it waited on another connection's lock. */
68
+ lockWait?: boolean;
44
69
  }
45
70
 
46
71
  type StallBucket = "1-2s" | "2-5s" | "5-15s" | "15s+";
72
+ type UptimeBucket = "<30s" | "30s-2m" | "2-10m" | "10-60m" | "1-6h" | "6-24h" | "1d+";
73
+ type GapBucket = "none" | "in" | "<1s" | "1-10s" | "10-60s" | "1-10m" | "10m+";
74
+
75
+ /** What the desktop app's Bun process can tell about whether anyone was looking. */
76
+ export interface StallAttention {
77
+ /** One of the app's windows had the keyboard; null before the first focus change. */
78
+ active: boolean | null;
79
+ /** One of the app's windows was open and not minimized; null when it could not be read. */
80
+ visible: boolean | null;
81
+ }
47
82
 
48
83
  /** What the host keeps on disk between launches. */
49
84
  export interface StallReportState {
@@ -51,6 +86,8 @@ export interface StallReportState {
51
86
  sent: Record<string, number>;
52
87
  /** Stalls counted since each kind was last reported. */
53
88
  pending: Record<string, { count: number; longestMs: number }>;
89
+ /** When each process's stalls of a second or more in the last day happened, oldest first. */
90
+ recent?: Record<string, number[]>;
54
91
  }
55
92
 
56
93
  export interface StallReporterHost {
@@ -74,8 +111,13 @@ const SECTION_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*/;
74
111
  const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]{1,60})?$/;
75
112
  /** Kinds with counts waiting for their next report; the oldest go first past this. */
76
113
  const MAX_KINDS_KEPT = 200;
114
+ /** Stall times kept per process for the day count. */
115
+ const MAX_RECENT_KEPT = 500;
116
+ /** What error tracking keeps of the details line; fields that do not fit are left out. */
117
+ const MAX_DETAILS_LENGTH = 120;
77
118
 
78
119
  let host: StallReporterHost | null = null;
120
+ let attentionProbe: (() => StallAttention) | null = null;
79
121
 
80
122
  /** The fixed part of a section name: lower-case dotted words, cut where anything else starts. */
81
123
  function stallSectionName(name: string | undefined): string {
@@ -85,6 +127,15 @@ function stallSectionName(name: string | undefined): string {
85
127
  return section && section.length <= 80 ? section : "unmeasured";
86
128
  }
87
129
 
130
+ /** The section a report names; a SQLite statement's adds the statement's fixed name. */
131
+ function reportSection(event: StallEvent): string {
132
+ const section = stallSectionName(event.lastMeasured);
133
+ if (section !== "sqlite.blocked" || !event.sqlite) return section;
134
+ const operation = event.sqlite.operation.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
135
+ const named = `${section}.${operation}`.replace(/-+$/, "");
136
+ return SECTION_PATTERN.exec(named)?.[0] === named ? named : section;
137
+ }
138
+
88
139
  function stallBucket(blockedMs: number): StallBucket {
89
140
  if (blockedMs < 2_000) return "1-2s";
90
141
  if (blockedMs < 5_000) return "2-5s";
@@ -92,15 +143,50 @@ function stallBucket(blockedMs: number): StallBucket {
92
143
  return "15s+";
93
144
  }
94
145
 
146
+ function uptimeBucket(uptimeMs: number | undefined): UptimeBucket | "unknown" {
147
+ if (uptimeMs === undefined) return "unknown";
148
+ const minutes = uptimeMs / 60_000;
149
+ if (minutes < 0.5) return "<30s";
150
+ if (minutes < 2) return "30s-2m";
151
+ if (minutes < 10) return "2-10m";
152
+ if (minutes < 60) return "10-60m";
153
+ if (minutes < 360) return "1-6h";
154
+ if (minutes < 1_440) return "6-24h";
155
+ return "1d+";
156
+ }
157
+
158
+ /**
159
+ * How long before the stall began the last measured section ended. A
160
+ * section that ended inside the stall ran during it ("in"): the likely
161
+ * cause. Anything after that is time nothing measured ran in.
162
+ */
163
+ function gapBucket(event: StallEvent): GapBucket {
164
+ if (!event.lastMeasured || event.lastMeasuredEndedMsAgo === undefined) return "none";
165
+ const gapMs = event.lastMeasuredEndedMsAgo - event.blockedMs;
166
+ if (gapMs <= 0) return "in";
167
+ if (gapMs < 1_000) return "<1s";
168
+ if (gapMs < 10_000) return "1-10s";
169
+ if (gapMs < 60_000) return "10-60s";
170
+ if (gapMs < 600_000) return "1-10m";
171
+ return "10m+";
172
+ }
173
+
95
174
  /** A stall another process sent over, kept only when it has the right shape. */
96
175
  export function parseStallEvent(value: unknown): StallEvent | null {
97
176
  if (!value || typeof value !== "object") return null;
98
- const { blockedMs, lastMeasured, lastMeasuredEndedMsAgo } = value as Record<string, unknown>;
177
+ const { blockedMs, lastMeasured, lastMeasuredEndedMsAgo, sqlite, uptimeMs, phase } = value as Record<string, unknown>;
99
178
  if (typeof blockedMs !== "number" || !Number.isFinite(blockedMs) || blockedMs <= 0) return null;
179
+ const detail = sqlite && typeof sqlite === "object" ? sqlite as Record<string, unknown> : null;
100
180
  return {
101
181
  blockedMs,
102
182
  ...(typeof lastMeasured === "string" && lastMeasured.length <= 200 ? { lastMeasured } : {}),
103
183
  ...(typeof lastMeasuredEndedMsAgo === "number" && Number.isFinite(lastMeasuredEndedMsAgo) ? { lastMeasuredEndedMsAgo } : {}),
184
+ ...(detail && typeof detail.operation === "string" && detail.operation.length <= 80
185
+ && typeof detail.attempts === "number" && Number.isFinite(detail.attempts)
186
+ ? { sqlite: { operation: detail.operation, attempts: detail.attempts, lockWait: detail.lockWait === true } }
187
+ : {}),
188
+ ...(typeof uptimeMs === "number" && Number.isFinite(uptimeMs) && uptimeMs >= 0 ? { uptimeMs } : {}),
189
+ ...(phase === "startup" || phase === "steady" ? { phase } : {}),
104
190
  };
105
191
  }
106
192
 
@@ -108,13 +194,43 @@ function formatSeconds(ms: number): string {
108
194
  return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`;
109
195
  }
110
196
 
111
- /** One line of the stall log: when, which process, how long, and what ran last. */
112
- function formatStallLogLine(process: StallProcess, event: StallEvent, at: number): string {
197
+ function flag(value: boolean | null | undefined): string {
198
+ return value == null ? "?" : value ? "1" : "0";
199
+ }
200
+
201
+ /** The context a report carries, also written at the end of the log line. */
202
+ interface StallContext {
203
+ phase: StallReport["phase"];
204
+ uptime: StallReport["uptime"];
205
+ gap: GapBucket;
206
+ attention: StallAttention | null;
207
+ }
208
+
209
+ function stallContext(event: StallEvent, attention: StallAttention | null): StallContext {
210
+ return {
211
+ phase: event.phase ?? "unknown",
212
+ uptime: uptimeBucket(event.uptimeMs),
213
+ gap: gapBucket(event),
214
+ attention,
215
+ };
216
+ }
217
+
218
+ /** One line of the stall log: when, which process, how long, what ran last, and when in the process's life. */
219
+ function formatStallLogLine(process: StallProcess, event: StallEvent, context: StallContext, at: number): string {
113
220
  const time = new Date(at).toISOString().replace(/\.\d{3}Z$/, "Z");
114
221
  const last = event.lastMeasured
115
222
  ? `last measured: ${event.lastMeasured}${event.lastMeasuredEndedMsAgo === undefined ? "" : `, ended ${Math.round(event.lastMeasuredEndedMsAgo)} ms before`}`
116
223
  : "nothing measured before it";
117
- return `${time} ${process.padEnd(8)} froze ${formatSeconds(event.blockedMs).padStart(6)} ${last}`;
224
+ const sqlite = event.sqlite
225
+ ? ` (${event.sqlite.operation}, ${event.sqlite.attempts} ${event.sqlite.attempts === 1 ? "try" : "tries"}${event.sqlite.lockWait ? ", waited on a lock" : ""})`
226
+ : "";
227
+ const facts = [
228
+ `phase=${context.phase}`,
229
+ `up=${context.uptime}`,
230
+ `gap=${context.gap}`,
231
+ ...(context.attention ? [`fg=${flag(context.attention.active)}`, `vis=${flag(context.attention.visible)}`] : []),
232
+ ].join(" ");
233
+ return `${time} ${process.padEnd(8)} froze ${formatSeconds(event.blockedMs).padStart(6)} ${last}${sqlite} [${facts}]`;
118
234
  }
119
235
 
120
236
  function reportPlatform(platform: string): StallReport["platform"] {
@@ -128,13 +244,35 @@ function reportArch(arch: string): StallReport["arch"] {
128
244
  return arch === "arm64" || arch === "x64" ? arch : "other";
129
245
  }
130
246
 
131
- function sendThroughCrashReporter(report: StallReport): void {
132
- const details = [
247
+ /**
248
+ * The details line error tracking keeps, most telling first: the original
249
+ * four fields, then the new ones. One that would not fit is left out.
250
+ */
251
+ export function formatStallReportDetails(report: StallReport): string {
252
+ const fields = [
133
253
  `process=${report.process}`,
134
254
  `count=${report.count}`,
135
255
  `longest=${report.longestMs}ms`,
136
256
  `platform=${report.platform}-${report.arch}`,
137
- ].join(" ");
257
+ `phase=${report.phase}`,
258
+ `up=${report.uptime}`,
259
+ `gap=${report.gap}`,
260
+ ...(report.active === undefined ? [] : [`fg=${flag(report.active)}`]),
261
+ ...(report.visible === undefined ? [] : [`vis=${flag(report.visible)}`]),
262
+ ...(report.lockWait === undefined ? [] : [`lock=${flag(report.lockWait)}`]),
263
+ ...(report.attempts === undefined ? [] : [`try=${report.attempts}`]),
264
+ `day=${report.day}`,
265
+ ];
266
+ let details = "";
267
+ for (const field of fields) {
268
+ const next = details ? `${details} ${field}` : field;
269
+ if (next.length <= MAX_DETAILS_LENGTH) details = next;
270
+ }
271
+ return details;
272
+ }
273
+
274
+ function sendThroughCrashReporter(report: StallReport): void {
275
+ const details = formatStallReportDetails(report);
138
276
  // A plain object, not an Error: it has no stack, so no file path goes along.
139
277
  // The message is the bucket and section, so error tracking keeps one issue per kind.
140
278
  reportCrash(
@@ -146,10 +284,10 @@ function sendThroughCrashReporter(report: StallReport): void {
146
284
  function loadState(current: StallReporterHost): StallReportState {
147
285
  try {
148
286
  const state = current.load();
149
- return { sent: { ...state.sent }, pending: { ...state.pending } };
287
+ return { sent: { ...state.sent }, pending: { ...state.pending }, recent: { ...state.recent } };
150
288
  } catch (error) {
151
289
  logFailure("telemetry-stalls", "Could not read stall report state")(error);
152
- return { sent: {}, pending: {} };
290
+ return { sent: {}, pending: {}, recent: {} };
153
291
  }
154
292
  }
155
293
 
@@ -159,6 +297,22 @@ function prune(state: StallReportState, now: number): void {
159
297
  }
160
298
  const kinds = Object.keys(state.pending);
161
299
  for (const key of kinds.slice(0, Math.max(0, kinds.length - MAX_KINDS_KEPT))) delete state.pending[key];
300
+ for (const [process, times] of Object.entries(state.recent ?? {})) {
301
+ const kept = times.filter((at) => now - at < DAY_MS && at <= now);
302
+ if (kept.length > 0) state.recent![process] = kept;
303
+ else delete state.recent![process];
304
+ }
305
+ }
306
+
307
+ /** Never throws: a probe that fails reads as not known. */
308
+ function readAttention(): StallAttention | null {
309
+ if (!attentionProbe) return null;
310
+ try {
311
+ return attentionProbe();
312
+ } catch {
313
+ // Intentional ignore: a window that cannot be read leaves the report saying it is not known.
314
+ return { active: null, visible: null };
315
+ }
162
316
  }
163
317
 
164
318
  /** Records one stall of `process`. Does nothing where no host is installed; never throws. */
@@ -167,15 +321,20 @@ export function recordStall(process: StallProcess, event: StallEvent): void {
167
321
  if (!current || !(event.blockedMs > 0)) return;
168
322
  try {
169
323
  const now = current.now?.() ?? Date.now();
170
- current.appendLog(formatStallLogLine(process, event, now));
171
- if (event.blockedMs < STALL_REPORT_MIN_MS) return;
324
+ const reportable = event.blockedMs >= STALL_REPORT_MIN_MS;
325
+ // Asked only of a stall that will be reported: the probe reads the windows.
326
+ const context = stallContext(event, reportable && process === "app" ? readAttention() : null);
327
+ current.appendLog(formatStallLogLine(process, event, context, now));
328
+ if (!reportable) return;
172
329
  const appVersion = (current.appVersion ?? VERSION).trim().replace(/^v/, "");
173
- const section = stallSectionName(event.lastMeasured);
330
+ const section = reportSection(event);
174
331
  const bucket = stallBucket(event.blockedMs);
175
332
  const version = VERSION_PATTERN.test(appVersion) ? appVersion : "unknown";
176
333
  const key = `${process}|${bucket}|${section}|${version}`;
177
334
  const state = loadState(current);
178
335
  prune(state, now);
336
+ const recent = [...(state.recent?.[process] ?? []), now].slice(-MAX_RECENT_KEPT);
337
+ state.recent = { ...state.recent, [process]: recent };
179
338
  const pending = state.pending[key] ?? { count: 0, longestMs: 0 };
180
339
  pending.count += 1;
181
340
  pending.longestMs = Math.max(pending.longestMs, Math.round(event.blockedMs / 100) * 100);
@@ -193,6 +352,14 @@ export function recordStall(process: StallProcess, event: StallEvent): void {
193
352
  arch: reportArch(current.arch),
194
353
  count: pending.count,
195
354
  longestMs: pending.longestMs,
355
+ day: recent.length,
356
+ phase: context.phase,
357
+ uptime: context.uptime,
358
+ gap: context.gap,
359
+ ...(context.attention ? { active: context.attention.active, visible: context.attention.visible } : {}),
360
+ ...(event.sqlite && section.startsWith("sqlite.blocked")
361
+ ? { attempts: Math.max(1, Math.min(99, Math.round(event.sqlite.attempts))), lockWait: event.sqlite.lockWait }
362
+ : {}),
196
363
  });
197
364
  }
198
365
  current.save(state);
@@ -208,7 +375,16 @@ export function installStallReporter(next: StallReporterHost): () => void {
208
375
  };
209
376
  }
210
377
 
378
+ /** How the desktop app's Bun process tells whether anyone was looking when it froze (bun/window/attention.ts). */
379
+ export function setStallAttentionProbe(next: (() => StallAttention) | null): () => void {
380
+ attentionProbe = next;
381
+ return () => {
382
+ if (attentionProbe === next) attentionProbe = null;
383
+ };
384
+ }
385
+
211
386
  /** Test seam. */
212
387
  export function resetStallReporterForTests(): void {
213
388
  host = null;
389
+ attentionProbe = null;
214
390
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * A request that never reached its server, with `message`: the failure a
3
+ * saved copy answers for (`isTransientFailure`), unlike a plain `Error`, which
4
+ * reads as a reply that failed validation.
5
+ */
6
+ export function outage(message = "Unable to connect. Is the computer able to access the url?"): Error {
7
+ return Object.assign(new Error(message), { code: "ConnectionRefused" });
8
+ }
@@ -3,9 +3,9 @@ import type { DataProvider } from "../types/data-provider";
3
3
  import type { Quote } from "../types/financials";
4
4
  import type { InstrumentSearchResult } from "../types/instrument";
5
5
  import { canonicalExchange, isUsListingExchange, parsePublicTickerKey } from "../utils/exchanges";
6
- import { companyNameMatch, sameCompany, shortCompanyName } from "./company-name";
6
+ import { companyLines, companyNameExtraWords, companyNameMatch, namedByService, runTogether, shortCompanyName } from "./company-name";
7
7
  import { LISTING_SEARCH_LIMIT } from "./search";
8
- import { lineDistance } from "./search/home-listing";
8
+ import { lineDistance, primaryCompanyLine } from "./search/home-listing";
9
9
  import { classifyInstrumentKind, isCryptoInstrumentType } from "./search/ranking";
10
10
  import { isProviderBusy, type ProviderBusyError } from "../sources/provider-errors";
11
11
  import { readTerminalListingCode, type TerminalListing, type TerminalListingCode } from "./terminal-codes";
@@ -17,6 +17,8 @@ export interface CompanyNameListing {
17
17
  exchange: string;
18
18
  /** The listing's name as search gives it. */
19
19
  name: string;
20
+ /** The service marked the listing with the name as typed (`searchAliases`): the company it means by it. */
21
+ named?: true;
20
22
  /**
21
23
  * When the listing is a fund or an index carrying the name ("JP Morgan" read
22
24
  * as ^JTEK, JP MORGAN ETF TRUS), the company's common stock the name says
@@ -67,7 +69,10 @@ export function isCompanyNameInput(input: string): boolean {
67
69
  * depositary receipt (SAP's NYSE line for "SAP SE"), so a receipt is the
68
70
  * listing only of a company listed by receipts alone (PDD); then the row the
69
71
  * service marks with the name, a US line, the shortest symbol (WHR before
70
- * WHR-A) and search's order. The row the service marks with the name as typed
72
+ * WHR-A) and search's order. The first then gives way to the line `search`
73
+ * calls the company's primary listing (`primaryCompanyLine`), though its name
74
+ * says more: "lufthansa" is LHA on XETRA, not LUFTHANSA on Budapest, a
75
+ * cross-listing with no search score. The row the service marks with the name as typed
71
76
  * (`searchAliases`: JPM for "JP Morgan", SIE for "SIEMENS AG", though its name
72
77
  * is Siemens Aktiengesellschaft) is named by it exactly. Contracts, preferred
73
78
  * shares and notes are never the listing. With `quoted`, a listing with no
@@ -95,44 +100,62 @@ export async function findListingOfCompanyName(
95
100
  const answers = await Promise.all(queries.map((query) => searchListings(dataProvider, query)));
96
101
  const busy = answers.find(isProviderBusy);
97
102
  const results = answers.flatMap((answer) => Array.isArray(answer) ? answer : []);
98
- const ranked = results.flatMap((result, order) => {
103
+ const listed = results.flatMap((result, order) => {
99
104
  const exchange = listedExchange(result);
100
105
  const type = listedType(result);
101
106
  const named = namedByService(name, result);
102
107
  const match = named ? { extra: 0, abbreviated: 0 } : companyNameMatch(name, result.name);
103
108
  if (!exchange || match == null || classifyInstrumentKind(type) === "derivative" || NOT_COMMON.test(type)
104
109
  || parsePublicTickerKey(result.symbol).exchange) return [];
105
- if (options.oneWord && (match.abbreviated > 0 || isCryptoInstrumentType(type) || !(order === 0 || named || match.extra === 0))) return [];
110
+ // A word alone names a company by these lines only; the company's other lines are still its lines.
111
+ const namesCompany = !(options.oneWord && (match.abbreviated > 0 || isCryptoInstrumentType(type) || !(order === 0 || named || match.extra === 0)));
106
112
  // A receipt is a company's shares too, however search types it ("Depositary Receipt" or "American Depositary Receipt").
107
113
  const stock = classifyInstrumentKind(type) === "equity" || RECEIPT.test(type) ? 0 : 1;
108
114
  const home = result.homeListing === true;
109
115
  const line = home ? 0 : 1 + lineDistance(type, exchange, result.symbol);
110
116
  return [{
111
- result, exchange, stock, home,
117
+ result, exchange, stock, home, namesCompany,
112
118
  rank: [match.extra, match.abbreviated, stock, line, named ? 0 : 1, isUsListingExchange(exchange) ? 0 : 1, result.symbol.length, order],
113
119
  }];
114
120
  }).sort((left, right) => {
115
121
  const at = left.rank.findIndex((value, index) => value !== right.rank[index]);
116
122
  return at < 0 ? 0 : left.rank[at]! - right.rank[at]!;
117
123
  });
118
- const first = ranked[0];
124
+ const first = listed.find((listing) => listing.namesCompany);
119
125
  // Listings are named in Latin letters: a name in another script ("任天堂 ソニー") names none, answered or not.
120
126
  if (!first && busy && !hasNonLatinLetters(name)) throw busy;
121
127
  if (!first) return null;
122
- // The first may give way to its company's other lines: as near the name and of its kind. Another line the
123
- // service marks as a home is another company's (Siemens Ltd. on NSE, for "SIEMENS AG"), and so is a line
124
- // named as that one is (Siemens Ltd. on BSE).
125
- const otherHomes = new Set(ranked.filter((listing) => listing.home && listing !== first).map(({ result }) => plainName(result.name)));
126
- otherHomes.delete(plainName(first.result.name));
127
- const lines = ranked.filter((listing) => listing === first || (!listing.home && !otherHomes.has(plainName(listing.result.name))
128
- && sameCompany(first.result.name, listing.result.name)
129
- && listing.rank.slice(0, NAME_RANKS).every((value, index) => value === first.rank[index])));
130
- const quoted = options.quoted ? await firstQuoted(dataProvider, lines, options.movedLine) : { line: first, moved: null };
128
+ // Another line the service marks as a home is another company's (Siemens Ltd. on NSE, for "SIEMENS AG"), and
129
+ // so is a line named as that one is (Siemens Ltd. on BSE).
130
+ const otherHomesThan = (lead: typeof first) => {
131
+ const homes = new Set(listed.filter((listing) => listing.home && listing !== lead).map(({ result }) => plainName(result.name)));
132
+ homes.delete(plainName(lead.result.name));
133
+ return homes;
134
+ };
135
+ // The listing `search` calls the company's primary leads, though its name says more than the one typed: the
136
+ // name typed opens what a search for it lists first (LHA on XETRA for "lufthansa", not LUFTHANSA on Budapest).
137
+ const named = namedByService(name, first.result);
138
+ const firstHomes = otherHomesThan(first);
139
+ // Never a line of another business its name is in (`companyLines`): Siemens Energy AG is not Siemens AG's.
140
+ const lead = first.home ? first : primaryCompanyLine(first, companyLines(first, listed, lineFacts).filter((listing) => listing === first
141
+ || (!listing.home && !firstHomes.has(plainName(listing.result.name)) && listing.stock === first.stock
142
+ && (!named || companyNameExtraWords(listing.result.name, first.result.name) != null))),
143
+ ({ result, exchange }) => ({ distance: lineDistance(listedType(result), exchange, result.symbol), score: result.popularity ?? null }),
144
+ { named });
145
+ // The lead may give way to its company's other lines: as near the name and of its kind.
146
+ const otherHomes = otherHomesThan(lead);
147
+ const lines = [lead, ...companyLines(lead, listed, lineFacts).filter((listing) => listing !== lead && !listing.home
148
+ && !otherHomes.has(plainName(listing.result.name))
149
+ && listing.rank.slice(0, NAME_RANKS).every((value, index) => value === lead.rank[index]))];
150
+ const quoted = options.quoted ? await firstQuoted(dataProvider, lines, options.movedLine) : { line: lead, moved: null };
131
151
  const best = quoted.line;
132
152
  const found: CompanyNameListing = quoted.moved
133
153
  // The line it moved to is a US symbol, read bare as `quote` reads one (GETYQ, which trades over the counter).
134
154
  ? { symbol: quoted.moved.symbol, exchange: "", name: best.result.name, moved: quoted.moved.note }
135
- : { symbol: best.result.symbol.toUpperCase(), exchange: best.exchange, name: best.result.name };
155
+ : {
156
+ symbol: best.result.symbol.toUpperCase(), exchange: best.exchange, name: best.result.name,
157
+ ...namedByService(name, best.result) ? { named: true as const } : {},
158
+ };
136
159
  const meant = best.stock && options.meant ? await stockNamedRunTogether(dataProvider, name, results) : null;
137
160
  return meant ? { ...found, meant } : found;
138
161
  }
@@ -199,6 +222,11 @@ async function firstQuoted<T extends RankedListing>(
199
222
  }
200
223
 
201
224
  /** A name as two venues may spell it alike: "SIEMENS LTD" for Siemens Ltd. */
225
+ /** A listing as `companyLines` weighs it. */
226
+ function lineFacts({ result, exchange, home }: { result: InstrumentSearchResult; exchange: string; home: boolean }) {
227
+ return { name: result.name, exchange, receipt: RECEIPT.test(listedType(result)), home };
228
+ }
229
+
202
230
  function plainName(name: string | null | undefined): string {
203
231
  return (name ?? "").normalize("NFKD").replace(/\p{M}/gu, "").toUpperCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
204
232
  }
@@ -211,6 +239,20 @@ function hasPrice(quote: { price?: number } | null | undefined): boolean {
211
239
  return !!quote && Number.isFinite(quote.price) && quote.price !== 0;
212
240
  }
213
241
 
242
+ /**
243
+ * Whether search marks a listing under another symbol with the word as typed
244
+ * (`searchAliases`): "siemens" marks SIE on XETRA, whose symbol is not the
245
+ * word. One search, the one `findListingOfCompanyName` asks first, so asking
246
+ * this before it costs nothing more.
247
+ */
248
+ export async function marksAnotherListing(dataProvider: Partial<Pick<DataProvider, "search">>, word: string): Promise<boolean> {
249
+ // A source that cannot search marks nothing.
250
+ if (typeof dataProvider.search !== "function") return false;
251
+ const answer = await searchListings(dataProvider as Pick<DataProvider, "search">, word.trim());
252
+ return Array.isArray(answer) && answer.some((result) =>
253
+ result.symbol.toUpperCase() !== word.trim().toUpperCase() && namedByService(word, result));
254
+ }
255
+
214
256
  function searchListings(dataProvider: Pick<DataProvider, "search">, query: string): Promise<InstrumentSearchResult[] | ProviderBusyError> {
215
257
  return dataProvider.search(query, { limit: LISTING_SEARCH_LIMIT })
216
258
  .catch((error: unknown): InstrumentSearchResult[] | ProviderBusyError => {
@@ -228,18 +270,6 @@ function listedType(result: InstrumentSearchResult): string {
228
270
  return result.brokerContract?.secType || result.type || "";
229
271
  }
230
272
 
231
- /** Letters and digits only, in capitals: "JP Morgan" and "JPMorgan" are JPMORGAN. */
232
- function runTogether(text: string): string {
233
- return text.normalize("NFKD").replace(/\p{M}/gu, "").toUpperCase().replace(/[^\p{L}\p{N}]/gu, "");
234
- }
235
-
236
- /** Whether the service marked the row with the name as typed: the company it means by it. */
237
- export function namedByService(typed: string, result: InstrumentSearchResult): boolean {
238
- const wanted = runTogether(typed);
239
- return !!wanted && Array.isArray(result.searchAliases)
240
- && result.searchAliases.some((alias) => typeof alias === "string" && runTogether(alias) === wanted);
241
- }
242
-
243
273
  /**
244
274
  * The company's common stock on a US exchange whose name starts with the
245
275
  * typed words run together ("JPMorgan Chase & Co." for "JP Morgan"), first in
@@ -1,3 +1,5 @@
1
+ import type { InstrumentSearchResult } from "../types/instrument";
2
+
1
3
  /**
2
4
  * Company names as venues spell them, compared and shortened for sentences.
3
5
  * One company's listings carry its name with different legal forms and share
@@ -9,7 +11,7 @@
9
11
  const NAME_NOISE = new Set([
10
12
  "co", "company", "corp", "corporation", "inc", "incorporated", "ltd", "limited", "llc", "lp", "plc", "sa", "ag", "nv", "se",
11
13
  "spa", "ab", "as", "asa", "oyj", "bv", "gmbh", "kgaa", "bhd", "berhad", "pcl", "the", "adr", "ads", "cedear", "cedears",
12
- "sponsored", "unsponsored", "cdr", "cdi",
14
+ "sponsored", "unsponsored", "cdr", "cdi", "aktiengesellschaft", "aktiebolag",
13
15
  ]);
14
16
  // Words a venue adds to the share it lists ("Microsoft Corporation Common Stock"), never part of whose it is.
15
17
  const SHARE_NOISE = new Set(["common", "ordinary", "stock", "shares"]);
@@ -43,6 +45,56 @@ export function sameCompany(left: string | null | undefined, right: string | nul
43
45
  return [...fewer].every((word) => more.has(word));
44
46
  }
45
47
 
48
+ // Words that tell one class of a company's shares from another ("Berkshire Hathaway Inc. Class B"), never its business.
49
+ const SHARE_CLASS_WORD = /^(?:class|series|[a-z]|\d+)$/;
50
+
51
+ /** Whether two names differ only in how they name a class of shares, so they are one company's whatever the venue. */
52
+ function sameBusinessWords(left: Set<string>, right: Set<string>): boolean {
53
+ const differ = [...left].filter((word) => !right.has(word)).concat([...right].filter((word) => !left.has(word)));
54
+ return differ.every((word) => SHARE_CLASS_WORD.test(word));
55
+ }
56
+
57
+ /** A company line as `companyLines` weighs it. */
58
+ export interface CompanyLineFacts {
59
+ name: string | null | undefined;
60
+ /** The line's canonical venue. */
61
+ exchange: string;
62
+ /** A depositary receipt, whose name says whose shares it holds rather than whose it is. */
63
+ receipt: boolean;
64
+ /** The service marks it as its company's home line (`homeListing`). */
65
+ home: boolean;
66
+ }
67
+
68
+ /**
69
+ * The lines among `lines` that are `lead`'s company's own (`sameCompany`),
70
+ * less those under a name that adds words for another business, though one
71
+ * name's words are all in the other's: Siemens Energy AG and Siemens
72
+ * Healthineers AG are not Siemens AG, nor Sony Financial Group Sony Group. A
73
+ * name is another business's where a venue lists a line under it beside a
74
+ * line under the lead's own name (both on XETRA, both in Tokyo), or where it
75
+ * and a line under the lead's name are each marked as a home line, since a
76
+ * company has one (Siemens Ltd. on NSE beside Siemens AG on XETRA). Receipts
77
+ * are named for the shares they hold, so they never tell two businesses
78
+ * apart, and nor do words that name a class of shares (Class B). A name with
79
+ * more words on no shared venue stays the company's (Deutsche Lufthansa AG on
80
+ * XETRA for LUFTHANSA on Budapest). `lead` is always one of them.
81
+ */
82
+ export function companyLines<T>(lead: T, lines: readonly T[], facts: (line: T) => CompanyLineFacts): T[] {
83
+ const own = facts(lead);
84
+ const ownWords = companyWords(own.name);
85
+ const ownName = (line: CompanyLineFacts) => sameBusinessWords(companyWords(line.name), ownWords);
86
+ const candidates = lines.filter((line) => line === lead || sameCompany(own.name, facts(line).name));
87
+ const read = candidates.map((line) => ({ line, facts: facts(line), words: companyWords(facts(line).name) }));
88
+ const ownShares = read.filter((entry) => !entry.facts.receipt && ownName(entry.facts));
89
+ const ownVenues = new Set(ownShares.map((entry) => entry.facts.exchange).filter(Boolean));
90
+ const ownHome = ownShares.some((entry) => entry.facts.home);
91
+ // A name another business goes by, once one of its share lines gives it away: all its lines are that business's.
92
+ const others = read.filter((entry) => !entry.facts.receipt && !ownName(entry.facts)
93
+ && ((!!entry.facts.exchange && ownVenues.has(entry.facts.exchange)) || (entry.facts.home && ownHome)));
94
+ const otherBusiness = (words: Set<string>) => others.some((other) => sameBusinessWords(other.words, words));
95
+ return read.filter((entry) => entry.line === lead || ownName(entry.facts) || !otherBusiness(entry.words)).map((entry) => entry.line);
96
+ }
97
+
46
98
  /**
47
99
  * How many words a listing's name has beyond a company name typed for it,
48
100
  * which it must have every word of: 0 for the same name however each spells
@@ -234,3 +286,15 @@ export function companyNameCase(name: string): string {
234
286
  }).join("");
235
287
  });
236
288
  }
289
+
290
+ /** Letters and digits only, in capitals: "JP Morgan" and "JPMorgan" are JPMORGAN. */
291
+ export function runTogether(text: string): string {
292
+ return text.normalize("NFKD").replace(/\p{M}/gu, "").toUpperCase().replace(/[^\p{L}\p{N}]/gu, "");
293
+ }
294
+
295
+ /** Whether the service marked the row with the name as typed: the company it means by it. */
296
+ export function namedByService(typed: string, result: Pick<InstrumentSearchResult, "searchAliases">): boolean {
297
+ const wanted = runTogether(typed);
298
+ return !!wanted && Array.isArray(result.searchAliases)
299
+ && result.searchAliases.some((alias) => typeof alias === "string" && runTogether(alias) === wanted);
300
+ }