@phosphorco/bb-thread-metrics 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTRACT.md CHANGED
@@ -1,4 +1,4 @@
1
- # Thread metrics wire v1 (@phosphorco/bb-thread-metrics 0.1.0)
1
+ # Thread metrics wire v1 (@phosphorco/bb-thread-metrics 0.2.0 candidate)
2
2
 
3
3
  | Supplier owns | Package owns | Host owns |
4
4
  | --- | --- | --- |
@@ -316,3 +316,215 @@ obsolete outcomes. These post-await fences are required even when the SDK claims
316
316
  cancellation support. Keep last good data during failure according to the host's
317
317
  staleness policy; a late response cannot revive a disposed/replaced demand.
318
318
  No package-global lifecycle or client cancellation machinery is introduced.
319
+
320
+
321
+ ## Additive recent snapshot capability (package0.2)
322
+
323
+ The new optional boolean `recentSnapshotV1` capability requires the exact fixed
324
+ `threadMetricsRecentSnapshotV1` handler; registration rejects either mismatch.
325
+ Input is strict `{ifRevision?:string}` within1024 canonical UTF-8 JSON bytes.
326
+ Full replies have wire version1, `unchanged:false`, opaque revision, immutable
327
+ publication time, rank time,72h recent window, a published config revision/value,
328
+ explicit coverage/materialization-pending/cap state and rows. Unchanged replies
329
+ have version1, `unchanged:true`, revision and no recognized full-only payload.
330
+ Recognized rows/config/times/population/coverage/cap fields are rejected BEFORE
331
+ stripping; unrelated future additive fields remain allowed within the byte cap. The revision
332
+ must exactly match requested ifRevision. A new supplier instance must not reuse a
333
+ revision for different content. Reads do not advance revision or run fold,
334
+ closure, rank or materializer work; suppliers serve a held published object.
335
+ Adapters validate shape/bytes but cannot prove supplier implementation purity.
336
+
337
+ Each row has independent own/family last-fold timestamps, asOf and content
338
+ revision; at least one activity timestamp must be in the envelope's72h window.
339
+ The row score-config revision must match the published envelope config. Own and
340
+ family each carry memberCount/coverage/priceVersion, compact approximation
341
+ provenance, four raw dollar windows and
342
+ provider-attributed dollar windows, omitted-provider count, four weighted-score
343
+ windows, and separately named dollar/score percentile ranks. `estimate`/`floor`
344
+ values are finite nonnegative amounts; `unavailable` has null amount. Explicit
345
+ floor0 is a supplier attestation; unsupported root zeros must be unavailable,
346
+ never inferred from a partial population or a missing row. Family providers must
347
+ represent deduplicated actual members, not sums of other family rows.
348
+
349
+ Full canonical JSON is capped at4MiB including additive unknown fields,1000 rows,
350
+ 8KiB per raw row before stripping, and32 provider entries per scope. Omitted
351
+ providers/rows, uncertain omission counts (`exact:false`, even lower-bound0),
352
+ and pending materialization cannot claim complete coverage. These
353
+ are post-transport decoded bounds, not raw-network/preparse allocation limits.
354
+ Providers select most recent rows within both row/byte caps, publish cutoff and
355
+ honest omission state; row omission alone proves neither absence nor zero. The
356
+ codec always checks finite known provider subtotals and weighted arithmetic,
357
+ including when other providers are unavailable or omitted. Available totals must
358
+ cover every known subtotal within eight relative machine epsilons (no absolute
359
+ small-dollar epsilon); incomplete provider sets or contributing floors
360
+ require a floor rather than an estimate. Unknown components may yield unavailable
361
+ totals, never invented zero. Complete provider sets reconcile exact totals.
362
+
363
+ Each scope AND provider has `approximation:{approximate,reasons}` independently
364
+ of observation coverage. Compact bounded reasons retain `closest-model`,
365
+ `provider-default`, `model-attribution`, `bucket-boundary`, `missing-price`, and
366
+ `source-gap`; aggregate provenance must include contributing provider reasons.
367
+ Unavailable prices require `missing-price`. Unavailable observations are different:
368
+ a partial/unknown projection with source-gap approximation and source-gap,
369
+ root-unattested, history-unavailable or missing-thread coverage may retain
370
+ unavailable values without claiming a price failure. Any explicit unpriced
371
+ coverage still requires missing-price, and provider missing-price reasons must
372
+ remain in aggregate approximation. Suppliers must not label an actual pricing
373
+ gap as an observation gap. This is the lossless reason mapping
374
+ from readV1; per-model rate associations remain in on-demand read/detail and are
375
+ not copied into listing rows. `priceVersion` still identifies the price table.
376
+ Real materializer conformance remains necessary before release.
377
+
378
+ Scoring-config schemaVersion2 is versioned separately from normalization semantics.
379
+ Default mode is percentile with gates .9 m20/h24/d7; omitted provider weights mean1.
380
+ Absolute mode supplies thresholds for all four windows. `weightedScoreV2` sums
381
+ provider dollars times weights, returns unknown when a component is unavailable,
382
+ and rejects invalid/duplicate provider values and arithmetic overflow. Weighting
383
+ never reprices the raw-dollar fields. UI, settings CAS and scoring policy remain
384
+ ledger/surface-owned; this package contains no settings storage or sliders.
385
+
386
+ Ranks cannot be non-null for an empty population; a positive singleton ranks0.
387
+ When rankAsOf is at or after the row value epoch, zero or unavailable amounts require null rank.
388
+ Older rank epochs may deliberately remain while newer amounts change; consumers
389
+ must gate heat on supported positive CURRENT amounts as well as ranks.
390
+ `recentPercentileHeat` applies that current weighted-score gate; legacy
391
+ `heat(ranks)` alone is insufficient for recent rows. This codec does not rebuild
392
+ or prove actual population percentiles.
393
+
394
+ Supplier ranks recompute at most once/minute outside reads. A publication binds
395
+ configuration and scored content atomically; pending changes retain explicit
396
+ coverage. Consumers hold the object by SDK-owner identity and use keyed row
397
+ selectors. They may conditionally poll at30s while interested/visible/connected,
398
+ coalescing existing hints without a per-thread request. Covered listing rows need
399
+ no readV1; explicit inspection outside the window may use the existing bounded
400
+ read/detail lane. The contract package itself never polls or retries.
401
+
402
+ ### Optional recent token/count observations
403
+
404
+ A recent scope may carry `usage` with aggregate and per-provider token windows,
405
+ explicit `estimate`/`floor`/`unavailable` values, matching provider omissions,
406
+ and a descendant count with an exactness flag. Missing `usage` is unsupported,
407
+ not zero. Positive tokens do not depend on a price match or lifetime dollar
408
+ observations: wholly unpriced and bucket-only tokens can remain floors while
409
+ USD is unavailable. Unknown/legacy token inputs stay unavailable. A supplied
410
+ zero is an explicit observation attestation.
411
+
412
+ Provider identities must match the displayed dollar providers. The codec checks
413
+ componentwise conservation of known token observations and reconciles descendant
414
+ count with the loaded membership; it does not certify unseen global membership.
415
+ Omitted providers permit a larger aggregate supported subtotal, never a smaller
416
+ one. Presentation fitting removes dollar/token provider entries together and
417
+ retains aggregate supported observations with an explicit cap qualification.
418
+
419
+ Per-provider dollar contributions are the primary money presentation. Aggregate
420
+ cross-provider dollar windows remain internal reconciliation/ranking inputs and
421
+ must not be presented as a grand total. Weighted attention scores are unitless,
422
+ not currency; token counts and descendants are secondary to provider dollars.
423
+
424
+ An aggregate token `estimate` requires estimate-kind tokens for every displayed
425
+ provider and no omitted providers in that window. A floor or unavailable provider
426
+ cannot be upgraded by numerical equality alone. This requirement is independent
427
+ of USD pricing coverage: completely observed zero or positive tokens may remain
428
+ estimates while dollars are unpriced. Explicit member-cap, depth-cap or edge-cap
429
+ coverage cannot carry `descendants.exact=true`; other supplier membership
430
+ attestations still require evidence the codec cannot reconstruct.
431
+
432
+
433
+ ### Unique seven-day root inputs (unpublished0.2 producer extension)
434
+
435
+ Optional `heatInputs` is a unique all-window root-family table captured in the SAME durable rank publication as `heat`. It is independent of `rows`, whose actual fold activity covers only72h and whose money may be newer. Never join current display money into captured ranks by ID, infer missing activity timestamps, or compare cross-epoch money as equal. Each input carries its financial `asOf`, config revision, explicit attested root topology, root-domain dollar/weighted ranks and all three decisions. `family.dollarRanks/scoreRanks` retain their original scoped meaning; root ranks live only under `heat`. Unknown metadata remains absent/null; title/native personal-or-project navigation is only bounded already-collected data, not identity/membership proof.
436
+
437
+ `cohortComplete` attests the supplied ROOT table, not complete source observation or exact spend. It requires no pending or omitted input and exact omission accounting. Per-window context population coverage and each family value/attribution remain independent. Full certified windows reconcile exact supported-positive counts and first-tie ordering against the complete table; partial transport preserves published ranks but cannot give exact reweighted draft percentiles. A five-day root can appear here without any72h display row. Hidden/archive activity is included; child/deleted/unknown topology never enters this table.
438
+
439
+ Display rows PLUS captured input rows share one aggregate1000 physical-entry budget, whole4MiB canonical JSON,8KiB per entry; duplicate IDs across these two DIFFERENT captures still consume two physical entries. Raw additive fields count before output stripping. Producer packing may retain only a subset; omissions/capped membership are explicit and prevent a complete input attestation. It never reconstructs from display rows or adds a per-ID population endpoint.
440
+
441
+ Optional scope `attribution` carries per-window `unknownProvider`, `attributionComplete` and `unpriced` evidence. Known provider USD is primary presentation. Cross-provider `windows` and unitless weighted `scores` are INTERNAL reconciliation/ranking inputs, not a dollar grand total. Unknown remainder is a lower bound/unavailable reconciliation amount, NEVER an invented provider at weight1. No omitted provider IDs and exact zero remainder are required for complete attribution; complete observation/price are separately required for estimates. Explicit unpriced usage retains missing-price provenance. Legacy scopes lacking this extension remain readable diagnostics; they cannot populate the new certified heat input table. No numeric evidence is invented on migration.
442
+
443
+ The supplier still attests actual membership and ordering; codecs prove internal coherence, not unseen global source truth. Bounds are post-transport parsing containment. The optional producer remains source-only until reviewed publication, exact registry pins, fresh composition gates and host byte/time measurement; the contract extension alone is no activation/adoption/freshness proof.
444
+
445
+
446
+ ### Full publication identity and ordered consumer admission
447
+
448
+ An additive full-only `publication:{instance,sequence}` header exposes the
449
+ supplier's committed publication identity. `instance` is an opaque1–128-character
450
+ string; `sequence` is a positive safe integer. Old stored/full responses may omit
451
+ this header and remain readable for legacy diagnostics. New changed publications
452
+ from the ledger include it. A Heat capture adopter requires the header; absence
453
+ is unavailable, never inferred by parsing `revision`, rank time or a request
454
+ ordinal. Unchanged replies carry only the previously requested opaque revision,
455
+ never a new identity or recognized full-only fields.
456
+
457
+ Within one durable source history, changed committed publications strictly
458
+ increase sequence across writer-instance changes. No-op attempts and reopening
459
+ retain the exact held revision/pair/content. The publisher injects revision and
460
+ pair inside its guarded commit and excludes BOTH from the semantic digest. The
461
+ pair is validated against the stored instance/counter on hydration; reads return
462
+ the stored payload identity, not the newly registered writer. Actual encoded
463
+ header bytes count toward the same4MiB cap; no new allowance is created.
464
+
465
+ A retained owner resource, outside component mounts, must order validated full
466
+ responses by sequence before feeding Heat: lower is stale; equal accepts only
467
+ the same immutable pair/revision/content; greater with a new instance is an
468
+ ordered writer successor. Null, ordinary reconnect, facade changes and timeouts
469
+ do not reset this watermark or release actual transport admission. Source
470
+ replacement fences captured old callbacks. These are adopter obligations, not
471
+ facts proved by a stateless wire decoder or the presence of this header.
472
+
473
+ The persisted counter does NOT attest order across external database rollback,
474
+ reset or replacement. Such images may repeat or lower sequences while changed
475
+ writer UUIDs keep revisions distinct. A UUID alone must never authorize a reset;
476
+ a retained consumer holds/reports lower or equal-conflicting data until an
477
+ explicit authoritative selected-source replacement. This unit adds no restore
478
+ protocol or automatic reset. Counter exhaustion is explicit refusal, never wrap.
479
+ Settings Save CAS remains keyed by config version, not publication sequence;
480
+ settings published metadata must describe the exact held publication/config.
481
+
482
+
483
+ ### Retained snapshot client session (unpublished0.2)
484
+
485
+ The optional `/bb` `createRecentSnapshotSession(ownerBoundClient)` helper is an
486
+ imperative admission and ordering boundary, owned by the existing selected-source
487
+ resource outside React. The surface owns discovery, source-selection authority,
488
+ refresh cadence, retries, observable state and rendering. The helper creates no
489
+ subscription, refresh loop, retry queue or shared global store; construction is
490
+ quiet. It holds at most one decoded immutable full snapshot and one actual read.
491
+ A native adopter must retain the SAME session through zero interest, remount,
492
+ ordinary reconnect and facade/writer changes while a read remains outstanding.
493
+ Independent factories do not share admission. This helper alone does not prove
494
+ an adopter's resource lifetime.
495
+
496
+ `read()` sends the held opaque revision conditionally; `read({full:true})` requests
497
+ a full response. Original full envelopes are publicly validated before ordering.
498
+ A full response requires the publication pair: lower sequences fail, equal
499
+ sequences require the same immutable pair/revision/decoded content, and greater
500
+ sequences must use a new opaque revision. An ordered new writer advances the
501
+ private owner generation once; an ordinary same-source facade update does not.
502
+ Valid unchanged content returns the exact held observation object, allowing the
503
+ caller to confirm recovery without allocating or notifying for changed data.
504
+ Null, invalid, missing-identity, stale or failed responses preserve last content
505
+ and highwater. Validation/content comparison still costs bounded payload work;
506
+ this is not a claim of constant-time end-to-end decoding.
507
+
508
+ The default local observation deadline is the existing2000ms contract call
509
+ limit (configurable1–2000ms). Timeout, caller abort, inactive state or explicit
510
+ replacement rejects local observation and aborts its signal, but the actual slot
511
+ remains held until the underlying source Promise settles, even if abort is
512
+ ignored. Busy/inactive/already-aborted reads return typed local
513
+ `RecentSnapshotNotStarted` without invoking the source. A source-thrown instance
514
+ of that type is wrapped and cannot claim this session's pre-invocation proof.
515
+ There is no queued replay. A permanently held transport keeps this lane
516
+ unavailable; caller deadline never proves remote cancellation.
517
+
518
+ `updateSource` is only a same-plugin facade update and retains ordering.
519
+ `replaceSource` requires authoritative selected-source replacement, fences old
520
+ callbacks and clears held lineage while retaining any actual old transport slot.
521
+ Do not invoke it for a new UUID, timeout, remount, lower sequence or an ordinary
522
+ reconnect. External restored/reset images require authority supplied by the
523
+ native resource; this API does not establish that authority. `setActive` changes
524
+ interest without clearing the lineage. Only admitted observations may feed Heat
525
+ owner-generation transitions. Publication identity is independent of matching
526
+ Save config bytes/version, which the retained Heat adapter must still check.
527
+
528
+ These are client source conformance guarantees, not released pins, native
529
+ resource adoption, host latency/freshness or live bytes/time acceptance. No
530
+ existing consumer is switched merely by exporting this helper.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @phosphorco/bb-thread-metrics
2
2
 
