pi-roundtable-sandbox 0.7.8 → 0.7.10

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.
@@ -14,6 +14,14 @@ export interface PiContainerSpec {
14
14
  /** The host user that owns the mounted directories. */
15
15
  uid: number;
16
16
  gid: number;
17
+ /** Docker's log driver for the container; journald tagged `sandbox/<channel>` by default. */
18
+ log?: PiContainerLog;
19
+ }
20
+
21
+ /** A Docker log driver and its options, as `docker run --log-driver/--log-opt` takes them. */
22
+ export interface PiContainerLog {
23
+ driver: string;
24
+ options?: Readonly<Record<string, string>>;
17
25
  }
18
26
 
19
27
  export type PiContainerState = "missing" | "running" | "stopped";
@@ -30,6 +38,16 @@ export interface PiContainerDriver {
30
38
  ensureRunning(spec: PiContainerSpec): Promise<void>;
31
39
  remove(name: string): Promise<void>;
32
40
  status(name: string): Promise<PiContainerStatus>;
41
+ /** The container's last log lines, read before it is removed; empty when it is gone. */
42
+ logs?(name: string, lines: number): Promise<string>;
43
+ }
44
+
45
+ /** journald keeps a removed container's lines; the tag names the channel they came from. */
46
+ export function defaultContainerLog(channel: string): PiContainerLog {
47
+ return {
48
+ driver: "journald",
49
+ options: { tag: `sandbox/${channel.replace(/[^\w.:/-]/g, "_")}` },
50
+ };
33
51
  }
34
52
 
35
53
  export const PI_LABEL_CHANNEL = "roundtable.sandbox.channel";
@@ -59,6 +77,17 @@ export function piContainerCreateBody(spec: PiContainerSpec): object {
59
77
  if (!dir.startsWith("/") || dir === "/" || /[:,\r\n]/.test(dir))
60
78
  throw new Error("Invalid mount path");
61
79
  new Intl.DateTimeFormat("en-US", { timeZone: spec.timeZone ?? "UTC" });
80
+ const log = spec.log ?? defaultContainerLog(spec.channel);
81
+ if (
82
+ !/^[a-z0-9][a-z0-9_.-]{0,63}$/.test(log.driver) ||
83
+ Object.entries(log.options ?? {}).some(
84
+ ([key, value]) =>
85
+ !/^[a-z0-9][a-z0-9_.-]{0,63}$/.test(key) ||
86
+ typeof value !== "string" ||
87
+ /[\r\n]/.test(value),
88
+ )
89
+ )
90
+ throw new Error("Invalid container log driver");
62
91
  return {
63
92
  Image: spec.image,
64
93
  User: `${spec.uid}:${spec.gid}`,
@@ -96,13 +125,34 @@ export function piContainerCreateBody(spec: PiContainerSpec): object {
96
125
  ],
97
126
  RestartPolicy: { Name: "unless-stopped" },
98
127
  LogConfig: {
99
- Type: "local",
100
- Config: { "max-size": "10m", "max-file": "2" },
128
+ Type: log.driver,
129
+ Config: { ...log.options },
101
130
  },
102
131
  },
103
132
  };
104
133
  }
105
134
 
