@runalabs/rill-cli 0.1.8 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +11 -11
  2. package/dist/cli.js +577 -279
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -25,14 +25,7 @@ The following local-recording instructions remain available through `record star
25
25
 
26
26
  ## Install and verify
27
27
 
28
- Browser recording runs on macOS and Linux. Install Node.js 22+, Google Chrome or Chromium, and ffmpeg on `PATH`.
29
-
30
- ```sh
31
- # macOS
32
- brew install ffmpeg
33
- # Debian / Ubuntu
34
- sudo apt install ffmpeg
35
- ```
28
+ Browser recording runs on macOS and Linux. Install Node.js 22+ and Google Chrome or Chromium. Rill encodes recordings remotely by default; local FFmpeg is optional. Chrome stays on your machine and can access localhost. Raw frames upload after recording stops.
36
29
 
37
30
  Then install the scoped package:
38
31
 
@@ -43,7 +36,7 @@ rill login
43
36
  rill doctor
44
37
  ```
45
38
 
46
- Do not use `npx rill`: the unscoped package belongs to a different product. Doctor checks Node, API reachability, CLI compatibility, authentication, Chrome, and ffmpeg. Resolve failed checks before starting a run.
39
+ Do not use `npx rill`: the unscoped package belongs to a different product. Doctor checks Node, API reachability, CLI compatibility, authentication, Chrome, and remote encoding availability. Resolve failed checks before starting a run.
47
40
 
48
41
  ## Authenticate
49
42
 
@@ -67,7 +60,7 @@ rill record stop <recording-id>
67
60
  rill inspect https://userill.dev/s/<share-secret>
