@runalabs/rill-cli 0.1.14 → 0.1.15
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 +29 -2
- package/dist/cli.js +300 -105
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -124,7 +124,7 @@ rill record status <recording-id>
|
|
|
124
124
|
|
|
125
125
|
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
126
|
|
|
127
|
-
Capture follows the
|
|
127
|
+
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
128
|
|
|
129
129
|
### Recording options
|
|
130
130
|
|
|
@@ -135,12 +135,32 @@ Capture follows the first page selected at recording start. Keep the flow in tha
|
|
|
135
135
|
| `--title <title>` | Name the run; defaults to a timestamped title. |
|
|
136
136
|
| `--description <text>` | Describe what the run demonstrates. |
|
|
137
137
|
| `--headed` | Show the managed browser; default is headless. |
|
|
138
|
-
| `--cdp-url <url>` | Attach to an existing Chromium
|
|
138
|
+
| `--cdp-url <url>` | Attach to an existing Chromium CDP endpoint. Select a tab when several are open. |
|
|
139
|
+
| `--tab-id <id>` | Record the stable target ID returned by `record tabs`; requires `--cdp-url`. |
|
|
139
140
|
| `--width <pixels>` / `--height <pixels>` | Managed viewport; defaults to 1280 × 720. |
|
|
140
141
|
| `--max-duration <seconds>` | Automatically finalize local capture; defaults to 600. Still call `record stop` to upload. |
|
|
141
142
|
|
|
142
143
|
Rill closes browsers it launches during finalization. Attached browsers remain open.
|
|
143
144
|
|
|
145
|
+
### Record a tab you already use
|
|
146
|
+
|
|
147
|
+
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.
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
rill record tabs --cdp-url http://127.0.0.1:9222
|
|
151
|
+
rill record start --cdp-url http://127.0.0.1:9222 --tab-id <tab-id> --title "Checkout review"
|
|
152
|
+
# Continue your flow in that exact tab, then finalize/upload:
|
|
153
|
+
rill record stop <recording-id>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
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.
|
|
157
|
+
|
|
158
|
+
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.
|
|
159
|
+
|
|
160
|
+
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.
|
|
161
|
+
|
|
162
|
+
The CDP connection and disconnect behavior follow the [Playwright browser API](https://playwright.dev/docs/api/class-browser#browser-close).
|
|
163
|
+
|
|
144
164
|
## Set up a coding agent
|
|
145
165
|
|
|
146
166
|
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 +207,7 @@ Use `rill --help` or `<command> --help` for supported flags.
|
|
|
187
207
|
| `rill connect --email <email> [--no-wait]` | Start or resume account approval. |
|
|
188
208
|
| `rill connect status <authorization-id>` | Poll a saved approval once; obey `retryAfterSeconds`. |
|
|
189
209
|
| `rill doctor` | Check prerequisites, API compatibility, and authentication. |
|
|
210
|
+
| `rill record tabs --cdp-url <url>` | List existing Chromium tabs without starting a recording. |
|
|
190
211
|
| `rill record start [options]` | Start a managed or attached browser recording. |
|
|
191
212
|
| `rill record stop <recording-id>` | Finalize and upload; supports `--no-wait` and `--no-feedback`. |
|
|
192
213
|
| `rill record status <recording-id>` | Read remote state first, then local capture for unsubmitted recordings; local completion is `captured`. |
|
|
@@ -343,3 +364,9 @@ Run `rill whoami` (or `rill --pretty whoami`) to verify your current identity. J
|
|
|
343
364
|
`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
365
|
|
|
345
366
|
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.
|
|
367
|
+
|
|
368
|
+
### Failed local capture recovery
|
|
369
|
+
|
|
370
|
+
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.
|
|
371
|
+
|
|
372
|
+
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
|
}
|
|
@@ -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
|
}
|
|
@@ -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 } : {} } };
|
|
@@ -16778,6 +16913,48 @@ async function remoteAwareRecordingStatus(recordingId, dependencies, apiUrl = "h
|
|
|
16778
16913
|
return remoteView;
|
|
16779
16914
|
}
|
|
16780
16915
|
}
|
|
16916
|
+
async function releaseFailedLocalCapture(id, dependencies) {
|
|
16917
|
+
const local = await dependencies.localStatus(id).catch(() => null);
|
|
16918
|
+
if (!local || local.recordingId !== id || local.status !== "failed" || local.result) return false;
|
|
16919
|
+
try {
|
|
16920
|
+
await dependencies.failRemote(id);
|
|
16921
|
+
return true;
|
|
16922
|
+
} catch {
|
|
16923
|
+
return false;
|
|
16924
|
+
}
|
|
16925
|
+
}
|
|
16926
|
+
async function createRecordingWithRecovery(dependencies) {
|
|
16927
|
+
try {
|
|
16928
|
+
return await dependencies.createRemote();
|
|
16929
|
+
} catch (error51) {
|
|
16930
|
+
if (!(error51 instanceof ControlPlaneError) || error51.code !== "quota_exceeded") throw error51;
|
|
16931
|
+
const quota = error51.details?.quota;
|
|
16932
|
+
if (!quota || !["credential_concurrency", "workspace_concurrency"].includes(String(quota.type)) || typeof quota.blockingRecordingId !== "string" || !/^rec_[a-zA-Z0-9_-]+$/.test(quota.blockingRecordingId)) throw error51;
|
|
16933
|
+
if (!await releaseFailedLocalCapture(quota.blockingRecordingId, dependencies)) throw error51;
|
|
16934
|
+
dependencies.onRecovered?.(quota.blockingRecordingId);
|
|
16935
|
+
return dependencies.createRemote();
|
|
16936
|
+
}
|
|
16937
|
+
}
|
|
16938
|
+
async function stopRecordingWithCompensation(id, dependencies, apiUrl) {
|
|
16939
|
+
let local;
|
|
16940
|
+
try {
|
|
16941
|
+
local = await dependencies.stopLocal(id);
|
|
16942
|
+
} catch (error51) {
|
|
16943
|
+
const known = await dependencies.localStatus(id).catch(() => null);
|
|
16944
|
+
if (!known || known.recordingId !== id || known.status !== "failed" || known.result) throw error51;
|
|
16945
|
+
local = known;
|
|
16946
|
+
}
|
|
16947
|
+
if (local.recordingId === id && local.status === "failed" && !local.result) {
|
|
16948
|
+
const released = await releaseFailedLocalCapture(id, dependencies);
|
|
16949
|
+
throw new ControlPlaneError(
|
|
16950
|
+
"local_capture_failed",
|
|
16951
|
+
local.error ?? "The local capture could not be finalized.",
|
|
16952
|
+
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.`,
|
|
16953
|
+
{ recordingId: id, reservationReleased: released }
|
|
16954
|
+
);
|
|
16955
|
+
}
|
|
16956
|
+
return local;
|
|
16957
|
+
}
|
|
16781
16958
|
|
|
16782
16959
|
// src/config.ts
|
|
16783
16960
|
var PRODUCTION_API_URL = "https://userill.dev";
|
|
@@ -16881,9 +17058,14 @@ for (const action of ["status", "cancel"]) cloudRun.command(action).argument("<r
|
|
|
16881
17058
|
if (options.json || options.pretty) emit(customerRun(run2), options.pretty);
|
|
16882
17059
|
else process.stdout.write(formatCloudRun(run2));
|
|
16883
17060
|
});
|
|
16884
|
-
record2.command("
|
|
17061
|
+
record2.command("tabs").requiredOption("--cdp-url <url>", "Existing Chromium CDP endpoint").action(async (commandOptions) => {
|
|
17062
|
+
const options = program.opts();
|
|
17063
|
+
emit({ schemaVersion: 1, tabs: await listBrowserTabs(commandOptions.cdpUrl) }, options.pretty);
|
|
17064
|
+
});
|
|
17065
|
+
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
17066
|
const options = program.opts();
|
|
16886
17067
|
assertRecorderTransport();
|
|
17068
|
+
validateTabSelection(commandOptions);
|
|
16887
17069
|
await ensureAnonymousCredential(options.apiUrl);
|
|
16888
17070
|
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
16889
17071
|
const result = await withRunFeedback(() => client.agentSession(), async (rememberRecording) => {
|
|
@@ -16892,12 +17074,21 @@ record2.command("start").option("--encoding <mode>", "remote or local", "remote"
|
|
|
16892
17074
|
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
17075
|
const width = Number(commandOptions.width);
|
|
16894
17076
|
const height = Number(commandOptions.height);
|
|
16895
|
-
const
|
|
17077
|
+
const metadata = { requestId: crypto.randomUUID(), title: commandOptions.title ?? `Browser recording \xB7 ${(/* @__PURE__ */ new Date()).toISOString()}`, description: commandOptions.description, sourceUrl: commandOptions.url ?? null, width, height };
|
|
17078
|
+
const remote = await createRecordingWithRecovery({
|
|
17079
|
+
createRemote: () => client.createRecording(metadata),
|
|
17080
|
+
localStatus: recordingStatus,
|
|
17081
|
+
failRemote: (id) => client.failLocalCapture(id),
|
|
17082
|
+
onRecovered: (id) => {
|
|
17083
|
+
process.stderr.write(`Released the unused slot for failed local capture ${id}.
|
|
17084
|
+
`);
|
|
17085
|
+
}
|
|
17086
|
+
});
|
|
16896
17087
|
rememberRecording(remote.recordingId);
|
|
16897
17088
|
const local = await startRecordingWithCompensation(remote, {
|
|
16898
17089
|
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 });
|
|
17090
|
+
await ensureDaemon(process.argv[1], Boolean(commandOptions.cdpUrl));
|
|
17091
|
+
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
17092
|
},
|
|
16902
17093
|
cancelRemote: (recordingId, reason) => client.cancelRecording(recordingId, reason)
|
|
16903
17094
|
}, options.apiUrl);
|
|
@@ -16908,16 +17099,20 @@ record2.command("start").option("--encoding <mode>", "remote or local", "remote"
|
|
|
16908
17099
|
headed: Boolean(commandOptions.headed),
|
|
16909
17100
|
attached: Boolean(commandOptions.cdpUrl)
|
|
16910
17101
|
}).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) };
|
|
17102
|
+
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
17103
|
});
|
|
16913
17104
|
emit(result, options.pretty);
|
|
16914
17105
|
});
|
|
16915
17106
|
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
17107
|
const options = program.opts();
|
|
16917
17108
|
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
17109
|
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
17110
|
+
const local = await stopRecordingWithCompensation(recordingId, {
|
|
17111
|
+
stopLocal: stopRecording,
|
|
17112
|
+
localStatus: recordingStatus,
|
|
17113
|
+
failRemote: (id) => client.failLocalCapture(id)
|
|
17114
|
+
}, options.apiUrl);
|
|
17115
|
+
if (!local.result) throw new Error(local.error ?? "The recorder did not produce an artifact.");
|
|
16921
17116
|
const stopped = await client.markRecordingStopped(recordingId, {
|
|
16922
17117
|
durationSeconds: local.result.durationSeconds,
|
|
16923
17118
|
stoppedReason: local.result.stoppedReason,
|