135
+ /**
136
+ * A container without a TTY sends its log as frames: one byte naming the stream, three zero bytes,
137
+ * a big-endian length, then that many bytes. Text that is not framed is returned as it came.
138
+ */
139
+ export function demuxDockerLog(data: Uint8Array): string {
140
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
141
+ const parts: Uint8Array[] = [];
142
+ let at = 0;
143
+ while (at + 8 <= data.length) {
144
+ const stream = data[at] ?? 255;
145
+ if (stream > 2 || data[at + 1] || data[at + 2] || data[at + 3])
146
+ return new TextDecoder().decode(data);
147
+ const size = view.getUint32(at + 4);
148
+ parts.push(data.subarray(at + 8, at + 8 + size));
149
+ at += 8 + size;
150
+ }
151
+ if (at !== data.length && parts.length === 0)
152
+ return new TextDecoder().decode(data);
153
+ return parts.map((part) => new TextDecoder().decode(part)).join("");
154
+ }
155
+
106
156
  interface InspectBody {
107
157
  Config?: { Image?: string; Labels?: Record<string, string> };
108
158
  State?: { Running?: boolean };
@@ -112,11 +162,19 @@ interface InspectBody {
112
162
  export class PiDockerContainerDriver implements PiContainerDriver {
113
163
  readonly #socket: string;
114
164
 
165
+ readonly #log: ((channel: string) => PiContainerLog) | undefined;
166
+
115
167
  constructor(
116
168
  socket = "/var/run/docker.sock",
117
169
  readonly labelProfile = PI_LABEL_PROFILE,
170
+ /** The log driver every container gets; journald tagged `sandbox/<channel>` by default. */
171
+ options: {
172
+ log?: PiContainerLog | ((channel: string) => PiContainerLog);
173
+ } = {},
118
174
  ) {
119
175
  this.#socket = socket;
176
+ const { log } = options;
177
+ this.#log = typeof log === "function" ? log : log && (() => log);
120
178
  }
121
179
 
122
180
  async ensureRunning(spec: PiContainerSpec): Promise<void> {
@@ -132,7 +190,9 @@ export class PiDockerContainerDriver implements PiContainerDriver {
132
190
  await this.#call(
133
191
  "POST",
134
192
  `/containers/create?name=${encodeURIComponent(spec.name)}`,
135
- piContainerCreateBody(spec),
193
+ piContainerCreateBody(
194
+ this.#log ? { ...spec, log: this.#log(spec.channel) } : spec,
195
+ ),
136
196
  );
137
197
  await this.#call(
138
198
  "POST",
@@ -149,6 +209,17 @@ export class PiDockerContainerDriver implements PiContainerDriver {
149
209
  );
150
210
  }
151
211
 
212
+ async logs(name: string, lines: number): Promise<string> {
213
+ const response = await this.#call(
214
+ "GET",
215
+ `/containers/${encodeURIComponent(name)}/logs?stdout=1&stderr=1&tail=${Math.max(1, Math.floor(lines))}`,
216
+ undefined,
217
+ [404],
218
+ );
219
+ if (response.status === 404) return "";
220
+ return demuxDockerLog(new Uint8Array(await response.arrayBuffer()));
221
+ }
222
+
152
223
  async status(name: string): Promise<PiContainerStatus> {
153
224
  const response = await this.#call(
154
225
  "GET",
@@ -78,6 +78,124 @@ export interface PiTurnContext {
78
78
  export interface PiMcpDiscovery {
79
79
  servers: { name: string; tools: string[] }[];
80
80
  }
81
+ /** What the worker learns from the host at startup; `compaction` is absent when the host has no compactor. */
82
+ export interface PiWorkerConfig {
83
+ compaction?: { engine: string };
84
+ }
85
+ /** The JSON of one Pi `AgentMessage` from the worker's session. */
86
+ export type PiCompactMessage = { role: string } & Record<string, unknown>;
87
+ /** The worker's compaction preparation, sent to the host's compactor. */
88
+ export interface PiCompactRequest {
89
+ /** What triggered the compaction: a manual compact, the context threshold, or an overflow. */
90
+ reason: "manual" | "threshold" | "overflow";
91
+ tokensBefore: number;
92
+ /** The first session entry the compaction keeps; a compaction must keep the same one. */
93
+ firstKeptEntryId: string;
94
+ /** Whether the cut falls inside a turn, so `turnPrefixMessages` holds that turn's start. */
95
+ isSplitTurn: boolean;
96
+ messagesToSummarize: PiCompactMessage[];
97
+ turnPrefixMessages: PiCompactMessage[];
98
+ /** The messages from `firstKeptEntryId` on, which stay in the context. */
99
+ keptMessages: PiCompactMessage[];
100
+ previousSummary?: string;
101
+ customInstructions?: string;
102
+ /** Files only read, and files written or edited, in the summarized messages. */
103
+ readFiles: string[];
104
+ modifiedFiles: string[];
105
+ }
106
+ /** A compaction the host's compactor wrote, shaped as Pi's `CompactionResult`. */
107
+ export interface PiCompaction {
108
+ summary: string;
109
+ firstKeptEntryId: string;
110
+ tokensBefore: number;
111
+ estimatedTokensAfter?: number;
112
+ details?: unknown;
113
+ }
114
+ /** The host's answer: a compaction, or a fallback to Pi's own summary with its reason. */
115
+ export type PiCompactResponse =
116
+ | { ok: true; compaction: PiCompaction }
117
+ | { ok: false; fallback: string };
118
+ /** What the worker tells the host about its compactions, so the host logs them per channel. */
119
+ export type PiCompactionReport =
120
+ | {
121
+ type: "bypass";
122
+ /** Why the hard ceiling skipped the host's compactor for Pi's summary. */
123
+ reason: string;
124
+ tokensBefore: number;
125
+ }
126
+ | {
127
+ type: "end";
128
+ reason: PiCompactRequest["reason"];
129
+ aborted: boolean;
130
+ willRetry: boolean;
131
+ /** Absent when the compaction failed. */
132
+ engine?: "extension" | "pi";
133
+ tokensBefore?: number;
134
+ tokensAfter?: number;
135
+ nextCompactionAt?: number;
136
+ error?: string;
137
+ };
138
+ const COMPACT_REASONS = ["manual", "threshold", "overflow"];
139
+ function isMessageList(value: unknown): value is PiCompactMessage[] {
140
+ return (
141
+ Array.isArray(value) &&
142
+ value.every((item) => isRecord(item) && typeof item.role === "string")
143
+ );
144
+ }
145
+ function isStringList(value: unknown): value is string[] {
146
+ return (
147
+ Array.isArray(value) && value.every((item) => typeof item === "string")
148
+ );
149
+ }
150
+ function isTokenCount(value: unknown): value is number {
151
+ return Number.isSafeInteger(value) && (value as number) >= 0;
152
+ }
153
+ export function validateCompactRequest(
154
+ value: unknown,
155
+ ): asserts value is PiCompactRequest {
156
+ if (
157
+ !isRecord(value) ||
158
+ !COMPACT_REASONS.includes(value.reason as string) ||
159
+ !isTokenCount(value.tokensBefore) ||
160
+ typeof value.firstKeptEntryId !== "string" ||
161
+ value.firstKeptEntryId.length === 0 ||
162
+ value.firstKeptEntryId.length > 256 ||
163
+ typeof value.isSplitTurn !== "boolean" ||
164
+ !isMessageList(value.messagesToSummarize) ||
165
+ !isMessageList(value.turnPrefixMessages) ||
166
+ !isMessageList(value.keptMessages) ||
167
+ (value.previousSummary !== undefined &&
168
+ typeof value.previousSummary !== "string") ||
169
+ (value.customInstructions !== undefined &&
170
+ typeof value.customInstructions !== "string") ||
171
+ !isStringList(value.readFiles) ||
172
+ !isStringList(value.modifiedFiles)
173
+ )
174
+ throw new Error("Invalid compact request");
175
+ }
176
+ export function validateCompactionReport(
177
+ value: unknown,
178
+ ): asserts value is PiCompactionReport {
179
+ const valid =
180
+ isRecord(value) &&
181
+ (value.type === "bypass"
182
+ ? typeof value.reason === "string" &&
183
+ value.reason.length <= 1000 &&
184
+ isTokenCount(value.tokensBefore)
185
+ : value.type === "end" &&
186
+ COMPACT_REASONS.includes(value.reason as string) &&
187
+ typeof value.aborted === "boolean" &&
188
+ typeof value.willRetry === "boolean" &&
189
+ (value.engine === undefined ||
190
+ value.engine === "extension" ||
191
+ value.engine === "pi") &&
192
+ [value.tokensBefore, value.tokensAfter, value.nextCompactionAt].every(
193
+ (count) => count === undefined || isTokenCount(count),
194
+ ) &&
195
+ (value.error === undefined ||
196
+ (typeof value.error === "string" && value.error.length <= 10_000)));
197
+ if (!valid) throw new Error("Invalid compaction report");
198
+ }
81
199
  export function isPiThinkingLevel(value: unknown): value is PiThinkingLevel {
82
200
  return (
83
201
  typeof value === "string" &&
package/src/pi-runtime.ts CHANGED
@@ -10,14 +10,18 @@ import {
10
10
  renameSync,
11
11
  rmSync,
12
12
  statSync,
13
- unlinkSync,
14
13
  writeSync,
15
14
  } from "node:fs";
16
15
  import { isAbsolute, join } from "node:path";
17
16
  import { AgentRunError, type ChannelKey, type Logger } from "pi-roundtable";
18
- import { channelSegment } from "pi-roundtable/kit";
17
+ import { channelSegment, scrubDiagnostic } from "pi-roundtable/kit";
19
18
  import { ownDirectory } from "./directory-file.ts";
20
- import { type PiBrokerOptions, PiSandboxBroker } from "./pi-broker.ts";
19
+ import {
20
+ errorText,
21
+ PI_COMPACT_LIMITS,
22
+ type PiBrokerOptions,
23
+ PiSandboxBroker,
24
+ } from "./pi-broker.ts";
21
25
  import {
22
26
  type PiContainerDriver,
23
27
  type PiContainerStatus,
@@ -130,17 +134,31 @@ export class PiSandboxRuntime {
130
134
  )
131
135
  )
132
136
  throw new Error("Invalid container prefix");
133
- for (const timeout of [
134
- options.turnTimeoutMs ?? 600_000,
135
- options.startTimeoutMs ?? 90_000,
136
- ])
137
+ const turnTimeoutMs = options.turnTimeoutMs ?? 600_000;
138
+ for (const timeout of [turnTimeoutMs, options.startTimeoutMs ?? 90_000])
137
139
  if (!Number.isSafeInteger(timeout) || timeout < 1000 || timeout > 600_000)
138
140
  throw new Error("Invalid runtime deadline");
141
+ // A compactor that runs out of time still leaves Pi's summary half the turn.
142
+ const compaction = options.compaction && {
143
+ ...options.compaction,
144
+ timeoutMs:
145
+ options.compaction.timeoutMs ??
146
+ Math.max(
147
+ 1000,
148
+ Math.min(PI_COMPACT_LIMITS.timeoutMs, Math.floor(turnTimeoutMs / 3)),
149
+ ),
150
+ };
151
+ if (compaction && compaction.timeoutMs > turnTimeoutMs / 2)
152
+ throw new Error("The compactor's timeout must leave half the turn");
139
153
  mkdirSync(options.partyDir, { recursive: true, mode: 0o700 });
140
154
  const info = statSync(options.partyDir);
141
155
  if (info.uid !== process.getuid?.() || (info.mode & 0o077) !== 0)
142
156
  throw new Error("State directory must be private and host-owned");
143
- this.#options = { ...options, partyDir: realpathSync(options.partyDir) };
157
+ this.#options = {
158
+ ...options,
159
+ partyDir: realpathSync(options.partyDir),
160
+ ...(compaction ? { compaction } : {}),
161
+ };
144
162
  this.#driver =
145
163
  options.driver ??
146
164
  new PiDockerContainerDriver(undefined, options.labelProfile);
@@ -312,6 +330,22 @@ export class PiSandboxRuntime {
312
330
  await Promise.all([...this.#brokers.values()].map((entry) => entry.stop()));
313
331
  this.#brokers.clear();
314
332
  }
333
+ /** The worker's last log lines, read before its container is removed, so they are not lost with it. */
334
+ async #logWorker(channel: ChannelKey, turnId: string): Promise<void> {
335
+ if (!this.#driver.logs) return;
336
+ try {
337
+ const lines = await this.#driver.logs(this.#name(channel), 200);
338
+ this.#options.logger.warn(
339
+ { channel, turnId, lines: scrubDiagnostic(lines, 200_000) },
340
+ "sandbox worker log before removal",
341
+ );
342
+ } catch (error) {
343
+ this.#options.logger.warn(
344
+ { channel, turnId, error: scrubDiagnostic(errorText(error)) },
345
+ "sandbox worker log unavailable",
346
+ );
347
+ }
348
+ }
315
349
  async runTurn(turn: PiSandboxTurn): Promise<PiSandboxTurnResult> {
316
350
  if (this.#active.has(turn.channel))
317
351
  return { ok: false, error: new AgentRunError("Channel is busy") };
@@ -323,6 +357,10 @@ export class PiSandboxRuntime {
323
357
  AbortSignal.timeout(this.#options.turnTimeoutMs ?? 600_000),
324
358
  ]);
325
359
  signal.addEventListener("abort", () => controller.abort(), { once: true });
360
+ const timedOut = () =>
361
+ signal.aborted &&
362
+ signal.reason instanceof DOMException &&
363
+ signal.reason.name === "TimeoutError";
326
364
  let release: (() => void) | undefined;
327
365
  try {
328
366
  signal.throwIfAborted();
@@ -373,20 +411,38 @@ export class PiSandboxRuntime {
373
411
  });
374
412
  const body = await entry.broker.execute(request, signal);
375
413
  signal.throwIfAborted();
376
- if (!body.ok) throw new Error("Worker turn failed");
414
+ if (!body.ok) throw new Error(`Worker turn failed: ${body.error}`);
377
415
  validateReplyFiles(body.files);
378
416
  const files = body.files.map((file) => ({
379
417
  name: file.name,
380
418
  data: Buffer.from(file.data, "base64"),
381
419
  }));
382
420
  return { ok: true, text: body.text, files };
383
- } catch {
421
+ } catch (error) {
422
+ const failure = {
423
+ message: scrubDiagnostic(errorText(error), 4000),
424
+ timedOut: timedOut(),
425
+ cancelled: signal.aborted && !timedOut(),
426
+ };
427
+ this.#options.logger.error(
428
+ { channel: turn.channel, turnId: turn.turnId, ...failure },
429
+ "sandbox turn failed",
430
+ );
384
431
  // A timed-out Pi session must not keep consuming broker tools or corrupt the next turn.
385
432
  if (signal.aborted) {
386
433
  await this.#starts.get(turn.channel)?.catch(() => {});
434
+ await this.#logWorker(turn.channel, turn.turnId);
387
435
  await this.#driver.remove(this.#name(turn.channel));
388
436
  }
389
- return { ok: false, error: new AgentRunError("Sandbox turn failed") };
437
+ return {
438
+ ok: false,
439
+ error: new AgentRunError(
440
+ failure.timedOut
441
+ ? "Sandbox turn timed out"
442
+ : `Sandbox turn failed: ${failure.message}`,
443
+ { cause: error },
444
+ ),
445
+ };
390
446
  } finally {
391
447
  controller.abort();
392
448
  release?.();
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The worker session's compaction: the host's tiers, and the host's compactor reached over the broker.
3
+ */
4
+ import type {
5
+ AgentSession,
6
+ AgentSessionEvent,
7
+ ExtensionFactory,
8
+ SessionBeforeCompactEvent,
9
+ SessionBeforeCompactResult,
10
+ SettingsManager,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ type CompactionHistory,
14
+ CompactionTiers,
15
+ compactionEngine,
16
+ } from "pi-roundtable/kit";
17
+ import type {
18
+ PiCompactionReport,
19
+ PiCompactMessage,
20
+ PiCompactRequest,
21
+ PiCompactResponse,
22
+ PiWorkerConfig,
23
+ } from "../src/pi-protocol.ts";
24
+
25
+ type CompactionEnd = Extract<AgentSessionEvent, { type: "compaction_end" }>;
26
+ export type WorkerLog = (msg: string, fields?: Record<string, unknown>) => void;
27
+
28
+ export interface WorkerCompactionOptions {
29
+ /** The session's entries, for the tiers' loop guard. */
30
+ history: CompactionHistory;
31
+ contextWindow(provider: string, id: string): number | undefined;
32
+ /** What the host told the worker at startup; no `compaction` means no host compactor. */
33
+ config: PiWorkerConfig;
34
+ /** The broker's Unix socket. */
35
+ socket: string;
36
+ log: WorkerLog;
37
+ }
38
+
39
+ /** The request the host's compactor reads: Pi's preparation, with the messages it keeps. */
40
+ export function compactRequest(
41
+ event: SessionBeforeCompactEvent,
42
+ ): PiCompactRequest {
43
+ const { preparation } = event;
44
+ const start = event.branchEntries.findIndex(
45
+ (entry) => entry.id === preparation.firstKeptEntryId,
46
+ );
47
+ const keptMessages =
48
+ start < 0
49
+ ? []
50
+ : event.branchEntries
51
+ .slice(start)
52
+ .flatMap((entry) =>
53
+ entry.type === "message" ? [entry.message] : [],
54
+ );
55
+ const { read, written, edited } = preparation.fileOps;
56
+ const modified = new Set([...written, ...edited]);
57
+ return {
58
+ reason: event.reason,
59
+ tokensBefore: preparation.tokensBefore,
60
+ firstKeptEntryId: preparation.firstKeptEntryId,
61
+ isSplitTurn: preparation.isSplitTurn,
62
+ messagesToSummarize: preparation.messagesToSummarize as PiCompactMessage[],
63
+ turnPrefixMessages: preparation.turnPrefixMessages as PiCompactMessage[],
64
+ keptMessages: keptMessages as PiCompactMessage[],
65
+ ...(preparation.previousSummary === undefined
66
+ ? {}
67
+ : { previousSummary: preparation.previousSummary }),
68
+ ...(event.customInstructions === undefined
69
+ ? {}
70
+ : { customInstructions: event.customInstructions }),
71
+ readFiles: [...read].filter((path) => !modified.has(path)).sort(),
72
+ modifiedFiles: [...modified].sort(),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Compacts as the host's own sessions do: large windows at the soft threshold through the host's
78
+ * compactor, past the hard ceiling through Pi's summary, which reaches the model over the broker.
79
+ */
80
+ export class WorkerCompaction {
81
+ readonly #options: WorkerCompactionOptions;
82
+ readonly #tiers: CompactionTiers;
83
+ readonly #pending = new Set<Promise<void>>();
84
+
85
+ constructor(options: WorkerCompactionOptions) {
86
+ this.#options = options;
87
+ this.#tiers = new CompactionTiers(
88
+ options.history,
89
+ options.contextWindow,
90
+ options.config.compaction?.engine,
91
+ );
92
+ }
93
+
94
+ /** Pi's settings, with each model's reserve set so it compacts at its tier. */
95
+ settings(): SettingsManager {
96
+ return this.#tiers.settings();
97
+ }
98
+
99
+ /** The host compactor's handler, held back by the tiers; none when the host has no compactor. */
100
+ extensions(): { name: string; factory: ExtensionFactory }[] {
101
+ if (!this.#options.config.compaction) return [];
102
+ const handler: ExtensionFactory = (pi) => {
103
+ pi.on("session_before_compact", (event) => this.#compact(event));
104
+ };
105
+ return [
106
+ {
107
+ name: "host-compaction",
108
+ factory: this.#tiers.wrapCompactor(handler, (bypass) => {
109
+ this.#options.log("compaction skips the host compactor", bypass);
110
+ void this.#report({ type: "bypass", ...bypass });
111
+ }),
112
+ },
113
+ ];
114
+ }
115
+
116
+ /** Logs a finished compaction and reports it to the host, which logs it for the channel. */
117
+ async ended(event: CompactionEnd, session: AgentSession): Promise<void> {
118
+ const { result } = event;
119
+ const { model, settingsManager } = session;
120
+ const report: PiCompactionReport = result
121
+ ? {
122
+ type: "end",
123
+ reason: event.reason,
124
+ aborted: event.aborted,
125
+ willRetry: event.willRetry,
126
+ engine: compactionEngine(
127
+ result.details,
128
+ this.#options.config.compaction?.engine,
129
+ ),
130
+ tokensBefore: result.tokensBefore,
131
+ ...(result.estimatedTokensAfter === undefined
132
+ ? {}
133
+ : { tokensAfter: result.estimatedTokensAfter }),
134
+ ...(model
135
+ ? {
136
+ nextCompactionAt:
137
+ model.contextWindow -
138
+ settingsManager.getCompactionSettings(model).reserveTokens,
139
+ }
140
+ : {}),
141
+ }
142
+ : {
143
+ type: "end",
144
+ reason: event.reason,
145
+ aborted: event.aborted,
146
+ willRetry: event.willRetry,
147
+ ...(event.errorMessage === undefined
148
+ ? {}
149
+ : { error: event.errorMessage.slice(0, 10_000) }),
150
+ };
151
+ const { type: _type, ...fields } = report;
152
+ this.#options.log(
153
+ result ? "conversation compacted" : "compaction failed",
154
+ fields,
155
+ );
156
+ await this.#report(report);
157
+ }
158
+
159
+ async #compact(
160
+ event: SessionBeforeCompactEvent,
161
+ ): Promise<SessionBeforeCompactResult | undefined> {
162
+ const { log } = this.#options;
163
+ try {
164
+ // Local Unix-socket transport; no TCP connection or DNS lookup is made.
165
+ // nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
166
+ const response = await fetch("http://broker/compaction/compact", {
167
+ unix: this.#options.socket,
168
+ method: "POST",
169
+ headers: { "content-type": "application/json" },
170
+ body: JSON.stringify(compactRequest(event)),
171
+ signal: event.signal,
172
+ });
173
+ if (!response.ok) {
174
+ await response.body?.cancel();
175
+ log("compaction falls back to Pi's summary", {
176
+ fallback: `the broker answered ${response.status}`,
177
+ });
178
+ return undefined;
179
+ }
180
+ const answer = (await response.json()) as PiCompactResponse;
181
+ if (!answer.ok) {
182
+ log("compaction falls back to Pi's summary", {
183
+ fallback: answer.fallback,
184
+ });
185
+ return undefined;
186
+ }
187
+ return { compaction: answer.compaction };
188
+ } catch (error) {
189
+ log("compaction falls back to Pi's summary", {
190
+ fallback: `the compact request failed: ${String(error)}`,
191
+ });
192
+ return undefined;
193
+ }
194
+ }
195
+
196
+ /** Waits for the reports still on their way, so they reach the host while the turn is bound. */
197
+ async settled(): Promise<void> {
198
+ await Promise.allSettled([...this.#pending]);
199
+ }
200
+
201
+ #report(report: PiCompactionReport): Promise<void> {
202
+ const sending = this.#send(report).finally(() =>
203
+ this.#pending.delete(sending),
204
+ );
205
+ this.#pending.add(sending);
206
+ return sending;
207
+ }
208
+
209
+ async #send(report: PiCompactionReport): Promise<void> {
210
+ try {
211
+ // Local Unix-socket transport; no TCP connection or DNS lookup is made.
212
+ // nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
213
+ const response = await fetch("http://broker/compaction/report", {
214
+ unix: this.#options.socket,
215
+ method: "POST",
216
+ headers: { "content-type": "application/json" },
217
+ body: JSON.stringify(report),
218
+ signal: AbortSignal.timeout(5000),
219
+ });
220
+ await response.body?.cancel();
221
+ } catch (error) {
222
+ this.#options.log("compaction report failed", { error: String(error) });
223
+ }
224
+ }
225
+ }