humanish 0.145.0 → 0.147.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/actors/computer-use/loop/spend.js +5 -4
  3. package/dist/actors/computer-use/loop/trace.js +3 -0
  4. package/dist/actors/computer-use/loop/types.d.ts +5 -0
  5. package/dist/actors/computer-use/loop/usage.js +10 -2
  6. package/dist/actors/computer-use/openai-conversation.d.ts +7 -0
  7. package/dist/actors/computer-use/openai-conversation.js +19 -2
  8. package/dist/actors/computer-use/openai-provider.d.ts +2 -0
  9. package/dist/actors/computer-use/openai-provider.js +2 -0
  10. package/dist/actors/computer-use/openai-wire.d.ts +4 -0
  11. package/dist/actors/computer-use/openai-wire.js +9 -1
  12. package/dist/actors/contract.d.ts +5 -0
  13. package/dist/analysis/admission.d.ts +8 -3
  14. package/dist/analysis/admission.js +7 -3
  15. package/dist/analysis/automatic-config.js +2 -1
  16. package/dist/analysis/automatic.js +2 -2
  17. package/dist/analysis/evidence.js +8 -4
  18. package/dist/analysis/execute.d.ts +7 -10
  19. package/dist/analysis/execute.js +79 -49
  20. package/dist/analysis/image-tokens.js +9 -3
  21. package/dist/analysis/output-limit.d.ts +12 -0
  22. package/dist/analysis/output-limit.js +34 -0
  23. package/dist/analysis/output-retry.d.ts +20 -0
  24. package/dist/analysis/output-retry.js +63 -0
  25. package/dist/analysis/provider.d.ts +1 -1
  26. package/dist/analysis/provider.js +3 -3
  27. package/dist/analysis/responses.js +1 -1
  28. package/dist/analysis/service.js +2 -2
  29. package/dist/cli/commands/analyze.js +1 -1
  30. package/dist/cli/io.js +2 -0
  31. package/dist/cli/run-findings.js +10 -2
  32. package/dist/evidence/image.d.ts +2 -0
  33. package/dist/evidence/image.js +24 -3
  34. package/dist/evidence/webp-encode-worker.d.ts +1 -0
  35. package/dist/evidence/webp-encode-worker.js +31 -0
  36. package/dist/evidence/webp-transcoder.d.ts +15 -0
  37. package/dist/evidence/webp-transcoder.js +98 -0
  38. package/dist/evidence/webp.d.ts +31 -0
  39. package/dist/evidence/webp.js +94 -0
  40. package/dist/feedback/derivative-text.d.ts +7 -2
  41. package/dist/feedback/derivative-text.js +20 -1
  42. package/dist/feedback/export-bundle.js +79 -25
  43. package/dist/observer-app.html +1 -1
  44. package/dist/remote/launch.js +93 -48
  45. package/dist/remote/runner.d.ts +47 -14
  46. package/dist/remote/runner.js +199 -64
  47. package/dist/routes/computer-use/e2b-desktop/desktop.js +47 -7
  48. package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +4 -2
  49. package/dist/routes/computer-use/e2b-desktop/prepare.js +4 -2
  50. package/dist/routes/computer-use/e2b-desktop/start.d.ts +14 -6
  51. package/dist/routes/computer-use/e2b-desktop/start.js +72 -34
  52. package/dist/routes/computer-use/participant-execution.d.ts +0 -7
  53. package/dist/routes/computer-use/participant-execution.js +6 -28
  54. package/dist/routes/computer-use/participant-frames.d.ts +32 -0
  55. package/dist/routes/computer-use/participant-frames.js +93 -0
  56. package/dist/routes/computer-use/participant-model.js +3 -0
  57. package/dist/routes/computer-use/participant-runs.js +4 -0
  58. package/dist/routes/computer-use/plan.js +5 -1
  59. package/dist/routes/computer-use/result.js +6 -0
  60. package/dist/routes/computer-use/types.d.ts +4 -0
  61. package/dist/routes/shared-world/plan.js +4 -1
  62. package/dist/run/pricing.d.ts +7 -0
  63. package/dist/run/pricing.js +58 -15
  64. package/dist/study/config.js +4 -1
  65. package/dist/study/keys.d.ts +1 -0
  66. package/dist/study/keys.js +1 -0
  67. package/dist/study/parse/actors.js +5 -0
  68. package/dist/study/plan-participants.d.ts +2 -0
  69. package/dist/study/plan-participants.js +2 -0
  70. package/dist/study/types.d.ts +7 -0
  71. package/dist/study/validation.d.ts +5 -0
  72. package/dist/study/validation.js +23 -0
  73. package/dist/substrates/e2b/cdp-probe.d.ts +16 -1
  74. package/dist/substrates/e2b/cdp-probe.js +67 -5
  75. package/dist/substrates/e2b/desktop-browser.js +4 -2
  76. package/dist/substrates/e2b/desktop-cdp.d.ts +9 -2
  77. package/dist/substrates/e2b/desktop-cdp.js +26 -4
  78. package/dist/verify/artifacts.js +2 -1
  79. package/docs/contracts/run-bundle.md +29 -11
  80. package/docs/contracts/schemas.md +20 -12
  81. package/docs/contracts/study-analysis.md +64 -11
  82. package/docs/product/automatic-analysis.md +9 -6
  83. package/package.json +6 -5
