@telorun/runner-core 0.5.2

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 (109) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +38 -0
  3. package/dist/backend.d.ts +71 -0
  4. package/dist/backend.d.ts.map +1 -0
  5. package/dist/backend.js +2 -0
  6. package/dist/backend.js.map +1 -0
  7. package/dist/base-image-catalog.d.ts +110 -0
  8. package/dist/base-image-catalog.d.ts.map +1 -0
  9. package/dist/base-image-catalog.js +245 -0
  10. package/dist/base-image-catalog.js.map +1 -0
  11. package/dist/capabilities-schema.d.ts +33 -0
  12. package/dist/capabilities-schema.d.ts.map +1 -0
  13. package/dist/capabilities-schema.js +44 -0
  14. package/dist/capabilities-schema.js.map +1 -0
  15. package/dist/config.d.ts +37 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +93 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/contract.d.ts +170 -0
  20. package/dist/contract.d.ts.map +1 -0
  21. package/dist/contract.js +24 -0
  22. package/dist/contract.js.map +1 -0
  23. package/dist/debug/relay.d.ts +25 -0
  24. package/dist/debug/relay.d.ts.map +1 -0
  25. package/dist/debug/relay.js +89 -0
  26. package/dist/debug/relay.js.map +1 -0
  27. package/dist/dependency-key.d.ts +38 -0
  28. package/dist/dependency-key.d.ts.map +1 -0
  29. package/dist/dependency-key.js +68 -0
  30. package/dist/dependency-key.js.map +1 -0
  31. package/dist/index.d.ts +20 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +19 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/routes/capabilities.d.ts +12 -0
  36. package/dist/routes/capabilities.d.ts.map +1 -0
  37. package/dist/routes/capabilities.js +15 -0
  38. package/dist/routes/capabilities.js.map +1 -0
  39. package/dist/routes/health.d.ts +5 -0
  40. package/dist/routes/health.d.ts.map +1 -0
  41. package/dist/routes/health.js +8 -0
  42. package/dist/routes/health.js.map +1 -0
  43. package/dist/routes/io.d.ts +8 -0
  44. package/dist/routes/io.d.ts.map +1 -0
  45. package/dist/routes/io.js +239 -0
  46. package/dist/routes/io.js.map +1 -0
  47. package/dist/routes/probe.d.ts +7 -0
  48. package/dist/routes/probe.d.ts.map +1 -0
  49. package/dist/routes/probe.js +22 -0
  50. package/dist/routes/probe.js.map +1 -0
  51. package/dist/routes/sessions.d.ts +22 -0
  52. package/dist/routes/sessions.d.ts.map +1 -0
  53. package/dist/routes/sessions.js +223 -0
  54. package/dist/routes/sessions.js.map +1 -0
  55. package/dist/server.d.ts +34 -0
  56. package/dist/server.d.ts.map +1 -0
  57. package/dist/server.js +67 -0
  58. package/dist/server.js.map +1 -0
  59. package/dist/session/bundle-path.d.ts +12 -0
  60. package/dist/session/bundle-path.d.ts.map +1 -0
  61. package/dist/session/bundle-path.js +27 -0
  62. package/dist/session/bundle-path.js.map +1 -0
  63. package/dist/session/byte-ring-buffer.d.ts +29 -0
  64. package/dist/session/byte-ring-buffer.d.ts.map +1 -0
  65. package/dist/session/byte-ring-buffer.js +54 -0
  66. package/dist/session/byte-ring-buffer.js.map +1 -0
  67. package/dist/session/registry.d.ts +62 -0
  68. package/dist/session/registry.d.ts.map +1 -0
  69. package/dist/session/registry.js +156 -0
  70. package/dist/session/registry.js.map +1 -0
  71. package/dist/session/ring-buffer.d.ts +37 -0
  72. package/dist/session/ring-buffer.d.ts.map +1 -0
  73. package/dist/session/ring-buffer.js +64 -0
  74. package/dist/session/ring-buffer.js.map +1 -0
  75. package/dist/session/session-id.d.ts +3 -0
  76. package/dist/session/session-id.d.ts.map +1 -0
  77. package/dist/session/session-id.js +19 -0
  78. package/dist/session/session-id.js.map +1 -0
  79. package/dist/sse/channel.d.ts +11 -0
  80. package/dist/sse/channel.d.ts.map +1 -0
  81. package/dist/sse/channel.js +129 -0
  82. package/dist/sse/channel.js.map +1 -0
  83. package/package.json +48 -0
  84. package/src/backend.ts +88 -0
  85. package/src/base-image-catalog.test.ts +209 -0
  86. package/src/base-image-catalog.ts +320 -0
  87. package/src/capabilities-schema.test.ts +54 -0
  88. package/src/capabilities-schema.ts +71 -0
  89. package/src/config.ts +122 -0
  90. package/src/contract.ts +170 -0
  91. package/src/debug/relay.ts +104 -0
  92. package/src/dependency-key.test.ts +64 -0
  93. package/src/dependency-key.ts +105 -0
  94. package/src/index.ts +33 -0
  95. package/src/routes/capabilities.ts +20 -0
  96. package/src/routes/health.ts +9 -0
  97. package/src/routes/io.ts +265 -0
  98. package/src/routes/probe.ts +35 -0
  99. package/src/routes/sessions.ts +270 -0
  100. package/src/server.ts +108 -0
  101. package/src/session/bundle-path.ts +27 -0
  102. package/src/session/byte-ring-buffer.ts +62 -0
  103. package/src/session/registry.test.ts +34 -0
  104. package/src/session/registry.ts +185 -0
  105. package/src/session/ring-buffer.test.ts +54 -0
  106. package/src/session/ring-buffer.ts +75 -0
  107. package/src/session/session-id.test.ts +17 -0
  108. package/src/session/session-id.ts +20 -0
  109. package/src/sse/channel.ts +154 -0
