@coreplane/switchboard 0.0.0 → 1.18.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.
Files changed (131) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +17 -1
  3. package/dist/assets/.dockerignore +27 -0
  4. package/dist/assets/.env.example +33 -0
  5. package/dist/assets/Dockerfile +111 -0
  6. package/dist/assets/config/config.example.yaml +359 -0
  7. package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
  8. package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
  9. package/dist/assets/deploy/bin/cf-logs +32 -0
  10. package/dist/assets/deploy/cloudflare/package.json +29 -0
  11. package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
  12. package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
  13. package/dist/assets/deploy/cloudflare/worker.ts +382 -0
  14. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
  15. package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
  16. package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
  17. package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
  18. package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
  19. package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
  20. package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
  21. package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
  22. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
  23. package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
  24. package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
  25. package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
  26. package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
  27. package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
  28. package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
  29. package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
  30. package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
  31. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
  32. package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
  33. package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
  34. package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
  35. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
  36. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
  37. package/dist/assets/deploy/profile.example.json +13 -0
  38. package/dist/assets/deploy/secrets.manifest.json +108 -0
  39. package/dist/assets/docker-entrypoint.sh +15 -0
  40. package/dist/assets/package-lock.json +18407 -0
  41. package/dist/assets/package.json +104 -0
  42. package/dist/assets/project.json +219 -0
  43. package/dist/assets/source.json +5 -0
  44. package/dist/assets/src/core/authz/actor.ts +100 -0
  45. package/dist/assets/src/core/authz/authorize.ts +169 -0
  46. package/dist/assets/src/core/authz/grants.ts +347 -0
  47. package/dist/assets/src/core/authz/policy.ts +281 -0
  48. package/dist/assets/src/core/authz/resource.ts +147 -0
  49. package/dist/assets/src/core/authz/types.ts +164 -0
  50. package/dist/assets/src/core/drain.ts +54 -0
  51. package/dist/assets/src/core/ingressTokens.ts +64 -0
  52. package/dist/assets/src/core/memory/engine.ts +115 -0
  53. package/dist/assets/src/core/memory/scorer.ts +147 -0
  54. package/dist/assets/src/core/memory/types.ts +120 -0
  55. package/dist/assets/src/core/normalizeSpans.ts +299 -0
  56. package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
  57. package/dist/assets/src/core/redact.ts +113 -0
  58. package/dist/assets/src/core/runEvents.ts +537 -0
  59. package/dist/assets/src/core/runFriction.ts +665 -0
  60. package/dist/assets/src/core/runLedger/decisions.ts +126 -0
  61. package/dist/assets/src/core/runLedger/types.ts +177 -0
  62. package/dist/assets/src/core/runRecord.ts +627 -0
  63. package/dist/assets/src/core/runShape.ts +61 -0
  64. package/dist/assets/src/core/schedules.ts +452 -0
  65. package/dist/assets/src/core/time/formatDuration.ts +61 -0
  66. package/dist/assets/src/core/trace/attrs.ts +203 -0
  67. package/dist/assets/src/core/trace/classify.ts +49 -0
  68. package/dist/assets/src/core/trace/clock.ts +6 -0
  69. package/dist/assets/src/core/trace/context.ts +9 -0
  70. package/dist/assets/src/core/trace/ids.ts +23 -0
  71. package/dist/assets/src/core/trace/partition.ts +235 -0
  72. package/dist/assets/src/core/trace/sinks.ts +68 -0
  73. package/dist/assets/src/core/trace/streamSpans.ts +163 -0
  74. package/dist/assets/src/core/trace/traceparent.ts +29 -0
  75. package/dist/assets/src/core/trace/tracer.ts +247 -0
  76. package/dist/assets/src/core/trace/types.ts +125 -0
  77. package/dist/assets/src/core/trace/workerTrace.ts +97 -0
  78. package/dist/assets/src/deploy/buildStamp.ts +93 -0
  79. package/dist/assets/src/deploy/liveGate.ts +203 -0
  80. package/dist/assets/src/deploy/profile.ts +162 -0
  81. package/dist/assets/src/deploy/restart.ts +393 -0
  82. package/dist/assets/src/effort.ts +17 -0
  83. package/dist/assets/src/execution/bashTimeout.ts +78 -0
  84. package/dist/assets/src/execution/bindingPurge.ts +43 -0
  85. package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
  86. package/dist/assets/src/execution/residentCleanliness.ts +95 -0
  87. package/dist/assets/src/execution/residentCredentials.ts +81 -0
  88. package/dist/assets/src/execution/residentDepCache.ts +321 -0
  89. package/dist/assets/src/execution/residentDepsStore.ts +326 -0
  90. package/dist/assets/src/execution/residentDetach.ts +48 -0
  91. package/dist/assets/src/execution/residentDisk.ts +107 -0
  92. package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
  93. package/dist/assets/src/execution/residentExecWrap.ts +100 -0
  94. package/dist/assets/src/execution/residentHead.ts +85 -0
  95. package/dist/assets/src/execution/residentReadonly.ts +72 -0
  96. package/dist/assets/src/execution/residentRefresh.ts +429 -0
  97. package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
  98. package/dist/assets/src/execution/residentState.ts +47 -0
  99. package/dist/assets/src/execution/residentStepReport.ts +98 -0
  100. package/dist/assets/src/execution/residentStepTrace.ts +97 -0
  101. package/dist/assets/src/execution/residentSteps.ts +99 -0
  102. package/dist/assets/src/execution/residentText.ts +83 -0
  103. package/dist/assets/src/execution/residentTrace.ts +119 -0
  104. package/dist/assets/src/execution/sandboxEnv.ts +42 -0
  105. package/dist/assets/src/execution/sandboxErrors.ts +159 -0
  106. package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
  107. package/dist/assets/src/execution/shellQuote.ts +8 -0
  108. package/dist/assets/src/mcp/registry.ts +242 -0
  109. package/dist/assets/src/providers/types.ts +152 -0
  110. package/dist/assets/web/dist/.vite/manifest.json +176 -0
  111. package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
  112. package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
  113. package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
  114. package/dist/assets/web/dist/assets/ResidentDetailPage-DvQ05AGa.js +1 -0
  115. package/dist/assets/web/dist/assets/ResidentsIndexPage-B3uxKUne.js +1 -0
  116. package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
  117. package/dist/assets/web/dist/assets/RunRoutePage-ty94olNM.js +126 -0
  118. package/dist/assets/web/dist/assets/RunsIndexPage-CM-qxyQm.js +1 -0
  119. package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
  120. package/dist/assets/web/dist/assets/ScheduledPage-C1psvLD4.js +1 -0
  121. package/dist/assets/web/dist/assets/StatusDot-DuoQnQeU.js +1 -0
  122. package/dist/assets/web/dist/assets/Tooltip-BfLPyxQy.js +1 -0
  123. package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
  124. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  125. package/dist/assets/web/dist/assets/main-Bnbk_Rsg.js +28 -0
  126. package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
  127. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
  128. package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
  129. package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
  130. package/dist/cli.js +34494 -0
  131. package/package.json +43 -10