68
61
  ```
69
62
 
70
- Start returns `recordingId`, `cdpUrl`, `status`, `bodyCaptureEnabled`, and `maxDurationSeconds`. Stop finalizes capture, uploads video and diagnostics, and waits for playback. Confirm `status: "ready"` and a non-null `shareUrl` before returning the evidence link. Local capture finishing does not establish remote readiness.
63
+ Start returns `recordingId`, `cdpUrl`, `status`, `bodyCaptureEnabled`, and `maxDurationSeconds`. Stop finalizes capture, uploads frames and diagnostics for remote encoding, and waits for playback. Confirm `status: "ready"` and a non-null `shareUrl` before returning the evidence link. Local capture finishing does not establish remote readiness.
71
64
 
72
65
  New browser recordings include a visible cursor and click pulses by default. In Rill Settings → Cursor appearance, workspace members can turn them off or choose an accent color. The CLI reads this preference when starting a recording; it is baked into the video and does not change existing recordings. Use an updated CLI and recorder daemon to capture the overlay.
73
66
 
@@ -100,6 +93,7 @@ Capture follows the first page selected at recording start. Keep the flow in tha
100
93
  | Option | Behavior |
101
94
  | --- | --- |
102
95
  | `--url <url>` | Open the target in a managed browser. |
96
+ | `--encoding remote` / `--encoding local` | Remote is the default; local requires FFmpeg. |
103
97
  | `--title <title>` | Name the run; defaults to a timestamped title. |
104
98
  | `--description <text>` | Describe what the run demonstrates. |
105
99
  | `--headed` | Show the managed browser; default is headless. |
@@ -142,7 +136,7 @@ rill upload ./test-results/checkout.webm --title "Checkout test"
142
136
 
143
137
  Upload WebM or MP4 evidence. The command waits for readiness and returns the recording ID and share URL. Existing uploads report `diagnosticsAvailable: false`; they do not import Playwright traces or reconstruct diagnostic events.
144
138
 
145
- Both `upload` and `record stop` accept `--no-wait`. This still waits for upload but skips playback processing: the result is `status: "processing"` with `shareUrl: null`. Check the recording in the dashboard before sharing.
139
+ Both `upload` and `record stop` accept `--no-wait`. This waits for upload acceptance, then returns while processing continues. Remote recordings may report `queued` or `encoding` before `processing` and `ready`. Use `rill record status <recording-id>` to retrieve progress and the eventual share link.
146
140
 
147
141
  ## Command reference
148
142
 
@@ -269,3 +263,9 @@ Substitute your API URL if you configured another environment. These commands cl
269
263
  Use `--json` for a machine-readable result (`--pretty` also enables JSON formatting).
270
264
  Provider token usage is retained internally and omitted from customer output.
271
265
  `rill run status <run-id>` and `rill run cancel <run-id>` support the same output options.
266
+
267
+ ### Remote encoding
268
+
269
+ Since 0.1.10, `record start` uses remote encoding by default. Chrome stays local, including access to localhost; FFmpeg is required only for `record start --encoding local`. For local encoding, install FFmpeg with `brew install ffmpeg` on macOS or `sudo apt install ffmpeg` on Debian/Ubuntu, then run `doctor --encoding local`. Remote encoding adds no charge and consumes no cloud-run credits; existing recording allowances still apply. Captures are limited to ten minutes and 1 GiB of raw frames, with three pending jobs per workspace. Busy recordings can reach the byte limit sooner.
270
+
271
+ `record stop` uploads frames and waits for playback. `record stop ID --no-wait` returns after upload acceptance; processing continues if the terminal closes. Use `record status ID` to retrieve progress and the share link, or `record retry ID` to resume an interrupted upload or retry encoding without repeating browser actions. Failed raw captures expire after 24 hours. Remote encoding uses no cloud-run credits and adds no charge.
package/dist/cli.js CHANGED
@@ -5,80 +5,9 @@ var __export = (target, all) => {
5
5
  __defProp(target, name, { get: all[name], enumerable: true });
6
6
  };
7
7
 
8
- // src/cli.ts
9
- import { spawn as spawn6 } from "child_process";
10
- import { Command } from "commander";
11
- import { hostname as hostname3 } from "os";
12
- import { basename, dirname as dirname2 } from "path";
13
- import { readdir, rm as rm2, stat as stat2 } from "fs/promises";
14
-
15
- // ../recorder/src/client.ts
16
- import { request } from "http";
17
- import { spawn } from "child_process";
18
-
19
- // ../recorder/src/paths.ts
20
- import { homedir, tmpdir } from "os";
8
+ // src/remote-encoding.ts
9
+ import { open, readFile as readFile2 } from "fs/promises";
21
10
  import { join } from "path";
22
- var rillDirectory = () => process.env.RILL_HOME ?? join(homedir(), ".rill");
23
- var daemonSocketPath = () => process.env.RILL_SOCKET ?? join(rillDirectory(), "recorder.sock");
24
- var daemonStatePath = () => join(rillDirectory(), "sessions.json");
25
- var recordingTempRoot = () => process.env.RILL_TEMP ?? join(tmpdir(), "rill-recordings");
26
-
27
- // ../recorder/src/client.ts
28
- async function ensureDaemon(cliEntryPath) {
29
- try {
30
- await callDaemon("GET", "/health");
31
- return;
32
- } catch {
33
- }
34
- const child = spawn(process.execPath, [cliEntryPath, "daemon"], { detached: true, stdio: "ignore", env: process.env });
35
- child.unref();
36
- const deadline = Date.now() + 1e4;
37
- while (Date.now() < deadline) {
38
- try {
39
- await callDaemon("GET", "/health");
40
- return;
41
- } catch {
42
- await new Promise((resolve) => setTimeout(resolve, 100));
43
- }
44
- }
45
- throw new Error("The local recorder service did not start.");
46
- }
47
- function startRecording(input) {
48
- return callDaemon("POST", "/sessions", input);
49
- }
50
- function stopRecording(recordingId) {
51
- return callDaemon("POST", `/sessions/${encodeURIComponent(recordingId)}/stop`, {});
52
- }
53
- function recordingStatus(recordingId) {
54
- return callDaemon("GET", `/sessions/${encodeURIComponent(recordingId)}`);
55
- }
56
- function callDaemon(method, path, body) {
57
- return new Promise((resolve, reject) => {
58
- const payload = body === void 0 ? void 0 : Buffer.from(JSON.stringify(body));
59
- const outgoing = request({ socketPath: daemonSocketPath(), method, path, headers: payload ? { "Content-Type": "application/json", "Content-Length": payload.length } : void 0 }, (incoming) => {
60
- const chunks = [];
61
- incoming.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
62
- incoming.on("end", () => {
63
- try {
64
- const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
65
- if ((incoming.statusCode ?? 500) >= 400) reject(new Error(value.error?.message ?? `Recorder returned ${incoming.statusCode}.`));
66
- else resolve(value);
67
- } catch (error51) {
68
- reject(error51);
69
- }
70
- });
71
- });
72
- outgoing.once("error", reject);
73
- if (payload) outgoing.write(payload);
74
- outgoing.end();
75
- });
76
- }
77
-
78
- // ../recorder/src/daemon.ts
79
- import { createServer as createServer2 } from "http";
80
- import { mkdir as mkdir3, readFile, unlink, writeFile as writeFile2 } from "fs/promises";
81
- import { dirname, join as join4 } from "path";
82
11
 
83
12
  // ../../node_modules/zod/v4/classic/external.js
84
13
  var external_exports = {};
@@ -14594,6 +14523,352 @@ function date4(params) {
14594
14523
  // ../../node_modules/zod/v4/classic/external.js
14595
14524
  config(en_default());
14596
14525
 
14526
+ // ../contracts/src/encoding.ts
14527
+ var ENCODING_PART_BYTES = 4 * 1024 * 1024;
14528
+ var ENCODING_MAX_BYTES = 1024 * 1024 * 1024;
14529
+ var frameManifestSchema = external_exports.object({
14530
+ version: external_exports.literal(1),
14531
+ durationSeconds: external_exports.number().positive().max(600),
14532
+ width: external_exports.number().int().min(320).max(3840),
14533
+ height: external_exports.number().int().min(240).max(2160),
14534
+ frames: external_exports.array(external_exports.object({ timestamp: external_exports.number().nonnegative().finite(), size: external_exports.number().int().positive().max(ENCODING_PART_BYTES) }).strict()).min(1).max(36e3)
14535
+ }).strict().superRefine((m, ctx) => {
14536
+ if (m.frames.reduce((n, f) => n + f.size, 0) > ENCODING_MAX_BYTES) ctx.addIssue({ code: "custom", message: "Frame upload exceeds the trial byte limit." });
14537
+ for (let i = 1; i < m.frames.length; i++) if (m.frames[i].timestamp < m.frames[i - 1].timestamp) ctx.addIssue({ code: "custom", message: "Frame timestamps must be ordered." });
14538
+ if (m.frames.at(-1).timestamp - m.frames[0].timestamp > m.durationSeconds + 1) ctx.addIssue({ code: "custom", message: "Frame timestamps exceed capture duration." });
14539
+ });
14540
+
14541
+ // src/control-plane.ts
14542
+ import { createReadStream } from "fs";
14543
+ import { readFile, stat } from "fs/promises";
14544
+ import { Upload } from "tus-js-client";
14545
+ var ControlPlaneError = class extends Error {
14546
+ constructor(code, message, recovery, details, httpStatus) {
14547
+ super(`${code}: ${message}${recovery ? `: ${recovery}` : ""}`);
14548
+ this.code = code;
14549
+ this.recovery = recovery;
14550
+ this.details = details;
14551
+ this.httpStatus = httpStatus;
14552
+ this.name = "ControlPlaneError";
14553
+ }
14554
+ };
14555
+ var ControlPlaneHttpError = class extends Error {
14556
+ constructor(httpStatus) {
14557
+ super(`Request failed (${httpStatus}).`);
14558
+ this.httpStatus = httpStatus;
14559
+ this.name = "ControlPlaneHttpError";
14560
+ }
14561
+ };
14562
+ var ControlPlaneClient = class {
14563
+ constructor(apiUrl, token) {
14564
+ this.apiUrl = apiUrl;
14565
+ this.token = token;
14566
+ }
14567
+ encodingCapabilities() {
14568
+ return this.request("/api/encoding/capabilities");
14569
+ }
14570
+ beginEncoding(id, manifest) {
14571
+ return this.request(`/api/encoding/${encodeURIComponent(id)}`, { method: "POST", body: JSON.stringify(manifest) });
14572
+ }
14573
+ encodingStatus(id) {
14574
+ return this.request(`/api/encoding/${encodeURIComponent(id)}`);
14575
+ }
14576
+ uploadEncodingPart(id, part, bytes) {
14577
+ return this.request(`/api/encoding/${encodeURIComponent(id)}/parts/${part}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(bytes.length) }, body: new Uint8Array(bytes), signal: AbortSignal.timeout(12e4) });
14578
+ }
14579
+ completeEncoding(id) {
14580
+ return this.request(`/api/encoding/${encodeURIComponent(id)}/complete`, { method: "POST" });
14581
+ }
14582
+ retryEncoding(id) {
14583
+ return this.request(`/api/encoding/${encodeURIComponent(id)}/retry`, { method: "POST" });
14584
+ }
14585
+ createCloudRun(input) {
14586
+ return this.request("/api/cloud/runs", { method: "POST", body: JSON.stringify(input) });
14587
+ }
14588
+ cloudRun(id) {
14589
+ return this.request(`/api/cloud/runs/${encodeURIComponent(id)}`);
14590
+ }
14591
+ cancelCloudRun(id) {
14592
+ return this.request(`/api/cloud/runs/${encodeURIComponent(id)}/cancel`, { method: "POST" });
14593
+ }
14594
+ health() {
14595
+ return this.request("/api/health", {}, false);
14596
+ }
14597
+ agentSession() {
14598
+ return this.request("/api/agent/session", { signal: AbortSignal.timeout(5e3) });
14599
+ }
14600
+ reportCliCommand(input) {
14601
+ return this.request("/api/analytics/cli-command", { method: "POST", body: JSON.stringify(input) });
14602
+ }
14603
+ createRecording(input) {
14604
+ return this.request("/api/recordings", { method: "POST", body: JSON.stringify(input) });
14605
+ }
14606
+ markRecordingStarted(recordingId, input) {
14607
+ return this.request(`/api/recordings/${recordingId}/local-started`, {
14608
+ method: "POST",
14609
+ body: JSON.stringify(input)
14610
+ });
14611
+ }
14612
+ markRecordingStopped(recordingId, input) {
14613
+ return this.request(`/api/recordings/${recordingId}/local-stopped`, {
14614
+ method: "POST",
14615
+ body: JSON.stringify(input)
14616
+ });
14617
+ }
14618
+ submitRecordingFeedback(recordingId, input) {
14619
+ return this.request(recordingId ? `/api/recordings/${recordingId}/feedback` : "/api/feedback", {
14620
+ method: "POST",
14621
+ body: JSON.stringify(input)
14622
+ });
14623
+ }
14624
+ provisionVideo(recordingId, input) {
14625
+ return this.request(`/api/recordings/${recordingId}/video-upload`, { method: "POST", body: JSON.stringify(input) });
14626
+ }
14627
+ recordingStatus(recordingId) {
14628
+ return this.request(`/api/recordings/${recordingId}`);
14629
+ }
14630
+ cancelRecording(recordingId, reason = "cancelled_by_agent") {
14631
+ return this.request(`/api/recordings/${recordingId}/cancel`, {
14632
+ method: "POST",
14633
+ body: JSON.stringify({ reason })
14634
+ });
14635
+ }
14636
+ async waitUntilPlayable(recordingId, timeoutMs = 10 * 6e4) {
14637
+ const deadline = Date.now() + timeoutMs;
14638
+ let intervalMs = 1e3;
14639
+ while (Date.now() < deadline) {
14640
+ const recording = await this.recordingStatus(recordingId);
14641
+ if (recording.status === "ready" && recording.shareUrl) {
14642
+ return { ...recording, shareUrl: new URL(recording.shareUrl, this.apiUrl).toString() };
14643
+ }
14644
+ if (recording.status === "failed") throw new Error(`processing_failed: ${recording.failure_message ?? "Cloudflare Stream could not process the recording."}`);
14645
+ process.stderr.write(`\rprocessing ${recording.status.padEnd(12)}`);
14646
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
14647
+ intervalMs = Math.min(5e3, Math.round(intervalMs * 1.4));
14648
+ }
14649
+ throw new Error("processing_failed: Timed out waiting for the recording to become playable. Run `rill record status <recording-id>` to continue checking.");
14650
+ }
14651
+ async uploadVideo(uploadUrl, filePath) {
14652
+ const file2 = await stat(filePath);
14653
+ if (uploadUrl.startsWith(this.apiUrl)) {
14654
+ const response = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Length": String(file2.size) }, body: createReadStream(filePath), duplex: "half" });
14655
+ if (!response.ok) throw new Error(`Video upload failed (${response.status}).`);
14656
+ return;
14657
+ }
14658
+ await new Promise((resolve, reject) => {
14659
+ const upload = new Upload(createReadStream(filePath), {
14660
+ uploadUrl,
14661
+ uploadSize: file2.size,
14662
+ retryDelays: [0, 1e3, 3e3, 5e3, 1e4],
14663
+ removeFingerprintOnSuccess: true,
14664
+ onError: reject,
14665
+ onSuccess: () => resolve(),
14666
+ onProgress: (sent, total) => process.stderr.write(`\ruploading ${Math.round(sent / total * 100)}%`)
14667
+ });
14668
+ upload.start();
14669
+ });
14670
+ process.stderr.write("\n");
14671
+ }
14672
+ async uploadDiagnostics(recordingId, filePath, summary) {
14673
+ const body = await readFile(filePath);
14674
+ return this.request(`/api/recordings/${recordingId}/diagnostics`, { method: "PUT", headers: { "Content-Encoding": "gzip", "Content-Type": "application/x-ndjson", "Content-Length": String(body.length), "X-Rill-Summary": JSON.stringify(summary) }, body });
14675
+ }
14676
+ createDeviceAuthorization(input) {
14677
+ return this.request("/api/device/authorizations", { method: "POST", body: JSON.stringify(input) }, false);
14678
+ }
14679
+ pollDeviceAuthorization(deviceCode) {
14680
+ return this.request("/api/device/token", { method: "POST", body: JSON.stringify({ deviceCode }) }, false);
14681
+ }
14682
+ inspect(shareUrl) {
14683
+ const url2 = new URL(shareUrl);
14684
+ const slug = url2.pathname.split("/").filter(Boolean).at(-1);
14685
+ if (!slug) throw new Error("The share URL does not contain a slug.");
14686
+ return fetch(`${url2.origin}/api/public/shares/${encodeURIComponent(slug)}/context`, { headers: { Accept: "application/json" } }).then(async (response) => {
14687
+ if (!response.ok) throw new Error(`Could not inspect the share link (${response.status}).`);
14688
+ return response.json();
14689
+ });
14690
+ }
14691
+ async request(path, init = {}, authenticated = true) {
14692
+ const headers = new Headers(init.headers);
14693
+ if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
14694
+ if (authenticated) {
14695
+ if (!this.token) throw new Error("authentication_required: Run `rill login` or set RILL_TOKEN.");
14696
+ headers.set("Authorization", `Bearer ${this.token}`);
14697
+ }
14698
+ const response = await fetch(`${this.apiUrl}${path}`, { ...init, headers });
14699
+ const value = await response.json().catch(() => null);
14700
+ if (!response.ok && value?.error?.code && value.error.message) {
14701
+ throw new ControlPlaneError(value.error.code, value.error.message, value.error.recovery, value.error.details, response.status);
14702
+ }
14703
+ if (!response.ok) throw new ControlPlaneHttpError(response.status);
14704
+ return value;
14705
+ }
14706
+ };
14707
+
14708
+ // src/remote-encoding.ts
14709
+ async function withFrameUploadRetry(upload, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
14710
+ for (let attempt = 0; ; attempt++) {
14711
+ try {
14712
+ return await upload();
14713
+ } catch (error51) {
14714
+ const status = error51 instanceof ControlPlaneError || error51 instanceof ControlPlaneHttpError ? error51.httpStatus : void 0;
14715
+ const transient = status !== void 0 ? status === 408 || status === 429 || status >= 500 : error51 instanceof TypeError || error51 instanceof DOMException && ["TimeoutError", "AbortError"].includes(error51.name);
14716
+ if (!transient || attempt >= 7) throw error51;
14717
+ const delay = Math.min(15e3, 1e3 * 2 ** attempt);
14718
+ process.stderr.write(`
14719
+ Frame upload interrupted; retrying in ${delay / 1e3}s.
14720
+ `);
14721
+ await sleep(delay);
14722
+ }
14723
+ }
14724
+ }
14725
+ async function submitFrameCapture(client, id, directory) {
14726
+ const manifest = frameManifestSchema.parse(JSON.parse(await readFile2(join(directory, "capture-manifest.json"), "utf8")));
14727
+ let status = await client.beginEncoding(id, manifest);
14728
+ if (status.status === "failed") status = await client.retryEncoding(id);
14729
+ if (status.status !== "uploading") {
14730
+ if (["expired", "cancelled"].includes(status.status)) throw new Error(`Capture ${status.status}; it cannot be retried.`);
14731
+ return status;
14732
+ }
14733
+ const file2 = await open(join(directory, "frames.bin"), "r");
14734
+ try {
14735
+ const total = manifest.frames.reduce((n, f) => n + f.size, 0);
14736
+ if ((await file2.stat()).size !== total) throw new Error("Capture bytes do not match the manifest.");
14737
+ const uploaded = new Set(status.uploadedParts);
14738
+ for (let part = 0; part < Math.ceil(total / ENCODING_PART_BYTES); part++) {
14739
+ if (uploaded.has(part)) continue;
14740
+ const bytes = Buffer.alloc(Math.min(ENCODING_PART_BYTES, total - part * ENCODING_PART_BYTES));
14741
+ let offset = 0;
14742
+ while (offset < bytes.length) {
14743
+ const r = await file2.read(bytes, offset, bytes.length - offset, part * ENCODING_PART_BYTES + offset);
14744
+ if (!r.bytesRead) throw new Error("Incomplete capture bytes.");
14745
+ offset += r.bytesRead;
14746
+ }
14747
+ await withFrameUploadRetry(() => client.uploadEncodingPart(id, part, bytes));
14748
+ process.stderr.write(`\ruploading frames ${Math.round(Math.min(total, (part + 1) * ENCODING_PART_BYTES) / total * 100)}%`);
14749
+ }
14750
+ } finally {
14751
+ await file2.close();
14752
+ }
14753
+ return client.completeEncoding(id);
14754
+ }
14755
+ async function waitForEncoding(client, id, timeoutMs = 30 * 6e4) {
14756
+ const deadline = Date.now() + timeoutMs;
14757
+ while (Date.now() < deadline) {
14758
+ const status = await client.encodingStatus(id);
14759
+ if (status.status === "ready") return { ...status, ...await client.recordingStatus(id), recordingId: id };
14760
+ if (["failed", "expired", "cancelled"].includes(status.status)) throw new Error(`${status.error ?? status.status}. Retry with rill record retry ${id}; capture expires ${status.expiresAt}.`);
14761
+ process.stderr.write(`\r${status.status.padEnd(20)}`);
14762
+ await new Promise((r) => setTimeout(r, 2e3));
14763
+ }
14764
+ throw new Error(`Encoding continues remotely. Check rill record status ${id}.`);
14765
+ }
14766
+
14767
+ // src/cli.ts
14768
+ import { spawn as spawn6 } from "child_process";
14769
+ import { Command } from "commander";
14770
+ import { hostname as hostname3 } from "os";
14771
+ import { basename, dirname as dirname2 } from "path";
14772
+ import { readdir as readdir2, rm as rm4, stat as stat3 } from "fs/promises";
14773
+
14774
+ // ../recorder/src/client.ts
14775
+ import { request } from "http";
14776
+ import { spawn } from "child_process";
14777
+
14778
+ // ../recorder/src/paths.ts
14779
+ import { homedir, tmpdir } from "os";
14780
+ import { join as join2 } from "path";
14781
+ var rillDirectory = () => process.env.RILL_HOME ?? join2(homedir(), ".rill");
14782
+ var daemonSocketPath = () => process.env.RILL_SOCKET ?? join2(rillDirectory(), "recorder-v2.sock");
14783
+ var daemonStatePath = () => join2(rillDirectory(), "sessions-v2.json");
14784
+ var recordingTempRoot = () => process.env.RILL_TEMP ?? join2(tmpdir(), "rill-recordings");
14785
+
14786
+ // ../recorder/src/client.ts
14787
+ async function ensureDaemon(cliEntryPath) {
14788
+ try {
14789
+ await callDaemon("GET", "/health");
14790
+ return;
14791
+ } catch {
14792
+ }
14793
+ const child = spawn(process.execPath, [cliEntryPath, "daemon"], { detached: true, stdio: "ignore", env: process.env });
14794
+ child.unref();
14795
+ const deadline = Date.now() + 1e4;
14796
+ while (Date.now() < deadline) {
14797
+ try {
14798
+ await callDaemon("GET", "/health");
14799
+ return;
14800
+ } catch {
14801
+ await new Promise((resolve) => setTimeout(resolve, 100));
14802
+ }
14803
+ }
14804
+ throw new Error("The local recorder service did not start.");
14805
+ }
14806
+ function startRecording(input) {
14807
+ return callDaemon("POST", "/sessions", input);
14808
+ }
14809
+ function stopRecording(recordingId) {
14810
+ return callDaemon("POST", `/sessions/${encodeURIComponent(recordingId)}/stop`, {});
14811
+ }
14812
+ function recordingStatus(recordingId) {
14813
+ return callDaemon("GET", `/sessions/${encodeURIComponent(recordingId)}`);
14814
+ }
14815
+ function callDaemon(method, path, body) {
14816
+ return new Promise((resolve, reject) => {
14817
+ const payload = body === void 0 ? void 0 : Buffer.from(JSON.stringify(body));
14818
+ const outgoing = request({ socketPath: daemonSocketPath(), method, path, headers: payload ? { "Content-Type": "application/json", "Content-Length": payload.length } : void 0 }, (incoming) => {
14819
+ const chunks = [];
14820
+ incoming.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
14821
+ incoming.on("end", () => {
14822
+ try {
14823
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
14824
+ if ((incoming.statusCode ?? 500) >= 400) reject(new Error(value.error?.message ?? `Recorder returned ${incoming.statusCode}.`));
14825
+ else resolve(value);
14826
+ } catch (error51) {
14827
+ reject(error51);
14828
+ }
14829
+ });
14830
+ });
14831
+ outgoing.once("error", reject);
14832
+ if (payload) outgoing.write(payload);
14833
+ outgoing.end();
14834
+ });
14835
+ }
14836
+
14837
+ // ../recorder/src/retention.ts
14838
+ import { readdir, readFile as readFile3, rm } from "fs/promises";
14839
+ import { join as join3 } from "path";
14840
+ async function purgeExpiredRemoteCaptures(root, now = Date.now()) {
14841
+ const removed = [];
14842
+ for (const id of await readdir(root).catch(() => [])) {
14843
+ if (!/^rec_[a-zA-Z0-9_-]+$/.test(id)) continue;
14844
+ const directory = join3(root, id);
14845
+ try {
14846
+ const marker = JSON.parse(await readFile3(join3(directory, "remote-capture.json"), "utf8"));
14847
+ if (typeof marker.expiresAt !== "number" || marker.expiresAt > now) continue;
14848
+ await rm(directory, { recursive: true, force: true });
14849
+ removed.push(id);
14850
+ } catch {
14851
+ }
14852
+ }
14853
+ return removed;
14854
+ }
14855
+ async function hasRetainedRemoteCaptures(root) {
14856
+ for (const id of await readdir(root).catch(() => [])) {
14857
+ if (!/^rec_[a-zA-Z0-9_-]+$/.test(id)) continue;
14858
+ try {
14859
+ const marker = JSON.parse(await readFile3(join3(root, id, "remote-capture.json"), "utf8"));
14860
+ if (typeof marker.expiresAt === "number" && Number.isFinite(marker.expiresAt)) return true;
14861
+ } catch {
14862
+ }
14863
+ }
14864
+ return false;
14865
+ }
14866
+
14867
+ // ../recorder/src/daemon.ts
14868
+ import { createServer as createServer2 } from "http";
14869
+ import { mkdir as mkdir4, readFile as readFile5, unlink, writeFile as writeFile4, rename, stat as stat2 } from "fs/promises";
14870
+ import { dirname, join as join7 } from "path";
14871
+
14597
14872
  // ../contracts/src/recording.ts