@@ -13,16 +13,36 @@ export interface Runner {
13
13
  readonly shell: Shell;
14
14
  }
15
15
  export declare function runnerOf(sandbox: E2BDesktopSandbox): Runner;
16
+ /** A study file read here, for the same place in the runner's project. */
17
+ export interface ProjectFile {
18
+ /** Relative to the project, with `/` separators. */
19
+ path: string;
20
+ bytes: Uint8Array;
21
+ }
16
22
  /**
17
- * Install Node and the humanish package `tarball` (this machine's own humanish, packed), with the
18
- * provider SDKs its participants need at `peers` (name@version). Returns the installed version.
23
+ * Read each project-relative file in `files`, and every file under each directory in `directories`
24
+ * that exists here, for upload to the runner. Read before any sandbox exists, so a refusal costs
25
+ * nothing.
19
26
  */
20
- export declare function installHumanish(runner: Runner, tarball: Uint8Array, peers: readonly string[]): Promise<string>;
27
+ export declare function readProject(cwd: string, files: readonly string[], directories: readonly string[]): Promise<ProjectFile[]>;
28
+ /** What a runner is set up with. */
29
+ export interface RunnerSetup {
30
+ /** This machine's humanish as an npm tarball; it may still be packing when setup starts. */
31
+ tarball: Promise<Uint8Array>;
32
+ /**
33
+ * The npm specs its package depends on, from its own package.json, and the provider SDK its
34
+ * participants need, as name@version.
35
+ */
36
+ dependencies: readonly string[];
37
+ project: readonly ProjectFile[];
38
+ }
21
39
  /**
22
- * Upload each project-relative file in `files`, and every file under each directory in
23
- * `directories` that exists here, to the same place in the runner's project.
40
+ * Install Node, this humanish's dependencies and this humanish in the runner, and upload the
41
+ * study's files. Node's download starts at once and the dependencies install as soon as it is
42
+ * there, while the tarball packs and uploads beside them; the tarball is then unpacked in place,
43
+ * so no install waits for the pack. Returns the installed version.
24
44
  */
25
- export declare function uploadProject(runner: Runner, cwd: string, files: readonly string[], directories: readonly string[]): Promise<number>;
45
+ export declare function setUpRunner(runner: Runner, setup: RunnerSetup): Promise<string>;
26
46
  /** What the runner's run is started with. */