package/dist/server.js ADDED
@@ -0,0 +1,67 @@
1
+ import Fastify from "fastify";
2
+ import cors from "@fastify/cors";
3
+ import websocket from "@fastify/websocket";
4
+ import { capabilitiesRoute } from "./routes/capabilities.js";
5
+ import { healthRoute } from "./routes/health.js";
6
+ import { ioRoute } from "./routes/io.js";
7
+ import { probeRoute } from "./routes/probe.js";
8
+ import { sessionsRoute } from "./routes/sessions.js";
9
+ import { SessionRegistry } from "./session/registry.js";
10
+ export async function buildServer(deps) {
11
+ const app = Fastify({
12
+ logger: { level: deps.config.logLevel },
13
+ });
14
+ // CORS: SSE and fetch from the editor's browser origin are cross-origin by
15
+ // default. A runner with no auth is driven by whoever can reach the port, so
16
+ // default to `*` and let operators narrow via RUNNER_CORS_ORIGINS.
17
+ await app.register(cors, {
18
+ origin: deps.config.corsOrigins,
19
+ methods: ["GET", "POST", "DELETE", "OPTIONS"],
20
+ });
21
+ await app.register(websocket);
22
+ const registry = deps.registry ??
23
+ new SessionRegistry({
24
+ maxSessions: deps.config.maxSessions,
25
+ exitTtlMs: deps.config.exitTtlMs,
26
+ replayBufferBytes: deps.config.replayBufferBytes,
27
+ });
28
+ // Terms are stable across the process — resolve the capabilities once for them
29
+ // even when `capabilities` is a getter (the route still re-resolves per request).
30
+ const capabilitiesValue = typeof deps.capabilities === "function" ? deps.capabilities() : deps.capabilities;
31
+ await app.register(healthRoute(deps.version));
32
+ await app.register(capabilitiesRoute(deps.capabilities));
33
+ await app.register(probeRoute({ backend: deps.backend }));
34
+ await app.register(sessionsRoute({
35
+ backend: deps.backend,
36
+ registry,
37
+ corsOrigins: deps.config.corsOrigins,
38
+ defaultRegistryUrl: deps.defaultRegistryUrl,
39
+ validateConfig: deps.validateConfig,
40
+ // The capabilities document is the single source of the runner's terms;
41
+ // the session route enforces what /v1/capabilities advertises.
42
+ terms: capabilitiesValue.terms,
43
+ }));
44
+ await app.register(ioRoute({ registry, corsOrigins: deps.config.corsOrigins }));
45
+ return { app, registry };
46
+ }
47
+ /**
48
+ * Stop every live session. Used by graceful shutdown — marks each entry
49
+ * userStopped and stops its backend workload so nothing leaks past process
50
+ * exit. Backend-neutral: it only touches the abstract `BackendSession`.
51
+ */
52
+ export async function stopAllSessions(registry, log) {
53
+ const live = registry.list().filter((e) => e.session !== null && e.exitedAt === null);
54
+ if (live.length === 0)
55
+ return;
56
+ log.info({ count: live.length }, "stopping live sessions before shutdown");
57
+ await Promise.all(live.map(async (entry) => {
58
+ entry.userStopped = true;
59
+ try {
60
+ await entry.session?.stop();
61
+ }
62
+ catch (err) {
63
+ log.warn({ err, sessionId: entry.sessionId }, "failed to stop session during shutdown");
64
+ }
65
+ }));
66
+ }
67
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ export declare class BundlePathError extends Error {
2
+ }
3
+ /**
4
+ * Bundle file paths arrive POSIX-style and untrusted. Guard against traversal
5
+ * explicitly rather than trusting `path.resolve` — an `entryRelativePath` or
6
+ * `files[].relativePath` of `../foo` would escape the session's own directory.
7
+ * Backend-neutral: every backend normalizes paths the same way before placing
8
+ * files, regardless of how it ultimately delivers the bundle.
9
+ */
10
+ export declare function normalizeBundlePath(p: string): string;
11
+ export declare function validateSessionId(id: string): void;
12
+ //# sourceMappingURL=bundle-path.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundle-path.d.ts","sourceRoot":"","sources":["../../src/session/bundle-path.ts"],"names":[],"mappings":"AAEA,qBAAa,eAAgB,SAAQ,KAAK;CAAG;AAE7C;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CASrD;AAED,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAIlD"}
@@ -0,0 +1,27 @@
1
+ import { posix } from "node:path";
2
+ export class BundlePathError extends Error {
3
+ }
4
+ /**
5
+ * Bundle file paths arrive POSIX-style and untrusted. Guard against traversal
6
+ * explicitly rather than trusting `path.resolve` — an `entryRelativePath` or
7
+ * `files[].relativePath` of `../foo` would escape the session's own directory.
8
+ * Backend-neutral: every backend normalizes paths the same way before placing
9
+ * files, regardless of how it ultimately delivers the bundle.
10
+ */
11
+ export function normalizeBundlePath(p) {
12
+ const normalized = posix.normalize(p).replace(/^\/+/, "");
13
+ if (normalized === "" || normalized === "." || normalized.startsWith("../") || normalized === "..") {
14
+ throw new BundlePathError(`invalid bundle relativePath '${p}'`);
15
+ }
16
+ for (const part of normalized.split("/")) {
17
+ if (part === "..")
18
+ throw new BundlePathError(`invalid bundle relativePath '${p}'`);
19
+ }
20
+ return normalized;
21
+ }
22
+ export function validateSessionId(id) {
23
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
24
+ throw new BundlePathError(`invalid sessionId '${id}' (must match /^[a-zA-Z0-9_-]+$/)`);
25
+ }
26
+ }
27
+ //# sourceMappingURL=bundle-path.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundle-path.js","sourceRoot":"","sources":["../../src/session/bundle-path.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AAElC,MAAM,OAAO,eAAgB,SAAQ,KAAK;CAAG;AAE7C;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CAAC,CAAS;IAC3C,MAAM,UAAU,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1D,IAAI,UAAU,KAAK,EAAE,IAAI,UAAU,KAAK,GAAG,IAAI,UAAU,CAAC,UAAU,CAAC,KAAK,CAAC,IAAI,UAAU,KAAK,IAAI,EAAE,CAAC;QACnG,MAAM,IAAI,eAAe,CAAC,gCAAgC,CAAC,GAAG,CAAC,CAAC;IAClE,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QACzC,IAAI,IAAI,KAAK,IAAI;YAAE,MAAM,IAAI,eAAe,CAAC,gCAAgC,CAAC,GAAG,CAAC,CAAC;IACrF,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,EAAU;IAC1C,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC;QACjC,MAAM,IAAI,eAAe,CAAC,sBAAsB,EAAE,mCAAmC,CAAC,CAAC;IACzF,CAAC;AACH,CAAC"}
@@ -0,0 +1,29 @@
1
+ export interface BufferedBytes {
2
+ seq: number;
3
+ bytes: Buffer;
4
+ }
5
+ /**
6
+ * Byte-capped FIFO ring buffer for raw PTY output replay. Mirrors
7
+ * EventRingBuffer's eviction-with-gap semantics but stores Buffer slices keyed
8
+ * by a monotonic seq number — never reused, never reset.
9
+ *
10
+ * A client's `?lastSeq=<n>` asks for chunks with seq > n; if the oldest
11
+ * remaining seq is still > n+1, the gap must be signaled separately.
12
+ */
13
+ export declare class ByteRingBuffer {
14
+ private readonly maxBytes;
15
+ private readonly entries;
16
+ private totalBytes;
17
+ private nextSeq;
18
+ constructor(maxBytes: number);
19
+ push(bytes: Buffer): BufferedBytes;
20
+ private evict;
21
+ replay(afterSeq: number): {
22
+ entries: BufferedBytes[];
23
+ hasGap: boolean;
24
+ };
25
+ get size(): number;
26
+ get bytes(): number;
27
+ get latestSeq(): number;
28
+ }
29
+ //# sourceMappingURL=byte-ring-buffer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"byte-ring-buffer.d.ts","sourceRoot":"","sources":["../../src/session/byte-ring-buffer.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;GAOG;AACH,qBAAa,cAAc;IAKb,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAJrC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuB;IAC/C,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,OAAO,CAAK;gBAES,QAAQ,EAAE,MAAM;IAM7C,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa;IASlC,OAAO,CAAC,KAAK;IAUb,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG;QAAE,OAAO,EAAE,aAAa,EAAE,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE;IAOvE,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,IAAI,SAAS,IAAI,MAAM,CAEtB;CACF"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Byte-capped FIFO ring buffer for raw PTY output replay. Mirrors
3
+ * EventRingBuffer's eviction-with-gap semantics but stores Buffer slices keyed
4
+ * by a monotonic seq number — never reused, never reset.
5
+ *
6
+ * A client's `?lastSeq=<n>` asks for chunks with seq > n; if the oldest
7
+ * remaining seq is still > n+1, the gap must be signaled separately.
8
+ */
9
+ export class ByteRingBuffer {
10
+ maxBytes;
11
+ entries = [];
12
+ totalBytes = 0;
13
+ nextSeq = 1;
14
+ constructor(maxBytes) {
15
+ this.maxBytes = maxBytes;
16
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
17
+ throw new Error(`ByteRingBuffer maxBytes must be a positive integer, got ${maxBytes}`);
18
+ }
19
+ }
20
+ push(bytes) {
21
+ const seq = this.nextSeq++;
22
+ const entry = { seq, bytes };
23
+ this.entries.push(entry);
24
+ this.totalBytes += bytes.byteLength;
25
+ this.evict();
26
+ return entry;
27
+ }
28
+ evict() {
29
+ // Always retain at least the most-recent entry, even if it alone exceeds
30
+ // maxBytes — same invariant as EventRingBuffer.
31
+ while (this.totalBytes > this.maxBytes && this.entries.length > 1) {
32
+ const dropped = this.entries.shift();
33
+ if (!dropped)
34
+ break;
35
+ this.totalBytes -= dropped.bytes.byteLength;
36
+ }
37
+ }
38
+ replay(afterSeq) {
39
+ const entries = this.entries.filter((e) => e.seq > afterSeq);
40
+ const oldestSeq = this.entries[0]?.seq ?? this.nextSeq;
41
+ const hasGap = afterSeq + 1 < oldestSeq;
42
+ return { entries, hasGap };
43
+ }
44
+ get size() {
45
+ return this.entries.length;
46
+ }
47
+ get bytes() {
48
+ return this.totalBytes;
49
+ }
50
+ get latestSeq() {
51
+ return this.nextSeq - 1;
52
+ }
53
+ }
54
+ //# sourceMappingURL=byte-ring-buffer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"byte-ring-buffer.js","sourceRoot":"","sources":["../../src/session/byte-ring-buffer.ts"],"names":[],"mappings":"AAKA;;;;;;;GAOG;AACH,MAAM,OAAO,cAAc;IAKI;IAJZ,OAAO,GAAoB,EAAE,CAAC;IACvC,UAAU,GAAG,CAAC,CAAC;IACf,OAAO,GAAG,CAAC,CAAC;IAEpB,YAA6B,QAAgB;QAAhB,aAAQ,GAAR,QAAQ,CAAQ;QAC3C,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,QAAQ,IAAI,CAAC,EAAE,CAAC;YACjD,MAAM,IAAI,KAAK,CAAC,2DAA2D,QAAQ,EAAE,CAAC,CAAC;QACzF,CAAC;IACH,CAAC;IAED,IAAI,CAAC,KAAa;QAChB,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QAC3B,MAAM,KAAK,GAAkB,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC;QAC5C,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACzB,IAAI,CAAC,UAAU,IAAI,KAAK,CAAC,UAAU,CAAC;QACpC,IAAI,CAAC,KAAK,EAAE,CAAC;QACb,OAAO,KAAK,CAAC;IACf,CAAC;IAEO,KAAK;QACX,yEAAyE;QACzE,gDAAgD;QAChD,OAAO,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAClE,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;YACrC,IAAI,CAAC,OAAO;gBAAE,MAAM;YACpB,IAAI,CAAC,UAAU,IAAI,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC;QAC9C,CAAC;IACH,CAAC;IAED,MAAM,CAAC,QAAgB;QACrB,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,GAAG,QAAQ,CAAC,CAAC;QAC7D,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,IAAI,CAAC,OAAO,CAAC;QACvD,MAAM,MAAM,GAAG,QAAQ,GAAG,CAAC,GAAG,SAAS,CAAC;QACxC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAC7B,CAAC;IAED,IAAI,IAAI;QACN,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC;IAC7B,CAAC;IAED,IAAI,KAAK;QACP,OAAO,IAAI,CAAC,UAAU,CAAC;IACzB,CAAC;IAED,IAAI,SAAS;QACX,OAAO,IAAI,CAAC,OAAO,GAAG,CAAC,CAAC;IAC1B,CAAC;CACF"}
@@ -0,0 +1,62 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { BackendSession } from "../backend.js";
3
+ import { type RunEvent, type RunStatus } from "../contract.js";
4
+ import { ByteRingBuffer, type BufferedBytes } from "./byte-ring-buffer.js";
5
+ import { EventRingBuffer, type BufferedEvent } from "./ring-buffer.js";
6
+ export interface SessionEntry {
7
+ readonly sessionId: string;
8
+ readonly createdAt: Date;
9
+ readonly buffer: EventRingBuffer;
10
+ readonly byteBuffer: ByteRingBuffer;
11
+ readonly emitter: EventEmitter;
12
+ readonly byteEmitter: EventEmitter;
13
+ /** The live backend workload. Null until `start` resolves; the route writes
14
+ * stdin / resize / stop through it. A backend's `writeStdin` is a no-op once
15
+ * the workload has terminated, so callers need not null it on exit. */
16
+ session: BackendSession | null;
17
+ status: RunStatus;
18
+ exitedAt: Date | null;
19
+ userStopped: boolean;
20
+ evictionTimer: NodeJS.Timeout | null;
21
+ }
22
+ export interface RegistryDeps {
23
+ maxSessions: number;
24
+ exitTtlMs: number;
25
+ replayBufferBytes: number;
26
+ }
27
+ export declare class SessionLimitError extends Error {
28
+ }
29
+ export declare class SessionEvictedError extends Error {
30
+ }
31
+ export declare class SessionRegistry {
32
+ private readonly deps;
33
+ private readonly sessions;
34
+ constructor(deps: RegistryDeps);
35
+ size(): number;
36
+ has(sessionId: string): boolean;
37
+ get(sessionId: string): SessionEntry | undefined;
38
+ list(): SessionEntry[];
39
+ /**
40
+ * Creates a fresh registry entry. Callers are responsible for guarding against
41
+ * duplicate insertion. Throws SessionLimitError if we're at capacity.
42
+ */
43
+ register(args: {
44
+ sessionId: string;
45
+ }): SessionEntry;
46
+ pushBytes(sessionId: string, bytes: Buffer): BufferedBytes | undefined;
47
+ subscribeBytes(sessionId: string, listener: (b: BufferedBytes) => void): () => void;
48
+ emit(sessionId: string, event: RunEvent): BufferedEvent | undefined;
49
+ subscribe(sessionId: string, listener: (e: BufferedEvent) => void): () => void;
50
+ /** Free a slot at capacity by removing the oldest already-terminated session
51
+ * (by exit time). Retained exited sessions are history kept for re-attach, so
52
+ * they yield to a new run rather than blocking it; live sessions are never
53
+ * evicted. Returns false when every session is still live. */
54
+ private evictOldestTerminal;
55
+ private scheduleEviction;
56
+ /**
57
+ * Remove an entry immediately (used by shutdown sweeps and startup cleanup).
58
+ * Returns true if it was present.
59
+ */
60
+ remove(sessionId: string): boolean;
61
+ }
62
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/session/registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,EAAc,KAAK,QAAQ,EAAE,KAAK,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3E,OAAO,EAAE,eAAe,EAAE,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEvE,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;IACjC,QAAQ,CAAC,UAAU,EAAE,cAAc,CAAC;IACpC,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC;IAC/B,QAAQ,CAAC,WAAW,EAAE,YAAY,CAAC;IAEnC;;4EAEwE;IACxE,OAAO,EAAE,cAAc,GAAG,IAAI,CAAC;IAC/B,MAAM,EAAE,SAAS,CAAC;IAClB,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;IACtB,WAAW,EAAE,OAAO,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAaD,qBAAa,iBAAkB,SAAQ,KAAK;CAAG;AAC/C,qBAAa,mBAAoB,SAAQ,KAAK;CAAG;AAEjD,qBAAa,eAAe;IAGd,OAAO,CAAC,QAAQ,CAAC,IAAI;IAFjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAmC;gBAE/B,IAAI,EAAE,YAAY;IAE/C,IAAI,IAAI,MAAM;IAId,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAI/B,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS;IAIhD,IAAI,IAAI,YAAY,EAAE;IAItB;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,YAAY;IA6BnD,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS;IAqBtE,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,CAAC,EAAE,aAAa,KAAK,IAAI,GAAG,MAAM,IAAI;IAOnF,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,GAAG,aAAa,GAAG,SAAS;IAenE,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,CAAC,EAAE,aAAa,KAAK,IAAI,GAAG,MAAM,IAAI;IAO9E;;;mEAG+D;IAC/D,OAAO,CAAC,mBAAmB;IAU3B,OAAO,CAAC,gBAAgB;IAUxB;;;OAGG;IACH,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;CAOnC"}
@@ -0,0 +1,156 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { isTerminal } from "../contract.js";
3
+ import { ByteRingBuffer } from "./byte-ring-buffer.js";
4
+ import { EventRingBuffer } from "./ring-buffer.js";
5
+ const EVENT_EMITTED = "event";
6
+ const BYTES_EMITTED = "chunk";
7
+ /** Cap on a single buffered byte chunk. Without this, one huge workload
8
+ * burst (`cat largefile`) could be admitted as one entry; the ring
9
+ * buffer's "retain at least one" invariant would then keep that one
10
+ * oversized entry resident regardless of `replayBufferBytes`. Splitting
11
+ * on push means the cap actually bounds memory and the eviction loop
12
+ * has fine-grained units to drop. */
13
+ const MAX_PUSH_CHUNK = 64 * 1024;
14
+ export class SessionLimitError extends Error {
15
+ }
16
+ export class SessionEvictedError extends Error {
17
+ }
18
+ export class SessionRegistry {
19
+ deps;
20
+ sessions = new Map();
21
+ constructor(deps) {
22
+ this.deps = deps;
23
+ }
24
+ size() {
25
+ return this.sessions.size;
26
+ }
27
+ has(sessionId) {
28
+ return this.sessions.has(sessionId);
29
+ }
30
+ get(sessionId) {
31
+ return this.sessions.get(sessionId);
32
+ }
33
+ list() {
34
+ return [...this.sessions.values()];
35
+ }
36
+ /**
37
+ * Creates a fresh registry entry. Callers are responsible for guarding against
38
+ * duplicate insertion. Throws SessionLimitError if we're at capacity.
39
+ */
40
+ register(args) {
41
+ if (this.sessions.size >= this.deps.maxSessions && !this.evictOldestTerminal()) {
42
+ throw new SessionLimitError(`runner is at its configured max of ${this.deps.maxSessions} concurrent sessions`);
43
+ }
44
+ const entry = {
45
+ sessionId: args.sessionId,
46
+ createdAt: new Date(),
47
+ buffer: new EventRingBuffer(this.deps.replayBufferBytes),
48
+ byteBuffer: new ByteRingBuffer(this.deps.replayBufferBytes),
49
+ emitter: new EventEmitter(),
50
+ byteEmitter: new EventEmitter(),
51
+ session: null,
52
+ status: { kind: "starting" },
53
+ exitedAt: null,
54
+ userStopped: false,
55
+ evictionTimer: null,
56
+ };
57
+ // Many transient SSE / WS subscribers per session is normal — bump the
58
+ // default 10-listener warning to a high cap so the alarm still fires
59
+ // for a real listener leak. 256 is well above the realistic concurrent-
60
+ // tab count and well below "obviously a bug".
61
+ entry.emitter.setMaxListeners(256);
62
+ entry.byteEmitter.setMaxListeners(256);
63
+ this.sessions.set(args.sessionId, entry);
64
+ return entry;
65
+ }
66
+ pushBytes(sessionId, bytes) {
67
+ const entry = this.sessions.get(sessionId);
68
+ if (!entry)
69
+ return undefined;
70
+ if (bytes.byteLength <= MAX_PUSH_CHUNK) {
71
+ const buffered = entry.byteBuffer.push(bytes);
72
+ entry.byteEmitter.emit(BYTES_EMITTED, buffered);
73
+ return buffered;
74
+ }
75
+ // Split the chunk into MAX_PUSH_CHUNK-sized slices, each getting its
76
+ // own seq. Returns the last buffered piece for the caller's bookkeeping.
77
+ let last;
78
+ for (let off = 0; off < bytes.byteLength; off += MAX_PUSH_CHUNK) {
79
+ const slice = bytes.subarray(off, Math.min(off + MAX_PUSH_CHUNK, bytes.byteLength));
80
+ // subarray shares memory with the parent buffer; copy so the ring's
81
+ // entry doesn't pin the original allocation past eviction.
82
+ last = entry.byteBuffer.push(Buffer.from(slice));
83
+ entry.byteEmitter.emit(BYTES_EMITTED, last);
84
+ }
85
+ return last;
86
+ }
87
+ subscribeBytes(sessionId, listener) {
88
+ const entry = this.sessions.get(sessionId);
89
+ if (!entry)
90
+ throw new SessionEvictedError(`session '${sessionId}' not in registry`);
91
+ entry.byteEmitter.on(BYTES_EMITTED, listener);
92
+ return () => entry.byteEmitter.off(BYTES_EMITTED, listener);
93
+ }
94
+ emit(sessionId, event) {
95
+ const entry = this.sessions.get(sessionId);
96
+ if (!entry)
97
+ return undefined;
98
+ const buffered = entry.buffer.push(event);
99
+ if (event.type === "status") {
100
+ entry.status = event.status;
101
+ if (isTerminal(event.status)) {
102
+ entry.exitedAt = new Date();
103
+ this.scheduleEviction(entry);
104
+ }
105
+ }
106
+ entry.emitter.emit(EVENT_EMITTED, buffered);
107
+ return buffered;
108
+ }
109
+ subscribe(sessionId, listener) {
110
+ const entry = this.sessions.get(sessionId);
111
+ if (!entry)
112
+ throw new SessionEvictedError(`session '${sessionId}' not in registry`);
113
+ entry.emitter.on(EVENT_EMITTED, listener);
114
+ return () => entry.emitter.off(EVENT_EMITTED, listener);
115
+ }
116
+ /** Free a slot at capacity by removing the oldest already-terminated session
117
+ * (by exit time). Retained exited sessions are history kept for re-attach, so
118
+ * they yield to a new run rather than blocking it; live sessions are never
119
+ * evicted. Returns false when every session is still live. */
120
+ evictOldestTerminal() {
121
+ let oldest;
122
+ for (const entry of this.sessions.values()) {
123
+ if (entry.exitedAt === null)
124
+ continue;
125
+ if (!oldest || entry.exitedAt < oldest.exitedAt)
126
+ oldest = entry;
127
+ }
128
+ if (!oldest)
129
+ return false;
130
+ return this.remove(oldest.sessionId);
131
+ }
132
+ scheduleEviction(entry) {
133
+ if (entry.evictionTimer)
134
+ return;
135
+ entry.evictionTimer = setTimeout(() => {
136
+ this.sessions.delete(entry.sessionId);
137
+ }, this.deps.exitTtlMs);
138
+ // Allow process exit even if evictions are pending — they are pure state,
139
+ // not work.
140
+ entry.evictionTimer.unref?.();
141
+ }
142
+ /**
143
+ * Remove an entry immediately (used by shutdown sweeps and startup cleanup).
144
+ * Returns true if it was present.
145
+ */
146
+ remove(sessionId) {
147
+ const entry = this.sessions.get(sessionId);
148
+ if (!entry)
149
+ return false;
150
+ if (entry.evictionTimer)
151
+ clearTimeout(entry.evictionTimer);
152
+ this.sessions.delete(sessionId);
153
+ return true;
154
+ }
155
+ }
156
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/session/registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAG3C,OAAO,EAAE,UAAU,EAAiC,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,cAAc,EAAsB,MAAM,uBAAuB,CAAC;AAC3E,OAAO,EAAE,eAAe,EAAsB,MAAM,kBAAkB,CAAC;AA0BvE,MAAM,aAAa,GAAG,OAAO,CAAC;AAC9B,MAAM,aAAa,GAAG,OAAO,CAAC;AAE9B;;;;;sCAKsC;AACtC,MAAM,cAAc,GAAG,EAAE,GAAG,IAAI,CAAC;AAEjC,MAAM,OAAO,iBAAkB,SAAQ,KAAK;CAAG;AAC/C,MAAM,OAAO,mBAAoB,SAAQ,KAAK;CAAG;AAEjD,MAAM,OAAO,eAAe;IAGG;IAFZ,QAAQ,GAAG,IAAI,GAAG,EAAwB,CAAC;IAE5D,YAA6B,IAAkB;QAAlB,SAAI,GAAJ,IAAI,CAAc;IAAG,CAAC;IAEnD,IAAI;QACF,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;IAC5B,CAAC;IAED,GAAG,CAAC,SAAiB;QACnB,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACtC,CAAC;IAED,GAAG,CAAC,SAAiB;QACnB,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACtC,CAAC;IAED,IAAI;QACF,OAAO,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;IACrC,CAAC;IAED;;;OAGG;IACH,QAAQ,CAAC,IAA2B;QAClC,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,WAAW,IAAI,CAAC,IAAI,CAAC,mBAAmB,EAAE,EAAE,CAAC;YAC/E,MAAM,IAAI,iBAAiB,CACzB,sCAAsC,IAAI,CAAC,IAAI,CAAC,WAAW,sBAAsB,CAClF,CAAC;QACJ,CAAC;QACD,MAAM,KAAK,GAAiB;YAC1B,SAAS,EAAE,IAAI,CAAC,SAAS;YACzB,SAAS,EAAE,IAAI,IAAI,EAAE;YACrB,MAAM,EAAE,IAAI,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,iBAAiB,CAAC;YACxD,UAAU,EAAE,IAAI,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,iBAAiB,CAAC;YAC3D,OAAO,EAAE,IAAI,YAAY,EAAE;YAC3B,WAAW,EAAE,IAAI,YAAY,EAAE;YAC/B,OAAO,EAAE,IAAI;YACb,MAAM,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE;YAC5B,QAAQ,EAAE,IAAI;YACd,WAAW,EAAE,KAAK;YAClB,aAAa,EAAE,IAAI;SACpB,CAAC;QACF,uEAAuE;QACvE,qEAAqE;QACrE,wEAAwE;QACxE,8CAA8C;QAC9C,KAAK,CAAC,OAAO,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;QACnC,KAAK,CAAC,WAAW,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;QACvC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QACzC,OAAO,KAAK,CAAC;IACf,CAAC;IAED,SAAS,CAAC,SAAiB,EAAE,KAAa;QACxC,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAC3C,IAAI,CAAC,KAAK;YAAE,OAAO,SAAS,CAAC;QAC7B,IAAI,KAAK,CAAC,UAAU,IAAI,cAAc,EAAE,CAAC;YACvC,MAAM,QAAQ,GAAG,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YAC9C,KAAK,CAAC,WAAW,CAAC,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;YAChD,OAAO,QAAQ,CAAC;QAClB,CAAC;QACD,qEAAqE;QACrE,yEAAyE;QACzE,IAAI,IAA+B,CAAC;QACpC,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,GAAG,KAAK,CAAC,UAAU,EAAE,GAAG,IAAI,cAAc,EAAE,CAAC;YAChE,MAAM,KAAK,GAAG,KAAK,CAAC,QAAQ,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,GAAG,cAAc,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;YACpF,oEAAoE;YACpE,2DAA2D;YAC3D,IAAI,GAAG,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;YACjD,KAAK,CAAC,WAAW,CAAC,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,CAAC;QAC9C,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAED,cAAc,CAAC,SAAiB,EAAE,QAAoC;QACpE,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAC3C,IAAI,CAAC,KAAK;YAAE,MAAM,IAAI,mBAAmB,CAAC,YAAY,SAAS,mBAAmB,CAAC,CAAC;QACpF,KAAK,CAAC,WAAW,CAAC,EAAE,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;QAC9C,OAAO,GAAG,EAAE,CAAC,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;IAC9D,CAAC;IAED,IAAI,CAAC,SAAiB,EAAE,KAAe;QACrC,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAC3C,IAAI,CAAC,KAAK;YAAE,OAAO,SAAS,CAAC;QAC7B,MAAM,QAAQ,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC1C,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC5B,KAAK,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC;YAC5B,IAAI,UAAU,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC7B,KAAK,CAAC,QAAQ,GAAG,IAAI,IAAI,EAAE,CAAC;gBAC5B,IAAI,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAAC;YAC/B,CAAC;QACH,CAAC;QACD,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;QAC5C,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,SAAS,CAAC,SAAiB,EAAE,QAAoC;QAC/D,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAC3C,IAAI,CAAC,KAAK;YAAE,MAAM,IAAI,mBAAmB,CAAC,YAAY,SAAS,mBAAmB,CAAC,CAAC;QACpF,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;QAC1C,OAAO,GAAG,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;IAC1D,CAAC;IAED;;;mEAG+D;IACvD,mBAAmB;QACzB,IAAI,MAAgC,CAAC;QACrC,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,EAAE,CAAC;YAC3C,IAAI,KAAK,CAAC,QAAQ,KAAK,IAAI;gBAAE,SAAS;YACtC,IAAI,CAAC,MAAM,IAAI,KAAK,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAS;gBAAE,MAAM,GAAG,KAAK,CAAC;QACnE,CAAC;QACD,IAAI,CAAC,MAAM;YAAE,OAAO,KAAK,CAAC;QAC1B,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IACvC,CAAC;IAEO,gBAAgB,CAAC,KAAmB;QAC1C,IAAI,KAAK,CAAC,aAAa;YAAE,OAAO;QAChC,KAAK,CAAC,aAAa,GAAG,UAAU,CAAC,GAAG,EAAE;YACpC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;QACxC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACxB,0EAA0E;QAC1E,YAAY;QACZ,KAAK,CAAC,aAAa,CAAC,KAAK,EAAE,EAAE,CAAC;IAChC,CAAC;IAED;;;OAGG;IACH,MAAM,CAAC,SAAiB;QACtB,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAC3C,IAAI,CAAC,KAAK;YAAE,OAAO,KAAK,CAAC;QACzB,IAAI,KAAK,CAAC,aAAa;YAAE,YAAY,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QAC3D,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAChC,OAAO,IAAI,CAAC;IACd,CAAC;CACF"}
@@ -0,0 +1,37 @@
1
+ import type { RunEvent } from "../contract.js";
2
+ export interface BufferedEvent {
3
+ id: number;
4
+ event: RunEvent;
5
+ bytes: number;
6
+ }
7
+ /**
8
+ * Byte-capped FIFO ring buffer for SSE replay. Each entry tracks its
9
+ * serialized byte size; when a new entry would push total bytes past the cap,
10
+ * oldest entries are evicted until it fits. Entries are never split.
11
+ *
12
+ * Ids are assigned monotonically starting at 1 — never reused, never reset.
13
+ * A client's `Last-Event-ID: <n>` asks for events with id > n; if the oldest
14
+ * remaining id is still > n+1, the gap must be signaled separately.
15
+ */
16
+ export declare class EventRingBuffer {
17
+ private readonly maxBytes;
18
+ private readonly entries;
19
+ private totalBytes;
20
+ private nextId;
21
+ constructor(maxBytes: number);
22
+ push(event: RunEvent): BufferedEvent;
23
+ private evict;
24
+ /**
25
+ * Returns entries with id > afterId, in insertion order.
26
+ * If `hasGap` is true, the caller should emit a `gap` marker before replay
27
+ * because events between afterId+1 and the first returned id have been evicted.
28
+ */
29
+ replay(afterId: number): {
30
+ entries: BufferedEvent[];
31
+ hasGap: boolean;
32
+ };
33
+ get size(): number;
34
+ get bytes(): number;
35
+ get latestId(): number;
36
+ }
37
+ //# sourceMappingURL=ring-buffer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ring-buffer.d.ts","sourceRoot":"","sources":["../../src/session/ring-buffer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,QAAQ,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;GAQG;AACH,qBAAa,eAAe;IAKd,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAJrC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuB;IAC/C,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,MAAM,CAAK;gBAEU,QAAQ,EAAE,MAAM;IAM7C,IAAI,CAAC,KAAK,EAAE,QAAQ,GAAG,aAAa;IAUpC,OAAO,CAAC,KAAK;IAab;;;;OAIG;IACH,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG;QAAE,OAAO,EAAE,aAAa,EAAE,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE;IAOtE,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,IAAI,QAAQ,IAAI,MAAM,CAErB;CACF"}
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Byte-capped FIFO ring buffer for SSE replay. Each entry tracks its
3
+ * serialized byte size; when a new entry would push total bytes past the cap,
4
+ * oldest entries are evicted until it fits. Entries are never split.
5
+ *
6
+ * Ids are assigned monotonically starting at 1 — never reused, never reset.
7
+ * A client's `Last-Event-ID: <n>` asks for events with id > n; if the oldest
8
+ * remaining id is still > n+1, the gap must be signaled separately.
9
+ */
10
+ export class EventRingBuffer {
11
+ maxBytes;
12
+ entries = [];
13
+ totalBytes = 0;
14
+ nextId = 1;
15
+ constructor(maxBytes) {
16
+ this.maxBytes = maxBytes;
17
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
18
+ throw new Error(`EventRingBuffer maxBytes must be a positive integer, got ${maxBytes}`);
19
+ }
20
+ }
21
+ push(event) {
22
+ const id = this.nextId++;
23
+ const bytes = Buffer.byteLength(JSON.stringify(event), "utf8");
24
+ const entry = { id, event, bytes };
25
+ this.entries.push(entry);
26
+ this.totalBytes += bytes;
27
+ this.evict();
28
+ return entry;
29
+ }
30
+ evict() {
31
+ // Invariant: always retain at least the most-recently-pushed entry, even
32
+ // if it alone exceeds maxBytes. A single oversized event (a huge log
33
+ // chunk) is still more useful to a reconnecting client than an empty
34
+ // buffer, and the alternative (drop-and-gap) would surface as a spurious
35
+ // "earlier output truncated" banner immediately after a big write.
36
+ while (this.totalBytes > this.maxBytes && this.entries.length > 1) {
37
+ const dropped = this.entries.shift();
38
+ if (!dropped)
39
+ break;
40
+ this.totalBytes -= dropped.bytes;
41
+ }
42
+ }
43
+ /**
44
+ * Returns entries with id > afterId, in insertion order.
45
+ * If `hasGap` is true, the caller should emit a `gap` marker before replay
46
+ * because events between afterId+1 and the first returned id have been evicted.
47
+ */
48
+ replay(afterId) {
49
+ const entries = this.entries.filter((e) => e.id > afterId);
50
+ const oldestId = this.entries[0]?.id ?? this.nextId;
51
+ const hasGap = afterId + 1 < oldestId;
52
+ return { entries, hasGap };
53
+ }
54
+ get size() {
55
+ return this.entries.length;
56
+ }
57
+ get bytes() {
58
+ return this.totalBytes;
59
+ }
60
+ get latestId() {
61
+ return this.nextId - 1;
62
+ }
63
+ }
64
+ //# sourceMappingURL=ring-buffer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ring-buffer.js","sourceRoot":"","sources":["../../src/session/ring-buffer.ts"],"names":[],"mappings":"AAQA;;;;;;;;GAQG;AACH,MAAM,OAAO,eAAe;IAKG;IAJZ,OAAO,GAAoB,EAAE,CAAC;IACvC,UAAU,GAAG,CAAC,CAAC;IACf,MAAM,GAAG,CAAC,CAAC;IAEnB,YAA6B,QAAgB;QAAhB,aAAQ,GAAR,QAAQ,CAAQ;QAC3C,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,QAAQ,IAAI,CAAC,EAAE,CAAC;YACjD,MAAM,IAAI,KAAK,CAAC,4DAA4D,QAAQ,EAAE,CAAC,CAAC;QAC1F,CAAC;IACH,CAAC;IAED,IAAI,CAAC,KAAe;QAClB,MAAM,EAAE,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;QACzB,MAAM,KAAK,GAAG,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,CAAC;QAC/D,MAAM,KAAK,GAAkB,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;QAClD,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACzB,IAAI,CAAC,UAAU,IAAI,KAAK,CAAC;QACzB,IAAI,CAAC,KAAK,EAAE,CAAC;QACb,OAAO,KAAK,CAAC;IACf,CAAC;IAEO,KAAK;QACX,yEAAyE;QACzE,qEAAqE;QACrE,qEAAqE;QACrE,yEAAyE;QACzE,mEAAmE;QACnE,OAAO,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAClE,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;YACrC,IAAI,CAAC,OAAO;gBAAE,MAAM;YACpB,IAAI,CAAC,UAAU,IAAI,OAAO,CAAC,KAAK,CAAC;QACnC,CAAC;IACH,CAAC;IAED;;;;OAIG;IACH,MAAM,CAAC,OAAe;QACpB,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,OAAO,CAAC,CAAC;QAC3D,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,IAAI,IAAI,CAAC,MAAM,CAAC;QACpD,MAAM,MAAM,GAAG,OAAO,GAAG,CAAC,GAAG,QAAQ,CAAC;QACtC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;IAC7B,CAAC;IAED,IAAI,IAAI;QACN,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC;IAC7B,CAAC;IAED,IAAI,KAAK;QACP,OAAO,IAAI,CAAC,UAAU,CAAC;IACzB,CAAC;IAED,IAAI,QAAQ;QACV,OAAO,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;IACzB,CAAC;CACF"}
@@ -0,0 +1,3 @@
1
+ /** Generates a short, DNS- and Kubernetes-safe session id. */
2
+ export declare function generateSessionId(): string;
3
+ //# sourceMappingURL=session-id.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-id.d.ts","sourceRoot":"","sources":["../../src/session/session-id.ts"],"names":[],"mappings":"AAYA,8DAA8D;AAC9D,wBAAgB,iBAAiB,IAAI,MAAM,CAM1C"}
@@ -0,0 +1,19 @@
1
+ import { randomBytes } from "node:crypto";
2
+ // Lowercase RFC 4648 base32 alphabet. Every character is valid in a DNS label
3
+ // and an RFC 1123 Kubernetes resource name, so the id drops straight into both
4
+ // `<id>.<domain>` session hostnames and `telo-run-<id>` container/pod names.
5
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
6
+ // 12 chars × 5 bits ≈ 60 bits of entropy. Collisions are negligible across the
7
+ // concurrently-active session set (there is no dedup check, so the id must be
8
+ // unique on its own) while staying far shorter than the previous UUID.
9
+ const SESSION_ID_LENGTH = 12;
10
+ /** Generates a short, DNS- and Kubernetes-safe session id. */
11
+ export function generateSessionId() {
12
+ const bytes = randomBytes(SESSION_ID_LENGTH);
13
+ let id = "";
14
+ // 256 is a multiple of 32, so `byte % 32` indexes the alphabet without bias.
15
+ for (let i = 0; i < SESSION_ID_LENGTH; i++)
16
+ id += ALPHABET[bytes[i] % 32];
17
+ return id;
18
+ }
19
+ //# sourceMappingURL=session-id.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-id.js","sourceRoot":"","sources":["../../src/session/session-id.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE1C,8EAA8E;AAC9E,+EAA+E;AAC/E,6EAA6E;AAC7E,MAAM,QAAQ,GAAG,kCAAkC,CAAC;AAEpD,+EAA+E;AAC/E,8EAA8E;AAC9E,uEAAuE;AACvE,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAE7B,8DAA8D;AAC9D,MAAM,UAAU,iBAAiB;IAC/B,MAAM,KAAK,GAAG,WAAW,CAAC,iBAAiB,CAAC,CAAC;IAC7C,IAAI,EAAE,GAAG,EAAE,CAAC;IACZ,6EAA6E;IAC7E,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,iBAAiB,EAAE,CAAC,EAAE;QAAE,EAAE,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC;IAC1E,OAAO,EAAE,CAAC;AACZ,CAAC"}
@@ -0,0 +1,11 @@
1
+ import type { FastifyReply, FastifyRequest } from "fastify";
2
+ import { type SessionRegistry } from "../session/registry.js";
3
+ export interface SseStreamArgs {
4
+ registry: SessionRegistry;
5
+ req: FastifyRequest;
6
+ reply: FastifyReply;
7
+ sessionId: string;
8
+ corsOrigins: string[] | "*";
9
+ }
10
+ export declare function streamSessionEvents(args: SseStreamArgs): Promise<void>;
11
+ //# sourceMappingURL=channel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/sse/channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAG5D,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAKnF,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,eAAe,CAAC;IAC1B,GAAG,EAAE,cAAc,CAAC;IACpB,KAAK,EAAE,YAAY,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,EAAE,GAAG,GAAG,CAAC;CAC7B;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAuF5E"}