@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,163 @@
1
+ /** Which spans reach a run's stream, and how the partition counts each one
2
+ * (docs/reference/specs/tracing.md). Everything not named here is log-only by default.
3
+ *
4
+ * Three classes:
5
+ * - counted: the span's interval claims one of the four buckets;
6
+ * - uncounted: structure only — its own time, minus its counted children, is
7
+ * Switchboard overhead by design;
8
+ * - background: concurrent, non-blocking work the claim pass ignores entirely.
9
+ *
10
+ * Invariant (tested): every ancestor of a counted span that belongs to a
11
+ * different bucket is uncounted, so the deepest counted node can always claim
12
+ * an instant without a bucket fight. */
13
+
14
+ export type Bucket = "getting_ready" | "thinking" | "tools" | "finishing_up";
15
+ export type SpanClass = { kind: "counted"; bucket: Bucket } | { kind: "uncounted" } | { kind: "background" };
16
+
17
+ /** Who owns the run: an agent run (the default) or a deterministic command run,
18
+ * which changes where `run.command` and its grafts count. */
19
+ export type RunOwner = "agent" | "command";
20
+
21
+ /** The enumerated streamed names. Prefix families (`tool.`, `mcp.`, the two
22
+ * graft prefixes) are matched separately. */
23
+ export const STREAMED_SPANS = [
24
+ "request",
25
+ "slack.receive",
26
+ "dispatch.history",
27
+ "dispatch.admission",
28
+ "dispatch.ack_card",
29
+ "dispatch.gate.repo",
30
+ "dispatch.gate.pr_head",
31
+ "dispatch.workspace.attach",
32
+ "dispatch.gate.attached_head",
33
+ "dispatch.mcp_discovery",
34
+ "dispatch.compose",
35
+ "dispatch.channel_visibility",
36
+ "dispatch.repo_context",
37
+ "dispatch.memory_read",
38
+ "dispatch.refuse",
39
+ "dispatch.ship_preflight",
40
+ "dispatch.ledger_claim",
41
+ "run.agent",
42
+ "run.command",
43
+ "run.reading_diff",
44
+ "run.settle_reviewed_head",
45
+ "run.description_turn",
46
+ "run.observe_workspace",
47
+ "run.pr_post_step",
48
+ "run.reading_diff_join",
49
+ "run.pr_description_join",
50
+ "model.turn",
51
+ "ship.round",
52
+ "post.card_close",
53
+ "post.reply",
54
+ ] as const;
55
+
56
+ export type StreamedSpanName = (typeof STREAMED_SPANS)[number];
57
+
58
+ /** Streamed prefix families and the bucket each counts toward. `run.command.`
59
+ * (the op-route grafts) follows `run.command`'s owner-dependent bucket. */
60
+ export const STREAMED_PREFIXES = {
61
+ "tool.": "tools",
62
+ "mcp.": "tools",
63
+ "dispatch.workspace.attach.": "getting_ready",
64
+ "run.command.": "owner",
65
+ } as const satisfies Record<string, Bucket | "owner">;
66
+
67
+ const STREAMED_SET: ReadonlySet<string> = new Set(STREAMED_SPANS);
68
+
69
+ export function isStreamed(name: string): boolean {
70
+ if (STREAMED_SET.has(name)) return true;
71
+ return Object.keys(STREAMED_PREFIXES).some((p) => name.startsWith(p));
72
+ }
73
+
74
+ const GETTING_READY: ReadonlySet<string> = new Set([
75
+ "slack.receive",
76
+ "dispatch.history",
77
+ "dispatch.admission",
78
+ "dispatch.ack_card",
79
+ "dispatch.gate.repo",
80
+ "dispatch.gate.pr_head",
81
+ "dispatch.workspace.attach",
82
+ "dispatch.gate.attached_head",
83
+ "dispatch.mcp_discovery",
84
+ "dispatch.compose",
85
+ "dispatch.channel_visibility",
86
+ "dispatch.repo_context",
87
+ "dispatch.memory_read",
88
+ "dispatch.refuse",
89
+ "dispatch.ship_preflight",
90
+ "dispatch.ledger_claim",
91
+ ]);
92
+ const FINISHING_UP: ReadonlySet<string> = new Set([
93
+ "run.observe_workspace",
94
+ "run.pr_post_step",
95
+ "run.reading_diff_join",
96
+ "run.pr_description_join",
97
+ ]);
98
+ const UNCOUNTED: ReadonlySet<string> = new Set([
99
+ "request",
100
+ "run.agent",
101
+ "ship.round",
102
+ "run.settle_reviewed_head",
103
+ "run.description_turn",
104
+ "post.card_close",
105
+ "post.reply",
106
+ ]);
107
+ const BACKGROUND: ReadonlySet<string> = new Set(["run.reading_diff"]);
108
+
109
+ /** The class of a streamed name under `owner`. A name that is not streamed has
110
+ * no class (log-only spans never reach the partition). */
111
+ export function classOf(name: string, owner: RunOwner): SpanClass | undefined {
112
+ if (BACKGROUND.has(name)) return { kind: "background" };
113
+ if (UNCOUNTED.has(name)) return { kind: "uncounted" };
114
+ if (name === "model.turn") return { kind: "counted", bucket: "thinking" };
115
+ if (name === "run.command" || name.startsWith("run.command.")) {
116
+ return { kind: "counted", bucket: owner === "command" ? "tools" : "getting_ready" };
117
+ }
118
+ if (GETTING_READY.has(name) || name.startsWith("dispatch.workspace.attach."))
119
+ return { kind: "counted", bucket: "getting_ready" };
120
+ if (FINISHING_UP.has(name)) return { kind: "counted", bucket: "finishing_up" };
121
+ if (name.startsWith("tool.") || name.startsWith("mcp.")) return { kind: "counted", bucket: "tools" };
122
+ return undefined;
123
+ }
124
+
125
+ /** The parents each streamed name may have (docs/reference/specs/tracing.md taxonomy) —
126
+ * what the ancestor invariant is checked against. Prefix families are keyed by
127
+ * their prefix. */
128
+ export const PARENTS: Readonly<Record<string, readonly string[]>> = {
129
+ request: [],
130
+ "slack.receive": ["request"],
131
+ "dispatch.history": ["request"],
132
+ "dispatch.admission": ["request"],
133
+ "dispatch.ack_card": ["request"],
134
+ "dispatch.gate.repo": ["request"],
135
+ "dispatch.gate.pr_head": ["request"],
136
+ "dispatch.workspace.attach": ["request"],
137
+ "dispatch.gate.attached_head": ["request"],
138
+ "dispatch.mcp_discovery": ["request"],
139
+ "dispatch.compose": ["request"],
140
+ "dispatch.channel_visibility": ["request"],
141
+ "dispatch.repo_context": ["request"],
142
+ "dispatch.memory_read": ["request"],
143
+ "dispatch.refuse": ["request"],
144
+ "dispatch.ship_preflight": ["request"],
145
+ "dispatch.ledger_claim": ["request"],
146
+ "run.agent": ["request", "ship.round", "run.settle_reviewed_head", "run.description_turn"],
147
+ "run.command": ["request"],
148
+ "run.reading_diff": ["request"],
149
+ "run.settle_reviewed_head": ["request", "ship.round"],
150
+ "run.description_turn": ["request", "ship.round"],
151
+ "run.observe_workspace": ["request", "ship.round"],
152
+ "run.pr_post_step": ["request", "ship.round"],
153
+ "run.reading_diff_join": ["request", "ship.round"],
154
+ "run.pr_description_join": ["request", "ship.round"],
155
+ "model.turn": ["run.agent"],
156
+ "ship.round": ["request"],
157
+ "post.card_close": ["request"],
158
+ "post.reply": ["request"],
159
+ "tool.": ["run.agent"],
160
+ "mcp.": ["tool."],
161
+ "dispatch.workspace.attach.": ["dispatch.workspace.attach"],
162
+ "run.command.": ["run.command"],
163
+ };
@@ -0,0 +1,29 @@
1
+ /** W3C Trace Context `traceparent` (version 00), strict: exactly
2
+ * `00-<32 hex>-<16 hex>-<2 hex>`, lowercase, and neither id all zero. Used
3
+ * only between our own Workers (docs/reference/specs/tracing.md: container edges ignore an
4
+ * inbound header and mint their own; internal Workers adopt one only inside
5
+ * their authenticated branch). */
6
+
7
+ export interface TraceParent {
8
+ traceId: string;
9
+ parentId: string;
10
+ sampled: boolean;
11
+ }
12
+
13
+ const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
14
+
15
+ export function parseTraceparent(header: string | null | undefined): TraceParent | undefined {
16
+ if (typeof header !== "string") return undefined;
17
+ const m = TRACEPARENT_RE.exec(header.trim());
18
+ if (!m) return undefined;
19
+ const [, traceId, parentId, flags] = m;
20
+ if (/^0+$/.test(traceId) || /^0+$/.test(parentId)) return undefined;
21
+ return { traceId, parentId, sampled: (parseInt(flags, 16) & 1) === 1 };
22
+ }
23
+
24
+ export function formatTraceparent(traceId: string, spanId: string, sampled = true): string {
25
+ return `00-${traceId}-${spanId}-${sampled ? "01" : "00"}`;
26
+ }
27
+
28
+ /** The three context headers an edge strips or ignores. */
29
+ export const TRACE_CONTEXT_HEADERS = ["traceparent", "tracestate", "baggage"] as const;
@@ -0,0 +1,247 @@
1
+ /** The one production `Tracer` (docs/reference/specs/tracing.md).
2
+ *
3
+ * Pinned semantics, each with a test:
4
+ * - `span(fn)` invokes `fn` synchronously and ends the child in `finally`:
5
+ * `ok` on return, `error` and rethrow on throw;
6
+ * - `end()` is idempotent; `startedAt` may backdate a span created after the
7
+ * fact; a child started after its parent ended is a late child, recorded
8
+ * with true times (the stream sink is what refuses it);
9
+ * - a throwing sink never reaches traced code;
10
+ * - names are sanitized to `[a-z0-9_.-]`, 64 chars, with the MCP bridge's
11
+ * hash-suffix rule for a cut name;
12
+ * - a failure with a classification records kind and code and no message; an
13
+ * unclassified one records its message redacted and capped at 200. */
14
+ import { redactAndCap } from "../redact.js";
15
+ import type { SpanAttrs } from "./attrs.js";
16
+ import { classificationOf } from "./classify.js";
17
+ import { identityContext } from "./context.js";
18
+ import { newSpanId, newTraceId } from "./ids.js";
19
+ import type {
20
+ Clock,
21
+ RootOptions,
22
+ Span,
23
+ SpanContext,
24
+ SpanOptions,
25
+ SpanRecord,
26
+ SpanSink,
27
+ SpanStatus,
28
+ Tracer,
29
+ ErrorKind,
30
+ GraftOptions,
31
+ } from "./types.js";
32
+
33
+ export const SPAN_NAME_MAX = 64;
34
+ export const ERROR_MESSAGE_CAP = 200;
35
+
36
+ /** Lowercase `[a-z0-9_.-]`, at most 64 chars. A longer name keeps its head and
37
+ * ends in `-<8 hex>` of the full name, so two long names never collide into
38
+ * one (the same rule the MCP bridge applies to tool names). */
39
+ export function sanitizeSpanName(raw: string): string {
40
+ const clean =
41
+ raw
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9_.-]+/g, "_")
44
+ .replace(/^[_.-]+|[_.-]+$/g, "") || "span";
45
+ if (clean.length <= SPAN_NAME_MAX) return clean;
46
+ return `${clean.slice(0, SPAN_NAME_MAX - 9)}-${fnv1a(raw)}`;
47
+ }
48
+
49
+ function fnv1a(s: string): string {
50
+ let h = 0x811c9dc5;
51
+ for (let i = 0; i < s.length; i++) {
52
+ h ^= s.charCodeAt(i);
53
+ h = Math.imul(h, 0x01000193) >>> 0;
54
+ }
55
+ return h.toString(16).padStart(8, "0");
56
+ }
57
+
58
+ export interface TracerOptions {
59
+ clock: Clock;
60
+ context?: SpanContext;
61
+ /** Where a sink's own failure is reported; never rethrown into traced code. */
62
+ warn?: (message: string) => void;
63
+ }
64
+
65
+ export function createTracer(opts: TracerOptions): Tracer {
66
+ const context = opts.context ?? identityContext;
67
+ const warn = opts.warn ?? ((m: string) => console.warn(m));
68
+ const shared: Shared = { clock: opts.clock, context, warn };
69
+ return {
70
+ clock: opts.clock,
71
+ start(name, root: RootOptions) {
72
+ const span = new SpanImpl(shared, {
73
+ traceId: root.parent?.traceId ?? newTraceId(),
74
+ parentId: root.parent?.parentId,
75
+ adopted: root.parent !== undefined,
76
+ name,
77
+ sinks: root.sinks,
78
+ startedAt: root.startedAt,
79
+ attrs: root.attrs,
80
+ });
81
+ span.emitStart();
82
+ return span;
83
+ },
84
+ };
85
+ }
86
+
87
+ interface Shared {
88
+ clock: Clock;
89
+ context: SpanContext;
90
+ warn: (message: string) => void;
91
+ }
92
+
93
+ interface SpanInit {
94
+ traceId: string;
95
+ parentId: string | undefined;
96
+ /** A root continuing a remote trace (see `SpanRecord.adopted`); children never are. */
97
+ adopted?: boolean;
98
+ name: string;
99
+ sinks: SpanSink[];
100
+ startedAt: number | undefined;
101
+ attrs: SpanAttrs | undefined;
102
+ }
103
+
104
+ class SpanImpl implements Span {
105
+ readonly id = newSpanId();
106
+ readonly traceId: string;
107
+ readonly name: string;
108
+ readonly parentId: string | undefined;
109
+ ended = false;
110
+ private readonly sinks: SpanSink[];
111
+ private readonly rec: SpanRecord;
112
+
113
+ constructor(
114
+ private readonly shared: Shared,
115
+ init: SpanInit,
116
+ ) {
117
+ this.traceId = init.traceId;
118
+ this.parentId = init.parentId;
119
+ this.name = sanitizeSpanName(init.name);
120
+ this.sinks = init.sinks;
121
+ this.rec = {
122
+ traceId: this.traceId,
123
+ spanId: this.id,
124
+ ...(init.parentId ? { parentSpanId: init.parentId } : {}),
125
+ ...(init.adopted ? { adopted: true as const } : {}),
126
+ name: this.name,
127
+ startedAt: init.startedAt ?? shared.clock(),
128
+ attrs: { ...(init.attrs ?? {}) },
129
+ };
130
+ }
131
+
132
+ emitStart(): void {
133
+ for (const s of this.sinks) {
134
+ try {
135
+ s.onStart?.(this.record());
136
+ } catch (err) {
137
+ this.shared.warn(
138
+ `[trace] sink onStart threw for ${this.name}: ${err instanceof Error ? err.message : String(err)}`,
139
+ );
140
+ }
141
+ }
142
+ }
143
+
144
+ async span<T>(name: string, fn: (span: Span) => Promise<T> | T, opts?: SpanOptions): Promise<T> {
145
+ const child = this.start(name, opts);
146
+ try {
147
+ // `fn` is invoked synchronously here (the async wrapper only defers what
148
+ // follows the first await inside `fn`), so a caller's ordering holds.
149
+ const result = await this.shared.context.run(child, () => fn(child));
150
+ child.end("ok");
151
+ return result;
152
+ } catch (err) {
153
+ child.fail(err);
154
+ child.end("error");
155
+ throw err;
156
+ }
157
+ }
158
+
159
+ start(name: string, opts?: SpanOptions): Span {
160
+ const child = new SpanImpl(this.shared, {
161
+ traceId: this.traceId,
162
+ parentId: this.id,
163
+ name,
164
+ sinks: this.sinks,
165
+ startedAt: opts?.startedAt,
166
+ attrs: opts?.attrs,
167
+ });
168
+ child.emitStart();
169
+ return child;
170
+ }
171
+
172
+ graft(name: string, opts: GraftOptions): SpanRecord {
173
+ const child = new SpanImpl(this.shared, {
174
+ traceId: this.traceId,
175
+ parentId: this.id,
176
+ name,
177
+ sinks: this.sinks,
178
+ startedAt: opts.startedAt,
179
+ attrs: opts.attrs,
180
+ });
181
+ child.emitStart();
182
+ child.endAt(Math.max(opts.startedAt, opts.endedAt), opts.status ?? "ok", opts.errorKind, opts.errorCode);
183
+ return child.record();
184
+ }
185
+
186
+ /** `end` with the end stamp supplied (a graft); the same idempotence and sinks. */
187
+ private endAt(endedAt: number, status: SpanStatus, errorKind?: ErrorKind, errorCode?: string): void {
188
+ if (this.ended) return;
189
+ this.ended = true;
190
+ this.rec.endedAt = endedAt;
191
+ this.rec.durationMs = Math.max(0, endedAt - this.rec.startedAt);
192
+ this.rec.status = status;
193
+ if (errorKind !== undefined) {
194
+ this.rec.errorKind = errorKind;
195
+ if (errorCode !== undefined) this.rec.errorCode = errorCode;
196
+ }
197
+ for (const s of this.sinks) {
198
+ try {
199
+ s.onEnd(this.record());
200
+ } catch (err) {
201
+ this.shared.warn(
202
+ `[trace] sink onEnd threw for ${this.name}: ${err instanceof Error ? err.message : String(err)}`,
203
+ );
204
+ }
205
+ }
206
+ }
207
+
208
+ end(status?: SpanStatus, attrs?: SpanAttrs): void {
209
+ if (this.ended) return;
210
+ this.ended = true;
211
+ if (attrs) this.setAttrs(attrs);
212
+ const endedAt = this.shared.clock();
213
+ this.rec.endedAt = endedAt;
214
+ this.rec.durationMs = Math.max(0, endedAt - this.rec.startedAt);
215
+ this.rec.status = status ?? this.rec.status ?? "ok";
216
+ for (const s of this.sinks) {
217
+ try {
218
+ s.onEnd(this.record());
219
+ } catch (err) {
220
+ this.shared.warn(
221
+ `[trace] sink onEnd threw for ${this.name}: ${err instanceof Error ? err.message : String(err)}`,
222
+ );
223
+ }
224
+ }
225
+ }
226
+
227
+ fail(err: unknown): void {
228
+ this.rec.status = "error";
229
+ const c = classificationOf(err);
230
+ if (c) {
231
+ this.rec.errorKind = c.kind;
232
+ if (c.code !== undefined) this.rec.errorCode = c.code;
233
+ delete this.rec.errorMessage;
234
+ return;
235
+ }
236
+ const message = err instanceof Error ? err.message : String(err);
237
+ this.rec.errorMessage = redactAndCap(message, ERROR_MESSAGE_CAP);
238
+ }
239
+
240
+ setAttrs(attrs: SpanAttrs): void {
241
+ (this.rec as { attrs: SpanAttrs }).attrs = { ...this.rec.attrs, ...attrs };
242
+ }
243
+
244
+ record(): SpanRecord {
245
+ return { ...this.rec, attrs: { ...this.rec.attrs } };
246
+ }
247
+ }
@@ -0,0 +1,125 @@
1
+ /** The one measurement primitive (docs/reference/specs/tracing.md).
2
+ *
3
+ * A span is one unit of work with a start, an end, a name and a parent. A root
4
+ * has no parent; one root per message. Every awaited step Switchboard takes
5
+ * runs inside `span(fn)` (Execute Around Method), so the timeline of a run is a
6
+ * side effect of the code's shape, never a second bookkeeping.
7
+ *
8
+ * Node-free, no I/O: the Workers import this by relative path. */
9
+ import type { SpanAttrs } from "./attrs.js";
10
+
11
+ /** The only way production code reads the time: injected, never `Date.now()`
12
+ * (docs/reference/specs/tracing.md, the clock ratchet). */
13
+ export type Clock = () => number;
14
+
15
+ export type SpanStatus = "ok" | "error";
16
+
17
+ /** Our classification of a failure. The peer's own discriminator, where one
18
+ * exists, rides beside it as `errorCode`; free text never does for a span whose
19
+ * error came from a remote body (see `classify.ts`). */
20
+ export type ErrorKind = "timeout" | "transport" | "http" | "refused" | "infra" | "other";
21
+
22
+ export interface SpanRecord {
23
+ traceId: string;
24
+ spanId: string;
25
+ parentSpanId?: string;
26
+ /** The parent belongs to another process: this span is a root of its own
27
+ * process that continues a remote trace — a Worker's root adopting the
28
+ * bot's `traceparent` (docs/reference/specs/tracing.md item 22). The log sink prints
29
+ * it as a root; it never travels on a run stream. */
30
+ adopted?: true;
31
+ name: string;
32
+ startedAt: number;
33
+ endedAt?: number;
34
+ durationMs?: number;
35
+ status?: SpanStatus;
36
+ errorKind?: ErrorKind;
37
+ errorCode?: string;
38
+ /** Redacted and capped; absent whenever the error carries a classification. */
39
+ errorMessage?: string;
40
+ attrs: SpanAttrs;
41
+ }
42
+
43
+ /** Observer of span starts and ends (Observer). Sinks are attached to a root
44
+ * and inherited by its whole subtree; a throwing sink never reaches traced
45
+ * code. */
46
+ export interface SpanSink {
47
+ onStart?(span: SpanRecord): void;
48
+ onEnd(span: SpanRecord): void;
49
+ }
50
+
51
+ /** The seam the no-gaps test enters through (Strategy): production is the
52
+ * identity, so the primitive stays free of `node:async_hooks`; the test wraps
53
+ * `fn` in an `AsyncLocalStorage.run`, so a bare await outside any `span(fn)`
54
+ * records no current span. */
55
+ export interface SpanContext {
56
+ run<T>(span: Span, fn: () => T): T;
57
+ }
58
+
59
+ export interface SpanOptions {
60
+ attrs?: SpanAttrs;
61
+ /** Backdate a span created after the fact (a grafted step, a late measure). */
62
+ startedAt?: number;
63
+ }
64
+
65
+ /** The one trailing argument a client method takes to join a trace: the
66
+ * caller's span, under which the client's outbound call becomes an
67
+ * `http.client` span (docs/reference/specs/tracing.md item 24). Absent, the call is the
68
+ * plain fetch it was. */
69
+ export interface TraceOptions {
70
+ span?: Span;
71
+ }
72
+
73
+ export interface Span {
74
+ readonly id: string;
75
+ readonly traceId: string;
76
+ readonly name: string;
77
+ readonly parentId: string | undefined;
78
+ readonly ended: boolean;
79
+ /** Execute Around Method: start a child, run `fn` inside it (invoked
80
+ * synchronously), end it when `fn` settles — `ok` on return, `error` (and
81
+ * rethrow) on throw. The one way to time an awaited step. */
82
+ span<T>(name: string, fn: (span: Span) => Promise<T> | T, opts?: SpanOptions): Promise<T>;
83
+ /** A handle: a child kept across a suspension point and ended explicitly.
84
+ * The root is the only handle in the request path. */
85
+ start(name: string, opts?: SpanOptions): Span;
86
+ /** A child recorded after the fact with BOTH stamps supplied — a step another
87
+ * process measured (a resident's attach steps), rebased and clipped by the
88
+ * caller: its start and end reach the sinks at once, in the given order,
89
+ * never entering the context. */
90
+ graft(name: string, opts: GraftOptions): SpanRecord;
91
+ /** Idempotent. */
92
+ end(status?: SpanStatus, attrs?: SpanAttrs): void;
93
+ /** Record the failure's classification (or its redacted message) without
94
+ * ending the span. */
95
+ fail(err: unknown): void;
96
+ setAttrs(attrs: SpanAttrs): void;
97
+ record(): SpanRecord;
98
+ }
99
+
100
+ export interface GraftOptions {
101
+ startedAt: number;
102
+ endedAt: number;
103
+ status?: SpanStatus;
104
+ attrs?: SpanAttrs;
105
+ /** An error's classification, when the measuring process named one; a graft never carries a message. */
106
+ errorKind?: ErrorKind;
107
+ errorCode?: string;
108
+ }
109
+
110
+ export interface RootOptions {
111
+ sinks: SpanSink[];
112
+ startedAt?: number;
113
+ attrs?: SpanAttrs;
114
+ /** A remote parent to adopt — a Worker's authenticated branch reading the
115
+ * bot's `traceparent` (docs/reference/specs/tracing.md item 22): the root joins that
116
+ * trace as a child of that span instead of minting its own. */
117
+ parent?: { traceId: string; parentId: string };
118
+ }
119
+
120
+ /** The one production implementation is `createTracer`; the seams with two
121
+ * implementations are `SpanSink`, `Clock` and `SpanContext`. */
122
+ export interface Tracer {
123
+ start(name: string, opts: RootOptions): Span;
124
+ readonly clock: Clock;
125
+ }
@@ -0,0 +1,97 @@
1
+ import type { SpanAttrs } from "./attrs.js";
2
+ import { createLogSink } from "./sinks.js";
3
+ import { formatTraceparent, parseTraceparent, TRACE_CONTEXT_HEADERS } from "./traceparent.js";
4
+ import type { Span, SpanSink, Tracer } from "./types.js";
5
+
6
+ // The Workers' side of trace context (docs/reference/specs/tracing.md item 22), shared by
7
+ // the shim, the state Worker, the resident and the sandbox — pure functions
8
+ // over the platform's Request/Headers, no I/O. The public edge (the shim)
9
+ // strips whatever context a caller sent and mints its own root; an internal
10
+ // Worker adopts the bot's `traceparent` only after its bearer checked out; a
11
+ // route attr is always a word from a closed table, never the path a caller
12
+ // typed; and an unauthenticated refusal leaves no line at all.
13
+
14
+ export interface RemoteParent {
15
+ traceId: string;
16
+ parentId: string;
17
+ }
18
+
19
+ /** The parent a `traceparent` header names, or nothing for an absent or malformed one. */
20
+ export function adoptedParent(header: string | null | undefined): RemoteParent | undefined {
21
+ const tp = parseTraceparent(header);
22
+ return tp ? { traceId: tp.traceId, parentId: tp.parentId } : undefined;
23
+ }
24
+
25
+ /** The request without any inbound trace context — what the public edge forwards. */
26
+ export function stripTraceContext(request: Request): Request {
27
+ const headers = new Headers(request.headers);
28
+ for (const name of TRACE_CONTEXT_HEADERS) headers.delete(name);
29
+ return new Request(request, { headers });
30
+ }
31
+
32
+ /** The request carrying `span` as its trace context — what a Worker sends onward. */
33
+ export function withTraceContext(request: Request, span: Span): Request {
34
+ const headers = new Headers(request.headers);
35
+ headers.set("traceparent", formatTraceparent(span.traceId, span.id));
36
+ return new Request(request, { headers });
37
+ }
38
+
39
+ /** The shim's closed route table: the word a `bot-shim.fetch` root carries for
40
+ * a path. `undefined` means no root at all — a static asset, the favicon, or
41
+ * the live view's SSE stream (one long-lived request per open dashboard). */
42
+ export function shimRoute(pathname: string): string | undefined {
43
+ if (/\.(js|mjs|css|map|ico|svg|png|jpe?g|webp|woff2?|txt)$/i.test(pathname) || pathname.startsWith("/assets/")) {
44
+ return undefined;
45
+ }
46
+ if (/^\/runs\/[^/]+\/events$/.test(pathname)) return undefined;
47
+ if (pathname === "/healthz") return "healthz";
48
+ if (pathname === "/ingress") return "ingress";
49
+ if (pathname === "/mcp" || pathname.startsWith("/mcp/")) return "mcp";
50
+ if (pathname === "/runs" || pathname.startsWith("/runs/")) return "runs";
51
+ if (pathname === "/residents" || pathname.startsWith("/residents/")) return "residents";
52
+ if (pathname === "/costs" || pathname.startsWith("/costs/")) return "costs";
53
+ if (pathname.startsWith("/api/")) return "api";
54
+ if (pathname.startsWith("/admin/")) return "admin";
55
+ if (pathname === "/docs" || pathname.startsWith("/docs/")) return "docs";
56
+ if (pathname === "/" || pathname === "/index.html") return "page";
57
+ return "other";
58
+ }
59
+
60
+ /** A sink that drops the records an unauthenticated refusal would leave: a
61
+ * root that ended with HTTP 401 or 403 is never written. */
62
+ export function refusalFilter(sink: SpanSink): SpanSink {
63
+ const refused = (status: unknown) => status === 401 || status === 403;
64
+ return {
65
+ ...(sink.onStart ? { onStart: (rec) => sink.onStart!(rec) } : {}),
66
+ onEnd: (rec) => {
67
+ if (refused(rec.attrs.httpStatus)) return;
68
+ sink.onEnd(rec);
69
+ },
70
+ };
71
+ }
72
+
73
+ /** A Worker's span log: `slow` (the root always, a child when it took a second
74
+ * or more), refusals filtered, one JSON line per record through `write`. */
75
+ export function workerLogSink(write: (line: string) => void): SpanSink {
76
+ return refusalFilter(createLogSink({ level: "slow", write }));
77
+ }
78
+
79
+ export interface AdoptedRootOptions {
80
+ sinks: SpanSink[];
81
+ /** The inbound `traceparent`, read only after the request authenticated. */
82
+ traceparent?: string | null;
83
+ startedAt?: number;
84
+ attrs?: SpanAttrs;
85
+ }
86
+
87
+ /** A Worker's root for one authenticated request: joins the caller's trace when
88
+ * `traceparent` parses, else starts its own. */
89
+ export function startAdoptedRoot(tracer: Tracer, name: string, opts: AdoptedRootOptions): Span {
90
+ const parent = adoptedParent(opts.traceparent);
91
+ return tracer.start(name, {
92
+ sinks: opts.sinks,
93
+ ...(opts.startedAt !== undefined ? { startedAt: opts.startedAt } : {}),
94
+ ...(opts.attrs ? { attrs: opts.attrs } : {}),
95
+ ...(parent ? { parent } : {}),
96
+ });
97
+ }