14598
14873
  var cursorAppearanceSchema = external_exports.object({
14599
14874
  enabled: external_exports.boolean().default(true),
@@ -14755,8 +15030,8 @@ async function attachCursorOverlay(page, cdp, input) {
14755
15030
  import { chromium } from "playwright-core";
14756
15031
  import { spawn as spawn3 } from "child_process";
14757
15032
  import { createServer } from "net";
14758
- import { mkdir as mkdir2, mkdtemp, rm } from "fs/promises";
14759
- import { join as join3 } from "path";
15033
+ import { mkdir as mkdir3, mkdtemp, rm as rm2, writeFile as writeFile3 } from "fs/promises";
15034
+ import { join as join6 } from "path";
14760
15035
  import { tmpdir as tmpdir2 } from "os";
14761
15036
 
14762
15037
  // ../recorder/src/bundle.ts
@@ -14835,27 +15110,70 @@ function redactJson(value, depth = 0) {
14835
15110
  return value;
14836
15111
  }
14837
15112
 
14838
- // ../recorder/src/screencast.ts
14839
- import { mkdir, writeFile } from "fs/promises";
15113
+ // ../recorder/src/encoding.ts
15114
+ import { open as open2, writeFile, mkdir } from "fs/promises";
14840
15115
  import { spawn as spawn2 } from "child_process";
14841
- import { join as join2 } from "path";
14842
- import { once as once2 } from "events";
14843
- function createFfconcatManifest(frames, expectedDurationSeconds) {
14844
- if (!frames.length) throw new Error("No video frames were captured.");
14845
- const orderedFrames = frames.toSorted((left, right) => left.timestamp - right.timestamp);
14846
- const firstTimestamp = orderedFrames[0].timestamp;
14847
- const lines = ["ffconcat version 1.0"];
14848
- for (let index = 0; index < orderedFrames.length; index += 1) {
14849
- const frame = orderedFrames[index];
14850
- const next = orderedFrames[index + 1];
14851
- const elapsedAtFrame = Math.max(0, frame.timestamp - firstTimestamp);
14852
- const duration3 = next ? Math.max(1 / 60, next.timestamp - frame.timestamp) : Math.max(1 / 60, expectedDurationSeconds - elapsedAtFrame);
14853
- lines.push(`file '${frame.path.replaceAll("'", "'\\''")}'`, `duration ${duration3.toFixed(6)}`);
14854
- }
14855
- lines.push(`file '${orderedFrames.at(-1).path.replaceAll("'", "'\\''")}'`);
14856
- return `${lines.join("\n")}
14857
- `;
15116
+ import { join as join4 } from "path";
15117
+ var active = 0;
15118
+ var waiting = [];
15119
+ function encodingConcurrency() {
15120
+ const n = Number(process.env.RILL_ENCODING_CONCURRENCY ?? 2);
15121
+ if (!Number.isInteger(n) || n < 1 || n > 8) throw new Error("Invalid RILL_ENCODING_CONCURRENCY");
15122
+ return n;
15123
+ }
15124
+ async function withEncodingSlot(work) {
15125
+ if (active >= encodingConcurrency()) await new Promise((resolve) => waiting.push(resolve));
15126
+ else active++;
15127
+ try {
15128
+ return await work();
15129
+ } finally {
15130
+ const next = waiting.shift();
15131
+ if (next) next();
15132
+ else active--;
15133
+ }
15134
+ }
15135
+ async function encodeFrames(directory, manifest, output, signal) {
15136
+ frameManifestSchema.parse(manifest);
15137
+ return withEncodingSlot(async () => {
15138
+ signal?.throwIfAborted();
15139
+ const lines = ["ffconcat version 1.0"];
15140
+ for (let i = 0; i < manifest.frames.length; i++) {
15141
+ const next = manifest.frames[i + 1];
15142
+ const duration3 = next ? next.timestamp - manifest.frames[i].timestamp : manifest.durationSeconds - (manifest.frames[i].timestamp - manifest.frames[0].timestamp);
15143
+ lines.push(`file 'frame-${String(i).padStart(6, "0")}.jpg'`, `duration ${Math.max(1 / 60, duration3).toFixed(6)}`);
15144
+ }
15145
+ lines.push(`file 'frame-${String(manifest.frames.length - 1).padStart(6, "0")}.jpg'`);
15146
+ await writeFile(join4(directory, "frames.ffconcat"), lines.join("\n") + "\n", { mode: 384 });
15147
+ const threads = Number(process.env.RILL_FFMPEG_THREADS ?? 1);
15148
+ if (!Number.isInteger(threads) || threads < 1 || threads > 16) throw new Error("Invalid RILL_FFMPEG_THREADS");
15149
+ await new Promise((resolve, reject) => {
15150
+ const child = spawn2(process.env.RILL_FFMPEG ?? "ffmpeg", ["-hide_banner", "-loglevel", "error", "-protocol_whitelist", "file", "-f", "concat", "-safe", "1", "-i", "frames.ffconcat", "-vsync", "vfr", "-pix_fmt", "yuv420p", "-c:v", "libvpx-vp9", "-deadline", "good", "-cpu-used", "4", "-threads", String(threads), "-b:v", "0", "-crf", "32", "-y", output], { cwd: directory, stdio: ["ignore", "ignore", "pipe"] });
15151
+ let stderr = "";
15152
+ child.stderr.on("data", (chunk) => {
15153
+ stderr = (stderr + String(chunk)).slice(-2e3);
15154
+ });
15155
+ const abort = () => child.kill("SIGKILL");
15156
+ const timer = setTimeout(abort, 20 * 6e4);
15157
+ signal?.addEventListener("abort", abort, { once: true });
15158
+ const clean = () => {
15159
+ clearTimeout(timer);
15160
+ signal?.removeEventListener("abort", abort);
15161
+ };
15162
+ child.once("error", (error51) => {
15163
+ clean();
15164
+ reject(error51);
15165
+ });
15166
+ child.once("exit", (code) => {
15167
+ clean();
15168
+ code === 0 ? resolve() : reject(new Error(`ffmpeg failed (${code}): ${stderr}`));
15169
+ });
15170
+ });
15171
+ });
14858
15172
  }
15173
+
15174
+ // ../recorder/src/screencast.ts
15175
+ import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile4, open as open3 } from "fs/promises";
15176
+ import { join as join5 } from "path";
14859
15177
  var ScreencastRecorder = class {
14860
15178
  constructor(session, directory, width, height) {
14861
15179
  this.session = session;
@@ -14867,13 +15185,24 @@ var ScreencastRecorder = class {
14867
15185
  pendingWrites = /* @__PURE__ */ new Set();
14868
15186
  started = false;
14869
15187
  frameCounter = 0;
15188
+ frameBytes = 0;
15189
+ captureError;
14870
15190
  async start() {
14871
- await mkdir(this.directory, { recursive: true, mode: 448 });
15191
+ await mkdir2(this.directory, { recursive: true, mode: 448 });
14872
15192
  this.session.on("Page.screencastFrame", (payload) => {
14873
- const path = join2(this.directory, `frame-${String(this.frameCounter).padStart(6, "0")}.jpg`);
15193
+ const bytes = Buffer.from(payload.data, "base64");
15194
+ if (this.frameCounter >= 36e3 || this.frameBytes + bytes.length > ENCODING_MAX_BYTES || bytes.length > ENCODING_PART_BYTES) {
15195
+ this.captureError = new Error("Capture exceeds the remote encoding frame or byte limit.");
15196
+ void this.session.send("Page.screencastFrameAck", { sessionId: payload.sessionId }).catch(() => void 0);
15197
+ return;
15198
+ }
15199
+ this.frameBytes += bytes.length;
15200
+ const path = join5(this.directory, `frame-${String(this.frameCounter).padStart(6, "0")}.jpg`);
14874
15201
  this.frameCounter += 1;
14875
- const write = writeFile(path, Buffer.from(payload.data, "base64"), { mode: 384 }).then(() => {
15202
+ const write = writeFile2(path, bytes, { mode: 384 }).then(() => {
14876
15203
  this.frames.push({ path, timestamp: payload.metadata.timestamp ?? Date.now() / 1e3 });
15204
+ }).catch((error51) => {
15205
+ this.captureError = error51 instanceof Error ? error51 : new Error(String(error51));
14877
15206
  }).finally(() => {
14878
15207
  this.pendingWrites.delete(write);
14879
15208
  void this.session.send("Page.screencastFrameAck", { sessionId: payload.sessionId }).catch(() => void 0);
@@ -14884,21 +15213,27 @@ var ScreencastRecorder = class {
14884
15213
  await this.session.send("Page.startScreencast", { format: "jpeg", quality: 82, maxWidth: this.width, maxHeight: this.height, everyNthFrame: 1 });
14885
15214
  this.started = true;
14886
15215
  }
14887
- async stop(outputPath, expectedDurationSeconds) {
15216
+ async stop(outputPath, expectedDurationSeconds, captureOnly = false) {
14888
15217
  if (this.started) await this.session.send("Page.stopScreencast").catch(() => void 0);
14889
15218
  await new Promise((resolve) => setTimeout(resolve, 120));
14890
15219
  await Promise.all(this.pendingWrites);
15220
+ if (this.captureError) throw this.captureError;
14891
15221
  if (!this.frames.length) throw new Error("No video frames were captured.");
14892
- const manifestPath = join2(this.directory, "frames.ffconcat");
14893
- await writeFile(manifestPath, createFfconcatManifest(this.frames, expectedDurationSeconds), { mode: 384 });
14894
- const ffmpeg = process.env.RILL_FFMPEG ?? "ffmpeg";
14895
- const processHandle = spawn2(ffmpeg, ["-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", manifestPath, "-vsync", "vfr", "-pix_fmt", "yuv420p", "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32", "-y", outputPath], { stdio: ["ignore", "ignore", "pipe"] });
14896
- let stderr = "";
14897
- processHandle.stderr.on("data", (chunk) => {
14898
- stderr += String(chunk);
14899
- });
14900
- const [exitCode] = await once2(processHandle, "exit");
14901
- if (exitCode !== 0) throw new Error(`ffmpeg could not finalize the recording: ${stderr.trim()}`);
15222
+ const ordered = this.frames.toSorted((a, b) => a.timestamp - b.timestamp);
15223
+ const frames = [];
15224
+ const bundle = await open3(join5(this.directory, "frames.bin"), "w", 384);
15225
+ try {
15226
+ for (const frame of ordered) {
15227
+ const bytes = await readFile4(frame.path);
15228
+ await bundle.writeFile(bytes);
15229
+ frames.push({ timestamp: frame.timestamp, size: bytes.length });
15230
+ }
15231
+ } finally {
15232
+ await bundle.close();
15233
+ }
15234
+ const manifest = frameManifestSchema.parse({ version: 1, durationSeconds: Math.min(600, expectedDurationSeconds), width: this.width, height: this.height, frames });
15235
+ await writeFile2(join5(this.directory, "capture-manifest.json"), JSON.stringify(manifest), { mode: 384 });
15236
+ if (!captureOnly) await encodeFrames(this.directory, manifest, outputPath);
14902
15237
  return { frameCount: this.frames.length, outputPath };
14903
15238
  }
14904
15239
  };
@@ -14932,6 +15267,7 @@ var RecordingSession = class _RecordingSession {
14932
15267
  bodyBytes = 0;
14933
15268
  truncated = false;
14934
15269
  constructor(options, runtime) {
15270
+ this.encoding = options.encoding ?? "local";
14935
15271
  this.recordingId = options.recordingId;
14936
15272
  this.cdpUrl = runtime.cdpUrl;
14937
15273
  this.browser = runtime.browser;
@@ -14945,10 +15281,11 @@ var RecordingSession = class _RecordingSession {
14945
15281
  static async start(options) {
14946
15282
  const width = options.width ?? 1280;
14947
15283
  const height = options.height ?? 720;
14948
- await mkdir2(options.outputDirectory, { recursive: true, mode: 448 });
15284
+ await mkdir3(options.outputDirectory, { recursive: true, mode: 448 });
15285
+ if (options.encoding === "remote") await writeFile3(join6(options.outputDirectory, "remote-capture.json"), JSON.stringify({ expiresAt: Date.now() + 864e5 }), { mode: 384 });
14949
15286
  const runtime = options.cdpUrl ? await connectAttached(options.cdpUrl, options.outputDirectory) : await launchManaged({ headed: options.headed ?? false, width, height, outputDirectory: options.outputDirectory, proxyServer: options.proxyServer });
14950
15287
  const cdpSession = await runtime.context.newCDPSession(runtime.page);
14951
- const screencast = new ScreencastRecorder(cdpSession, join3(options.outputDirectory, "frames"), width, height);
15288
+ const screencast = new ScreencastRecorder(cdpSession, join6(options.outputDirectory, "frames"), width, height);
14952
15289
  const session = new _RecordingSession(options, { ...runtime, screencast });
14953
15290
  try {
14954
15291
  await session.attachDiagnostics(options.captureNetworkBodies ?? false);
@@ -14980,8 +15317,8 @@ var RecordingSession = class _RecordingSession {
14980
15317
  if (this.maxTimer) clearTimeout(this.maxTimer);
14981
15318
  const durationSeconds = Math.max(0.1, (Date.now() - this.startedAt.getTime()) / 1e3);
14982
15319
  const outputDirectory = this.outputDirectory;
14983
- const videoPath = join3(outputDirectory, "recording.webm");
14984
- const diagnosticsPath = join3(outputDirectory, "diagnostics-v1.ndjson.gz");
15320
+ const videoPath = join6(outputDirectory, "recording.webm");
15321
+ const diagnosticsPath = join6(outputDirectory, "diagnostics-v1.ndjson.gz");
14985
15322
  const bundle = {
14986
15323
  schemaVersion: 1,
14987
15324
  capturePolicyVersion: "safe-default-v1",
@@ -14998,21 +15335,22 @@ var RecordingSession = class _RecordingSession {
14998
15335
  try {
14999
15336
  const safeBundle = JSON.parse(this.secretValues.reduce((text, secret) => text.split(JSON.stringify(secret).slice(1, -1)).join("[redacted]"), JSON.stringify(bundle)));
15000
15337
  await writeBundle(safeBundle, diagnosticsPath);
15001
- await this.screencast.stop(videoPath, durationSeconds);
15338
+ await this.screencast.stop(videoPath, durationSeconds, this.encoding === "remote");
15002
15339
  } finally {
15003
15340
  await this.removeCursorOverlay?.();
15004
15341
  if (this.browserProcess) {
15005
15342
  await this.browser.close().catch(() => void 0);
15006
15343
  await terminateBrowser(this.browserProcess);
15007
- if (this.profileDirectory) await rm(this.profileDirectory, { recursive: true, force: true }).catch(() => void 0);
15344
+ if (this.profileDirectory) await rm2(this.profileDirectory, { recursive: true, force: true }).catch(() => void 0);
15008
15345
  }
15009
15346
  }
15010
15347
  const consoleErrors = this.consoleEvents.filter((event) => event.severity === "error").length;
15011
15348
  const failedRequests = this.networkEvents.filter((event) => event.failureReason).length;
15012
15349
  const httpErrors = this.networkEvents.filter((event) => (event.status ?? 0) >= 400).length;
15013
- return { recordingId: this.recordingId, videoPath, diagnosticsPath, durationSeconds, stoppedReason: reason, diagnostics: { consoleErrors, failedRequests, httpErrors, truncated: this.truncated, bodyCaptureEnabled: this.captureNetworkBodies } };
15350
+ return { recordingId: this.recordingId, videoPath, ...this.encoding === "remote" ? { frameDirectory: join6(outputDirectory, "frames") } : {}, diagnosticsPath, durationSeconds, stoppedReason: reason, diagnostics: { consoleErrors, failedRequests, httpErrors, truncated: this.truncated, bodyCaptureEnabled: this.captureNetworkBodies } };
15014
15351
  }
15015
15352
  captureNetworkBodies = false;
15353
+ encoding = "local";
15016
15354
  outputDirectory = "";
15017
15355
  async attachDiagnostics(captureNetworkBodies) {
15018
15356
  this.captureNetworkBodies = captureNetworkBodies;
@@ -15121,7 +15459,7 @@ var RecordingSession = class _RecordingSession {
15121
15459
  };
15122
15460
  async function launchManaged(options) {
15123
15461
  const port = await getAvailablePort();
15124
- const profileDirectory = await mkdtemp(join3(tmpdir2(), "rill-chrome-"));
15462
+ const profileDirectory = await mkdtemp(join6(tmpdir2(), "rill-chrome-"));
15125
15463
  const executable = findChromeExecutable();
15126
15464
  const args = [
15127
15465
  `--remote-debugging-port=${port}`,
@@ -15150,7 +15488,7 @@ async function launchManaged(options) {
15150
15488
  return { cdpUrl, browser, context, page, browserProcess, profileDirectory };
15151
15489
  } catch (error51) {
15152
15490
  await terminateBrowser(browserProcess);
15153
- await rm(profileDirectory, { recursive: true, force: true }).catch(() => void 0);
15491
+ await rm2(profileDirectory, { recursive: true, force: true }).catch(() => void 0);
15154
15492
  throw error51;
15155
15493
  }
15156
15494
  }
@@ -15201,17 +15539,22 @@ var sessions = /* @__PURE__ */ new Map();
15201
15539
  var lastActivity = Date.now();
15202
15540
  async function runDaemon() {
15203
15541
  const socketPath = daemonSocketPath();
15204
- await mkdir3(dirname(socketPath), { recursive: true, mode: 448 });
15205
- await mkdir3(recordingTempRoot(), { recursive: true, mode: 448 });
15542
+ await mkdir4(dirname(socketPath), { recursive: true, mode: 448 });
15543
+ await mkdir4(recordingTempRoot(), { recursive: true, mode: 448 });
15206
15544
  await restoreRecoverableSessions();
15545
+ await purgeExpiredCaptures();
15207
15546
  await unlink(socketPath).catch((error51) => {
15208
15547
  if (error51.code !== "ENOENT") throw error51;
15209
15548
  });
15210
15549
  const server = createServer2((request2, response) => void route(request2, response));
15211
15550
  server.listen(socketPath, () => process.platform !== "win32" && import("fs/promises").then(({ chmod }) => chmod(socketPath, 384)));
15212
15551
  const idleTimer = setInterval(() => {
15213
- const hasActiveSession = [...sessions.values()].some((entry) => entry.state === "recording" || entry.state === "finalizing");
15214
- if (!hasActiveSession && Date.now() - lastActivity > 5 * 6e4) server.close(() => process.exit(0));
15552
+ void (async () => {
15553
+ await purgeExpiredCaptures();
15554
+ const hasActiveSession = [...sessions.values()].some((entry) => entry.state === "recording" || entry.state === "finalizing");
15555
+ const hasRetainedCapture = await hasRetainedRemoteCaptures(recordingTempRoot());
15556
+ if (!hasActiveSession && !hasRetainedCapture && Date.now() - lastActivity > 5 * 6e4) server.close(() => process.exit(0));
15557
+ })().catch(() => void 0);
15215
15558
  }, 3e4);
15216
15559
  idleTimer.unref();
15217
15560
  const close = () => {
@@ -15230,8 +15573,8 @@ async function route(request2, response) {
15230
15573
  if ([...sessions.values()].filter((entry) => entry.state === "recording").length >= 2) return json2(response, 429, { error: { code: "quota_exceeded", message: "This recorder already has two active sessions." } });
15231
15574
  try {
15232
15575
  const input = await readJson(request2);
15233
- if (!input.recordingId) return json2(response, 400, { error: { code: "validation_failed", message: "recordingId is required." } });
15234
- const outputDirectory = join4(recordingTempRoot(), input.recordingId);
15576
+ if (!/^rec_[a-zA-Z0-9_-]+$/.test(input.recordingId)) return json2(response, 400, { error: { code: "validation_failed", message: "recordingId is required." } });
15577
+ const outputDirectory = join7(recordingTempRoot(), input.recordingId);
15235
15578
  const session = await RecordingSession.start({ ...input, outputDirectory });
15236
15579
  const status = session.status();
15237
15580
  const entry = { session, base: { recordingId: status.recordingId, cdpUrl: status.cdpUrl, startedAt: status.startedAt }, state: "recording" };
@@ -15275,7 +15618,7 @@ function serializeEntry(entry) {
15275
15618
  }
15276
15619
  async function restoreRecoverableSessions() {
15277
15620
  try {
15278
- const value = JSON.parse(await readFile(daemonStatePath(), "utf8"));
15621
+ const value = JSON.parse(await readFile5(daemonStatePath(), "utf8"));
15279
15622
  for (const [id, entry] of Object.entries(value)) {
15280
15623
  if (entry.status !== "ready" && entry.status !== "failed") continue;
15281
15624
  sessions.set(id, { base: { recordingId: entry.recordingId, cdpUrl: entry.cdpUrl, startedAt: entry.startedAt }, state: entry.status, result: entry.result, error: entry.error });
@@ -15285,8 +15628,10 @@ async function restoreRecoverableSessions() {
15285
15628
  }
15286
15629
  async function persistState() {
15287
15630
  const state = Object.fromEntries([...sessions.entries()].map(([id, entry]) => [id, serializeEntry(entry)]));
15288
- await writeFile2(daemonStatePath(), `${JSON.stringify(state, null, 2)}
15631
+ const temporary = daemonStatePath() + "." + crypto.randomUUID() + ".tmp";
15632
+ await writeFile4(temporary, `${JSON.stringify(state, null, 2)}
15289
15633
  `, { mode: 384 });
15634
+ await rename(temporary, daemonStatePath());
15290
15635
  }
15291
15636
  async function readJson(request2) {
15292
15637
  const chunks = [];
@@ -15303,151 +15648,23 @@ function json2(response, status, value) {
15303
15648
  response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
15304
15649
  response.end(JSON.stringify(value));
15305
15650
  }
15306
-
15307
- // src/control-plane.ts
15308
- import { createReadStream } from "fs";
15309
- import { readFile as readFile2, stat } from "fs/promises";
15310
- import { Upload } from "tus-js-client";
15311
- var ControlPlaneError = class extends Error {
15312
- constructor(code, message, recovery, details) {
15313
- super(`${code}: ${message}${recovery ? `: ${recovery}` : ""}`);
15314
- this.code = code;
15315
- this.recovery = recovery;
15316
- this.details = details;
15317
- this.name = "ControlPlaneError";
15318
- }
15319
- };
15320
- var ControlPlaneClient = class {
15321
- constructor(apiUrl, token) {
15322
- this.apiUrl = apiUrl;
15323
- this.token = token;
15324
- }
15325
- createCloudRun(input) {
15326
- return this.request("/api/cloud/runs", { method: "POST", body: JSON.stringify(input) });
15327
- }
15328
- cloudRun(id) {
15329
- return this.request(`/api/cloud/runs/${encodeURIComponent(id)}`);
15330
- }
15331
- cancelCloudRun(id) {
15332
- return this.request(`/api/cloud/runs/${encodeURIComponent(id)}/cancel`, { method: "POST" });
15333
- }
15334
- health() {
15335
- return this.request("/api/health", {}, false);
15336
- }
15337
- agentSession() {
15338
- return this.request("/api/agent/session", { signal: AbortSignal.timeout(5e3) });
15339
- }
15340
- reportCliCommand(input) {
15341
- return this.request("/api/analytics/cli-command", { method: "POST", body: JSON.stringify(input) });
15342
- }
15343
- createRecording(input) {
15344
- return this.request("/api/recordings", { method: "POST", body: JSON.stringify(input) });
15345
- }
15346
- markRecordingStarted(recordingId, input) {
15347
- return this.request(`/api/recordings/${recordingId}/local-started`, {
15348
- method: "POST",
15349
- body: JSON.stringify(input)
15350
- });
15351
- }
15352
- markRecordingStopped(recordingId, input) {
15353
- return this.request(`/api/recordings/${recordingId}/local-stopped`, {
15354
- method: "POST",
15355
- body: JSON.stringify(input)
15356
- });
15357
- }
15358
- submitRecordingFeedback(recordingId, input) {
15359
- return this.request(recordingId ? `/api/recordings/${recordingId}/feedback` : "/api/feedback", {
15360
- method: "POST",
15361
- body: JSON.stringify(input)
15362
- });
15363
- }
15364
- provisionVideo(recordingId, input) {
15365
- return this.request(`/api/recordings/${recordingId}/video-upload`, { method: "POST", body: JSON.stringify(input) });
15366
- }
15367
- recordingStatus(recordingId) {
15368
- return this.request(`/api/recordings/${recordingId}`);
15369
- }
15370
- cancelRecording(recordingId, reason = "cancelled_by_agent") {
15371
- return this.request(`/api/recordings/${recordingId}/cancel`, {
15372
- method: "POST",
15373
- body: JSON.stringify({ reason })
15374
- });
15375
- }
15376
- async waitUntilPlayable(recordingId, timeoutMs = 10 * 6e4) {
15377
- const deadline = Date.now() + timeoutMs;
15378
- let intervalMs = 1e3;
15379
- while (Date.now() < deadline) {
15380
- const recording = await this.recordingStatus(recordingId);
15381
- if (recording.status === "ready" && recording.shareUrl) {
15382
- return { ...recording, shareUrl: new URL(recording.shareUrl, this.apiUrl).toString() };
15383
- }
15384
- if (recording.status === "failed") throw new Error(`processing_failed: ${recording.failure_message ?? "Cloudflare Stream could not process the recording."}`);
15385
- process.stderr.write(`\rprocessing ${recording.status.padEnd(12)}`);
15386
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
15387
- intervalMs = Math.min(5e3, Math.round(intervalMs * 1.4));
15388
- }
15389
- throw new Error("processing_failed: Timed out waiting for the recording to become playable. Run `rill record status <recording-id>` to continue checking.");
15390
- }
15391
- async uploadVideo(uploadUrl, filePath) {
15392
- const file2 = await stat(filePath);
15393
- if (uploadUrl.startsWith(this.apiUrl)) {
15394
- const response = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Length": String(file2.size) }, body: createReadStream(filePath), duplex: "half" });
15395
- if (!response.ok) throw new Error(`Video upload failed (${response.status}).`);
15396
- return;
15397
- }
15398
- await new Promise((resolve, reject) => {
15399
- const upload = new Upload(createReadStream(filePath), {
15400
- uploadUrl,
15401
- uploadSize: file2.size,
15402
- retryDelays: [0, 1e3, 3e3, 5e3, 1e4],
15403
- removeFingerprintOnSuccess: true,
15404
- onError: reject,
15405
- onSuccess: () => resolve(),
15406
- onProgress: (sent, total) => process.stderr.write(`\ruploading ${Math.round(sent / total * 100)}%`)
15407
- });
15408
- upload.start();
15409
- });
15410
- process.stderr.write("\n");
15411
- }
15412
- async uploadDiagnostics(recordingId, filePath, summary) {
15413
- const body = await readFile2(filePath);
15414
- return this.request(`/api/recordings/${recordingId}/diagnostics`, { method: "PUT", headers: { "Content-Encoding": "gzip", "Content-Type": "application/x-ndjson", "Content-Length": String(body.length), "X-Rill-Summary": JSON.stringify(summary) }, body });
15415
- }
15416
- createDeviceAuthorization(input) {
15417
- return this.request("/api/device/authorizations", { method: "POST", body: JSON.stringify(input) }, false);
15418
- }
15419
- pollDeviceAuthorization(deviceCode) {
15420
- return this.request("/api/device/token", { method: "POST", body: JSON.stringify({ deviceCode }) }, false);
15651
+ async function purgeExpiredCaptures() {
15652
+ const removed = await purgeExpiredRemoteCaptures(recordingTempRoot());
15653
+ for (const id of removed) sessions.delete(id);
15654
+ let changed = removed.length > 0;
15655
+ for (const [id, entry] of sessions) if (entry.result?.frameDirectory && !await stat2(entry.result.frameDirectory).catch(() => null)) {
15656
+ sessions.delete(id);
15657
+ changed = true;
15421
15658
  }
15422
- inspect(shareUrl) {
15423
- const url2 = new URL(shareUrl);
15424
- const slug = url2.pathname.split("/").filter(Boolean).at(-1);
15425
- if (!slug) throw new Error("The share URL does not contain a slug.");
15426
- return fetch(`${url2.origin}/api/public/shares/${encodeURIComponent(slug)}/context`, { headers: { Accept: "application/json" } }).then(async (response) => {
15427
- if (!response.ok) throw new Error(`Could not inspect the share link (${response.status}).`);
15428
- return response.json();
15429
- });
15430
- }
15431
- async request(path, init = {}, authenticated = true) {
15432
- const headers = new Headers(init.headers);
15433
- if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
15434
- if (authenticated) {
15435
- if (!this.token) throw new Error("authentication_required: Run `rill login` or set RILL_TOKEN.");
15436
- headers.set("Authorization", `Bearer ${this.token}`);
15437
- }
15438
- const response = await fetch(`${this.apiUrl}${path}`, { ...init, headers });
15439
- const value = await response.json().catch(() => null);
15440
- if (!response.ok && value?.error?.code && value.error.message) {
15441
- throw new ControlPlaneError(value.error.code, value.error.message, value.error.recovery, value.error.details);
15442
- }
15443
- if (!response.ok) throw new Error(`Request failed (${response.status}).`);
15444
- return value;
15445
- }
15446
- };
15659
+ if (changed) await persistState();
15660
+ }
15447
15661
 
15448
15662
  // src/analytics.ts
15449
15663
  var commandCategories = /* @__PURE__ */ new Map([
15450
15664
  ["doctor", "doctor"],
15665
+ ["record", "cloud_record"],
15666
+ ["run status", "cloud_run_status"],
15667
+ ["run cancel", "cloud_run_cancel"],
15451
15668
  ["record start", "record_start"],
15452
15669
  ["record stop", "record_stop"],
15453
15670
  ["record status", "record_status"],
@@ -15594,6 +15811,8 @@ var CLI_VERSION = packageJson.version;
15594
15811
 
15595
15812
  // src/doctor.ts
15596
15813
  var defaultDependencies = {
15814
+ encoding: "remote",
15815
+ encodingCapabilities: (apiUrl, token) => new ControlPlaneClient(apiUrl, token).encodingCapabilities(),
15597
15816
  nodeVersion: process.version,
15598
15817
  cliVersion: CLI_VERSION,
15599
15818
  chromePath: findChromeExecutable(),
@@ -15648,7 +15867,18 @@ async function runDoctor(apiUrl, overrides = {}) {
15648
15867
  }
15649
15868
  }
15650
15869
  checks.push(await executableCheck("chrome", dependencies.chromePath, ["--version"], dependencies.commandVersion, "Install Google Chrome or Chromium, or set RILL_CHROME_PATH to its executable."));
15651
- checks.push(await executableCheck("ffmpeg", dependencies.ffmpegPath, ["-version"], dependencies.commandVersion, process.platform === "darwin" ? "Install ffmpeg with `brew install ffmpeg`, or set RILL_FFMPEG." : "Install ffmpeg using the system package manager, or set RILL_FFMPEG."));
15870
+ if (dependencies.encoding === "local") {
15871
+ checks.push(await executableCheck("ffmpeg", dependencies.ffmpegPath, ["-version"], dependencies.commandVersion, process.platform === "darwin" ? "Install ffmpeg with `brew install ffmpeg`, or set RILL_FFMPEG." : "Install ffmpeg using the system package manager, or set RILL_FFMPEG."));
15872
+ } else {
15873
+ checks.push({ name: "ffmpeg", status: "skip", message: "Remote encoding does not require local FFmpeg." });
15874
+ try {
15875
+ const token = await dependencies.loadCredential(apiUrl);
15876
+ if (!token || !(await dependencies.encodingCapabilities(apiUrl, token)).enabled) throw new Error("Remote encoding is not enabled for this workspace.");
15877
+ checks.push({ name: "encoding", status: "pass", message: "Remote encoding is enabled." });
15878
+ } catch (error51) {
15879
+ checks.push({ name: "encoding", status: "fail", message: errorMessage(error51), recovery: "Use --encoding local until your workspace joins the private trial." });
15880
+ }
15881
+ }
15652
15882
  return { schemaVersion: 1, status: checks.some((check2) => check2.status === "fail") ? "error" : "ok", apiUrl, checks, ...runFeedbackFields(feedbackEnabled) };
15653
15883
  }
15654
15884
  async function executableCheck(name, path, args, run2, recovery) {
@@ -15757,13 +15987,23 @@ function controlPlaneUrl(environmentUrl) {
15757
15987
  }
15758
15988
 
15759
15989
  // src/cli.ts
15990
+ async function removeLocalCapture(id) {
15991
+ await ensureDaemon(process.argv[1]);
15992
+ const local = await recordingStatus(id).catch(() => null);
15993
+ if (local?.result?.frameDirectory) await rm4(dirname2(local.result.videoPath), { recursive: true, force: true });
15994
+ }
15995
+ function watchEncoding(apiUrl, id, token) {
15996
+ const child = spawn6(process.execPath, [process.argv[1], "--api-url", apiUrl, "encoding-watch", id], { detached: true, stdio: "ignore", env: { ...process.env, RILL_TOKEN: token } });
15997
+ child.on("error", () => void 0);
15998
+ child.unref();
15999
+ }
15760
16000
  var program = new Command();
15761
16001
  program.name("rill").description("Record, inspect, and share agent browser runs.").version(CLI_VERSION).option("--api-url <url>", "Rill control plane", controlPlaneUrl(process.env.RILL_API_URL)).option("--pretty", "Pretty-print JSON output", false).option("--json", "Output cloud run results as JSON", false);
15762
16002
  var activeTelemetry = null;
15763
16003
  program.command("daemon", { hidden: true }).action(() => runDaemon());
15764
- program.command("doctor").description("Validate Rill prerequisites before recording.").action(async () => {
16004
+ program.command("doctor").description("Validate Rill prerequisites before recording.").option("--encoding <mode>", "remote or local", "remote").action(async (commandOptions) => {
15765
16005
  const options = program.opts();
15766
- const result = await runDoctor(options.apiUrl);
16006
+ const result = await runDoctor(options.apiUrl, { encoding: commandOptions.encoding });
15767
16007
  emit(result, options.pretty);
15768
16008
  if (result.status === "error") process.exitCode = 1;
15769
16009
  });
@@ -15797,6 +16037,7 @@ var record2 = program.command("record").description("Run a cloud browser from a
15797
16037
  const options = program.opts();
15798
16038
  const token = await loadCredential(options.apiUrl) ?? await authorize(true);
15799
16039
  const client = new ControlPlaneClient(options.apiUrl, token);
16040
+ if (!activeTelemetry) await startCliTelemetry(client, "cloud_record");
15800
16041
  const id = commandOptions.id ?? crypto.randomUUID();
15801
16042
  process.stderr.write(`Run ID: ${id}
15802
16043
  `);
@@ -15823,11 +16064,14 @@ for (const action of ["status", "cancel"]) cloudRun.command(action).argument("<r
15823
16064
  if (options.json || options.pretty) emit(customerRun(run2), options.pretty);
15824
16065
  else process.stdout.write(formatCloudRun(run2));
15825
16066
  });
15826
- record2.command("start").option("--url <url>").option("--title <title>").option("--description <description>", "").option("--headed").option("--cdp-url <url>").option("--width <pixels>", "", "1280").option("--height <pixels>", "", "720").option("--max-duration <seconds>", "", "600").action(async (commandOptions) => {
16067
+ record2.command("start").option("--encoding <mode>", "remote or local", "remote").option("--url <url>").option("--title <title>").option("--description <description>", "").option("--headed").option("--cdp-url <url>").option("--width <pixels>", "", "1280").option("--height <pixels>", "", "720").option("--max-duration <seconds>", "", "600").action(async (commandOptions) => {
15827
16068
  const options = program.opts();
15828
16069
  const token = await loadCredential(options.apiUrl);
15829
16070
  const client = new ControlPlaneClient(options.apiUrl, token);
15830
16071
  const result = await withRunFeedback(() => client.agentSession(), async (rememberRecording) => {
16072
+ if (!["local", "remote"].includes(commandOptions.encoding)) throw new Error("Encoding must be remote or local.");
16073
+ if (commandOptions.encoding === "remote" && !(await client.encodingCapabilities()).enabled) throw new Error("Remote encoding is unavailable for this workspace. Use --encoding local.");
16074
+ if (!Number.isFinite(Number(commandOptions.maxDuration)) || Number(commandOptions.maxDuration) < 1 || Number(commandOptions.maxDuration) > 600) throw new Error("Recording duration must be between 1 and 600 seconds.");
15831
16075
  const width = Number(commandOptions.width);
15832
16076
  const height = Number(commandOptions.height);
15833
16077
  const remote = await client.createRecording({ title: commandOptions.title ?? `Browser recording \xB7 ${(/* @__PURE__ */ new Date()).toISOString()}`, description: commandOptions.description, sourceUrl: commandOptions.url ?? null, width, height });
@@ -15835,7 +16079,7 @@ record2.command("start").option("--url <url>").option("--title <title>").option(
15835
16079
  const local = await startRecordingWithCompensation(remote, {
15836
16080
  startLocal: async () => {
15837
16081
  await ensureDaemon(process.argv[1]);
15838
- return startRecording({ recordingId: remote.recordingId, url: commandOptions.url, cdpUrl: commandOptions.cdpUrl, headed: Boolean(commandOptions.headed), width, height, maxDurationSeconds: Number(commandOptions.maxDuration), captureNetworkBodies: remote.bodyCaptureEnabled, cursorAppearance: remote.cursorAppearance });
16082
+ return startRecording({ encoding: commandOptions.encoding, recordingId: remote.recordingId, url: commandOptions.url, cdpUrl: commandOptions.cdpUrl, headed: Boolean(commandOptions.headed), width, height, maxDurationSeconds: Number(commandOptions.maxDuration), captureNetworkBodies: remote.bodyCaptureEnabled, cursorAppearance: remote.cursorAppearance });
15839
16083
  },
15840
16084
  cancelRemote: (recordingId, reason) => client.cancelRecording(recordingId, reason)
15841
16085
  });
@@ -15863,10 +16107,19 @@ record2.command("stop").argument("<recording-id>").option("--no-wait").option("-
15863
16107
  diagnosticsAvailable: true,
15864
16108
  requestFeedback: commandOptions.feedback
15865
16109
  }).catch(() => null);
15866
- const video = await stat2(local.result.videoPath);
16110
+ if (local.result.frameDirectory) {
16111
+ await client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics);
16112
+ const accepted = await submitFrameCapture(client, recordingId, local.result.frameDirectory);
16113
+ const result2 = commandOptions.wait && record2.opts().wait !== false ? await waitForEncoding(client, recordingId) : accepted;
16114
+ emit({ schemaVersion: 1, ...result2, ...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false) }, options.pretty);
16115
+ if (result2.status === "ready") await rm4(dirname2(local.result.videoPath), { recursive: true, force: true });
16116
+ else watchEncoding(options.apiUrl, recordingId, token);
16117
+ return;
16118
+ }
16119
+ const video = await stat3(local.result.videoPath);
15867
16120
  const provisioned = await client.provisionVideo(recordingId, { sizeBytes: video.size, filename: basename(local.result.videoPath), uploadKind: "recorded_browser" });
15868
16121
  await Promise.all([client.uploadVideo(provisioned.uploadUrl, local.result.videoPath), client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics)]);
15869
- const remote = commandOptions.wait === false ? null : await client.waitUntilPlayable(recordingId);
16122
+ const remote = commandOptions.wait === false || record2.opts().wait === false ? null : await client.waitUntilPlayable(recordingId);
15870
16123
  if (remote) process.stderr.write("\n");
15871
16124
  const result = {
15872
16125
  schemaVersion: 1,
@@ -15879,7 +16132,40 @@ record2.command("stop").argument("<recording-id>").option("--no-wait").option("-
15879
16132
  ...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false)
15880
16133
  };
15881
16134
  emit(result, options.pretty);
15882
- if (remote) await rm2(dirname2(local.result.videoPath), { recursive: true, force: true });
16135
+ if (remote) await rm4(dirname2(local.result.videoPath), { recursive: true, force: true });
16136
+ });
16137
+ record2.command("retry").argument("<recording-id>").option("--no-wait").action(async (recordingId, commandOptions) => {
16138
+ const options = program.opts();
16139
+ const client = new ControlPlaneClient(options.apiUrl, await loadCredential(options.apiUrl));
16140
+ const current = await client.encodingStatus(recordingId).catch((error51) => {
16141
+ if (error51 instanceof ControlPlaneError && error51.code === "recording_not_found") return null;
16142
+ throw error51;
16143
+ });
16144
+ let accepted = current;
16145
+ if (!current || current.status === "uploading") {
16146
+ await ensureDaemon(process.argv[1]);
16147
+ const local = await recordingStatus(recordingId);
16148
+ if (!local.result?.frameDirectory) throw new Error("Local capture is unavailable.");
16149
+ await client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics);
16150
+ accepted = await submitFrameCapture(client, recordingId, local.result.frameDirectory);
16151
+ } else if (current.status === "failed") accepted = await client.retryEncoding(recordingId);
16152
+ if (!accepted) throw new Error("Encoding job is unavailable.");
16153
+ if (["expired", "cancelled"].includes(accepted.status)) throw new Error(`Recording ${accepted.status}.`);
16154
+ const result = commandOptions.wait && record2.opts().wait !== false ? await waitForEncoding(client, recordingId) : accepted;
16155
+ emit({ schemaVersion: 1, ...result }, options.pretty);
16156
+ if (result.status === "ready") await removeLocalCapture(recordingId);
16157
+ else watchEncoding(options.apiUrl, recordingId, await loadCredential(options.apiUrl));
16158
+ });
16159
+ program.command("encoding-watch", { hidden: true }).argument("<recording-id>").action(async (id) => {
16160
+ if (!/^rec_[a-zA-Z0-9_-]+$/.test(id)) throw new Error("Invalid recording ID");
16161
+ const options = program.opts();
16162
+ const client = new ControlPlaneClient(options.apiUrl, await loadCredential(options.apiUrl));
16163
+ const result = await waitForEncoding(client, id, 864e5);
16164
+ if (result.status === "ready") {
16165
+ await ensureDaemon(process.argv[1]);
16166
+ const local = await recordingStatus(id).catch(() => null);
16167
+ if (local?.result?.frameDirectory) await rm4(dirname2(local.result.videoPath), { recursive: true, force: true });
16168
+ }
15883
16169
  });
15884
16170
  var feedback = program.command("feedback").description("Submit a short Rill product review for a recorded run.");
15885
16171
  feedback.command("submit").argument("[recording-id]", "Omit only when setup failed before a recording was created").requiredOption("--outcome <outcome>", "succeeded, failed, or abandoned").option("--helped <text>", "What went well or helped during setup and the run", "").option("--friction <text>", "What created friction during the run", "").option("--improvement <text>", "Possible Rill improvements, prioritized by usefulness", "").action(async (recordingId, commandOptions) => {
@@ -15901,6 +16187,15 @@ record2.command("status").argument("<recording-id>").action(async (recordingId)
15901
16187
  const options = program.opts();
15902
16188
  const token = await loadCredential(options.apiUrl);
15903
16189
  const client = new ControlPlaneClient(options.apiUrl, token);
16190
+ try {
16191
+ const encoding = await client.encodingStatus(recordingId);
16192
+ const remote = encoding.status === "ready" ? await client.recordingStatus(recordingId) : {};
16193
+ emit({ schemaVersion: 1, ...encoding, ...remote, recordingId, source: "remote" }, options.pretty);
16194
+ if (encoding.status === "ready") await removeLocalCapture(recordingId);
16195
+ return;
16196
+ } catch (error51) {
16197
+ if (!(error51 instanceof Error) || !error51.message.includes("recording_not_found")) throw error51;
16198
+ }
15904
16199
  const status = await remoteAwareRecordingStatus(recordingId, {
15905
16200
  localStatus: async (id) => {
15906
16201
  await ensureDaemon(process.argv[1]);
@@ -15920,7 +16215,7 @@ program.command("upload").argument("<video-file>").option("--title <title>").opt
15920
16215
  const options = program.opts();
15921
16216
  const token = await loadCredential(options.apiUrl);
15922
16217
  const client = new ControlPlaneClient(options.apiUrl, token);
15923
- const file2 = await stat2(videoFile);
16218
+ const file2 = await stat3(videoFile);
15924
16219
  const recording = await client.createRecording({ title: commandOptions.title ?? basename(videoFile), sourceUrl: null });
15925
16220
  const upload = await client.provisionVideo(recording.recordingId, { sizeBytes: file2.size, filename: basename(videoFile), uploadKind: "existing_video" });
15926
16221
  await client.uploadVideo(upload.uploadUrl, videoFile);
@@ -15936,19 +16231,19 @@ var drafts = program.command("drafts");
15936
16231
  drafts.command("list").action(async () => {
15937
16232
  const options = program.opts();
15938
16233
  const root = process.env.RILL_TEMP ?? `${process.env.TMPDIR ?? "/tmp"}/rill-recordings`;
15939
- const names = await readdir(root).catch(() => []);
16234
+ const names = await readdir2(root).catch(() => []);
15940
16235
  emit({ schemaVersion: 1, drafts: names }, options.pretty);
15941
16236
  });
15942
16237
  drafts.command("purge").action(async () => {
15943
16238
  const options = program.opts();
15944
16239
  const root = process.env.RILL_TEMP ?? `${process.env.TMPDIR ?? "/tmp"}/rill-recordings`;
15945
- const names = await readdir(root).catch(() => []);
16240
+ const names = await readdir2(root).catch(() => []);
15946
16241
  let purged = 0;
15947
16242
  for (const name of names) {
15948
16243
  const path = `${root}/${name}`;
15949
- const metadata = await stat2(path);
16244
+ const metadata = await stat3(path);
15950
16245
  if (Date.now() - metadata.mtimeMs > 24 * 60 * 60 * 1e3) {
15951
- await rm2(path, { recursive: true, force: true });
16246
+ await rm4(path, { recursive: true, force: true });
15952
16247
  purged += 1;
15953
16248
  }
15954
16249
  }
@@ -15961,11 +16256,14 @@ program.hook("preAction", async (_command, actionCommand) => {
15961
16256
  const token = await loadCredential(options.apiUrl).catch(() => null);
15962
16257
  if (!token) return;
15963
16258
  const client = new ControlPlaneClient(options.apiUrl, token);
16259
+ await startCliTelemetry(client, command);
16260
+ });
16261
+ async function startCliTelemetry(client, command) {
15964
16262
  const session = await client.agentSession().catch(() => null);
15965
16263
  if (!session?.analyticsEnabled) return;
15966
16264
  activeTelemetry = { client, command, startedAt: Date.now() };
15967
16265
  await client.reportCliCommand({ phase: "started", command, cliVersion: CLI_VERSION, os: process.platform, arch: process.arch }).catch(() => void 0);
15968
- });
16266
+ }
15969
16267
  program.hook("postAction", async () => {
15970
16268
  await finishCliTelemetry(process.exitCode ? new Error("command_reported_failure: Command returned a failure status.") : null);
15971
16269
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runalabs/rill-cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Record, inspect, and share agent-controlled browser runs with Rill.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {