@runalabs/rill-cli 0.1.14 → 0.1.16
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 +49 -5
- package/dist/cli.js +421 -129
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,6 +25,10 @@ The following local-recording instructions remain available through `record star
|
|
|
25
25
|
|
|
26
26
|
## Install and verify
|
|
27
27
|
|
|
28
|
+
Check `node --version`, `node -p "process.platform"`, and `rill --version` before installing. Reuse an installed compatible CLI. Commands execute in the agent's environment, which may be a separate sandbox from the user's desktop. Chrome must be accessible there, and the agent must be able to attach over CDP; doctor does not verify browser-control capability.
|
|
29
|
+
|
|
30
|
+
If the CLI is missing, check npm with `npm ping --fetch-timeout=5000 --fetch-retries=0` before installation. See the [canonical skill](https://userill.dev/skill.md) for the bounded Rill connectivity check and Windows Terminal fallback. Report blocked network access instead of repeating installation attempts.
|
|
31
|
+
|
|
28
32
|
Browser recording runs on macOS, Linux, and Windows. 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.
|
|
29
33
|
|
|
30
34
|
Then install the scoped package:
|
|
@@ -42,7 +46,7 @@ Do not use `npx rill`: the unscoped package belongs to a different product. Doct
|
|
|
42
46
|
|
|
43
47
|
### Connect with pollable approval
|
|
44
48
|
|
|
45
|
-
`rill whoami`
|
|
49
|
+
`rill whoami` shows a short account summary without starting sign-in. Agents should use `rill whoami --json` for verified identity fields. `rill connect --email you@example.com` waits for human approval by default. For agents that cannot hold a foreground command open:
|
|
46
50
|
|
|
47
51
|
```sh
|
|
48
52
|
rill connect --email you@example.com --no-wait
|
|
@@ -124,7 +128,7 @@ rill record status <recording-id>
|
|
|
124
128
|
|
|
125
129
|
Replace the placeholders with start's actual values. This snippet verifies attachment only until you add your flow assertions. Keep the directory for retries and remove it when finished. Playwright documents the [connected-browser disconnect behavior](https://playwright.dev/docs/api/class-browser#browser-close).
|
|
126
130
|
|
|
127
|
-
Capture follows the
|
|
131
|
+
Capture follows the page selected at recording start. Keep the flow in that page: new tabs and popups do not receive complete video and diagnostic coverage. Keep the recorded page open until Rill stops.
|
|
128
132
|
|
|
129
133
|
### Recording options
|
|
130
134
|
|
|
@@ -135,12 +139,32 @@ Capture follows the first page selected at recording start. Keep the flow in tha
|
|
|
135
139
|
| `--title <title>` | Name the run; defaults to a timestamped title. |
|
|
136
140
|
| `--description <text>` | Describe what the run demonstrates. |
|
|
137
141
|
| `--headed` | Show the managed browser; default is headless. |
|
|
138
|
-
| `--cdp-url <url>` | Attach to an existing Chromium
|
|
142
|
+
| `--cdp-url <url>` | Attach to an existing Chromium CDP endpoint. Select a tab when several are open. |
|
|
143
|
+
| `--tab-id <id>` | Record the stable target ID returned by `record tabs`; requires `--cdp-url`. |
|
|
139
144
|
| `--width <pixels>` / `--height <pixels>` | Managed viewport; defaults to 1280 × 720. |
|
|
140
145
|
| `--max-duration <seconds>` | Automatically finalize local capture; defaults to 600. Still call `record stop` to upload. |
|
|
141
146
|
|
|
142
147
|
Rill closes browsers it launches during finalization. Attached browsers remain open.
|
|
143
148
|
|
|
149
|
+
### Record a tab you already use
|
|
150
|
+
|
|
151
|
+
Your Chromium browser must already expose a CDP endpoint; Rill cannot attach to an ordinary unexposed tab. Keep the endpoint local or otherwise access-controlled. This does not launch a second browser, switch tabs automatically or provide Safari/Firefox support.
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
rill record tabs --cdp-url http://127.0.0.1:9222
|
|
155
|
+
rill record start --cdp-url http://127.0.0.1:9222 --tab-id <tab-id> --title "Checkout review"
|
|
156
|
+
# Continue your flow in that exact tab, then finalize/upload:
|
|
157
|
+
rill record stop <recording-id>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Discovery requires no Rill sign-in and returns `tabId`, title and a sanitized URL. Query values, credentials and fragments are removed; embedded data/file URLs are omitted. Tab titles can still contain private content. Start returns the selected `tabId` at the top level and in `browserAttachment`. If only one page is open, `--tab-id` is optional. Several pages without a selection, or a closed/missing ID, fail instead of recording a different page. A tab ID remains tied to that tab across navigation; relist after closing it.
|
|
161
|
+
|
|
162
|
+
Keep automation on the selected target. For Playwright, inspect each page with `context.newCDPSession(page)`, call `Target.getTargetInfo`, match `targetInfo.targetId` to the returned `tabId`, and detach that inspection session. Do not assume `context.pages()[0]` is the recorded tab. `--url` deliberately navigates the selected tab when supplied; omit it to record the page as it is. Capture stays on this tab; popups and other tabs are not fully covered.
|
|
163
|
+
|
|
164
|
+
An older running recorder service cannot honor tab selection. Start detects this and fails before sending capture instructions. Finish active recordings first; the service exits after five idle minutes when it has no retained recoverable captures. For an immediate manual restart, inspect the local `/health` response over the configured Rill socket, verify its recorder PID, and terminate only that idle recorder process. Restarting it interrupts active captures; this release does not restart it automatically.
|
|
165
|
+
|
|
166
|
+
The CDP connection and disconnect behavior follow the [Playwright browser API](https://playwright.dev/docs/api/class-browser#browser-close).
|
|
167
|
+
|
|
144
168
|
## Set up a coding agent
|
|
145
169
|
|
|
146
170
|
Install and authenticate the CLI where the agent runs. No separate skill installation is required. Optionally save the following workflow in your agent's project instructions or skill setup; it is also available in the [public agent instructions](https://userill.dev/docs/cli#agents):
|
|
@@ -187,6 +211,7 @@ Use `rill --help` or `<command> --help` for supported flags.
|
|
|
187
211
|
| `rill connect --email <email> [--no-wait]` | Start or resume account approval. |
|
|
188
212
|
| `rill connect status <authorization-id>` | Poll a saved approval once; obey `retryAfterSeconds`. |
|
|
189
213
|
| `rill doctor` | Check prerequisites, API compatibility, and authentication. |
|
|
214
|
+
| `rill record tabs --cdp-url <url>` | List existing Chromium tabs without starting a recording. |
|
|
190
215
|
| `rill record start [options]` | Start a managed or attached browser recording. |
|
|
191
216
|
| `rill record stop <recording-id>` | Finalize and upload; supports `--no-wait` and `--no-feedback`. |
|
|
192
217
|
| `rill record status <recording-id>` | Read remote state first, then local capture for unsubmitted recordings; local completion is `captured`. |
|
|
@@ -200,7 +225,20 @@ Use `rill --help` or `<command> --help` for supported flags.
|
|
|
200
225
|
|
|
201
226
|
## Output and errors
|
|
202
227
|
|
|
203
|
-
|
|
228
|
+
`whoami` shows a concise human summary by default. Use `rill whoami --json` for the existing schema-versioned identity response, including email, workspace, scopes and expiry. Its exit codes are unchanged: missing credentials exit 1; connected and anonymous access exit 0.
|
|
229
|
+
|
|
230
|
+
Doctor and local recording commands retain JSON on stdout by default. Use `--human` for readable doctor, login, recording start/stop/status/retry summaries and errors. Doctor shows the command execution OS, named prerequisite checks, recovery guidance, and a pass/fail/warning count. Recording summaries show the ID, status, duration when available, share URL when returned, and the exact next command. Pending processing is distinct from a ready recording; a private recording may have no share URL.
|
|
231
|
+
|
|
232
|
+
Explicit `--json` or `--pretty` takes precedence over `--human`. Use `--pretty` for indented JSON. Human output uses restrained terminal color only on a TTY, respects `NO_COLOR` and `TERM=dumb`, and retains status symbols and text without color. Human errors go to stderr; JSON errors remain on stdout. Help and version commands print plain text. Feedback and attachment metadata remain available in JSON; selecting a human view does not disable workspace feedback.
|
|
233
|
+
|
|
234
|
+
```sh
|
|
235
|
+
rill whoami
|
|
236
|
+
rill whoami --json
|
|
237
|
+
rill --human doctor
|
|
238
|
+
rill --human record start --url https://example.com --title "Smoke test" --headed
|
|
239
|
+
rill --human record stop <recording-id>
|
|
240
|
+
rill --human record status <recording-id>
|
|
241
|
+
```
|
|
204
242
|
|
|
205
243
|
```sh
|
|
206
244
|
rill --pretty record status <recording-id>
|
|
@@ -336,10 +374,16 @@ Use `rill record list` to list accessible recordings and `rill inspect <recordin
|
|
|
336
374
|
|
|
337
375
|
### Check the connected account
|
|
338
376
|
|
|
339
|
-
Run `rill whoami`
|
|
377
|
+
Run `rill whoami` for a short human account summary, or `rill whoami --json` (also `rill --pretty whoami`) for structured identity fields. JSON includes status, account email when available, workspace, scopes, and access expiry. It uses RILL_TOKEN when set, otherwise the saved OS credential. Existing access can renew automatically; it never creates an identity or opens an approval flow. Missing credentials return `not_connected` in JSON and exit 1. Invalid or revoked credentials return an error.
|
|
340
378
|
|
|
341
379
|
### Capture time and upload progress (0.1.14)
|
|
342
380
|
|
|
343
381
|
`record start` and active local `record status` return `startedAt`, `stopsAt`, `maxDurationSeconds`, `elapsedSeconds`, and `remainingSeconds`. Use the remaining time to budget retries. After automatic capture stop, status reports `captured` with the stop/upload command; zero remaining time does not mean playback is ready. Fields may be absent for recovered drafts or an older daemon.
|
|
344
382
|
|
|
345
383
|
Remote frame uploads write accepted/total MiB, transfer speed and an approximate remaining transfer time to stderr. Estimates use this attempt's observed transfers, including retry delays, and exclude previously uploaded bytes. They do not estimate queue or encoding time. JSON stdout stays machine readable. Failed local captures offer an explicit cancellation command for their remote slot; local drafts are retained.
|
|
384
|
+
|
|
385
|
+
### Failed local capture recovery
|
|
386
|
+
|
|
387
|
+
If the local recorder reports a terminal failure without a finalized artifact, `rill record stop <id>` attempts to release its unused server reservation and preserves the capture error. The JSON error uses `local_capture_failed` with `details.reservationReleased`; a failed cleanup includes the status command for the selected API origin.
|
|
388
|
+
|
|
389
|
+
When `record start` is blocked by a recording concurrency slot, it checks the reported blocking recording in the local daemon. A confirmed failed capture with no artifact can release its own unused reservation, then retry creation once with the same request ID. Missing/active local state, retained artifacts, other credentials, Stream uploads, encoding jobs and quota-counted reservations are preserved. Older servers or unavailable networks leave the original quota error and manual recovery guidance intact. Daily upload/storage limits still apply. This change does not resume a failed capture, import missing diagnostics, or add Windows recording support.
|
package/dist/cli.js
CHANGED
|
@@ -116,12 +116,13 @@ function recordingNextStep(apiUrl, recordingId, status, options = {}) {
|
|
|
116
116
|
if (["uploading", "queued", "encoding", "processing", "finalizing"].includes(status)) return { nextCommand: cliCommand(apiUrl, "record", "status", recordingId), message: "Finalizing recordings hold a concurrency slot. Wait for ready; cancel only if abandoning the recording." };
|
|
117
117
|
return {};
|
|
118
118
|
}
|
|
119
|
-
function browserAttachment(cdpUrl) {
|
|
119
|
+
function browserAttachment(cdpUrl, tabId) {
|
|
120
120
|
return {
|
|
121
121
|
protocol: "cdp",
|
|
122
122
|
cdpUrl,
|
|
123
|
+
...tabId ? { tabId } : {},
|
|
123
124
|
playwrightMethod: "chromium.connectOverCDP",
|
|
124
|
-
instructions: "Attach to this endpoint and use the existing browser context. A browser tool must explicitly support CDP attachment; do not drive a separate browser. Disconnect the automation client without closing the recorded browser, then run record stop.",
|
|
125
|
+
instructions: (tabId ? "Record only the returned tabId. Match it using a page CDP session and Target.getTargetInfo; do not pick the first page. " : "") + "Attach to this endpoint and use the existing browser context. A browser tool must explicitly support CDP attachment; do not drive a separate browser. Disconnect the automation client without closing the recorded browser, then run record stop.",
|
|
125
126
|
shellFallback: "See the shell-only Playwright example in the Rill CLI README; install playwright-core in a temporary directory outside the project."
|
|
126
127
|
};
|
|
127
128
|
}
|
|
@@ -441,7 +442,7 @@ var ControlPlaneClient = class {
|
|
|
441
442
|
return this.request(`/api/cloud/runs/${encodeURIComponent(id)}/cancel`, { method: "POST" });
|
|
442
443
|
}
|
|
443
444
|
health() {
|
|
444
|
-
return this.request("/api/health", {}, false);
|
|
445
|
+
return this.request("/api/health", { signal: AbortSignal.timeout(5e3) }, false);
|
|
445
446
|
}
|
|
446
447
|
agentSession() {
|
|
447
448
|
return this.request("/api/agent/session", { signal: AbortSignal.timeout(5e3) });
|
|
@@ -452,6 +453,9 @@ var ControlPlaneClient = class {
|
|
|
452
453
|
createRecording(input) {
|
|
453
454
|
return this.request("/api/recordings", { method: "POST", body: JSON.stringify(input) });
|
|
454
455
|
}
|
|
456
|
+
failLocalCapture(recordingId) {
|
|
457
|
+
return this.request(`/api/recordings/${encodeURIComponent(recordingId)}/local-failed`, { method: "POST", body: "{}" });
|
|
458
|
+
}
|
|
455
459
|
markRecordingStarted(recordingId, input) {
|
|
456
460
|
return this.request(`/api/recordings/${recordingId}/local-started`, {
|
|
457
461
|
method: "POST",
|
|
@@ -15147,19 +15151,20 @@ var frameManifestSchema = external_exports.object({
|
|
|
15147
15151
|
});
|
|
15148
15152
|
|
|
15149
15153
|
// src/remote-encoding.ts
|
|
15150
|
-
async function withFrameUploadRetry(upload, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
15154
|
+
async function withFrameUploadRetry(upload, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), canRetry = () => true) {
|
|
15151
15155
|
for (let attempt = 0; ; attempt++) {
|
|
15152
15156
|
try {
|
|
15153
15157
|
return await upload();
|
|
15154
15158
|
} catch (error51) {
|
|
15155
15159
|
const status = error51 instanceof ControlPlaneError || error51 instanceof ControlPlaneHttpError ? error51.httpStatus : void 0;
|
|
15156
15160
|
const transient = status !== void 0 ? status === 408 || status === 429 || status >= 500 : error51 instanceof TypeError || error51 instanceof DOMException && ["TimeoutError", "AbortError"].includes(error51.name);
|
|
15157
|
-
if (!transient || attempt >= 7) throw error51;
|
|
15161
|
+
if (!transient || attempt >= 7 || !canRetry()) throw error51;
|
|
15158
15162
|
const delay = Math.min(15e3, 1e3 * 2 ** attempt);
|
|
15159
15163
|
process.stderr.write(`
|
|
15160
15164
|
Frame upload interrupted; retrying in ${delay / 1e3}s.
|
|
15161
15165
|
`);
|
|
15162
15166
|
await sleep(delay);
|
|
15167
|
+
if (!canRetry()) throw error51;
|
|
15163
15168
|
}
|
|
15164
15169
|
}
|
|
15165
15170
|
}
|
|
@@ -15175,8 +15180,12 @@ async function submitFrameCapture(client, id, directory) {
|
|
|
15175
15180
|
try {
|
|
15176
15181
|
const total = manifest.frames.reduce((n, f) => n + f.size, 0);
|
|
15177
15182
|
if ((await file2.stat()).size !== total) throw new Error("Capture bytes do not match the manifest.");
|
|
15178
|
-
const
|
|
15179
|
-
|
|
15183
|
+
const parts = Math.ceil(total / ENCODING_PART_BYTES);
|
|
15184
|
+
const uploaded = new Set(status.uploadedParts.filter((part) => Number.isInteger(part) && part >= 0 && part < parts));
|
|
15185
|
+
const missing = Array.from({ length: parts }, (_, part) => part).filter((part) => !uploaded.has(part));
|
|
15186
|
+
let next = 0, failed = false;
|
|
15187
|
+
let firstFailure;
|
|
15188
|
+
let acceptedBytes = [...uploaded].reduce((sum, part) => sum + Math.min(ENCODING_PART_BYTES, total - part * ENCODING_PART_BYTES), 0);
|
|
15180
15189
|
let transferredBytes = 0;
|
|
15181
15190
|
const startedAt = Date.now();
|
|
15182
15191
|
const progress = () => {
|
|
@@ -15186,20 +15195,34 @@ async function submitFrameCapture(client, id, directory) {
|
|
|
15186
15195
|
process.stderr.write(`\ruploading frames ${Math.floor(acceptedBytes / total * 100)}% \xB7 ${(acceptedBytes / 1048576).toFixed(2)}/${(total / 1048576).toFixed(2)} MiB${rate > 0 ? ` \xB7 ${(rate / 1048576).toFixed(2)} MiB/s` : ""}${remaining}`);
|
|
15187
15196
|
};
|
|
15188
15197
|
progress();
|
|
15189
|
-
|
|
15190
|
-
|
|
15191
|
-
|
|
15192
|
-
|
|
15193
|
-
|
|
15194
|
-
|
|
15195
|
-
|
|
15196
|
-
|
|
15197
|
-
|
|
15198
|
-
|
|
15199
|
-
|
|
15200
|
-
|
|
15201
|
-
|
|
15202
|
-
|
|
15198
|
+
const transfer = async () => {
|
|
15199
|
+
while (!failed) {
|
|
15200
|
+
const part = missing[next++];
|
|
15201
|
+
if (part === void 0) return;
|
|
15202
|
+
try {
|
|
15203
|
+
const bytes = Buffer.alloc(Math.min(ENCODING_PART_BYTES, total - part * ENCODING_PART_BYTES));
|
|
15204
|
+
let offset = 0;
|
|
15205
|
+
while (offset < bytes.length) {
|
|
15206
|
+
const r = await file2.read(bytes, offset, bytes.length - offset, part * ENCODING_PART_BYTES + offset);
|
|
15207
|
+
if (!r.bytesRead) throw new Error("Incomplete capture bytes.");
|
|
15208
|
+
offset += r.bytesRead;
|
|
15209
|
+
}
|
|
15210
|
+
if (failed) return;
|
|
15211
|
+
await withFrameUploadRetry(() => client.uploadEncodingPart(id, part, bytes), void 0, () => !failed);
|
|
15212
|
+
acceptedBytes += bytes.length;
|
|
15213
|
+
transferredBytes += bytes.length;
|
|
15214
|
+
progress();
|
|
15215
|
+
} catch (error51) {
|
|
15216
|
+
if (!failed) {
|
|
15217
|
+
firstFailure = error51;
|
|
15218
|
+
failed = true;
|
|
15219
|
+
}
|
|
15220
|
+
throw error51;
|
|
15221
|
+
}
|
|
15222
|
+
}
|
|
15223
|
+
};
|
|
15224
|
+
await Promise.allSettled(Array.from({ length: Math.min(3, missing.length) }, transfer));
|
|
15225
|
+
if (failed) throw firstFailure;
|
|
15203
15226
|
} finally {
|
|
15204
15227
|
await file2.close();
|
|
15205
15228
|
}
|
|
@@ -15221,7 +15244,7 @@ async function waitForEncoding(client, id, timeoutMs = 30 * 6e4) {
|
|
|
15221
15244
|
// src/cli.ts
|
|
15222
15245
|
import { spawn as spawn8 } from "child_process";
|
|
15223
15246
|
import { hostname as hostname4, tmpdir as tmpdir3 } from "os";
|
|
15224
|
-
import { Command } from "commander";
|
|
15247
|
+
import { Command, CommanderError } from "commander";
|
|
15225
15248
|
import { basename, dirname as dirname2, join as join11 } from "path";
|
|
15226
15249
|
import { readdir as readdir2, rm as rm7, stat as stat5 } from "fs/promises";
|
|
15227
15250
|
|
|
@@ -15327,11 +15350,118 @@ async function windowsIpcIdentity(directory = join5(rillDirectory(), "windows-ip
|
|
|
15327
15350
|
// ../recorder/src/client.ts
|
|
15328
15351
|
import { request as request2 } from "http";
|
|
15329
15352
|
import { spawn as spawn4 } from "child_process";
|
|
15330
|
-
|
|
15353
|
+
|
|
15354
|
+
// ../recorder/src/browser-tabs.ts
|
|
15355
|
+
import { chromium } from "playwright-core";
|
|
15356
|
+
|
|
15357
|
+
// ../recorder/src/redaction.ts
|
|
15358
|
+
var secretKeyPattern = /(?:pass(?:word)?|secret|token|api[_-]?key|authorization|cookie|session|access[_-]?token|refresh[_-]?token|client[_-]?secret)/i;
|
|
15359
|
+
var tokenValuePattern = /\b(?:sk|pk|ghp|github_pat|xox[baprs]|eyJ)[-_a-zA-Z0-9.]{16,}\b/g;
|
|
15360
|
+
function sanitizeUrl(input) {
|
|
15331
15361
|
try {
|
|
15332
|
-
|
|
15333
|
-
|
|
15362
|
+
const url2 = new URL(input);
|
|
15363
|
+
url2.username = "";
|
|
15364
|
+
url2.password = "";
|
|
15365
|
+
url2.hash = "";
|
|
15366
|
+
for (const key2 of [...url2.searchParams.keys()]) url2.searchParams.set(key2, "[REDACTED]");
|
|
15367
|
+
return url2.toString();
|
|
15334
15368
|
} catch {
|
|
15369
|
+
return "[invalid-url]";
|
|
15370
|
+
}
|
|
15371
|
+
}
|
|
15372
|
+
function redactText(value, maxLength = 8e3) {
|
|
15373
|
+
return value.replace(tokenValuePattern, "[REDACTED]").slice(0, maxLength);
|
|
15374
|
+
}
|
|
15375
|
+
function redactBody(value, contentType, maxBytes = 65536) {
|
|
15376
|
+
if (Buffer.byteLength(value) > maxBytes) return { body: void 0, omittedReason: "body_too_large" };
|
|
15377
|
+
if (!isAllowedBodyType(contentType)) return { body: void 0, omittedReason: "unsupported_content_type" };
|
|
15378
|
+
if (contentType?.includes("json")) {
|
|
15379
|
+
try {
|
|
15380
|
+
const parsed = JSON.parse(value);
|
|
15381
|
+
return { body: JSON.stringify(redactJson(parsed)) };
|
|
15382
|
+
} catch {
|
|
15383
|
+
return { body: redactText(value) };
|
|
15384
|
+
}
|
|
15385
|
+
}
|
|
15386
|
+
return { body: redactText(value) };
|
|
15387
|
+
}
|
|
15388
|
+
function isAllowedBodyType(contentType) {
|
|
15389
|
+
if (!contentType) return false;
|
|
15390
|
+
const normalized = contentType.toLowerCase();
|
|
15391
|
+
return normalized.includes("application/json") || normalized.includes("application/problem+json") || normalized.includes("application/graphql-response+json") || normalized.includes("text/plain") || normalized.includes("application/xml") || normalized.includes("text/xml");
|
|
15392
|
+
}
|
|
15393
|
+
function redactJson(value, depth = 0) {
|
|
15394
|
+
if (depth > 12) return "[TRUNCATED]";
|
|
15395
|
+
if (Array.isArray(value)) return value.slice(0, 100).map((item) => redactJson(item, depth + 1));
|
|
15396
|
+
if (value && typeof value === "object") {
|
|
15397
|
+
return Object.fromEntries(Object.entries(value).slice(0, 200).map(([key2, child]) => [key2, secretKeyPattern.test(key2) ? "[REDACTED]" : redactJson(child, depth + 1)]));
|
|
15398
|
+
}
|
|
15399
|
+
if (typeof value === "string") return redactText(value, 8e3);
|
|
15400
|
+
return value;
|
|
15401
|
+
}
|
|
15402
|
+
|
|
15403
|
+
// ../recorder/src/browser-tabs.ts
|
|
15404
|
+
function validateTabSelection(options) {
|
|
15405
|
+
if (options.tabId && !options.cdpUrl) throw new Error("validation_failed: --tab-id requires --cdp-url.");
|
|
15406
|
+
}
|
|
15407
|
+
async function tabs(browser) {
|
|
15408
|
+
const result = [];
|
|
15409
|
+
for (const context of browser.contexts()) for (const page of context.pages()) {
|
|
15410
|
+
if (page.isClosed()) continue;
|
|
15411
|
+
const session = await context.newCDPSession(page).catch(() => null);
|
|
15412
|
+
if (!session) {
|
|
15413
|
+
if (page.isClosed()) continue;
|
|
15414
|
+
throw new Error("browser_unavailable: Could not inspect an open tab.");
|
|
15415
|
+
}
|
|
15416
|
+
try {
|
|
15417
|
+
const { targetInfo } = await session.send("Target.getTargetInfo");
|
|
15418
|
+
result.push({ page, context, tab: { tabId: targetInfo.targetId, url: /^(?:https?:|about:)/i.test(page.url()) ? sanitizeUrl(page.url()) : `[${page.url().split(":", 1)[0]} URL omitted]`, title: redactText(targetInfo.title, 200) } });
|
|
15419
|
+
} catch (error51) {
|
|
15420
|
+
if (!page.isClosed()) throw error51;
|
|
15421
|
+
} finally {
|
|
15422
|
+
await session.detach().catch(() => void 0);
|
|
15423
|
+
}
|
|
15424
|
+
}
|
|
15425
|
+
return result;
|
|
15426
|
+
}
|
|
15427
|
+
async function listBrowserTabs(cdpUrl) {
|
|
15428
|
+
const browser = await chromium.connectOverCDP(cdpUrl, { noDefaults: true });
|
|
15429
|
+
try {
|
|
15430
|
+
return (await tabs(browser)).map((entry) => entry.tab);
|
|
15431
|
+
} finally {
|
|
15432
|
+
await browser.close();
|
|
15433
|
+
}
|
|
15434
|
+
}
|
|
15435
|
+
async function connectRecordingTab(cdpUrl, outputDirectory, tabId) {
|
|
15436
|
+
const browser = await chromium.connectOverCDP(cdpUrl, { artifactsDir: outputDirectory, isLocal: true, noDefaults: true });
|
|
15437
|
+
try {
|
|
15438
|
+
const available = await tabs(browser);
|
|
15439
|
+
const selected = tabId ? available.find((entry) => entry.tab.tabId === tabId) : available.length === 1 ? available[0] : void 0;
|
|
15440
|
+
if (!selected) {
|
|
15441
|
+
if (tabId) throw new Error("browser_unavailable: The selected tab is no longer available. Run record tabs --cdp-url <endpoint> and choose an open tab.");
|
|
15442
|
+
if (!available.length) throw new Error("browser_unavailable: The attached browser has no page target to record.");
|
|
15443
|
+
throw new Error("validation_failed: Several tabs are open. Run record tabs --cdp-url <endpoint>, then record start --cdp-url <endpoint> --tab-id <tab-id>.");
|
|
15444
|
+
}
|
|
15445
|
+
return { cdpUrl, browser, context: selected.context, page: selected.page, tabId: selected.tab.tabId };
|
|
15446
|
+
} catch (error51) {
|
|
15447
|
+
await browser.close().catch(() => void 0);
|
|
15448
|
+
throw error51;
|
|
15449
|
+
}
|
|
15450
|
+
}
|
|
15451
|
+
|
|
15452
|
+
// ../recorder/src/client.ts
|
|
15453
|
+
async function ensureDaemon(cliEntryPath, requireTabSelection = false) {
|
|
15454
|
+
const check2 = (health2) => {
|
|
15455
|
+
if (requireTabSelection && !health2.capabilities?.includes("tab-selection-v1")) throw new Error("browser_unavailable: The running Rill recorder predates tab selection. Stop active recordings, then restart the recorder service before attaching.");
|
|
15456
|
+
};
|
|
15457
|
+
let health;
|
|
15458
|
+
try {
|
|
15459
|
+
health = await callDaemon("GET", "/health");
|
|
15460
|
+
} catch {
|
|
15461
|
+
}
|
|
15462
|
+
if (health) {
|
|
15463
|
+
check2(health);
|
|
15464
|
+
return;
|
|
15335
15465
|
}
|
|
15336
15466
|
const child = spawn4(process.execPath, [cliEntryPath, "daemon"], { detached: true, stdio: "ignore", env: process.env, windowsHide: true });
|
|
15337
15467
|
let startupError;
|
|
@@ -15343,11 +15473,13 @@ async function ensureDaemon(cliEntryPath) {
|
|
|
15343
15473
|
while (Date.now() < deadline) {
|
|
15344
15474
|
if (startupError) throw startupError;
|
|
15345
15475
|
try {
|
|
15346
|
-
await callDaemon("GET", "/health");
|
|
15347
|
-
return;
|
|
15476
|
+
health = await callDaemon("GET", "/health");
|
|
15348
15477
|
} catch {
|
|
15349
15478
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
15479
|
+
continue;
|
|
15350
15480
|
}
|
|
15481
|
+
check2(health);
|
|
15482
|
+
return;
|
|
15351
15483
|
}
|
|
15352
15484
|
throw new Error("The local recorder service did not start.");
|
|
15353
15485
|
}
|
|
@@ -15424,6 +15556,55 @@ import { createServer as createServer2 } from "http";
|
|
|
15424
15556
|
import { mkdir as mkdir7, readFile as readFile7, unlink, writeFile as writeFile6, rename as rename2, stat as stat4, chmod } from "fs/promises";
|
|
15425
15557
|
import { dirname, join as join10 } from "path";
|
|
15426
15558
|
|
|
15559
|
+
// ../recorder/src/click-capture.ts
|
|
15560
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
15561
|
+
async function captureClicks(page, session, onClick) {
|
|
15562
|
+
const binding = `__rillClick_${randomUUID2().replaceAll("-", "")}`;
|
|
15563
|
+
const source = `(() => {
|
|
15564
|
+
const key = ${JSON.stringify(binding)};
|
|
15565
|
+
if (window[key + '_listener']) return;
|
|
15566
|
+
const listener = event => {
|
|
15567
|
+
const target = event.target;
|
|
15568
|
+
const label = target?.innerText?.trim().slice(0, 80) || target?.getAttribute?.('aria-label') || target?.tagName || 'Element';
|
|
15569
|
+
try { window[key](JSON.stringify({ x: event.clientX, y: event.clientY, label })); } catch {}
|
|
15570
|
+
};
|
|
15571
|
+
window[key + '_listener'] = listener;
|
|
15572
|
+
document.addEventListener('click', listener, { capture: true });
|
|
15573
|
+
})();`;
|
|
15574
|
+
const receive = (event) => {
|
|
15575
|
+
if (event.name !== binding) return;
|
|
15576
|
+
try {
|
|
15577
|
+
const value = JSON.parse(event.payload);
|
|
15578
|
+
if (typeof value.x === "number" && Number.isFinite(value.x) && typeof value.y === "number" && Number.isFinite(value.y) && typeof value.label === "string") onClick({ x: value.x, y: value.y, label: value.label.slice(0, 80) });
|
|
15579
|
+
} catch {
|
|
15580
|
+
}
|
|
15581
|
+
};
|
|
15582
|
+
let identifier;
|
|
15583
|
+
session.on("Runtime.bindingCalled", receive);
|
|
15584
|
+
const cleanup = async () => {
|
|
15585
|
+
session.off("Runtime.bindingCalled", receive);
|
|
15586
|
+
if (identifier) await session.send("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => void 0);
|
|
15587
|
+
await Promise.all(page.frames().map((frame) => frame.evaluate((key2) => {
|
|
15588
|
+
const target = window;
|
|
15589
|
+
const listener = target[key2 + "_listener"];
|
|
15590
|
+
if (typeof listener === "function") document.removeEventListener("click", listener, { capture: true });
|
|
15591
|
+
delete target[key2 + "_listener"];
|
|
15592
|
+
delete target[key2];
|
|
15593
|
+
}, binding).catch(() => void 0)));
|
|
15594
|
+
await session.send("Runtime.removeBinding", { name: binding }).catch(() => void 0);
|
|
15595
|
+
};
|
|
15596
|
+
try {
|
|
15597
|
+
await session.send("Runtime.enable");
|
|
15598
|
+
await session.send("Runtime.addBinding", { name: binding });
|
|
15599
|
+
({ identifier } = await session.send("Page.addScriptToEvaluateOnNewDocument", { source }));
|
|
15600
|
+
await Promise.all(page.frames().map((frame) => frame.evaluate(source)));
|
|
15601
|
+
return cleanup;
|
|
15602
|
+
} catch (error51) {
|
|
15603
|
+
await cleanup();
|
|
15604
|
+
throw error51;
|
|
15605
|
+
}
|
|
15606
|
+
}
|
|
15607
|
+
|
|
15427
15608
|
// ../contracts/src/cloud-blocklist.ts
|
|
15428
15609
|
var blockedCloudDomains = [
|
|
15429
15610
|
// Torrent indexes and unofficial software-download destinations.
|
|
@@ -15764,7 +15945,7 @@ async function attachCursorOverlay(page, cdp, input) {
|
|
|
15764
15945
|
}
|
|
15765
15946
|
|
|
15766
15947
|
// ../recorder/src/session.ts
|
|
15767
|
-
import { chromium } from "playwright-core";
|
|
15948
|
+
import { chromium as chromium2 } from "playwright-core";
|
|
15768
15949
|
import { spawn as spawn6 } from "child_process";
|
|
15769
15950
|
import { createServer } from "net";
|
|
15770
15951
|
import { mkdir as mkdir6, mkdtemp, rm as rm5, writeFile as writeFile5 } from "fs/promises";
|
|
@@ -15806,52 +15987,6 @@ function findChromeExecutable(platform = process.platform, env = process.env, ex
|
|
|
15806
15987
|
return candidates.find(exists) ?? "google-chrome";
|
|
15807
15988
|
}
|
|
15808
15989
|
|
|
15809
|
-
// ../recorder/src/redaction.ts
|
|
15810
|
-
var secretKeyPattern = /(?:pass(?:word)?|secret|token|api[_-]?key|authorization|cookie|session|access[_-]?token|refresh[_-]?token|client[_-]?secret)/i;
|
|
15811
|
-
var tokenValuePattern = /\b(?:sk|pk|ghp|github_pat|xox[baprs]|eyJ)[-_a-zA-Z0-9.]{16,}\b/g;
|
|
15812
|
-
function sanitizeUrl(input) {
|
|
15813
|
-
try {
|
|
15814
|
-
const url2 = new URL(input);
|
|
15815
|
-
url2.username = "";
|
|
15816
|
-
url2.password = "";
|
|
15817
|
-
url2.hash = "";
|
|
15818
|
-
for (const key2 of [...url2.searchParams.keys()]) url2.searchParams.set(key2, "[REDACTED]");
|
|
15819
|
-
return url2.toString();
|
|
15820
|
-
} catch {
|
|
15821
|
-
return "[invalid-url]";
|
|
15822
|
-
}
|
|
15823
|
-
}
|
|
15824
|
-
function redactText(value, maxLength = 8e3) {
|
|
15825
|
-
return value.replace(tokenValuePattern, "[REDACTED]").slice(0, maxLength);
|
|
15826
|
-
}
|
|
15827
|
-
function redactBody(value, contentType, maxBytes = 65536) {
|
|
15828
|
-
if (Buffer.byteLength(value) > maxBytes) return { body: void 0, omittedReason: "body_too_large" };
|
|
15829
|
-
if (!isAllowedBodyType(contentType)) return { body: void 0, omittedReason: "unsupported_content_type" };
|
|
15830
|
-
if (contentType?.includes("json")) {
|
|
15831
|
-
try {
|
|
15832
|
-
const parsed = JSON.parse(value);
|
|
15833
|
-
return { body: JSON.stringify(redactJson(parsed)) };
|
|
15834
|
-
} catch {
|
|
15835
|
-
return { body: redactText(value) };
|
|
15836
|
-
}
|
|
15837
|
-
}
|
|
15838
|
-
return { body: redactText(value) };
|
|
15839
|
-
}
|
|
15840
|
-
function isAllowedBodyType(contentType) {
|
|
15841
|
-
if (!contentType) return false;
|
|
15842
|
-
const normalized = contentType.toLowerCase();
|
|
15843
|
-
return normalized.includes("application/json") || normalized.includes("application/problem+json") || normalized.includes("application/graphql-response+json") || normalized.includes("text/plain") || normalized.includes("application/xml") || normalized.includes("text/xml");
|
|
15844
|
-
}
|
|
15845
|
-
function redactJson(value, depth = 0) {
|
|
15846
|
-
if (depth > 12) return "[TRUNCATED]";
|
|
15847
|
-
if (Array.isArray(value)) return value.slice(0, 100).map((item) => redactJson(item, depth + 1));
|
|
15848
|
-
if (value && typeof value === "object") {
|
|
15849
|
-
return Object.fromEntries(Object.entries(value).slice(0, 200).map(([key2, child]) => [key2, secretKeyPattern.test(key2) ? "[REDACTED]" : redactJson(child, depth + 1)]));
|
|
15850
|
-
}
|
|
15851
|
-
if (typeof value === "string") return redactText(value, 8e3);
|
|
15852
|
-
return value;
|
|
15853
|
-
}
|
|
15854
|
-
|
|
15855
15990
|
// ../recorder/src/encoding.ts
|
|
15856
15991
|
import { open as open2, writeFile as writeFile3, mkdir as mkdir4 } from "fs/promises";
|
|
15857
15992
|
import { spawn as spawn5 } from "child_process";
|
|
@@ -16009,6 +16144,7 @@ var RecordingSession = class _RecordingSession {
|
|
|
16009
16144
|
recordingId;
|
|
16010
16145
|
startedAt = /* @__PURE__ */ new Date();
|
|
16011
16146
|
cdpUrl;
|
|
16147
|
+
tabId;
|
|
16012
16148
|
browser;
|
|
16013
16149
|
context;
|
|
16014
16150
|
page;
|
|
@@ -16023,6 +16159,7 @@ var RecordingSession = class _RecordingSession {
|
|
|
16023
16159
|
stopPromise;
|
|
16024
16160
|
maxTimer;
|
|
16025
16161
|
maxDurationSeconds;
|
|
16162
|
+
removeClickCapture;
|
|
16026
16163
|
removeCursorOverlay;
|
|
16027
16164
|
diagnosticsPaused = false;
|
|
16028
16165
|
secretValues = [];
|
|
@@ -16039,6 +16176,7 @@ var RecordingSession = class _RecordingSession {
|
|
|
16039
16176
|
this.captureSignal = options.signal;
|
|
16040
16177
|
this.recordingId = options.recordingId;
|
|
16041
16178
|
this.cdpUrl = runtime.cdpUrl;
|
|
16179
|
+
this.tabId = runtime.tabId;
|
|
16042
16180
|
this.browser = runtime.browser;
|
|
16043
16181
|
this.context = runtime.context;
|
|
16044
16182
|
this.page = runtime.page;
|
|
@@ -16048,16 +16186,25 @@ var RecordingSession = class _RecordingSession {
|
|
|
16048
16186
|
this.outputDirectory = options.outputDirectory;
|
|
16049
16187
|
}
|
|
16050
16188
|
static async start(options) {
|
|
16189
|
+
validateTabSelection(options);
|
|
16051
16190
|
const width = options.width ?? 1280;
|
|
16052
16191
|
const height = options.height ?? 720;
|
|
16053
16192
|
await mkdir6(options.outputDirectory, { recursive: true, mode: 448 });
|
|
16054
16193
|
if (options.encoding === "remote") await writeFile5(join9(options.outputDirectory, "remote-capture.json"), JSON.stringify({ expiresAt: Date.now() + 864e5 }), { mode: 384 });
|
|
16055
|
-
const runtime = options.cdpUrl ? await
|
|
16056
|
-
|
|
16194
|
+
const runtime = options.cdpUrl ? await connectRecordingTab(options.cdpUrl, options.outputDirectory, options.tabId) : await launchManaged({ headed: options.headed ?? false, width, height, outputDirectory: options.outputDirectory, proxyServer: options.proxyServer, blockServiceWorkers: options.blockServiceWorkers });
|
|
16195
|
+
let cdpSession;
|
|
16196
|
+
try {
|
|
16197
|
+
cdpSession = await runtime.context.newCDPSession(runtime.page);
|
|
16198
|
+
} catch (error51) {
|
|
16199
|
+
await runtime.browser.close().catch(() => void 0);
|
|
16200
|
+
if ("browserProcess" in runtime && runtime.browserProcess) await terminateBrowser(runtime.browserProcess);
|
|
16201
|
+
if ("profileDirectory" in runtime && runtime.profileDirectory) await rm5(runtime.profileDirectory, { recursive: true, force: true }).catch(() => void 0);
|
|
16202
|
+
throw error51;
|
|
16203
|
+
}
|
|
16057
16204
|
const screencast = new ScreencastRecorder(cdpSession, join9(options.outputDirectory, "frames"), width, height);
|
|
16058
16205
|
const session = new _RecordingSession(options, { ...runtime, screencast });
|
|
16059
16206
|
try {
|
|
16060
|
-
await session.attachDiagnostics(options.captureNetworkBodies ?? false);
|
|
16207
|
+
await session.attachDiagnostics(options.captureNetworkBodies ?? false, cdpSession);
|
|
16061
16208
|
if (!options.hideCursor) session.removeCursorOverlay = await attachCursorOverlay(runtime.page, cdpSession, options.cursorAppearance);
|
|
16062
16209
|
await options.beforeCapture?.(runtime.page);
|
|
16063
16210
|
session.startedAt = /* @__PURE__ */ new Date();
|
|
@@ -16082,6 +16229,7 @@ var RecordingSession = class _RecordingSession {
|
|
|
16082
16229
|
return {
|
|
16083
16230
|
recordingId: this.recordingId,
|
|
16084
16231
|
cdpUrl: this.cdpUrl,
|
|
16232
|
+
...this.tabId ? { tabId: this.tabId } : {},
|
|
16085
16233
|
startedAt: this.startedAt.toISOString(),
|
|
16086
16234
|
maxDurationSeconds: this.maxDurationSeconds,
|
|
16087
16235
|
elapsedSeconds,
|
|
@@ -16118,9 +16266,10 @@ var RecordingSession = class _RecordingSession {
|
|
|
16118
16266
|
await writeBundle(safeBundle, diagnosticsPath);
|
|
16119
16267
|
await this.screencast.stop(videoPath, durationSeconds, this.encoding === "remote", this.captureSignal);
|
|
16120
16268
|
} finally {
|
|
16121
|
-
await this.
|
|
16269
|
+
await this.removeClickCapture?.().catch(() => void 0);
|
|
16270
|
+
await this.removeCursorOverlay?.().catch(() => void 0);
|
|
16271
|
+
await this.browser.close().catch(() => void 0);
|
|
16122
16272
|
if (this.browserProcess) {
|
|
16123
|
-
await this.browser.close().catch(() => void 0);
|
|
16124
16273
|
await terminateBrowser(this.browserProcess);
|
|
16125
16274
|
if (this.profileDirectory) await rm5(this.profileDirectory, { recursive: true, force: true }).catch(() => void 0);
|
|
16126
16275
|
}
|
|
@@ -16135,7 +16284,7 @@ var RecordingSession = class _RecordingSession {
|
|
|
16135
16284
|
videoFormat = "webm";
|
|
16136
16285
|
encoding = "local";
|
|
16137
16286
|
outputDirectory = "";
|
|
16138
|
-
async attachDiagnostics(captureNetworkBodies) {
|
|
16287
|
+
async attachDiagnostics(captureNetworkBodies, cdpSession) {
|
|
16139
16288
|
this.captureNetworkBodies = captureNetworkBodies;
|
|
16140
16289
|
const offset = () => Date.now() - this.startedAt.getTime();
|
|
16141
16290
|
const pushTimeline = (event) => {
|
|
@@ -16146,14 +16295,7 @@ var RecordingSession = class _RecordingSession {
|
|
|
16146
16295
|
}
|
|
16147
16296
|
this.timelineEvents.push({ id: `time_${this.timelineEvents.length + 1}`, ...event });
|
|
16148
16297
|
};
|
|
16149
|
-
await this.page
|
|
16150
|
-
await this.page.addInitScript(() => {
|
|
16151
|
-
document.addEventListener("click", (event) => {
|
|
16152
|
-
const target = event.target;
|
|
16153
|
-
const label = target?.innerText?.trim().slice(0, 80) || target?.getAttribute("aria-label") || target?.tagName || "Element";
|
|
16154
|
-
void window.__rillClick?.({ x: event.clientX, y: event.clientY, label });
|
|
16155
|
-
}, { capture: true });
|
|
16156
|
-
});
|
|
16298
|
+
this.removeClickCapture = await captureClicks(this.page, cdpSession, (payload) => pushTimeline({ offsetMs: offset(), type: "click", label: `${payload.label || "Element"} at ${Math.round(payload.x)},${Math.round(payload.y)}` }));
|
|
16157
16299
|
this.page.on("framenavigated", (frame) => {
|
|
16158
16300
|
if (frame === this.page.mainFrame()) pushTimeline({ offsetMs: offset(), type: "navigation", label: sanitizeUrl(frame.url()) });
|
|
16159
16301
|
});
|
|
@@ -16264,7 +16406,7 @@ async function launchManaged(options) {
|
|
|
16264
16406
|
const cdpUrl = `http://127.0.0.1:${port}`;
|
|
16265
16407
|
try {
|
|
16266
16408
|
await waitForCdp(cdpUrl, browserProcess);
|
|
16267
|
-
const browser = await
|
|
16409
|
+
const browser = await chromium2.connectOverCDP(cdpUrl, { artifactsDir: options.outputDirectory, isLocal: true });
|
|
16268
16410
|
const context = options.blockServiceWorkers ? await browser.newContext({ serviceWorkers: "block" }) : browser.contexts()[0];
|
|
16269
16411
|
const page = context.pages()[0] ?? await context.newPage();
|
|
16270
16412
|
await page.setViewportSize({ width: options.width, height: options.height });
|
|
@@ -16275,13 +16417,6 @@ async function launchManaged(options) {
|
|
|
16275
16417
|
throw error51;
|
|
16276
16418
|
}
|
|
16277
16419
|
}
|
|
16278
|
-
async function connectAttached(cdpUrl, outputDirectory) {
|
|
16279
|
-
const browser = await chromium.connectOverCDP(cdpUrl, { artifactsDir: outputDirectory, isLocal: true, noDefaults: true });
|
|
16280
|
-
const context = browser.contexts()[0];
|
|
16281
|
-
const page = context.pages()[0];
|
|
16282
|
-
if (!page) throw new Error("The attached browser has no page target to record.");
|
|
16283
|
-
return { cdpUrl, browser, context, page };
|
|
16284
|
-
}
|
|
16285
16420
|
async function getAvailablePort() {
|
|
16286
16421
|
const server = createServer();
|
|
16287
16422
|
await new Promise((resolve, reject) => server.listen(0, "127.0.0.1", resolve).once("error", reject));
|
|
@@ -16366,7 +16501,7 @@ async function runDaemon() {
|
|
|
16366
16501
|
async function route(request3, response) {
|
|
16367
16502
|
lastActivity = Date.now();
|
|
16368
16503
|
const url2 = new URL(request3.url ?? "/", "http://daemon.local");
|
|
16369
|
-
if (request3.method === "GET" && url2.pathname === "/health") return json2(response, 200, { ok: true, pid: process.pid });
|
|
16504
|
+
if (request3.method === "GET" && url2.pathname === "/health") return json2(response, 200, { ok: true, pid: process.pid, capabilities: ["tab-selection-v1"] });
|
|
16370
16505
|
if (request3.method === "POST" && url2.pathname === "/sessions") {
|
|
16371
16506
|
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." } });
|
|
16372
16507
|
try {
|
|
@@ -16375,7 +16510,7 @@ async function route(request3, response) {
|
|
|
16375
16510
|
const outputDirectory = join10(recordingTempRoot(), input.recordingId);
|
|
16376
16511
|
const session = await RecordingSession.start({ ...input, outputDirectory });
|
|
16377
16512
|
const status = session.status();
|
|
16378
|
-
const entry = { session, base: { recordingId: status.recordingId, cdpUrl: status.cdpUrl, startedAt: status.startedAt }, state: "recording" };
|
|
16513
|
+
const entry = { session, base: { recordingId: status.recordingId, cdpUrl: status.cdpUrl, tabId: status.tabId, startedAt: status.startedAt }, state: "recording" };
|
|
16379
16514
|
sessions.set(input.recordingId, entry);
|
|
16380
16515
|
const maxTimer = setTimeout(() => void finalizeEntry(entry, "max_duration"), Math.max(0, Date.parse(status.stopsAt) - Date.now()) + 250);
|
|
16381
16516
|
maxTimer.unref();
|
|
@@ -16426,7 +16561,7 @@ async function restoreRecoverableSessions() {
|
|
|
16426
16561
|
const value = JSON.parse(await readFile7(daemonStatePath(), "utf8"));
|
|
16427
16562
|
for (const [id, entry] of Object.entries(value)) {
|
|
16428
16563
|
if (entry.status !== "ready" && entry.status !== "failed") continue;
|
|
16429
|
-
sessions.set(id, { base: { recordingId: entry.recordingId, cdpUrl: entry.cdpUrl, startedAt: entry.startedAt }, state: entry.status, result: entry.result, error: entry.error });
|
|
16564
|
+
sessions.set(id, { base: { recordingId: entry.recordingId, cdpUrl: entry.cdpUrl, tabId: entry.tabId, startedAt: entry.startedAt }, state: entry.status, result: entry.result, error: entry.error });
|
|
16430
16565
|
}
|
|
16431
16566
|
} catch {
|
|
16432
16567
|
}
|
|
@@ -16502,7 +16637,7 @@ function emit(value, pretty = false) {
|
|
|
16502
16637
|
function failureValue(error51) {
|
|
16503
16638
|
const message = error51 instanceof Error ? error51.message : String(error51);
|
|
16504
16639
|
const [candidate] = message.split(":", 1);
|
|
16505
|
-
const knownCodes = /* @__PURE__ */ new Set(["authentication_required", "authorization_not_found", "expired_token", "access_denied", "unsupported_platform", "scope_denied", "quota_exceeded", "browser_unavailable", "recording_not_found", "upload_interrupted", "processing_pending", "processing_failed", "share_revoked", "validation_failed"]);
|
|
16640
|
+
const knownCodes = /* @__PURE__ */ new Set(["authentication_required", "authorization_not_found", "expired_token", "access_denied", "unsupported_platform", "scope_denied", "quota_exceeded", "browser_unavailable", "local_capture_failed", "recording_not_found", "upload_interrupted", "processing_pending", "processing_failed", "share_revoked", "validation_failed"]);
|
|
16506
16641
|
const structured = error51 && typeof error51 === "object" ? error51 : {};
|
|
16507
16642
|
const code = structured.code && knownCodes.has(structured.code) ? structured.code : knownCodes.has(candidate) ? candidate : "internal_error";
|
|
16508
16643
|
return { error: { code, message, ...structured.recovery ? { recovery: structured.recovery } : {}, ...structured.details ? { details: structured.details } : {} } };
|
|
@@ -16512,6 +16647,72 @@ function fail(error51) {
|
|
|
16512
16647
|
process.exitCode = 1;
|
|
16513
16648
|
}
|
|
16514
16649
|
|
|
16650
|
+
// src/human-output.ts
|
|
16651
|
+
function terminalColor(enabled = Boolean(process.stdout.isTTY) && !("NO_COLOR" in process.env) && process.env.TERM !== "dumb") {
|
|
16652
|
+
return (text, code) => enabled ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
16653
|
+
}
|
|
16654
|
+
function formatIdentity(identity, color = terminalColor()) {
|
|
16655
|
+
if (identity.status === "not_connected") return `${color("! Not connected to Rill", 33)}
|
|
16656
|
+
|
|
16657
|
+
Sign in: ${cliCommand(identity.apiUrl, "login")}
|
|
16658
|
+
`;
|
|
16659
|
+
if (identity.status === "anonymous") return `${color("\u2713 Using Rill anonymously", 32)}
|
|
16660
|
+
|
|
16661
|
+
Connect your account: ${cliCommand(identity.apiUrl, "connect", "--email", "<your-email>")}
|
|
16662
|
+
`;
|
|
16663
|
+
return `${color("\u2713 Connected to Rill", 32)}
|
|
16664
|
+
Account: ${identity.email ?? "Email unavailable"}
|
|
16665
|
+
`;
|
|
16666
|
+
}
|
|
16667
|
+
function formatDoctor(result, color = terminalColor()) {
|
|
16668
|
+
const labels = { node: "Node.js", api: "API connectivity", cli_version: "CLI version", authentication: "Authentication", chrome: "Browser", ffmpeg: "FFmpeg", encoding: "Encoding", recorder_transport: "Recorder transport" };
|
|
16669
|
+
const symbols = { pass: "\u2713", fail: "\xD7", warn: "!", skip: "\u2013" };
|
|
16670
|
+
const colors = { pass: 32, fail: 31, warn: 33, skip: 90 };
|
|
16671
|
+
const lines = [color("Rill CLI Doctor", 34), `Execution environment: ${result.environment.platform}`, ""];
|
|
16672
|
+
for (const check2 of result.checks) {
|
|
16673
|
+
lines.push(color(`${symbols[check2.status]} ${labels[check2.name]}: ${check2.message}`, colors[check2.status]));
|
|
16674
|
+
if (check2.recovery) lines.push(` \u2192 ${check2.recovery}`);
|
|
16675
|
+
}
|
|
16676
|
+
const warnings = result.checks.filter((c) => c.status === "warn").length;
|
|
16677
|
+
lines.push(
|
|
16678
|
+
"",
|
|
16679
|
+
"Browser control: not verified. Your agent must attach to the returned CDP endpoint.",
|
|
16680
|
+
"",
|
|
16681
|
+
`${result.checks.filter((c) => c.status === "pass").length} passed \xB7 ${result.checks.filter((c) => c.status === "fail").length} failed \xB7 ${warnings} ${warnings === 1 ? "warning" : "warnings"}`
|
|
16682
|
+
);
|
|
16683
|
+
return lines.join("\n") + "\n";
|
|
16684
|
+
}
|
|
16685
|
+
function formatRecording(value, apiUrl, color = terminalColor()) {
|
|
16686
|
+
const artifact = value.result && typeof value.result === "object" ? value.result : {};
|
|
16687
|
+
const id = String(value.recordingId ?? value.id ?? "");
|
|
16688
|
+
const status = String(value.status ?? "unknown");
|
|
16689
|
+
const titles = { recording: "Recording started", ready: "Recording ready", captured: "Captured locally \xB7 upload required", uploading: "Uploading recording", queued: "Recording queued for encoding", encoding: "Encoding recording", processing: "Processing recording", finalizing: "Finalizing recording", failed: "Recording failed", cancelled: "Recording cancelled", expired: "Recording expired" };
|
|
16690
|
+
const lines = [color(`${["recording", "ready"].includes(status) ? "\u2713" : ["failed", "expired"].includes(status) ? "\xD7" : "\u2013"} ${titles[status] ?? `Recording status: ${status}`}`, ["failed", "expired"].includes(status) ? 31 : status === "ready" || status === "recording" ? 32 : 33)];
|
|
16691
|
+
if (id) lines.push(`ID: ${id}`);
|
|
16692
|
+
const duration3 = value.durationSeconds ?? value.duration_seconds ?? artifact.durationSeconds;
|
|
16693
|
+
if (typeof duration3 === "number" && Number.isFinite(duration3)) lines.push(`Duration: ${Math.floor(duration3 / 60)}m ${Math.floor(duration3 % 60)}s`);
|
|
16694
|
+
if (typeof value.remainingSeconds === "number") lines.push(`Time remaining: ${Math.max(0, Math.ceil(value.remainingSeconds))}s`);
|
|
16695
|
+
if (typeof value.cdpUrl === "string") lines.push(`Browser connection: ${value.cdpUrl}`);
|
|
16696
|
+
if (typeof value.tabId === "string") lines.push(`Recorded tab: ${value.tabId}`);
|
|
16697
|
+
if (typeof value.shareUrl === "string" && value.shareUrl) lines.push("", `Share: ${value.shareUrl}`);
|
|
16698
|
+
else if (status === "ready") lines.push("", "Ready. No share link returned; open this recording in your Rill account.");
|
|
16699
|
+
const message = value.message ?? value.error;
|
|
16700
|
+
if (typeof message === "string" && message) lines.push("", message);
|
|
16701
|
+
const next = typeof value.nextCommand === "string" ? value.nextCommand : status === "recording" && id ? cliCommand(apiUrl, "record", "stop", id) : void 0;
|
|
16702
|
+
if (next) lines.push("", status === "recording" ? "Perform your test, then stop and upload:" : "Next:", ` ${next}`);
|
|
16703
|
+
return lines.join("\n") + "\n";
|
|
16704
|
+
}
|
|
16705
|
+
function formatFailure(error51, color = terminalColor()) {
|
|
16706
|
+
const { error: failure } = failureValue(error51);
|
|
16707
|
+
let message = failure.message.startsWith(`${failure.code}: `) ? failure.message.slice(failure.code.length + 2) : failure.message;
|
|
16708
|
+
if (failure.recovery && message.endsWith(`: ${failure.recovery}`)) message = message.slice(0, -failure.recovery.length - 2);
|
|
16709
|
+
const lines = [color(`\xD7 ${message}`, 31)];
|
|
16710
|
+
if (typeof failure.details?.recordingId === "string") lines.push(`ID: ${failure.details.recordingId}`);
|
|
16711
|
+
if (failure.recovery) lines.push("", `Next: ${failure.recovery}`);
|
|
16712
|
+
else if (typeof failure.details?.nextCommand === "string") lines.push("", `Next: ${failure.details.nextCommand}`);
|
|
16713
|
+
return lines.join("\n") + "\n";
|
|
16714
|
+
}
|
|
16715
|
+
|
|
16515
16716
|
// src/feedback-protocol.ts
|
|
16516
16717
|
function suggestedFeedbackAction(recordingId) {
|
|
16517
16718
|
return {
|
|
@@ -16606,9 +16807,11 @@ async function runDoctor(apiUrl, overrides = {}) {
|
|
|
16606
16807
|
if (!health.ok || health.service !== "rill") throw new Error("The endpoint did not identify itself as Rill.");
|
|
16607
16808
|
checks.push({ name: "api", status: "pass", message: `Rill API is reachable at ${apiUrl}.` });
|
|
16608
16809
|
} catch (error51) {
|
|
16609
|
-
checks.push({ name: "api", status: "fail", message: `Rill API is unreachable: ${errorMessage(error51)}`, recovery: `Check
|
|
16810
|
+
checks.push({ name: "api", status: "fail", message: `Rill API is unreachable: ${errorMessage(error51)}`, recovery: `Check network access in this ${dependencies.platform} execution environment and the API URL, then rerun \`rill --api-url ${apiUrl} doctor\`. An agent sandbox may have different network access from your desktop.` });
|
|
16610
16811
|
}
|
|
16611
|
-
if (!health
|
|
16812
|
+
if (!health) {
|
|
16813
|
+
checks.push({ name: "cli_version", status: "skip", message: `CLI ${dependencies.cliVersion} compatibility was not checked because the API is unreachable.`, version: dependencies.cliVersion });
|
|
16814
|
+
} else if (!health.minimumCliVersion || !health.recommendedCliVersion) {
|
|
16612
16815
|
checks.push({ name: "cli_version", status: "fail", message: `CLI ${dependencies.cliVersion} could not be checked against the API compatibility policy.`, version: dependencies.cliVersion, recovery: "Deploy a Rill API that advertises minimumCliVersion and recommendedCliVersion." });
|
|
16613
16816
|
} else if (compareVersions(dependencies.cliVersion, health.minimumCliVersion) < 0) {
|
|
16614
16817
|
checks.push({ name: "cli_version", status: "fail", message: `CLI ${dependencies.cliVersion} is older than the minimum supported version ${health.minimumCliVersion}.`, version: dependencies.cliVersion, recovery: `Run \`npm install --global @runalabs/rill-cli@${health.recommendedCliVersion}\`.` });
|
|
@@ -16643,20 +16846,23 @@ async function runDoctor(apiUrl, overrides = {}) {
|
|
|
16643
16846
|
}
|
|
16644
16847
|
}
|
|
16645
16848
|
}
|
|
16646
|
-
checks.push(await executableCheck("chrome", dependencies.chromePath, ["--version"], dependencies.commandVersion,
|
|
16849
|
+
checks.push(await executableCheck("chrome", dependencies.chromePath, ["--version"], dependencies.commandVersion, `Chrome must be available in this ${dependencies.platform} execution environment. Install Chrome/Chromium here or set RILL_CHROME_PATH. A browser on a separate desktop is not automatically accessible; use that desktop's terminal to record locally.`));
|
|
16647
16850
|
if (dependencies.encoding === "local") {
|
|
16648
16851
|
checks.push(await executableCheck("ffmpeg", dependencies.ffmpegPath, ["-version"], dependencies.commandVersion, dependencies.platform === "darwin" ? "Install ffmpeg with `brew install ffmpeg`, or set RILL_FFMPEG." : "Install ffmpeg using the system package manager, or set RILL_FFMPEG."));
|
|
16649
16852
|
} else {
|
|
16650
16853
|
checks.push({ name: "ffmpeg", status: "skip", message: "Remote encoding does not require local FFmpeg." });
|
|
16651
|
-
|
|
16652
|
-
|
|
16653
|
-
|
|
16654
|
-
|
|
16655
|
-
|
|
16656
|
-
|
|
16854
|
+
if (!health) checks.push({ name: "encoding", status: "skip", message: "Remote encoding was not checked because the API is unreachable." });
|
|
16855
|
+
else {
|
|
16856
|
+
try {
|
|
16857
|
+
const token = await dependencies.loadCredential(apiUrl);
|
|
16858
|
+
if (!(token ? (await dependencies.encodingCapabilities(apiUrl, token)).enabled : health?.anonymousAccess && health.anonymousRemoteEncoding)) throw new Error("Remote encoding is not enabled for this workspace.");
|
|
16859
|
+
checks.push({ name: "encoding", status: "pass", message: token ? "Remote encoding is enabled." : "Remote encoding is available for the first anonymous recording." });
|
|
16860
|
+
} catch (error51) {
|
|
16861
|
+
checks.push({ name: "encoding", status: "fail", message: errorMessage(error51), recovery: "Run doctor --encoding local to check local FFmpeg, then record start --encoding local, or ask the workspace owner to enable remote encoding." });
|
|
16862
|
+
}
|
|
16657
16863
|
}
|
|
16658
16864
|
}
|
|
16659
|
-
return { schemaVersion: 1, status: checks.some((check2) => check2.status === "fail") ? "error" : "ok", apiUrl, checks, ...runFeedbackFields(feedbackEnabled) };
|
|
16865
|
+
return { schemaVersion: 1, status: checks.some((check2) => check2.status === "fail") ? "error" : "ok", apiUrl, environment: { platform: dependencies.platform }, checks, ...runFeedbackFields(feedbackEnabled) };
|
|
16660
16866
|
}
|
|
16661
16867
|
async function executableCheck(name, path, args, run2, recovery) {
|
|
16662
16868
|
try {
|
|
@@ -16778,6 +16984,48 @@ async function remoteAwareRecordingStatus(recordingId, dependencies, apiUrl = "h
|
|
|
16778
16984
|
return remoteView;
|
|
16779
16985
|
}
|
|
16780
16986
|
}
|
|
16987
|
+
async function releaseFailedLocalCapture(id, dependencies) {
|
|
16988
|
+
const local = await dependencies.localStatus(id).catch(() => null);
|
|
16989
|
+
if (!local || local.recordingId !== id || local.status !== "failed" || local.result) return false;
|
|
16990
|
+
try {
|
|
16991
|
+
await dependencies.failRemote(id);
|
|
16992
|
+
return true;
|
|
16993
|
+
} catch {
|
|
16994
|
+
return false;
|
|
16995
|
+
}
|
|
16996
|
+
}
|
|
16997
|
+
async function createRecordingWithRecovery(dependencies) {
|
|
16998
|
+
try {
|
|
16999
|
+
return await dependencies.createRemote();
|
|
17000
|
+
} catch (error51) {
|
|
17001
|
+
if (!(error51 instanceof ControlPlaneError) || error51.code !== "quota_exceeded") throw error51;
|
|
17002
|
+
const quota = error51.details?.quota;
|
|
17003
|
+
if (!quota || !["credential_concurrency", "workspace_concurrency"].includes(String(quota.type)) || typeof quota.blockingRecordingId !== "string" || !/^rec_[a-zA-Z0-9_-]+$/.test(quota.blockingRecordingId)) throw error51;
|
|
17004
|
+
if (!await releaseFailedLocalCapture(quota.blockingRecordingId, dependencies)) throw error51;
|
|
17005
|
+
dependencies.onRecovered?.(quota.blockingRecordingId);
|
|
17006
|
+
return dependencies.createRemote();
|
|
17007
|
+
}
|
|
17008
|
+
}
|
|
17009
|
+
async function stopRecordingWithCompensation(id, dependencies, apiUrl) {
|
|
17010
|
+
let local;
|
|
17011
|
+
try {
|
|
17012
|
+
local = await dependencies.stopLocal(id);
|
|
17013
|
+
} catch (error51) {
|
|
17014
|
+
const known = await dependencies.localStatus(id).catch(() => null);
|
|
17015
|
+
if (!known || known.recordingId !== id || known.status !== "failed" || known.result) throw error51;
|
|
17016
|
+
local = known;
|
|
17017
|
+
}
|
|
17018
|
+
if (local.recordingId === id && local.status === "failed" && !local.result) {
|
|
17019
|
+
const released = await releaseFailedLocalCapture(id, dependencies);
|
|
17020
|
+
throw new ControlPlaneError(
|
|
17021
|
+
"local_capture_failed",
|
|
17022
|
+
local.error ?? "The local capture could not be finalized.",
|
|
17023
|
+
released ? "Its unused recording slot was released. Start a new recording." : `The recording slot could not be released automatically. Check ${cliCommand(apiUrl, "record", "status", id)} before cancelling it.`,
|
|
17024
|
+
{ recordingId: id, reservationReleased: released }
|
|
17025
|
+
);
|
|
17026
|
+
}
|
|
17027
|
+
return local;
|
|
17028
|
+
}
|
|
16781
17029
|
|
|
16782
17030
|
// src/config.ts
|
|
16783
17031
|
var PRODUCTION_API_URL = "https://userill.dev";
|
|
@@ -16797,13 +17045,23 @@ function watchEncoding(apiUrl, id) {
|
|
|
16797
17045
|
child.unref();
|
|
16798
17046
|
}
|
|
16799
17047
|
var program = new Command();
|
|
16800
|
-
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
|
|
17048
|
+
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 machine-readable JSON (including whoami)", false).option("--human", "Readable doctor, login, whoami and recording start/stop/status/retry output", false);
|
|
17049
|
+
program.configureOutput({ outputError: () => void 0 }).exitOverride();
|
|
17050
|
+
function humanOutput(identity = false) {
|
|
17051
|
+
const options = program.opts();
|
|
17052
|
+
return !options.json && !options.pretty && (identity || options.human);
|
|
17053
|
+
}
|
|
17054
|
+
function emitRecording(value, pretty) {
|
|
17055
|
+
if (humanOutput()) process.stdout.write(formatRecording(value, program.opts().apiUrl));
|
|
17056
|
+
else emit(value, pretty);
|
|
17057
|
+
}
|
|
16801
17058
|
var activeTelemetry = null;
|
|
16802
17059
|
program.command("daemon", { hidden: true }).action(() => runDaemon());
|
|
16803
17060
|
program.command("doctor").description("Validate Rill prerequisites before recording.").option("--encoding <mode>", "remote or local", "remote").action(async (commandOptions) => {
|
|
16804
17061
|
const options = program.opts();
|
|
16805
17062
|
const result = await runDoctor(options.apiUrl, { encoding: commandOptions.encoding });
|
|
16806
|
-
|
|
17063
|
+
if (humanOutput()) process.stdout.write(formatDoctor(result));
|
|
17064
|
+
else emit(result, options.pretty);
|
|
16807
17065
|
if (result.status === "error") process.exitCode = 1;
|
|
16808
17066
|
});
|
|
16809
17067
|
async function authorize(quiet = false) {
|
|
@@ -16824,15 +17082,19 @@ async function authorize(quiet = false) {
|
|
|
16824
17082
|
if (result.status === "pending") continue;
|
|
16825
17083
|
if (result.status !== "approved" || !result.token) throw new Error(`Device authorization ${result.status}.`);
|
|
16826
17084
|
await saveCredential(options.apiUrl, result.token);
|
|
16827
|
-
if (!quiet)
|
|
17085
|
+
if (!quiet) {
|
|
17086
|
+
if (humanOutput()) process.stdout.write("\u2713 Signed in to Rill\nCheck your account: rill whoami\n");
|
|
17087
|
+
else emit({ schemaVersion: 1, status: "authenticated", expiresAt: result.expiresAt, apiUrl: options.apiUrl }, options.pretty);
|
|
17088
|
+
}
|
|
16828
17089
|
return result.token;
|
|
16829
17090
|
}
|
|
16830
17091
|
throw new Error("Device authorization expired.");
|
|
16831
17092
|
}
|
|
16832
|
-
program.command("whoami").description("Show the current verified
|
|
17093
|
+
program.command("whoami").description("Show the current verified identity without starting sign-in.").addHelpText("after", "\nShows a short account summary by default. Use rill whoami --json for agents, or rill --pretty whoami for indented JSON.").action(async () => {
|
|
16833
17094
|
const options = program.opts();
|
|
16834
17095
|
const identity = await whoami(options.apiUrl);
|
|
16835
|
-
|
|
17096
|
+
if (humanOutput(true)) process.stdout.write(formatIdentity(identity));
|
|
17097
|
+
else emit(identity, options.pretty);
|
|
16836
17098
|
if (!identity.authenticated) process.exitCode = 1;
|
|
16837
17099
|
});
|
|
16838
17100
|
var connect = program.command("connect").description("Connect this agent once, with renewable account access.").option("--email <email>", "Email of the Rill account approving access").option("--no-wait", "Return approval instructions immediately; poll with connect status").action(async (input) => {
|
|
@@ -16881,9 +17143,14 @@ for (const action of ["status", "cancel"]) cloudRun.command(action).argument("<r
|
|
|
16881
17143
|
if (options.json || options.pretty) emit(customerRun(run2), options.pretty);
|
|
16882
17144
|
else process.stdout.write(formatCloudRun(run2));
|
|
16883
17145
|
});
|
|
16884
|
-
record2.command("
|
|
17146
|
+
record2.command("tabs").requiredOption("--cdp-url <url>", "Existing Chromium CDP endpoint").action(async (commandOptions) => {
|
|
17147
|
+
const options = program.opts();
|
|
17148
|
+
emit({ schemaVersion: 1, tabs: await listBrowserTabs(commandOptions.cdpUrl) }, options.pretty);
|
|
17149
|
+
});
|
|
17150
|
+
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("--tab-id <id>", "Record this existing tab from record tabs").option("--width <pixels>", "", "1280").option("--height <pixels>", "", "720").option("--max-duration <seconds>", "", "600").action(async (commandOptions) => {
|
|
16885
17151
|
const options = program.opts();
|
|
16886
17152
|
assertRecorderTransport();
|
|
17153
|
+
validateTabSelection(commandOptions);
|
|
16887
17154
|
await ensureAnonymousCredential(options.apiUrl);
|
|
16888
17155
|
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
16889
17156
|
const result = await withRunFeedback(() => client.agentSession(), async (rememberRecording) => {
|
|
@@ -16892,12 +17159,21 @@ record2.command("start").option("--encoding <mode>", "remote or local", "remote"
|
|
|
16892
17159
|
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.");
|
|
16893
17160
|
const width = Number(commandOptions.width);
|
|
16894
17161
|
const height = Number(commandOptions.height);
|
|
16895
|
-
const
|
|
17162
|
+
const metadata = { requestId: crypto.randomUUID(), title: commandOptions.title ?? `Browser recording \xB7 ${(/* @__PURE__ */ new Date()).toISOString()}`, description: commandOptions.description, sourceUrl: commandOptions.url ?? null, width, height };
|
|
17163
|
+
const remote = await createRecordingWithRecovery({
|
|
17164
|
+
createRemote: () => client.createRecording(metadata),
|
|
17165
|
+
localStatus: recordingStatus,
|
|
17166
|
+
failRemote: (id) => client.failLocalCapture(id),
|
|
17167
|
+
onRecovered: (id) => {
|
|
17168
|
+
process.stderr.write(`Released the unused slot for failed local capture ${id}.
|
|
17169
|
+
`);
|
|
17170
|
+
}
|
|
17171
|
+
});
|
|
16896
17172
|
rememberRecording(remote.recordingId);
|
|
16897
17173
|
const local = await startRecordingWithCompensation(remote, {
|
|
16898
17174
|
startLocal: async () => {
|
|
16899
|
-
await ensureDaemon(process.argv[1]);
|
|
16900
|
-
return startRecording({ encoding: commandOptions.encoding, recordingId: remote.recordingId, url: commandOptions.url, cdpUrl: commandOptions.cdpUrl, headed: Boolean(commandOptions.headed), width, height, maxDurationSeconds: Math.min(Number(commandOptions.maxDuration), remote.maxDurationSeconds ?? 600), captureNetworkBodies: remote.bodyCaptureEnabled, cursorAppearance: remote.cursorAppearance });
|
|
17175
|
+
await ensureDaemon(process.argv[1], Boolean(commandOptions.cdpUrl));
|
|
17176
|
+
return startRecording({ encoding: commandOptions.encoding, recordingId: remote.recordingId, url: commandOptions.url, cdpUrl: commandOptions.cdpUrl, tabId: commandOptions.tabId, headed: Boolean(commandOptions.headed), width, height, maxDurationSeconds: Math.min(Number(commandOptions.maxDuration), remote.maxDurationSeconds ?? 600), captureNetworkBodies: remote.bodyCaptureEnabled, cursorAppearance: remote.cursorAppearance });
|
|
16901
17177
|
},
|
|
16902
17178
|
cancelRemote: (recordingId, reason) => client.cancelRecording(recordingId, reason)
|
|
16903
17179
|
}, options.apiUrl);
|
|
@@ -16908,16 +17184,20 @@ record2.command("start").option("--encoding <mode>", "remote or local", "remote"
|
|
|
16908
17184
|
headed: Boolean(commandOptions.headed),
|
|
16909
17185
|
attached: Boolean(commandOptions.cdpUrl)
|
|
16910
17186
|
}).catch(() => void 0);
|
|
16911
|
-
return { schemaVersion: 1, recordingId: remote.recordingId, status: local.status, cdpUrl: local.cdpUrl, browserAttachment: browserAttachment(local.cdpUrl), startedAt: local.startedAt, stopsAt: local.stopsAt, elapsedSeconds: local.elapsedSeconds, remainingSeconds: local.remainingSeconds, bodyCaptureEnabled: remote.bodyCaptureEnabled, anonymous: remote.anonymous ?? false, expiresAt: remote.expiresAt ?? null, maxDurationSeconds: Math.min(Number(commandOptions.maxDuration), remote.maxDurationSeconds ?? 600) };
|
|
17187
|
+
return { schemaVersion: 1, recordingId: remote.recordingId, status: local.status, cdpUrl: local.cdpUrl, ...local.tabId ? { tabId: local.tabId } : {}, browserAttachment: browserAttachment(local.cdpUrl, local.tabId), startedAt: local.startedAt, stopsAt: local.stopsAt, elapsedSeconds: local.elapsedSeconds, remainingSeconds: local.remainingSeconds, bodyCaptureEnabled: remote.bodyCaptureEnabled, anonymous: remote.anonymous ?? false, expiresAt: remote.expiresAt ?? null, maxDurationSeconds: Math.min(Number(commandOptions.maxDuration), remote.maxDurationSeconds ?? 600) };
|
|
16912
17188
|
});
|
|
16913
|
-
|
|
17189
|
+
emitRecording(result, options.pretty);
|
|
16914
17190
|
});
|
|
16915
17191
|
record2.command("stop").argument("<recording-id>").option("--no-wait").option("--no-feedback", "Disable feedback only when the user explicitly requests it").action(async (recordingId, commandOptions) => {
|
|
16916
17192
|
const options = program.opts();
|
|
16917
17193
|
await ensureDaemon(process.argv[1]);
|
|
16918
|
-
const local = await stopRecording(recordingId);
|
|
16919
|
-
if (!local.result) throw new Error(local.error ?? "The recorder did not produce an artifact.");
|
|
16920
17194
|
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
17195
|
+
const local = await stopRecordingWithCompensation(recordingId, {
|
|
17196
|
+
stopLocal: stopRecording,
|
|
17197
|
+
localStatus: recordingStatus,
|
|
17198
|
+
failRemote: (id) => client.failLocalCapture(id)
|
|
17199
|
+
}, options.apiUrl);
|
|
17200
|
+
if (!local.result) throw new Error(local.error ?? "The recorder did not produce an artifact.");
|
|
16921
17201
|
const stopped = await client.markRecordingStopped(recordingId, {
|
|
16922
17202
|
durationSeconds: local.result.durationSeconds,
|
|
16923
17203
|
stoppedReason: local.result.stoppedReason,
|
|
@@ -16928,7 +17208,7 @@ record2.command("stop").argument("<recording-id>").option("--no-wait").option("-
|
|
|
16928
17208
|
await client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics);
|
|
16929
17209
|
const accepted = await submitFrameCapture(client, recordingId, local.result.frameDirectory);
|
|
16930
17210
|
const result2 = commandOptions.wait && record2.opts().wait !== false ? await waitForEncoding(client, recordingId) : accepted;
|
|
16931
|
-
|
|
17211
|
+
emitRecording({ schemaVersion: 1, ...result2, recordingId, ...recordingNextStep(options.apiUrl, recordingId, result2.status, { resumableFrames: true }), ...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false) }, options.pretty);
|
|
16932
17212
|
if (result2.status === "ready") await rm7(dirname2(local.result.videoPath), { recursive: true, force: true });
|
|
16933
17213
|
else watchEncoding(options.apiUrl, recordingId);
|
|
16934
17214
|
return;
|
|
@@ -16950,7 +17230,7 @@ record2.command("stop").argument("<recording-id>").option("--no-wait").option("-
|
|
|
16950
17230
|
...recordingNextStep(options.apiUrl, recordingId, remote ? "ready" : "processing"),
|
|
16951
17231
|
...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false)
|
|
16952
17232
|
};
|
|
16953
|
-
|
|
17233
|
+
emitRecording(result, options.pretty);
|
|
16954
17234
|
if (remote) await rm7(dirname2(local.result.videoPath), { recursive: true, force: true });
|
|
16955
17235
|
});
|
|
16956
17236
|
record2.command("retry").argument("<recording-id>").option("--no-wait").action(async (recordingId, commandOptions) => {
|
|
@@ -16971,7 +17251,7 @@ record2.command("retry").argument("<recording-id>").option("--no-wait").action(a
|
|
|
16971
17251
|
if (!accepted) throw new Error("Encoding job is unavailable.");
|
|
16972
17252
|
if (["expired", "cancelled"].includes(accepted.status)) throw new Error(`Recording ${accepted.status}.`);
|
|
16973
17253
|
const result = commandOptions.wait && record2.opts().wait !== false ? await waitForEncoding(client, recordingId) : accepted;
|
|
16974
|
-
|
|
17254
|
+
emitRecording({ schemaVersion: 1, ...result, recordingId, ...recordingNextStep(options.apiUrl, recordingId, result.status, { resumableFrames: true }) }, options.pretty);
|
|
16975
17255
|
if (result.status === "ready") await removeLocalCapture(recordingId);
|
|
16976
17256
|
else watchEncoding(options.apiUrl, recordingId);
|
|
16977
17257
|
});
|
|
@@ -17013,7 +17293,7 @@ record2.command("status").argument("<recording-id>").action(async (recordingId)
|
|
|
17013
17293
|
try {
|
|
17014
17294
|
const encoding = await client.encodingStatus(recordingId);
|
|
17015
17295
|
const remote = encoding.status === "ready" ? await client.recordingStatus(recordingId) : {};
|
|
17016
|
-
|
|
17296
|
+
emitRecording({ schemaVersion: 1, ...encoding, ...remote, recordingId, source: "remote", ...recordingNextStep(options.apiUrl, recordingId, encoding.status, { resumableFrames: true }) }, options.pretty);
|
|
17017
17297
|
if (encoding.status === "ready") await removeLocalCapture(recordingId);
|
|
17018
17298
|
return;
|
|
17019
17299
|
} catch (error51) {
|
|
@@ -17026,7 +17306,7 @@ record2.command("status").argument("<recording-id>").action(async (recordingId)
|
|
|
17026
17306
|
},
|
|
17027
17307
|
remoteStatus: (id) => client.recordingStatus(id)
|
|
17028
17308
|
}, options.apiUrl);
|
|
17029
|
-
|
|
17309
|
+
emitRecording({ schemaVersion: 1, ...status, recordingId }, options.pretty);
|
|
17030
17310
|
});
|
|
17031
17311
|
record2.command("cancel").argument("<recording-id>").description("Cancel an active recording and release its remote quota slot.").action(async (recordingId) => {
|
|
17032
17312
|
const options = program.opts();
|
|
@@ -17106,8 +17386,20 @@ program.hook("postAction", async () => {
|
|
|
17106
17386
|
await finishCliTelemetry(process.exitCode ? new Error("command_reported_failure: Command returned a failure status.") : null);
|
|
17107
17387
|
});
|
|
17108
17388
|
program.parseAsync().catch(async (error51) => {
|
|
17389
|
+
if (error51 instanceof CommanderError && error51.exitCode === 0) return;
|
|
17390
|
+
if (error51 instanceof CommanderError) {
|
|
17391
|
+
const action = program.args[0] === "record" ? program.args[1] : void 0;
|
|
17392
|
+
const missingRecording = error51.code === "commander.missingArgument" && ["stop", "status", "retry", "cancel"].includes(action ?? "");
|
|
17393
|
+
error51 = Object.assign(new Error(`validation_failed: ${missingRecording ? "A recording ID is required." : error51.message.replace(/^error: /, "")}`), {
|
|
17394
|
+
code: "validation_failed",
|
|
17395
|
+
recovery: missingRecording ? `Run rill record ${action} <recording-id>. Use rill record list to find the ID.` : "Run rill --help for available commands and options."
|
|
17396
|
+
});
|
|
17397
|
+
}
|
|
17109
17398
|
await finishCliTelemetry(error51);
|
|
17110
|
-
|
|
17399
|
+
if (humanOutput(program.args[0] === "whoami")) {
|
|
17400
|
+
process.stderr.write(formatFailure(error51));
|
|
17401
|
+
process.exitCode = 1;
|
|
17402
|
+
} else fail(error51);
|
|
17111
17403
|
});
|
|
17112
17404
|
function commandPath(actionCommand) {
|
|
17113
17405
|
const path = [];
|