@frockbot/computer-host-protocol 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/computer-host-protocol",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -3,6 +3,7 @@ import {
3
3
  COMPUTER_HOST_LIMITS,
4
4
  COMPUTER_HOST_ROUTES,
5
5
  ComputerHostExecFrameReaderV1,
6
+ ComputerHostOpenFrameReaderV1,
6
7
  computerHostOperationKindV1,
7
8
  computerHostProblemV1,
8
9
  decodeBase64FieldV1,
@@ -17,15 +18,18 @@ import {
17
18
  decodeComputerHostFileWriteResultV1,
18
19
  decodeComputerHostHttpRequestV1,
19
20
  decodeComputerHostOpenResultV1,
21
+ decodeComputerHostOpenFrameV1,
20
22
  decodeComputerHostProblemV1,
21
23
  decodeComputerHostRequestV1,
22
24
  decodeComputerHostServiceResultV1,
23
25
  decodeComputerHostViewerResultV1,
24
26
  decodeComputerPathV1,
25
27
  encodeComputerHostExecFrameV1,
28
+ encodeComputerHostOpenFrameV1,
26
29
  encodeComputerHostRequestV1,
27
30
  problem,
28
31
  type ComputerHostOperationV1,
32
+ type ComputerHostOpenFrameV1,
29
33
  } from "./protocol.ts";
30
34
 
31
35
  const envelope = {
@@ -108,6 +112,29 @@ describe("envelope", () => {
108
112
  });
109
113
  });
110
114
 
