@hicaru/pi-rlm 0.1.0 → 0.1.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.
@@ -24,10 +24,10 @@ import { previewText } from "../text/preview.ts";
24
24
  import { mapPool } from "../util/concurrency.ts";
25
25
  import { LimitGuard } from "../core/limits.ts";
26
26
  import { checkResourceLimits } from "../core/resource-limits.ts";
27
- import type { RlmConfig, Sampling } from "../core/types.ts";
27
+ import type { InteractiveDeps, RlmConfig, Sampling } from "../core/types.ts";
28
28
  import { SandboxManager } from "../sandbox/sandbox-manager.ts";
29
29
  import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
30
- import type { ReplResult } from "../sandbox/protocol.ts";
30
+ import type { ProposedEdit, ReplResult } from "../sandbox/protocol.ts";
31
31
  import { RlmEmitter } from "./rlm-events.ts";
32
32
  import { SubcallStore } from "./subcall-store.ts";
33
33
  import type { ReplDetails } from "./repl-details.ts";
@@ -47,6 +47,10 @@ export const ReplToolParams = Object.freeze(Type.Object({
47
47
  code: Type.String({ description: "Python code to execute in the persistent REPL sandbox" }),
48
48
  }));
49
49
 
50
+ export function surfaceReplEdits(edits: readonly ProposedEdit[], raised: boolean): readonly ProposedEdit[] | undefined {
51
+ return edits.length > 0 && !raised ? edits : undefined;
52
+ }
53
+
50
54
  // ── Mutable bridge state (handler indirection) ──
51
55
 
