@runalabs/rill-cli 0.1.9 → 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.
- package/README.md +11 -11
- package/dist/cli.js +569 -278
- 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
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
9
|
-
import {
|
|
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
|
|
14759
|
-
import { join as
|
|
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/
|
|
14839
|
-
import {
|
|
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
|
|
14842
|
-
|
|
14843
|
-
|
|
14844
|
-
|
|
14845
|
-
const
|
|
14846
|
-
|
|
14847
|
-
|
|
14848
|
-
|
|
14849
|
-
|
|
14850
|
-
|
|
14851
|
-
|
|
14852
|
-
|
|
14853
|
-
|
|
14854
|
-
}
|
|
14855
|
-
|
|
14856
|
-
|
|
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
|
|
15191
|
+
await mkdir2(this.directory, { recursive: true, mode: 448 });
|
|
14872
15192
|
this.session.on("Page.screencastFrame", (payload) => {
|
|
14873
|
-
const
|
|
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 =
|
|
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
|
|
14893
|
-
|
|
14894
|
-
const
|
|
14895
|
-
|
|
14896
|
-
|
|
14897
|
-
|
|
14898
|
-
|
|
14899
|
-
|
|
14900
|
-
|
|
14901
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
14984
|
-
const diagnosticsPath =
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
15205
|
-
await
|
|
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
|
-
|
|
15214
|
-
|
|
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 (
|
|
15234
|
-
const outputDirectory =
|
|
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
|
|
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
|
-
|
|
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,147 +15648,16 @@ 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
|
-
|
|
15308
|
-
|
|
15309
|
-
|
|
15310
|
-
|
|
15311
|
-
|
|
15312
|
-
|
|
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);
|
|
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;
|
|
15336
15658
|
}
|
|
15337
|
-
|
|
15338
|
-
|
|
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);
|
|
15421
|
-
}
|
|
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([
|
|
@@ -15597,6 +15811,8 @@ var CLI_VERSION = packageJson.version;
|
|
|
15597
15811
|
|
|
15598
15812
|
// src/doctor.ts
|
|
15599
15813
|
var defaultDependencies = {
|
|
15814
|
+
encoding: "remote",
|
|
15815
|
+
encodingCapabilities: (apiUrl, token) => new ControlPlaneClient(apiUrl, token).encodingCapabilities(),
|
|
15600
15816
|
nodeVersion: process.version,
|
|
15601
15817
|
cliVersion: CLI_VERSION,
|
|
15602
15818
|
chromePath: findChromeExecutable(),
|
|
@@ -15651,7 +15867,18 @@ async function runDoctor(apiUrl, overrides = {}) {
|
|
|
15651
15867
|
}
|
|
15652
15868
|
}
|
|
15653
15869
|
checks.push(await executableCheck("chrome", dependencies.chromePath, ["--version"], dependencies.commandVersion, "Install Google Chrome or Chromium, or set RILL_CHROME_PATH to its executable."));
|
|
15654
|
-
|
|
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
|
+
}
|
|
15655
15882
|
return { schemaVersion: 1, status: checks.some((check2) => check2.status === "fail") ? "error" : "ok", apiUrl, checks, ...runFeedbackFields(feedbackEnabled) };
|
|
15656
15883
|
}
|
|
15657
15884
|
async function executableCheck(name, path, args, run2, recovery) {
|
|
@@ -15760,13 +15987,23 @@ function controlPlaneUrl(environmentUrl) {
|
|
|
15760
15987
|
}
|
|
15761
15988
|
|
|
15762
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
|
+
}
|
|
15763
16000
|
var program = new Command();
|
|
15764
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);
|
|
15765
16002
|
var activeTelemetry = null;
|
|
15766
16003
|
program.command("daemon", { hidden: true }).action(() => runDaemon());
|
|
15767
|
-
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) => {
|
|
15768
16005
|
const options = program.opts();
|
|
15769
|
-
const result = await runDoctor(options.apiUrl);
|
|
16006
|
+
const result = await runDoctor(options.apiUrl, { encoding: commandOptions.encoding });
|
|
15770
16007
|
emit(result, options.pretty);
|
|
15771
16008
|
if (result.status === "error") process.exitCode = 1;
|
|
15772
16009
|
});
|
|
@@ -15827,11 +16064,14 @@ for (const action of ["status", "cancel"]) cloudRun.command(action).argument("<r
|
|
|
15827
16064
|
if (options.json || options.pretty) emit(customerRun(run2), options.pretty);
|
|
15828
16065
|
else process.stdout.write(formatCloudRun(run2));
|
|
15829
16066
|
});
|
|
15830
|
-
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) => {
|
|
15831
16068
|
const options = program.opts();
|
|
15832
16069
|
const token = await loadCredential(options.apiUrl);
|
|
15833
16070
|
const client = new ControlPlaneClient(options.apiUrl, token);
|
|
15834
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.");
|
|
15835
16075
|
const width = Number(commandOptions.width);
|
|
15836
16076
|
const height = Number(commandOptions.height);
|
|
15837
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 });
|
|
@@ -15839,7 +16079,7 @@ record2.command("start").option("--url <url>").option("--title <title>").option(
|
|
|
15839
16079
|
const local = await startRecordingWithCompensation(remote, {
|
|
15840
16080
|
startLocal: async () => {
|
|
15841
16081
|
await ensureDaemon(process.argv[1]);
|
|
15842
|
-
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 });
|
|
15843
16083
|
},
|
|
15844
16084
|
cancelRemote: (recordingId, reason) => client.cancelRecording(recordingId, reason)
|
|
15845
16085
|
});
|
|
@@ -15867,10 +16107,19 @@ record2.command("stop").argument("<recording-id>").option("--no-wait").option("-
|
|
|
15867
16107
|
diagnosticsAvailable: true,
|
|
15868
16108
|
requestFeedback: commandOptions.feedback
|
|
15869
16109
|
}).catch(() => null);
|
|
15870
|
-
|
|
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);
|
|
15871
16120
|
const provisioned = await client.provisionVideo(recordingId, { sizeBytes: video.size, filename: basename(local.result.videoPath), uploadKind: "recorded_browser" });
|
|
15872
16121
|
await Promise.all([client.uploadVideo(provisioned.uploadUrl, local.result.videoPath), client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics)]);
|
|
15873
|
-
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);
|
|
15874
16123
|
if (remote) process.stderr.write("\n");
|
|
15875
16124
|
const result = {
|
|
15876
16125
|
schemaVersion: 1,
|
|
@@ -15883,7 +16132,40 @@ record2.command("stop").argument("<recording-id>").option("--no-wait").option("-
|
|
|
15883
16132
|
...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false)
|
|
15884
16133
|
};
|
|
15885
16134
|
emit(result, options.pretty);
|
|
15886
|
-
if (remote) await
|
|
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
|
+
}
|
|
15887
16169
|
});
|
|
15888
16170
|
var feedback = program.command("feedback").description("Submit a short Rill product review for a recorded run.");
|
|
15889
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) => {
|
|
@@ -15905,6 +16187,15 @@ record2.command("status").argument("<recording-id>").action(async (recordingId)
|
|
|
15905
16187
|
const options = program.opts();
|
|
15906
16188
|
const token = await loadCredential(options.apiUrl);
|
|
15907
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
|
+
}
|
|
15908
16199
|
const status = await remoteAwareRecordingStatus(recordingId, {
|
|
15909
16200
|
localStatus: async (id) => {
|
|
15910
16201
|
await ensureDaemon(process.argv[1]);
|
|
@@ -15924,7 +16215,7 @@ program.command("upload").argument("<video-file>").option("--title <title>").opt
|
|
|
15924
16215
|
const options = program.opts();
|
|
15925
16216
|
const token = await loadCredential(options.apiUrl);
|
|
15926
16217
|
const client = new ControlPlaneClient(options.apiUrl, token);
|
|
15927
|
-
const file2 = await
|
|
16218
|
+
const file2 = await stat3(videoFile);
|
|
15928
16219
|
const recording = await client.createRecording({ title: commandOptions.title ?? basename(videoFile), sourceUrl: null });
|
|
15929
16220
|
const upload = await client.provisionVideo(recording.recordingId, { sizeBytes: file2.size, filename: basename(videoFile), uploadKind: "existing_video" });
|
|
15930
16221
|
await client.uploadVideo(upload.uploadUrl, videoFile);
|
|
@@ -15940,19 +16231,19 @@ var drafts = program.command("drafts");
|
|
|
15940
16231
|
drafts.command("list").action(async () => {
|
|
15941
16232
|
const options = program.opts();
|
|
15942
16233
|
const root = process.env.RILL_TEMP ?? `${process.env.TMPDIR ?? "/tmp"}/rill-recordings`;
|
|
15943
|
-
const names = await
|
|
16234
|
+
const names = await readdir2(root).catch(() => []);
|
|
15944
16235
|
emit({ schemaVersion: 1, drafts: names }, options.pretty);
|
|
15945
16236
|
});
|
|
15946
16237
|
drafts.command("purge").action(async () => {
|
|
15947
16238
|
const options = program.opts();
|
|
15948
16239
|
const root = process.env.RILL_TEMP ?? `${process.env.TMPDIR ?? "/tmp"}/rill-recordings`;
|
|
15949
|
-
const names = await
|
|
16240
|
+
const names = await readdir2(root).catch(() => []);
|
|
15950
16241
|
let purged = 0;
|
|
15951
16242
|
for (const name of names) {
|
|
15952
16243
|
const path = `${root}/${name}`;
|
|
15953
|
-
const metadata = await
|
|
16244
|
+
const metadata = await stat3(path);
|
|
15954
16245
|
if (Date.now() - metadata.mtimeMs > 24 * 60 * 60 * 1e3) {
|
|
15955
|
-
await
|
|
16246
|
+
await rm4(path, { recursive: true, force: true });
|
|
15956
16247
|
purged += 1;
|
|
15957
16248
|
}
|
|
15958
16249
|
}
|