@@ -0,0 +1,203 @@
1
+ /** Span attributes: one flat key union with a value domain per key, and the
2
+ * keys each span name may carry (docs/reference/specs/tracing.md). Literal unions,
3
+ * numbers and booleans only; the string-valued keys (`host`, `route`,
4
+ * `command`, `agent`, `model`) come from closed tables at the emitter — a
5
+ * model is the parsed `<provider>/<model>` ref the registry resolved, never
6
+ * the typed directive — and are validated as sanitized identifiers here.
7
+ * `traceId` and free text are not attrs — an error's message has its own
8
+ * field. */
9
+
10
+ export type Backend = "local" | "resident" | "sandbox" | "e2b";
11
+ export type Channel = "slack" | "http" | "mcp" | "cli";
12
+
13
+ /** Every attribute key any span may carry, with its value domain. */
14
+ export interface AttrDomain {
15
+ // request
16
+ channel: Channel;
17
+ status: "completed" | "failed" | "refused" | "stopped";
18
+ queuedBeforeMs: number;
19
+ queuedBehindMs: number;
20
+ runId: string;
21
+ // slack.receive
22
+ caughtUp: boolean;
23
+ files: number;
24
+ dedupe: "fresh" | "duplicate";
25
+ // dispatch.* / run.* / post.*
26
+ outcome: string;
27
+ count: number;
28
+ backend: Backend;
29
+ // run.command
30
+ command: string;
31
+ // model.turn
32
+ /** The `<provider>/<model>` that took the turn — a ship run's children answer
33
+ * on different models, and the run page badges the switch per step. */
34
+ model: string;
35
+ stopReason: "end_turn" | "tool_use" | "max_tokens" | "stop_sequence" | "other";
36
+ inputTokens: number;
37
+ outputTokens: number;
38
+ cacheReadTokens: number;
39
+ cacheWriteTokens: number;
40
+ ttftMs: number;
41
+ thinkingMs: number;
42
+ textMs: number;
43
+ blocks: number;
44
+ finale: boolean;
45
+ // model.block.<kind>
46
+ index: number;
47
+ // tool.*
48
+ callId: string;
49
+ ok: boolean;
50
+ exitCode: number;
51
+ infra: boolean;
52
+ execMs: number;
53
+ serverMs: number;
54
+ attempts: number;
55
+ timeoutMs: number;
56
+ budget: "full" | "clipped";
57
+ token: "fresh" | "expiring" | "expired";
58
+ // exec.*
59
+ tokenExpiresInMin: number;
60
+ attempt: number;
61
+ delayMs: number;
62
+ // github.*, http.client, <worker>.fetch
63
+ scope: "read" | "write";
64
+ cached: boolean;
65
+ expiresInMs: number;
66
+ method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
67
+ route: string;
68
+ host: string;
69
+ httpStatus: number;
70
+ // mcp.*
71
+ bytes: number;
72
+ // ship.round
73
+ agent: string;
74
+ // resident grafts
75
+ waitedMs: number;
76
+ clockSkewMs: number;
77
+ timedOut: boolean;
78
+ abandoned: boolean;
79
+ // the bot's own roots (item 20): slack.catch_up, drain, deploy.step.<worker>
80
+ signal: "SIGTERM" | "SIGINT" | "other";
81
+ channels: number;
82
+ missed: number;
83
+ orphans: number;
84
+ skipped: number;
85
+ runs: number;
86
+ handed: number;
87
+ sealed: number;
88
+ abandonedRuns: number;
89
+ // the Workers' own roots (item 25): resident.watchdog, state.alarm
90
+ residents: number;
91
+ swept: number;
92
+ }
93
+
94
+ export type SpanAttrKey = keyof AttrDomain;
95
+
96
+ export type SpanAttrs = { readonly [K in SpanAttrKey]?: AttrDomain[K] };
97
+
98
+ /** The string-valued keys, and the shape their values must have: an identifier
99
+ * from a closed table, never free text (no whitespace, no `?`/`&`, at most 64
100
+ * chars). */
101
+ const IDENTIFIER_KEYS: ReadonlySet<SpanAttrKey> = new Set<SpanAttrKey>([
102
+ "runId",
103
+ "outcome",
104
+ "command",
105
+ "route",
106
+ "host",
107
+ "callId",
108
+ "agent",
109
+ "model",
110
+ ]);
111
+ const IDENTIFIER_RE = /^[A-Za-z0-9_./:@+-]{1,64}$/;
112
+
113
+ /** Validate one attrs bag: known keys, value in domain, identifiers sanitized.
114
+ * Returns the offending keys (empty when valid); emitters and tests use it,
115
+ * the tracer itself never throws over an attr. */
116
+ export function invalidAttrKeys(attrs: SpanAttrs): string[] {
117
+ const bad: string[] = [];
118
+ for (const [key, value] of Object.entries(attrs)) {
119
+ if (value === undefined) continue;
120
+ if (!(key in ATTR_TYPE)) {
121
+ bad.push(key);
122
+ continue;
123
+ }
124
+ const expected = ATTR_TYPE[key as SpanAttrKey];
125
+ if (expected === "string") {
126
+ if (typeof value !== "string") bad.push(key);
127
+ else if (IDENTIFIER_KEYS.has(key as SpanAttrKey) && !IDENTIFIER_RE.test(value)) bad.push(key);
128
+ } else if (typeof value !== expected) {
129
+ bad.push(key);
130
+ } else if (typeof value === "number" && !Number.isFinite(value)) {
131
+ bad.push(key);
132
+ }
133
+ }
134
+ return bad;
135
+ }
136
+
137
+ /** The runtime type of each key — the one place the domain is spelled twice
138
+ * (type and value), kept adjacent so they cannot drift. */
139
+ const ATTR_TYPE: Record<SpanAttrKey, "string" | "number" | "boolean"> = {
140
+ channel: "string",
141
+ status: "string",
142
+ queuedBeforeMs: "number",
143
+ queuedBehindMs: "number",
144
+ runId: "string",
145
+ caughtUp: "boolean",
146
+ files: "number",
147
+ dedupe: "string",
148
+ outcome: "string",
149
+ count: "number",
150
+ backend: "string",
151
+ command: "string",
152
+ model: "string",
153
+ stopReason: "string",
154
+ inputTokens: "number",
155
+ outputTokens: "number",
156
+ cacheReadTokens: "number",
157
+ cacheWriteTokens: "number",
158
+ ttftMs: "number",
159
+ thinkingMs: "number",
160
+ textMs: "number",
161
+ blocks: "number",
162
+ finale: "boolean",
163
+ index: "number",
164
+ callId: "string",
165
+ ok: "boolean",
166
+ exitCode: "number",
167
+ infra: "boolean",
168
+ execMs: "number",
169
+ serverMs: "number",
170
+ attempts: "number",
171
+ timeoutMs: "number",
172
+ budget: "string",
173
+ token: "string",
174
+ tokenExpiresInMin: "number",
175
+ attempt: "number",
176
+ delayMs: "number",
177
+ scope: "string",
178
+ cached: "boolean",
179
+ expiresInMs: "number",
180
+ method: "string",
181
+ route: "string",
182
+ host: "string",
183
+ httpStatus: "number",
184
+ bytes: "number",
185
+ agent: "string",
186
+ waitedMs: "number",
187
+ clockSkewMs: "number",
188
+ timedOut: "boolean",
189
+ abandoned: "boolean",
190
+ signal: "string",
191
+ channels: "number",
192
+ missed: "number",
193
+ orphans: "number",
194
+ skipped: "number",
195
+ runs: "number",
196
+ handed: "number",
197
+ sealed: "number",
198
+ abandonedRuns: "number",
199
+ residents: "number",
200
+ swept: "number",
201
+ };
202
+
203
+ export const ATTR_KEYS: readonly SpanAttrKey[] = Object.keys(ATTR_TYPE) as SpanAttrKey[];
@@ -0,0 +1,49 @@
1
+ /** Error classification: mark a thrown error with our kind and the peer's own
2
+ * discriminator at the point that knows them (an HTTP status, the resident's
3
+ * `needs`, the leading token of a resident reason), and read the mark back at
4
+ * any depth. A span that fails with a classified error records `errorKind` and
5
+ * `errorCode` and no message at all — free text from a remote body never
6
+ * reaches a span, a log line or the wire (docs/reference/specs/tracing.md). */
7
+ import type { ErrorKind } from "./types.js";
8
+
9
+ export interface ErrorClassification {
10
+ kind: ErrorKind;
11
+ code?: string;
12
+ }
13
+
14
+ const MARKS = new WeakMap<object, ErrorClassification>();
15
+
16
+ /** How far `classificationOf` follows `cause` links. */
17
+ export const CAUSE_DEPTH = 5;
18
+
19
+ const CODE_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
20
+
21
+ /** Mark `err` (any object; a non-object is returned unmarked) and return it, so
22
+ * `throw classifyError(new Error(...), { kind: "http", code: "503" })` reads
23
+ * naturally. A `code` that is not a short identifier is dropped: a code is the
24
+ * peer's discriminator, never its prose. */
25
+ export function classifyError<E>(err: E, c: ErrorClassification): E {
26
+ if (err !== null && typeof err === "object") {
27
+ MARKS.set(err, c.code !== undefined && CODE_RE.test(c.code) ? { kind: c.kind, code: c.code } : { kind: c.kind });
28
+ }
29
+ return err;
30
+ }
31
+
32
+ /** The classification on `err` or on any `cause` beneath it, to `CAUSE_DEPTH`. */
33
+ export function classificationOf(err: unknown): ErrorClassification | undefined {
34
+ let cur: unknown = err;
35
+ for (let depth = 0; depth <= CAUSE_DEPTH && cur !== null && typeof cur === "object"; depth++) {
36
+ const mark = MARKS.get(cur);
37
+ if (mark) return mark;
38
+ cur = (cur as { cause?: unknown }).cause;
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ /** An HTTP status as an `errorCode`: an integer in 100..599, stringified;
44
+ * anything else is no code. */
45
+ export function httpStatusCode(status: unknown): string | undefined {
46
+ return typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599
47
+ ? String(status)
48
+ : undefined;
49
+ }
@@ -0,0 +1,6 @@
1
+ import type { Clock } from "./types.js";
2
+
3
+ /** The ONLY production file that reads the wall clock. Everything else takes a
4
+ * `Clock` (docs/reference/specs/tracing.md: the clock ratchet forces the allowlist of
5
+ * direct reads to zero). */
6
+ export const systemClock: Clock = () => Date.now();
@@ -0,0 +1,9 @@
1
+ import type { SpanContext } from "./types.js";
2
+
3
+ /** Production `SpanContext`: the identity (Null Object). `span(fn)` enters it
4
+ * around `fn`, and it does nothing, so the primitive carries no async-context
5
+ * machinery and no Node dependency. The no-gaps test supplies an
6
+ * `AsyncLocalStorage`-backed one instead (src/core/testing/alsContext.ts). */
7
+ export const identityContext: SpanContext = {
8
+ run: (_span, fn) => fn(),
9
+ };
@@ -0,0 +1,23 @@
1
+ /** Trace and span ids in the W3C shape: 16 and 8 random bytes as lowercase hex,
2
+ * never all zero. Bare `crypto.getRandomValues` so Node and the Workers share
3
+ * one implementation. */
4
+
5
+ function randomHex(bytes: number): string {
6
+ const buf = new Uint8Array(bytes);
7
+ crypto.getRandomValues(buf);
8
+ let out = "";
9
+ for (const b of buf) out += b.toString(16).padStart(2, "0");
10
+ return out;
11
+ }
12
+
13
+ export function newTraceId(): string {
14
+ let id = randomHex(16);
15
+ while (/^0+$/.test(id)) id = randomHex(16);
16
+ return id;
17
+ }
18
+
19
+ export function newSpanId(): string {
20
+ let id = randomHex(8);
21
+ while (/^0+$/.test(id)) id = randomHex(8);
22
+ return id;
23
+ }
@@ -0,0 +1,235 @@
1
+ /** The partition (docs/reference/specs/tracing.md): every instant of a run's window
2
+ * belongs to exactly one of seven terms —
3
+ *
4
+ * window = getting ready + thinking + tools + finishing up
5
+ * + Switchboard overhead + not recorded + not loaded
6
+ *
7
+ * Three passes. (1) Claim: every counted span's interval, clipped to the
8
+ * window, claims the instants no deeper counted span already claimed (deepest
9
+ * wins; ties by earlier start, then id), so concurrent siblings count once and
10
+ * a tool inside a turn is tools, not thinking. Background subtrees claim
11
+ * nothing; uncounted spans are structure only. An open span (no end) runs to
12
+ * the window end on a live window; on a finished window, where nothing can
13
+ * still be running so a missing end was lost, a counted open span is cut at the
14
+ * first loss interval after its start. (2) Losses: `lost` intervals (seq gaps,
15
+ * `spans_dropped` notes) become not recorded and `elided` ones (a live
16
+ * replay's budget) not loaded, each minus the instants a counted span claims;
17
+ * where the two overlap, lost wins. (3) Overhead is the residual. */
18
+ import type { Bucket, RunOwner } from "./streamSpans.js";
19
+ import { classOf } from "./streamSpans.js";
20
+ import type { SpanRecord } from "./types.js";
21
+
22
+ export interface Window {
23
+ start: number;
24
+ end: number;
25
+ }
26
+
27
+ export interface LossInterval {
28
+ from: number;
29
+ to: number;
30
+ kind: "lost" | "elided";
31
+ }
32
+
33
+ export interface PartitionInput {
34
+ window: Window;
35
+ owner: RunOwner;
36
+ /** True for a record and for a live page after the `finished` frame. */
37
+ finished: boolean;
38
+ losses: readonly LossInterval[];
39
+ }
40
+
41
+ export interface Partition {
42
+ windowMs: number;
43
+ gettingReadyMs: number;
44
+ thinkingMs: number;
45
+ toolsMs: number;
46
+ finishingUpMs: number;
47
+ overheadMs: number;
48
+ notRecordedMs: number;
49
+ notLoadedMs: number;
50
+ /** Instants covered only by background spans — part of overhead, reported
51
+ * so the no-gaps test can account for them exactly. */
52
+ backgroundOnlyMs: number;
53
+ }
54
+
55
+ interface Claim {
56
+ start: number;
57
+ end: number;
58
+ bucket: Bucket;
59
+ depth: number;
60
+ startedAt: number;
61
+ spanId: string;
62
+ }
63
+
64
+ export function partition(spans: readonly SpanRecord[], input: PartitionInput): Partition {
65
+ const { window, owner, finished } = input;
66
+ const windowMs = Math.max(0, window.end - window.start);
67
+ const byId = new Map(spans.map((s) => [s.spanId, s]));
68
+
69
+ // Depth over the input set; an orphan (parent not in the set) is depth 1.
70
+ const depthMemo = new Map<string, number>();
71
+ const depthOf = (s: SpanRecord): number => {
72
+ const memo = depthMemo.get(s.spanId);
73
+ if (memo !== undefined) return memo;
74
+ depthMemo.set(s.spanId, 1); // cycle guard
75
+ const parent = s.parentSpanId ? byId.get(s.parentSpanId) : undefined;
76
+ const d = parent ? depthOf(parent) + 1 : 1;
77
+ depthMemo.set(s.spanId, d);
78
+ return d;
79
+ };
80
+ const underBackground = (s: SpanRecord): boolean => {
81
+ let cur: SpanRecord | undefined = s;
82
+ const seen = new Set<string>();
83
+ while (cur && !seen.has(cur.spanId)) {
84
+ seen.add(cur.spanId);
85
+ if (classOf(cur.name, owner)?.kind === "background") return true;
86
+ cur = cur.parentSpanId ? byId.get(cur.parentSpanId) : undefined;
87
+ }
88
+ return false;
89
+ };
90
+
91
+ const lossStarts = input.losses.map((l) => Math.max(window.start, l.from)).sort((a, b) => a - b);
92
+ const clipEnd = (s: SpanRecord): number => {
93
+ if (s.endedAt !== undefined) return Math.min(window.end, s.endedAt);
94
+ if (!finished) return window.end;
95
+ // Finished window: a counted open span's end was lost — cut at the next loss.
96
+ const next = lossStarts.find((at) => at > s.startedAt);
97
+ return next === undefined ? window.end : Math.min(window.end, next);
98
+ };
99
+
100
+ const claims: Claim[] = [];
101
+ const backgroundIntervals: Array<[number, number]> = [];
102
+ for (const s of spans) {
103
+ const cls = classOf(s.name, owner);
104
+ if (!cls) continue;
105
+ const start = Math.max(window.start, s.startedAt);
106
+ if (cls.kind === "background") {
107
+ const end = Math.min(window.end, s.endedAt ?? window.end);
108
+ if (end > start) backgroundIntervals.push([start, end]);
109
+ continue;
110
+ }
111
+ if (cls.kind !== "counted" || underBackground(s)) continue;
112
+ const end = clipEnd(s);
113
+ if (end <= start) continue;
114
+ claims.push({ start, end, bucket: cls.bucket, depth: depthOf(s), startedAt: s.startedAt, spanId: s.spanId });
115
+ }
116
+ claims.sort(
117
+ (a, b) =>
118
+ b.depth - a.depth || a.startedAt - b.startedAt || (a.spanId < b.spanId ? -1 : a.spanId > b.spanId ? 1 : 0),
119
+ );
120
+
121
+ // Elementary intervals from every boundary in play.
122
+ const points = new Set<number>([window.start, window.end]);
123
+ for (const c of claims) {
124
+ points.add(c.start);
125
+ points.add(c.end);
126
+ }
127
+ for (const l of input.losses) {
128
+ points.add(clamp(l.from, window));
129
+ points.add(clamp(l.to, window));
130
+ }
131
+ for (const [a, b] of backgroundIntervals) {
132
+ points.add(a);
133
+ points.add(b);
134
+ }
135
+ const sorted = [...points].filter((p) => p >= window.start && p <= window.end).sort((a, b) => a - b);
136
+
137
+ const buckets: Record<Bucket, number> = { getting_ready: 0, thinking: 0, tools: 0, finishing_up: 0 };
138
+ let notRecorded = 0;
139
+ let notLoaded = 0;
140
+ let backgroundOnly = 0;
141
+ for (let i = 0; i + 1 < sorted.length; i++) {
142
+ const a = sorted[i];
143
+ const b = sorted[i + 1];
144
+ const len = b - a;
145
+ if (len <= 0) continue;
146
+ const claim = claims.find((c) => c.start <= a && c.end >= b);
147
+ if (claim) {
148
+ buckets[claim.bucket] += len;
149
+ continue;
150
+ }
151
+ const lost = input.losses.some((l) => l.kind === "lost" && clamp(l.from, window) <= a && clamp(l.to, window) >= b);
152
+ if (lost) {
153
+ notRecorded += len;
154
+ continue;
155
+ }
156
+ const elided = input.losses.some(
157
+ (l) => l.kind === "elided" && clamp(l.from, window) <= a && clamp(l.to, window) >= b,
158
+ );
159
+ if (elided) {
160
+ notLoaded += len;
161
+ continue;
162
+ }
163
+ if (backgroundIntervals.some(([s, e]) => s <= a && e >= b)) backgroundOnly += len;
164
+ }
165
+ const counted = buckets.getting_ready + buckets.thinking + buckets.tools + buckets.finishing_up;
166
+ return {
167
+ windowMs,
168
+ gettingReadyMs: buckets.getting_ready,
169
+ thinkingMs: buckets.thinking,
170
+ toolsMs: buckets.tools,
171
+ finishingUpMs: buckets.finishing_up,
172
+ overheadMs: Math.max(0, windowMs - counted - notRecorded - notLoaded),
173
+ notRecordedMs: notRecorded,
174
+ notLoadedMs: notLoaded,
175
+ backgroundOnlyMs: backgroundOnly,
176
+ };
177
+ }
178
+
179
+ function clamp(at: number, w: Window): number {
180
+ return Math.min(w.end, Math.max(w.start, at));
181
+ }
182
+
183
+ /** The seven terms as the printed shape: totals and buckets floored to whole
184
+ * seconds, the residual absorbing the rounding so the printed items always sum
185
+ * to the printed total, and no bucket printing `0s`. */
186
+ export interface PrintedShape {
187
+ totalS: number;
188
+ items: Array<{ term: PrintedTerm; s: number }>;
189
+ }
190
+ export type PrintedTerm =
191
+ "getting ready" | "thinking" | "in tools" | "finishing up" | "Switchboard overhead" | "not recorded" | "not loaded";
192
+
193
+ export function printedShape(p: Partition): PrintedShape {
194
+ const totalS = Math.floor(p.windowMs / 1000);
195
+ const floored: Array<[PrintedTerm, number]> = [
196
+ ["getting ready", Math.floor(p.gettingReadyMs / 1000)],
197
+ ["thinking", Math.floor(p.thinkingMs / 1000)],
198
+ ["in tools", Math.floor(p.toolsMs / 1000)],
199
+ ["finishing up", Math.floor(p.finishingUpMs / 1000)],
200
+ ["not recorded", Math.floor(p.notRecordedMs / 1000)],
201
+ ["not loaded", Math.floor(p.notLoadedMs / 1000)],
202
+ ];
203
+ const sum = floored.reduce((a, [, s]) => a + s, 0);
204
+ const items = floored.filter(([, s]) => s > 0).map(([term, s]) => ({ term, s }));
205
+ const overhead = totalS - sum;
206
+ if (overhead > 0) items.push({ term: "Switchboard overhead", s: overhead });
207
+ // Order: the four counted words, then overhead, then the two loss terms.
208
+ const order: PrintedTerm[] = [
209
+ "getting ready",
210
+ "thinking",
211
+ "in tools",
212
+ "finishing up",
213
+ "Switchboard overhead",
214
+ "not recorded",
215
+ "not loaded",
216
+ ];
217
+ items.sort((a, b) => order.indexOf(a.term) - order.indexOf(b.term));
218
+ return { totalS, items };
219
+ }
220
+
221
+ /** A bucket is informative at 5 % of the window or 2 s; the shape is shown when
222
+ * at least two are. */
223
+ export function isInformative(p: Partition): boolean {
224
+ const terms = [
225
+ p.gettingReadyMs,
226
+ p.thinkingMs,
227
+ p.toolsMs,
228
+ p.finishingUpMs,
229
+ p.overheadMs,
230
+ p.notRecordedMs,
231
+ p.notLoadedMs,
232
+ ];
233
+ const informative = terms.filter((ms) => ms >= 2000 || (p.windowMs > 0 && ms / p.windowMs >= 0.05)).length;
234
+ return informative >= 2;
235
+ }
@@ -0,0 +1,68 @@
1
+ /** The process log sink and the Null Object sink (docs/reference/specs/tracing.md).
2
+ *
3
+ * A `LogSink` writes one JSON line per span end:
4
+ * `{"span","traceId","spanId","parentSpanId","startedAt","ms","status",
5
+ * "errorKind"?,"errorCode"?,"errorMessage"?,"attrs"}` — never text, summary
6
+ * or output; attrs are the validated bag the span carried. Verbosity:
7
+ * `roots` prints roots only (one or two lines per run), `slow` prints roots
8
+ * plus every span of `slowMs` or more. */
9
+ import type { SpanRecord, SpanSink } from "./types.js";
10
+
11
+ export type TracingLogLevel = "roots" | "slow";
12
+ export const TRACING_LOG_LEVELS: readonly TracingLogLevel[] = ["roots", "slow"];
13
+ export const SLOW_SPAN_MS = 1000;
14
+
15
+ export interface LogSinkOptions {
16
+ level: TracingLogLevel;
17
+ write: (line: string) => void;
18
+ slowMs?: number;
19
+ }
20
+
21
+ export interface LogLine {
22
+ span: string;
23
+ traceId: string;
24
+ spanId: string;
25
+ parentSpanId?: string;
26
+ startedAt: number;
27
+ ms: number;
28
+ status: "ok" | "error";
29
+ errorKind?: string;
30
+ errorCode?: string;
31
+ errorMessage?: string;
32
+ attrs: Record<string, string | number | boolean>;
33
+ }
34
+
35
+ export function logLineOf(rec: SpanRecord): LogLine {
36
+ return {
37
+ span: rec.name,
38
+ traceId: rec.traceId,
39
+ spanId: rec.spanId,
40
+ ...(rec.parentSpanId ? { parentSpanId: rec.parentSpanId } : {}),
41
+ startedAt: rec.startedAt,
42
+ ms: rec.durationMs ?? 0,
43
+ status: rec.status ?? "ok",
44
+ ...(rec.errorKind ? { errorKind: rec.errorKind } : {}),
45
+ ...(rec.errorCode ? { errorCode: rec.errorCode } : {}),
46
+ ...(rec.errorMessage ? { errorMessage: rec.errorMessage } : {}),
47
+ attrs: Object.fromEntries(Object.entries(rec.attrs).filter(([, v]) => v !== undefined)) as LogLine["attrs"],
48
+ };
49
+ }
50
+
51
+ export function createLogSink(opts: LogSinkOptions): SpanSink {
52
+ const slowMs = opts.slowMs ?? SLOW_SPAN_MS;
53
+ return {
54
+ onEnd(rec) {
55
+ // A root is this process's own: one with no parent, or one that adopted a
56
+ // remote parent (a Worker continuing the bot's trace). Without the second
57
+ // clause every sub-second adopted request stayed unlogged at `slow` while
58
+ // the same request without context printed — the opposite of useful.
59
+ const isRoot = rec.parentSpanId === undefined || rec.adopted === true;
60
+ if (!isRoot && (opts.level === "roots" || (rec.durationMs ?? 0) < slowMs)) return;
61
+ opts.write(JSON.stringify(logLineOf(rec)));
62
+ },
63
+ };
64
+ }
65
+
66
+ /** A sink that observes nothing — what a request's stream and card sinks are
67
+ * until they are bound (Null Object). */
68
+ export const NULL_SINK: SpanSink = { onEnd: () => {} };