52
56
  /**
@@ -60,12 +64,14 @@ class NativeBridgeState {
60
64
  currentParentId: string | undefined;
61
65
  currentDepth = 0;
62
66
  currentLimits: LimitGuard | null = null;
67
+ currentInteractive: InteractiveDeps | null = null;
63
68
 
64
- swap(inv: { emitter: RlmEmitter; parentId?: string; depth: number; limits: LimitGuard }): void {
69
+ swap(inv: { emitter: RlmEmitter; parentId?: string; depth: number; limits: LimitGuard; interactive: InteractiveDeps }): void {
65
70
  this.currentEmitter = inv.emitter;
66
71
  this.currentParentId = inv.parentId;
67
72
  this.currentDepth = inv.depth;
68
73
  this.currentLimits = inv.limits;
74
+ this.currentInteractive = inv.interactive;
69
75
  }
70
76
 
71
77
  buildLlmHandlers(deps: {
@@ -220,6 +226,8 @@ class NativeBridgeState {
220
226
  maxTokens: deps.config.maxTokens,
221
227
  maxErrors: deps.config.maxErrors,
222
228
  },
229
+ onTodo: state.currentInteractive?.onTodo,
230
+ onAskUserQuestion: state.currentInteractive?.onAskUserQuestion,
223
231
  });
224
232
 
225
233
  try {
@@ -273,6 +281,7 @@ export interface ReplToolDeps {
273
281
  readonly config: RlmConfig;
274
282
  readonly signal?: AbortSignal;
275
283
  readonly onUsage?: (usage: Usage, role: "sub") => void;
284
+ readonly ensureContext?: () => Promise<void>;
276
285
  }
277
286
 
278
287
  export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
@@ -374,6 +383,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
374
383
  parentId: undefined,
375
384
  });
376
385
 
386
+ await deps.ensureContext?.();
377
387
  await sandboxManager.getOrCreate({
378
388
  ...llmHandlers,
379
389
  ...rlmHandlers,
@@ -395,7 +405,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
395
405
  // Wire per-invocation mutable state only after the serialized exec slot
396
406
  // is active. Swapping earlier would let queued repl() calls overwrite
397
407
  // emitter/limits for the currently running REPL execution.
398
- bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits });
408
+ bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits, interactive });
399
409
  });
400
410
  const elapsed = Date.now() - start;
401
411
  capturedStdout = result.stdout;
@@ -411,6 +421,12 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
411
421
 
412
422
  if (queuedId) emitter.emitSubcallUpdated({ id: queuedId, status: "done" });
413
423
 
424
+ const baseText = result.stdout || result.answerContent || "(no output)";
425
+ const surfacedEdits = surfaceReplEdits(result.edits, result.raised);
426
+ const editsBlock = surfacedEdits
427
+ ? `\n\nSTAGED_EDITS:\n${JSON.stringify(surfacedEdits)}`
428
+ : "";
429
+
414
430
  const details: ReplDetails = {
415
431
  status: "done",
416
432
  output: result.stdout,
@@ -418,10 +434,11 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
418
434
  executionTimeMs: elapsed,
419
435
  subcalls: store.getSubcalls(),
420
436
  totals: store.getTotals(),
437
+ edits: surfacedEdits,
421
438
  };
422
439
  // Final progressive update
423
440
  onUpdate?.({ content: [{ type: "text", text: result.stdout.slice(0, 500) || "(no output)" }], details });
424
- return { content: [{ type: "text", text: result.stdout || result.answerContent || "(no output)" }], details };
441
+ return { content: [{ type: "text", text: baseText + editsBlock }], details };
425
442
  } catch (e) {
426
443
  progressStatus = "error";
427
444
  const msg = errorMessage(e);
@@ -477,6 +494,9 @@ function renderReplCollapsed(details: ReplDetails, theme: Theme): Text {
477
494
  parts.push(formatCost(details.totals.costUsd));
478
495
  if (details.totals.tokens > 0) parts.push(`${formatTokens(details.totals.tokens)} tok`);
479
496
  if (details.executionTimeMs > 0) parts.push(`${details.executionTimeMs}ms`);
497
+ if (details.edits && details.edits.length > 0) {
498
+ parts.push(theme.fg("success", `${details.edits.length} staged`));
499
+ }
480
500
  const stats = parts.length > 0 ? ` ${theme.fg("dim", parts.join(" · "))}` : "";
481
501
 
482
502
  const header = `${glyph} ${theme.fg("toolTitle", theme.bold("REPL"))}${stats}`;
@@ -511,6 +531,15 @@ function renderReplExpanded(details: ReplDetails, theme: Theme): Container {
511
531
  container.addChild(new Text(out, 0, 0));
512
532
  }
513
533
 
534
+ if (details.edits && details.edits.length > 0) {
535
+ const editFiles = new Set<string>();
536
+ for (const edit of details.edits) editFiles.add(edit.path);
537
+ container.addChild(new Spacer(1));
538
+ container.addChild(new Text(theme.fg("success",
539
+ `${details.edits.length} edit${details.edits.length > 1 ? "s" : ""} staged across ${editFiles.size} file${editFiles.size > 1 ? "s" : ""}`,
540
+ ), 0, 0));
541
+ }
542
+
514
543
  // Stderr
515
544
  if (details.stderr) {
516
545
  container.addChild(new Spacer(1));
@@ -2,8 +2,8 @@
2
2
  * RlmEmitter — typed EventEmitter wrapper for RLM lifecycle events.
3
3
  *
4
4
  * Replaces RlmToolBridge mutation methods. The engine and bridges emit typed
5
- * events instead of calling bridge.addSubcall/updateSubcall/etc. Listeners
6
- * (RlmEventAggregator, TelemetrySink) subscribe to build derived state.
5
+ * events instead of calling bridge.addSubcall/updateSubcall/etc. The
6
+ * RlmEventAggregator subscribes to build derived state.
7
7
  *
8
8
  * Node.js EventEmitter is synchronous — listeners run in registration order
9
9
  * during emit. No backpressure needed: engine events are sequential, one at
@@ -11,7 +11,6 @@
11
11
  */
12
12
 
13
13
  import { EventEmitter } from "node:events";
14
- import type { TelemetrySink } from "../telemetry/sink.ts";
15
14
  import type { SubcallKind, SubcallStatus, RlmRunStatus } from "./rlm-details.ts";
16
15
  import type { ProposedEdit } from "../sandbox/protocol.ts";
17
16
 
