glm-coding-router 1.1.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +542 -426
  3. package/dist/bin/glm-review.js +28 -3
  4. package/dist/bin/glm-worker.js +30 -4
  5. package/dist/budget/estimator.js +218 -0
  6. package/dist/budget/manager.js +223 -0
  7. package/dist/cli.js +46 -3
  8. package/dist/commands/dashboard.js +348 -0
  9. package/dist/commands/doctor-auth.js +107 -0
  10. package/dist/commands/doctor-command.js +171 -41
  11. package/dist/commands/landing.js +47 -0
  12. package/dist/commands/runs.js +568 -0
  13. package/dist/commands/status.js +28 -15
  14. package/dist/commands/usage.js +34 -58
  15. package/dist/commands/watch.js +289 -0
  16. package/dist/core/config.js +61 -0
  17. package/dist/core/errors.js +24 -0
  18. package/dist/core/key-inspector.js +45 -0
  19. package/dist/core/paths.js +32 -0
  20. package/dist/core/process.js +83 -0
  21. package/dist/core/prompt.js +18 -5
  22. package/dist/core/routing-flags.js +59 -0
  23. package/dist/core/user-env.js +17 -7
  24. package/dist/core/zai-quota.js +148 -0
  25. package/dist/events/bus.js +64 -0
  26. package/dist/events/claude-adapter.js +416 -0
  27. package/dist/events/types.js +9 -0
  28. package/dist/handoff/bundle.js +203 -0
  29. package/dist/handoff/parent-handoff.js +48 -0
  30. package/dist/mcp/server.js +45 -1
  31. package/dist/routing/glm-routing.js +131 -0
  32. package/dist/runs/checkpoint.js +204 -0
  33. package/dist/runs/drain.js +165 -0
  34. package/dist/runs/heartbeat.js +45 -0
  35. package/dist/runs/registry.js +350 -0
  36. package/dist/runs/store.js +186 -0
  37. package/dist/runs/ulid.js +112 -0
  38. package/dist/runs/worker-run.js +672 -0
  39. package/dist/templates/agents-block.js +53 -44
  40. package/dist/templates/claude-block.js +56 -47
  41. package/dist/templates/glm-delegation-skill.js +76 -65
  42. package/dist/tui/command-ui.js +158 -0
  43. package/dist/tui/progress.js +338 -0
  44. package/dist/tui/render.js +144 -0
  45. package/package.json +1 -1