3
- Version 0.1.0: bounded dollar summaries, family closure, heat ranks and optional
3
+ Version0.2.0 candidate (not released): adds a bounded recent snapshot and scoring-configv2. Existing wirev1 summaries, family closure, heat ranks and optional
4
4
  on-demand breakdowns for independent BB plugins. See [CONTRACT.md](CONTRACT.md)
5
5
  for ownership, wire semantics and proof limits.
6
6
 
@@ -29,3 +29,30 @@ checks emitted declaration compatibility with server and browser SDKs. The
29
29
  `/testing` entry's `runSupplierConformance` accepts the adopter's real registration
30
30
  factory and representative read/detail cases. It returns explicit source
31
31
  conformance checks, never live host acceptance.
32
+
33
+
34
+ ## Recent snapshot (0.2 candidate)
35
+
36
+ Owners may advertise `capabilities.recentSnapshotV1:true` with a matching
37
+ `recentSnapshot(input)` handler. `createThreadMetricsClient(...).recentSnapshot({ifRevision?})`
38
+ returns the immutable precomputed object or an unchanged revision. Absence of this
39
+ optional capability leaves the0.1 read/detail contract intact. Old consumers may
40
+ ignore it. The adapter does not add refresh timers, caches or materialization.
41
+
42
+ The full response holds own/family raw estimated-dollar windows and provider
43
+ contributions, separately named weighted scores, raw dollar ranks and score ranks,
44
+ activity/materialization/rank times, coverage/cap/pending flags and the published
45
+ scoring configuration. Zero is explicit supplier evidence; omission/unavailable is
46
+ not zero. Config weights affect heat scores, never retained dollars. Defaults are
47
+ weight1 and current p90 percentile gates. Raw/scored populations remain distinct.
48
+
49
+ Bounds:72h recent activity,1000 rows,4MiB canonical JSON and8KiB/row,32 provider
50
+ entries per projection/config. Bounds include unknown additive fields before
51
+ schema stripping; the SDK has already parsed transport JSON. Providers enforce
52
+ materializer work/storage and publication bounds independently. Clients must not
53
+ turn row omission into an eager per-ID read storm. The planned coordinator uses
54
+ one held snapshot/mount+30s conditional cadence; the package schedules nothing.
55
+
56
+ Current source tests are package/adapter proof only. Registry release, ledger
57
+ materialization, live byte/time measurement, all consumer adoptions and native
58
+ host/freshness proof remain separate gates.
package/dist/bb.js CHANGED
@@ -1,6 +1,247 @@
1
1
  // src/bb.ts
