pi-roundtable-sandbox 0.7.9 → 0.7.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +84 -2
- package/package.json +2 -2
- package/src/container-driver.ts +43 -12
- package/src/index.ts +22 -0
- package/src/pi-broker.ts +353 -6
- package/src/pi-container-driver.ts +74 -3
- package/src/pi-protocol.ts +118 -0
- package/src/pi-runtime.ts +67 -10
- package/src/precheck-runner.ts +401 -0
- package/worker/Dockerfile +1 -1
- package/worker/pi-compaction.ts +225 -0
- package/worker/pi-main.ts +96 -13
- package/worker/precheck-main.ts +147 -0
package/src/pi-broker.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import type { ChannelKey } from "pi-roundtable";
|
|
1
|
+
import type { ChannelKey, Logger } from "pi-roundtable";
|
|
2
|
+
import { HARD_COMPACT_TOKENS, scrubDiagnostic } from "pi-roundtable/kit";
|
|
2
3
|
import { type BrokerListener, listenBroker } from "./broker.ts";
|
|
3
4
|
import { piModelInput } from "./pi-model-input.ts";
|
|
4
5
|
import {
|
|
5
6
|
PI_MEDIA_LIMITS,
|
|
7
|
+
type PiCompaction,
|
|
8
|
+
type PiCompactionReport,
|
|
9
|
+
type PiCompactRequest,
|
|
10
|
+
type PiCompactResponse,
|
|
6
11
|
type PiThinkingLevel,
|
|
7
12
|
type PiToolResponse,
|
|
8
13
|
type PiTurnRequest,
|
|
9
14
|
type PiTurnResponse,
|
|
15
|
+
type PiWorkerConfig,
|
|
16
|
+
validateCompactionReport,
|
|
17
|
+
validateCompactRequest,
|
|
10
18
|
validateImages,
|
|
11
19
|
validateReplyFiles,
|
|
12
20
|
} from "./pi-protocol.ts";
|
|
@@ -25,6 +33,31 @@ export interface PiMcpServer {
|
|
|
25
33
|
url: string;
|
|
26
34
|
tools: readonly string[];
|
|
27
35
|
}
|
|
36
|
+
/** Defaults for a host compactor's limits. */
|
|
37
|
+
export const PI_COMPACT_LIMITS = {
|
|
38
|
+
/** A larger compact request falls back to Pi's summary unread. */
|
|
39
|
+
requestBytes: 32 * 1024 * 1024,
|
|
40
|
+
/** A compactor still running after this falls back to Pi's summary. */
|
|
41
|
+
timeoutMs: 120_000,
|
|
42
|
+
/** Compact requests one turn may make; the rest fall back to Pi's summary. */
|
|
43
|
+
compactsPerTurn: 3,
|
|
44
|
+
/** Compaction reports one turn may send. */
|
|
45
|
+
reportsPerTurn: 16,
|
|
46
|
+
} as const;
|
|
47
|
+
/** Compacts a sandbox session on the host, with what the worker cannot reach (a remote service, say). */
|
|
48
|
+
export interface PiCompactor {
|
|
49
|
+
/** The `engine` this compactor's compactions record in their details; the broker stamps it. */
|
|
50
|
+
engine: string;
|
|
51
|
+
/** A compaction, or undefined to leave it to Pi's own summary. A throw also falls back. */
|
|
52
|
+
compact(
|
|
53
|
+
request: PiCompactRequest,
|
|
54
|
+
context: { channel: ChannelKey; signal: AbortSignal },
|
|
55
|
+
): Promise<PiCompaction | undefined>;
|
|
56
|
+
/** Longest a compaction may take before Pi's summary runs instead (default 120 seconds). */
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
/** Largest request accepted, in bytes (default 32 MiB); a larger one falls back to Pi's summary. */
|
|
59
|
+
maxRequestBytes?: number;
|
|
60
|
+
}
|
|
28
61
|
export interface PiBrokerOptions {
|
|
29
62
|
model: string;
|
|
30
63
|
/** Host-only, read at each call. Never sent to the worker. */
|
|
@@ -47,6 +80,10 @@ export interface PiBrokerOptions {
|
|
|
47
80
|
maxOutputTokens?: number;
|
|
48
81
|
/** Trusted endpoints can use HTTP only for explicitly configured local MCP. */
|
|
49
82
|
allowHttpMcp?: boolean;
|
|
83
|
+
/** Host-side compaction; without it the worker compacts with Pi's summary alone. */
|
|
84
|
+
compaction?: PiCompactor;
|
|
85
|
+
/** Receives upstream failures and the worker's compactions, per channel. */
|
|
86
|
+
logger?: Logger;
|
|
50
87
|
}
|
|
51
88
|
const MODEL_PATHS = new Set([
|
|
52
89
|
"/anthropic/v1/messages",
|
|
@@ -69,6 +106,14 @@ function passthroughHeader(name: string): boolean {
|
|
|
69
106
|
RESPONSE_HEADERS.includes(name)
|
|
70
107
|
);
|
|
71
108
|
}
|
|
109
|
+
/** An error's name, message and cause chain, for a log line. */
|
|
110
|
+
export function errorText(error: unknown): string {
|
|
111
|
+
if (!(error instanceof Error)) return String(error);
|
|
112
|
+
const text = `${error.name}: ${error.message}`;
|
|
113
|
+
return error.cause === undefined
|
|
114
|
+
? text
|
|
115
|
+
: `${text} (cause: ${errorText(error.cause)})`;
|
|
116
|
+
}
|
|
72
117
|
function endpoint(raw: string, allowHttp = false): URL {
|
|
73
118
|
// pi-lens-ignore: unchecked-throwing-call -- invalid trusted endpoint configuration must fail startup.
|
|
74
119
|
const url = new URL(raw);
|
|
@@ -87,6 +132,7 @@ function streamBounded(
|
|
|
87
132
|
signal: AbortSignal,
|
|
88
133
|
maxBytes: number,
|
|
89
134
|
secret: string,
|
|
135
|
+
onFailure: (error: unknown) => void,
|
|
90
136
|
): ReadableStream<Uint8Array> {
|
|
91
137
|
const reader = response.body?.getReader();
|
|
92
138
|
let total = 0;
|
|
@@ -119,7 +165,8 @@ function streamBounded(
|
|
|
119
165
|
const cut = Math.max(0, data.length - tail);
|
|
120
166
|
if (cut) controller.enqueue(data.subarray(0, cut));
|
|
121
167
|
carry = data.subarray(cut);
|
|
122
|
-
} catch {
|
|
168
|
+
} catch (error) {
|
|
169
|
+
onFailure(error);
|
|
123
170
|
await reader?.cancel().catch(() => {});
|
|
124
171
|
controller.error(new Error("Broker response failed"));
|
|
125
172
|
}
|
|
@@ -128,6 +175,57 @@ function streamBounded(
|
|
|
128
175
|
});
|
|
129
176
|
}
|
|
130
177
|
|
|
178
|
+
/** At most `limit` bytes of a body as text; the rest is cancelled, never read. */
|
|
179
|
+
async function bodyHead(
|
|
180
|
+
body: ReadableStream<Uint8Array>,
|
|
181
|
+
limit: number,
|
|
182
|
+
): Promise<string> {
|
|
183
|
+
const reader = body.getReader();
|
|
184
|
+
const chunks: Uint8Array[] = [];
|
|
185
|
+
let size = 0;
|
|
186
|
+
try {
|
|
187
|
+
while (size < limit) {
|
|
188
|
+
const { done, value } = await reader.read();
|
|
189
|
+
if (done) break;
|
|
190
|
+
chunks.push(value);
|
|
191
|
+
size += value.byteLength;
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// The worker's branch reports a broken stream; the log keeps what arrived.
|
|
195
|
+
} finally {
|
|
196
|
+
await reader.cancel().catch(() => {});
|
|
197
|
+
}
|
|
198
|
+
return Buffer.concat(chunks).subarray(0, limit).toString("utf8");
|
|
199
|
+
}
|
|
200
|
+
/** A credential as it may appear in text: raw, URL-encoded, base64 and hex. */
|
|
201
|
+
function secretForms(secret: string): string[] {
|
|
202
|
+
if (!secret) return [];
|
|
203
|
+
return [
|
|
204
|
+
secret,
|
|
205
|
+
encodeURIComponent(secret),
|
|
206
|
+
Buffer.from(secret).toString("base64"),
|
|
207
|
+
Buffer.from(secret).toString("base64url"),
|
|
208
|
+
Buffer.from(secret).toString("hex"),
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
/** A compactor's answer, or a rejection once the signal aborts, even if the compactor ignores it. */
|
|
212
|
+
function abortableCompact<T>(
|
|
213
|
+
promise: Promise<T>,
|
|
214
|
+
signal: AbortSignal,
|
|
215
|
+
): Promise<T> {
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
const abort = () => reject(signal.reason);
|
|
218
|
+
if (signal.aborted) {
|
|
219
|
+
abort();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
223
|
+
promise
|
|
224
|
+
.then(resolve, reject)
|
|
225
|
+
.finally(() => signal.removeEventListener("abort", abort));
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
131
229
|
/** Explicit subscription mode. A broker is idle except while the host binds one admitted turn. */
|
|
132
230
|
export class PiSandboxBroker {
|
|
133
231
|
readonly #options: PiBrokerOptions;
|
|
@@ -181,6 +279,12 @@ export class PiSandboxBroker {
|
|
|
181
279
|
this.#rearmStartup();
|
|
182
280
|
return new Response("Ready");
|
|
183
281
|
}
|
|
282
|
+
if (path === "/worker/config" && request.method === "GET") {
|
|
283
|
+
const compaction = this.#options.compaction;
|
|
284
|
+
return Response.json({
|
|
285
|
+
...(compaction ? { compaction: { engine: compaction.engine } } : {}),
|
|
286
|
+
} satisfies PiWorkerConfig);
|
|
287
|
+
}
|
|
184
288
|
if (path === "/worker/next" && request.method === "GET") {
|
|
185
289
|
if (this.#next) return new Response("Already waiting", { status: 429 });
|
|
186
290
|
if (this.#pending && !this.#pending.delivered) {
|
|
@@ -306,6 +410,23 @@ export class PiSandboxBroker {
|
|
|
306
410
|
(options.maxCalls ?? 128) > 1000
|
|
307
411
|
)
|
|
308
412
|
throw new Error("Invalid broker budget");
|
|
413
|
+
const compaction = options.compaction;
|
|
414
|
+
if (
|
|
415
|
+
compaction &&
|
|
416
|
+
(!/^[a-zA-Z0-9@/._-]{1,100}$/.test(compaction.engine) ||
|
|
417
|
+
!Number.isSafeInteger(
|
|
418
|
+
compaction.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs,
|
|
419
|
+
) ||
|
|
420
|
+
(compaction.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs) < 1000 ||
|
|
421
|
+
(compaction.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs) > 600_000 ||
|
|
422
|
+
!Number.isSafeInteger(
|
|
423
|
+
compaction.maxRequestBytes ?? PI_COMPACT_LIMITS.requestBytes,
|
|
424
|
+
) ||
|
|
425
|
+
(compaction.maxRequestBytes ?? PI_COMPACT_LIMITS.requestBytes) < 1 ||
|
|
426
|
+
(compaction.maxRequestBytes ?? PI_COMPACT_LIMITS.requestBytes) >
|
|
427
|
+
96 * 1024 * 1024)
|
|
428
|
+
)
|
|
429
|
+
throw new Error("Invalid compactor");
|
|
309
430
|
if (
|
|
310
431
|
!Number.isSafeInteger(options.maxOutputTokens ?? 128_000) ||
|
|
311
432
|
(options.maxOutputTokens ?? 128_000) < 1025 ||
|
|
@@ -354,6 +475,12 @@ export class PiSandboxBroker {
|
|
|
354
475
|
})),
|
|
355
476
|
});
|
|
356
477
|
}
|
|
478
|
+
if (
|
|
479
|
+
(url.pathname === "/compaction/compact" ||
|
|
480
|
+
url.pathname === "/compaction/report") &&
|
|
481
|
+
!url.search
|
|
482
|
+
)
|
|
483
|
+
return this.#compaction(request, url.pathname);
|
|
357
484
|
const startup = !this.#turn;
|
|
358
485
|
const turn = this.#turn ?? this.#startup;
|
|
359
486
|
if (!turn || turn.context.signal.aborted)
|
|
@@ -378,6 +505,11 @@ export class PiSandboxBroker {
|
|
|
378
505
|
turn.active++;
|
|
379
506
|
// The turn signal carries the deadline: a long generation or image call must not be cut shorter.
|
|
380
507
|
const signal = AbortSignal.any([turn.context.signal, request.signal]);
|
|
508
|
+
const failed = this.#failureLog(
|
|
509
|
+
turn.context.channel,
|
|
510
|
+
toolName ? `tool:${toolName}` : server ? `mcp:${server.name}` : "model",
|
|
511
|
+
);
|
|
512
|
+
let secret = "";
|
|
381
513
|
try {
|
|
382
514
|
const input: unknown = JSON.parse(
|
|
383
515
|
await boundedText(request.body, 96 * 1024 * 1024),
|
|
@@ -405,7 +537,6 @@ export class PiSandboxBroker {
|
|
|
405
537
|
return Response.json(result);
|
|
406
538
|
}
|
|
407
539
|
let target: string;
|
|
408
|
-
let secret: string;
|
|
409
540
|
let body: Record<string, unknown>;
|
|
410
541
|
const headers = new Headers({
|
|
411
542
|
"content-type": "application/json",
|
|
@@ -478,23 +609,239 @@ export class PiSandboxBroker {
|
|
|
478
609
|
if (id && !id.includes(secret) && id.length <= 256)
|
|
479
610
|
outgoing.set("mcp-session-id", id);
|
|
480
611
|
}
|
|
481
|
-
if (upstream.status < 200 || upstream.status > 599)
|
|
612
|
+
if (upstream.status < 200 || upstream.status > 599) {
|
|
613
|
+
failed(secret, { status: upstream.status });
|
|
482
614
|
return new Response("Upstream refused", { status: 502 });
|
|
615
|
+
}
|
|
616
|
+
let answer = upstream;
|
|
617
|
+
if (upstream.status >= 400 && upstream.body) {
|
|
618
|
+
// The log reads the error body's head from one branch; the worker gets all of it from the other.
|
|
619
|
+
const [logged, sent] = upstream.body.tee();
|
|
620
|
+
void bodyHead(logged, 64 * 1024).then((body) =>
|
|
621
|
+
failed(secret, { status: upstream.status, body }),
|
|
622
|
+
);
|
|
623
|
+
answer = new Response(sent, { status: upstream.status });
|
|
624
|
+
} else if (upstream.status >= 400)
|
|
625
|
+
failed(secret, { status: upstream.status });
|
|
483
626
|
return new Response(
|
|
484
627
|
upstream.status === 204 || upstream.status === 205
|
|
485
628
|
? null
|
|
486
629
|
: streamBounded(
|
|
487
|
-
|
|
630
|
+
answer,
|
|
488
631
|
signal,
|
|
489
632
|
PI_MEDIA_LIMITS.totalFileBytes,
|
|
490
633
|
secret,
|
|
634
|
+
(error) => failed(secret, { status: upstream.status, error }),
|
|
491
635
|
),
|
|
492
636
|
{ headers: outgoing, status: upstream.status },
|
|
493
637
|
);
|
|
494
|
-
} catch {
|
|
638
|
+
} catch (error) {
|
|
639
|
+
failed(secret, { error });
|
|
495
640
|
return new Response("Broker call failed", { status: 502 });
|
|
496
641
|
} finally {
|
|
497
642
|
turn.active--;
|
|
498
643
|
}
|
|
499
644
|
}
|
|
645
|
+
/** Logs a failed upstream call with its latency; the body and error lose any credential. */
|
|
646
|
+
#failureLog(channel: ChannelKey, upstream: string) {
|
|
647
|
+
const started = Date.now();
|
|
648
|
+
return (
|
|
649
|
+
secret: string,
|
|
650
|
+
failure: { status?: number; body?: string; error?: unknown },
|
|
651
|
+
): void => {
|
|
652
|
+
const clean = (text: string) =>
|
|
653
|
+
scrubDiagnostic(
|
|
654
|
+
secretForms(secret).reduce(
|
|
655
|
+
(masked, form) => masked.replaceAll(form, "[redacted]"),
|
|
656
|
+
text,
|
|
657
|
+
),
|
|
658
|
+
2000,
|
|
659
|
+
);
|
|
660
|
+
this.#options.logger?.warn(
|
|
661
|
+
{
|
|
662
|
+
channel,
|
|
663
|
+
upstream,
|
|
664
|
+
...(failure.status === undefined ? {} : { status: failure.status }),
|
|
665
|
+
latencyMs: Date.now() - started,
|
|
666
|
+
...(failure.body === undefined ? {} : { body: clean(failure.body) }),
|
|
667
|
+
...(failure.error === undefined
|
|
668
|
+
? {}
|
|
669
|
+
: { error: clean(errorText(failure.error)) }),
|
|
670
|
+
},
|
|
671
|
+
"sandbox upstream call failed",
|
|
672
|
+
);
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
/** Settles when the running compactor really ends, even after its request fell back. */
|
|
676
|
+
#compacting: Promise<unknown> | undefined;
|
|
677
|
+
readonly #compactionUse = new WeakMap<
|
|
678
|
+
object,
|
|
679
|
+
{ compacts: number; reports: number }
|
|
680
|
+
>();
|
|
681
|
+
/** The worker's compactions, only inside an admitted turn: a host compaction, or a report to log. */
|
|
682
|
+
async #compaction(request: Request, path: string): Promise<Response> {
|
|
683
|
+
const turn = this.#turn;
|
|
684
|
+
if (request.method !== "POST")
|
|
685
|
+
return new Response("Not found", { status: 404 });
|
|
686
|
+
if (!turn || turn.context.signal.aborted)
|
|
687
|
+
return new Response("Turn ended", { status: 410 });
|
|
688
|
+
const { channel } = turn.context;
|
|
689
|
+
const logger = this.#options.logger;
|
|
690
|
+
const use = this.#compactionUse.get(turn) ?? { compacts: 0, reports: 0 };
|
|
691
|
+
this.#compactionUse.set(turn, use);
|
|
692
|
+
if (path === "/compaction/report") {
|
|
693
|
+
// A turn compacts a few times at most; more reports only fill the host's log.
|
|
694
|
+
if (++use.reports > PI_COMPACT_LIMITS.reportsPerTurn)
|
|
695
|
+
return new Response("Report budget exhausted", { status: 429 });
|
|
696
|
+
try {
|
|
697
|
+
const report: unknown = JSON.parse(
|
|
698
|
+
await boundedText(request.body, 64 * 1024, turn.context.signal),
|
|
699
|
+
);
|
|
700
|
+
validateCompactionReport(report);
|
|
701
|
+
this.#logReport(channel, report);
|
|
702
|
+
return new Response("Accepted");
|
|
703
|
+
} catch {
|
|
704
|
+
return new Response("Invalid report", { status: 400 });
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
const compactor = this.#options.compaction;
|
|
708
|
+
const fallback = (reason: string): Response => {
|
|
709
|
+
logger?.warn(
|
|
710
|
+
{ channel, engine: compactor?.engine, fallback: reason },
|
|
711
|
+
"compaction falls back to Pi's summary",
|
|
712
|
+
);
|
|
713
|
+
return Response.json({
|
|
714
|
+
ok: false,
|
|
715
|
+
fallback: reason,
|
|
716
|
+
} satisfies PiCompactResponse);
|
|
717
|
+
};
|
|
718
|
+
if (!compactor) return fallback("the host has no compactor");
|
|
719
|
+
if (this.#compacting) return fallback("a compaction is already running");
|
|
720
|
+
if (++use.compacts > PI_COMPACT_LIMITS.compactsPerTurn)
|
|
721
|
+
return fallback(
|
|
722
|
+
`the turn already asked for ${PI_COMPACT_LIMITS.compactsPerTurn} compactions`,
|
|
723
|
+
);
|
|
724
|
+
const maxBytes =
|
|
725
|
+
compactor.maxRequestBytes ?? PI_COMPACT_LIMITS.requestBytes;
|
|
726
|
+
if (Number(request.headers.get("content-length") ?? 0) > maxBytes)
|
|
727
|
+
return fallback(`the request is over ${maxBytes} bytes`);
|
|
728
|
+
let running: Promise<unknown> = Promise.resolve();
|
|
729
|
+
this.#compacting = running;
|
|
730
|
+
const timeout = AbortSignal.timeout(
|
|
731
|
+
compactor.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs,
|
|
732
|
+
);
|
|
733
|
+
const signal = AbortSignal.any([
|
|
734
|
+
turn.context.signal,
|
|
735
|
+
request.signal,
|
|
736
|
+
timeout,
|
|
737
|
+
]);
|
|
738
|
+
const started = Date.now();
|
|
739
|
+
try {
|
|
740
|
+
let compactRequest: unknown;
|
|
741
|
+
try {
|
|
742
|
+
compactRequest = JSON.parse(
|
|
743
|
+
await boundedText(request.body, maxBytes, signal),
|
|
744
|
+
);
|
|
745
|
+
validateCompactRequest(compactRequest);
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if (signal.aborted) throw error;
|
|
748
|
+
return fallback(
|
|
749
|
+
error instanceof Error && error.message === "body too large"
|
|
750
|
+
? `the request is over ${maxBytes} bytes`
|
|
751
|
+
: "the request is invalid",
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
const compacting = compactor.compact(compactRequest, { channel, signal });
|
|
755
|
+
running = compacting.catch(() => {});
|
|
756
|
+
this.#compacting = running;
|
|
757
|
+
const result = await abortableCompact(compacting, signal);
|
|
758
|
+
if (!result) return fallback("the compactor declined");
|
|
759
|
+
const details =
|
|
760
|
+
result.details === undefined
|
|
761
|
+
? { engine: compactor.engine }
|
|
762
|
+
: isRecord(result.details)
|
|
763
|
+
? { ...result.details, engine: compactor.engine }
|
|
764
|
+
: undefined;
|
|
765
|
+
if (
|
|
766
|
+
!details ||
|
|
767
|
+
typeof result.summary !== "string" ||
|
|
768
|
+
result.summary.length === 0 ||
|
|
769
|
+
result.firstKeptEntryId !== compactRequest.firstKeptEntryId ||
|
|
770
|
+
!Number.isSafeInteger(result.tokensBefore) ||
|
|
771
|
+
(result.estimatedTokensAfter !== undefined &&
|
|
772
|
+
!Number.isSafeInteger(result.estimatedTokensAfter))
|
|
773
|
+
)
|
|
774
|
+
return fallback("the compactor's result is invalid");
|
|
775
|
+
logger?.info(
|
|
776
|
+
{
|
|
777
|
+
channel,
|
|
778
|
+
engine: compactor.engine,
|
|
779
|
+
tokensBefore: result.tokensBefore,
|
|
780
|
+
tokensAfter: result.estimatedTokensAfter,
|
|
781
|
+
latencyMs: Date.now() - started,
|
|
782
|
+
},
|
|
783
|
+
"host compactor answered",
|
|
784
|
+
);
|
|
785
|
+
return Response.json({
|
|
786
|
+
ok: true,
|
|
787
|
+
compaction: {
|
|
788
|
+
summary: result.summary,
|
|
789
|
+
firstKeptEntryId: result.firstKeptEntryId,
|
|
790
|
+
tokensBefore: result.tokensBefore,
|
|
791
|
+
...(result.estimatedTokensAfter === undefined
|
|
792
|
+
? {}
|
|
793
|
+
: { estimatedTokensAfter: result.estimatedTokensAfter }),
|
|
794
|
+
details,
|
|
795
|
+
},
|
|
796
|
+
} satisfies PiCompactResponse);
|
|
797
|
+
} catch (error) {
|
|
798
|
+
return fallback(
|
|
799
|
+
timeout.aborted
|
|
800
|
+
? `the compactor took over ${compactor.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs} ms`
|
|
801
|
+
: signal.aborted
|
|
802
|
+
? "the compaction was aborted"
|
|
803
|
+
: `the compactor failed: ${scrubDiagnostic(errorText(error), 2000)}`,
|
|
804
|
+
);
|
|
805
|
+
} finally {
|
|
806
|
+
// A compactor that ignored its signal still holds the slot until it really ends.
|
|
807
|
+
void running.then(() => {
|
|
808
|
+
if (this.#compacting === running) this.#compacting = undefined;
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/** Logs a worker compaction the way the host logs its own sessions' compactions. */
|
|
813
|
+
#logReport(channel: ChannelKey, report: PiCompactionReport): void {
|
|
814
|
+
const logger = this.#options.logger;
|
|
815
|
+
if (report.type === "bypass") {
|
|
816
|
+
logger?.info(
|
|
817
|
+
{
|
|
818
|
+
channel,
|
|
819
|
+
reason: report.reason,
|
|
820
|
+
tokensBefore: report.tokensBefore,
|
|
821
|
+
ceiling: HARD_COMPACT_TOKENS,
|
|
822
|
+
},
|
|
823
|
+
"compaction skips the extension for Pi's summary",
|
|
824
|
+
);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const trigger = report.reason === "manual" ? "self" : report.reason;
|
|
828
|
+
if (!report.engine) {
|
|
829
|
+
logger?.warn(
|
|
830
|
+
{ channel, trigger, aborted: report.aborted, error: report.error },
|
|
831
|
+
"compaction failed",
|
|
832
|
+
);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
logger?.info(
|
|
836
|
+
{
|
|
837
|
+
channel,
|
|
838
|
+
trigger,
|
|
839
|
+
engine: report.engine,
|
|
840
|
+
tokensBefore: report.tokensBefore,
|
|
841
|
+
tokensAfter: report.tokensAfter,
|
|
842
|
+
nextCompactionAt: report.nextCompactionAt,
|
|
843
|
+
},
|
|
844
|
+
"conversation compacted",
|
|
845
|
+
);
|
|
846
|
+
}
|
|
500
847
|
}
|
|
@@ -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:
|
|
100
|
-
Config: {
|
|
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(
|
|
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",
|