27
47
  export interface RemoteRunStart {
28
48
  /** The study file, relative to the project. */
@@ -51,19 +71,28 @@ export interface RemoteRunState {
51
71
  /** The last lines the run wrote to stderr. */
52
72
  errTail: string;
53
73
  }
54
- export declare function readRemoteRun(runner: Runner, runId: string): Promise<RemoteRunState>;
55
- /** One file of the runner's run directory: its path in the directory and its sha256. */
74
+ /**
75
+ * How the runner's run is going. The runner checks its run every 0.2 s for up to `waitMs` and
76
+ * answers as soon as it differs from `seen`: a new state, a result, or its process ending. One
77
+ * command answers each wait, so the launcher learns of a change within a check of it.
78
+ */
79
+ export declare function waitForRemoteRun(runner: Runner, runId: string, seen: {
80
+ state: string | undefined;
81
+ alive: boolean;
82
+ }, waitMs: number): Promise<RemoteRunState>;
83
+ /** One file of the runner's run directory: its path in the directory, its size and its sha256. */
56
84
  export interface RemoteFile {
57
85
  path: string;
86
+ size: number;
58
87
  sha256: string;
59
88
  }
60
89
  /**
61
- * The regular files of the runner's run directory and their digests. Refuses a directory that holds
62
- * a link, which a run never writes.
90
+ * The regular files of the runner's run directory, with their sizes and digests. Refuses a
91
+ * directory that holds a link, which a run never writes.
63
92
  */
64
93
  export declare function listRemoteRun(runner: Runner, runId: string): Promise<RemoteFile[]>;
65
- /** sha256sum lines, `<hex> ./<path>`, as RemoteFiles with paths relative to the directory. */
66
- export declare function parseDigests(output: string): RemoteFile[];
94
+ /** sha256sum lines, `<hex> ./<path>`, with paths relative to the directory. */
95
+ export declare function parseDigests(output: string): Omit<RemoteFile, "size">[];
67
96
  /**
68
97
  * The screenshots and recordings of the runner's run that have not changed for `settledSeconds`,
69
98
  * with their sizes. A run writes each of them once, so one that has settled can be copied while
@@ -80,8 +109,12 @@ export declare function parseSizes(output: string): {
80
109
  }[];
81
110
  /** The bytes of one file of the runner's run directory, as they are now. */
82
111
  export declare function readRemoteBytes(runner: Runner, runId: string, file: string): Promise<Uint8Array>;
83
- /** The bytes of one file of the runner's run directory, checked against its listed digest. */
84
- export declare function readRemoteFile(runner: Runner, runId: string, file: RemoteFile): Promise<Uint8Array>;
112
+ /**
113
+ * Read `files` of the runner's run directory and hand each to `take`, checked against its listed
114
+ * digest. The files go in batches, each joined into one runner file and read back in one request,
115
+ * so a batch costs two round trips whatever its number of files.
116
+ */
117
+ export declare function readRemoteFiles(runner: Runner, runId: string, files: readonly RemoteFile[], take: (file: RemoteFile, bytes: Uint8Array) => Promise<void>): Promise<void>;
85
118
  /**
86
119
  * Ask the runner's run to stop as a signal would, so it reclaims its participants and ends. The
87
120
  * `[h]` keeps the pattern from matching the shell that runs it.
@@ -46,47 +46,88 @@ async function runOrFail(runner, step, command, timeoutMs) {
46
46
  return result;
47
47
  }
48
48
  /**
49
- * Install Node and the humanish package `tarball` (this machine's own humanish, packed), with the
50
- * provider SDKs its participants need at `peers` (name@version). Returns the installed version.
49
+ * Read each project-relative file in `files`, and every file under each directory in `directories`
50
+ * that exists here, for upload to the runner. Read before any sandbox exists, so a refusal costs
51
+ * nothing.
51
52
  */
52
- export async function installHumanish(runner, tarball, peers) {
53
- await runner.shell.writeFile(`${RUNNER_STATE}/humanish.tgz`, toArrayBuffer(tarball));
54
- const { version, sha256 } = NODE;
55
- const script = [
56
- "set -eu",
57
- `cd "$HOME/${RUNNER_STATE}"`,
58
- 'case "$(uname -m)" in x86_64) arch=x64;; aarch64|arm64) arch=arm64;; *) echo "unsupported CPU $(uname -m)" >&2; exit 1;; esac',
59
- `if [ "$arch" = x64 ]; then sum=${sha256.x64}; else sum=${sha256.arm64}; fi`,
60
- `curl -fsSL -o node.tgz "https://nodejs.org/dist/v${version}/node-v${version}-linux-$arch.tar.gz"`,
61
- 'echo "$sum node.tgz" | sha256sum -c - >/dev/null',
62
- "mkdir -p node && tar -xzf node.tgz --strip-components=1 -C node && rm node.tgz",
63
- `export PATH="$HOME/${RUNNER_STATE}/node/bin:$PATH" npm_config_prefix="$HOME/${RUNNER_STATE}/node"`,
64
- `npm install -g --no-fund --no-audit --loglevel=error ./humanish.tgz ${peers.map(shellQuote).join(" ")}`,
65
- "humanish --version",
66
- ].join("\n");
67
- const result = await runOrFail(runner, "Installing humanish", `bash -c ${shellQuote(script)}`, 600_000);
68
- return result.stdout.trim().split("\n").at(-1) ?? "";
69
- }
70
- /**
71
- * Upload each project-relative file in `files`, and every file under each directory in
72
- * `directories` that exists here, to the same place in the runner's project.
73
- */
74
- export async function uploadProject(runner, cwd, files, directories) {
53
+ export async function readProject(cwd, files, directories) {
75
54
  const all = new Set(files);
76
55
  for (const directory of directories)
77
56
  for (const file of await filesUnder(cwd, directory))
78
57
  all.add(file);
79
58
  const root = await realpath(cwd);
59
+ const read = [];
80
60
  for (const file of all) {
81
61
  // A link could hand the runner a file from anywhere on this machine, such as a key file, so
82
62
  // only a regular file whose real path is where the study names it is uploaded.
83
63
  const real = await realpath(path.join(cwd, file)).catch(() => undefined);
84
64
  if (real !== path.join(root, file) || !(await lstat(real)).isFile())
85
65
  throw new Error(`${file} is a link or not a regular file, so it is not uploaded to the runner.`);
86
- const bytes = await readFile(real);
87
- await runner.shell.writeFile(`${RUNNER_PROJECT}/${file}`, toArrayBuffer(bytes));
66
+ read.push({ path: file, bytes: await readFile(real) });
88
67
  }
89
- return all.size;
68
+ return read;
69
+ }
70
+ /** Study files written to the runner at once. */
71
+ const UPLOAD_CONCURRENCY = 8;
72
+ /** The npm project in the runner that holds this humanish and its dependencies. */
73
+ const RUNNER_APP = `${RUNNER_STATE}/app`;
74
+ /**
75
+ * Install Node, this humanish's dependencies and this humanish in the runner, and upload the
76
+ * study's files. Node's download starts at once and the dependencies install as soon as it is
77
+ * there, while the tarball packs and uploads beside them; the tarball is then unpacked in place,
78
+ * so no install waits for the pack. Returns the installed version.
79
+ */
80
+ export async function setUpRunner(runner, setup) {
81
+ const npm = `export PATH="$HOME/${RUNNER_STATE}/node/bin:$PATH"`;
82
+ const dependencies = [
83
+ "set -eu",
84
+ `mkdir -p "$HOME/${RUNNER_APP}"`,
85
+ `cd "$HOME/${RUNNER_APP}"`,
86
+ npm,
87
+ `printf '{"private":true}\\n' > package.json`,
88
+ `npm install --no-save --no-package-lock --no-fund --no-audit --update-notifier=false --loglevel=error ${setup.dependencies.map(shellQuote).join(" ")}`,
89
+ ].join("\n");
90
+ const steps = [
91
+ runOrFail(runner, "Installing Node", nodeInstallScript(), 600_000).then(() => runOrFail(runner, "Installing humanish's dependencies", `bash -c ${shellQuote(dependencies)}`, 600_000)),
92
+ setup.tarball.then((bytes) => runner.shell.writeFile(`${RUNNER_STATE}/humanish.tgz`, toArrayBuffer(bytes))),
93
+ uploadProject(runner, setup.project),
94
+ ];
95
+ await Promise.all(steps);
96
+ // npm's own install would resolve every dependency again. Unpacked beside them, the package
97
+ // finds them as an installed one does, and its bin goes where npm puts a global one.
98
+ const unpack = [
99
+ "set -eu",
100
+ `cd "$HOME/${RUNNER_STATE}"`,
101
+ npm,
102
+ `package="$HOME/${RUNNER_APP}/node_modules/humanish"`,
103
+ 'mkdir -p "$package" && tar -xzf humanish.tgz --strip-components=1 -C "$package"',
104
+ `bin="$package/$(node -p 'require(process.argv[1]).bin.humanish' "$package/package.json")"`,
105
+ 'chmod +x "$bin" && ln -sf "$bin" node/bin/humanish',
106
+ `node -p 'require(process.argv[1]).version' "$package/package.json"`,
107
+ ].join("\n");
108
+ const result = await runOrFail(runner, "Installing humanish", `bash -c ${shellQuote(unpack)}`, 600_000);
109
+ return result.stdout.trim().split("\n").at(-1) ?? "";
110
+ }
111
+ /** Download Node into the runner, check it against its published sha256 and extract it. */
112
+ function nodeInstallScript() {
113
+ const { version, sha256 } = NODE;
114
+ const script = [
115
+ "set -eu",
116
+ `mkdir -p "$HOME/${RUNNER_STATE}"`,
117
+ `cd "$HOME/${RUNNER_STATE}"`,
118
+ 'case "$(uname -m)" in x86_64) arch=x64;; aarch64|arm64) arch=arm64;; *) echo "unsupported CPU $(uname -m)" >&2; exit 1;; esac',
119
+ `if [ "$arch" = x64 ]; then sum=${sha256.x64}; else sum=${sha256.arm64}; fi`,
120
+ `curl -fsSL -o node.tgz "https://nodejs.org/dist/v${version}/node-v${version}-linux-$arch.tar.gz"`,
121
+ 'echo "$sum node.tgz" | sha256sum -c - >/dev/null',
122
+ "mkdir -p node && tar -xzf node.tgz --strip-components=1 -C node && rm node.tgz",
123
+ ].join("\n");
124
+ return `bash -c ${shellQuote(script)}`;
125
+ }
126
+ async function uploadProject(runner, files) {
127
+ for (let at = 0; at < files.length; at += UPLOAD_CONCURRENCY)
128
+ await Promise.all(files
129
+ .slice(at, at + UPLOAD_CONCURRENCY)
130
+ .map((file) => runner.shell.writeFile(`${RUNNER_PROJECT}/${file.path}`, toArrayBuffer(file.bytes))));
90
131
  }
91
132
  async function filesUnder(cwd, directory) {
92
133
  let entries;
@@ -141,32 +182,64 @@ export async function startRemoteRun(runner, start) {
141
182
  if (result.exitCode !== 0)
142
183
  throw new RunnerCommandError("Starting the run", result);
143
184
  }
144
- export async function readRemoteRun(runner, runId) {
145
- const read = async (file) => {
146
- const result = await runner.shell.run(`cat -- ${shellQuote(file)} 2>/dev/null`, {
147
- timeoutMs: 30_000,
148
- });
149
- return result.exitCode === 0 ? result.stdout : undefined;
150
- };
151
- const status = parseJson(await read(`${RUNNER_PROJECT}/.humanish/runs/${runId}/status.json`));
152
- const result = parseJson(await read(`${RUNNER_STATE}/result.json`));
153
- const err = await runner.shell.run(`tail -c 2000 ${RUNNER_STATE}/watch.err 2>/dev/null`, {
154
- timeoutMs: 30_000,
155
- });
156
- const alive = await runner.shell.run("pgrep -f '[h]umanish watch' >/dev/null", {
157
- timeoutMs: 30_000,
185
+ /** How often the runner checks its run while the launcher waits on it. */
186
+ const WAIT_CHECK_SECONDS = 0.2;
187
+ const SECTION = "--- humanish ";
188
+ /**
189
+ * How the runner's run is going. The runner checks its run every 0.2 s for up to `waitMs` and
190
+ * answers as soon as it differs from `seen`: a new state, a result, or its process ending. One
191
+ * command answers each wait, so the launcher learns of a change within a check of it.
192
+ */
193
+ export async function waitForRemoteRun(runner, runId, seen, waitMs) {
194
+ const status = `$HOME/${RUNNER_PROJECT}/.humanish/runs/${runId}/status.json`;
195
+ const result = `$HOME/${RUNNER_STATE}/result.json`;
196
+ const checks = Math.max(1, Math.ceil(waitMs / 1000 / WAIT_CHECK_SECONDS));
197
+ // The first "state" in status.json is the run's own; its outcome and analysis come after it.
198
+ const script = [
199
+ `status="${status}" result="${result}"`,
200
+ `state() { grep -m1 -o '"state": *"[^"]*"' "$status" 2>/dev/null | sed 's/.*"\\([^"]*\\)"$/\\1/'; }`,
201
+ "alive() { if pgrep -f '[h]umanish watch' >/dev/null; then echo 1; else echo 0; fi; }",
202
+ `i=0; while [ "$i" -lt ${checks} ]; do`,
203
+ ' [ -s "$result" ] && break',
204
+ ` [ "$(state)" != ${shellQuote(seen.state ?? "")} ] && break`,
205
+ ` [ "$(alive)" != ${seen.alive ? 1 : 0} ] && break`,
206
+ ` sleep ${WAIT_CHECK_SECONDS}; i=$((i + 1))`,
207
+ "done",
208
+ `echo "${SECTION}alive $(alive)"`,
209
+ `echo "${SECTION}status"; cat "$status" 2>/dev/null`,
210
+ `echo; echo "${SECTION}result"; cat "$result" 2>/dev/null`,
211
+ `echo; echo "${SECTION}err"; tail -c 2000 "$HOME/${RUNNER_STATE}/watch.err" 2>/dev/null`,
212
+ "exit 0",
213
+ ].join("\n");
214
+ const answer = await runner.shell.run(`bash -c ${shellQuote(script)}`, {
215
+ timeoutMs: waitMs + 30_000,
158
216
  });
217
+ if (answer.exitCode !== 0)
218
+ throw new RunnerCommandError("Reading the run", answer);
219
+ const sections = new Map();
220
+ let name;
221
+ for (const line of answer.stdout.split("\n")) {
222
+ if (line.startsWith(SECTION)) {
223
+ const [key = "", ...rest] = line.slice(SECTION.length).split(" ");
224
+ name = key;
225
+ sections.set(key, rest.join(" "));
226
+ }
227
+ else if (name !== undefined)
228
+ sections.set(name, `${sections.get(name)}${line}\n`);
229
+ }
230
+ const state = parseJson(sections.get("status"));
231
+ const ended = parseJson(sections.get("result"));
159
232
  return {
160
- ...(typeof status?.state === "string" ? { state: status.state } : {}),
161
- ...(typeof result?.exitCode === "number"
162
- ? { result: { exitCode: result.exitCode, result: result.result } }
233
+ ...(typeof state?.state === "string" ? { state: state.state } : {}),
234
+ ...(typeof ended?.exitCode === "number"
235
+ ? { result: { exitCode: ended.exitCode, result: ended.result } }
163
236
  : {}),
164
- alive: alive.exitCode === 0,
165
- errTail: tailOf(err.stdout),
237
+ alive: sections.get("alive")?.trim() === "1",
238
+ errTail: tailOf(sections.get("err")),
166
239
  };
167
240
  }
168
241
  function parseJson(text) {
169
- if (text === undefined)
242
+ if (text === undefined || text.trim() === "")
170
243
  return undefined;
171
244
  try {
172
245
  const value = JSON.parse(text);
@@ -179,18 +252,28 @@ function parseJson(text) {
179
252
  }
180
253
  }
181
254
  /**
182
- * The regular files of the runner's run directory and their digests. Refuses a directory that holds
183
- * a link, which a run never writes.
255
+ * The regular files of the runner's run directory, with their sizes and digests. Refuses a
256
+ * directory that holds a link, which a run never writes.
184
257
  */
185
258
  export async function listRemoteRun(runner, runId) {
186
259
  const dir = `${RUNNER_PROJECT}/.humanish/runs/${runId}`;
187
- const links = await runOrFail(runner, "Listing the run", `cd ${shellQuote(dir)} && find . ! -type f ! -type d -print -quit`, 60_000);
188
- if (links.stdout.trim() !== "")
189
- throw new Error(`The runner's run directory holds something other than files and folders (${tailOf(links.stdout)}), so it is not copied.`);
190
- const listed = await runOrFail(runner, "Listing the run", `cd ${shellQuote(dir)} && find . -type f -print0 | xargs -0 -r sha256sum`, 300_000);
191
- return parseDigests(listed.stdout);
260
+ const listed = await runOrFail(runner, "Listing the run", `cd ${shellQuote(dir)} && find . ! -type f ! -type d -print -quit && echo "${SECTION}sizes" && find . -type f -printf '%s %p\\n' && echo "${SECTION}digests" && find . -type f -print0 | xargs -0 -r sha256sum`, 300_000);
261
+ const [links = "", rest = ""] = listed.stdout.split(`${SECTION}sizes\n`);
262
+ if (links.trim() !== "")
263
+ throw new Error(`The runner's run directory holds something other than files and folders (${tailOf(links)}), so it is not copied.`);
264
+ const [sizes = "", digests = ""] = rest.split(`${SECTION}digests\n`);
265
+ const size = new Map();
266
+ for (const line of sizes.split("\n")) {
267
+ const match = /^(\d+) \.\/(.+)$/.exec(line);
268
+ if (match !== null)
269
+ size.set(match[2], Number(match[1]));
270
+ }
271
+ return parseDigests(digests).flatMap((file) => {
272
+ const bytes = size.get(file.path);
273
+ return bytes === undefined ? [] : [{ ...file, size: bytes }];
274
+ });
192
275
  }
193
- /** sha256sum lines, `<hex> ./<path>`, as RemoteFiles with paths relative to the directory. */
276
+ /** sha256sum lines, `<hex> ./<path>`, with paths relative to the directory. */
194
277
  export function parseDigests(output) {
195
278
  const files = [];
196
279
  for (const line of output.split("\n")) {
@@ -224,22 +307,74 @@ export function parseSizes(output) {
224
307
  }
225
308
  /** The bytes of one file of the runner's run directory, as they are now. */
226
309
  export async function readRemoteBytes(runner, runId, file) {
310
+ return readRunnerBytes(runner, `${RUNNER_PROJECT}/.humanish/runs/${runId}/${file}`);
311
+ }
312
+ /** The bytes of a runner file, by its path under the sandbox user's home. */
313
+ async function readRunnerBytes(runner, file) {
227
314
  const read = runner.sandbox.files.read;
228
315
  if (read === undefined)
229
316
  throw new Error("This sandbox cannot read files back.");
230
- const stream = await read.call(runner.sandbox.files, `${RUNNER_PROJECT}/.humanish/runs/${runId}/${file}`, { format: "stream", requestTimeoutMs: 300_000 });
317
+ const stream = await read.call(runner.sandbox.files, file, {
318
+ format: "stream",
319
+ requestTimeoutMs: 300_000,
320
+ });
231
321
  const parts = [];
232
322
  for await (const chunk of stream)
233
323
  parts.push(chunk);
234
324
  return Buffer.concat(parts);
235
325
  }
236
- /** The bytes of one file of the runner's run directory, checked against its listed digest. */
237
- export async function readRemoteFile(runner, runId, file) {
238
- const bytes = await readRemoteBytes(runner, runId, file.path);
239
- const digest = createHash("sha256").update(bytes).digest("hex");
240
- if (digest !== file.sha256)
241
- throw new Error(`${file.path} changed or arrived damaged while it was copied from the runner.`);
242
- return bytes;
326
+ /** The most bytes one batch of a copy joins into one runner file. */
327
+ const COPY_BATCH_BYTES = 64 * 1024 * 1024;
328
+ /** The most files one batch of a copy names on its command line. */
329
+ const COPY_BATCH_FILES = 200;
330
+ /** The runner file a batch is joined into, under the sandbox user's home. */
331
+ const COPY_FILE = `${RUNNER_STATE}/copy.bin`;
332
+ /**
333
+ * Read `files` of the runner's run directory and hand each to `take`, checked against its listed
334
+ * digest. The files go in batches, each joined into one runner file and read back in one request,
335
+ * so a batch costs two round trips whatever its number of files.
336
+ */
337
+ export async function readRemoteFiles(runner, runId, files, take) {
338
+ const dir = `${RUNNER_PROJECT}/.humanish/runs/${runId}`;
339
+ for (const batch of batches(files)) {
340
+ // Each file's end offset in the joined file, so a file that changed size since the listing
341
+ // still splits where it ends; its digest then refuses it.
342
+ const joined = await runOrFail(runner, "Copying the run", `cd ${shellQuote(dir)} && : > "$HOME/${COPY_FILE}" && for f in ${batch.map((file) => shellQuote(file.path)).join(" ")}; do cat -- "$f" >> "$HOME/${COPY_FILE}" && wc -c < "$HOME/${COPY_FILE}" || exit 1; done`, 300_000);
343
+ const ends = joined.stdout.trim().split("\n").map(Number);
344
+ const bytes = await readRunnerBytes(runner, COPY_FILE);
345
+ if (ends.length !== batch.length || ends.at(-1) !== bytes.byteLength)
346
+ throw new Error("A batch of the run's files arrived incomplete from the runner.");
347
+ let start = 0;
348
+ for (const [index, file] of batch.entries()) {
349
+ const part = bytes.subarray(start, ends[index]);
350
+ start = ends[index];
351
+ if (createHash("sha256").update(part).digest("hex") !== file.sha256)
352
+ throw new Error(`${file.path} changed or arrived damaged while it was copied from the runner.`);
353
+ await take(file, part);
354
+ }
355
+ }
356
+ if (files.length > 0)
357
+ await runner.shell
358
+ .run(`rm -f "$HOME/${COPY_FILE}"`, { timeoutMs: 30_000 })
359
+ .catch(() => undefined);
360
+ }
361
+ function batches(files) {
362
+ const all = [];
363
+ let batch = [];
364
+ let bytes = 0;
365
+ for (const file of files) {
366
+ if (batch.length > 0 &&
367
+ (bytes + file.size > COPY_BATCH_BYTES || batch.length >= COPY_BATCH_FILES)) {
368
+ all.push(batch);
369
+ batch = [];
370
+ bytes = 0;
371
+ }
372
+ batch.push(file);
373
+ bytes += file.size;
374
+ }
375
+ if (batch.length > 0)
376
+ all.push(batch);
377
+ return all;
243
378
  }
244
379
  /**
245
380
  * Ask the runner's run to stop as a signal would, so it reclaims its participants and ends. The
@@ -5,10 +5,20 @@ import { participantInbox, planParticipantComms } from "./comms.js";
5
5
  import { participantBrowserStateObserver } from "./fidelity.js";
6
6
  import { networkHooks, planParticipantNetwork } from "./network.js";
7
7
  import { acquireParticipantDesktop, placeParticipantFiles, provisionParticipantSubject, verifyParticipantScreen, } from "./prepare.js";
8
- import { keepParticipantScreenOn, openParticipantSurface, startParticipantMedia, startParticipantStream, } from "./start.js";
8
+ import { keepParticipantScreenOn, openParticipantSurface, recordsSound, startParticipantRecording, startParticipantSpeech, startParticipantStream, } from "./start.js";
9
9
  import { desktopEvidenceOf, newParticipantState } from "./state.js";
10
10
  import { finishE2BDesktop } from "./teardown.js";
11
11
  import { participantServeUrl } from "../types.js";
12
+ /**
13
+ * Wait for every step, then throw the first failure in list order. No step is still running when
14
+ * preparation fails and teardown starts, and the failure named is the one the serial order named.
15
+ */
16
+ async function settleInOrder(steps) {
17
+ const settled = await Promise.allSettled(steps);
18
+ const failed = settled.find((step) => step.status === "rejected");
19
+ if (failed !== undefined)
20
+ throw failed.reason;
21
+ }
12
22
  export function createE2BParticipantDesktop(spec, deps, warnings) {
13
23
  const targetUrl = spec.planned.targetUrl ?? deps.appUrl;
14
24
  const state = newParticipantState(spec);
@@ -42,15 +52,45 @@ export function createE2BParticipantDesktop(spec, deps, warnings) {
42
52
  throw new Error("Participant desktop preparation can only start once, before the desktop is released.");
43
53
  preparationStarted = true;
44
54
  const desktop = await acquireParticipantDesktop(ctx, state);
45
- // The device claim is verified in the sandbox, and fails closed.
46
- await verifyParticipantScreen(ctx, state, desktop);
47
- await placeParticipantFiles(ctx, state, desktop);
48
- await provisionParticipantSubject(ctx, state, desktop);
49
- await startParticipantMedia(ctx, state, desktop);
50
- await openParticipantSurface(ctx, state, desktop);
55
+ const { kind } = ctx.deps.subject;
56
+ if (kind === "clone" || kind === "local-tree" || kind === "desktop-cli") {
57
+ // A subject provisioned in the sandbox takes minutes and may read the participant's files
58
+ // as it starts, so these steps run in order. The device claim is verified in the sandbox,
59
+ // and fails closed.
60
+ await verifyParticipantScreen(ctx, state, desktop);
61
+ await placeParticipantFiles(ctx, state, desktop);
62
+ await provisionParticipantSubject(ctx, state, desktop);
63
+ await startParticipantSurface(desktop);
64
+ }
65
+ else {
66
+ // An app that is only opened: Chrome takes seconds to start, so the screen check and the
67
+ // file copy run beside it. The files only need to land before the session.
68
+ await settleInOrder([
69
+ verifyParticipantScreen(ctx, state, desktop),
70
+ placeParticipantFiles(ctx, state, desktop),
71
+ startParticipantSurface(desktop),
72
+ ]);
73
+ }
51
74
  await keepParticipantScreenOn(ctx, desktop);
52
75
  prepared = true;
53
76
  }
77
+ /**
78
+ * Start speech and the recording, and open the browser or terminal. Chrome is launched in the
79
+ * sound env of speech or an audible recording, so those start before it; a silent recording
80
+ * starts beside it. Either way the recording is running before the session starts.
81
+ */
82
+ async function startParticipantSurface(desktop) {
83
+ await startParticipantSpeech(ctx, state, desktop);
84
+ if (recordsSound(ctx)) {
85
+ await startParticipantRecording(ctx, state, desktop);
86
+ await openParticipantSurface(ctx, state, desktop);
87
+ return;
88
+ }
89
+ await settleInOrder([
90
+ openParticipantSurface(ctx, state, desktop),
91
+ startParticipantRecording(ctx, state, desktop),
92
+ ]);
93
+ }
54
94
  async function openSession() {
55
95
  const { desktop, allocation } = state;
56
96
  if (!prepared || !desktop || !allocation || opened || finalization)
@@ -16,8 +16,10 @@ export declare function acquireParticipantDesktop(ctx: E2BParticipantContext, st
16
16
  export declare function verifyParticipantScreen(ctx: E2BParticipantContext, state: E2BParticipantState, desktop: E2BDesktopSandbox): Promise<void>;
17
17
  /**
18
18
  * Copy the participant's files (participants[].files) into its home folder and confirm each one's
19
- * size there, before the subject and the browser start. A file that does not land whole fails the
20
- * participant before its first turn. The state records each confirmed file as it lands.
19
+ * size there, before a provisioned subject starts and before the session. For an app that is only
20
+ * opened it runs while the browser starts: the browser reads the folder only when the participant
21
+ * opens a file chooser. A file that does not land whole fails the participant before its first
22
+ * turn. The state records each confirmed file as it lands.
21
23
  */
22
24
  export declare function placeParticipantFiles(ctx: E2BParticipantContext, state: E2BParticipantState, desktop: E2BDesktopSandbox): Promise<void>;
23
25
  /** Install a desktop CLI, or clone or unpack the subject and serve it, in the sandbox. */
@@ -158,8 +158,10 @@ export async function verifyParticipantScreen(ctx, state, desktop) {
158
158
  }
159
159
  /**
160
160
  * Copy the participant's files (participants[].files) into its home folder and confirm each one's
161
- * size there, before the subject and the browser start. A file that does not land whole fails the
162
- * participant before its first turn. The state records each confirmed file as it lands.
161
+ * size there, before a provisioned subject starts and before the session. For an app that is only
162
+ * opened it runs while the browser starts: the browser reads the folder only when the participant
163
+ * opens a file chooser. A file that does not land whole fails the participant before its first
164
+ * turn. The state records each confirmed file as it lands.
163
165
  */
164
166
  export async function placeParticipantFiles(ctx, state, desktop) {
165
167
  const { spec, deps } = ctx;
@@ -1,7 +1,15 @@
1
1
  import type { E2BDesktopSandbox } from "../../../substrates/e2b/sdk.js";
2
2
  import type { E2BParticipantContext, E2BParticipantState } from "./state.js";
3
- /** Start the declared speech worker and screen recording. A recording that cannot start is a warning. */
4
- export declare function startParticipantMedia(ctx: E2BParticipantContext, state: E2BParticipantState, desktop: E2BDesktopSandbox): Promise<void>;
3
+ /** Start the declared speech worker. Chrome is launched in its sound env, so it starts first. */
4
+ export declare function startParticipantSpeech(ctx: E2BParticipantContext, state: E2BParticipantState, desktop: E2BDesktopSandbox): Promise<void>;
5
+ /**
6
+ * Whether the study records the desktop's sound. An audible recording runs its own sound server
7
+ * unless speech already did, and Chrome is launched in that server's env, so the recording starts
8
+ * before Chrome. A silent recording needs nothing from Chrome and starts beside it.
9
+ */
10
+ export declare function recordsSound(ctx: E2BParticipantContext): boolean;
11
+ /** Start the declared screen recording. A recording that cannot start is a warning. */
12
+ export declare function startParticipantRecording(ctx: E2BParticipantContext, state: E2BParticipantState, desktop: E2BDesktopSandbox): Promise<void>;
5
13
  /**
6
14
  * Open what the participant sees: the browser at the target, with any declared camera and mobile
7
15
  * fidelity, or a terminal window on the desktop-cli route. A participant with a time zone sees the
@@ -11,10 +19,10 @@ export declare function openParticipantSurface(ctx: E2BParticipantContext, state
11
19
  /**
12
20
  * Turn the desktop's screen saver off, so the screen stays on while the participant waits without
13
21
  * input. The XFCE session sets the screen saver's timeout in its first seconds, and the last write
14
- * wins. Nothing waits for those writes: this runs after the browser or terminal has opened and the
15
- * desktop has waited DESKTOP_SETTLE_MS, and on 16 fresh desktops the last write came within 2.5 s
16
- * of a command sent as soon as the desktop was created. A screen saver that cannot be turned off
17
- * is a warning.
22
+ * wins. Nothing waits for those writes: this runs after a Chromium page has loaded, which needs
23
+ * DevTools to answer first (4.8 s or more after launch on live desktops), or after the fixed 8 s
24
+ * wait, and on 16 fresh desktops the last write came within 2.5 s of a command sent as soon as the
25
+ * desktop was created. A screen saver that cannot be turned off is a warning.
18
26
  */
19
27
  export declare function keepParticipantScreenOn(ctx: E2BParticipantContext, desktop: E2BDesktopSandbox): Promise<void>;
20
28
  /**