@runalabs/rill-cli 0.1.9 → 0.1.11
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 +35 -16
- package/dist/cli.js +963 -506
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -5,80 +5,9 @@ var __export = (target, all) => {
|
|
|
5
5
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
-
// src/
|
|
9
|
-
import {
|
|
10
|
-
import { Command } from "commander";
|
|
11
|
-
import { hostname as hostname3 } from "os";
|
|
12
|
-
import { basename, dirname as dirname2 } from "path";
|
|
13
|
-
import { readdir, rm as rm2, stat as stat2 } from "fs/promises";
|
|
14
|
-
|
|
15
|
-
// ../recorder/src/client.ts
|
|
16
|
-
import { request } from "http";
|
|
17
|
-
import { spawn } from "child_process";
|
|
18
|
-
|
|
19
|
-
// ../recorder/src/paths.ts
|
|
20
|
-
import { homedir, tmpdir } from "os";
|
|
8
|
+
// src/remote-encoding.ts
|
|
9
|
+
import { open, readFile as readFile2 } from "fs/promises";
|
|
21
10
|
import { join } from "path";
|
|
22
|
-
var rillDirectory = () => process.env.RILL_HOME ?? join(homedir(), ".rill");
|
|
23
|
-
var daemonSocketPath = () => process.env.RILL_SOCKET ?? join(rillDirectory(), "recorder.sock");
|
|
24
|
-
var daemonStatePath = () => join(rillDirectory(), "sessions.json");
|
|
25
|
-
var recordingTempRoot = () => process.env.RILL_TEMP ?? join(tmpdir(), "rill-recordings");
|
|
26
|
-
|
|
27
|
-
// ../recorder/src/client.ts
|
|
28
|
-
async function ensureDaemon(cliEntryPath) {
|
|
29
|
-
try {
|
|
30
|
-
await callDaemon("GET", "/health");
|
|
31
|
-
return;
|
|
32
|
-
} catch {
|
|
33
|
-
}
|
|
34
|
-
const child = spawn(process.execPath, [cliEntryPath, "daemon"], { detached: true, stdio: "ignore", env: process.env });
|
|
35
|
-
child.unref();
|
|
36
|
-
const deadline = Date.now() + 1e4;
|
|
37
|
-
while (Date.now() < deadline) {
|
|
38
|
-
try {
|
|
39
|
-
await callDaemon("GET", "/health");
|
|
40
|
-
return;
|
|
41
|
-
} catch {
|
|
42
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
throw new Error("The local recorder service did not start.");
|
|
46
|
-
}
|
|
47
|
-
function startRecording(input) {
|
|
48
|
-
return callDaemon("POST", "/sessions", input);
|
|
49
|
-
}
|
|
50
|
-
function stopRecording(recordingId) {
|
|
51
|
-
return callDaemon("POST", `/sessions/${encodeURIComponent(recordingId)}/stop`, {});
|
|
52
|
-
}
|
|
53
|
-
function recordingStatus(recordingId) {
|
|
54
|
-
return callDaemon("GET", `/sessions/${encodeURIComponent(recordingId)}`);
|
|
55
|
-
}
|
|
56
|
-
function callDaemon(method, path, body) {
|
|
57
|
-
return new Promise((resolve, reject) => {
|
|
58
|
-
const payload = body === void 0 ? void 0 : Buffer.from(JSON.stringify(body));
|
|
59
|
-
const outgoing = request({ socketPath: daemonSocketPath(), method, path, headers: payload ? { "Content-Type": "application/json", "Content-Length": payload.length } : void 0 }, (incoming) => {
|
|
60
|
-
const chunks = [];
|
|
61
|
-
incoming.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
62
|
-
incoming.on("end", () => {
|
|
63
|
-
try {
|
|
64
|
-
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
65
|
-
if ((incoming.statusCode ?? 500) >= 400) reject(new Error(value.error?.message ?? `Recorder returned ${incoming.statusCode}.`));
|
|
66
|
-
else resolve(value);
|
|
67
|
-
} catch (error51) {
|
|
68
|
-
reject(error51);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
outgoing.once("error", reject);
|
|
73
|
-
if (payload) outgoing.write(payload);
|
|
74
|
-
outgoing.end();
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ../recorder/src/daemon.ts
|
|
79
|
-
import { createServer as createServer2 } from "http";
|
|
80
|
-
import { mkdir as mkdir3, readFile, unlink, writeFile as writeFile2 } from "fs/promises";
|
|
81
|
-
import { dirname, join as join4 } from "path";
|
|
82
11
|
|
|
83
12
|
// ../../node_modules/zod/v4/classic/external.js
|
|
84
13
|
var external_exports = {};
|
|
@@ -14594,192 +14523,543 @@ function date4(params) {
|
|
|
14594
14523
|
// ../../node_modules/zod/v4/classic/external.js
|
|
14595
14524
|
config(en_default());
|
|
14596
14525
|
|
|
14597
|
-
// ../contracts/src/
|
|
14598
|
-
var
|
|
14599
|
-
|
|
14600
|
-
|
|
14601
|
-
|
|
14602
|
-
|
|
14603
|
-
|
|
14604
|
-
|
|
14605
|
-
|
|
14606
|
-
|
|
14607
|
-
"
|
|
14608
|
-
"
|
|
14609
|
-
"
|
|
14610
|
-
]);
|
|
14611
|
-
var recordingSchema = external_exports.object({
|
|
14612
|
-
id: external_exports.string(),
|
|
14613
|
-
title: external_exports.string(),
|
|
14614
|
-
description: external_exports.string().default(""),
|
|
14615
|
-
status: recordingStatusSchema,
|
|
14616
|
-
sourceUrl: external_exports.string().nullable(),
|
|
14617
|
-
createdAt: external_exports.string().datetime(),
|
|
14618
|
-
durationSeconds: external_exports.number().nonnegative(),
|
|
14619
|
-
width: external_exports.number().int().positive(),
|
|
14620
|
-
height: external_exports.number().int().positive(),
|
|
14621
|
-
shareSlug: external_exports.string().nullable(),
|
|
14622
|
-
shareUrl: external_exports.string().nullable(),
|
|
14623
|
-
thumbnailUrl: external_exports.string().nullable(),
|
|
14624
|
-
playbackUrl: external_exports.string().nullable(),
|
|
14625
|
-
expiresAt: external_exports.string().datetime().nullable(),
|
|
14626
|
-
diagnostics: external_exports.object({
|
|
14627
|
-
available: external_exports.boolean(),
|
|
14628
|
-
bodyCaptureEnabled: external_exports.boolean(),
|
|
14629
|
-
consoleErrors: external_exports.number().int().nonnegative(),
|
|
14630
|
-
failedRequests: external_exports.number().int().nonnegative(),
|
|
14631
|
-
httpErrors: external_exports.number().int().nonnegative(),
|
|
14632
|
-
truncated: external_exports.boolean()
|
|
14633
|
-
})
|
|
14526
|
+
// ../contracts/src/encoding.ts
|
|
14527
|
+
var ENCODING_PART_BYTES = 4 * 1024 * 1024;
|
|
14528
|
+
var ENCODING_MAX_BYTES = 1024 * 1024 * 1024;
|
|
14529
|
+
var frameManifestSchema = external_exports.object({
|
|
14530
|
+
version: external_exports.literal(1),
|
|
14531
|
+
durationSeconds: external_exports.number().positive().max(600),
|
|
14532
|
+
width: external_exports.number().int().min(320).max(3840),
|
|
14533
|
+
height: external_exports.number().int().min(240).max(2160),
|
|
14534
|
+
frames: external_exports.array(external_exports.object({ timestamp: external_exports.number().nonnegative().finite(), size: external_exports.number().int().positive().max(ENCODING_PART_BYTES) }).strict()).min(1).max(36e3)
|
|
14535
|
+
}).strict().superRefine((m, ctx) => {
|
|
14536
|
+
if (m.frames.reduce((n, f) => n + f.size, 0) > ENCODING_MAX_BYTES) ctx.addIssue({ code: "custom", message: "Frame upload exceeds the trial byte limit." });
|
|
14537
|
+
for (let i = 1; i < m.frames.length; i++) if (m.frames[i].timestamp < m.frames[i - 1].timestamp) ctx.addIssue({ code: "custom", message: "Frame timestamps must be ordered." });
|
|
14538
|
+
if (m.frames.at(-1).timestamp - m.frames[0].timestamp > m.durationSeconds + 1) ctx.addIssue({ code: "custom", message: "Frame timestamps exceed capture duration." });
|
|
14634
14539
|
});
|
|
14635
|
-
var agentOutcomeSchema = external_exports.object({
|
|
14636
|
-
outcome: external_exports.enum(["walkthrough", "passed", "failed", "inconclusive"]),
|
|
14637
|
-
summary: external_exports.string().min(1).max(1e3),
|
|
14638
|
-
evidence: external_exports.string().max(1e3)
|
|
14639
|
-
}).strict();
|
|
14640
14540
|
|
|
14641
|
-
//
|
|
14642
|
-
|
|
14643
|
-
|
|
14644
|
-
|
|
14645
|
-
|
|
14646
|
-
|
|
14647
|
-
|
|
14648
|
-
|
|
14649
|
-
|
|
14650
|
-
|
|
14651
|
-
|
|
14652
|
-
|
|
14653
|
-
};
|
|
14654
|
-
scope[options.key] = cleanup;
|
|
14655
|
-
const mount = () => {
|
|
14656
|
-
if (controller.signal.aborted || host || !document.documentElement) return;
|
|
14657
|
-
host = document.createElement("div");
|
|
14658
|
-
host.setAttribute("data-rill-cursor", "");
|
|
14659
|
-
host.setAttribute("aria-hidden", "true");
|
|
14660
|
-
host.style.cssText = "all:initial!important;position:fixed!important;inset:0!important;width:100%!important;height:100%!important;pointer-events:none!important;z-index:2147483647!important;";
|
|
14661
|
-
const root = host.attachShadow({ mode: "closed" });
|
|
14662
|
-
const style = document.createElement("style");
|
|
14663
|
-
style.textContent = `
|
|
14664
|
-
:host, * { pointer-events: none !important; }
|
|
14665
|
-
.pointer { position:absolute; left:0; top:0; display:none; width:24px; height:28px; }
|
|
14666
|
-
.halo { position:absolute; left:-12px; top:-12px; width:26px; height:26px; border-radius:50%; background:${options.color}; opacity:.22; }
|
|
14667
|
-
svg { position:absolute; left:-2px; top:-2px; width:24px; height:28px; filter:drop-shadow(0 1px 2px #0008); }
|
|
14668
|
-
.pulse { position:absolute; width:32px; height:32px; box-sizing:border-box; border:2px solid ${options.color}; border-radius:50%; }
|
|
14669
|
-
`;
|
|
14670
|
-
const pointer = document.createElement("div");
|
|
14671
|
-
pointer.className = "pointer";
|
|
14672
|
-
pointer.innerHTML = '<div class="halo"></div><svg viewBox="0 0 24 28" xmlns="http://www.w3.org/2000/svg"><path d="M3 2 L3 22 L8 17 L12 25 L16 23 L12 15 L20 15 Z" fill="white" stroke="#171717" stroke-width="1.5" stroke-linejoin="round"/></svg>';
|
|
14673
|
-
root.append(style, pointer);
|
|
14674
|
-
document.documentElement.appendChild(host);
|
|
14675
|
-
const listenerOptions = { capture: true, passive: true, signal: controller.signal };
|
|
14676
|
-
const hide = () => {
|
|
14677
|
-
pointer.style.display = "none";
|
|
14678
|
-
};
|
|
14679
|
-
const move = (event) => {
|
|
14680
|
-
if (event.pointerType === "touch") return;
|
|
14681
|
-
if (event.target instanceof HTMLIFrameElement || event.target instanceof HTMLFrameElement) {
|
|
14682
|
-
hide();
|
|
14683
|
-
return;
|
|
14684
|
-
}
|
|
14685
|
-
pointer.style.display = "block";
|
|
14686
|
-
pointer.style.transform = `translate(${event.clientX}px, ${event.clientY}px)`;
|
|
14687
|
-
};
|
|
14688
|
-
document.addEventListener("pointermove", move, listenerOptions);
|
|
14689
|
-
document.addEventListener("pointerover", move, listenerOptions);
|
|
14690
|
-
document.addEventListener("pointerdown", (event) => {
|
|
14691
|
-
if (event.pointerType === "touch") return;
|
|
14692
|
-
move(event);
|
|
14693
|
-
const pulse = document.createElement("div");
|
|
14694
|
-
pulse.className = "pulse";
|
|
14695
|
-
pulse.style.left = `${event.clientX - 16}px`;
|
|
14696
|
-
pulse.style.top = `${event.clientY - 16}px`;
|
|
14697
|
-
root.insertBefore(pulse, pointer);
|
|
14698
|
-
const animation = pulse.animate([
|
|
14699
|
-
{ transform: "scale(.4)", opacity: 0.9 },
|
|
14700
|
-
{ transform: "scale(1.8)", opacity: 0 }
|
|
14701
|
-
], { duration: 400, easing: "ease-out" });
|
|
14702
|
-
animations.add(animation);
|
|
14703
|
-
void animation.finished.catch(() => void 0).finally(() => {
|
|
14704
|
-
animations.delete(animation);
|
|
14705
|
-
pulse.remove();
|
|
14706
|
-
});
|
|
14707
|
-
}, listenerOptions);
|
|
14708
|
-
document.addEventListener("pointerout", (event) => {
|
|
14709
|
-
if (!event.relatedTarget) hide();
|
|
14710
|
-
}, listenerOptions);
|
|
14711
|
-
window.addEventListener("blur", hide, { signal: controller.signal });
|
|
14712
|
-
document.addEventListener("visibilitychange", () => {
|
|
14713
|
-
if (document.hidden) hide();
|
|
14714
|
-
}, { signal: controller.signal });
|
|
14715
|
-
};
|
|
14716
|
-
if (document.documentElement) mount();
|
|
14717
|
-
else document.addEventListener("DOMContentLoaded", mount, { once: true, signal: controller.signal });
|
|
14718
|
-
}
|
|
14719
|
-
async function attachCursorOverlay(page, cdp, input) {
|
|
14720
|
-
const appearance = cursorAppearanceSchema.parse(input ?? {});
|
|
14721
|
-
if (!appearance.enabled) return async () => {
|
|
14722
|
-
};
|
|
14723
|
-
const key = `__rillCursor_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
14724
|
-
const source = `(${installCursorOverlay.toString()})(${JSON.stringify({ ...appearance, key })})`;
|
|
14725
|
-
await cdp.send("Page.enable");
|
|
14726
|
-
const { identifier } = await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source });
|
|
14727
|
-
let stopped = false;
|
|
14728
|
-
const removeFromFrame = (frame) => frame.evaluate((key2) => {
|
|
14729
|
-
const cleanup2 = window[key2];
|
|
14730
|
-
if (typeof cleanup2 === "function") cleanup2();
|
|
14731
|
-
}, key).catch(() => void 0);
|
|
14732
|
-
const onNavigation = (frame) => {
|
|
14733
|
-
if (stopped) return;
|
|
14734
|
-
void frame.evaluate(source).then(() => {
|
|
14735
|
-
if (stopped) return removeFromFrame(frame);
|
|
14736
|
-
}).catch(() => void 0);
|
|
14737
|
-
};
|
|
14738
|
-
page.on("framenavigated", onNavigation);
|
|
14739
|
-
const cleanup = async () => {
|
|
14740
|
-
stopped = true;
|
|
14741
|
-
page.off("framenavigated", onNavigation);
|
|
14742
|
-
await cdp.send("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => void 0);
|
|
14743
|
-
await Promise.all(page.frames().map(removeFromFrame));
|
|
14744
|
-
};
|
|
14745
|
-
try {
|
|
14746
|
-
await Promise.all(page.frames().map((frame) => frame.evaluate(source)));
|
|
14747
|
-
return cleanup;
|
|
14748
|
-
} catch (error51) {
|
|
14749
|
-
await cleanup();
|
|
14750
|
-
throw error51;
|
|
14541
|
+
// src/control-plane.ts
|
|
14542
|
+
import { createReadStream } from "fs";
|
|
14543
|
+
import { readFile, stat } from "fs/promises";
|
|
14544
|
+
import { Upload } from "tus-js-client";
|
|
14545
|
+
var ControlPlaneError = class extends Error {
|
|
14546
|
+
constructor(code, message, recovery, details, httpStatus) {
|
|
14547
|
+
super(`${code}: ${message}${recovery ? `: ${recovery}` : ""}`);
|
|
14548
|
+
this.code = code;
|
|
14549
|
+
this.recovery = recovery;
|
|
14550
|
+
this.details = details;
|
|
14551
|
+
this.httpStatus = httpStatus;
|
|
14552
|
+
this.name = "ControlPlaneError";
|
|
14751
14553
|
}
|
|
14752
|
-
}
|
|
14753
|
-
|
|
14754
|
-
|
|
14755
|
-
|
|
14756
|
-
|
|
14757
|
-
|
|
14758
|
-
|
|
14759
|
-
|
|
14760
|
-
|
|
14761
|
-
|
|
14762
|
-
|
|
14763
|
-
|
|
14764
|
-
|
|
14765
|
-
|
|
14766
|
-
|
|
14767
|
-
|
|
14768
|
-
|
|
14769
|
-
|
|
14770
|
-
|
|
14771
|
-
|
|
14772
|
-
`);
|
|
14773
|
-
|
|
14774
|
-
|
|
14775
|
-
|
|
14776
|
-
|
|
14777
|
-
|
|
14778
|
-
|
|
14779
|
-
|
|
14780
|
-
|
|
14781
|
-
}
|
|
14782
|
-
|
|
14554
|
+
};
|
|
14555
|
+
var ControlPlaneHttpError = class extends Error {
|
|
14556
|
+
constructor(httpStatus) {
|
|
14557
|
+
super(`Request failed (${httpStatus}).`);
|
|
14558
|
+
this.httpStatus = httpStatus;
|
|
14559
|
+
this.name = "ControlPlaneHttpError";
|
|
14560
|
+
}
|
|
14561
|
+
};
|
|
14562
|
+
var ControlPlaneClient = class {
|
|
14563
|
+
constructor(apiUrl, token) {
|
|
14564
|
+
this.apiUrl = apiUrl;
|
|
14565
|
+
this.token = token;
|
|
14566
|
+
}
|
|
14567
|
+
encodingCapabilities() {
|
|
14568
|
+
return this.request("/api/encoding/capabilities");
|
|
14569
|
+
}
|
|
14570
|
+
beginEncoding(id, manifest) {
|
|
14571
|
+
return this.request(`/api/encoding/${encodeURIComponent(id)}`, { method: "POST", body: JSON.stringify(manifest) });
|
|
14572
|
+
}
|
|
14573
|
+
encodingStatus(id) {
|
|
14574
|
+
return this.request(`/api/encoding/${encodeURIComponent(id)}`);
|
|
14575
|
+
}
|
|
14576
|
+
uploadEncodingPart(id, part, bytes) {
|
|
14577
|
+
return this.request(`/api/encoding/${encodeURIComponent(id)}/parts/${part}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream", "Content-Length": String(bytes.length) }, body: new Uint8Array(bytes), signal: AbortSignal.timeout(12e4) });
|
|
14578
|
+
}
|
|
14579
|
+
completeEncoding(id) {
|
|
14580
|
+
return this.request(`/api/encoding/${encodeURIComponent(id)}/complete`, { method: "POST" });
|
|
14581
|
+
}
|
|
14582
|
+
retryEncoding(id) {
|
|
14583
|
+
return this.request(`/api/encoding/${encodeURIComponent(id)}/retry`, { method: "POST" });
|
|
14584
|
+
}
|
|
14585
|
+
createCloudRun(input) {
|
|
14586
|
+
return this.request("/api/cloud/runs", { method: "POST", body: JSON.stringify(input) });
|
|
14587
|
+
}
|
|
14588
|
+
cloudRun(id) {
|
|
14589
|
+
return this.request(`/api/cloud/runs/${encodeURIComponent(id)}`);
|
|
14590
|
+
}
|
|
14591
|
+
cancelCloudRun(id) {
|
|
14592
|
+
return this.request(`/api/cloud/runs/${encodeURIComponent(id)}/cancel`, { method: "POST" });
|
|
14593
|
+
}
|
|
14594
|
+
health() {
|
|
14595
|
+
return this.request("/api/health", {}, false);
|
|
14596
|
+
}
|
|
14597
|
+
agentSession() {
|
|
14598
|
+
return this.request("/api/agent/session", { signal: AbortSignal.timeout(5e3) });
|
|
14599
|
+
}
|
|
14600
|
+
reportCliCommand(input) {
|
|
14601
|
+
return this.request("/api/analytics/cli-command", { method: "POST", body: JSON.stringify(input) });
|
|
14602
|
+
}
|
|
14603
|
+
createRecording(input) {
|
|
14604
|
+
return this.request("/api/recordings", { method: "POST", body: JSON.stringify(input) });
|
|
14605
|
+
}
|
|
14606
|
+
markRecordingStarted(recordingId, input) {
|
|
14607
|
+
return this.request(`/api/recordings/${recordingId}/local-started`, {
|
|
14608
|
+
method: "POST",
|
|
14609
|
+
body: JSON.stringify(input)
|
|
14610
|
+
});
|
|
14611
|
+
}
|
|
14612
|
+
markRecordingStopped(recordingId, input) {
|
|
14613
|
+
return this.request(`/api/recordings/${recordingId}/local-stopped`, {
|
|
14614
|
+
method: "POST",
|
|
14615
|
+
body: JSON.stringify(input)
|
|
14616
|
+
});
|
|
14617
|
+
}
|
|
14618
|
+
submitRecordingFeedback(recordingId, input) {
|
|
14619
|
+
return this.request(recordingId ? `/api/recordings/${recordingId}/feedback` : "/api/feedback", {
|
|
14620
|
+
method: "POST",
|
|
14621
|
+
body: JSON.stringify(input)
|
|
14622
|
+
});
|
|
14623
|
+
}
|
|
14624
|
+
provisionVideo(recordingId, input) {
|
|
14625
|
+
return this.request(`/api/recordings/${recordingId}/video-upload`, { method: "POST", body: JSON.stringify(input) });
|
|
14626
|
+
}
|
|
14627
|
+
recordingStatus(recordingId) {
|
|
14628
|
+
return this.request(`/api/recordings/${recordingId}`);
|
|
14629
|
+
}
|
|
14630
|
+
cancelRecording(recordingId, reason = "cancelled_by_agent") {
|
|
14631
|
+
return this.request(`/api/recordings/${recordingId}/cancel`, {
|
|
14632
|
+
method: "POST",
|
|
14633
|
+
body: JSON.stringify({ reason })
|
|
14634
|
+
});
|
|
14635
|
+
}
|
|
14636
|
+
async waitUntilPlayable(recordingId, timeoutMs = 10 * 6e4) {
|
|
14637
|
+
const deadline = Date.now() + timeoutMs;
|
|
14638
|
+
let intervalMs = 1e3;
|
|
14639
|
+
while (Date.now() < deadline) {
|
|
14640
|
+
const recording = await this.recordingStatus(recordingId);
|
|
14641
|
+
if (recording.status === "ready" && recording.shareUrl) {
|
|
14642
|
+
return { ...recording, shareUrl: new URL(recording.shareUrl, this.apiUrl).toString() };
|
|
14643
|
+
}
|
|
14644
|
+
if (recording.status === "failed") throw new Error(`processing_failed: ${recording.failure_message ?? "Cloudflare Stream could not process the recording."}`);
|
|
14645
|
+
process.stderr.write(`\rprocessing ${recording.status.padEnd(12)}`);
|
|
14646
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
14647
|
+
intervalMs = Math.min(5e3, Math.round(intervalMs * 1.4));
|
|
14648
|
+
}
|
|
14649
|
+
throw new Error("processing_failed: Timed out waiting for the recording to become playable. Run `rill record status <recording-id>` to continue checking.");
|
|
14650
|
+
}
|
|
14651
|
+
async uploadVideo(uploadUrl, filePath) {
|
|
14652
|
+
const file2 = await stat(filePath);
|
|
14653
|
+
if (uploadUrl.startsWith(this.apiUrl)) {
|
|
14654
|
+
const response = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Length": String(file2.size) }, body: createReadStream(filePath), duplex: "half" });
|
|
14655
|
+
if (!response.ok) throw new Error(`Video upload failed (${response.status}).`);
|
|
14656
|
+
return;
|
|
14657
|
+
}
|
|
14658
|
+
await new Promise((resolve, reject) => {
|
|
14659
|
+
const upload = new Upload(createReadStream(filePath), {
|
|
14660
|
+
uploadUrl,
|
|
14661
|
+
uploadSize: file2.size,
|
|
14662
|
+
retryDelays: [0, 1e3, 3e3, 5e3, 1e4],
|
|
14663
|
+
removeFingerprintOnSuccess: true,
|
|
14664
|
+
onError: reject,
|
|
14665
|
+
onSuccess: () => resolve(),
|
|
14666
|
+
onProgress: (sent, total) => process.stderr.write(`\ruploading ${Math.round(sent / total * 100)}%`)
|
|
14667
|
+
});
|
|
14668
|
+
upload.start();
|
|
14669
|
+
});
|
|
14670
|
+
process.stderr.write("\n");
|
|
14671
|
+
}
|
|
14672
|
+
async uploadDiagnostics(recordingId, filePath, summary) {
|
|
14673
|
+
const body = await readFile(filePath);
|
|
14674
|
+
return this.request(`/api/recordings/${recordingId}/diagnostics`, { method: "PUT", headers: { "Content-Encoding": "gzip", "Content-Type": "application/x-ndjson", "Content-Length": String(body.length), "X-Rill-Summary": JSON.stringify(summary) }, body });
|
|
14675
|
+
}
|
|
14676
|
+
listRecordings() {
|
|
14677
|
+
return this.request("/api/recordings");
|
|
14678
|
+
}
|
|
14679
|
+
createDeviceAuthorization(input) {
|
|
14680
|
+
return this.request("/api/device/authorizations", { method: "POST", body: JSON.stringify(input) }, false);
|
|
14681
|
+
}
|
|
14682
|
+
pollDeviceAuthorization(deviceCode) {
|
|
14683
|
+
return this.request("/api/device/token", { method: "POST", body: JSON.stringify({ deviceCode }) }, false);
|
|
14684
|
+
}
|
|
14685
|
+
inspect(shareUrl) {
|
|
14686
|
+
if (/^rec_[a-zA-Z0-9_-]+$/.test(shareUrl)) return this.request(`/api/recordings/${encodeURIComponent(shareUrl)}`);
|
|
14687
|
+
const url2 = new URL(shareUrl);
|
|
14688
|
+
const slug = url2.pathname.split("/").filter(Boolean).at(-1);
|
|
14689
|
+
if (!slug) throw new Error("The share URL does not contain a slug.");
|
|
14690
|
+
return fetch(`${url2.origin}/api/public/shares/${encodeURIComponent(slug)}/context`, { headers: { Accept: "application/json" } }).then(async (response) => {
|
|
14691
|
+
if (!response.ok) throw new Error(`Could not inspect the share link (${response.status}).`);
|
|
14692
|
+
return response.json();
|
|
14693
|
+
});
|
|
14694
|
+
}
|
|
14695
|
+
async request(path, init = {}, authenticated = true) {
|
|
14696
|
+
const headers = new Headers(init.headers);
|
|
14697
|
+
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
|
14698
|
+
if (authenticated) {
|
|
14699
|
+
const token = typeof this.token === "function" ? await this.token() : this.token;
|
|
14700
|
+
if (!token) throw new Error("authentication_required: Run `rill login` or set RILL_TOKEN.");
|
|
14701
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
14702
|
+
}
|
|
14703
|
+
const response = await fetch(`${this.apiUrl}${path}`, { ...init, headers });
|
|
14704
|
+
const value = await response.json().catch(() => null);
|
|
14705
|
+
if (!response.ok && value?.error?.code && value.error.message) {
|
|
14706
|
+
throw new ControlPlaneError(value.error.code, value.error.message, value.error.recovery, value.error.details, response.status);
|
|
14707
|
+
}
|
|
14708
|
+
if (!response.ok) throw new ControlPlaneHttpError(response.status);
|
|
14709
|
+
return value;
|
|
14710
|
+
}
|
|
14711
|
+
};
|
|
14712
|
+
|
|
14713
|
+
// src/remote-encoding.ts
|
|
14714
|
+
async function withFrameUploadRetry(upload, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
14715
|
+
for (let attempt = 0; ; attempt++) {
|
|
14716
|
+
try {
|
|
14717
|
+
return await upload();
|
|
14718
|
+
} catch (error51) {
|
|
14719
|
+
const status = error51 instanceof ControlPlaneError || error51 instanceof ControlPlaneHttpError ? error51.httpStatus : void 0;
|
|
14720
|
+
const transient = status !== void 0 ? status === 408 || status === 429 || status >= 500 : error51 instanceof TypeError || error51 instanceof DOMException && ["TimeoutError", "AbortError"].includes(error51.name);
|
|
14721
|
+
if (!transient || attempt >= 7) throw error51;
|
|
14722
|
+
const delay = Math.min(15e3, 1e3 * 2 ** attempt);
|
|
14723
|
+
process.stderr.write(`
|
|
14724
|
+
Frame upload interrupted; retrying in ${delay / 1e3}s.
|
|
14725
|
+
`);
|
|
14726
|
+
await sleep(delay);
|
|
14727
|
+
}
|
|
14728
|
+
}
|
|
14729
|
+
}
|
|
14730
|
+
async function submitFrameCapture(client, id, directory) {
|
|
14731
|
+
const manifest = frameManifestSchema.parse(JSON.parse(await readFile2(join(directory, "capture-manifest.json"), "utf8")));
|
|
14732
|
+
let status = await client.beginEncoding(id, manifest);
|
|
14733
|
+
if (status.status === "failed") status = await client.retryEncoding(id);
|
|
14734
|
+
if (status.status !== "uploading") {
|
|
14735
|
+
if (["expired", "cancelled"].includes(status.status)) throw new Error(`Capture ${status.status}; it cannot be retried.`);
|
|
14736
|
+
return status;
|
|
14737
|
+
}
|
|
14738
|
+
const file2 = await open(join(directory, "frames.bin"), "r");
|
|
14739
|
+
try {
|
|
14740
|
+
const total = manifest.frames.reduce((n, f) => n + f.size, 0);
|
|
14741
|
+
if ((await file2.stat()).size !== total) throw new Error("Capture bytes do not match the manifest.");
|
|
14742
|
+
const uploaded = new Set(status.uploadedParts);
|
|
14743
|
+
for (let part = 0; part < Math.ceil(total / ENCODING_PART_BYTES); part++) {
|
|
14744
|
+
if (uploaded.has(part)) continue;
|
|
14745
|
+
const bytes = Buffer.alloc(Math.min(ENCODING_PART_BYTES, total - part * ENCODING_PART_BYTES));
|
|
14746
|
+
let offset = 0;
|
|
14747
|
+
while (offset < bytes.length) {
|
|
14748
|
+
const r = await file2.read(bytes, offset, bytes.length - offset, part * ENCODING_PART_BYTES + offset);
|
|
14749
|
+
if (!r.bytesRead) throw new Error("Incomplete capture bytes.");
|
|
14750
|
+
offset += r.bytesRead;
|
|
14751
|
+
}
|
|
14752
|
+
await withFrameUploadRetry(() => client.uploadEncodingPart(id, part, bytes));
|
|
14753
|
+
process.stderr.write(`\ruploading frames ${Math.round(Math.min(total, (part + 1) * ENCODING_PART_BYTES) / total * 100)}%`);
|
|
14754
|
+
}
|
|
14755
|
+
} finally {
|
|
14756
|
+
await file2.close();
|
|
14757
|
+
}
|
|
14758
|
+
return client.completeEncoding(id);
|
|
14759
|
+
}
|
|
14760
|
+
async function waitForEncoding(client, id, timeoutMs = 30 * 6e4) {
|
|
14761
|
+
const deadline = Date.now() + timeoutMs;
|
|
14762
|
+
while (Date.now() < deadline) {
|
|
14763
|
+
const status = await client.encodingStatus(id);
|
|
14764
|
+
if (status.status === "ready") return { ...status, ...await client.recordingStatus(id), recordingId: id };
|
|
14765
|
+
if (["failed", "expired", "cancelled"].includes(status.status)) throw new Error(`${status.error ?? status.status}. Retry with rill record retry ${id}; capture expires ${status.expiresAt}.`);
|
|
14766
|
+
process.stderr.write(`\r${status.status.padEnd(20)}`);
|
|
14767
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
14768
|
+
}
|
|
14769
|
+
throw new Error(`Encoding continues remotely. Check rill record status ${id}.`);
|
|
14770
|
+
}
|
|
14771
|
+
|
|
14772
|
+
// src/cli.ts
|
|
14773
|
+
import { spawn as spawn6 } from "child_process";
|
|
14774
|
+
import { hostname as hostname4 } from "os";
|
|
14775
|
+
import { Command } from "commander";
|
|
14776
|
+
import { basename, dirname as dirname2 } from "path";
|
|
14777
|
+
import { readdir as readdir2, rm as rm5, stat as stat4 } from "fs/promises";
|
|
14778
|
+
|
|
14779
|
+
// ../recorder/src/client.ts
|
|
14780
|
+
import { request } from "http";
|
|
14781
|
+
import { spawn } from "child_process";
|
|
14782
|
+
|
|
14783
|
+
// ../recorder/src/paths.ts
|
|
14784
|
+
import { homedir, tmpdir } from "os";
|
|
14785
|
+
import { join as join2 } from "path";
|
|
14786
|
+
var rillDirectory = () => process.env.RILL_HOME ?? join2(homedir(), ".rill");
|
|
14787
|
+
var daemonSocketPath = () => process.env.RILL_SOCKET ?? join2(rillDirectory(), "recorder-v2.sock");
|
|
14788
|
+
var daemonStatePath = () => join2(rillDirectory(), "sessions-v2.json");
|
|
14789
|
+
var recordingTempRoot = () => process.env.RILL_TEMP ?? join2(tmpdir(), "rill-recordings");
|
|
14790
|
+
|
|
14791
|
+
// ../recorder/src/client.ts
|
|
14792
|
+
async function ensureDaemon(cliEntryPath) {
|
|
14793
|
+
try {
|
|
14794
|
+
await callDaemon("GET", "/health");
|
|
14795
|
+
return;
|
|
14796
|
+
} catch {
|
|
14797
|
+
}
|
|
14798
|
+
const child = spawn(process.execPath, [cliEntryPath, "daemon"], { detached: true, stdio: "ignore", env: process.env });
|
|
14799
|
+
child.unref();
|
|
14800
|
+
const deadline = Date.now() + 1e4;
|
|
14801
|
+
while (Date.now() < deadline) {
|
|
14802
|
+
try {
|
|
14803
|
+
await callDaemon("GET", "/health");
|
|
14804
|
+
return;
|
|
14805
|
+
} catch {
|
|
14806
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
14807
|
+
}
|
|
14808
|
+
}
|
|
14809
|
+
throw new Error("The local recorder service did not start.");
|
|
14810
|
+
}
|
|
14811
|
+
function startRecording(input) {
|
|
14812
|
+
return callDaemon("POST", "/sessions", input);
|
|
14813
|
+
}
|
|
14814
|
+
function stopRecording(recordingId) {
|
|
14815
|
+
return callDaemon("POST", `/sessions/${encodeURIComponent(recordingId)}/stop`, {});
|
|
14816
|
+
}
|
|
14817
|
+
function recordingStatus(recordingId) {
|
|
14818
|
+
return callDaemon("GET", `/sessions/${encodeURIComponent(recordingId)}`);
|
|
14819
|
+
}
|
|
14820
|
+
function callDaemon(method, path, body) {
|
|
14821
|
+
return new Promise((resolve, reject) => {
|
|
14822
|
+
const payload = body === void 0 ? void 0 : Buffer.from(JSON.stringify(body));
|
|
14823
|
+
const outgoing = request({ socketPath: daemonSocketPath(), method, path, headers: payload ? { "Content-Type": "application/json", "Content-Length": payload.length } : void 0 }, (incoming) => {
|
|
14824
|
+
const chunks = [];
|
|
14825
|
+
incoming.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
14826
|
+
incoming.on("end", () => {
|
|
14827
|
+
try {
|
|
14828
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
14829
|
+
if ((incoming.statusCode ?? 500) >= 400) reject(new Error(value.error?.message ?? `Recorder returned ${incoming.statusCode}.`));
|
|
14830
|
+
else resolve(value);
|
|
14831
|
+
} catch (error51) {
|
|
14832
|
+
reject(error51);
|
|
14833
|
+
}
|
|
14834
|
+
});
|
|
14835
|
+
});
|
|
14836
|
+
outgoing.once("error", reject);
|
|
14837
|
+
if (payload) outgoing.write(payload);
|
|
14838
|
+
outgoing.end();
|
|
14839
|
+
});
|
|
14840
|
+
}
|
|
14841
|
+
|
|
14842
|
+
// ../recorder/src/retention.ts
|
|
14843
|
+
import { readdir, readFile as readFile3, rm } from "fs/promises";
|
|
14844
|
+
import { join as join3 } from "path";
|
|
14845
|
+
async function purgeExpiredRemoteCaptures(root, now = Date.now()) {
|
|
14846
|
+
const removed = [];
|
|
14847
|
+
for (const id of await readdir(root).catch(() => [])) {
|
|
14848
|
+
if (!/^rec_[a-zA-Z0-9_-]+$/.test(id)) continue;
|
|
14849
|
+
const directory = join3(root, id);
|
|
14850
|
+
try {
|
|
14851
|
+
const marker = JSON.parse(await readFile3(join3(directory, "remote-capture.json"), "utf8"));
|
|
14852
|
+
if (typeof marker.expiresAt !== "number" || marker.expiresAt > now) continue;
|
|
14853
|
+
await rm(directory, { recursive: true, force: true });
|
|
14854
|
+
removed.push(id);
|
|
14855
|
+
} catch {
|
|
14856
|
+
}
|
|
14857
|
+
}
|
|
14858
|
+
return removed;
|
|
14859
|
+
}
|
|
14860
|
+
async function hasRetainedRemoteCaptures(root) {
|
|
14861
|
+
for (const id of await readdir(root).catch(() => [])) {
|
|
14862
|
+
if (!/^rec_[a-zA-Z0-9_-]+$/.test(id)) continue;
|
|
14863
|
+
try {
|
|
14864
|
+
const marker = JSON.parse(await readFile3(join3(root, id, "remote-capture.json"), "utf8"));
|
|
14865
|
+
if (typeof marker.expiresAt === "number" && Number.isFinite(marker.expiresAt)) return true;
|
|
14866
|
+
} catch {
|
|
14867
|
+
}
|
|
14868
|
+
}
|
|
14869
|
+
return false;
|
|
14870
|
+
}
|
|
14871
|
+
|
|
14872
|
+
// ../recorder/src/daemon.ts
|
|
14873
|
+
import { createServer as createServer2 } from "http";
|
|
14874
|
+
import { mkdir as mkdir4, readFile as readFile5, unlink, writeFile as writeFile4, rename, stat as stat2 } from "fs/promises";
|
|
14875
|
+
import { dirname, join as join7 } from "path";
|
|
14876
|
+
|
|
14877
|
+
// ../contracts/src/recording.ts
|
|
14878
|
+
var cursorAppearanceSchema = external_exports.object({
|
|
14879
|
+
enabled: external_exports.boolean().default(true),
|
|
14880
|
+
color: external_exports.string().regex(/^#[0-9a-fA-F]{6}$/).default("#6366f1")
|
|
14881
|
+
});
|
|
14882
|
+
var recordingStatusSchema = external_exports.enum([
|
|
14883
|
+
"draft",
|
|
14884
|
+
"recording",
|
|
14885
|
+
"uploading",
|
|
14886
|
+
"processing",
|
|
14887
|
+
"ready",
|
|
14888
|
+
"failed",
|
|
14889
|
+
"revoked"
|
|
14890
|
+
]);
|
|
14891
|
+
var recordingSchema = external_exports.object({
|
|
14892
|
+
id: external_exports.string(),
|
|
14893
|
+
title: external_exports.string(),
|
|
14894
|
+
description: external_exports.string().default(""),
|
|
14895
|
+
status: recordingStatusSchema,
|
|
14896
|
+
sourceUrl: external_exports.string().nullable(),
|
|
14897
|
+
createdAt: external_exports.string().datetime(),
|
|
14898
|
+
durationSeconds: external_exports.number().nonnegative(),
|
|
14899
|
+
width: external_exports.number().int().positive(),
|
|
14900
|
+
height: external_exports.number().int().positive(),
|
|
14901
|
+
shareSlug: external_exports.string().nullable(),
|
|
14902
|
+
shareUrl: external_exports.string().nullable(),
|
|
14903
|
+
thumbnailUrl: external_exports.string().nullable(),
|
|
14904
|
+
playbackUrl: external_exports.string().nullable(),
|
|
14905
|
+
expiresAt: external_exports.string().datetime().nullable(),
|
|
14906
|
+
diagnostics: external_exports.object({
|
|
14907
|
+
available: external_exports.boolean(),
|
|
14908
|
+
bodyCaptureEnabled: external_exports.boolean(),
|
|
14909
|
+
consoleErrors: external_exports.number().int().nonnegative(),
|
|
14910
|
+
failedRequests: external_exports.number().int().nonnegative(),
|
|
14911
|
+
httpErrors: external_exports.number().int().nonnegative(),
|
|
14912
|
+
truncated: external_exports.boolean()
|
|
14913
|
+
})
|
|
14914
|
+
});
|
|
14915
|
+
var agentOutcomeSchema = external_exports.object({
|
|
14916
|
+
outcome: external_exports.enum(["walkthrough", "passed", "failed", "inconclusive"]),
|
|
14917
|
+
summary: external_exports.string().min(1).max(1e3),
|
|
14918
|
+
evidence: external_exports.string().max(1e3)
|
|
14919
|
+
}).strict();
|
|
14920
|
+
|
|
14921
|
+
// ../recorder/src/cursor-overlay.ts
|
|
14922
|
+
function installCursorOverlay(options) {
|
|
14923
|
+
const scope = window;
|
|
14924
|
+
if (scope[options.key]) return;
|
|
14925
|
+
let host;
|
|
14926
|
+
const controller = new AbortController();
|
|
14927
|
+
const animations = /* @__PURE__ */ new Set();
|
|
14928
|
+
const cleanup = () => {
|
|
14929
|
+
controller.abort();
|
|
14930
|
+
for (const animation of animations) animation.cancel();
|
|
14931
|
+
host?.remove();
|
|
14932
|
+
delete scope[options.key];
|
|
14933
|
+
};
|
|
14934
|
+
scope[options.key] = cleanup;
|
|
14935
|
+
const mount = () => {
|
|
14936
|
+
if (controller.signal.aborted || host || !document.documentElement) return;
|
|
14937
|
+
host = document.createElement("div");
|
|
14938
|
+
host.setAttribute("data-rill-cursor", "");
|
|
14939
|
+
host.setAttribute("aria-hidden", "true");
|
|
14940
|
+
host.style.cssText = "all:initial!important;position:fixed!important;inset:0!important;width:100%!important;height:100%!important;pointer-events:none!important;z-index:2147483647!important;";
|
|
14941
|
+
const root = host.attachShadow({ mode: "closed" });
|
|
14942
|
+
const style = document.createElement("style");
|
|
14943
|
+
style.textContent = `
|
|
14944
|
+
:host, * { pointer-events: none !important; }
|
|
14945
|
+
.pointer { position:absolute; left:0; top:0; display:none; width:24px; height:28px; }
|
|
14946
|
+
.halo { position:absolute; left:-12px; top:-12px; width:26px; height:26px; border-radius:50%; background:${options.color}; opacity:.22; }
|
|
14947
|
+
svg { position:absolute; left:-2px; top:-2px; width:24px; height:28px; filter:drop-shadow(0 1px 2px #0008); }
|
|
14948
|
+
.pulse { position:absolute; width:32px; height:32px; box-sizing:border-box; border:2px solid ${options.color}; border-radius:50%; }
|
|
14949
|
+
`;
|
|
14950
|
+
const pointer = document.createElement("div");
|
|
14951
|
+
pointer.className = "pointer";
|
|
14952
|
+
pointer.innerHTML = '<div class="halo"></div><svg viewBox="0 0 24 28" xmlns="http://www.w3.org/2000/svg"><path d="M3 2 L3 22 L8 17 L12 25 L16 23 L12 15 L20 15 Z" fill="white" stroke="#171717" stroke-width="1.5" stroke-linejoin="round"/></svg>';
|
|
14953
|
+
root.append(style, pointer);
|
|
14954
|
+
document.documentElement.appendChild(host);
|
|
14955
|
+
const listenerOptions = { capture: true, passive: true, signal: controller.signal };
|
|
14956
|
+
const hide = () => {
|
|
14957
|
+
pointer.style.display = "none";
|
|
14958
|
+
};
|
|
14959
|
+
const move = (event) => {
|
|
14960
|
+
if (event.pointerType === "touch") return;
|
|
14961
|
+
if (event.target instanceof HTMLIFrameElement || event.target instanceof HTMLFrameElement) {
|
|
14962
|
+
hide();
|
|
14963
|
+
return;
|
|
14964
|
+
}
|
|
14965
|
+
pointer.style.display = "block";
|
|
14966
|
+
pointer.style.transform = `translate(${event.clientX}px, ${event.clientY}px)`;
|
|
14967
|
+
};
|
|
14968
|
+
document.addEventListener("pointermove", move, listenerOptions);
|
|
14969
|
+
document.addEventListener("pointerover", move, listenerOptions);
|
|
14970
|
+
document.addEventListener("pointerdown", (event) => {
|
|
14971
|
+
if (event.pointerType === "touch") return;
|
|
14972
|
+
move(event);
|
|
14973
|
+
const pulse = document.createElement("div");
|
|
14974
|
+
pulse.className = "pulse";
|
|
14975
|
+
pulse.style.left = `${event.clientX - 16}px`;
|
|
14976
|
+
pulse.style.top = `${event.clientY - 16}px`;
|
|
14977
|
+
root.insertBefore(pulse, pointer);
|
|
14978
|
+
const animation = pulse.animate([
|
|
14979
|
+
{ transform: "scale(.4)", opacity: 0.9 },
|
|
14980
|
+
{ transform: "scale(1.8)", opacity: 0 }
|
|
14981
|
+
], { duration: 400, easing: "ease-out" });
|
|
14982
|
+
animations.add(animation);
|
|
14983
|
+
void animation.finished.catch(() => void 0).finally(() => {
|
|
14984
|
+
animations.delete(animation);
|
|
14985
|
+
pulse.remove();
|
|
14986
|
+
});
|
|
14987
|
+
}, listenerOptions);
|
|
14988
|
+
document.addEventListener("pointerout", (event) => {
|
|
14989
|
+
if (!event.relatedTarget) hide();
|
|
14990
|
+
}, listenerOptions);
|
|
14991
|
+
window.addEventListener("blur", hide, { signal: controller.signal });
|
|
14992
|
+
document.addEventListener("visibilitychange", () => {
|
|
14993
|
+
if (document.hidden) hide();
|
|
14994
|
+
}, { signal: controller.signal });
|
|
14995
|
+
};
|
|
14996
|
+
if (document.documentElement) mount();
|
|
14997
|
+
else document.addEventListener("DOMContentLoaded", mount, { once: true, signal: controller.signal });
|
|
14998
|
+
}
|
|
14999
|
+
async function attachCursorOverlay(page, cdp, input) {
|
|
15000
|
+
const appearance = cursorAppearanceSchema.parse(input ?? {});
|
|
15001
|
+
if (!appearance.enabled) return async () => {
|
|
15002
|
+
};
|
|
15003
|
+
const key = `__rillCursor_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
15004
|
+
const source = `(${installCursorOverlay.toString()})(${JSON.stringify({ ...appearance, key })})`;
|
|
15005
|
+
await cdp.send("Page.enable");
|
|
15006
|
+
const { identifier } = await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source });
|
|
15007
|
+
let stopped = false;
|
|
15008
|
+
const removeFromFrame = (frame) => frame.evaluate((key2) => {
|
|
15009
|
+
const cleanup2 = window[key2];
|
|
15010
|
+
if (typeof cleanup2 === "function") cleanup2();
|
|
15011
|
+
}, key).catch(() => void 0);
|
|
15012
|
+
const onNavigation = (frame) => {
|
|
15013
|
+
if (stopped) return;
|
|
15014
|
+
void frame.evaluate(source).then(() => {
|
|
15015
|
+
if (stopped) return removeFromFrame(frame);
|
|
15016
|
+
}).catch(() => void 0);
|
|
15017
|
+
};
|
|
15018
|
+
page.on("framenavigated", onNavigation);
|
|
15019
|
+
const cleanup = async () => {
|
|
15020
|
+
stopped = true;
|
|
15021
|
+
page.off("framenavigated", onNavigation);
|
|
15022
|
+
await cdp.send("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => void 0);
|
|
15023
|
+
await Promise.all(page.frames().map(removeFromFrame));
|
|
15024
|
+
};
|
|
15025
|
+
try {
|
|
15026
|
+
await Promise.all(page.frames().map((frame) => frame.evaluate(source)));
|
|
15027
|
+
return cleanup;
|
|
15028
|
+
} catch (error51) {
|
|
15029
|
+
await cleanup();
|
|
15030
|
+
throw error51;
|
|
15031
|
+
}
|
|
15032
|
+
}
|
|
15033
|
+
|
|
15034
|
+
// ../recorder/src/session.ts
|
|
15035
|
+
import { chromium } from "playwright-core";
|
|
15036
|
+
import { spawn as spawn3 } from "child_process";
|
|
15037
|
+
import { createServer } from "net";
|
|
15038
|
+
import { mkdir as mkdir3, mkdtemp, rm as rm2, writeFile as writeFile3 } from "fs/promises";
|
|
15039
|
+
import { join as join6 } from "path";
|
|
15040
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
15041
|
+
|
|
15042
|
+
// ../recorder/src/bundle.ts
|
|
15043
|
+
import { createGzip } from "zlib";
|
|
15044
|
+
import { createWriteStream } from "fs";
|
|
15045
|
+
import { once } from "events";
|
|
15046
|
+
async function writeBundle(bundle, outputPath) {
|
|
15047
|
+
const output = createWriteStream(outputPath, { mode: 384 });
|
|
15048
|
+
const gzip = createGzip({ level: 9 });
|
|
15049
|
+
gzip.pipe(output);
|
|
15050
|
+
const { console: consoleEvents, network, timeline, ...manifest } = bundle;
|
|
15051
|
+
gzip.write(`${JSON.stringify({ kind: "manifest", ...manifest })}
|
|
15052
|
+
`);
|
|
15053
|
+
for (const event of consoleEvents) gzip.write(`${JSON.stringify({ kind: "console", ...event })}
|
|
15054
|
+
`);
|
|
15055
|
+
for (const event of network) gzip.write(`${JSON.stringify({ kind: "network", ...event })}
|
|
15056
|
+
`);
|
|
15057
|
+
for (const event of timeline) gzip.write(`${JSON.stringify({ kind: "timeline", ...event })}
|
|
15058
|
+
`);
|
|
15059
|
+
gzip.end();
|
|
15060
|
+
await once(output, "close");
|
|
15061
|
+
}
|
|
15062
|
+
|
|
14783
15063
|
// ../recorder/src/environment.ts
|
|
14784
15064
|
import { existsSync } from "fs";
|
|
14785
15065
|
function findChromeExecutable() {
|
|
@@ -14835,27 +15115,70 @@ function redactJson(value, depth = 0) {
|
|
|
14835
15115
|
return value;
|
|
14836
15116
|
}
|
|
14837
15117
|
|
|
14838
|
-
// ../recorder/src/
|
|
14839
|
-
import {
|
|
15118
|
+
// ../recorder/src/encoding.ts
|
|
15119
|
+
import { open as open2, writeFile, mkdir } from "fs/promises";
|
|
14840
15120
|
import { spawn as spawn2 } from "child_process";
|
|
14841
|
-
import { join as
|
|
14842
|
-
|
|
14843
|
-
|
|
14844
|
-
|
|
14845
|
-
const
|
|
14846
|
-
|
|
14847
|
-
|
|
14848
|
-
|
|
14849
|
-
|
|
14850
|
-
|
|
14851
|
-
|
|
14852
|
-
|
|
14853
|
-
|
|
14854
|
-
}
|
|
14855
|
-
|
|
14856
|
-
|
|
14857
|
-
|
|
15121
|
+
import { join as join4 } from "path";
|
|
15122
|
+
var active = 0;
|
|
15123
|
+
var waiting = [];
|
|
15124
|
+
function encodingConcurrency() {
|
|
15125
|
+
const n = Number(process.env.RILL_ENCODING_CONCURRENCY ?? 2);
|
|
15126
|
+
if (!Number.isInteger(n) || n < 1 || n > 8) throw new Error("Invalid RILL_ENCODING_CONCURRENCY");
|
|
15127
|
+
return n;
|
|
15128
|
+
}
|
|
15129
|
+
async function withEncodingSlot(work) {
|
|
15130
|
+
if (active >= encodingConcurrency()) await new Promise((resolve) => waiting.push(resolve));
|
|
15131
|
+
else active++;
|
|
15132
|
+
try {
|
|
15133
|
+
return await work();
|
|
15134
|
+
} finally {
|
|
15135
|
+
const next = waiting.shift();
|
|
15136
|
+
if (next) next();
|
|
15137
|
+
else active--;
|
|
15138
|
+
}
|
|
15139
|
+
}
|
|
15140
|
+
async function encodeFrames(directory, manifest, output, signal) {
|
|
15141
|
+
frameManifestSchema.parse(manifest);
|
|
15142
|
+
return withEncodingSlot(async () => {
|
|
15143
|
+
signal?.throwIfAborted();
|
|
15144
|
+
const lines = ["ffconcat version 1.0"];
|
|
15145
|
+
for (let i = 0; i < manifest.frames.length; i++) {
|
|
15146
|
+
const next = manifest.frames[i + 1];
|
|
15147
|
+
const duration3 = next ? next.timestamp - manifest.frames[i].timestamp : manifest.durationSeconds - (manifest.frames[i].timestamp - manifest.frames[0].timestamp);
|
|
15148
|
+
lines.push(`file 'frame-${String(i).padStart(6, "0")}.jpg'`, `duration ${Math.max(1 / 60, duration3).toFixed(6)}`);
|
|
15149
|
+
}
|
|
15150
|
+
lines.push(`file 'frame-${String(manifest.frames.length - 1).padStart(6, "0")}.jpg'`);
|
|
15151
|
+
await writeFile(join4(directory, "frames.ffconcat"), lines.join("\n") + "\n", { mode: 384 });
|
|
15152
|
+
const threads = Number(process.env.RILL_FFMPEG_THREADS ?? 1);
|
|
15153
|
+
if (!Number.isInteger(threads) || threads < 1 || threads > 16) throw new Error("Invalid RILL_FFMPEG_THREADS");
|
|
15154
|
+
await new Promise((resolve, reject) => {
|
|
15155
|
+
const child = spawn2(process.env.RILL_FFMPEG ?? "ffmpeg", ["-hide_banner", "-loglevel", "error", "-protocol_whitelist", "file", "-f", "concat", "-safe", "1", "-i", "frames.ffconcat", "-vsync", "vfr", "-pix_fmt", "yuv420p", "-c:v", "libvpx-vp9", "-deadline", "good", "-cpu-used", "4", "-threads", String(threads), "-b:v", "0", "-crf", "32", "-y", output], { cwd: directory, stdio: ["ignore", "ignore", "pipe"] });
|
|
15156
|
+
let stderr = "";
|
|
15157
|
+
child.stderr.on("data", (chunk) => {
|
|
15158
|
+
stderr = (stderr + String(chunk)).slice(-2e3);
|
|
15159
|
+
});
|
|
15160
|
+
const abort = () => child.kill("SIGKILL");
|
|
15161
|
+
const timer = setTimeout(abort, 20 * 6e4);
|
|
15162
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
15163
|
+
const clean = () => {
|
|
15164
|
+
clearTimeout(timer);
|
|
15165
|
+
signal?.removeEventListener("abort", abort);
|
|
15166
|
+
};
|
|
15167
|
+
child.once("error", (error51) => {
|
|
15168
|
+
clean();
|
|
15169
|
+
reject(error51);
|
|
15170
|
+
});
|
|
15171
|
+
child.once("exit", (code) => {
|
|
15172
|
+
clean();
|
|
15173
|
+
code === 0 ? resolve() : reject(new Error(`ffmpeg failed (${code}): ${stderr}`));
|
|
15174
|
+
});
|
|
15175
|
+
});
|
|
15176
|
+
});
|
|
14858
15177
|
}
|
|
15178
|
+
|
|
15179
|
+
// ../recorder/src/screencast.ts
|
|
15180
|
+
import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile4, open as open3 } from "fs/promises";
|
|
15181
|
+
import { join as join5 } from "path";
|
|
14859
15182
|
var ScreencastRecorder = class {
|
|
14860
15183
|
constructor(session, directory, width, height) {
|
|
14861
15184
|
this.session = session;
|
|
@@ -14867,13 +15190,24 @@ var ScreencastRecorder = class {
|
|
|
14867
15190
|
pendingWrites = /* @__PURE__ */ new Set();
|
|
14868
15191
|
started = false;
|
|
14869
15192
|
frameCounter = 0;
|
|
15193
|
+
frameBytes = 0;
|
|
15194
|
+
captureError;
|
|
14870
15195
|
async start() {
|
|
14871
|
-
await
|
|
15196
|
+
await mkdir2(this.directory, { recursive: true, mode: 448 });
|
|
14872
15197
|
this.session.on("Page.screencastFrame", (payload) => {
|
|
14873
|
-
const
|
|
15198
|
+
const bytes = Buffer.from(payload.data, "base64");
|
|
15199
|
+
if (this.frameCounter >= 36e3 || this.frameBytes + bytes.length > ENCODING_MAX_BYTES || bytes.length > ENCODING_PART_BYTES) {
|
|
15200
|
+
this.captureError = new Error("Capture exceeds the remote encoding frame or byte limit.");
|
|
15201
|
+
void this.session.send("Page.screencastFrameAck", { sessionId: payload.sessionId }).catch(() => void 0);
|
|
15202
|
+
return;
|
|
15203
|
+
}
|
|
15204
|
+
this.frameBytes += bytes.length;
|
|
15205
|
+
const path = join5(this.directory, `frame-${String(this.frameCounter).padStart(6, "0")}.jpg`);
|
|
14874
15206
|
this.frameCounter += 1;
|
|
14875
|
-
const write =
|
|
15207
|
+
const write = writeFile2(path, bytes, { mode: 384 }).then(() => {
|
|
14876
15208
|
this.frames.push({ path, timestamp: payload.metadata.timestamp ?? Date.now() / 1e3 });
|
|
15209
|
+
}).catch((error51) => {
|
|
15210
|
+
this.captureError = error51 instanceof Error ? error51 : new Error(String(error51));
|
|
14877
15211
|
}).finally(() => {
|
|
14878
15212
|
this.pendingWrites.delete(write);
|
|
14879
15213
|
void this.session.send("Page.screencastFrameAck", { sessionId: payload.sessionId }).catch(() => void 0);
|
|
@@ -14884,21 +15218,27 @@ var ScreencastRecorder = class {
|
|
|
14884
15218
|
await this.session.send("Page.startScreencast", { format: "jpeg", quality: 82, maxWidth: this.width, maxHeight: this.height, everyNthFrame: 1 });
|
|
14885
15219
|
this.started = true;
|
|
14886
15220
|
}
|
|
14887
|
-
async stop(outputPath, expectedDurationSeconds) {
|
|
15221
|
+
async stop(outputPath, expectedDurationSeconds, captureOnly = false) {
|
|
14888
15222
|
if (this.started) await this.session.send("Page.stopScreencast").catch(() => void 0);
|
|
14889
15223
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
14890
15224
|
await Promise.all(this.pendingWrites);
|
|
15225
|
+
if (this.captureError) throw this.captureError;
|
|
14891
15226
|
if (!this.frames.length) throw new Error("No video frames were captured.");
|
|
14892
|
-
const
|
|
14893
|
-
|
|
14894
|
-
const
|
|
14895
|
-
|
|
14896
|
-
|
|
14897
|
-
|
|
14898
|
-
|
|
14899
|
-
|
|
14900
|
-
|
|
14901
|
-
|
|
15227
|
+
const ordered = this.frames.toSorted((a, b) => a.timestamp - b.timestamp);
|
|
15228
|
+
const frames = [];
|
|
15229
|
+
const bundle = await open3(join5(this.directory, "frames.bin"), "w", 384);
|
|
15230
|
+
try {
|
|
15231
|
+
for (const frame of ordered) {
|
|
15232
|
+
const bytes = await readFile4(frame.path);
|
|
15233
|
+
await bundle.writeFile(bytes);
|
|
15234
|
+
frames.push({ timestamp: frame.timestamp, size: bytes.length });
|
|
15235
|
+
}
|
|
15236
|
+
} finally {
|
|
15237
|
+
await bundle.close();
|
|
15238
|
+
}
|
|
15239
|
+
const manifest = frameManifestSchema.parse({ version: 1, durationSeconds: Math.min(600, expectedDurationSeconds), width: this.width, height: this.height, frames });
|
|
15240
|
+
await writeFile2(join5(this.directory, "capture-manifest.json"), JSON.stringify(manifest), { mode: 384 });
|
|
15241
|
+
if (!captureOnly) await encodeFrames(this.directory, manifest, outputPath);
|
|
14902
15242
|
return { frameCount: this.frames.length, outputPath };
|
|
14903
15243
|
}
|
|
14904
15244
|
};
|
|
@@ -14932,6 +15272,7 @@ var RecordingSession = class _RecordingSession {
|
|
|
14932
15272
|
bodyBytes = 0;
|
|
14933
15273
|
truncated = false;
|
|
14934
15274
|
constructor(options, runtime) {
|
|
15275
|
+
this.encoding = options.encoding ?? "local";
|
|
14935
15276
|
this.recordingId = options.recordingId;
|
|
14936
15277
|
this.cdpUrl = runtime.cdpUrl;
|
|
14937
15278
|
this.browser = runtime.browser;
|
|
@@ -14945,10 +15286,11 @@ var RecordingSession = class _RecordingSession {
|
|
|
14945
15286
|
static async start(options) {
|
|
14946
15287
|
const width = options.width ?? 1280;
|
|
14947
15288
|
const height = options.height ?? 720;
|
|
14948
|
-
await
|
|
15289
|
+
await mkdir3(options.outputDirectory, { recursive: true, mode: 448 });
|
|
15290
|
+
if (options.encoding === "remote") await writeFile3(join6(options.outputDirectory, "remote-capture.json"), JSON.stringify({ expiresAt: Date.now() + 864e5 }), { mode: 384 });
|
|
14949
15291
|
const runtime = options.cdpUrl ? await connectAttached(options.cdpUrl, options.outputDirectory) : await launchManaged({ headed: options.headed ?? false, width, height, outputDirectory: options.outputDirectory, proxyServer: options.proxyServer });
|
|
14950
15292
|
const cdpSession = await runtime.context.newCDPSession(runtime.page);
|
|
14951
|
-
const screencast = new ScreencastRecorder(cdpSession,
|
|
15293
|
+
const screencast = new ScreencastRecorder(cdpSession, join6(options.outputDirectory, "frames"), width, height);
|
|
14952
15294
|
const session = new _RecordingSession(options, { ...runtime, screencast });
|
|
14953
15295
|
try {
|
|
14954
15296
|
await session.attachDiagnostics(options.captureNetworkBodies ?? false);
|
|
@@ -14980,8 +15322,8 @@ var RecordingSession = class _RecordingSession {
|
|
|
14980
15322
|
if (this.maxTimer) clearTimeout(this.maxTimer);
|
|
14981
15323
|
const durationSeconds = Math.max(0.1, (Date.now() - this.startedAt.getTime()) / 1e3);
|
|
14982
15324
|
const outputDirectory = this.outputDirectory;
|
|
14983
|
-
const videoPath =
|
|
14984
|
-
const diagnosticsPath =
|
|
15325
|
+
const videoPath = join6(outputDirectory, "recording.webm");
|
|
15326
|
+
const diagnosticsPath = join6(outputDirectory, "diagnostics-v1.ndjson.gz");
|
|
14985
15327
|
const bundle = {
|
|
14986
15328
|
schemaVersion: 1,
|
|
14987
15329
|
capturePolicyVersion: "safe-default-v1",
|
|
@@ -14998,21 +15340,22 @@ var RecordingSession = class _RecordingSession {
|
|
|
14998
15340
|
try {
|
|
14999
15341
|
const safeBundle = JSON.parse(this.secretValues.reduce((text, secret) => text.split(JSON.stringify(secret).slice(1, -1)).join("[redacted]"), JSON.stringify(bundle)));
|
|
15000
15342
|
await writeBundle(safeBundle, diagnosticsPath);
|
|
15001
|
-
await this.screencast.stop(videoPath, durationSeconds);
|
|
15343
|
+
await this.screencast.stop(videoPath, durationSeconds, this.encoding === "remote");
|
|
15002
15344
|
} finally {
|
|
15003
15345
|
await this.removeCursorOverlay?.();
|
|
15004
15346
|
if (this.browserProcess) {
|
|
15005
15347
|
await this.browser.close().catch(() => void 0);
|
|
15006
15348
|
await terminateBrowser(this.browserProcess);
|
|
15007
|
-
if (this.profileDirectory) await
|
|
15349
|
+
if (this.profileDirectory) await rm2(this.profileDirectory, { recursive: true, force: true }).catch(() => void 0);
|
|
15008
15350
|
}
|
|
15009
15351
|
}
|
|
15010
15352
|
const consoleErrors = this.consoleEvents.filter((event) => event.severity === "error").length;
|
|
15011
15353
|
const failedRequests = this.networkEvents.filter((event) => event.failureReason).length;
|
|
15012
15354
|
const httpErrors = this.networkEvents.filter((event) => (event.status ?? 0) >= 400).length;
|
|
15013
|
-
return { recordingId: this.recordingId, videoPath, diagnosticsPath, durationSeconds, stoppedReason: reason, diagnostics: { consoleErrors, failedRequests, httpErrors, truncated: this.truncated, bodyCaptureEnabled: this.captureNetworkBodies } };
|
|
15355
|
+
return { recordingId: this.recordingId, videoPath, ...this.encoding === "remote" ? { frameDirectory: join6(outputDirectory, "frames") } : {}, diagnosticsPath, durationSeconds, stoppedReason: reason, diagnostics: { consoleErrors, failedRequests, httpErrors, truncated: this.truncated, bodyCaptureEnabled: this.captureNetworkBodies } };
|
|
15014
15356
|
}
|
|
15015
15357
|
captureNetworkBodies = false;
|
|
15358
|
+
encoding = "local";
|
|
15016
15359
|
outputDirectory = "";
|
|
15017
15360
|
async attachDiagnostics(captureNetworkBodies) {
|
|
15018
15361
|
this.captureNetworkBodies = captureNetworkBodies;
|
|
@@ -15061,34 +15404,34 @@ var RecordingSession = class _RecordingSession {
|
|
|
15061
15404
|
this.consoleEvents.push(event);
|
|
15062
15405
|
pushTimeline({ offsetMs: event.offsetMs, type: "exception", label: event.text.slice(0, 160), severity: "error" });
|
|
15063
15406
|
});
|
|
15064
|
-
this.page.on("request", (
|
|
15407
|
+
this.page.on("request", (request3) => {
|
|
15065
15408
|
if (this.diagnosticsPaused) return;
|
|
15066
15409
|
if (this.networkEvents.length >= 5e3) {
|
|
15067
15410
|
this.truncated = true;
|
|
15068
15411
|
return;
|
|
15069
15412
|
}
|
|
15070
15413
|
const started = offset();
|
|
15071
|
-
this.requestStart.set(
|
|
15072
|
-
const event = { id: `net_${this.networkEvents.length + 1}`, offsetMs: started, method:
|
|
15073
|
-
if (captureNetworkBodies &&
|
|
15074
|
-
const redacted = redactBody(
|
|
15414
|
+
this.requestStart.set(request3, started);
|
|
15415
|
+
const event = { id: `net_${this.networkEvents.length + 1}`, offsetMs: started, method: request3.method(), url: sanitizeUrl(request3.url()), resourceType: request3.resourceType() };
|
|
15416
|
+
if (captureNetworkBodies && request3.postData()) {
|
|
15417
|
+
const redacted = redactBody(request3.postData(), request3.headers()["content-type"]);
|
|
15075
15418
|
event.requestBody = redacted.body;
|
|
15076
15419
|
event.bodyOmittedReason = redacted.omittedReason;
|
|
15077
15420
|
if (redacted.body) this.bodyBytes += Buffer.byteLength(redacted.body);
|
|
15078
15421
|
}
|
|
15079
15422
|
this.networkEvents.push(event);
|
|
15080
|
-
this.requestEvents.set(
|
|
15423
|
+
this.requestEvents.set(request3, event);
|
|
15081
15424
|
});
|
|
15082
15425
|
this.page.on("response", (response) => this.updateResponse(response));
|
|
15083
|
-
this.page.on("requestfailed", (
|
|
15084
|
-
const event = this.requestEvents.get(
|
|
15426
|
+
this.page.on("requestfailed", (request3) => {
|
|
15427
|
+
const event = this.requestEvents.get(request3);
|
|
15085
15428
|
if (!event) return;
|
|
15086
|
-
event.failureReason =
|
|
15087
|
-
event.durationMs = Math.max(0, offset() - (this.requestStart.get(
|
|
15429
|
+
event.failureReason = request3.failure()?.errorText ?? "request_failed";
|
|
15430
|
+
event.durationMs = Math.max(0, offset() - (this.requestStart.get(request3) ?? offset()));
|
|
15088
15431
|
pushTimeline({ offsetMs: event.offsetMs, type: "network", label: `${event.method} ${event.url} \u2192 ${event.failureReason}`, severity: "error" });
|
|
15089
15432
|
});
|
|
15090
|
-
this.page.on("requestfinished", async (
|
|
15091
|
-
const response = await
|
|
15433
|
+
this.page.on("requestfinished", async (request3) => {
|
|
15434
|
+
const response = await request3.response();
|
|
15092
15435
|
if (response) await this.captureResponseBody(response);
|
|
15093
15436
|
});
|
|
15094
15437
|
}
|
|
@@ -15121,7 +15464,7 @@ var RecordingSession = class _RecordingSession {
|
|
|
15121
15464
|
};
|
|
15122
15465
|
async function launchManaged(options) {
|
|
15123
15466
|
const port = await getAvailablePort();
|
|
15124
|
-
const profileDirectory = await mkdtemp(
|
|
15467
|
+
const profileDirectory = await mkdtemp(join6(tmpdir2(), "rill-chrome-"));
|
|
15125
15468
|
const executable = findChromeExecutable();
|
|
15126
15469
|
const args = [
|
|
15127
15470
|
`--remote-debugging-port=${port}`,
|
|
@@ -15150,7 +15493,7 @@ async function launchManaged(options) {
|
|
|
15150
15493
|
return { cdpUrl, browser, context, page, browserProcess, profileDirectory };
|
|
15151
15494
|
} catch (error51) {
|
|
15152
15495
|
await terminateBrowser(browserProcess);
|
|
15153
|
-
await
|
|
15496
|
+
await rm2(profileDirectory, { recursive: true, force: true }).catch(() => void 0);
|
|
15154
15497
|
throw error51;
|
|
15155
15498
|
}
|
|
15156
15499
|
}
|
|
@@ -15201,17 +15544,22 @@ var sessions = /* @__PURE__ */ new Map();
|
|
|
15201
15544
|
var lastActivity = Date.now();
|
|
15202
15545
|
async function runDaemon() {
|
|
15203
15546
|
const socketPath = daemonSocketPath();
|
|
15204
|
-
await
|
|
15205
|
-
await
|
|
15547
|
+
await mkdir4(dirname(socketPath), { recursive: true, mode: 448 });
|
|
15548
|
+
await mkdir4(recordingTempRoot(), { recursive: true, mode: 448 });
|
|
15206
15549
|
await restoreRecoverableSessions();
|
|
15550
|
+
await purgeExpiredCaptures();
|
|
15207
15551
|
await unlink(socketPath).catch((error51) => {
|
|
15208
15552
|
if (error51.code !== "ENOENT") throw error51;
|
|
15209
15553
|
});
|
|
15210
|
-
const server = createServer2((
|
|
15554
|
+
const server = createServer2((request3, response) => void route(request3, response));
|
|
15211
15555
|
server.listen(socketPath, () => process.platform !== "win32" && import("fs/promises").then(({ chmod }) => chmod(socketPath, 384)));
|
|
15212
15556
|
const idleTimer = setInterval(() => {
|
|
15213
|
-
|
|
15214
|
-
|
|
15557
|
+
void (async () => {
|
|
15558
|
+
await purgeExpiredCaptures();
|
|
15559
|
+
const hasActiveSession = [...sessions.values()].some((entry) => entry.state === "recording" || entry.state === "finalizing");
|
|
15560
|
+
const hasRetainedCapture = await hasRetainedRemoteCaptures(recordingTempRoot());
|
|
15561
|
+
if (!hasActiveSession && !hasRetainedCapture && Date.now() - lastActivity > 5 * 6e4) server.close(() => process.exit(0));
|
|
15562
|
+
})().catch(() => void 0);
|
|
15215
15563
|
}, 3e4);
|
|
15216
15564
|
idleTimer.unref();
|
|
15217
15565
|
const close = () => {
|
|
@@ -15222,16 +15570,16 @@ async function runDaemon() {
|
|
|
15222
15570
|
process.once("SIGTERM", close);
|
|
15223
15571
|
process.once("SIGINT", close);
|
|
15224
15572
|
}
|
|
15225
|
-
async function route(
|
|
15573
|
+
async function route(request3, response) {
|
|
15226
15574
|
lastActivity = Date.now();
|
|
15227
|
-
const url2 = new URL(
|
|
15228
|
-
if (
|
|
15229
|
-
if (
|
|
15575
|
+
const url2 = new URL(request3.url ?? "/", "http://daemon.local");
|
|
15576
|
+
if (request3.method === "GET" && url2.pathname === "/health") return json2(response, 200, { ok: true, pid: process.pid });
|
|
15577
|
+
if (request3.method === "POST" && url2.pathname === "/sessions") {
|
|
15230
15578
|
if ([...sessions.values()].filter((entry) => entry.state === "recording").length >= 2) return json2(response, 429, { error: { code: "quota_exceeded", message: "This recorder already has two active sessions." } });
|
|
15231
15579
|
try {
|
|
15232
|
-
const input = await readJson(
|
|
15233
|
-
if (
|
|
15234
|
-
const outputDirectory =
|
|
15580
|
+
const input = await readJson(request3);
|
|
15581
|
+
if (!/^rec_[a-zA-Z0-9_-]+$/.test(input.recordingId)) return json2(response, 400, { error: { code: "validation_failed", message: "recordingId is required." } });
|
|
15582
|
+
const outputDirectory = join7(recordingTempRoot(), input.recordingId);
|
|
15235
15583
|
const session = await RecordingSession.start({ ...input, outputDirectory });
|
|
15236
15584
|
const status = session.status();
|
|
15237
15585
|
const entry = { session, base: { recordingId: status.recordingId, cdpUrl: status.cdpUrl, startedAt: status.startedAt }, state: "recording" };
|
|
@@ -15248,8 +15596,8 @@ async function route(request2, response) {
|
|
|
15248
15596
|
if (match) {
|
|
15249
15597
|
const entry = sessions.get(decodeURIComponent(match[1]));
|
|
15250
15598
|
if (!entry) return json2(response, 404, { error: { code: "recording_not_found", message: "The local recording session was not found." } });
|
|
15251
|
-
if (
|
|
15252
|
-
if (
|
|
15599
|
+
if (request3.method === "GET" && !match[2]) return json2(response, 200, serializeEntry(entry));
|
|
15600
|
+
if (request3.method === "POST" && match[2] === "stop") {
|
|
15253
15601
|
if (entry.state === "ready") return json2(response, 200, serializeEntry(entry));
|
|
15254
15602
|
await finalizeEntry(entry, "agent_request");
|
|
15255
15603
|
return json2(response, entry.result ? 200 : 500, serializeEntry(entry));
|
|
@@ -15275,7 +15623,7 @@ function serializeEntry(entry) {
|
|
|
15275
15623
|
}
|
|
15276
15624
|
async function restoreRecoverableSessions() {
|
|
15277
15625
|
try {
|
|
15278
|
-
const value = JSON.parse(await
|
|
15626
|
+
const value = JSON.parse(await readFile5(daemonStatePath(), "utf8"));
|
|
15279
15627
|
for (const [id, entry] of Object.entries(value)) {
|
|
15280
15628
|
if (entry.status !== "ready" && entry.status !== "failed") continue;
|
|
15281
15629
|
sessions.set(id, { base: { recordingId: entry.recordingId, cdpUrl: entry.cdpUrl, startedAt: entry.startedAt }, state: entry.status, result: entry.result, error: entry.error });
|
|
@@ -15285,13 +15633,15 @@ async function restoreRecoverableSessions() {
|
|
|
15285
15633
|
}
|
|
15286
15634
|
async function persistState() {
|
|
15287
15635
|
const state = Object.fromEntries([...sessions.entries()].map(([id, entry]) => [id, serializeEntry(entry)]));
|
|
15288
|
-
|
|
15636
|
+
const temporary = daemonStatePath() + "." + crypto.randomUUID() + ".tmp";
|
|
15637
|
+
await writeFile4(temporary, `${JSON.stringify(state, null, 2)}
|
|
15289
15638
|
`, { mode: 384 });
|
|
15639
|
+
await rename(temporary, daemonStatePath());
|
|
15290
15640
|
}
|
|
15291
|
-
async function readJson(
|
|
15641
|
+
async function readJson(request3) {
|
|
15292
15642
|
const chunks = [];
|
|
15293
15643
|
let size = 0;
|
|
15294
|
-
for await (const chunk of
|
|
15644
|
+
for await (const chunk of request3) {
|
|
15295
15645
|
const buffer = Buffer.from(chunk);
|
|
15296
15646
|
size += buffer.length;
|
|
15297
15647
|
if (size > 1e6) throw new Error("Request body exceeds 1 MB.");
|
|
@@ -15303,147 +15653,16 @@ function json2(response, status, value) {
|
|
|
15303
15653
|
response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
|
|
15304
15654
|
response.end(JSON.stringify(value));
|
|
15305
15655
|
}
|
|
15306
|
-
|
|
15307
|
-
|
|
15308
|
-
|
|
15309
|
-
|
|
15310
|
-
|
|
15311
|
-
|
|
15312
|
-
|
|
15313
|
-
super(`${code}: ${message}${recovery ? `: ${recovery}` : ""}`);
|
|
15314
|
-
this.code = code;
|
|
15315
|
-
this.recovery = recovery;
|
|
15316
|
-
this.details = details;
|
|
15317
|
-
this.name = "ControlPlaneError";
|
|
15318
|
-
}
|
|
15319
|
-
};
|
|
15320
|
-
var ControlPlaneClient = class {
|
|
15321
|
-
constructor(apiUrl, token) {
|
|
15322
|
-
this.apiUrl = apiUrl;
|
|
15323
|
-
this.token = token;
|
|
15324
|
-
}
|
|
15325
|
-
createCloudRun(input) {
|
|
15326
|
-
return this.request("/api/cloud/runs", { method: "POST", body: JSON.stringify(input) });
|
|
15327
|
-
}
|
|
15328
|
-
cloudRun(id) {
|
|
15329
|
-
return this.request(`/api/cloud/runs/${encodeURIComponent(id)}`);
|
|
15330
|
-
}
|
|
15331
|
-
cancelCloudRun(id) {
|
|
15332
|
-
return this.request(`/api/cloud/runs/${encodeURIComponent(id)}/cancel`, { method: "POST" });
|
|
15333
|
-
}
|
|
15334
|
-
health() {
|
|
15335
|
-
return this.request("/api/health", {}, false);
|
|
15336
|
-
}
|
|
15337
|
-
agentSession() {
|
|
15338
|
-
return this.request("/api/agent/session", { signal: AbortSignal.timeout(5e3) });
|
|
15339
|
-
}
|
|
15340
|
-
reportCliCommand(input) {
|
|
15341
|
-
return this.request("/api/analytics/cli-command", { method: "POST", body: JSON.stringify(input) });
|
|
15342
|
-
}
|
|
15343
|
-
createRecording(input) {
|
|
15344
|
-
return this.request("/api/recordings", { method: "POST", body: JSON.stringify(input) });
|
|
15345
|
-
}
|
|
15346
|
-
markRecordingStarted(recordingId, input) {
|
|
15347
|
-
return this.request(`/api/recordings/${recordingId}/local-started`, {
|
|
15348
|
-
method: "POST",
|
|
15349
|
-
body: JSON.stringify(input)
|
|
15350
|
-
});
|
|
15351
|
-
}
|
|
15352
|
-
markRecordingStopped(recordingId, input) {
|
|
15353
|
-
return this.request(`/api/recordings/${recordingId}/local-stopped`, {
|
|
15354
|
-
method: "POST",
|
|
15355
|
-
body: JSON.stringify(input)
|
|
15356
|
-
});
|
|
15357
|
-
}
|
|
15358
|
-
submitRecordingFeedback(recordingId, input) {
|
|
15359
|
-
return this.request(recordingId ? `/api/recordings/${recordingId}/feedback` : "/api/feedback", {
|
|
15360
|
-
method: "POST",
|
|
15361
|
-
body: JSON.stringify(input)
|
|
15362
|
-
});
|
|
15363
|
-
}
|
|
15364
|
-
provisionVideo(recordingId, input) {
|
|
15365
|
-
return this.request(`/api/recordings/${recordingId}/video-upload`, { method: "POST", body: JSON.stringify(input) });
|
|
15366
|
-
}
|
|
15367
|
-
recordingStatus(recordingId) {
|
|
15368
|
-
return this.request(`/api/recordings/${recordingId}`);
|
|
15369
|
-
}
|
|
15370
|
-
cancelRecording(recordingId, reason = "cancelled_by_agent") {
|
|
15371
|
-
return this.request(`/api/recordings/${recordingId}/cancel`, {
|
|
15372
|
-
method: "POST",
|
|
15373
|
-
body: JSON.stringify({ reason })
|
|
15374
|
-
});
|
|
15375
|
-
}
|
|
15376
|
-
async waitUntilPlayable(recordingId, timeoutMs = 10 * 6e4) {
|
|
15377
|
-
const deadline = Date.now() + timeoutMs;
|
|
15378
|
-
let intervalMs = 1e3;
|
|
15379
|
-
while (Date.now() < deadline) {
|
|
15380
|
-
const recording = await this.recordingStatus(recordingId);
|
|
15381
|
-
if (recording.status === "ready" && recording.shareUrl) {
|
|
15382
|
-
return { ...recording, shareUrl: new URL(recording.shareUrl, this.apiUrl).toString() };
|
|
15383
|
-
}
|
|
15384
|
-
if (recording.status === "failed") throw new Error(`processing_failed: ${recording.failure_message ?? "Cloudflare Stream could not process the recording."}`);
|
|
15385
|
-
process.stderr.write(`\rprocessing ${recording.status.padEnd(12)}`);
|
|
15386
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
15387
|
-
intervalMs = Math.min(5e3, Math.round(intervalMs * 1.4));
|
|
15388
|
-
}
|
|
15389
|
-
throw new Error("processing_failed: Timed out waiting for the recording to become playable. Run `rill record status <recording-id>` to continue checking.");
|
|
15390
|
-
}
|
|
15391
|
-
async uploadVideo(uploadUrl, filePath) {
|
|
15392
|
-
const file2 = await stat(filePath);
|
|
15393
|
-
if (uploadUrl.startsWith(this.apiUrl)) {
|
|
15394
|
-
const response = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Length": String(file2.size) }, body: createReadStream(filePath), duplex: "half" });
|
|
15395
|
-
if (!response.ok) throw new Error(`Video upload failed (${response.status}).`);
|
|
15396
|
-
return;
|
|
15397
|
-
}
|
|
15398
|
-
await new Promise((resolve, reject) => {
|
|
15399
|
-
const upload = new Upload(createReadStream(filePath), {
|
|
15400
|
-
uploadUrl,
|
|
15401
|
-
uploadSize: file2.size,
|
|
15402
|
-
retryDelays: [0, 1e3, 3e3, 5e3, 1e4],
|
|
15403
|
-
removeFingerprintOnSuccess: true,
|
|
15404
|
-
onError: reject,
|
|
15405
|
-
onSuccess: () => resolve(),
|
|
15406
|
-
onProgress: (sent, total) => process.stderr.write(`\ruploading ${Math.round(sent / total * 100)}%`)
|
|
15407
|
-
});
|
|
15408
|
-
upload.start();
|
|
15409
|
-
});
|
|
15410
|
-
process.stderr.write("\n");
|
|
15411
|
-
}
|
|
15412
|
-
async uploadDiagnostics(recordingId, filePath, summary) {
|
|
15413
|
-
const body = await readFile2(filePath);
|
|
15414
|
-
return this.request(`/api/recordings/${recordingId}/diagnostics`, { method: "PUT", headers: { "Content-Encoding": "gzip", "Content-Type": "application/x-ndjson", "Content-Length": String(body.length), "X-Rill-Summary": JSON.stringify(summary) }, body });
|
|
15415
|
-
}
|
|
15416
|
-
createDeviceAuthorization(input) {
|
|
15417
|
-
return this.request("/api/device/authorizations", { method: "POST", body: JSON.stringify(input) }, false);
|
|
15418
|
-
}
|
|
15419
|
-
pollDeviceAuthorization(deviceCode) {
|
|
15420
|
-
return this.request("/api/device/token", { method: "POST", body: JSON.stringify({ deviceCode }) }, false);
|
|
15421
|
-
}
|
|
15422
|
-
inspect(shareUrl) {
|
|
15423
|
-
const url2 = new URL(shareUrl);
|
|
15424
|
-
const slug = url2.pathname.split("/").filter(Boolean).at(-1);
|
|
15425
|
-
if (!slug) throw new Error("The share URL does not contain a slug.");
|
|
15426
|
-
return fetch(`${url2.origin}/api/public/shares/${encodeURIComponent(slug)}/context`, { headers: { Accept: "application/json" } }).then(async (response) => {
|
|
15427
|
-
if (!response.ok) throw new Error(`Could not inspect the share link (${response.status}).`);
|
|
15428
|
-
return response.json();
|
|
15429
|
-
});
|
|
15430
|
-
}
|
|
15431
|
-
async request(path, init = {}, authenticated = true) {
|
|
15432
|
-
const headers = new Headers(init.headers);
|
|
15433
|
-
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
|
15434
|
-
if (authenticated) {
|
|
15435
|
-
if (!this.token) throw new Error("authentication_required: Run `rill login` or set RILL_TOKEN.");
|
|
15436
|
-
headers.set("Authorization", `Bearer ${this.token}`);
|
|
15437
|
-
}
|
|
15438
|
-
const response = await fetch(`${this.apiUrl}${path}`, { ...init, headers });
|
|
15439
|
-
const value = await response.json().catch(() => null);
|
|
15440
|
-
if (!response.ok && value?.error?.code && value.error.message) {
|
|
15441
|
-
throw new ControlPlaneError(value.error.code, value.error.message, value.error.recovery, value.error.details);
|
|
15442
|
-
}
|
|
15443
|
-
if (!response.ok) throw new Error(`Request failed (${response.status}).`);
|
|
15444
|
-
return value;
|
|
15656
|
+
async function purgeExpiredCaptures() {
|
|
15657
|
+
const removed = await purgeExpiredRemoteCaptures(recordingTempRoot());
|
|
15658
|
+
for (const id of removed) sessions.delete(id);
|
|
15659
|
+
let changed = removed.length > 0;
|
|
15660
|
+
for (const [id, entry] of sessions) if (entry.result?.frameDirectory && !await stat2(entry.result.frameDirectory).catch(() => null)) {
|
|
15661
|
+
sessions.delete(id);
|
|
15662
|
+
changed = true;
|
|
15445
15663
|
}
|
|
15446
|
-
|
|
15664
|
+
if (changed) await persistState();
|
|
15665
|
+
}
|
|
15447
15666
|
|
|
15448
15667
|
// src/analytics.ts
|
|
15449
15668
|
var commandCategories = /* @__PURE__ */ new Map([
|
|
@@ -15475,6 +15694,153 @@ function cliFailureCode(error51) {
|
|
|
15475
15694
|
return message.match(/^([a-z][a-z0-9_]{0,79}):/)?.[1] ?? "unexpected_error";
|
|
15476
15695
|
}
|
|
15477
15696
|
|
|
15697
|
+
// src/agent-session.ts
|
|
15698
|
+
import { hostname as hostname3, homedir as homedir2 } from "os";
|
|
15699
|
+
import { mkdir as mkdir5, rm as rm4, stat as stat3 } from "fs/promises";
|
|
15700
|
+
import { join as join8 } from "path";
|
|
15701
|
+
import { createHash, randomBytes } from "crypto";
|
|
15702
|
+
var JWT_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
15703
|
+
var CLAIM_GRANT = "urn:workos:agent-auth:grant-type:claim";
|
|
15704
|
+
var AgentAuthError = class extends Error {
|
|
15705
|
+
constructor(code, message) {
|
|
15706
|
+
super(`${code}: ${message}`);
|
|
15707
|
+
this.code = code;
|
|
15708
|
+
}
|
|
15709
|
+
};
|
|
15710
|
+
function parseAuthorization(value) {
|
|
15711
|
+
if (!value?.startsWith("{")) return null;
|
|
15712
|
+
const saved = JSON.parse(value);
|
|
15713
|
+
if (saved.version !== 1 || typeof saved.anonymous !== "boolean") throw new Error("Saved Rill authorization is invalid. Run rill connect --email <your-email>.");
|
|
15714
|
+
return saved;
|
|
15715
|
+
}
|
|
15716
|
+
async function request2(apiUrl, path, body, form = false) {
|
|
15717
|
+
const url2 = new URL(apiUrl);
|
|
15718
|
+
if (url2.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(url2.hostname)) throw new Error("Agent authentication requires HTTPS.");
|
|
15719
|
+
const response = await fetch(`${apiUrl}${path}`, {
|
|
15720
|
+
method: "POST",
|
|
15721
|
+
redirect: "error",
|
|
15722
|
+
signal: AbortSignal.timeout(15e3),
|
|
15723
|
+
headers: { "Content-Type": form ? "application/x-www-form-urlencoded" : "application/json" },
|
|
15724
|
+
body: form ? new URLSearchParams(Object.entries(body).map(([key, value2]) => [key, String(value2)])) : JSON.stringify(body)
|
|
15725
|
+
});
|
|
15726
|
+
const value = await response.json();
|
|
15727
|
+
if (!response.ok) throw new AgentAuthError(value.error ?? "authentication_failed", value.error_description ?? "Could not authorize Rill.");
|
|
15728
|
+
return value;
|
|
15729
|
+
}
|
|
15730
|
+
function withTokens(saved, tokens) {
|
|
15731
|
+
return {
|
|
15732
|
+
...saved,
|
|
15733
|
+
accessToken: tokens.access_token,
|
|
15734
|
+
accessExpiresAt: Date.now() + tokens.expires_in * 1e3,
|
|
15735
|
+
...tokens.identity_assertion ? { identityAssertion: tokens.identity_assertion, assertionExpires: tokens.assertion_expires } : {}
|
|
15736
|
+
};
|
|
15737
|
+
}
|
|
15738
|
+
var save = (apiUrl, value) => saveCredential(apiUrl, JSON.stringify(value));
|
|
15739
|
+
var pending = /* @__PURE__ */ new Map();
|
|
15740
|
+
async function loadAgentCredential(apiUrl) {
|
|
15741
|
+
if (process.env.RILL_TOKEN) return process.env.RILL_TOKEN;
|
|
15742
|
+
const inFlight = pending.get(apiUrl);
|
|
15743
|
+
if (inFlight) return inFlight;
|
|
15744
|
+
const promise2 = (async () => {
|
|
15745
|
+
const raw = await loadStoredCredential(apiUrl);
|
|
15746
|
+
const saved = parseAuthorization(raw);
|
|
15747
|
+
if (!saved) return raw;
|
|
15748
|
+
if (saved.accessToken && (saved.accessExpiresAt ?? 0) > Date.now() + 6e4) return saved.accessToken;
|
|
15749
|
+
if (!saved.identityAssertion && saved.anonymous && saved.registrationToken) return null;
|
|
15750
|
+
if (!saved.identityAssertion) throw new Error("Connection is awaiting approval. Run rill connect --email <your-email>.");
|
|
15751
|
+
const tokens = await request2(apiUrl, "/oauth2/token", { grant_type: JWT_GRANT, assertion: saved.identityAssertion }, true);
|
|
15752
|
+
await save(apiUrl, withTokens(saved, tokens));
|
|
15753
|
+
return tokens.access_token;
|
|
15754
|
+
})();
|
|
15755
|
+
pending.set(apiUrl, promise2);
|
|
15756
|
+
try {
|
|
15757
|
+
return await promise2;
|
|
15758
|
+
} finally {
|
|
15759
|
+
pending.delete(apiUrl);
|
|
15760
|
+
}
|
|
15761
|
+
}
|
|
15762
|
+
async function ensureAnonymousCredential(apiUrl) {
|
|
15763
|
+
const root = join8(homedir2(), ".rill", "auth-locks");
|
|
15764
|
+
await mkdir5(root, { recursive: true, mode: 448 });
|
|
15765
|
+
const lock = join8(root, createHash("sha256").update(apiUrl).digest("hex"));
|
|
15766
|
+
const deadline = Date.now() + 3e4;
|
|
15767
|
+
while (true) {
|
|
15768
|
+
try {
|
|
15769
|
+
await mkdir5(lock, { mode: 448 });
|
|
15770
|
+
break;
|
|
15771
|
+
} catch (error51) {
|
|
15772
|
+
if (error51.code !== "EEXIST") throw error51;
|
|
15773
|
+
const info = await stat3(lock).catch(() => null);
|
|
15774
|
+
if (info && Date.now() - info.mtimeMs > 12e4) {
|
|
15775
|
+
await rm4(lock, { recursive: true, force: true });
|
|
15776
|
+
continue;
|
|
15777
|
+
}
|
|
15778
|
+
if (Date.now() > deadline) throw new Error("Another Rill process is setting up authorization. Retry shortly.");
|
|
15779
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
15780
|
+
}
|
|
15781
|
+
}
|
|
15782
|
+
try {
|
|
15783
|
+
return await createAnonymousCredential(apiUrl);
|
|
15784
|
+
} finally {
|
|
15785
|
+
await rm4(lock, { recursive: true, force: true });
|
|
15786
|
+
}
|
|
15787
|
+
}
|
|
15788
|
+
async function createAnonymousCredential(apiUrl) {
|
|
15789
|
+
const existing = await loadAgentCredential(apiUrl);
|
|
15790
|
+
if (existing) return existing;
|
|
15791
|
+
const stored = parseAuthorization(await loadStoredCredential(apiUrl));
|
|
15792
|
+
const initial = stored?.registrationToken ? stored : { version: 1, anonymous: true, registrationToken: randomBytes(32).toString("hex") };
|
|
15793
|
+
await save(apiUrl, initial);
|
|
15794
|
+
const registered = await request2(apiUrl, "/agent/identity", { type: "anonymous", analytics_enabled: process.env.RILL_ANONYMOUS_ANALYTICS !== "0" && process.env.DO_NOT_TRACK !== "1", registration_token: initial.registrationToken, name: `${hostname3()} \xB7 ${process.platform}` });
|
|
15795
|
+
const saved = { ...initial, registrationToken: void 0, identityAssertion: registered.identity_assertion, assertionExpires: registered.assertion_expires, claimToken: registered.claim_token };
|
|
15796
|
+
await save(apiUrl, saved);
|
|
15797
|
+
process.stderr.write("Recording anonymously: three recordings, up to five minutes each. Recordings and share links expire after seven days. Run rill connect --email <your-email> to keep them.\n");
|
|
15798
|
+
return await loadAgentCredential(apiUrl);
|
|
15799
|
+
}
|
|
15800
|
+
async function connectAgent(apiUrl, email3) {
|
|
15801
|
+
if (process.env.RILL_TOKEN) throw new Error("Unset RILL_TOKEN before connecting a local identity.");
|
|
15802
|
+
let saved = parseAuthorization(await loadStoredCredential(apiUrl));
|
|
15803
|
+
let claim;
|
|
15804
|
+
if (saved?.claimToken && saved.anonymous) {
|
|
15805
|
+
try {
|
|
15806
|
+
const result = await request2(apiUrl, "/agent/identity/claim", { claim_token: saved.claimToken, email: email3 });
|
|
15807
|
+
claim = result.claim_attempt;
|
|
15808
|
+
} catch (error51) {
|
|
15809
|
+
if (!(error51 instanceof AgentAuthError) || error51.code !== "claim_expired") throw error51;
|
|
15810
|
+
}
|
|
15811
|
+
}
|
|
15812
|
+
if (!claim) {
|
|
15813
|
+
await save(apiUrl, saved ?? { version: 1, anonymous: true });
|
|
15814
|
+
const result = await request2(apiUrl, "/agent/identity", { type: "service_auth", login_hint: email3, name: `${hostname3()} \xB7 ${process.platform}` });
|
|
15815
|
+
saved = { version: 1, anonymous: true, claimToken: result.claim_token };
|
|
15816
|
+
await save(apiUrl, saved);
|
|
15817
|
+
claim = result.claim;
|
|
15818
|
+
}
|
|
15819
|
+
process.stderr.write(`Open ${claim.verification_uri}
|
|
15820
|
+
Sign in as ${email3} and enter ${claim.user_code}. Approve only the request you started.
|
|
15821
|
+
`);
|
|
15822
|
+
const deadline = Date.now() + claim.expires_in * 1e3;
|
|
15823
|
+
let interval = claim.interval;
|
|
15824
|
+
while (Date.now() < deadline) {
|
|
15825
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1e3));
|
|
15826
|
+
try {
|
|
15827
|
+
const tokens = await request2(apiUrl, "/oauth2/token", { grant_type: CLAIM_GRANT, claim_token: saved.claimToken }, true);
|
|
15828
|
+
const connected = withTokens({ version: 1, anonymous: false }, tokens);
|
|
15829
|
+
await save(apiUrl, connected);
|
|
15830
|
+
return { status: "authenticated", expiresAt: connected.assertionExpires, apiUrl };
|
|
15831
|
+
} catch (error51) {
|
|
15832
|
+
if (error51 instanceof AgentAuthError && error51.code === "expired_token") throw new AgentAuthError("expired_token", "The approval attempt expired. Run rill connect again to request a fresh code using the same saved claim token.");
|
|
15833
|
+
if (error51 instanceof AgentAuthError && error51.code === "authorization_pending") continue;
|
|
15834
|
+
if (error51 instanceof AgentAuthError && error51.code === "slow_down") {
|
|
15835
|
+
interval += 5;
|
|
15836
|
+
continue;
|
|
15837
|
+
}
|
|
15838
|
+
throw error51;
|
|
15839
|
+
}
|
|
15840
|
+
}
|
|
15841
|
+
throw new AgentAuthError("expired_token", "Connection request expired. Run rill connect again to restart the approval attempt using the same saved claim token.");
|
|
15842
|
+
}
|
|
15843
|
+
|
|
15478
15844
|
// src/keychain.ts
|
|
15479
15845
|
import { spawn as spawn4 } from "child_process";
|
|
15480
15846
|
var service = "rill";
|
|
@@ -15489,15 +15855,20 @@ async function saveCredential(apiUrl, token) {
|
|
|
15489
15855
|
}
|
|
15490
15856
|
throw new Error("OS keychain storage is not supported on this platform yet. Use RILL_TOKEN.");
|
|
15491
15857
|
}
|
|
15492
|
-
|
|
15493
|
-
|
|
15858
|
+
function loadCredential(apiUrl) {
|
|
15859
|
+
return loadAgentCredential(apiUrl);
|
|
15860
|
+
}
|
|
15861
|
+
async function loadStoredCredential(apiUrl) {
|
|
15494
15862
|
try {
|
|
15495
|
-
if (process.platform === "darwin") return (await run("security", ["find-generic-password", "-a", apiUrl, "-s", service, "-w"])).trim();
|
|
15496
|
-
if (process.platform === "linux") return (await run("secret-tool", ["lookup", "service", service, "api-url", apiUrl])).trim();
|
|
15497
|
-
} catch {
|
|
15498
|
-
|
|
15863
|
+
if (process.platform === "darwin") return (await run("security", ["find-generic-password", "-a", apiUrl, "-s", service, "-w"])).trim() || null;
|
|
15864
|
+
if (process.platform === "linux") return (await run("secret-tool", ["lookup", "service", service, "api-url", apiUrl])).trim() || null;
|
|
15865
|
+
} catch (error51) {
|
|
15866
|
+
const code = error51.exitCode;
|
|
15867
|
+
if (process.platform === "darwin" && code === 44) return null;
|
|
15868
|
+
if (process.platform === "linux" && code === 1 && !error51.message.trim()) return null;
|
|
15869
|
+
throw new Error("Cannot read the OS credential store. Unlock your keychain or enable Secret Service; your identity has not been replaced.");
|
|
15499
15870
|
}
|
|
15500
|
-
|
|
15871
|
+
throw new Error("Local authorization requires macOS Keychain or Linux Secret Service.");
|
|
15501
15872
|
}
|
|
15502
15873
|
function run(command, args, input) {
|
|
15503
15874
|
return new Promise((resolve, reject) => {
|
|
@@ -15507,7 +15878,7 @@ function run(command, args, input) {
|
|
|
15507
15878
|
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
|
|
15508
15879
|
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
|
|
15509
15880
|
child.once("error", reject);
|
|
15510
|
-
child.once("exit", (code) => code === 0 ? resolve(Buffer.concat(stdout).toString("utf8")) : reject(new Error(Buffer.concat(stderr).toString("utf8").trim()
|
|
15881
|
+
child.once("exit", (code) => code === 0 ? resolve(Buffer.concat(stdout).toString("utf8")) : reject(Object.assign(new Error(Buffer.concat(stderr).toString("utf8").trim()), { exitCode: code })));
|
|
15511
15882
|
if (input) child.stdin.end(input);
|
|
15512
15883
|
else child.stdin.end();
|
|
15513
15884
|
});
|
|
@@ -15597,6 +15968,8 @@ var CLI_VERSION = packageJson.version;
|
|
|
15597
15968
|
|
|
15598
15969
|
// src/doctor.ts
|
|
15599
15970
|
var defaultDependencies = {
|
|
15971
|
+
encoding: "remote",
|
|
15972
|
+
encodingCapabilities: (apiUrl, token) => new ControlPlaneClient(apiUrl, token).encodingCapabilities(),
|
|
15600
15973
|
nodeVersion: process.version,
|
|
15601
15974
|
cliVersion: CLI_VERSION,
|
|
15602
15975
|
chromePath: findChromeExecutable(),
|
|
@@ -15639,7 +16012,7 @@ async function runDoctor(apiUrl, overrides = {}) {
|
|
|
15639
16012
|
} else {
|
|
15640
16013
|
const token = await dependencies.loadCredential(apiUrl);
|
|
15641
16014
|
if (!token) {
|
|
15642
|
-
checks.push({ name: "authentication", status: "fail", message: "No Rill agent credential is available.", recovery: `Run \`rill --api-url ${apiUrl} login\` or set RILL_TOKEN.` });
|
|
16015
|
+
checks.push({ name: "authentication", status: health.anonymousAccess ? "warn" : "fail", message: health.anonymousAccess ? "The first recording will create an anonymous identity in your OS credential store. Anonymous recordings expire after seven days." : "No Rill agent credential is available.", recovery: `Run \`rill --api-url ${apiUrl} login\` or set RILL_TOKEN.` });
|
|
15643
16016
|
} else {
|
|
15644
16017
|
try {
|
|
15645
16018
|
const session = await dependencies.agentSession(apiUrl, token);
|
|
@@ -15651,7 +16024,18 @@ async function runDoctor(apiUrl, overrides = {}) {
|
|
|
15651
16024
|
}
|
|
15652
16025
|
}
|
|
15653
16026
|
checks.push(await executableCheck("chrome", dependencies.chromePath, ["--version"], dependencies.commandVersion, "Install Google Chrome or Chromium, or set RILL_CHROME_PATH to its executable."));
|
|
15654
|
-
|
|
16027
|
+
if (dependencies.encoding === "local") {
|
|
16028
|
+
checks.push(await executableCheck("ffmpeg", dependencies.ffmpegPath, ["-version"], dependencies.commandVersion, process.platform === "darwin" ? "Install ffmpeg with `brew install ffmpeg`, or set RILL_FFMPEG." : "Install ffmpeg using the system package manager, or set RILL_FFMPEG."));
|
|
16029
|
+
} else {
|
|
16030
|
+
checks.push({ name: "ffmpeg", status: "skip", message: "Remote encoding does not require local FFmpeg." });
|
|
16031
|
+
try {
|
|
16032
|
+
const token = await dependencies.loadCredential(apiUrl);
|
|
16033
|
+
if (!(token ? (await dependencies.encodingCapabilities(apiUrl, token)).enabled : health?.anonymousAccess && health.anonymousRemoteEncoding)) throw new Error("Remote encoding is not enabled for this workspace.");
|
|
16034
|
+
checks.push({ name: "encoding", status: "pass", message: token ? "Remote encoding is enabled." : "Remote encoding is available for the first anonymous recording." });
|
|
16035
|
+
} catch (error51) {
|
|
16036
|
+
checks.push({ name: "encoding", status: "fail", message: errorMessage(error51), recovery: "Remote encoding must be enabled on the server before using the default recording workflow." });
|
|
16037
|
+
}
|
|
16038
|
+
}
|
|
15655
16039
|
return { schemaVersion: 1, status: checks.some((check2) => check2.status === "fail") ? "error" : "ok", apiUrl, checks, ...runFeedbackFields(feedbackEnabled) };
|
|
15656
16040
|
}
|
|
15657
16041
|
async function executableCheck(name, path, args, run2, recovery) {
|
|
@@ -15760,20 +16144,31 @@ function controlPlaneUrl(environmentUrl) {
|
|
|
15760
16144
|
}
|
|
15761
16145
|
|
|
15762
16146
|
// src/cli.ts
|
|
16147
|
+
async function removeLocalCapture(id) {
|
|
16148
|
+
await ensureDaemon(process.argv[1]);
|
|
16149
|
+
const local = await recordingStatus(id).catch(() => null);
|
|
16150
|
+
if (local?.result?.frameDirectory) await rm5(dirname2(local.result.videoPath), { recursive: true, force: true });
|
|
16151
|
+
}
|
|
16152
|
+
function watchEncoding(apiUrl, id) {
|
|
16153
|
+
const child = spawn6(process.execPath, [process.argv[1], "--api-url", apiUrl, "encoding-watch", id], { detached: true, stdio: "ignore", env: { ...process.env } });
|
|
16154
|
+
child.on("error", () => void 0);
|
|
16155
|
+
child.unref();
|
|
16156
|
+
}
|
|
15763
16157
|
var program = new Command();
|
|
15764
16158
|
program.name("rill").description("Record, inspect, and share agent browser runs.").version(CLI_VERSION).option("--api-url <url>", "Rill control plane", controlPlaneUrl(process.env.RILL_API_URL)).option("--pretty", "Pretty-print JSON output", false).option("--json", "Output cloud run results as JSON", false);
|
|
15765
16159
|
var activeTelemetry = null;
|
|
15766
16160
|
program.command("daemon", { hidden: true }).action(() => runDaemon());
|
|
15767
|
-
program.command("doctor").description("Validate Rill prerequisites before recording.").action(async () => {
|
|
16161
|
+
program.command("doctor").description("Validate Rill prerequisites before recording.").option("--encoding <mode>", "remote or local", "remote").action(async (commandOptions) => {
|
|
15768
16162
|
const options = program.opts();
|
|
15769
|
-
const result = await runDoctor(options.apiUrl);
|
|
16163
|
+
const result = await runDoctor(options.apiUrl, { encoding: commandOptions.encoding });
|
|
15770
16164
|
emit(result, options.pretty);
|
|
15771
16165
|
if (result.status === "error") process.exitCode = 1;
|
|
15772
16166
|
});
|
|
15773
16167
|
async function authorize(quiet = false) {
|
|
15774
16168
|
const options = program.opts();
|
|
16169
|
+
if (parseAuthorization(await loadStoredCredential(options.apiUrl))?.anonymous) throw new Error("Use rill connect --email <your-email> to keep and claim this anonymous identity before using account access.");
|
|
15775
16170
|
const client = new ControlPlaneClient(options.apiUrl, null);
|
|
15776
|
-
const authorization = await client.createDeviceAuthorization({ deviceName: `${
|
|
16171
|
+
const authorization = await client.createDeviceAuthorization({ deviceName: `${hostname4()} \xB7 ${process.platform}` });
|
|
15777
16172
|
process.stderr.write(`Open ${authorization.verificationUri} and enter ${authorization.userCode}
|
|
15778
16173
|
`);
|
|
15779
16174
|
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
|
@@ -15792,7 +16187,11 @@ async function authorize(quiet = false) {
|
|
|
15792
16187
|
}
|
|
15793
16188
|
throw new Error("Device authorization expired.");
|
|
15794
16189
|
}
|
|
15795
|
-
program.command("
|
|
16190
|
+
program.command("connect").description("Connect this agent once, with renewable account access.").requiredOption("--email <email>", "Email of the Rill account approving access").action(async (input) => {
|
|
16191
|
+
const options = program.opts();
|
|
16192
|
+
emit({ schemaVersion: 1, ...await connectAgent(options.apiUrl, input.email) }, options.pretty);
|
|
16193
|
+
});
|
|
16194
|
+
program.command("login").description("Authorize Rill using the existing device login flow.").action(async () => {
|
|
15796
16195
|
await authorize();
|
|
15797
16196
|
});
|
|
15798
16197
|
var record2 = program.command("record").description("Run a cloud browser from a URL and prompt, or manage a local recording.").argument("[url]").option("-p, --prompt <prompt>").option("--expectation <text>").option("--account <name>", "Saved website test account").option("--id <uuid>", "Retry a submission with the same run ID").option("--no-wait").action(async (url2, commandOptions) => {
|
|
@@ -15822,16 +16221,19 @@ var record2 = program.command("record").description("Run a cloud browser from a
|
|
|
15822
16221
|
var cloudRun = program.command("run").description("Retrieve or cancel a cloud run.");
|
|
15823
16222
|
for (const action of ["status", "cancel"]) cloudRun.command(action).argument("<run-id>").action(async (id) => {
|
|
15824
16223
|
const options = program.opts();
|
|
15825
|
-
const client = new ControlPlaneClient(options.apiUrl,
|
|
16224
|
+
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
15826
16225
|
const run2 = await (action === "status" ? client.cloudRun(id) : client.cancelCloudRun(id));
|
|
15827
16226
|
if (options.json || options.pretty) emit(customerRun(run2), options.pretty);
|
|
15828
16227
|
else process.stdout.write(formatCloudRun(run2));
|
|
15829
16228
|
});
|
|
15830
|
-
record2.command("start").option("--url <url>").option("--title <title>").option("--description <description>", "").option("--headed").option("--cdp-url <url>").option("--width <pixels>", "", "1280").option("--height <pixels>", "", "720").option("--max-duration <seconds>", "", "600").action(async (commandOptions) => {
|
|
16229
|
+
record2.command("start").option("--encoding <mode>", "remote or local", "remote").option("--url <url>").option("--title <title>").option("--description <description>", "").option("--headed").option("--cdp-url <url>").option("--width <pixels>", "", "1280").option("--height <pixels>", "", "720").option("--max-duration <seconds>", "", "600").action(async (commandOptions) => {
|
|
15831
16230
|
const options = program.opts();
|
|
15832
|
-
|
|
15833
|
-
const client = new ControlPlaneClient(options.apiUrl,
|
|
16231
|
+
await ensureAnonymousCredential(options.apiUrl);
|
|
16232
|
+
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
15834
16233
|
const result = await withRunFeedback(() => client.agentSession(), async (rememberRecording) => {
|
|
16234
|
+
if (!["local", "remote"].includes(commandOptions.encoding)) throw new Error("Encoding must be remote or local.");
|
|
16235
|
+
if (commandOptions.encoding === "remote" && !(await client.encodingCapabilities()).enabled) throw new Error("Remote encoding is unavailable for this workspace. Use --encoding local.");
|
|
16236
|
+
if (!Number.isFinite(Number(commandOptions.maxDuration)) || Number(commandOptions.maxDuration) < 1 || Number(commandOptions.maxDuration) > 600) throw new Error("Recording duration must be between 1 and 600 seconds.");
|
|
15835
16237
|
const width = Number(commandOptions.width);
|
|
15836
16238
|
const height = Number(commandOptions.height);
|
|
15837
16239
|
const remote = await client.createRecording({ title: commandOptions.title ?? `Browser recording \xB7 ${(/* @__PURE__ */ new Date()).toISOString()}`, description: commandOptions.description, sourceUrl: commandOptions.url ?? null, width, height });
|
|
@@ -15839,7 +16241,7 @@ record2.command("start").option("--url <url>").option("--title <title>").option(
|
|
|
15839
16241
|
const local = await startRecordingWithCompensation(remote, {
|
|
15840
16242
|
startLocal: async () => {
|
|
15841
16243
|
await ensureDaemon(process.argv[1]);
|
|
15842
|
-
return startRecording({ recordingId: remote.recordingId, url: commandOptions.url, cdpUrl: commandOptions.cdpUrl, headed: Boolean(commandOptions.headed), width, height, maxDurationSeconds: Number(commandOptions.maxDuration), captureNetworkBodies: remote.bodyCaptureEnabled, cursorAppearance: remote.cursorAppearance });
|
|
16244
|
+
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 });
|
|
15843
16245
|
},
|
|
15844
16246
|
cancelRemote: (recordingId, reason) => client.cancelRecording(recordingId, reason)
|
|
15845
16247
|
});
|
|
@@ -15850,7 +16252,7 @@ record2.command("start").option("--url <url>").option("--title <title>").option(
|
|
|
15850
16252
|
headed: Boolean(commandOptions.headed),
|
|
15851
16253
|
attached: Boolean(commandOptions.cdpUrl)
|
|
15852
16254
|
}).catch(() => void 0);
|
|
15853
|
-
return { schemaVersion: 1, recordingId: remote.recordingId, status: local.status, cdpUrl: local.cdpUrl, bodyCaptureEnabled: remote.bodyCaptureEnabled, maxDurationSeconds: Number(commandOptions.maxDuration) };
|
|
16255
|
+
return { schemaVersion: 1, recordingId: remote.recordingId, status: local.status, cdpUrl: local.cdpUrl, bodyCaptureEnabled: remote.bodyCaptureEnabled, anonymous: remote.anonymous ?? false, expiresAt: remote.expiresAt ?? null, maxDurationSeconds: Math.min(Number(commandOptions.maxDuration), remote.maxDurationSeconds ?? 600) };
|
|
15854
16256
|
});
|
|
15855
16257
|
emit(result, options.pretty);
|
|
15856
16258
|
});
|
|
@@ -15859,31 +16261,73 @@ record2.command("stop").argument("<recording-id>").option("--no-wait").option("-
|
|
|
15859
16261
|
await ensureDaemon(process.argv[1]);
|
|
15860
16262
|
const local = await stopRecording(recordingId);
|
|
15861
16263
|
if (!local.result) throw new Error(local.error ?? "The recorder did not produce an artifact.");
|
|
15862
|
-
const
|
|
15863
|
-
const client = new ControlPlaneClient(options.apiUrl, token);
|
|
16264
|
+
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
15864
16265
|
const stopped = await client.markRecordingStopped(recordingId, {
|
|
15865
16266
|
durationSeconds: local.result.durationSeconds,
|
|
15866
16267
|
stoppedReason: local.result.stoppedReason,
|
|
15867
16268
|
diagnosticsAvailable: true,
|
|
15868
16269
|
requestFeedback: commandOptions.feedback
|
|
15869
16270
|
}).catch(() => null);
|
|
15870
|
-
|
|
16271
|
+
if (local.result.frameDirectory) {
|
|
16272
|
+
await client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics);
|
|
16273
|
+
const accepted = await submitFrameCapture(client, recordingId, local.result.frameDirectory);
|
|
16274
|
+
const result2 = commandOptions.wait && record2.opts().wait !== false ? await waitForEncoding(client, recordingId) : accepted;
|
|
16275
|
+
emit({ schemaVersion: 1, ...result2, ...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false) }, options.pretty);
|
|
16276
|
+
if (result2.status === "ready") await rm5(dirname2(local.result.videoPath), { recursive: true, force: true });
|
|
16277
|
+
else watchEncoding(options.apiUrl, recordingId);
|
|
16278
|
+
return;
|
|
16279
|
+
}
|
|
16280
|
+
const video = await stat4(local.result.videoPath);
|
|
15871
16281
|
const provisioned = await client.provisionVideo(recordingId, { sizeBytes: video.size, filename: basename(local.result.videoPath), uploadKind: "recorded_browser" });
|
|
15872
16282
|
await Promise.all([client.uploadVideo(provisioned.uploadUrl, local.result.videoPath), client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics)]);
|
|
15873
|
-
const remote = commandOptions.wait === false ? null : await client.waitUntilPlayable(recordingId);
|
|
16283
|
+
const remote = commandOptions.wait === false || record2.opts().wait === false ? null : await client.waitUntilPlayable(recordingId);
|
|
15874
16284
|
if (remote) process.stderr.write("\n");
|
|
15875
16285
|
const result = {
|
|
15876
16286
|
schemaVersion: 1,
|
|
15877
16287
|
recordingId,
|
|
15878
16288
|
status: remote ? "ready" : "processing",
|
|
15879
16289
|
shareUrl: remote?.shareUrl ?? null,
|
|
16290
|
+
expiresAt: remote?.expires_at ?? null,
|
|
15880
16291
|
durationSeconds: local.result.durationSeconds,
|
|
15881
16292
|
stoppedReason: local.result.stoppedReason,
|
|
15882
16293
|
diagnostics: local.result.diagnostics,
|
|
15883
16294
|
...recordingFeedbackFields(recordingId, stopped?.feedbackRequested ?? false)
|
|
15884
16295
|
};
|
|
15885
16296
|
emit(result, options.pretty);
|
|
15886
|
-
if (remote) await
|
|
16297
|
+
if (remote) await rm5(dirname2(local.result.videoPath), { recursive: true, force: true });
|
|
16298
|
+
});
|
|
16299
|
+
record2.command("retry").argument("<recording-id>").option("--no-wait").action(async (recordingId, commandOptions) => {
|
|
16300
|
+
const options = program.opts();
|
|
16301
|
+
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
16302
|
+
const current = await client.encodingStatus(recordingId).catch((error51) => {
|
|
16303
|
+
if (error51 instanceof ControlPlaneError && error51.code === "recording_not_found") return null;
|
|
16304
|
+
throw error51;
|
|
16305
|
+
});
|
|
16306
|
+
let accepted = current;
|
|
16307
|
+
if (!current || current.status === "uploading") {
|
|
16308
|
+
await ensureDaemon(process.argv[1]);
|
|
16309
|
+
const local = await recordingStatus(recordingId);
|
|
16310
|
+
if (!local.result?.frameDirectory) throw new Error("Local capture is unavailable.");
|
|
16311
|
+
await client.uploadDiagnostics(recordingId, local.result.diagnosticsPath, local.result.diagnostics);
|
|
16312
|
+
accepted = await submitFrameCapture(client, recordingId, local.result.frameDirectory);
|
|
16313
|
+
} else if (current.status === "failed") accepted = await client.retryEncoding(recordingId);
|
|
16314
|
+
if (!accepted) throw new Error("Encoding job is unavailable.");
|
|
16315
|
+
if (["expired", "cancelled"].includes(accepted.status)) throw new Error(`Recording ${accepted.status}.`);
|
|
16316
|
+
const result = commandOptions.wait && record2.opts().wait !== false ? await waitForEncoding(client, recordingId) : accepted;
|
|
16317
|
+
emit({ schemaVersion: 1, ...result }, options.pretty);
|
|
16318
|
+
if (result.status === "ready") await removeLocalCapture(recordingId);
|
|
16319
|
+
else watchEncoding(options.apiUrl, recordingId);
|
|
16320
|
+
});
|
|
16321
|
+
program.command("encoding-watch", { hidden: true }).argument("<recording-id>").action(async (id) => {
|
|
16322
|
+
if (!/^rec_[a-zA-Z0-9_-]+$/.test(id)) throw new Error("Invalid recording ID");
|
|
16323
|
+
const options = program.opts();
|
|
16324
|
+
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
16325
|
+
const result = await waitForEncoding(client, id, 864e5);
|
|
16326
|
+
if (result.status === "ready") {
|
|
16327
|
+
await ensureDaemon(process.argv[1]);
|
|
16328
|
+
const local = await recordingStatus(id).catch(() => null);
|
|
16329
|
+
if (local?.result?.frameDirectory) await rm5(dirname2(local.result.videoPath), { recursive: true, force: true });
|
|
16330
|
+
}
|
|
15887
16331
|
});
|
|
15888
16332
|
var feedback = program.command("feedback").description("Submit a short Rill product review for a recorded run.");
|
|
15889
16333
|
feedback.command("submit").argument("[recording-id]", "Omit only when setup failed before a recording was created").requiredOption("--outcome <outcome>", "succeeded, failed, or abandoned").option("--helped <text>", "What went well or helped during setup and the run", "").option("--friction <text>", "What created friction during the run", "").option("--improvement <text>", "Possible Rill improvements, prioritized by usefulness", "").action(async (recordingId, commandOptions) => {
|
|
@@ -15892,7 +16336,7 @@ feedback.command("submit").argument("[recording-id]", "Omit only when setup fail
|
|
|
15892
16336
|
if (![commandOptions.helped, commandOptions.friction, commandOptions.improvement].some((value) => value.trim())) throw new Error("validation_failed: Include at least one short feedback answer.");
|
|
15893
16337
|
const options = program.opts();
|
|
15894
16338
|
const token = await loadCredential(options.apiUrl);
|
|
15895
|
-
const client = new ControlPlaneClient(options.apiUrl,
|
|
16339
|
+
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
15896
16340
|
await client.submitRecordingFeedback(recordingId ?? null, {
|
|
15897
16341
|
outcome: commandOptions.outcome,
|
|
15898
16342
|
whatHelped: commandOptions.helped,
|
|
@@ -15901,10 +16345,23 @@ feedback.command("submit").argument("[recording-id]", "Omit only when setup fail
|
|
|
15901
16345
|
});
|
|
15902
16346
|
emit(feedbackSubmissionReceipt(recordingId ?? null), options.pretty);
|
|
15903
16347
|
});
|
|
16348
|
+
record2.command("list").description("List recordings visible to this connection.").action(async () => {
|
|
16349
|
+
const options = program.opts();
|
|
16350
|
+
emit(await new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl)).listRecordings(), options.pretty);
|
|
16351
|
+
});
|
|
15904
16352
|
record2.command("status").argument("<recording-id>").action(async (recordingId) => {
|
|
15905
16353
|
const options = program.opts();
|
|
15906
16354
|
const token = await loadCredential(options.apiUrl);
|
|
15907
|
-
const client = new ControlPlaneClient(options.apiUrl,
|
|
16355
|
+
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
16356
|
+
try {
|
|
16357
|
+
const encoding = await client.encodingStatus(recordingId);
|
|
16358
|
+
const remote = encoding.status === "ready" ? await client.recordingStatus(recordingId) : {};
|
|
16359
|
+
emit({ schemaVersion: 1, ...encoding, ...remote, recordingId, source: "remote" }, options.pretty);
|
|
16360
|
+
if (encoding.status === "ready") await removeLocalCapture(recordingId);
|
|
16361
|
+
return;
|
|
16362
|
+
} catch (error51) {
|
|
16363
|
+
if (!(error51 instanceof Error) || !error51.message.includes("recording_not_found")) throw error51;
|
|
16364
|
+
}
|
|
15908
16365
|
const status = await remoteAwareRecordingStatus(recordingId, {
|
|
15909
16366
|
localStatus: async (id) => {
|
|
15910
16367
|
await ensureDaemon(process.argv[1]);
|
|
@@ -15917,42 +16374,42 @@ record2.command("status").argument("<recording-id>").action(async (recordingId)
|
|
|
15917
16374
|
record2.command("cancel").argument("<recording-id>").description("Cancel an active recording and release its remote quota slot.").action(async (recordingId) => {
|
|
15918
16375
|
const options = program.opts();
|
|
15919
16376
|
const token = await loadCredential(options.apiUrl);
|
|
15920
|
-
const client = new ControlPlaneClient(options.apiUrl,
|
|
16377
|
+
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
15921
16378
|
emit(await client.cancelRecording(recordingId), options.pretty);
|
|
15922
16379
|
});
|
|
15923
16380
|
program.command("upload").argument("<video-file>").option("--title <title>").option("--no-wait").action(async (videoFile, commandOptions) => {
|
|
15924
16381
|
const options = program.opts();
|
|
15925
|
-
|
|
15926
|
-
const client = new ControlPlaneClient(options.apiUrl,
|
|
15927
|
-
const file2 = await
|
|
16382
|
+
await ensureAnonymousCredential(options.apiUrl);
|
|
16383
|
+
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
16384
|
+
const file2 = await stat4(videoFile);
|
|
15928
16385
|
const recording = await client.createRecording({ title: commandOptions.title ?? basename(videoFile), sourceUrl: null });
|
|
15929
16386
|
const upload = await client.provisionVideo(recording.recordingId, { sizeBytes: file2.size, filename: basename(videoFile), uploadKind: "existing_video" });
|
|
15930
16387
|
await client.uploadVideo(upload.uploadUrl, videoFile);
|
|
15931
16388
|
const remote = commandOptions.wait === false ? null : await client.waitUntilPlayable(recording.recordingId);
|
|
15932
16389
|
if (remote) process.stderr.write("\n");
|
|
15933
|
-
emit({ schemaVersion: 1, recordingId: recording.recordingId, status: remote ? "ready" : "processing", shareUrl: remote?.shareUrl ?? null, diagnosticsAvailable: false }, options.pretty);
|
|
16390
|
+
emit({ schemaVersion: 1, recordingId: recording.recordingId, status: remote ? "ready" : "processing", shareUrl: remote?.shareUrl ?? null, expiresAt: remote?.expires_at ?? recording.expiresAt ?? null, diagnosticsAvailable: false }, options.pretty);
|
|
15934
16391
|
});
|
|
15935
16392
|
program.command("inspect").argument("<share-url>").action(async (shareUrl) => {
|
|
15936
16393
|
const options = program.opts();
|
|
15937
|
-
emit(await new ControlPlaneClient(options.apiUrl,
|
|
16394
|
+
emit(await new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl)).inspect(shareUrl), options.pretty);
|
|
15938
16395
|
});
|
|
15939
16396
|
var drafts = program.command("drafts");
|
|
15940
16397
|
drafts.command("list").action(async () => {
|
|
15941
16398
|
const options = program.opts();
|
|
15942
16399
|
const root = process.env.RILL_TEMP ?? `${process.env.TMPDIR ?? "/tmp"}/rill-recordings`;
|
|
15943
|
-
const names = await
|
|
16400
|
+
const names = await readdir2(root).catch(() => []);
|
|
15944
16401
|
emit({ schemaVersion: 1, drafts: names }, options.pretty);
|
|
15945
16402
|
});
|
|
15946
16403
|
drafts.command("purge").action(async () => {
|
|
15947
16404
|
const options = program.opts();
|
|
15948
16405
|
const root = process.env.RILL_TEMP ?? `${process.env.TMPDIR ?? "/tmp"}/rill-recordings`;
|
|
15949
|
-
const names = await
|
|
16406
|
+
const names = await readdir2(root).catch(() => []);
|
|
15950
16407
|
let purged = 0;
|
|
15951
16408
|
for (const name of names) {
|
|
15952
16409
|
const path = `${root}/${name}`;
|
|
15953
|
-
const metadata = await
|
|
16410
|
+
const metadata = await stat4(path);
|
|
15954
16411
|
if (Date.now() - metadata.mtimeMs > 24 * 60 * 60 * 1e3) {
|
|
15955
|
-
await
|
|
16412
|
+
await rm5(path, { recursive: true, force: true });
|
|
15956
16413
|
purged += 1;
|
|
15957
16414
|
}
|
|
15958
16415
|
}
|
|
@@ -15964,7 +16421,7 @@ program.hook("preAction", async (_command, actionCommand) => {
|
|
|
15964
16421
|
const options = program.opts();
|
|
15965
16422
|
const token = await loadCredential(options.apiUrl).catch(() => null);
|
|
15966
16423
|
if (!token) return;
|
|
15967
|
-
const client = new ControlPlaneClient(options.apiUrl,
|
|
16424
|
+
const client = new ControlPlaneClient(options.apiUrl, () => loadCredential(options.apiUrl));
|
|
15968
16425
|
await startCliTelemetry(client, command);
|
|
15969
16426
|
});
|
|
15970
16427
|
async function startCliTelemetry(client, command) {
|