115
+ describe("open", () => {
116
+ test("selects a stream without making it mandatory for earlier V1 callers", () => {
117
+ expect(
118
+ decodeComputerHostRequestV1(
119
+ "open",
120
+ request({ kind: "open", stream: true }),
121
+ ).operation,
122
+ ).toEqual({ kind: "open", stream: true });
123
+ expect(
124
+ decodeComputerHostRequestV1("open", request({ kind: "open" })).operation,
125
+ ).toEqual({ kind: "open" });
126
+ });
127
+
128
+ test("refuses a non-boolean stream flag", () => {
129
+ expect(() =>
130
+ decodeComputerHostRequestV1("open", {
131
+ ...request({ kind: "open" }),
132
+ stream: "yes",
133
+ }),
134
+ ).toThrow(/open stream must be a boolean/);
135
+ });
136
+ });
137
+
111
138
  describe("exec", () => {
112
139
  const exec: ComputerHostOperationV1 = {
113
140
  kind: "exec",
@@ -617,6 +644,69 @@ describe("problems", () => {
617
644
  });
618
645
  });
619
646
 
647
+ describe("open frames", () => {
648
+ const progress = {
649
+ kind: "provision" as const,
650
+ phase: "packages",
651
+ label: "installing the desktop packages",
652
+ index: 2,
653
+ total: 5,
654
+ status: "running" as const,
655
+ resumed: false,
656
+ };
657
+ const result = {
658
+ version: 1 as const,
659
+ effectId: "effect-1",
660
+ spriteName: "frockbot-0123456789ab",
661
+ directory: "agent-data/agents/bot-1",
662
+ generation: 1,
663
+ provisioning: {
664
+ ...progress,
665
+ phase: "ready",
666
+ label: "the Computer is ready",
667
+ status: "complete" as const,
668
+ index: 5,
669
+ },
670
+ };
671
+
672
+ test("round-trips progress, result, and error frames", () => {
673
+ const frames: ComputerHostOpenFrameV1[] = [
674
+ { type: "progress", progress },
675
+ { type: "result", result },
676
+ {
677
+ type: "error",
678
+ code: "provider-unavailable",
679
+ message: "the container stopped",
680
+ retryable: true,
681
+ },
682
+ ];
683
+ for (const frame of frames) {
684
+ expect(
685
+ decodeComputerHostOpenFrameV1(
686
+ encodeComputerHostOpenFrameV1(frame).trimEnd(),
687
+ ),
688
+ ).toEqual(frame);
689
+ }
690
+ });
691
+
692
+ test("reassembles open frames across arbitrary chunk boundaries", () => {
693
+ const frames: ComputerHostOpenFrameV1[] = [
694
+ { type: "progress", progress },
695
+ { type: "result", result },
696
+ ];
697
+ const wire = frames.map(encodeComputerHostOpenFrameV1).join("");
698
+ for (const size of [1, 3, 17, wire.length]) {
699
+ const reader = new ComputerHostOpenFrameReaderV1();
700
+ const seen: ComputerHostOpenFrameV1[] = [];
701
+ for (let index = 0; index < wire.length; index += size) {
702
+ seen.push(...reader.push(wire.slice(index, index + size)));
703
+ }
704
+ seen.push(...reader.end());
705
+ expect(seen).toEqual(frames);
706
+ }
707
+ });
708
+ });
709
+
620
710
  describe("exec frames", () => {
621
711
  test("round-trips every frame type", () => {
622
712
  const frames = [
package/src/protocol.ts CHANGED
@@ -21,7 +21,7 @@ export const COMPUTER_HOST_PROTOCOL_VERSION = 1;
21
21
  /** Header carrying the shared secret between the two Workers and the container. */
22
22
  export const COMPUTER_HOST_TOKEN_HEADER = "x-frockbot-host-token";
23
23
 
24
- /** NDJSON media type used by a streaming exec response. */
24
+ /** NDJSON media type used by a streaming Computer host response. */
25
25
  export const COMPUTER_HOST_STREAM_MEDIA_TYPE = "application/x-ndjson";
26
26
 
27
27
  /**
@@ -104,6 +104,11 @@ export interface ComputerHostEnvelopeV1 {
104
104
 
105
105
  export interface ComputerHostOpenOperationV1 {
106
106
  kind: "open";
107
+ /**
108
+ * True for provisioning frames followed by one terminal result, false or
109
+ * absent for one buffered answer. Absence is the pre-streaming V1 shape.
110
+ */
111
+ stream?: boolean;
107
112
  }
108
113
 
109
114
  export interface ComputerHostExecOperationV1 {
@@ -382,6 +387,16 @@ export interface ComputerHostProblemV1 {
382
387
  retryable: boolean;
383
388
  }
384
389
 
390
+ export type ComputerHostOpenFrameV1 =
391
+ | { type: "progress"; progress: ComputerHostProvisioningV1 }
392
+ | { type: "result"; result: ComputerHostOpenResultV1 }
393
+ | {
394
+ type: "error";
395
+ code: ComputerHostErrorCodeV1;
396
+ message: string;
397
+ retryable: boolean;
398
+ };
399
+
385
400
  export type ComputerHostExecFrameV1 =
386
401
  | { type: "stdout"; dataBase64: string }
387
402
  | { type: "stderr"; dataBase64: string }
@@ -606,6 +621,12 @@ function decodeOperation(
606
621
  ): ComputerHostOperationV1 {
607
622
  switch (kind) {
608
623
  case "open":
624
+ return {
625
+ kind,
626
+ ...(value.stream === undefined
627
+ ? {}
628
+ : { stream: boolean(value.stream, "Computer open stream") }),
629
+ };
609
630
  case "cancel":
610
631
  return { kind };
611
632
  case "exec":
@@ -723,7 +744,7 @@ function decodeOperation(
723
744
 
724
745
  const OPERATION_FIELDS: Record<ComputerHostOperationKindV1, readonly string[]> =
725
746
  {
726
- open: [],
747
+ open: ["stream"],
727
748
  exec: [
728
749
  "script",
729
750
  "cwd",
@@ -1187,7 +1208,64 @@ export function decodeComputerHostCancelResultV1(
1187
1208
  };
1188
1209
  }
1189
1210
 
1190
- // --- exec frames -----------------------------------------------------------
1211
+ // --- stream frames ---------------------------------------------------------
1212
+
1213
+ /** One open-stream NDJSON line, newline included. */
1214
+ export function encodeComputerHostOpenFrameV1(
1215
+ frame: ComputerHostOpenFrameV1,
1216
+ ): string {
1217
+ return `${JSON.stringify(frame)}\n`;
1218
+ }
1219
+
1220
+ export function decodeComputerHostOpenFrameV1(
1221
+ line: string,
1222
+ ): ComputerHostOpenFrameV1 {
1223
+ let parsed: unknown;
1224
+ try {
1225
+ parsed = JSON.parse(line);
1226
+ } catch {
1227
+ fail("Computer open frame is not JSON");
1228
+ }
1229
+ const value = object(parsed, "Computer open frame");
1230
+ if (value.type === "progress") {
1231
+ exactly(value, ["type", "progress"], "Computer open frame");
1232
+ return {
1233
+ type: "progress",
1234
+ progress: decodeComputerHostProvisioningV1(
1235
+ value.progress,
1236
+ "Computer open frame",
1237
+ ),
1238
+ };
1239
+ }
1240
+ if (value.type === "result") {
1241
+ exactly(value, ["type", "result"], "Computer open frame");
1242
+ return {
1243
+ type: "result",
1244
+ result: decodeComputerHostOpenResultV1(value.result),
1245
+ };
1246
+ }
1247
+ if (value.type === "error") {
1248
+ exactly(
1249
+ value,
1250
+ ["type", "code", "message", "retryable"],
1251
+ "Computer open frame",
1252
+ );
1253
+ if (!ERROR_CODES.includes(value.code as ComputerHostErrorCodeV1)) {
1254
+ fail("Computer open frame code is invalid");
1255
+ }
1256
+ return {
1257
+ type: "error",
1258
+ code: value.code as ComputerHostErrorCodeV1,
1259
+ message: boundedString(
1260
+ value.message,
1261
+ COMPUTER_HOST_LIMITS.message,
1262
+ "Computer open frame message",
1263
+ ),
1264
+ retryable: boolean(value.retryable, "Computer open frame retryable"),
1265
+ };
1266
+ }
1267
+ return fail("Computer open frame type is invalid");
1268
+ }
1191
1269
 
1192
1270
  /** One NDJSON line, newline included. */
1193
1271
  export function encodeComputerHostExecFrameV1(
@@ -1267,31 +1345,48 @@ export function decodeComputerHostExecFrameV1(
1267
1345
  * transport may split or coalesce anywhere, so a frame boundary is the newline
1268
1346
  * this decoder finds and never the chunk the transport delivered.
1269
1347
  */
1270
- export class ComputerHostExecFrameReaderV1 {
1348
+ class ComputerHostFrameReaderV1<Frame> {
1271
1349
  private buffer = "";
1272
1350
  private readonly decoder = new TextDecoder();
1351
+ private readonly decodeFrame: (line: string) => Frame;
1352
+
1353
+ constructor(decodeFrame: (line: string) => Frame) {
1354
+ this.decodeFrame = decodeFrame;
1355
+ }
1273
1356
 
1274
1357
  /** Frames completed by this chunk, in order. */
1275
- push(chunk: Uint8Array | string): ComputerHostExecFrameV1[] {
1358
+ push(chunk: Uint8Array | string): Frame[] {
1276
1359
  this.buffer +=
1277
1360
  typeof chunk === "string"
1278
1361
  ? chunk
1279
1362
  : this.decoder.decode(chunk, { stream: true });
1280
- const frames: ComputerHostExecFrameV1[] = [];
1363
+ const frames: Frame[] = [];
1281
1364
  let newline = this.buffer.indexOf("\n");
1282
1365
  while (newline >= 0) {
1283
1366
  const line = this.buffer.slice(0, newline).trim();
1284
1367
  this.buffer = this.buffer.slice(newline + 1);
1285
- if (line) frames.push(decodeComputerHostExecFrameV1(line));
1368
+ if (line) frames.push(this.decodeFrame(line));
1286
1369
  newline = this.buffer.indexOf("\n");
1287
1370
  }
1288
1371
  return frames;
1289
1372
  }
1290
1373
 
1291
1374
  /** The trailing frame of a stream that ended without its final newline. */
1292
- end(): ComputerHostExecFrameV1[] {
1375
+ end(): Frame[] {
1293
1376
  const line = this.buffer.trim();
1294
1377
  this.buffer = "";
1295
- return line ? [decodeComputerHostExecFrameV1(line)] : [];
1378
+ return line ? [this.decodeFrame(line)] : [];
1379
+ }
1380
+ }
1381
+
1382
+ export class ComputerHostOpenFrameReaderV1 extends ComputerHostFrameReaderV1<ComputerHostOpenFrameV1> {
1383
+ constructor() {
1384
+ super(decodeComputerHostOpenFrameV1);
1385
+ }
1386
+ }
1387
+
1388
+ export class ComputerHostExecFrameReaderV1 extends ComputerHostFrameReaderV1<ComputerHostExecFrameV1> {
1389
+ constructor() {
1390
+ super(decodeComputerHostExecFrameV1);
1296
1391
  }
1297
1392
  }