@@ -0,0 +1,350 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { logger, redact } from "../core/logging.js";
5
+ import { activeRunFile, activeRunsDir, runDir, runsDir } from "../core/paths.js";
6
+ import { atomicWriteFile } from "../project/atomic-write.js";
7
+ import { ZAI_API_KEY_ENV } from "../core/zai-key.js";
8
+ import { eventsFilePath } from "./store.js";
9
+ /** C3: 120 chars is the widest a task title may ever be on disk or in a line. */
10
+ export const TASK_TITLE_MAX_CHARS = 120;
11
+ /**
12
+ * A heartbeat older than this (30 s = six missed 5 s ticks) plus a dead pid
13
+ * is what makes a run orphaned. The threshold is a property of the liveness
14
+ * protocol, not a user knob — tuning it without the heartbeat interval would
15
+ * silently break orphan detection.
16
+ */
17
+ export const ORPHAN_HEARTBEAT_MS = 30_000;
18
+ const DAY_MS = 24 * 60 * 60 * 1000;
19
+ /**
20
+ * C3: the first line of the prompt, redacted, cut to 120 chars — the only
21
+ * prompt-derived text ever persisted.
22
+ *
23
+ * `secrets` exists because the process environment is NOT where the key
24
+ * lives on the platform this ships to: `resolveZaiApiKey` reads it from the
25
+ * Windows User Environment (or a keychain) precisely so it stays out of
26
+ * `process.env`. A caller that already resolved the key passes it here; the
27
+ * env var is only a fallback for when it happens to be exported.
28
+ */
29
+ export function taskTitleOf(prompt, secrets = []) {
30
+ const firstLine = prompt.split(/\r?\n/, 1)[0] ?? "";
31
+ return redact(firstLine, [...secrets, process.env[ZAI_API_KEY_ENV]]).slice(0, TASK_TITLE_MAX_CHARS);
32
+ }
33
+ /**
34
+ * C3: sha256 of the FULL prompt. The hash lets the estimator recognize the
35
+ * same task again while the body itself never touches disk.
36
+ */
37
+ export function taskHashOf(prompt) {
38
+ return createHash("sha256").update(prompt, "utf8").digest("hex");
39
+ }
40
+ /**
41
+ * Registers a run and creates its history directory immediately — not at the
42
+ * end — so `watch` can attach to a run that is still going. Only this
43
+ * function propagates filesystem errors: a run whose registry entry cannot be
44
+ * written exists nowhere, and the caller must decide what to do about that.
45
+ */
46
+ export function createRun(meta, deps) {
47
+ fs.mkdirSync(runDir(deps.home, meta.date, meta.id), { recursive: true });
48
+ fs.mkdirSync(activeRunsDir(deps.home), { recursive: true });
49
+ const active = {
50
+ ...meta,
51
+ state: "RUNNING",
52
+ heartbeatAt: (deps.now?.() ?? new Date()).toISOString(),
53
+ };
54
+ atomicWriteFile(activeRunFile(deps.home, meta.id), serialize(active));
55
+ return active;
56
+ }
57
+ /**
58
+ * Atomically patches the active file. Returns null — never throws — when the
59
+ * entry is missing or corrupt: a stale registry file must not take the run
60
+ * down with it, and callers (the heartbeat) treat null as "skip quietly".
61
+ */
62
+ export function updateRun(home, id, patch) {
63
+ const file = activeRunFile(home, id);
64
+ try {
65
+ const current = readJsonFile(file);
66
+ if (!isStoredRun(current)) {
67
+ logger.debug(`updateRun: ${file} is missing or not a run entry`);
68
+ return null;
69
+ }
70
+ const next = { ...current, ...patch };
71
+ atomicWriteFile(file, serialize(next));
72
+ return next;
73
+ }
74
+ catch (error) {
75
+ logger.debug(`updateRun: patching ${id} failed: ${errorMessage(error)}`);
76
+ return null;
77
+ }
78
+ }
79
+ /**
80
+ * Writes `summary.json` into the run's history directory, then deletes the
81
+ * active file — that order is what makes "active file gone but no summary" a
82
+ * crash signature rather than a possible intermediate state of a clean run.
83
+ * Never throws: a run that already finished is not worth failing `finishRun`
84
+ * over, so every failure is logged at debug and skipped.
85
+ */
86
+ export function finishRun(home, id, summary) {
87
+ const dir = locateRunDir(home, id);
88
+ if (dir === null) {
89
+ logger.debug(`finishRun: no history directory found for ${id}`);
90
+ return;
91
+ }
92
+ try {
93
+ atomicWriteFile(path.join(dir, "summary.json"), serialize(summary));
94
+ }
95
+ catch (error) {
96
+ logger.debug(`finishRun: writing summary for ${id} failed: ${errorMessage(error)}`);
97
+ return;
98
+ }
99
+ try {
100
+ fs.rmSync(activeRunFile(home, id), { force: true });
101
+ }
102
+ catch (error) {
103
+ logger.debug(`finishRun: removing active file for ${id} failed: ${errorMessage(error)}`);
104
+ }
105
+ }
106
+ /** Every registered run that claims to be alive, newest first. Corrupt files are skipped, never fatal. */
107
+ export function listActive(home) {
108
+ const dir = activeRunsDir(home);
109
+ let names;
110
+ try {
111
+ names = fs.readdirSync(dir);
112
+ }
113
+ catch {
114
+ return [];
115
+ }
116
+ const runs = [];
117
+ for (const name of names) {
118
+ if (!name.endsWith(".json")) {
119
+ continue;
120
+ }
121
+ const entry = readJsonFile(path.join(dir, name));
122
+ if (isStoredRun(entry)) {
123
+ runs.push(entry);
124
+ }
125
+ else {
126
+ logger.debug(`listActive: skipping unreadable entry ${name}`);
127
+ }
128
+ }
129
+ // ULIDs sort chronologically, so id order is start order with no parsing.
130
+ return runs.sort((a, b) => (a.id < b.id ? 1 : -1));
131
+ }
132
+ /** History entries, newest first, optionally filtered by kind and/or terminal state. */
133
+ export function listHistory(home, filter = {}) {
134
+ const refs = [];
135
+ for (const date of historyDates(home)) {
136
+ for (const id of runIdsIn(path.join(historyRoot(home), date))) {
137
+ const ref = readSummaryRef(home, date, id);
138
+ if (ref !== null &&
139
+ (filter.kind === undefined || ref.kind === filter.kind) &&
140
+ (filter.state === undefined || ref.state === filter.state)) {
141
+ refs.push(ref);
142
+ }
143
+ }
144
+ }
145
+ // Dates and ids were read sorted ascending; reverse for newest-first.
146
+ return refs.reverse();
147
+ }
148
+ /**
149
+ * Enforces `retentionDays` then `maxRuns` (oldest first), called
150
+ * opportunistically — a pruning failure must never block the work around it.
151
+ * Returns the removed run ids so `runs clean` can say what it did.
152
+ */
153
+ export function pruneHistory(home, limits) {
154
+ const now = Date.now();
155
+ const entries = [];
156
+ for (const date of historyDates(home)) {
157
+ const dateMs = Date.parse(`${date}T00:00:00.000Z`);
158
+ if (!Number.isFinite(dateMs)) {
159
+ logger.debug(`pruneHistory: skipping non-date directory ${date}`);
160
+ continue;
161
+ }
162
+ for (const id of runIdsIn(path.join(historyRoot(home), date))) {
163
+ entries.push({ date, id, dateMs });
164
+ }
165
+ }
166
+ const expired = entries.filter((entry) => now - entry.dateMs >= limits.retentionDays * DAY_MS);
167
+ const kept = entries
168
+ .filter((entry) => !expired.includes(entry))
169
+ .sort((a, b) => (a.date === b.date ? (a.id < b.id ? -1 : 1) : a.date < b.date ? -1 : 1));
170
+ // Oldest first, only as many as exceed the cap.
171
+ const overflow = kept.slice(0, Math.max(0, kept.length - limits.maxRuns));
172
+ const removed = [];
173
+ const touchedDates = new Set();
174
+ for (const entry of [...expired, ...overflow]) {
175
+ try {
176
+ fs.rmSync(runDir(home, entry.date, entry.id), { recursive: true, force: true });
177
+ removed.push(entry.id);
178
+ touchedDates.add(entry.date);
179
+ }
180
+ catch (error) {
181
+ logger.debug(`pruneHistory: removing ${entry.id} failed: ${errorMessage(error)}`);
182
+ }
183
+ }
184
+ for (const date of touchedDates) {
185
+ try {
186
+ fs.rmdirSync(path.join(historyRoot(home), date));
187
+ }
188
+ catch {
189
+ // Directory not empty (a kept/failed removal lives there) — nothing to do.
190
+ }
191
+ }
192
+ return { removed };
193
+ }
194
+ /**
195
+ * True when the heartbeat is stale AND the process is gone. Both conditions:
196
+ * a fresh heartbeat with a dead pid is a run that just hasn't ticked again,
197
+ * and a stale heartbeat with a live pid is a busy worker, not an orphan.
198
+ */
199
+ export function isOrphaned(run, deps = {}) {
200
+ const now = deps.now?.() ?? Date.now();
201
+ const heartbeatAge = now - Date.parse(run.heartbeatAt);
202
+ // A NaN age (unparseable heartbeatAt) fails the comparison, so a corrupt
203
+ // entry is treated as fresh rather than reaped on a technicality.
204
+ if (!(heartbeatAge > ORPHAN_HEARTBEAT_MS)) {
205
+ return false;
206
+ }
207
+ const isAlive = deps.isAlive ?? defaultIsAlive;
208
+ return !isAlive(run.pid);
209
+ }
210
+ /**
211
+ * The liveness probe: `process.kill(pid, 0)` sends no signal, it only asks
212
+ * the OS whether the process exists. EPERM means it exists but belongs to
213
+ * someone else — that is a live pid, not a dead one.
214
+ */
215
+ function defaultIsAlive(pid) {
216
+ try {
217
+ process.kill(pid, 0);
218
+ return true;
219
+ }
220
+ catch (error) {
221
+ return error.code === "EPERM";
222
+ }
223
+ }
224
+ function historyRoot(home) {
225
+ return path.join(runsDir(home), "history");
226
+ }
227
+ /** Date directories, sorted oldest first so consumers can rely on iteration order. */
228
+ function historyDates(home) {
229
+ try {
230
+ return fs
231
+ .readdirSync(historyRoot(home), { withFileTypes: true })
232
+ .filter((entry) => entry.isDirectory())
233
+ .map((entry) => entry.name)
234
+ .sort();
235
+ }
236
+ catch {
237
+ return [];
238
+ }
239
+ }
240
+ function runIdsIn(dir) {
241
+ try {
242
+ return fs
243
+ .readdirSync(dir, { withFileTypes: true })
244
+ .filter((entry) => entry.isDirectory())
245
+ .map((entry) => entry.name)
246
+ .sort();
247
+ }
248
+ catch {
249
+ return [];
250
+ }
251
+ }
252
+ /**
253
+ * Builds one history entry. `summary.json` answers "how did it end"; when it
254
+ * is missing the run crashed, and kind/startedAt come from the first line of
255
+ * `events.jsonl` (a `RunStarted`, written before anything could crash).
256
+ */
257
+ function readSummaryRef(home, date, id) {
258
+ const dir = runDir(home, date, id);
259
+ const summary = readJsonFile(path.join(dir, "summary.json"));
260
+ const firstEvent = readFirstEvent(dir);
261
+ if (!isSummary(summary) && firstEvent === null) {
262
+ // Neither artifact is readable — debris, not a run worth listing.
263
+ logger.debug(`listHistory: skipping ${dir} (no readable summary or events)`);
264
+ return null;
265
+ }
266
+ const state = isSummary(summary) ? summary.state : "CRASHED";
267
+ return {
268
+ id,
269
+ date,
270
+ kind: firstEvent?.kind ?? null,
271
+ state,
272
+ startedAt: firstEvent?.startedAt ?? null,
273
+ summary: isSummary(summary) ? summary : null,
274
+ };
275
+ }
276
+ /** The first line of `events.jsonl`, or null — reading more would make a listing O(stream). */
277
+ function readFirstEvent(dir) {
278
+ try {
279
+ const firstLine = fs.readFileSync(eventsFilePath(dir), "utf8").split(/\r?\n/, 1)[0] ?? "";
280
+ if (firstLine.trim() === "") {
281
+ return null;
282
+ }
283
+ const parsed = JSON.parse(firstLine);
284
+ if (typeof parsed !== "object" || parsed === null) {
285
+ return null;
286
+ }
287
+ const record = parsed;
288
+ if (typeof record.ts !== "string") {
289
+ return null;
290
+ }
291
+ return {
292
+ kind: typeof record.kind === "string" ? record.kind : null,
293
+ startedAt: record.ts,
294
+ };
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ }
300
+ /**
301
+ * Where a run's history directory is. The active file is the fast path; the
302
+ * scan exists so `finishRun` still works when the active file was already
303
+ * reaped (crashed process, later cleanup).
304
+ */
305
+ function locateRunDir(home, id) {
306
+ const entry = readJsonFile(activeRunFile(home, id));
307
+ if (isStoredRun(entry)) {
308
+ const dir = runDir(home, entry.date, id);
309
+ if (fs.existsSync(dir)) {
310
+ return dir;
311
+ }
312
+ }
313
+ for (const date of historyDates(home)) {
314
+ const dir = runDir(home, date, id);
315
+ if (fs.existsSync(dir)) {
316
+ return dir;
317
+ }
318
+ }
319
+ return null;
320
+ }
321
+ function readJsonFile(file) {
322
+ try {
323
+ return JSON.parse(fs.readFileSync(file, "utf8"));
324
+ }
325
+ catch {
326
+ return null;
327
+ }
328
+ }
329
+ /**
330
+ * Deliberately shallow: enough to know the file is a run entry, not a full
331
+ * schema check — a future field must not make every old entry unreadable.
332
+ */
333
+ function isStoredRun(value) {
334
+ if (typeof value !== "object" || value === null) {
335
+ return false;
336
+ }
337
+ const record = value;
338
+ return (typeof record.id === "string" &&
339
+ typeof record.state === "string" &&
340
+ typeof record.heartbeatAt === "string");
341
+ }
342
+ function isSummary(value) {
343
+ return typeof value === "object" && value !== null && typeof value.state === "string";
344
+ }
345
+ function serialize(value) {
346
+ return JSON.stringify(value, null, 2) + "\n";
347
+ }
348
+ function errorMessage(error) {
349
+ return error instanceof Error ? error.message : String(error);
350
+ }
@@ -0,0 +1,186 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { logger } from "../core/logging.js";
4
+ /** The one file every run is guaranteed to leave behind, even when it crashed. */
5
+ export const EVENTS_FILE_NAME = "events.jsonl";
6
+ /** `events.jsonl` inside a run directory; exported so every module spells the path the same way. */
7
+ export function eventsFilePath(runDirPath) {
8
+ return path.join(runDirPath, EVENTS_FILE_NAME);
9
+ }
10
+ /**
11
+ * Opens `events.jsonl` in append mode. Append mode is the whole crash story:
12
+ * the file is never truncated, every line reaches the OS as it is appended,
13
+ * and a killed process leaves at worst one half-written final line — which
14
+ * `readEvents` tolerates — and never a summary, which is precisely how
15
+ * `runs show` tells a crashed run from a finished one.
16
+ *
17
+ * `append` may throw (disk full, fd gone); the event bus catches subscriber
18
+ * exceptions, so a broken store degrades to lost lines instead of a dead run.
19
+ */
20
+ export function openRunStore(runDirPath) {
21
+ fs.mkdirSync(runDirPath, { recursive: true });
22
+ const file = eventsFilePath(runDirPath);
23
+ const fd = fs.openSync(file, "a");
24
+ let closed = false;
25
+ return {
26
+ path: file,
27
+ append(event) {
28
+ if (closed) {
29
+ logger.debug(`run store: append after close on ${file} skipped`);
30
+ return;
31
+ }
32
+ fs.writeSync(fd, JSON.stringify(event) + "\n", null, "utf8");
33
+ },
34
+ flush() {
35
+ if (!closed) {
36
+ fs.fsyncSync(fd);
37
+ }
38
+ },
39
+ close() {
40
+ if (closed) {
41
+ return;
42
+ }
43
+ closed = true;
44
+ try {
45
+ fs.fsyncSync(fd);
46
+ }
47
+ finally {
48
+ fs.closeSync(fd);
49
+ }
50
+ },
51
+ };
52
+ }
53
+ /**
54
+ * Reconstructs the event stream from a run directory. A malformed line is
55
+ * skipped, not fatal — a truncated final line is the normal wreckage of a
56
+ * crash, and the events before it are exactly what survived. A missing file
57
+ * yields an empty stream for the same reason: history readers must never
58
+ * throw on the artifacts a crash leaves behind.
59
+ */
60
+ export function readEvents(runDirPath) {
61
+ let text;
62
+ try {
63
+ text = fs.readFileSync(eventsFilePath(runDirPath), "utf8");
64
+ }
65
+ catch {
66
+ logger.debug(`run store: no events file in ${runDirPath}`);
67
+ return [];
68
+ }
69
+ const events = [];
70
+ const lines = text.split(/\r?\n/);
71
+ for (const [index, line] of lines.entries()) {
72
+ if (line.trim() === "") {
73
+ continue;
74
+ }
75
+ try {
76
+ const parsed = JSON.parse(line);
77
+ if (isEventLike(parsed)) {
78
+ events.push(parsed);
79
+ }
80
+ else {
81
+ logger.debug(`run store: line ${index + 1} in ${runDirPath} is not an event`);
82
+ }
83
+ }
84
+ catch {
85
+ logger.debug(`run store: skipping malformed line ${index + 1} in ${runDirPath}`);
86
+ }
87
+ }
88
+ return events;
89
+ }
90
+ /**
91
+ * Rebuilds a summary from events alone. The stream's `RunCompleted` numbers
92
+ * (A0: taken straight from the result message) are authoritative when
93
+ * present; the event-derived fallbacks exist so a crashed run still gets a
94
+ * meaningful summary — that is the entire point of rebuilding rather than
95
+ * trusting a file the crash prevented us from writing.
96
+ */
97
+ export function summarize(events) {
98
+ let state = "CRASHED";
99
+ let turns = 0;
100
+ let resultTurns = 0;
101
+ let resultDurationMs = 0;
102
+ let durationMs = 0;
103
+ let tokensIn = 0;
104
+ let tokensOut = 0;
105
+ let denied = 0;
106
+ let retries = 0;
107
+ let validation = "none";
108
+ const filesChanged = new Set();
109
+ for (const event of events) {
110
+ switch (event.type) {
111
+ case "TurnStarted":
112
+ turns = Math.max(turns, event.turn);
113
+ break;
114
+ case "FileChanged":
115
+ filesChanged.add(event.path);
116
+ break;
117
+ case "ToolDenied":
118
+ denied += 1;
119
+ if (validation === "pending") {
120
+ validation = "denied";
121
+ }
122
+ break;
123
+ case "ApiRetry":
124
+ retries += 1;
125
+ break;
126
+ case "ValidationStarted":
127
+ validation = "pending";
128
+ break;
129
+ case "ValidationCompleted":
130
+ validation = event.ok ? "ok" : "failed";
131
+ break;
132
+ case "RunCompleted":
133
+ state = "COMPLETED";
134
+ // A run can carry MORE THAN ONE result message: a child that hits
135
+ // --max-turns and continues emits one per segment. Observed live on
136
+ // 2026-09-20 — RunFailed(max_turns), then two RunCompleted — where
137
+ // overwriting made a 21-minute, 64-turn run persist as "88s, 5 turns".
138
+ // Each result describes only its own segment, so accumulate, and let
139
+ // the derived turn count win when it is larger.
140
+ resultTurns += event.turns;
141
+ resultDurationMs += event.durationMs;
142
+ tokensIn += event.tokensIn;
143
+ tokensOut += event.tokensOut;
144
+ break;
145
+ case "RunFailed":
146
+ state = "FAILED";
147
+ break;
148
+ case "RunCancelled":
149
+ state = "CANCELLED";
150
+ break;
151
+ default:
152
+ break;
153
+ }
154
+ }
155
+ // The wall clock is the span of the stream; a sum of per-segment result
156
+ // durations misses the gaps between them. Take whichever is larger so a
157
+ // long run can never be reported as a short one.
158
+ const span = events.length > 0
159
+ ? Date.parse(events[events.length - 1].ts) - Date.parse(events[0].ts)
160
+ : 0;
161
+ durationMs = Math.max(resultDurationMs, Number.isFinite(span) && span > 0 ? span : 0);
162
+ turns = Math.max(turns, resultTurns);
163
+ return {
164
+ id: events.length > 0 ? events[0].runId : "",
165
+ state,
166
+ turns,
167
+ durationMs,
168
+ filesChanged: [...filesChanged],
169
+ tokensIn,
170
+ tokensOut,
171
+ denied,
172
+ retries,
173
+ validation,
174
+ };
175
+ }
176
+ /**
177
+ * The runtime check is deliberately just "parses and has a `type` string":
178
+ * fully validating the union at runtime would duplicate the event model, and
179
+ * a line that passes this check but carries wrong fields is history written
180
+ * by a future version — dropping it would lose data forever.
181
+ */
182
+ function isEventLike(value) {
183
+ return (typeof value === "object" &&
184
+ value !== null &&
185
+ typeof value.type === "string");
186
+ }
@@ -0,0 +1,112 @@
1
+ import { randomBytes } from "node:crypto";
2
+ /**
3
+ * ULID generation (specs/v2-architecture.md, Phase B).
4
+ *
5
+ * Lexicographic sort order equals chronological order — that property, not
6
+ * the id's shape, is why runs use ULIDs instead of UUIDs: the run history is
7
+ * browsed by sorting ids as plain strings.
8
+ */
9
+ /** Crockford base32: no I, L, O or U, so no visually ambiguous character ever appears in a run id. */
10
+ const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
11
+ const TIME_LEN = 10;
12
+ const RANDOM_BYTES = 10; // 80 bits = exactly 16 base32 chars × 5 bits
13
+ /**
14
+ * Process-wide monotonic state: the last timestamp used and the random
15
+ * component it was paired with. Two ids in the same millisecond must still
16
+ * order, so the second one increments the first one's randomness instead of
17
+ * drawing fresh bytes.
18
+ */
19
+ let lastTime = -1;
20
+ let lastRandom = null;
21
+ /**
22
+ * A 26-char ULID: 10 chars of millisecond timestamp (most significant first)
23
+ * followed by 16 chars of randomness. Monotonic within the process — same-ms
24
+ * calls increment the 80-bit random component as a big-endian integer, so
25
+ * generated ids never collide and always sort after their predecessors.
26
+ * `now` is injectable so tests can pin and freeze time.
27
+ */
28
+ export function ulid(now = Date.now) {
29
+ let time = now();
30
+ let random = null;
31
+ // `<=` rather than `===` also absorbs a clock that jumps backwards: reusing
32
+ // the last timestamp with an incremented random part preserves the invariant
33
+ // that actually matters — generation order == sort order.
34
+ if (lastRandom !== null && time <= lastTime) {
35
+ random = incrementRandom(lastRandom);
36
+ if (random === null) {
37
+ // All 80 random bits overflowed (probability 2^-80). Spin to the next
38
+ // millisecond rather than return a value that sorts before its
39
+ // predecessor; if the clock never advances (a frozen injected clock),
40
+ // step the timestamp ourselves — still strictly after the previous id.
41
+ let spins = 0;
42
+ do {
43
+ time = now();
44
+ } while (time <= lastTime && ++spins < 1_000_000);
45
+ if (time <= lastTime) {
46
+ time = lastTime + 1;
47
+ }
48
+ random = randomBytes(RANDOM_BYTES);
49
+ }
50
+ else {
51
+ time = lastTime;
52
+ }
53
+ }
54
+ else {
55
+ random = randomBytes(RANDOM_BYTES);
56
+ }
57
+ lastTime = time;
58
+ lastRandom = random;
59
+ return encodeTime(time) + encodeRandom(random);
60
+ }
61
+ /** `run_` + a ULID (doc §5): the prefix makes run ids self-describing in logs, dirs and file names. */
62
+ export function runId(now = Date.now) {
63
+ return `run_${ulid(now)}`;
64
+ }
65
+ /**
66
+ * A 48-bit millisecond timestamp fits exactly in a double, so plain division
67
+ * encodes it most-significant-first into 10 chars — which is what makes the
68
+ * time part of the string sort chronologically.
69
+ */
70
+ function encodeTime(time) {
71
+ let chars = "";
72
+ let remaining = time;
73
+ for (let count = 0; count < TIME_LEN; count++) {
74
+ chars = ENCODING[remaining % 32] + chars;
75
+ remaining = Math.floor(remaining / 32);
76
+ }
77
+ return chars;
78
+ }
79
+ /**
80
+ * 10 bytes → 16 × 5-bit symbols, big-endian. 80 is a multiple of 5, so the
81
+ * bit funnel drains exactly with nothing left over.
82
+ */
83
+ function encodeRandom(bytes) {
84
+ let chars = "";
85
+ let buffer = 0;
86
+ let bits = 0;
87
+ for (const byte of bytes) {
88
+ buffer = (buffer << 8) | byte;
89
+ bits += 8;
90
+ while (bits >= 5) {
91
+ bits -= 5;
92
+ chars += ENCODING[(buffer >>> bits) & 31];
93
+ }
94
+ }
95
+ return chars;
96
+ }
97
+ /**
98
+ * +1 on the 80-bit random component read as a big-endian integer. Returns
99
+ * null when every bit was already 1 — the caller must then move to a new
100
+ * millisecond, because a wrapped value would sort before its predecessor.
101
+ */
102
+ function incrementRandom(bytes) {
103
+ const next = Uint8Array.from(bytes);
104
+ for (let index = next.length - 1; index >= 0; index--) {
105
+ if (next[index] < 0xff) {
106
+ next[index] += 1;
107
+ return next;
108
+ }
109
+ next[index] = 0;
110
+ }
111
+ return null;
112
+ }