@phosphorco/bb-thread-metrics 0.0.0-stage → 0.1.0

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 ADDED
@@ -0,0 +1,318 @@
1
+ # Thread metrics wire v1 (@phosphorco/bb-thread-metrics 0.1.0)
2
+
3
+ | Supplier owns | Package owns | Host owns |
4
+ | --- | --- | --- |
5
+ | Ledger, edge index, pricing data, observation/retention, source coverage and revisions | Bounded DTOs, dual-edge closure, dollar percentile/heat, fixed RPC, injected adapters and conformance | Discovery triggers, demand batching, caching, navigation, thread panel rendering and recovery |
6
+
7
+ The package is pure data and transport, without storage, collectors, pricing seeds,
8
+ React, polling or lifecycle scheduling. Root uses Zod and the seed-free algebra
9
+ `/vector` entry. `/bb` accepts injected SDK objects; `/testing` exercises real
10
+ registered handlers on the SDK fake host.
11
+
12
+ ## API sketch (cross-lane alignment)
13
+
14
+ - `familyClosure(edges, roots, policy?)`: union of parent and lifecycle-owner
15
+ links, reflexive, cycle/diamond safe, hidden always included, archived default
16
+ true, deleted excluded. Explicit input/member/depth caps and coverage reasons.
17
+ - `dollarPercentiles(population)`: per-window host-wide active dollar
18
+ population (finite positive amounts only); ties use strict-lower-count / (N-1).
19
+ Singleton and all-equal populations rank 0; no median mark. Zero, unknown and
20
+ inactive get null. `heat(ranks)` gates m20/on-fire, h24/hot, d7/heavy at >=0.9.
21
+ - `threadMetricsDescribe({})`: `{protocol:'bb-thread-metrics',versions:[1],
22
+ capabilities:{summary:true,series:boolean,detail:boolean}}`. Negotiate envelope
23
+ before decoding v1 capabilities; unknown output fields tolerated.
24
+ - `threadMetricsReadV1({threadIds,scope,includeArchived?,knownRevisions?,
25
+ series?:{activeThreadId,maxPoints}})` <=200 unique IDs. A single active ID may
26
+ request last-24h buckets, <=288. Summary rows never contain descendant records.
27
+ - `threadMetricsDetailV1({threadId,scope,includeArchived?,axis,window,cursor?,limit?})`
28
+ is optional, explicitly on demand. Axis `model|time`; bounded rows,
29
+ optional nextCursor, total and coverage metadata. No summary fanout.
30
+
31
+ Every summary row has threadId/scope/memberCount/coverage/coverageReasons/asOf/
32
+ revision/priceVersion/currency:'USD'/estimated:true/approximation/ranks. Changed
33
+ rows add windows `{m20,h24,d7,lifetime}` of `{tokens:UsageVector,amount:number|null}`;
34
+ unchanged rows omit windows but still carry current metadata and ranks. Revisions
35
+ must fingerprint scope/archive policy/window content/series demand; a producer
36
+ may return unchanged only for a matching known revision. Consumers merge the
37
+ fresh metadata even when unchanged. Price corrections and sliding windows must
38
+ invalidate content revisions. Approximation has bounded reasons and optional
39
+ provider/model/rate attribution (matched, closest-model, provider-default,
40
+ unpriced); missing price is last resort. Provider reported costs remain separate.
41
+
42
+ Inputs are strict and byte bounded; outputs strip additive fields and enforce
43
+ byte bounds before decoding. Suppliers echo only requested IDs once each; missing
44
+ IDs are partial coverage, never implicit zero. Detail rows reconcile to the stated
45
+ total only when complete; pagination and row limits are explicit. USD ranks must
46
+ be computed from the host-wide dollar population using the same scope and archived
47
+ policy, never token totals or the visible/requested set. Shared descendants count
48
+ once per root. Overlap across separate family roots is comparative data and must
49
+ never be summed into host spend. Ranks carry population asOf/count metadata per window.
50
+
51
+ Discovery lists once, deduplicates target IDs, probes bounded candidates using
52
+ bounded concurrency, per-call and overall deadlines. Aborting a probe does not
53
+ release its slot until its actual SDK promise settles. A deadline ends the pass;
54
+ late results cannot publish. Failures are independent, keyed by target plugin ID.
55
+ Mount/refresh/reconnect/plugin-lifecycle triggers belong to the consumer; no poll.
56
+
57
+ ## Wire details and producer obligations
58
+
59
+ Timestamps are Unix milliseconds, finite safe nonnegative integers. Revisions
60
+ and price versions are nonempty strings <=256 characters. Thread/provider IDs
61
+ are <=200 characters, begin alphanumeric and contain only letters, digits,
62
+ underscore, period, colon or hyphen. Model and rate IDs are <=256 characters.
63
+ Amounts are finite nonnegative USD values or null. Null is unknown, never zero;
64
+ null estimates require `missing-price` approximation provenance. `reported`
65
+ uses its own currency and timestamp and is never added to estimated amounts.
66
+
67
+ Coverage is `complete|partial|unknown`; closed reasons are `source-gap`, `root-unattested`,
68
+ `history-unavailable`, `member-cap`, `depth-cap`, `edge-cap`, `missing-thread`,
69
+ `unpriced`, `retention`, `row-cap`, `pagination`, `population-incomplete`.
70
+ Approximation reasons are `closest-model`, `provider-default`,
71
+ `model-attribution`, `bucket-boundary`, `missing-price`, `source-gap`.
72
+ Nonempty approximation reasons or unmatched rate attribution require the
73
+ `approximate` flag. Each summary carries <=32 rate attribution records, not an
74
+ unbounded catalog; a supplier may omit that optional list while retaining reasons.
75
+
76
+ Family closure scans <=50,000 edge records and emits <=10,000 members to depth
77
+ <=256. Policy may lower each cap; it may never increase a package bound. Roots
78
+ are <=200. Duplicate edge identities are invalid. A missing root downgrades
79
+ coverage and contributes no invented member. Archived nodes remain traversable
80
+ when excluded from membership, so a live grandchild remains reachable. Deleted
81
+ nodes and traversal through them are excluded. Family results are traversal-order
82
+ arrays and callers must not infer rank from their order. Source completeness is
83
+ an explicit policy input: a complete closure only proves the supplied snapshot.
84
+
85
+ Dollar population input is explicitly supplied, one amount per distinct thread.
86
+ Amounts must match the requested scope/archive policy at a common snapshot.
87
+ Unknown and zero-dollar threads receive null rank and do not enter the active
88
+ priced population. For N positive known-dollar threads, rank is the number of
89
+ strictly lower amounts divided by N-1; N=1 yields zero. Thus equal peers share
90
+ rank, an all-equal population ranks zero, a unique maximum ranks one, and a
91
+ median thread receives no default heat. Unknown-priced active threads mean
92
+ population coverage is incomplete and must be disclosed. Per-window
93
+ `rankPopulation` contains priced-active count, asOf and coverage. The consumer
94
+ may suppress heat on incomplete coverage without replacing null dollars with zero.
95
+
96
+ Summary output is `{version:1,rows}`. Read omissions mean unavailable data;
97
+ consumers retain prior values as stale or show unavailable. An omitted row is
98
+ never zero spend. A changed row carries windows and optionally buckets;
99
+ unchanged carries current metadata/ranks only. Recognized `windows` or `buckets`
100
+ content is rejected on unchanged rows before output stripping, even if series was
101
+ requested. Truly unknown additive output fields are stripped. With no series requested the registration adapter strips no known
102
+ bucket field silently: it rejects it. The supplier must avoid computing raw
103
+ breakdowns for summaries; no descendant/model detail field exists in that DTO.
104
+ Buckets have aligned five-minute starts in the last 288 fixed buckets including
105
+ the current bucket at asOf, are strictly increasing, never future, and never
106
+ exceed the requested point count. Null bucket dollars require top-level
107
+ `missing-price` approximation provenance, including when all windows are priced. The input explicitly names
108
+ one active requested thread. The package cannot independently verify UI focus.
109
+
110
+ Known revisions are bounded by requested IDs and must refer to content for that
111
+ exact scope/archive/series projection. Ingest revision alone is insufficient:
112
+ the revision MUST change with every input capable of changing membership or
113
+ sums: rolling-window/bucket expiry, rate table generation, scope/archive/series
114
+ policy, edge/index revision, membership evidence and retention all participate.
115
+ A change to an input must change revision even if the current numeric sum happens
116
+ to remain equal. A source unable to establish this revision must disclose unknown
117
+ coverage and cannot safely reuse a known revision for an unchanged answer. An unchanged answer still updates asOf,
118
+ coverage, approximation and rank metadata in the consumer cache. Suppliers must
119
+ compare cheap projection revisions before expensive aggregation; the package
120
+ validates the echoed revision and shape but cannot inspect supplier storage or
121
+ prove that comparison cost.
122
+
123
+ Detail outputs echo thread/scope/axis/window and include asOf, revision,
124
+ priceVersion, currency, estimated, coverage, approximation, total, rows and an
125
+ optional nextCursor. Rows carry tokens, amount and approximation, plus either
126
+ providerId/modelId or start/end. Limits default to 200; cursors <=1024
127
+ characters. Identities are unique per page. Complete unpaginated detail must
128
+ reconcile all four vector components and dollars to total (relative tolerance
129
+ 1e-8); null row dollars require a null total. A null detail total always requires
130
+ top-level `missing-price` approximation provenance, even on partial/unknown pages
131
+ with no rows. Pagination must disclose partial
132
+ coverage. Total describes the whole requested projection, not just the page.
133
+ Across pages the supplier must keep one revision stable or reject/restart its
134
+ cursor. Consumers must dedup page identities and discard mixed revisions.
135
+ Model rows aggregate by provider/model after deduplicating family members; time
136
+ rows partition the requested retained window without overlap. Sparse unoccupied
137
+ gaps are allowed and never imply invented spend. Source
138
+ observation/model/bucket uncertainty remains in coverage/approximation.
139
+
140
+ ## Runtime adapters and admission
141
+
142
+ `registerThreadMetricsOwner(bb, port)` registers strict byte-admitted input
143
+ schemas and validates all responses. Port has capabilities, async read, and
144
+ optional async detail; detail capability must match the handler. Describe/read
145
+ are always registered; detail is registered only when supported. All use
146
+ `experimental_discoverable:true`; no experimental discovery endpoint is called.
147
+ SDK imports in `/bb` are type-only. Public SDK server and browser types are
148
+ accepted by client and discovery helpers.
149
+
150
+ `createThreadMetricsClient(sdk, pluginId)` binds every call to that target.
151
+ Describe negotiates wire 1 (highest shared supported wire version) before decoding
152
+ capabilities. Fixed read/detail methods are v1; discovery must precede consumer
153
+ use and optional capabilities must be honored. Native errors are classified:
154
+ 404/unknown_method -> absent; 503 -> disabled; AbortError -> aborted;
155
+ TimeoutError -> timeout; ZodError -> malformed; other faults -> failed.
156
+
157
+ `createThreadMetricsDiscovery(sdk)` is one retained per-mounted-coordinator
158
+ resource. Its `discover(options?)` lists once and probes only running/degraded
159
+ plugins, deduped by target ID. It examines <=10,000 listed entries, probes <=128
160
+ candidates, with <=4 actual outstanding probe reads, <=2000ms per probe and a
161
+ <=5000ms overall deadline. Options can lower all budgets. Truncated/unstarted
162
+ candidates are counted in omittedCount and complete is false. This bounds work
163
+ after the SDK list materializes; the SDK owns list transport bytes and its native
164
+ list response is already materialized before this package can inspect it.
165
+
166
+ A local probe timeout aborts its signal and publishes timeout, but its lane stays
167
+ occupied until the actual promise settles. Overall deadline returns a result and
168
+ suppresses late publication. The coordinator tracks actual list/probe settlement
169
+ across passes, exposing `outstandingReads`. A new pass while running or draining
170
+ returns `listError:'busy'` and performs no I/O. Consumers must retain the same
171
+ coordinator over refresh/reconnect; recreating one bypasses admission. There is
172
+ no global registry or singleton, no automatic retry, queue or poll. Abort stops
173
+ publication, and failures remain per-target; callback faults do not discard
174
+ other target results. A genuinely unresponsive SDK leaves this coordinator busy
175
+ until that SDK read settles; this is honest backpressure, not proof of remote
176
+ cancellation. Consumers retain their last good snapshot during busy/failure.
177
+
178
+ Input byte limit is 64 KiB, all read/detail output 2 MiB, describe 16 KiB.
179
+ UTF-8 JSON size is checked before schema decoding (including additive fields).
180
+ SDK response objects are already materialized before this check. DTO/schema
181
+ admission does not claim to bound remote transport memory or arbitrary supplier
182
+ work. Output schemas strip additive fields and inputs reject unknown fields.
183
+
184
+ ## Verification and remaining proof
185
+
186
+ `bun run test` builds JS and tsc declarations, then imports emitted public entries.
187
+ `bun run typecheck` checks source, and `tsc -p tsconfig.public-tests.json --noEmit`
188
+ checks emitted public types with both SDK environments. `npm pack --dry-run
189
+ --json` inventories the package. No dist is committed.
190
+
191
+ The `/testing` kit registers the adopter's real factory on createFakePluginHost,
192
+ then exercises negotiated describe, representative read/detail cases, strict
193
+ input rejection, count bounds and response semantics through real registered
194
+ handlers. Advertised detail requires detail cases. Every check is locally
195
+ bounded and the report identifies failures; registration/storage behavior and
196
+ live network cancellation are not proven by the kit. The kit does not generate
197
+ a replacement handler or claim exhaustive semantic proof. The supplier must add
198
+ its own rolling-expiry, rate-change, retained-revision, paginated consistency,
199
+ edge-index completeness and dollar-population tests. Host integration tests must
200
+ prove absence/failure/recovery/caching and navigation; live composition and registry
201
+ publication remain separate steward obligations. Optional SDK peers describe the
202
+ intended range; local checks use installed SDK 0.6.52+phosphor, not every version.
203
+
204
+
205
+ ## Membership boundary and time clipping (owner rulings)
206
+
207
+ Shared metrics detail supports **model and time only**. It does not enumerate
208
+ members or offer descendant spend rows over RPC. The pure `familyClosure` export
209
+ is reusable internal algebra over an explicitly supplied edge snapshot, not a
210
+ membership discovery endpoint. `readV1` exposes memberCount only. Descendant
211
+ spend/member listing belongs to a ledger-specific internal debrief RPC pending
212
+ adoption of the existing `bb-thread-membership` protocol after stable publication.
213
+ No membership package dependency is added while its exact registry version is
214
+ unavailable. That protocol owns opaque scopes, member enumeration, revision-fenced
215
+ paging and direct/inherited attribution; metrics must not duplicate it.
216
+
217
+ Coverage vocabulary follows membership: `complete|partial|unknown`. Summary and
218
+ detail may include `excluded`, a nonnegative safe integer counting members/rows
219
+ known to be excluded under the supplier's projection. Omit it when not countable;
220
+ its absence never implies zero. Coverage reasons retain `source-gap` and describe
221
+ why data is partial. All membership/sum input revision duties above are mandatory.
222
+
223
+ Time detail uses five-minute fixed buckets, consistent with usage-algebra's
224
+ last-N windows. For asOf t, B=300000, and N=4/288/2016 for m20/h24/d7:
225
+ `lower=max(0,floor(t/B)*B-(N-1)*B)`. Lifetime time detail is limited to the same
226
+ retained 2016 buckets as d7; a lifetime total including earlier history therefore
227
+ needs partial retention coverage rather than claiming complete reconciliation.
228
+ `retainedWindowStart(asOf,window)` exposes this lower bound. Summary series uses
229
+ the same boundary with N=288.
230
+
231
+ Each time interval is half-open `[start,end)`. Start is aligned to B, at least
232
+ lower and strictly less than asOf. End is an aligned bucket boundary or exactly
233
+ asOf, is greater than start and no greater than asOf. Coarser groups spanning
234
+ multiple buckets are allowed. The current interval **must be clipped to asOf**;
235
+ it must not label future time up to the next bucket boundary. At an exact bucket
236
+ boundary the new current bucket contributes zero duration and its zero-width
237
+ interval is omitted. Within each page rows must be sorted by start and disjoint
238
+ (`next.start >= previous.end`); gaps are allowed. The validator enforces these
239
+ rules independently of whether dollar/vector totals happen to reconcile.
240
+ Cross-page interval ordering, completeness and revision consistency remain the
241
+ supplier/consumer paging obligation.
242
+
243
+
244
+ ## Unattested root evidence (initial 0.1.0 owner ruling)
245
+
246
+ `root-unattested` means the supplier cannot attest the root thread's own usage
247
+ evidence. It is distinct from `source-gap` and general retention: observed
248
+ family/descendant spend does not establish the missing root's spend. The ledger
249
+ owns detection, omission/floor policy and later recovery; this contract adds no
250
+ membership enumeration or root-policy decision.
251
+
252
+ For partial coverage with `root-unattested`, positive reported dollars are an
253
+ observed lower bound (floor). Consumers phrase that value **"at least $X"**,
254
+ not a complete spend total. Heat compares these floor dollar amounts under the
255
+ same population policy, preserving partial population coverage; it must not
256
+ invent missing root dollars or substitute tokens. Zero observed root evidence
257
+ must not be shown as zero actual spend: omit the unsupported spend result or
258
+ render it unavailable, retaining any previous good value only as explicitly stale.
259
+ A missing/unattested root is never evidence that actual spend was $0.
260
+
261
+ When root evidence is later attested, the ledger reconciles it idempotently,
262
+ changes the projection revision when the membership/sum input changes and clears
263
+ `root-unattested` only after the matching evidence is recovered. The consumer
264
+ replaces its floor/unavailable state from the authoritative response. Actual
265
+ recovery/deduplication and UI wording implementations are separate adopter work;
266
+ this package supplies the reason codec and preserves observed values unchanged.
267
+
268
+ ## Byte conformance and aggregate approximation hardening
269
+
270
+ The conformance kit admits every positive read/detail case with `parseBounded`
271
+ before invoking a handler; shape-only parsing is insufficient. Every supplier
272
+ must reject the mandatory shape-valid oversized read input: 200 unique IDs of
273
+ 200 characters plus a 256-character known revision per ID (133051 UTF-8 JSON
274
+ bytes, above the 65536-byte input cap). Count/field validation alone accepts this
275
+ input, so the kit requires a structured invalid_input or Zod validation failure
276
+ from the real registered handler boundary. A raw-schema supplier that omits byte
277
+ admission fails source conformance even if all ordinary reads work. This proves
278
+ local handler admission, not a limit on the SDK's prior transport allocation.
279
+
280
+ Detail's top-level `approximation` describes the **aggregate total**, not merely
281
+ the page or a separate pricing lookup. If any contributing returned row has
282
+ `approximate:true`, the aggregate must also be approximate and retain every
283
+ reason on that row. A contradictory exact aggregate or a missing row reason is
284
+ rejected. This requirement applies on partial/unknown pages too. Aggregate
285
+ reasons may be a superset of current-page reasons (unseen contributors or
286
+ aggregation uncertainty); partial pages still need not sum to the whole total.
287
+ Across pages the supplier must retain the total's full provenance consistently.
288
+ Existing null-money requirements remain: null total requires top-level
289
+ missing-price provenance, and null row amounts require row missing-price
290
+ provenance. Additive unknown output fields remain tolerated.
291
+
292
+ Optional rate attribution remains optional; no caller is required to populate
293
+ `rates`. When present, every matched/closest-model/provider-default record must
294
+ have a non-null rateId identifying the selected priced rate. An unpriced record
295
+ must have a null rateId. The codec rejects both contradictory pairings; it does
296
+ not verify a rate against the supplier's operator-owned table.
297
+
298
+ ## Ordinary client deadlines and publication fences
299
+
300
+ `createThreadMetricsClient` binds the owner and validates the wire data. It only
301
+ forwards the caller's AbortSignal to the SDK. It creates no per-call deadline,
302
+ queue, outstanding-read limit, generation fence or automatic retry, and does not
303
+ promise that abort rejects the call or stops remote work. A supplier/transport
304
+ that ignores abort can still return a schema-valid result after cancellation.
305
+ The discovery coordinator's bounded lifetime/admission does not extend to these
306
+ ordinary read/detail calls.
307
+
308
+ Consumers own read/detail deadlines and actual outstanding admission across
309
+ refresh/reconnect/replacement requests. Racing a call against a timeout permits
310
+ local UI progress but must not free its transport slot until the actual promise
311
+ settles. A new owner/client instance must not bypass the demand coordinator's
312
+ retained admission budget. After **every await**, before publishing/caching any
313
+ response or error, consumers check their abort state and current generation
314
+ (owner, thread/scope/archive/window/series demand and view lifecycle) and discard
315
+ obsolete outcomes. These post-await fences are required even when the SDK claims
316
+ cancellation support. Keep last good data during failure according to the host's
317
+ staleness policy; a late response cannot revive a disposed/replaced demand.
318
+ No package-global lifecycle or client cancellation machinery is introduced.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Phosphor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,31 @@
1
- # Temporary Holding Version
1
+ # @phosphorco/bb-thread-metrics
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Version 0.1.0: bounded dollar summaries, family closure, heat ranks and optional
4
+ on-demand breakdowns for independent BB plugins. See [CONTRACT.md](CONTRACT.md)
5
+ for ownership, wire semantics and proof limits.
6
+
7
+ ```ts
8
+ import { familyClosure, dollarPercentiles, heat } from '@phosphorco/bb-thread-metrics';
9
+ import { createThreadMetricsDiscovery, createThreadMetricsClient } from '@phosphorco/bb-thread-metrics/bb';
10
+
11
+ // Retain one coordinator per mounted demand resource, including across refresh.
12
+ const discovery = createThreadMetricsDiscovery(sdk);
13
+ const pass = await discovery.discover({ signal });
14
+ for (const owner of pass.rows) {
15
+ if (owner.state !== 'ready') continue;
16
+ const client = createThreadMetricsClient(sdk, owner.pluginId);
17
+ const summary = await client.read({ threadIds: ['thr_example'], scope: 'family' });
18
+ }
19
+ ```
20
+
21
+ Trigger discovery on mount, refresh, reconnect or plugin lifecycle. `busy` means
22
+ previous actual SDK reads are still outstanding; retain good data and retry only
23
+ on a later consumer trigger. Creating another coordinator to bypass busy defeats
24
+ the bound. No polling or automatic retry is supplied.
25
+
26
+ `bun run test` builds public JS/declarations before testing emitted entries.
27
+ `bun run typecheck` checks source; `tsc -p tsconfig.public-tests.json --noEmit`
28
+ checks emitted declaration compatibility with server and browser SDKs. The
29
+ `/testing` entry's `runSupplierConformance` accepts the adopter's real registration
30
+ factory and representative read/detail cases. It returns explicit source
31
+ conformance checks, never live host acceptance.
package/dist/bb.js ADDED
@@ -0,0 +1,197 @@
1
+ // src/bb.ts
2
+ import { z } from "zod";
3
+ import { LIMITS, METHODS, describeInputSchema, describeEnvelopeSchema, capabilitiesSchema, readInputSchema, detailInputSchema, parseBounded, negotiateVersion, validateReadResult, validateDetailResult, classifyOwnerError } from "./index.js";
4
+ function sdkSchema(schema, maxBytes) {
5
+ 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
+ try {
7
+ return { value: maxBytes === undefined ? schema.parse(value) : parseBounded(schema, value, maxBytes) };
8
+ } catch (e) {
9
+ return { issues: e instanceof z.ZodError ? e.issues.map((i) => ({ message: i.message, path: i.path })) : [{ message: "Payload byte limit" }] };
10
+ }
11
+ } } };
12
+ }
13
+ function registerThreadMetricsOwner(bb, port) {
14
+ const capabilities = capabilitiesSchema.parse(port.capabilities);
15
+ if (capabilities.detail !== Boolean(port.detail))
16
+ throw new Error("Detail capability/handler mismatch");
17
+ const describe = () => ({ protocol: "bb-thread-metrics", versions: [1], capabilities });
18
+ const contract = {
19
+ threadMetricsDescribe: { input: sdkSchema(describeInputSchema, LIMITS.inputBytes), output: sdkSchema(z.unknown()) },
20
+ threadMetricsReadV1: { input: sdkSchema(readInputSchema, LIMITS.inputBytes), output: sdkSchema(z.unknown()) }
21
+ };
22
+ const handlers = {
23
+ threadMetricsDescribe: (raw) => {
24
+ parseBounded(describeInputSchema, raw, LIMITS.inputBytes);
25
+ return describe();
26
+ },
27
+ threadMetricsReadV1: async (raw) => {
28
+ const input = parseBounded(readInputSchema, raw, LIMITS.inputBytes);
29
+ if (input.series && !capabilities.series)
30
+ throw new Error("Series not supported");
31
+ return validateReadResult(input, await port.read(input));
32
+ }
33
+ };
34
+ bb.rpc.register(contract, handlers, { experimental_discoverable: true });
35
+ 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);
38
+ return validateDetailResult(input, await port.detail(input));
39
+ } }, { experimental_discoverable: true });
40
+ }
41
+ function createThreadMetricsClient(sdk, pluginId) {
42
+ if (!pluginId || pluginId.length > 200)
43
+ throw new Error("Invalid target plugin id");
44
+ async function call(method, input, signal) {
45
+ return sdk.plugins.callRpc({ pluginId, method, input, outputSchema: z.unknown(), ...signal ? { signal } : {} });
46
+ }
47
+ return {
48
+ pluginId,
49
+ async describe(signal) {
50
+ const raw = await call(METHODS.describe, {}, signal);
51
+ return negotiateVersion(parseBounded(describeEnvelopeSchema, raw, LIMITS.describeBytes));
52
+ },
53
+ async read(raw, signal) {
54
+ const input = parseBounded(readInputSchema, raw, LIMITS.inputBytes);
55
+ return validateReadResult(input, await call(METHODS.read, input, signal));
56
+ },
57
+ async detail(raw, signal) {
58
+ const input = parseBounded(detailInputSchema, raw, LIMITS.inputBytes);
59
+ return validateDetailResult(input, await call(METHODS.detail, input, signal));
60
+ }
61
+ };
62
+ }
63
+ async function discoverThreadMetricsOwners(options) {
64
+ const bounded = (v, max) => {
65
+ const n = v ?? max;
66
+ if (!Number.isInteger(n) || n < 1 || n > max)
67
+ throw new Error("Invalid discovery budget");
68
+ return n;
69
+ };
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);
71
+ const pass = new AbortController, rows = new Map, active = new Map;
72
+ let done = false, ending = false, listedReady = false, omittedCount = 0, eligibleCount = 0, started = 0;
73
+ const timers = new Set;
74
+ let finish;
75
+ const result = new Promise((resolve) => {
76
+ finish = resolve;
77
+ });
78
+ const publish = (row) => {
79
+ if (done)
80
+ return;
81
+ rows.set(row.pluginId, row);
82
+ try {
83
+ options.onRow?.(row);
84
+ } catch {}
85
+ };
86
+ const end = (complete, listError) => {
87
+ if (done || ending)
88
+ return;
89
+ ending = true;
90
+ for (const [pluginId] of active)
91
+ if (!rows.has(pluginId))
92
+ publish({ pluginId, state: "error", error: options.signal?.aborted ? "aborted" : "timeout" });
93
+ done = true;
94
+ pass.abort();
95
+ for (const c of active.values())
96
+ c.abort();
97
+ for (const t of timers)
98
+ clearTimeout(t);
99
+ timers.clear();
100
+ options.signal?.removeEventListener("abort", abort);
101
+ finish({ rows: [...rows.values()], omittedCount: omittedCount + Math.max(0, eligibleCount - started), complete, ...listError ? { listError } : {} });
102
+ };
103
+ const abort = () => end(false, "aborted");
104
+ options.signal?.addEventListener("abort", abort, { once: true });
105
+ if (options.signal?.aborted) {
106
+ abort();
107
+ return result;
108
+ }
109
+ const deadline = setTimeout(() => end(false, listedReady ? undefined : "timeout"), deadlineMs);
110
+ timers.add(deadline);
111
+ (async () => {
112
+ let listed;
113
+ try {
114
+ listed = await options.sdk.plugins.list({ signal: pass.signal });
115
+ } catch (e) {
116
+ end(false, classifyOwnerError(e));
117
+ return;
118
+ }
119
+ if (done)
120
+ return;
121
+ listedReady = true;
122
+ const unique = new Map;
123
+ for (const p of listed.plugins.slice(0, LIMITS.listedPlugins))
124
+ if (p.id.length <= 200 && (p.status === "running" || p.status === "degraded"))
125
+ unique.set(p.id, p);
126
+ const candidates = [...unique.values()].slice(0, count);
127
+ omittedCount = unique.size - candidates.length + Math.max(0, listed.plugins.length - LIMITS.listedPlugins);
128
+ eligibleCount = candidates.length;
129
+ let index = 0;
130
+ async function worker() {
131
+ while (!done && index < candidates.length) {
132
+ const p = candidates[index++];
133
+ started++;
134
+ const control = new AbortController;
135
+ active.set(p.id, control);
136
+ let timedOut = false;
137
+ const timer = setTimeout(() => {
138
+ timedOut = true;
139
+ control.abort();
140
+ publish({ pluginId: p.id, state: "error", error: "timeout" });
141
+ }, callMs);
142
+ timers.add(timer);
143
+ try {
144
+ const negotiated = await createThreadMetricsClient(options.sdk, p.id).describe(control.signal);
145
+ if (!timedOut && !done)
146
+ publish(negotiated.kind === "ready" ? { pluginId: p.id, state: "ready", version: 1, capabilities: negotiated.capabilities } : { pluginId: p.id, state: "incompatible" });
147
+ } catch (e) {
148
+ if (!timedOut && !done)
149
+ publish({ pluginId: p.id, state: "error", error: classifyOwnerError(e) });
150
+ } finally {
151
+ clearTimeout(timer);
152
+ timers.delete(timer);
153
+ active.delete(p.id);
154
+ }
155
+ }
156
+ }
157
+ await Promise.all(Array.from({ length: Math.min(concurrency, candidates.length) }, () => worker()));
158
+ if (!done)
159
+ end(omittedCount === 0);
160
+ })().catch(() => end(false, "failed"));
161
+ return result;
162
+ }
163
+ function createThreadMetricsDiscovery(sdk) {
164
+ let running = false, outstanding = 0;
165
+ async function track(fn) {
166
+ outstanding++;
167
+ try {
168
+ return await fn();
169
+ } finally {
170
+ outstanding--;
171
+ }
172
+ }
173
+ const admitted = { plugins: {
174
+ list: (args) => track(() => sdk.plugins.list(args)),
175
+ callRpc: (args) => track(() => sdk.plugins.callRpc(args))
176
+ } };
177
+ return {
178
+ get outstandingReads() {
179
+ return outstanding;
180
+ },
181
+ async discover(options = {}) {
182
+ if (running || outstanding > 0)
183
+ return { rows: [], omittedCount: 0, complete: false, listError: "busy" };
184
+ running = true;
185
+ try {
186
+ return await discoverThreadMetricsOwners({ ...options, sdk: admitted });
187
+ } finally {
188
+ running = false;
189
+ }
190
+ }
191
+ };
192
+ }
193
+ export {
194
+ registerThreadMetricsOwner,
195
+ createThreadMetricsDiscovery,
196
+ createThreadMetricsClient
197
+ };