@@ -76,7 +75,6 @@ export interface RootPromptEvent {
76
75
  *
77
76
  * Auto-generates monotonic subcall IDs (`s1`, `s2`, …) via `emitSubcallCreated()`.
78
77
  * Provides typed `on*` methods that return unsubscribe functions.
79
- * `attachSink()` wires all events to a TelemetrySink and returns a detach function.
80
78
  */
81
79
  export class RlmEmitter {
82
80
  private readonly ee = new EventEmitter();
@@ -169,43 +167,6 @@ export class RlmEmitter {
169
167
  return () => { this.ee.off("root-prompt", handler); };
170
168
  }
171
169
 
172
- // ── Sink integration ──
173
-
174
- /**
175
- * Wire all lifecycle events to a TelemetrySink.
176
- * Returns a detach function that unsubscribes all sink listeners.
177
- * The caller is responsible for calling `sink.shutdown()` after detaching.
178
- */
179
- attachSink(sink: TelemetrySink): () => void {
180
- const unsubs: (() => void)[] = [];
181
-
182
- unsubs.push(this.onSubcallCreated((event) => {
183
- sink.start(event.id, {
184
- kind: event.kind,
185
- depth: event.depth,
186
- parentId: event.parentId,
187
- model: event.model,
188
- label: event.label,
189
- detail: event.detail,
190
- args: event.args,
191
- });
192
- }));
193
-
194
- unsubs.push(this.onSubcallUpdated((event) => {
195
- if (event.costUsd !== undefined || event.tokens !== undefined) {
196
- sink.usage(event.id, event.costUsd ?? 0, event.tokens ?? 0);
197
- }
198
- if (event.status !== undefined && event.status !== "running") {
199
- sink.end(event.id, {
200
- error: event.status === "error" ? (event.detail ?? "error") : undefined,
201
- resultPreview: event.resultPreview,
202
- });
203
- }
204
- }));
205
-
206
- return () => { unsubs.forEach((fn) => fn()); };
207
- }
208
-
209
170
  // ── Lifecycle ──
210
171
 
211
172
  /** Remove all listeners. Call after the run completes to prevent leaks. */
@@ -11,7 +11,6 @@ import { Container, Markdown, Spacer, Text, type Component } from "@earendil-wor
11
11
  import { Type } from "typebox";
12
12
  import { createPiInteractiveDeps } from "../bridge/pi-interactive.ts";
13
13
  import type { RlmController, StartInput } from "../mode/rlm-mode.ts";
14
- import { createTelemetrySink } from "../telemetry/index.ts";
15
14
  import { formatCost, formatTokens, spinnerFrame } from "../ui/theme.ts";
16
15
  import { errorMessage } from "../util/errors.ts";
17
16
  import { type RlmDetails } from "./rlm-details.ts";
@@ -23,8 +22,6 @@ import {
23
22
  renderExpandedSubcallTree,
24
23
  } from "./subcall-render.ts";
25
24
  import { createProgressNotifier, validateToolParams } from "./tool-utils.ts";
26
- import { applyEdits } from "../patch/index.ts";
27
- import { tryExtractDiff } from "../core/answer.ts";
28
25
 
29
26
  // ── Parameter schema ──
30
27
 
@@ -63,11 +60,8 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
63
60
  if (!validation.ok) return validation.error;
64
61
  const params = validation.value;
65
62
 
66
- const sink = await createTelemetrySink(controller.config.telemetry);
67
63
  const emitter = new RlmEmitter();
68
64
  const aggregator = new RlmEventAggregator(emitter, onUpdate ?? (() => {}));
69
- let detachSink: (() => void) | undefined;
70
- if (sink) detachSink = emitter.attachSink(sink);
71
65
  emitter.emitRootPrompt(params.prompt);
72
66
 
73
67
  // Wire abort signal to controller
@@ -98,10 +92,6 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
98
92
  const result = await done;
99
93
 
100
94
  emitter.emitAnswer(result.answer);
101
- const proposedEdits = result.edits ?? [];
102
- const proposedDiffs = result.diffs?.length ? result.diffs : tryExtractDiff(result.answer);
103
- if (proposedEdits.length > 0) emitter.emitEdits(proposedEdits);
104
- await applyEdits(proposedEdits, proposedDiffs, ctx);
105
95
 
106
96
  return {
107
97
  content: [{ type: "text", text: result.answer }],
@@ -116,11 +106,8 @@ export function createRlmTool(controller: RlmController): ToolDefinition<typeof
116
106
  };
117
107
  } finally {
118
108
  progress.stop();
119
- detachSink?.();
120
109
  aggregator.dispose();
121
110
  emitter.shutdown();
122
- try { await sink?.shutdown(); }
123
- catch (err) { console.warn(`[rlm] telemetry shutdown failed: ${errorMessage(err)}`); }
124
111
  }
125
112
  },
126
113
 
