@coreplane/switchboard 0.0.0 → 1.18.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/LICENSE +201 -0
- package/README.md +18 -1
- package/dist/assets/.dockerignore +27 -0
- package/dist/assets/.env.example +33 -0
- package/dist/assets/Dockerfile +111 -0
- package/dist/assets/config/config.example.yaml +359 -0
- package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
- package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
- package/dist/assets/deploy/bin/cf-logs +32 -0
- package/dist/assets/deploy/cloudflare/package.json +29 -0
- package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
- package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
- package/dist/assets/deploy/cloudflare/worker.ts +382 -0
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
- package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
- package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
- package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
- package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
- package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
- package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
- package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
- package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
- package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
- package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
- package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
- package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
- package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
- package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
- package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/profile.example.json +13 -0
- package/dist/assets/deploy/secrets.manifest.json +108 -0
- package/dist/assets/docker-entrypoint.sh +15 -0
- package/dist/assets/package-lock.json +18407 -0
- package/dist/assets/package.json +104 -0
- package/dist/assets/project.json +219 -0
- package/dist/assets/source.json +5 -0
- package/dist/assets/src/core/authz/actor.ts +100 -0
- package/dist/assets/src/core/authz/authorize.ts +169 -0
- package/dist/assets/src/core/authz/grants.ts +347 -0
- package/dist/assets/src/core/authz/policy.ts +281 -0
- package/dist/assets/src/core/authz/resource.ts +147 -0
- package/dist/assets/src/core/authz/types.ts +164 -0
- package/dist/assets/src/core/drain.ts +54 -0
- package/dist/assets/src/core/ingressTokens.ts +64 -0
- package/dist/assets/src/core/memory/engine.ts +115 -0
- package/dist/assets/src/core/memory/scorer.ts +147 -0
- package/dist/assets/src/core/memory/types.ts +120 -0
- package/dist/assets/src/core/normalizeSpans.ts +299 -0
- package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
- package/dist/assets/src/core/redact.ts +113 -0
- package/dist/assets/src/core/runEvents.ts +537 -0
- package/dist/assets/src/core/runFriction.ts +665 -0
- package/dist/assets/src/core/runLedger/decisions.ts +126 -0
- package/dist/assets/src/core/runLedger/types.ts +177 -0
- package/dist/assets/src/core/runRecord.ts +627 -0
- package/dist/assets/src/core/runShape.ts +61 -0
- package/dist/assets/src/core/schedules.ts +452 -0
- package/dist/assets/src/core/time/formatDuration.ts +61 -0
- package/dist/assets/src/core/trace/attrs.ts +203 -0
- package/dist/assets/src/core/trace/classify.ts +49 -0
- package/dist/assets/src/core/trace/clock.ts +6 -0
- package/dist/assets/src/core/trace/context.ts +9 -0
- package/dist/assets/src/core/trace/ids.ts +23 -0
- package/dist/assets/src/core/trace/partition.ts +235 -0
- package/dist/assets/src/core/trace/sinks.ts +68 -0
- package/dist/assets/src/core/trace/streamSpans.ts +163 -0
- package/dist/assets/src/core/trace/traceparent.ts +29 -0
- package/dist/assets/src/core/trace/tracer.ts +247 -0
- package/dist/assets/src/core/trace/types.ts +125 -0
- package/dist/assets/src/core/trace/workerTrace.ts +97 -0
- package/dist/assets/src/deploy/buildStamp.ts +93 -0
- package/dist/assets/src/deploy/liveGate.ts +203 -0
- package/dist/assets/src/deploy/profile.ts +162 -0
- package/dist/assets/src/deploy/restart.ts +393 -0
- package/dist/assets/src/effort.ts +17 -0
- package/dist/assets/src/execution/bashTimeout.ts +78 -0
- package/dist/assets/src/execution/bindingPurge.ts +43 -0
- package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
- package/dist/assets/src/execution/residentCleanliness.ts +95 -0
- package/dist/assets/src/execution/residentCredentials.ts +81 -0
- package/dist/assets/src/execution/residentDepCache.ts +321 -0
- package/dist/assets/src/execution/residentDepsStore.ts +326 -0
- package/dist/assets/src/execution/residentDetach.ts +48 -0
- package/dist/assets/src/execution/residentDisk.ts +107 -0
- package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
- package/dist/assets/src/execution/residentExecWrap.ts +100 -0
- package/dist/assets/src/execution/residentHead.ts +85 -0
- package/dist/assets/src/execution/residentReadonly.ts +72 -0
- package/dist/assets/src/execution/residentRefresh.ts +429 -0
- package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
- package/dist/assets/src/execution/residentState.ts +47 -0
- package/dist/assets/src/execution/residentStepReport.ts +98 -0
- package/dist/assets/src/execution/residentStepTrace.ts +97 -0
- package/dist/assets/src/execution/residentSteps.ts +99 -0
- package/dist/assets/src/execution/residentText.ts +83 -0
- package/dist/assets/src/execution/residentTrace.ts +119 -0
- package/dist/assets/src/execution/sandboxEnv.ts +42 -0
- package/dist/assets/src/execution/sandboxErrors.ts +159 -0
- package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
- package/dist/assets/src/execution/shellQuote.ts +8 -0
- package/dist/assets/src/mcp/registry.ts +242 -0
- package/dist/assets/src/providers/types.ts +152 -0
- package/dist/assets/web/dist/.vite/manifest.json +176 -0
- package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
- package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
- package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
- package/dist/assets/web/dist/assets/ResidentDetailPage-D3shEnzl.js +1 -0
- package/dist/assets/web/dist/assets/ResidentsIndexPage-DWIubQ05.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-BMjuE-oX.js +126 -0
- package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-C3_jYIo0.js +1 -0
- package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
- package/dist/assets/web/dist/assets/ScheduledPage-g1W58mtN.js +1 -0
- package/dist/assets/web/dist/assets/StatusDot-DcPRw3zu.js +1 -0
- package/dist/assets/web/dist/assets/Tooltip-DJUkMYjo.js +1 -0
- package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
- package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
- package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
- package/dist/assets/web/dist/assets/main-CyM5f4JC.js +28 -0
- package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
- package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
- package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
- package/dist/cli.js +34494 -0
- package/package.json +43 -10
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import type { ChannelVisibility, Predicate } from "./authz/types.js";
|
|
2
|
+
import type { RunEvent } from "./runEvents.js";
|
|
3
|
+
import { isHeadMaterial, isSpanRecord } from "./runEvents.js";
|
|
4
|
+
import {
|
|
5
|
+
FRICTION_CATEGORIES,
|
|
6
|
+
type CategoryTotals,
|
|
7
|
+
type FrictionCategory,
|
|
8
|
+
type FrictionDiagnosis,
|
|
9
|
+
} from "./runFriction.js";
|
|
10
|
+
|
|
11
|
+
// Run history (docs/decisions/0006-runs-have-two-lives.md): the cross-deployable contract for a persisted run.
|
|
12
|
+
// Both the bot (`src/core/runStore.ts`) and the state Worker's `RunHistoryDO`
|
|
13
|
+
// (`deploy/cloudflare-memory/`) import this file, so it is node-free — no Node
|
|
14
|
+
// built-in imports, bytes measured with `TextEncoder` — and pure: no I/O, no
|
|
15
|
+
// clock (callers pass `nowMs`). It owns the record shape, the structural
|
|
16
|
+
// validator both sides run on anything that crossed a process boundary, the
|
|
17
|
+
// ONE retention function both sides apply (so a read on either side hides the
|
|
18
|
+
// same rows), and the byte-budget helper that keeps a record storable.
|
|
19
|
+
|
|
20
|
+
// `interrupted` (tombstone-first): the run was cut down before finish —
|
|
21
|
+
// container replaced at the drain deadline, or crashed outright. Written as a
|
|
22
|
+
// provisional TERMINAL record at run start (`finishedAt` = `startedAt` there:
|
|
23
|
+
// nobody knows the real death time of a crash) and upgraded at the drain
|
|
24
|
+
// deadline with the full event stream; the finish-path write replaces it for a
|
|
25
|
+
// run that ends normally, so `interrupted` survives only for a run that never
|
|
26
|
+
// reached `finish`.
|
|
27
|
+
export type RunStatus = "completed" | "stopped_soft" | "stopped_hard" | "failed" | "interrupted";
|
|
28
|
+
|
|
29
|
+
const RUN_STATUSES: readonly RunStatus[] = ["completed", "stopped_soft", "stopped_hard", "failed", "interrupted"];
|
|
30
|
+
|
|
31
|
+
/** Every `runs.*` id: checked before any store call. */
|
|
32
|
+
export const RUN_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
33
|
+
|
|
34
|
+
/** One finished run as the store keeps it. Events are already redacted and
|
|
35
|
+
* capped upstream (`runEvents.ts`); this layer adds no data. */
|
|
36
|
+
export interface RunRecord {
|
|
37
|
+
/** The run registry id (unguessable; safe to print — it is not the view token). */
|
|
38
|
+
id: string;
|
|
39
|
+
/** The human run label from the runs index. */
|
|
40
|
+
label?: string;
|
|
41
|
+
/** Resolved agent name, when known. */
|
|
42
|
+
agent?: string;
|
|
43
|
+
/** `<provider>/<model>` the run resolved to, when known. */
|
|
44
|
+
model?: string;
|
|
45
|
+
/** Platform-namespaced ids (AGENTS.md invariant 4). */
|
|
46
|
+
channelId: string;
|
|
47
|
+
userId: string;
|
|
48
|
+
threadKey: string;
|
|
49
|
+
/** How the run's channel may travel (authorization): stamped at dispatch
|
|
50
|
+
* from the `ChannelDirectory`, read by `member-of` (a `public` run is
|
|
51
|
+
* readable by everyone). A stored record written before the stamp existed
|
|
52
|
+
* reads as `unknown` — never public (`normalizeStored`). */
|
|
53
|
+
channelVisibility: ChannelVisibility;
|
|
54
|
+
/** `owner/name` for repo runs. */
|
|
55
|
+
repo?: string;
|
|
56
|
+
/** Epoch ms. */
|
|
57
|
+
startedAt: number;
|
|
58
|
+
finishedAt: number;
|
|
59
|
+
/** The seven stamps and the one duration (docs/reference/specs/tracing.md). `receivedAt`:
|
|
60
|
+
* our process saw the message, from the adapter's clock (stamped by the
|
|
61
|
+
* dispatcher once the adapters carry it; absent until then, so every reader
|
|
62
|
+
* falls back to `startedAt`). `sealedAt`: the stream closed, when the first
|
|
63
|
+
* reply attempt completed or the branch was abandoned; `replyOk` is
|
|
64
|
+
* tri-state — `true` a reply was attempted and delivered, `false` attempted
|
|
65
|
+
* and threw, absent none was made. `stepCount`: content events only (span
|
|
66
|
+
* records excluded). `schema`: the record's stream schema (`SPAN_SCHEMA`);
|
|
67
|
+
* a record absent it or below it carries no timing. All omitted when absent. */
|
|
68
|
+
receivedAt?: number;
|
|
69
|
+
sealedAt?: number;
|
|
70
|
+
replyOk?: boolean;
|
|
71
|
+
stepCount?: number;
|
|
72
|
+
schema?: number;
|
|
73
|
+
status: RunStatus;
|
|
74
|
+
/** Events the run published in total — unchanged by truncation. */
|
|
75
|
+
eventCount: number;
|
|
76
|
+
/** Events actually present in `events` (= `events.length`). */
|
|
77
|
+
storedEventCount: number;
|
|
78
|
+
/** True when events were dropped from the middle to fit the byte budget. */
|
|
79
|
+
truncated: boolean;
|
|
80
|
+
events: RunEvent[];
|
|
81
|
+
diagnosis: FrictionDiagnosis;
|
|
82
|
+
/** The run's latest one-line activity at finish (`activityOfEvents`) — for a
|
|
83
|
+
* failed inline run the `⚠️ <error>` reply, so a persisted row can say what
|
|
84
|
+
* failed (live-view item 20). Optional: records written before it lack it. */
|
|
85
|
+
activity?: string;
|
|
86
|
+
/** Who started the run, resolved (`IncomingMessage.userName`) — the index's
|
|
87
|
+
* source mark says `via Slack · alice`, never a raw member id. */
|
|
88
|
+
userName?: string;
|
|
89
|
+
/** The thread that started the run (`IncomingMessage.sourceUrl`), for the
|
|
90
|
+
* index's hover link. Optional as above. */
|
|
91
|
+
sourceUrl?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A run as a listing shows it: the record minus its events. `diagnosis` stays —
|
|
95
|
+
* the friction ledger's `recent()` is served from this shape. `bytes` is
|
|
96
|
+
* the stored record's JSON size when the store knows it; retention treats a
|
|
97
|
+
* missing value as 0. */
|
|
98
|
+
export type RunListItem = Omit<RunRecord, "events"> & { bytes?: number };
|
|
99
|
+
|
|
100
|
+
/** A stored event with its `seq`: the registry's monotonic stamp (`RunRegistry.publish`),
|
|
101
|
+
* the SAME number the live stream and the persisted record use for this event —
|
|
102
|
+
* so an `afterSeq` cursor addresses the same events on both sides. Only an
|
|
103
|
+
* event that reached the store without a `seq` (a hand-built record) is given
|
|
104
|
+
* its 1-based position instead. */
|
|
105
|
+
export type StoredRunEvent = RunEvent & { seq: number };
|
|
106
|
+
|
|
107
|
+
/** The `seq` each of a record's events is stored under, index-aligned with
|
|
108
|
+
* `events`: their own registry stamps when every event carries a strictly
|
|
109
|
+
* increasing positive integer `seq` (the production shape), otherwise the
|
|
110
|
+
* 1-based position for EVERY event — a hand-built record, or a sequence that
|
|
111
|
+
* would collide on the store's `(run_id, seq)` key. One rule for all three
|
|
112
|
+
* stores, so they agree on what `seq` a record's events have. */
|
|
113
|
+
export function storedEventSeqs(events: readonly RunEvent[]): number[] {
|
|
114
|
+
let prev = 0;
|
|
115
|
+
for (const e of events) {
|
|
116
|
+
const s = e.seq;
|
|
117
|
+
if (typeof s !== "number" || !Number.isInteger(s) || s <= prev) return events.map((_, i) => i + 1);
|
|
118
|
+
prev = s;
|
|
119
|
+
}
|
|
120
|
+
return events.map((e) => e.seq as number);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Paging bounds every store and the service share — one definition. */
|
|
124
|
+
export const RUN_LIST_DEFAULT_LIMIT = 50;
|
|
125
|
+
export const RUN_LIST_MAX_LIMIT = 200;
|
|
126
|
+
export const RUN_EVENTS_DEFAULT_PAGE = 1000;
|
|
127
|
+
export const RUN_EVENTS_MAX_PAGE = 5000;
|
|
128
|
+
|
|
129
|
+
/** The ONE `list` limit clamp — every store, the DO, and `RunsService` apply it:
|
|
130
|
+
* default `RUN_LIST_DEFAULT_LIMIT`, at least 1, at most `RUN_LIST_MAX_LIMIT`. */
|
|
131
|
+
export function clampListLimit(limit: number | undefined): number {
|
|
132
|
+
if (limit === undefined || !Number.isFinite(limit)) return RUN_LIST_DEFAULT_LIMIT;
|
|
133
|
+
return Math.min(RUN_LIST_MAX_LIMIT, Math.max(1, Math.floor(limit)));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The `list` query — the same fields on the wire (`/runs/list`) and in the
|
|
137
|
+
* `RunStore` interface. */
|
|
138
|
+
export interface RunListOptions {
|
|
139
|
+
/** Rows to return: default `RUN_LIST_DEFAULT_LIMIT`, capped at `RUN_LIST_MAX_LIMIT`. */
|
|
140
|
+
limit?: number;
|
|
141
|
+
/** Cursor: only runs ordered after the last row seen — `finishedAt` strictly
|
|
142
|
+
* less, or equal with `id` strictly less when `beforeId` is given. Without
|
|
143
|
+
* `beforeId`, same-millisecond siblings of the last row are skipped. */
|
|
144
|
+
before?: number;
|
|
145
|
+
/** The `id` of the last row seen; pairs with `before` to make the cursor total. */
|
|
146
|
+
beforeId?: string;
|
|
147
|
+
/** Only runs finished at or after this epoch ms. */
|
|
148
|
+
sinceMs?: number;
|
|
149
|
+
agent?: string;
|
|
150
|
+
/** Platform-namespaced channel id (`slack:C0123`) — a plain filter the caller asked for. */
|
|
151
|
+
channel?: string;
|
|
152
|
+
/** What the ACTOR may see (authorization): the store predicate compiled
|
|
153
|
+
* from the policy, pushed down so no surface loads rows and filters after.
|
|
154
|
+
* Absent = no visibility constraint — only a caller that has already decided
|
|
155
|
+
* (the dispatcher's own writes, a test) omits it; the read services always
|
|
156
|
+
* pass one. */
|
|
157
|
+
visibleTo?: RunVisibilityFilter;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- visibility filter (the wire form of an authz `Predicate`) ----------------
|
|
161
|
+
|
|
162
|
+
/** The list-shaped authorization decision as it travels to a store: the authz
|
|
163
|
+
* `Predicate` with its sets as arrays, so it fits a JSON body (`/runs/list`) and
|
|
164
|
+
* the Worker can compile it to SQL. `channel-prefix` does not exist: channels
|
|
165
|
+
* are channels. Every store — in-memory, file, the DO — answers
|
|
166
|
+
* it with the same truth table as `matchesVisibility`. */
|
|
167
|
+
export type RunVisibilityFilter =
|
|
168
|
+
| { kind: "none" }
|
|
169
|
+
| { kind: "all" }
|
|
170
|
+
| { kind: "channels-in"; channelIds: string[] }
|
|
171
|
+
| { kind: "user-is"; userId: string }
|
|
172
|
+
| { kind: "repos-in"; repos: string[] }
|
|
173
|
+
| { kind: "visibility-in"; visibilities: ChannelVisibility[] }
|
|
174
|
+
| { kind: "or"; of: RunVisibilityFilter[] }
|
|
175
|
+
| { kind: "and"; of: RunVisibilityFilter[] };
|
|
176
|
+
|
|
177
|
+
export const CHANNEL_VISIBILITIES: readonly ChannelVisibility[] = ["public", "private", "dm", "machine", "unknown"];
|
|
178
|
+
|
|
179
|
+
/** A `Predicate` as the store receives it (sets → sorted arrays, so equal predicates serialize equally). */
|
|
180
|
+
export function toVisibilityFilter(predicate: Predicate): RunVisibilityFilter {
|
|
181
|
+
switch (predicate.kind) {
|
|
182
|
+
case "none":
|
|
183
|
+
case "all":
|
|
184
|
+
return { kind: predicate.kind };
|
|
185
|
+
case "channels-in":
|
|
186
|
+
return { kind: "channels-in", channelIds: [...predicate.channelIds].sort() };
|
|
187
|
+
case "user-is":
|
|
188
|
+
return { kind: "user-is", userId: predicate.userId };
|
|
189
|
+
case "repos-in":
|
|
190
|
+
return { kind: "repos-in", repos: [...predicate.repos].sort() };
|
|
191
|
+
case "visibility-in":
|
|
192
|
+
return { kind: "visibility-in", visibilities: [...predicate.visibilities].sort() };
|
|
193
|
+
case "or":
|
|
194
|
+
case "and":
|
|
195
|
+
return { kind: predicate.kind, of: predicate.of.map(toVisibilityFilter) };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const MAX_FILTER_DEPTH = 8;
|
|
200
|
+
const MAX_FILTER_IDS = 1000;
|
|
201
|
+
|
|
202
|
+
function isStringList(v: unknown, max: number): v is string[] {
|
|
203
|
+
return Array.isArray(v) && v.length <= max && v.every((s) => typeof s === "string" && s.length > 0);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Structural check on a filter from outside the process (the `/runs/list`
|
|
207
|
+
* body). Bounded in depth and width so a hostile body cannot build an
|
|
208
|
+
* unbounded SQL statement; an unknown kind or an unknown visibility is
|
|
209
|
+
* rejected, never treated as "all". */
|
|
210
|
+
export function isRunVisibilityFilter(v: unknown, depth = 0): v is RunVisibilityFilter {
|
|
211
|
+
if (depth > MAX_FILTER_DEPTH || typeof v !== "object" || v === null) return false;
|
|
212
|
+
const f = v as Record<string, unknown>;
|
|
213
|
+
switch (f.kind) {
|
|
214
|
+
case "none":
|
|
215
|
+
case "all":
|
|
216
|
+
return true;
|
|
217
|
+
case "channels-in":
|
|
218
|
+
return isStringList(f.channelIds, MAX_FILTER_IDS);
|
|
219
|
+
case "user-is":
|
|
220
|
+
return typeof f.userId === "string" && f.userId.length > 0;
|
|
221
|
+
case "repos-in":
|
|
222
|
+
return isStringList(f.repos, MAX_FILTER_IDS);
|
|
223
|
+
case "visibility-in":
|
|
224
|
+
return (
|
|
225
|
+
isStringList(f.visibilities, CHANNEL_VISIBILITIES.length) &&
|
|
226
|
+
f.visibilities.every((s) => (CHANNEL_VISIBILITIES as readonly string[]).includes(s))
|
|
227
|
+
);
|
|
228
|
+
case "or":
|
|
229
|
+
case "and":
|
|
230
|
+
return (
|
|
231
|
+
Array.isArray(f.of) && f.of.length <= MAX_FILTER_IDS && f.of.every((p) => isRunVisibilityFilter(p, depth + 1))
|
|
232
|
+
);
|
|
233
|
+
default:
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The one truth table every store implements: does `row` satisfy the filter?
|
|
239
|
+
* A row without `channelVisibility` is `unknown` — never public. */
|
|
240
|
+
export function matchesVisibility(
|
|
241
|
+
filter: RunVisibilityFilter,
|
|
242
|
+
row: Pick<RunListItem, "channelId" | "userId"> & { repo?: string; channelVisibility?: ChannelVisibility },
|
|
243
|
+
): boolean {
|
|
244
|
+
switch (filter.kind) {
|
|
245
|
+
case "none":
|
|
246
|
+
return false;
|
|
247
|
+
case "all":
|
|
248
|
+
return true;
|
|
249
|
+
case "channels-in":
|
|
250
|
+
return filter.channelIds.includes(row.channelId);
|
|
251
|
+
case "user-is":
|
|
252
|
+
return row.userId === filter.userId;
|
|
253
|
+
case "repos-in":
|
|
254
|
+
return row.repo !== undefined && filter.repos.includes(row.repo);
|
|
255
|
+
case "visibility-in":
|
|
256
|
+
return filter.visibilities.includes(row.channelVisibility ?? "unknown");
|
|
257
|
+
case "or":
|
|
258
|
+
return filter.of.some((p) => matchesVisibility(p, row));
|
|
259
|
+
case "and":
|
|
260
|
+
return filter.of.length > 0 && filter.of.every((p) => matchesVisibility(p, row));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The list order (`finishedAt` desc, `id` desc) as a cursor predicate: true
|
|
265
|
+
* when `row` comes strictly after the cursor. Shared by `selectListItems`;
|
|
266
|
+
* the Worker applies the same predicate in SQL. */
|
|
267
|
+
export function isAfterCursor(
|
|
268
|
+
row: { finishedAt: number; id: string },
|
|
269
|
+
before: number,
|
|
270
|
+
beforeId: string | undefined,
|
|
271
|
+
): boolean {
|
|
272
|
+
return row.finishedAt < before || (beforeId !== undefined && row.finishedAt === before && row.id < beforeId);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Deep copy via JSON — the ledgers and run stores hand out copies, never their rows. */
|
|
276
|
+
export function clone<T>(v: T): T {
|
|
277
|
+
return JSON.parse(JSON.stringify(v)) as T;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The three fields that identify a stored version of a run: a put whose record
|
|
281
|
+
* matches the stored row on all three is an identical retry, not a rewrite. */
|
|
282
|
+
export function sameStoredVersion(
|
|
283
|
+
a: { eventCount: number; finishedAt: number; bytes?: number },
|
|
284
|
+
b: { eventCount: number; finishedAt: number; bytes?: number },
|
|
285
|
+
): boolean {
|
|
286
|
+
return a.eventCount === b.eventCount && a.finishedAt === b.finishedAt && a.bytes === b.bytes;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface RetentionPolicy {
|
|
290
|
+
retentionDays: number;
|
|
291
|
+
maxRuns: number;
|
|
292
|
+
maxBytes: number;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const KIB = 1024;
|
|
296
|
+
const MIB = 1024 * KIB;
|
|
297
|
+
const GIB = 1024 * MIB;
|
|
298
|
+
|
|
299
|
+
export const DEFAULT_RETENTION_POLICY: Readonly<RetentionPolicy> = {
|
|
300
|
+
retentionDays: 30,
|
|
301
|
+
maxRuns: 5000,
|
|
302
|
+
maxBytes: 2 * GIB,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/** Inclusive `[min, max]` per policy field. */
|
|
306
|
+
export const RETENTION_BOUNDS: Readonly<Record<keyof RetentionPolicy, readonly [number, number]>> = {
|
|
307
|
+
retentionDays: [1, 365],
|
|
308
|
+
maxRuns: [1, 20_000],
|
|
309
|
+
maxBytes: [16 * MIB, 8 * GIB],
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/** Fill a partial policy from the defaults and clamp every field into its
|
|
313
|
+
* bounds. A non-finite value falls back to the default; fractions are floored. */
|
|
314
|
+
export function clampRetentionPolicy(partial: Partial<RetentionPolicy>): RetentionPolicy {
|
|
315
|
+
const field = (k: keyof RetentionPolicy): number => {
|
|
316
|
+
const v = partial[k];
|
|
317
|
+
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : DEFAULT_RETENTION_POLICY[k];
|
|
318
|
+
const [lo, hi] = RETENTION_BOUNDS[k];
|
|
319
|
+
return Math.min(hi, Math.max(lo, n));
|
|
320
|
+
};
|
|
321
|
+
return { retentionDays: field("retentionDays"), maxRuns: field("maxRuns"), maxBytes: field("maxBytes") };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---- validation -------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
function isOptionalString(v: unknown): boolean {
|
|
327
|
+
return v === undefined || typeof v === "string";
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function isFiniteNumber(v: unknown): v is number {
|
|
331
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isCategoryTotals(v: unknown): v is CategoryTotals {
|
|
335
|
+
if (typeof v !== "object" || v === null) return false;
|
|
336
|
+
const t = v as Record<string, unknown>;
|
|
337
|
+
return isFiniteNumber(t.count) && isFiniteNumber(t.durationMs);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Structural check on a stored diagnosis: `byCategory` is any object of
|
|
341
|
+
* `{ count, durationMs }` totals — NOT the current category list, so adding a
|
|
342
|
+
* category later does not invalidate every stored record. Readers run
|
|
343
|
+
* `normalizeDiagnosis` so every current category is present. */
|
|
344
|
+
export function isStoredDiagnosis(v: unknown): v is FrictionDiagnosis {
|
|
345
|
+
if (typeof v !== "object" || v === null) return false;
|
|
346
|
+
const d = v as Record<string, unknown>;
|
|
347
|
+
if (!Array.isArray(d.findings) || !isFiniteNumber(d.eventCount) || typeof d.verdict !== "string") return false;
|
|
348
|
+
if (d.runMs !== undefined && !isFiniteNumber(d.runMs)) return false;
|
|
349
|
+
if (typeof d.byCategory !== "object" || d.byCategory === null) return false;
|
|
350
|
+
// The shape (docs/reference/specs/tracing.md item 5), when the analyzer had a window: seven finite terms.
|
|
351
|
+
if (d.shape !== undefined) {
|
|
352
|
+
if (typeof d.shape !== "object" || d.shape === null) return false;
|
|
353
|
+
const shape = d.shape as Record<string, unknown>;
|
|
354
|
+
for (const k of [
|
|
355
|
+
"windowMs",
|
|
356
|
+
"gettingReadyMs",
|
|
357
|
+
"thinkingMs",
|
|
358
|
+
"toolsMs",
|
|
359
|
+
"finishingUpMs",
|
|
360
|
+
"overheadMs",
|
|
361
|
+
"notRecordedMs",
|
|
362
|
+
"notLoadedMs",
|
|
363
|
+
]) {
|
|
364
|
+
if (!isFiniteNumber(shape[k])) return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return Object.values(d.byCategory as Record<string, unknown>).every(isCategoryTotals);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** A stored diagnosis as the CURRENT analyzer shapes it: every current category
|
|
371
|
+
* present (a missing one zeroed), a category the analyzer no longer knows
|
|
372
|
+
* dropped. Pure; never mutates `d`. */
|
|
373
|
+
export function normalizeDiagnosis(d: FrictionDiagnosis): FrictionDiagnosis {
|
|
374
|
+
const stored = d.byCategory as Partial<Record<FrictionCategory, CategoryTotals>>;
|
|
375
|
+
const byCategory = Object.fromEntries(
|
|
376
|
+
FRICTION_CATEGORIES.map((c) => {
|
|
377
|
+
const t = stored[c];
|
|
378
|
+
return [c, t ? { count: t.count, durationMs: t.durationMs } : { count: 0, durationMs: 0 }];
|
|
379
|
+
}),
|
|
380
|
+
) as Record<FrictionCategory, CategoryTotals>;
|
|
381
|
+
return { ...d, byCategory };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** `normalizeDiagnosis` applied to anything carrying a `diagnosis` — a record or
|
|
385
|
+
* a listing row — on its way out of a store, and the `channelVisibility` stamp
|
|
386
|
+
* filled with `unknown` for a row written before it existed (fail-closed:
|
|
387
|
+
* `unknown` is never public). */
|
|
388
|
+
export function normalizeStored<T extends { diagnosis: FrictionDiagnosis; channelVisibility?: ChannelVisibility }>(
|
|
389
|
+
v: T,
|
|
390
|
+
): T & { channelVisibility: ChannelVisibility } {
|
|
391
|
+
return { ...v, diagnosis: normalizeDiagnosis(v.diagnosis), channelVisibility: v.channelVisibility ?? "unknown" };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Structural check on a record from outside the process (a Worker response, a
|
|
395
|
+
* file line, an HTTP body). Events are checked only for shape (objects with a
|
|
396
|
+
* string `type`) — the event union grows over time and a stored record must
|
|
397
|
+
* stay readable by an older reader; the diagnosis likewise is checked for
|
|
398
|
+
* shape, not for the current category list (see `normalizeDiagnosis`). */
|
|
399
|
+
export function isRunRecord(v: unknown): v is RunRecord {
|
|
400
|
+
if (typeof v !== "object" || v === null) return false;
|
|
401
|
+
const r = v as Record<string, unknown>;
|
|
402
|
+
if (typeof r.id !== "string" || !RUN_ID_PATTERN.test(r.id)) return false;
|
|
403
|
+
if (
|
|
404
|
+
!isOptionalString(r.label) ||
|
|
405
|
+
!isOptionalString(r.agent) ||
|
|
406
|
+
!isOptionalString(r.model) ||
|
|
407
|
+
!isOptionalString(r.repo)
|
|
408
|
+
)
|
|
409
|
+
return false;
|
|
410
|
+
if (!isOptionalString(r.activity) || !isOptionalString(r.sourceUrl) || !isOptionalString(r.userName)) return false;
|
|
411
|
+
if (typeof r.channelId !== "string" || typeof r.userId !== "string" || typeof r.threadKey !== "string") return false;
|
|
412
|
+
// Absent on records written before the stamp existed (read as `unknown`); present → a known value.
|
|
413
|
+
if (r.channelVisibility !== undefined && !CHANNEL_VISIBILITIES.includes(r.channelVisibility as ChannelVisibility))
|
|
414
|
+
return false;
|
|
415
|
+
if (!isFiniteNumber(r.startedAt) || !isFiniteNumber(r.finishedAt)) return false;
|
|
416
|
+
// The tracing stamps (docs/reference/specs/tracing.md): each optional, typed when present.
|
|
417
|
+
for (const key of ["receivedAt", "sealedAt"] as const) {
|
|
418
|
+
if (r[key] !== undefined && !isFiniteNumber(r[key])) return false;
|
|
419
|
+
}
|
|
420
|
+
if (r.replyOk !== undefined && typeof r.replyOk !== "boolean") return false;
|
|
421
|
+
for (const key of ["stepCount", "schema"] as const) {
|
|
422
|
+
if (r[key] !== undefined && (!isFiniteNumber(r[key]) || !Number.isInteger(r[key]) || (r[key] as number) < 0))
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
if (!RUN_STATUSES.includes(r.status as RunStatus)) return false;
|
|
426
|
+
if (!isFiniteNumber(r.eventCount) || !isFiniteNumber(r.storedEventCount)) return false;
|
|
427
|
+
if (typeof r.truncated !== "boolean") return false;
|
|
428
|
+
if (!Array.isArray(r.events)) return false;
|
|
429
|
+
if (
|
|
430
|
+
!r.events.every(
|
|
431
|
+
(e) => typeof e === "object" && e !== null && typeof (e as Record<string, unknown>).type === "string",
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
return false;
|
|
435
|
+
return isStoredDiagnosis(r.diagnosis);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Structural check on a listing row from outside the process: a `RunRecord`
|
|
439
|
+
* minus `events`, plus an optional numeric `bytes`. */
|
|
440
|
+
export function isRunListItem(v: unknown): v is RunListItem {
|
|
441
|
+
if (typeof v !== "object" || v === null) return false;
|
|
442
|
+
const r = v as Record<string, unknown>;
|
|
443
|
+
if (r.bytes !== undefined && !isFiniteNumber(r.bytes)) return false;
|
|
444
|
+
const { bytes: _bytes, ...rest } = r;
|
|
445
|
+
return isRunRecord({ ...rest, events: [] });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---- retention --------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
/** Newest first: `finishedAt` desc, then id desc — a total order, so both sides
|
|
451
|
+
* cut the same rows. */
|
|
452
|
+
export function newestFirst(a: RetentionKey, b: RetentionKey): number {
|
|
453
|
+
return b.finishedAt - a.finishedAt || (b.id > a.id ? 1 : b.id < a.id ? -1 : 0);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** What retention needs to know about a row — a `RunListItem` qualifies, and so
|
|
457
|
+
* does a bare `{ id, finishedAt, bytes }` projection from a SQL scan. */
|
|
458
|
+
export type RetentionKey = Pick<RunListItem, "id" | "finishedAt" | "bytes">;
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* The one retention function both the bot and the Worker run, in this order:
|
|
462
|
+
* (1) drop everything finished before `nowMs - retentionDays`; (2) keep the
|
|
463
|
+
* newest `maxRuns`; (3) drop the oldest while the cumulative `bytes` of what is
|
|
464
|
+
* kept exceeds `maxBytes` (missing `bytes` counts as 0). Returns the kept items
|
|
465
|
+
* newest-first; never mutates `items`.
|
|
466
|
+
*/
|
|
467
|
+
export function applyRetention<T extends RetentionKey>(
|
|
468
|
+
items: readonly T[],
|
|
469
|
+
policy: RetentionPolicy,
|
|
470
|
+
nowMs: number,
|
|
471
|
+
): T[] {
|
|
472
|
+
const cutoff = nowMs - policy.retentionDays * 86_400_000;
|
|
473
|
+
const kept = items
|
|
474
|
+
.filter((r) => r.finishedAt >= cutoff)
|
|
475
|
+
.sort(newestFirst)
|
|
476
|
+
.slice(0, Math.max(0, policy.maxRuns));
|
|
477
|
+
let total = 0;
|
|
478
|
+
let end = kept.length;
|
|
479
|
+
for (let i = 0; i < kept.length; i++) {
|
|
480
|
+
total += kept[i].bytes ?? 0;
|
|
481
|
+
if (total > policy.maxBytes) {
|
|
482
|
+
end = i;
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return kept.slice(0, end);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ---- byte budget ------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
/** The stored size of one record's JSON, after which events are dropped from the middle. */
|
|
492
|
+
export const MAX_RECORD_BYTES = 1.5 * MIB;
|
|
493
|
+
/** The JSON size of one event, after which its `text`/`summary` is truncated. */
|
|
494
|
+
export const MAX_EVENT_BYTES = 64 * KIB;
|
|
495
|
+
|
|
496
|
+
const ELLIPSIS = "…";
|
|
497
|
+
const encoder = new TextEncoder();
|
|
498
|
+
|
|
499
|
+
/** UTF-8 size of a string — what the store bills, unlike `.length`. */
|
|
500
|
+
export function utf8ByteLength(s: string): number {
|
|
501
|
+
return encoder.encode(s).byteLength;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The free-text field an event carries: `text` (input/context/assistant/answer) or `summary`
|
|
505
|
+
* (tool/note events). Undefined when the event has neither — and for a
|
|
506
|
+
* `review_artifact`, whose payload is its `diff` (capped by its producer at
|
|
507
|
+
* READING_DIFF_CAP, above this cap by design) and whose `summary` is one
|
|
508
|
+
* line: shrinking the line would never make the event fit, only lose it. */
|
|
509
|
+
function textField(e: Record<string, unknown>): "text" | "summary" | undefined {
|
|
510
|
+
if (e.type === "review_artifact") return undefined;
|
|
511
|
+
if (typeof e.text === "string") return "text";
|
|
512
|
+
if (typeof e.summary === "string") return "summary";
|
|
513
|
+
return undefined;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Truncate an event's text field until its JSON fits `maxBytes`, ending with an
|
|
517
|
+
* ellipsis. Unchanged (same object) when it already fits or has no text field.
|
|
518
|
+
* Each pass shrinks by the measured overshoot (at least one char), so the loop
|
|
519
|
+
* converges in a handful of iterations even for multi-byte text. Returns the
|
|
520
|
+
* event with its measured JSON size, so callers never re-serialize it. */
|
|
521
|
+
export function capEvent(event: RunEvent, maxBytes: number): { event: RunEvent; bytes: number } {
|
|
522
|
+
let size = utf8ByteLength(JSON.stringify(event));
|
|
523
|
+
if (size <= maxBytes) return { event, bytes: size };
|
|
524
|
+
const field = textField(event as unknown as Record<string, unknown>);
|
|
525
|
+
if (!field) return { event, bytes: size };
|
|
526
|
+
let text = (event as unknown as Record<string, string>)[field];
|
|
527
|
+
let capped = event;
|
|
528
|
+
while (size > maxBytes && text.length > 0) {
|
|
529
|
+
text = text.slice(0, Math.max(0, text.length - Math.max(1, size - maxBytes)));
|
|
530
|
+
capped = { ...event, [field]: text + ELLIPSIS } as RunEvent;
|
|
531
|
+
size = utf8ByteLength(JSON.stringify(capped));
|
|
532
|
+
}
|
|
533
|
+
return { event: capped, bytes: size };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Make a record storable under `maxBytes`. First every event is capped
|
|
538
|
+
* to `MAX_EVENT_BYTES`; then, if the record is still over budget, events are
|
|
539
|
+
* dropped from the MIDDLE: the kept set is a head and a tail grown alternately
|
|
540
|
+
* (head first) from the two ends until the next event would not fit, so the
|
|
541
|
+
* request/context/first tool steps and the terminal notes survive and the
|
|
542
|
+
* oldest middle steps go. `truncated` is set when any event was dropped,
|
|
543
|
+
* `eventCount` is left as published, `storedEventCount` is the kept length.
|
|
544
|
+
* Never mutates `record`.
|
|
545
|
+
*/
|
|
546
|
+
export function fitRecordToBudget(record: RunRecord, maxBytes: number = MAX_RECORD_BYTES): RunRecord {
|
|
547
|
+
const cappedAll = record.events.map((e) => capEvent(e, MAX_EVENT_BYTES));
|
|
548
|
+
const measure = (evs: RunEvent[]): number =>
|
|
549
|
+
utf8ByteLength(JSON.stringify({ ...record, events: evs, storedEventCount: evs.length, truncated: true }));
|
|
550
|
+
const whole: RunRecord = { ...record, events: cappedAll.map((c) => c.event), storedEventCount: cappedAll.length };
|
|
551
|
+
if (utf8ByteLength(JSON.stringify(whole)) <= maxBytes) return whole;
|
|
552
|
+
|
|
553
|
+
// Spans displace no content (docs/reference/specs/tracing.md): before any content event
|
|
554
|
+
// goes, span records are dropped pair by pair from the middle of the stream
|
|
555
|
+
// outward — never from the protected head — until the record fits or none is
|
|
556
|
+
// left. A dropped `tool.*`/`mcp.*` twin is re-synthesized by `normalizeSpans`
|
|
557
|
+
// from its content pair; a span with no twin degrades to `not recorded`.
|
|
558
|
+
const baseline = measure([]);
|
|
559
|
+
const capped = dropSpanPairsFromTheMiddle(cappedAll, maxBytes - baseline);
|
|
560
|
+
const events = capped.map((c) => c.event);
|
|
561
|
+
if (measure(events) <= maxBytes) return { ...record, events, storedEventCount: events.length, truncated: true };
|
|
562
|
+
|
|
563
|
+
// Budget the events by their own JSON sizes (plus one separator each) against
|
|
564
|
+
// what the record costs with no events, then verify the real serialization.
|
|
565
|
+
const sizes = capped.map((c) => c.bytes + 1);
|
|
566
|
+
let remaining = maxBytes - measure([]);
|
|
567
|
+
let head = 0;
|
|
568
|
+
let tail = 0;
|
|
569
|
+
while (head + tail < events.length) {
|
|
570
|
+
const takeHead = head <= tail;
|
|
571
|
+
const next = takeHead ? sizes[head] : sizes[events.length - 1 - tail];
|
|
572
|
+
if (next > remaining) break;
|
|
573
|
+
remaining -= next;
|
|
574
|
+
if (takeHead) head++;
|
|
575
|
+
else tail++;
|
|
576
|
+
}
|
|
577
|
+
const kept = [...events.slice(0, head), ...events.slice(events.length - tail)];
|
|
578
|
+
while (kept.length > 0 && measure(kept) > maxBytes) {
|
|
579
|
+
// Estimate was optimistic (should not happen; defensive): drop from the center.
|
|
580
|
+
kept.splice(Math.floor(kept.length / 2), 1);
|
|
581
|
+
}
|
|
582
|
+
return { ...record, events: kept, storedEventCount: kept.length, truncated: true };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
type Sized = { event: RunEvent; bytes: number };
|
|
586
|
+
|
|
587
|
+
/** The protected head as a record sees it: the leading run of head material
|
|
588
|
+
* (`input`, `context`, `run_meta`, the setup spans, the `mcp_unavailable` /
|
|
589
|
+
* `spans_dropped` / `cold_sandbox` notes) — the same set the registry protects. */
|
|
590
|
+
function headLength(events: readonly Sized[]): number {
|
|
591
|
+
let n = 0;
|
|
592
|
+
while (n < events.length && isHeadMaterial(events[n].event)) n++;
|
|
593
|
+
return n;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Drop span records pair by pair (both records of one `spanId`, or a lone
|
|
597
|
+
* one), nearest the middle of the non-head region first, until the events'
|
|
598
|
+
* measured sizes (plus one separator each) fit `eventBudget` — the record's
|
|
599
|
+
* budget minus what it costs with no events — or no span outside the head
|
|
600
|
+
* remains. Budgeted from the sizes already measured, so no record is
|
|
601
|
+
* re-serialized per pair; the caller verifies the real serialization once.
|
|
602
|
+
* Returns a new array. */
|
|
603
|
+
function dropSpanPairsFromTheMiddle(events: readonly Sized[], eventBudget: number): Sized[] {
|
|
604
|
+
const head = headLength(events);
|
|
605
|
+
const spanIndices: number[] = [];
|
|
606
|
+
for (let i = head; i < events.length; i++) if (isSpanRecord(events[i].event)) spanIndices.push(i);
|
|
607
|
+
if (spanIndices.length === 0) return [...events];
|
|
608
|
+
const byId = new Map<string, number[]>();
|
|
609
|
+
for (const i of spanIndices) {
|
|
610
|
+
const id = (events[i].event as { spanId: string }).spanId;
|
|
611
|
+
byId.set(id, [...(byId.get(id) ?? []), i]);
|
|
612
|
+
}
|
|
613
|
+
const middle = head + (events.length - head) / 2;
|
|
614
|
+
const byDistance = [...spanIndices].sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle));
|
|
615
|
+
let total = events.reduce((n, e) => n + e.bytes + 1, 0);
|
|
616
|
+
const dropped = new Set<number>();
|
|
617
|
+
for (const i of byDistance) {
|
|
618
|
+
if (total <= eventBudget) break;
|
|
619
|
+
if (dropped.has(i)) continue;
|
|
620
|
+
for (const j of byId.get((events[i].event as { spanId: string }).spanId) ?? []) {
|
|
621
|
+
if (dropped.has(j)) continue;
|
|
622
|
+
dropped.add(j);
|
|
623
|
+
total -= events[j].bytes + 1;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return events.filter((_, k) => !dropped.has(k));
|
|
627
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isInformative,
|
|
3
|
+
partition,
|
|
4
|
+
printedShape,
|
|
5
|
+
type LossInterval,
|
|
6
|
+
type Partition,
|
|
7
|
+
type Window,
|
|
8
|
+
} from "./trace/partition.js";
|
|
9
|
+
import type { RunOwner } from "./trace/streamSpans.js";
|
|
10
|
+
import type { SpanRecord } from "./trace/types.js";
|
|
11
|
+
import { formatDuration } from "./time/formatDuration.js";
|
|
12
|
+
|
|
13
|
+
// The shape line (docs/reference/specs/tracing.md item 5): the window partitioned into its
|
|
14
|
+
// buckets and printed as `32s getting ready · 2m 30s thinking · 55s in tools ·
|
|
15
|
+
// 8s finishing up · 7s Switchboard overhead` — the residual last, no repeated
|
|
16
|
+
// total, every non-zero bucket, the printed items summing to the printed total.
|
|
17
|
+
// One vocabulary for the card, the page and the friction report.
|
|
18
|
+
|
|
19
|
+
export interface ShapeInput {
|
|
20
|
+
window: Window;
|
|
21
|
+
owner: RunOwner;
|
|
22
|
+
finished: boolean;
|
|
23
|
+
losses?: readonly LossInterval[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The line for a partition already computed (a diagnosis's `shape`), or
|
|
27
|
+
* nothing when fewer than two buckets are informative (the caller then says
|
|
28
|
+
* the total and nothing more). */
|
|
29
|
+
export function formatShape(p: Partition | Omit<Partition, "backgroundOnlyMs">): string | undefined {
|
|
30
|
+
const full: Partition = { backgroundOnlyMs: 0, ...p };
|
|
31
|
+
if (!isInformative(full)) return undefined;
|
|
32
|
+
const printed = printedShape(full);
|
|
33
|
+
if (printed.items.length === 0) return undefined;
|
|
34
|
+
return printed.items.map(({ term, s }) => `${formatDuration(s * 1000, "clock")} ${term}`).join(" · ");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The shape line from a span set. */
|
|
38
|
+
export function shapeLine(spans: readonly SpanRecord[], input: ShapeInput): string | undefined {
|
|
39
|
+
return formatShape(partition(spans, { ...input, losses: input.losses ?? [] }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The Slack card's own gate on top of the informativeness rule: the shape is
|
|
43
|
+
* worth a line when the run took a minute or more, or getting ready alone took
|
|
44
|
+
* 15 s or more (docs/reference/specs/tracing.md — the card's size threshold). */
|
|
45
|
+
export function cardShapeLineOf(p: Partition | Omit<Partition, "backgroundOnlyMs">): string | undefined {
|
|
46
|
+
if (p.windowMs < 60_000 && p.gettingReadyMs < 15_000) return undefined;
|
|
47
|
+
return formatShape(p);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The card's gated shape line from a span set (a close before any run existed). */
|
|
51
|
+
export function cardShapeLine(spans: readonly SpanRecord[], input: ShapeInput): string | undefined {
|
|
52
|
+
return cardShapeLineOf(partition(spans, { ...input, losses: input.losses ?? [] }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The queued captions, never part of a duration (docs/reference/specs/tracing.md): shown
|
|
56
|
+
* from a minute of waiting. */
|
|
57
|
+
export function queuedCaption(kind: "before" | "behind", ms: number | undefined): string | undefined {
|
|
58
|
+
if (ms === undefined || ms < 60_000) return undefined;
|
|
59
|
+
const d = formatDuration(ms, "clock");
|
|
60
|
+
return kind === "before" ? `queued ${d} before we saw it` : `queued ${d} behind the previous run`;
|
|
61
|
+
}
|