2
2
  import { z } from "zod";
3
- import { LIMITS, METHODS, describeInputSchema, describeEnvelopeSchema, capabilitiesSchema, readInputSchema, detailInputSchema, parseBounded, negotiateVersion, validateReadResult, validateDetailResult, classifyOwnerError } from "./index.js";
3
+ import { HEAT_SETTINGS_METHODS, HEAT_SETTINGS_LIMITS, bounded, validateHeatSaveInput, heatSettingsReadInputSchema, heatSettingsSaveInputSchema, heatSettingsReadResultSchema, heatSettingsSaveResultSchema } from "./index.js";
4
+ import { RECENT_LIMITS, recentSnapshotInputSchema, validateRecentSnapshot as validateRecentSnapshot2, LIMITS as LIMITS2, METHODS, describeInputSchema, describeEnvelopeSchema, capabilitiesSchema, readInputSchema, detailInputSchema, parseBounded, negotiateVersion, validateReadResult, validateDetailResult, classifyOwnerError } from "./index.js";
5
+
6
+ // src/recent-session.ts
7
+ import { LIMITS, validateRecentSnapshot } from "./index.js";
8
+
9
+ class RecentSnapshotSessionError extends Error {
10
+ reason;
11
+ constructor(reason) {
12
+ super(`Recent snapshot session: ${reason}`);
13
+ this.reason = reason;
14
+ }
15
+ }
16
+
17
+ class RecentSnapshotNotStarted extends RecentSnapshotSessionError {
18
+ notStarted = true;
19
+ constructor(reason) {
20
+ super(reason);
21
+ }
22
+ }
23
+ var clock = { later(run, ms) {
24
+ const timer = setTimeout(run, ms);
25
+ return () => clearTimeout(timer);
26
+ } };
27
+ function next(n) {
28
+ if (n === Number.MAX_SAFE_INTEGER)
29
+ throw new Error("Recent session generation exhausted");
30
+ return n + 1;
31
+ }
32
+ function sourceCopy(source) {
33
+ if (!source.pluginId || source.pluginId.length > 200 || typeof source.recentSnapshot !== "function")
34
+ throw new Error("Invalid recent snapshot source");
35
+ return { pluginId: source.pluginId, recentSnapshot: source.recentSnapshot.bind(source) };
36
+ }
37
+ function freeze(value) {
38
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
39
+ for (const child of Object.values(value))
40
+ freeze(child);
41
+ Object.freeze(value);
42
+ }
43
+ return value;
44
+ }
45
+ function same(a, b) {
46
+ if (a === b)
47
+ return true;
48
+ if (!a || !b || typeof a !== "object" || typeof b !== "object")
49
+ return false;
50
+ if (Array.isArray(a) !== Array.isArray(b))
51
+ return false;
52
+ const ak = Object.keys(a), bk = Object.keys(b);
53
+ if (ak.length !== bk.length)
54
+ return false;
55
+ return ak.every((key) => Object.hasOwn(b, key) && same(a[key], b[key]));
56
+ }
57
+ function createRecentSnapshotSession(initial, options = {}) {
58
+ const timeoutMs = options.timeoutMs ?? LIMITS.callMs;
59
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > LIMITS.callMs)
60
+ throw new Error("Invalid recent session deadline");
61
+ const time = options.clock ?? clock;
62
+ let source = sourceCopy(initial), active = true, epoch = Symbol("view"), sourceGeneration = 1, ownerGeneration = 1;
63
+ let held;
64
+ let actual;
65
+ const admit = (input, raw, current) => {
66
+ if (!current())
67
+ throw new RecentSnapshotSessionError("stale-call");
68
+ const value = validateRecentSnapshot(input, raw);
69
+ if (!current())
70
+ throw new RecentSnapshotSessionError("stale-call");
71
+ if (value.unchanged) {
72
+ if (!held || held.snapshot.revision !== value.revision)
73
+ throw new RecentSnapshotSessionError("publication-conflict");
74
+ return held;
75
+ }
76
+ const publication = value.publication;
77
+ if (!publication)
78
+ throw new RecentSnapshotSessionError("missing-publication");
79
+ if (held) {
80
+ if (publication.sequence < held.publication.sequence)
81
+ throw new RecentSnapshotSessionError("stale-publication");
82
+ if (publication.sequence === held.publication.sequence) {
83
+ if (publication.instance !== held.publication.instance || value.revision !== held.snapshot.revision || !same(value, held.snapshot))
84
+ throw new RecentSnapshotSessionError("publication-conflict");
85
+ return held;
86
+ }
87
+ if (value.revision === held.snapshot.revision)
88
+ throw new RecentSnapshotSessionError("publication-conflict");
89
+ if (publication.instance !== held.publication.instance)
90
+ ownerGeneration = next(ownerGeneration);
91
+ }
92
+ held = freeze({ snapshot: value, publication, ownerGeneration, sourceGeneration });
93
+ return held;
94
+ };
95
+ return {
96
+ get current() {
97
+ return held;
98
+ },
99
+ get outstandingReads() {
100
+ return actual ? 1 : 0;
101
+ },
102
+ get ownerGeneration() {
103
+ return ownerGeneration;
104
+ },
105
+ get sourceGeneration() {
106
+ return sourceGeneration;
107
+ },
108
+ get pluginId() {
109
+ return source.pluginId;
110
+ },
111
+ updateSource(value) {
112
+ const updated = sourceCopy(value);
113
+ if (updated.pluginId !== source.pluginId)
114
+ throw new Error("Selected source change requires explicit replacement");
115
+ source = updated;
116
+ },
117
+ replaceSource(value) {
118
+ const updated = sourceCopy(value), sg = next(sourceGeneration), og = next(ownerGeneration);
119
+ source = updated;
120
+ sourceGeneration = sg;
121
+ ownerGeneration = og;
122
+ held = undefined;
123
+ epoch = Symbol("source");
124
+ actual?.cancel?.("source-replaced");
125
+ },
126
+ setActive(value) {
127
+ if (value === active)
128
+ return;
129
+ active = value;
130
+ epoch = Symbol("view");
131
+ if (!active)
132
+ actual?.cancel?.("inactive");
133
+ },
134
+ read(options2 = {}) {
135
+ const { signal, full } = options2;
136
+ options2 = {};
137
+ if (!active)
138
+ return Promise.reject(new RecentSnapshotNotStarted("inactive"));
139
+ if (signal?.aborted)
140
+ return Promise.reject(new RecentSnapshotNotStarted("aborted-before-start"));
141
+ if (actual)
142
+ return Promise.reject(new RecentSnapshotNotStarted("busy"));
143
+ const input = !full && held ? { ifRevision: held.snapshot.revision } : {};
144
+ let selected = source;
145
+ const binding = sourceGeneration, view = epoch, control = new AbortController, slot = { cancel: undefined };
146
+ actual = slot;
147
+ let finished = false;
148
+ const current = () => !finished && active && epoch === view && sourceGeneration === binding;
149
+ let resolve, reject;
150
+ const result = new Promise((yes, no) => {
151
+ resolve = yes;
152
+ reject = no;
153
+ });
154
+ let cancelTimer;
155
+ const finish = (error, observation) => {
156
+ if (finished)
157
+ return;
158
+ finished = true;
159
+ cancelTimer?.();
160
+ cancelTimer = undefined;
161
+ signal?.removeEventListener("abort", abort);
162
+ slot.cancel = undefined;
163
+ const yes = resolve, no = reject;
164
+ resolve = undefined;
165
+ reject = undefined;
166
+ if (observation === undefined)
167
+ no?.(error);
168
+ else
169
+ yes?.(observation);
170
+ };
171
+ const cancel = (reason) => {
172
+ finish(new RecentSnapshotSessionError(reason));
173
+ control.abort();
174
+ };
175
+ const abort = () => cancel("aborted");
176
+ slot.cancel = cancel;
177
+ signal?.addEventListener("abort", abort, { once: true });
178
+ try {
179
+ cancelTimer = time.later(() => cancel("timeout"), timeoutMs);
180
+ } catch (error) {
181
+ finish(error);
182
+ actual = undefined;
183
+ return result;
184
+ }
185
+ if (finished) {
186
+ actual = undefined;
187
+ cancelTimer?.();
188
+ return result;
189
+ }
190
+ let pending;
191
+ try {
192
+ pending = Promise.resolve(selected.recentSnapshot(Object.freeze(input), control.signal));
193
+ } catch (error) {
194
+ pending = Promise.reject(error);
195
+ } finally {
196
+ selected = undefined;
197
+ }
198
+ pending.then((value) => {
199
+ try {
200
+ if (!finished)
201
+ finish(undefined, admit(input, value, current));
202
+ } catch (error) {
203
+ finish(error);
204
+ } finally {
205
+ if (actual === slot)
206
+ actual = undefined;
207
+ }
208
+ }, (error) => {
209
+ try {
210
+ finish(error instanceof RecentSnapshotNotStarted ? new Error("Recent snapshot source rejected after invocation", { cause: error }) : error);
211
+ } finally {
212
+ if (actual === slot)
213
+ actual = undefined;
214
+ }
215
+ });
216
+ return result;
217
+ }
218
+ };
219
+ }
220
+
221
+ // src/bb.ts
222
+ function registerHeatSettingsOwner(bb, port) {
223
+ bb.rpc.register({
224
+ threadMetricsHeatSettingsReadV1: { input: sdkSchema(heatSettingsReadInputSchema, HEAT_SETTINGS_LIMITS.inputBytes), output: sdkSchema(z.unknown()) },
225
+ threadMetricsHeatSettingsSaveV1: { input: sdkSchema(heatSettingsSaveInputSchema, HEAT_SETTINGS_LIMITS.inputBytes), output: sdkSchema(z.unknown()) }
226
+ }, {
227
+ threadMetricsHeatSettingsReadV1: async (raw) => bounded(heatSettingsReadResultSchema, await port.read(bounded(heatSettingsReadInputSchema, raw, HEAT_SETTINGS_LIMITS.inputBytes)), HEAT_SETTINGS_LIMITS.outputBytes),
228
+ threadMetricsHeatSettingsSaveV1: async (raw) => bounded(heatSettingsSaveResultSchema, await port.save(validateHeatSaveInput(raw)), HEAT_SETTINGS_LIMITS.outputBytes)
229
+ }, { experimental_discoverable: true });
230
+ }
231
+ function createHeatSettingsClient(sdk, pluginId) {
232
+ if (!pluginId || pluginId.length > 200)
233
+ throw new Error("Invalid target plugin id");
234
+ const call = async (method, input, signal) => sdk.plugins.callRpc({ pluginId, method, input, outputSchema: z.unknown(), ...signal ? { signal } : {} });
235
+ return {
236
+ read: async (input, signal) => bounded(heatSettingsReadResultSchema, await call(HEAT_SETTINGS_METHODS.read, bounded(heatSettingsReadInputSchema, input, HEAT_SETTINGS_LIMITS.inputBytes), signal), HEAT_SETTINGS_LIMITS.outputBytes),
237
+ save: async (input, signal) => {
238
+ const result = bounded(heatSettingsSaveResultSchema, await call(HEAT_SETTINGS_METHODS.save, validateHeatSaveInput(input), signal), HEAT_SETTINGS_LIMITS.outputBytes);
239
+ if (result.operationKey !== input.operationKey)
240
+ throw new Error("Save operation mismatch");
241
+ return result;
242
+ }
243
+ };
244
+ }
4
245
  function sdkSchema(schema, maxBytes) {
5
246
  return { "~standard": { version: 1, vendor: "bb-thread-metrics", jsonSchema: { input: () => z.toJSONSchema(schema, { io: "input", unrepresentable: "any" }), output: () => z.toJSONSchema(schema, { io: "output", unrepresentable: "any" }) }, validate(value) {
6
247
  try {
@@ -12,29 +253,40 @@ function sdkSchema(schema, maxBytes) {
12
253
  }
13
254
  function registerThreadMetricsOwner(bb, port) {
14
255
  const capabilities = capabilitiesSchema.parse(port.capabilities);
256
+ if (Boolean(capabilities.recentSnapshotV1) !== Boolean(port.recentSnapshot))
257
+ throw new Error("Recent snapshot capability/handler mismatch");
258
+ if (Boolean(capabilities.heatSettingsV1) !== Boolean(port.heatSettings))
259
+ throw new Error("Heat settings capability/handler mismatch");
15
260
  if (capabilities.detail !== Boolean(port.detail))
16
261
  throw new Error("Detail capability/handler mismatch");
17
262
  const describe = () => ({ protocol: "bb-thread-metrics", versions: [1], capabilities });
18
263
  const contract = {
19
- threadMetricsDescribe: { input: sdkSchema(describeInputSchema, LIMITS.inputBytes), output: sdkSchema(z.unknown()) },
20
- threadMetricsReadV1: { input: sdkSchema(readInputSchema, LIMITS.inputBytes), output: sdkSchema(z.unknown()) }
264
+ threadMetricsDescribe: { input: sdkSchema(describeInputSchema, LIMITS2.inputBytes), output: sdkSchema(z.unknown()) },
265
+ threadMetricsReadV1: { input: sdkSchema(readInputSchema, LIMITS2.inputBytes), output: sdkSchema(z.unknown()) }
21
266
  };
22
267
  const handlers = {
23
268
  threadMetricsDescribe: (raw) => {
24
- parseBounded(describeInputSchema, raw, LIMITS.inputBytes);
269
+ parseBounded(describeInputSchema, raw, LIMITS2.inputBytes);
25
270
  return describe();
26
271
  },
27
272
  threadMetricsReadV1: async (raw) => {
28
- const input = parseBounded(readInputSchema, raw, LIMITS.inputBytes);
273
+ const input = parseBounded(readInputSchema, raw, LIMITS2.inputBytes);
29
274
  if (input.series && !capabilities.series)
30
275
  throw new Error("Series not supported");
31
276
  return validateReadResult(input, await port.read(input));
32
277
  }
33
278
  };
34
279
  bb.rpc.register(contract, handlers, { experimental_discoverable: true });
280
+ if (port.heatSettings)
281
+ registerHeatSettingsOwner(bb, port.heatSettings);
282
+ if (port.recentSnapshot)
283
+ bb.rpc.register({ threadMetricsRecentSnapshotV1: { input: sdkSchema(recentSnapshotInputSchema, RECENT_LIMITS.inputBytes), output: sdkSchema(z.unknown()) } }, { threadMetricsRecentSnapshotV1: async (raw) => {
284
+ const input = parseBounded(recentSnapshotInputSchema, raw, RECENT_LIMITS.inputBytes);
285
+ return validateRecentSnapshot2(input, await port.recentSnapshot(input));
286
+ } }, { experimental_discoverable: true });
35
287
  if (port.detail)
36
- bb.rpc.register({ threadMetricsDetailV1: { input: sdkSchema(detailInputSchema, LIMITS.inputBytes), output: sdkSchema(z.unknown()) } }, { threadMetricsDetailV1: async (raw) => {
37
- const input = parseBounded(detailInputSchema, raw, LIMITS.inputBytes);
288
+ bb.rpc.register({ threadMetricsDetailV1: { input: sdkSchema(detailInputSchema, LIMITS2.inputBytes), output: sdkSchema(z.unknown()) } }, { threadMetricsDetailV1: async (raw) => {
289
+ const input = parseBounded(detailInputSchema, raw, LIMITS2.inputBytes);
38
290
  return validateDetailResult(input, await port.detail(input));
39
291
  } }, { experimental_discoverable: true });
40
292
  }
@@ -48,26 +300,30 @@ function createThreadMetricsClient(sdk, pluginId) {
48
300
  pluginId,
49
301
  async describe(signal) {
50
302
  const raw = await call(METHODS.describe, {}, signal);
51
- return negotiateVersion(parseBounded(describeEnvelopeSchema, raw, LIMITS.describeBytes));
303
+ return negotiateVersion(parseBounded(describeEnvelopeSchema, raw, LIMITS2.describeBytes));
304
+ },
305
+ async recentSnapshot(raw = {}, signal) {
306
+ const input = parseBounded(recentSnapshotInputSchema, raw, RECENT_LIMITS.inputBytes);
307
+ return validateRecentSnapshot2(input, await call(METHODS.recentSnapshot, input, signal));
52
308
  },
53
309
  async read(raw, signal) {
54
- const input = parseBounded(readInputSchema, raw, LIMITS.inputBytes);
310
+ const input = parseBounded(readInputSchema, raw, LIMITS2.inputBytes);
55
311
  return validateReadResult(input, await call(METHODS.read, input, signal));
56
312
  },
57
313
  async detail(raw, signal) {
58
- const input = parseBounded(detailInputSchema, raw, LIMITS.inputBytes);
314
+ const input = parseBounded(detailInputSchema, raw, LIMITS2.inputBytes);
59
315
  return validateDetailResult(input, await call(METHODS.detail, input, signal));
60
316
  }
61
317
  };
62
318
  }
63
319
  async function discoverThreadMetricsOwners(options) {
64
- const bounded = (v, max) => {
320
+ const bounded2 = (v, max) => {
65
321
  const n = v ?? max;
66
322
  if (!Number.isInteger(n) || n < 1 || n > max)
67
323
  throw new Error("Invalid discovery budget");
68
324
  return n;
69
325
  };
70
- const count = bounded(options.maxCandidates, LIMITS.candidates), concurrency = bounded(options.concurrency, LIMITS.concurrency), callMs = bounded(options.callMs, LIMITS.callMs), deadlineMs = bounded(options.deadlineMs, LIMITS.discoveryMs);
326
+ const count = bounded2(options.maxCandidates, LIMITS2.candidates), concurrency = bounded2(options.concurrency, LIMITS2.concurrency), callMs = bounded2(options.callMs, LIMITS2.callMs), deadlineMs = bounded2(options.deadlineMs, LIMITS2.discoveryMs);
71
327
  const pass = new AbortController, rows = new Map, active = new Map;
72
328
  let done = false, ending = false, listedReady = false, omittedCount = 0, eligibleCount = 0, started = 0;
73
329
  const timers = new Set;
@@ -120,11 +376,11 @@ async function discoverThreadMetricsOwners(options) {
120
376
  return;
121
377
  listedReady = true;
122
378
  const unique = new Map;
123
- for (const p of listed.plugins.slice(0, LIMITS.listedPlugins))
379
+ for (const p of listed.plugins.slice(0, LIMITS2.listedPlugins))
124
380
  if (p.id.length <= 200 && (p.status === "running" || p.status === "degraded"))
125
381
  unique.set(p.id, p);
126
382
  const candidates = [...unique.values()].slice(0, count);
127
- omittedCount = unique.size - candidates.length + Math.max(0, listed.plugins.length - LIMITS.listedPlugins);
383
+ omittedCount = unique.size - candidates.length + Math.max(0, listed.plugins.length - LIMITS2.listedPlugins);
128
384
  eligibleCount = candidates.length;
129
385
  let index = 0;
130
386
  async function worker() {
@@ -192,6 +448,11 @@ function createThreadMetricsDiscovery(sdk) {
192
448
  }
193
449
  export {
194
450
  registerThreadMetricsOwner,
451
+ registerHeatSettingsOwner,
195
452
  createThreadMetricsDiscovery,
196
- createThreadMetricsClient
453
+ createThreadMetricsClient,
454
+ createRecentSnapshotSession,
455
+ createHeatSettingsClient,
456
+ RecentSnapshotSessionError,
457
+ RecentSnapshotNotStarted
197
458
  };