@@ -14,12 +14,19 @@ export interface ModelSelection {
14
14
  const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
15
15
  type SelectableThinkingLevel = (typeof LEVELS)[number];
16
16
 
17
- function items(models: Model<Api>[]): SelectItem[] {
18
- return models.map((m) => ({
17
+ const CHEAPEST_VALUE = "__rlm_cheapest__";
18
+
19
+ function items(models: Model<Api>[], includeCheapest = false): SelectItem[] {
20
+ const modelItems = models.map((m) => ({
19
21
  value: `${m.provider}/${m.id}`,
20
22
  label: `${m.provider}/${m.id}`,
21
23
  description: `in ${formatCost(m.cost.input)}/Mtok · out ${formatCost(m.cost.output)}/Mtok${m.reasoning ? " · reasoning" : ""}`,
22
24
  }));
25
+ if (!includeCheapest) return modelItems;
26
+ return [
27
+ { value: CHEAPEST_VALUE, label: "⟳ cheapest (auto)", description: "Always use the cheapest available model" },
28
+ ...modelItems,
29
+ ];
23
30
  }
24
31
 
25
32
  function supportedThinkingLevels(model: Model<Api>): SelectableThinkingLevel[] {
@@ -75,7 +82,7 @@ export async function selectModel(
75
82
  models: Model<Api>[],
76
83
  current?: Model<Api>,
77
84
  currentThinking?: ThinkingLevel,
78
- ): Promise<ModelSelection | undefined> {
85
+ ): Promise<ModelSelection | null | undefined> {
79
86
  if (models.length === 0) {
80
87
  ctx.ui.notify("RLM: no models with configured auth", "warning");
81
88
  return undefined;
@@ -96,7 +103,7 @@ export async function selectModel(
96
103
  render: (w) => [truncateToWidth(theme.fg("dim", `Filter: ${query || "type to filter…"}`), w)],
97
104
  invalidate: () => {},
98
105
  };
99
- const list = new SelectList(items(models), Math.min(models.length, 12), {
106
+ const list = new SelectList(items(models, true), Math.min(models.length + 1, 13), {
100
107
  selectedPrefix: (t) => theme.fg("accent", t),
101
108
  selectedText: (t) => theme.fg("accent", t),
102
109
  description: (t) => theme.fg("muted", t),
@@ -133,6 +140,7 @@ export async function selectModel(
133
140
  };
134
141
  });
135
142
 
143
+ if (chosen === CHEAPEST_VALUE) return null;
136
144
  const model = chosen ? models.find((m) => `${m.provider}/${m.id}` === chosen) : undefined;
137
145
  if (!model) return undefined;
138
146
  return { model, thinkingLevel: await selectThinkingLevel(ctx, model, currentThinking) };
@@ -1,148 +0,0 @@
1
- /**
2
- * Applies RLM-proposed edits to disk.
3
- *
4
- * Two edit kinds from the sandbox protocol:
5
- * ProposedEdit — oldText / newText anchor replacement
6
- * ProposedDiffEdit — unified diff string (applied via `diff.applyPatch`)
7
- *
8
- * Returns a Result. Caller decides whether to show errors in UI.
9
- */
10
-
11
- import { readFile, writeFile } from "node:fs/promises";
12
- import { resolve } from "node:path";
13
- import * as Diff from "diff";
14
- import type { ProposedDiffEdit, ProposedEdit } from "../sandbox/protocol.ts";
15
- import { err, ok, type Result } from "../util/errors.ts";
16
-
17
- // ── Shared private helpers ─────────────────────────────────────────────────
18
-
19
- /** Normalise to LF so string-replace is CRLF-safe. */
20
- function toLF(s: string): string {
21
- return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
22
- }
23
-
24
- /** Restore original line endings after replacement. */
25
- function restoreEndings(s: string, crlf: boolean): string {
26
- return crlf ? s.replace(/\n/g, "\r\n") : s;
27
- }
28
-
29
- function hasCRLF(s: string): boolean {
30
- return s.includes("\r\n");
31
- }
32
-
33
- // ── ApplyResult ─────────────────────────────────────────────────────────────
34
-
35
- export interface ApplySuccess {
36
- readonly applied: number;
37
- }
38
-
39
- export interface ApplyFailure {
40
- readonly failures: ReadonlyArray<{ readonly path: string; readonly reason: string }>;
41
- }
42
-
43
- export type ApplyResult = Result<ApplySuccess, ApplyFailure>;
44
-
45
- // ── Generic accumulator (shared by anchor + diff apply) ─────────────────────
46
-
47
- /**
48
- * Runs `applyOne` for every item, tallying successes and collecting failures.
49
- * The only thing that differs between anchor and diff apply is the per-item
50
- * helper and the failure-path key — both passed in, so the loop is written once.
51
- */
52
- async function applyAll<T>(
53
- items: readonly T[],
54
- applyOne: (item: T, cwd: string) => Promise<Result<void, string>>,
55
- getKey: (item: T) => string,
56
- cwd: string,
57
- ): Promise<ApplyResult> {
58
- const failures: Array<{ readonly path: string; readonly reason: string }> = [];
59
- let applied = 0;
60
- for (const item of items) {
61
- const r = await applyOne(item, cwd);
62
- if (r.ok) {
63
- applied++;
64
- } else {
65
- failures.push({ path: getKey(item), reason: r.error });
66
- }
67
- }
68
- return failures.length === 0 ? ok({ applied }) : err({ failures });
69
- }
70
-
71
- // ── ProposedEdit (oldText / newText) ────────────────────────────────────────
72
-
73
- async function applySingleAnchor(
74
- edit: ProposedEdit,
75
- cwd: string,
76
- ): Promise<Result<void, string>> {
77
- const abs = resolve(cwd, edit.path);
78
- let raw: string;
79
- try {
80
- raw = await readFile(abs, "utf8");
81
- } catch (e) {
82
- return err(`read error: ${e instanceof Error ? e.message : String(e)}`);
83
- }
84
- const crlf = hasCRLF(raw);
85
- const content = toLF(raw);
86
- const needle = toLF(edit.oldText);
87
- if (!content.includes(needle)) {
88
- return err(`oldText not found in ${edit.path}`);
89
- }
90
- const replaced = content.replace(needle, toLF(edit.newText));
91
- try {
92
- await writeFile(abs, restoreEndings(replaced, crlf), "utf8");
93
- return ok(undefined);
94
- } catch (e) {
95
- return err(`write error: ${e instanceof Error ? e.message : String(e)}`);
96
- }
97
- }
98
-
99
- export function applyAnchorEdits(
100
- edits: readonly ProposedEdit[],
101
- cwd: string,
102
- ): Promise<ApplyResult> {
103
- return applyAll(edits, applySingleAnchor, (e) => e.path, cwd);
104
- }
105
-
106
- // ── ProposedDiffEdit (unified diff string) ──────────────────────────────────
107
-
108
- async function applySingleDiff(
109
- diffEdit: ProposedDiffEdit,
110
- cwd: string,
111
- ): Promise<Result<void, string>> {
112
- // Extract file path from diff header: "--- a/path" or "--- path"
113
- const match = /^--- (?:a\/)?(.+)$/m.exec(diffEdit.diff);
114
- const relPath = match?.[1]?.trim();
115
- if (relPath === undefined) {
116
- return err("diff has no '---' header; cannot determine target file");
117
- }
118
- const abs = resolve(cwd, relPath);
119
- let raw: string;
120
- try {
121
- raw = await readFile(abs, "utf8");
122
- } catch (e) {
123
- return err(`read error: ${e instanceof Error ? e.message : String(e)}`);
124
- }
125
- const crlf = hasCRLF(raw);
126
- let patched: string | false;
127
- try {
128
- patched = Diff.applyPatch(toLF(raw), diffEdit.diff);
129
- } catch (e) {
130
- return err(`invalid diff — ${e instanceof Error ? e.message : String(e)}`);
131
- }
132
- if (patched === false) {
133
- return err(`patch does not apply cleanly to ${relPath}`);
134
- }
135
- try {
136
- await writeFile(abs, restoreEndings(patched, crlf), "utf8");
137
- return ok(undefined);
138
- } catch (e) {
139
- return err(`write error: ${e instanceof Error ? e.message : String(e)}`);
140
- }
141
- }
142
-
143
- export function applyDiffEdits(
144
- diffs: readonly ProposedDiffEdit[],
145
- cwd: string,
146
- ): Promise<ApplyResult> {
147
- return applyAll(diffs, applySingleDiff, (d) => d.diff.slice(0, 40), cwd);
148
- }
@@ -1,37 +0,0 @@
1
- /**
2
- * applyEdits — THE single call site for applying proposed edits/diffs.
3
- *
4
- * Both rlm-tool.ts and rlm.ts must call this and nothing else.
5
- * No duplication allowed.
6
- */
7
-
8
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
- import type { ProposedDiffEdit, ProposedEdit } from "../sandbox/protocol.ts";
10
- import { applyAnchorEdits, applyDiffEdits, type ApplyResult } from "./apply.ts";
11
-
12
- /** Surface an apply outcome through a single notify — used for both edit kinds. */
13
- function notifyApplyResult(r: ApplyResult, label: string, ctx: ExtensionContext): void {
14
- if (!r.ok) {
15
- const lines = r.error.failures.map((f) => `• ${f.path}: ${f.reason}`);
16
- ctx.ui.notify(`Some ${label}s failed:\n${lines.join("\n")}`, "error");
17
- } else {
18
- ctx.ui.notify(`Applied ${r.value.applied} ${label}${r.value.applied !== 1 ? "s" : ""}.`, "info");
19
- }
20
- }
21
-
22
- export async function applyEdits(
23
- edits: readonly ProposedEdit[],
24
- diffs: readonly ProposedDiffEdit[],
25
- ctx: ExtensionContext,
26
- ): Promise<void> {
27
- const hasEdits = edits.length > 0 || diffs.length > 0;
28
- if (!hasEdits) return;
29
- const cwd = ctx.cwd ?? process.cwd();
30
-
31
- if (edits.length > 0) {
32
- notifyApplyResult(await applyAnchorEdits(edits, cwd), "edit", ctx);
33
- }
34
- if (diffs.length > 0) {
35
- notifyApplyResult(await applyDiffEdits(diffs, cwd), "diff", ctx);
36
- }
37
- }
@@ -1,22 +0,0 @@
1
- /**
2
- * SubcallStart — parameter object carried forward for telemetry compatibility.
3
- *
4
- * The SubcallObserver interface, treeObserver(), observerWith(), and NOOP_OBSERVER
5
- * have been removed. The engine and bridges now call RlmToolBridge directly.
6
- */
7
-
8
- import type { SubcallKind } from "../tool/rlm-details.ts";
9
-
10
- export interface SubcallStart {
11
- readonly kind: SubcallKind;
12
- readonly depth: number;
13
- readonly parentId?: string;
14
- readonly model?: string;
15
- readonly label: string;
16
- readonly detail?: string;
17
- readonly args?: string;
18
- /** Run ID for the root node — lets MLflow correlate a resumed trace with the original. */
19
- readonly runId?: string;
20
- /** True when this is a resumed root node (not a fresh start). */
21
- readonly resume?: boolean;
22
- }
@@ -1,116 +0,0 @@
1
- export interface DispatcherSink<E> {
2
- readonly name: string;
3
- handle(event: E): Promise<void>;
4
- flush(): Promise<void>;
5
- shutdown(): Promise<void>;
6
- }
7
-
8
- export interface DispatcherOptions {
9
- readonly maxQueueSize: number;
10
- }
11
-
12
- /** Bounded FIFO async dispatcher with drop-oldest backpressure. */
13
- export class Dispatcher<E> {
14
- private readonly sinks: DispatcherSink<E>[] = [];
15
- private queue: E[] = [];
16
- private flushing = false;
17
- private inFlight: Promise<void> = Promise.resolve();
18
- private shuttingDown = false;
19
- private backpressureActive = false;
20
- private readonly failed = new Set<string>();
21
-
22
- constructor(private readonly options: DispatcherOptions) {}
23
-
24
- registerSink(sink: DispatcherSink<E>): () => void {
25
- this.sinks.push(sink);
26
- return () => {
27
- const idx = this.sinks.indexOf(sink);
28
- if (idx >= 0) this.sinks.splice(idx, 1);
29
- };
30
- }
31
-
32
- dispatch(event: E): void {
33
- if (this.shuttingDown || this.sinks.length === 0) return;
34
- const cap = this.options.maxQueueSize;
35
- if (this.queue.length >= cap) {
36
- this.queue.shift();
37
- if (!this.backpressureActive) {
38
- this.backpressureActive = true;
39
- console.warn(`[rlm-telemetry] backpressure: queue saturated at ${cap}; dropping oldest events`);
40
- }
41
- } else if (this.backpressureActive && this.queue.length < cap - 1) {
42
- this.backpressureActive = false;
43
- console.warn("[rlm-telemetry] backpressure recovered: queue back under capacity");
44
- }
45
- this.queue.push(event);
46
- this.scheduleFlush();
47
- }
48
-
49
- async shutdown(): Promise<void> {
50
- this.shuttingDown = true;
51
- const remaining = this.queue;
52
- this.queue = [];
53
- this.flushing = false;
54
-
55
- await this.inFlight;
56
-
57
- for (const event of remaining) await this.broadcast(event);
58
- const sinks = [...this.sinks];
59
- await Promise.allSettled(sinks.map((sink) => sink.flush()));
60
- await Promise.allSettled(sinks.map((sink) => sink.shutdown()));
61
- }
62
-
63
- reset(): void {
64
- this.sinks.length = 0;
65
- this.queue = [];
66
- this.flushing = false;
67
- this.inFlight = Promise.resolve();
68
- this.shuttingDown = false;
69
- this.backpressureActive = false;
70
- this.failed.clear();
71
- }
72
-
73
- private scheduleFlush(): void {
74
- if (this.flushing) return;
75
- this.flushing = true;
76
- this.drain();
77
- }
78
-
79
- private drain(): void {
80
- if (this.queue.length === 0) {
81
- this.flushing = false;
82
- return;
83
- }
84
- const batch = this.queue;
85
- this.queue = [];
86
-
87
- this.inFlight = this.inFlight.then(async () => {
88
- for (const event of batch) await this.broadcast(event);
89
- if (this.queue.length > 0) {
90
- const handle = setImmediate(() => this.drain());
91
- handle.unref?.();
92
- } else {
93
- this.flushing = false;
94
- }
95
- });
96
- }
97
-
98
- private async broadcast(event: E): Promise<void> {
99
- const sinks = [...this.sinks];
100
- const results = await Promise.allSettled(sinks.map((sink) => sink.handle(event)));
101
- results.forEach((result, idx) => {
102
- const name = sinks[idx]?.name;
103
- if (!name) return;
104
- if (result.status === "rejected") {
105
- if (!this.failed.has(name)) {
106
- this.failed.add(name);
107
- const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
108
- console.warn(`[rlm-telemetry] sink ${name} rejected event: ${reason}`);
109
- }
110
- } else if (this.failed.has(name)) {
111
- this.failed.delete(name);
112
- console.warn(`[rlm-telemetry] sink ${name} recovered`);
113
- }
114
- });
115
- }
116
- }
@@ -1,14 +0,0 @@
1
- import type { TelemetryConfig } from "../core/types.ts";
2
- import { resolveMlflowConfig } from "./mlflow-config.ts";
3
- import type { TelemetrySink } from "./sink.ts";
4
-
5
- export type { TelemetrySink };
6
-
7
- export async function createTelemetrySink(config: TelemetryConfig | undefined): Promise<TelemetrySink | undefined> {
8
- if (!config) return undefined;
9
- const resolved = resolveMlflowConfig({ trackingUri: config.trackingUri, experimentId: config.experimentId });
10
- const enabled = config.enabled ?? Boolean(resolved.trackingUri);
11
- if (!enabled || !resolved.trackingUri) return undefined;
12
- const { MlflowSink } = await import("./mlflow-sink.ts");
13
- return new MlflowSink(resolved, config.maxQueueSize);
14
- }
@@ -1,15 +0,0 @@
1
- export interface MlflowConfig {
2
- readonly trackingUri?: string;
3
- readonly experimentId?: string;
4
- readonly trackingToken?: string;
5
- }
6
-
7
- const readEnv = (key: string): string | undefined => process.env[key]?.trim() || undefined;
8
-
9
- export function resolveMlflowConfig(config: Pick<MlflowConfig, "trackingUri" | "experimentId">): MlflowConfig {
10
- return {
11
- trackingUri: readEnv("MLFLOW_TRACKING_URI") || config.trackingUri,
12
- experimentId: readEnv("MLFLOW_EXPERIMENT_ID") || config.experimentId,
13
- trackingToken: readEnv("MLFLOW_TRACKING_TOKEN"),
14
- };
15
- }