Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a9b7332
build(recall): the Windows staging steps can actually run, and ship a…
Amir-SSVLabs Aug 10, 2026
bbc23a8
fix(windows): simplify the verbatim resource dir before a child proce…
Amir-SSVLabs Aug 10, 2026
2fc1d77
fix(recording): give Windows the process-tree kill it never had
Amir-SSVLabs Aug 10, 2026
79cc2fa
test(recording): run the real sidecar against the real SDK on Windows
Amir-SSVLabs Aug 10, 2026
4f3e044
test(recall): the sidecar contract suite can pass on Windows
Amir-SSVLabs Aug 10, 2026
b16ea90
docs(recording): Windows has been run now — and what the run found
Amir-SSVLabs Aug 10, 2026
50e9607
fix(recording): AudioStatus crosses the wire in snake_case, so nothin…
Amir-SSVLabs Aug 10, 2026
a4d033e
fix(recording): two readers, one window peak — whichever polls second…
Amir-SSVLabs Aug 10, 2026
3ad819d
feat(recording): a dead input is one whose level never moves
Amir-SSVLabs Aug 10, 2026
523ba9f
feat(windows): name the input device
Amir-SSVLabs Aug 10, 2026
34cc669
ci: a Windows leg that can go red
Amir-SSVLabs Aug 10, 2026
382fd82
docs(recording): what the detector catches, and what it still cannot
Amir-SSVLabs Aug 10, 2026
8e10c8c
fix(recording): a NO INPUT row should look like the failure it is
Amir-SSVLabs Aug 10, 2026
72527d7
fix(recording): satisfy clippy's unnecessary_sort_by on the newest-fi…
Amir-SSVLabs Aug 10, 2026
c768369
Merge origin/dev into fix/windows-recording
Amir-SSVLabs Aug 11, 2026
afb40e1
Merge origin/dev into fix/windows-recording (2)
Amir-SSVLabs Aug 12, 2026
c6081ab
docs(resources): say that these regression tests run nowhere in CI
Amir-SSVLabs Aug 12, 2026
994b538
Merge origin/dev into fix/windows-recording (3)
Amir-SSVLabs Aug 12, 2026
acb5258
chore(recording): drop two helpers nothing calls, and comments that n…
Amir-SSVLabs Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ jobs:
with:
workspaces: src-tauri

# NOT WIDENED TO `cargo fmt --all --check`, though it should be. The
# engine crates are unchecked today, and widening was scoped into this
# PR — but the base is ~160 fmt-dirty sites across the workspace, and
# this narrower check is ALREADY failing on dev (src-tauri/src/lib.rs
# alone has 11). Widening it here would mean either landing red or
# burying an audio review under a whole-workspace reformat. Do the
# reformat as its own commit, then add --all.
- name: Format check
run: |
if ! cargo fmt --manifest-path src-tauri/Cargo.toml --check; then
Expand All @@ -103,6 +110,12 @@ jobs:
- name: Clippy
run: cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings

# The engine crates are NOT covered by the step above, which is how the
# recording crate accumulated six clippy errors nobody saw. --all-targets
# or the test-only code is not even compiled.
- name: Clippy (recording engine, tests included)
run: cargo clippy -p brains-recording --all-targets -- -D warnings

- name: Unit tests
# THE WHOLE WORKSPACE, not just the shell crate. `--manifest-path` at a workspace
# member selects that package alone, so every engine's tests — `brains-browser`'s
Expand All @@ -120,6 +133,59 @@ jobs:
# it type-checks the code against the pinned bindings, which is the part that rots.
run: cargo check -p brains-browser --features chromium

# WHY THIS JOB EXISTS: call recording had never been run on Windows, and the
# reason it could go that long is that nothing here ever compiled for it.
# The bugs that cost a bring-up day were a verbatim resource path, a missing
# process-tree kill, and a wire type in snake_case — the first two are
# Windows-only by construction and the third was invisible everywhere.
#
# It is deliberately NARROW and deliberately BLOCKING. Never
# continue-on-error: a job that cannot go red is worse than no job, because
# it reads as coverage — a green Windows job here once hid 14 failing tests.
windows:
name: Windows (recording)
runs-on: windows-latest
timeout-minutes: 45
defaults:
run:
# Every step here is POSIX; the runner's default shell is pwsh.
shell: bash
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch
with:
toolchain: stable
components: clippy

- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: src-tauri

# SCOPED TO brains-recording ON PURPOSE, and the scope is the whole
# design of this job. Two things are knowingly excluded:
#
# * --workspace, because src/engines/agents/local/src/sandbox.rs
# asserts real_path("/tmp") == "/private/tmp" with no platform gate,
# so the workspace is red on Windows for reasons that have nothing
# to do with recording.
# * -p brains-desktop --lib, because it pulls brains-storage in and
# that crate has its own pre-existing clippy debt (backups.rs
# sort_by_key, previews.rs needless borrows).
#
# Both are worth fixing and both are their own PR. Widening this job
# before they are fixed would mean landing it red, and a red-on-arrival
# job gets `continue-on-error` bolted on within a week — which is the
# exact failure this job exists to avoid.
- name: Clippy (recording engine, tests included)
run: cargo clippy -p brains-recording --all-targets -- -D warnings

- name: Unit tests (recording engine)
run: cargo test -p brains-recording

- name: Build the recording engine
run: cargo build -p brains-recording

eval:
name: Eval (token-less)
runs-on: macos-latest
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions scripts/dev/recall/host.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Running host tools from Node, on every platform this build targets.
//
// Two Windows facts live here so no call site has to rediscover them:
//
// * `npm` IS `npm.cmd`, AND A .cmd NEEDS A SHELL. execFile resolves the
// extension for you on neither count: without it the staging step dies
// with ENOENT, and with it Node refuses outright (`EINVAL`) — since the
// 2024 argument-injection fix, execFile will not run a batch file except
// through a shell. So: the real name, and `shell: true`.
// * GNU TAR READS A DRIVE LETTER AS A HOSTNAME. `tar -czf C:\...` is
// parsed as the remote spec `host:path` and aborts before touching the
// disk. Git for Windows puts that tar first on PATH while System32's
// bsdtar accepts either form — so which shell invoked the build decided
// whether recording packaged. Every path goes relative to an explicit
// cwd instead, which both tars read the same way.
//
// Both are no-ops on macOS and Linux: the name is `npm`, and a relative path
// under `cwd` names the same file an absolute one did.
import { execFileSync } from "node:child_process";
import { isAbsolute, relative } from "node:path";

const windows = process.platform === "win32";

/// Run npm in `cwd`. Every argument a caller passes is a literal flag, which
/// is what makes the Windows shell hop harmless — nothing here comes from
/// outside this repo, so there is no argument to be quoted badly.
export function npm(cwd, args) {
execFileSync(windows ? "npm.cmd" : "npm", args, { cwd, stdio: "inherit", shell: windows });
}

/// Run `tar` with every absolute path argument rewritten relative to `cwd`.
/// Flags pass through untouched — only an absolute path can carry the drive
/// letter that GNU tar misreads.
export function tar(cwd, args) {
execFileSync(
"tar",
args.map((arg) => (isAbsolute(arg) ? relative(cwd, arg) : arg)),
{ cwd, stdio: "inherit" },
);
}
23 changes: 14 additions & 9 deletions scripts/dev/recall/package-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@
// how the archive was built. Full lockfile determinism is out of scope —
// tracking the SDK's dependencies is the SDK maintainer's responsibility.
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { copyFile, mkdir, rename, rm, writeFile } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { npm, tar } from "./host.mjs";

const root = resolve(import.meta.dirname, "..", "..", "..");
const sdkDir = join(root, "node_modules", "@recallai", "desktop-sdk");
Expand Down Expand Up @@ -66,15 +66,20 @@ await rm(temporaryArchive, { force: true });
// framework rewrites the symlinks its signature depends on, and Apple rejects
// what comes out — measured 2026-08-12.
if (!existsSync(join(sdkDir, "node_modules"))) {
execFileSync(
"npm",
["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", "--no-package-lock"],
{ cwd: sdkDir, stdio: "inherit" },
);
// `npm` is `npm.cmd` on Windows and a .cmd needs a shell — see host.mjs.
npm(sdkDir, [
"install",
"--omit=dev",
"--ignore-scripts",
"--no-audit",
"--no-fund",
"--no-package-lock",
]);
}
execFileSync("tar", ["-czf", temporaryArchive, "-C", dirname(sdkDir), basename(sdkDir)], {
stdio: "inherit",
});
// Through host.mjs: GNU tar (first on PATH under Git for Windows) reads the
// `C:` in an absolute path as a hostname and aborts, so every path goes
// relative to an explicit cwd. A no-op on macOS and Linux.
tar(root, ["-czf", temporaryArchive, "-C", dirname(sdkDir), basename(sdkDir)]);
await rm(archivePath, { force: true });
await rename(temporaryArchive, archivePath);

Expand Down
32 changes: 29 additions & 3 deletions scripts/dev/recall/prepare-sidecar.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Readable } from "node:stream";
import { finished } from "node:stream/promises";
import { execFileSync } from "node:child_process";
import { basename, dirname, join, resolve } from "node:path";
import { tar } from "./host.mjs";

const root = resolve(import.meta.dirname, "..", "..", "..");
const sdkDir = join(root, "node_modules", "@recallai", "desktop-sdk");
Expand Down Expand Up @@ -90,7 +91,7 @@ async function ensureRecallSdk() {
const url = `https://recallai-desktop-sdk-releases.s3.us-east-1.amazonaws.com/${commit}/${archiveName}`;
console.log(`Preparing the Recall Desktop SDK ${pkg.version} for ${process.platform}…`);
await download(url, archive);
execFileSync("tar", ["-xf", archive, "-C", sdkDir], { stdio: "inherit" });
tar(root, ["-xf", archive, "-C", sdkDir]);
await unlink(archive).catch(() => {});
if (!existsSync(executable)) {
throw new Error(`the Recall SDK archive did not produce ${basename(executable)}`);
Expand All @@ -116,16 +117,41 @@ async function officialMacNode() {
if (!existsSync(extracted)) {
console.log(`Downloading a self-contained Node ${nodeVersion} (${arch}) for the sidecar…`);
await download(`https://nodejs.org/dist/v${nodeVersion}/${folder}.tar.gz`, archive);
execFileSync("tar", ["-xzf", archive, "-C", binariesDir], { stdio: "inherit" });
tar(root, ["-xzf", archive, "-C", binariesDir]);
await unlink(archive).catch(() => {});
}
return extracted;
}

/// The pinned Windows runtime. `nodejs.org/dist/…/win-x64/node.exe` is already
/// the single self-contained executable, so there is nothing to unpack.
///
/// PINNED FOR THE SAME REASON AS macOS, and the reason is not tidiness: what
/// the sidecar ships decides how it resolves paths. The reference app's
/// Windows recording died because its Node could not resolve a main module
/// from the verbatim path Tauri hands over (`\\?\C:\…`) — and a Node that
/// old shipped only because it happened to be the one that ran the build.
/// Node 22.14 and 24 both resolve it; some older ones do not. Copying
/// `process.execPath` makes the shipped app a function of the build machine,
/// which is how that class of bug stays invisible until a user hits it.
async function officialWindowsNode() {
const pinned = join(binariesDir, `node-v${nodeVersion}-win-x64.exe`);
if (!existsSync(pinned)) {
console.log(`Downloading the pinned Node ${nodeVersion} (win-x64) for the sidecar…`);
await download(`https://nodejs.org/dist/v${nodeVersion}/win-x64/node.exe`, pinned);
}
return pinned;
}

async function ensureNodeSidecar() {
await mkdir(binariesDir, { recursive: true });
const suffix = process.platform === "win32" ? ".exe" : "";
const source = process.platform === "darwin" ? await officialMacNode() : process.execPath;
const source =
process.platform === "darwin"
? await officialMacNode()
: process.platform === "win32"
? await officialWindowsNode()
: process.execPath;
const names = new Set([`recall-node-${target}${suffix}`]);
if (process.platform === "darwin") {
// Tauri's universal build resolves the first name; single-arch local
Expand Down
34 changes: 26 additions & 8 deletions scripts/dev/recall/test-sidecar.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,14 @@ test("the sidecar writes mixed PCM callbacks to a valid local WAV", async () =>

await send(1, "init", { apiUrl: "https://example.test" });
const prepared = await send(2, "prepare", { apiUrl: "https://example.test" });
assert.deepEqual(prepared.permissions, { microphone: "granted", "system-audio": "granted" });
// Permissions are a macOS concept HERE: `requestPermissions()` returns early
// off darwin, so nothing is requested and nothing is reported. That is the
// sidecar's actual contract on Windows, asserted rather than assumed — a
// Windows build that suddenly reported a permission map would be news.
assert.deepEqual(
prepared.permissions,
process.platform === "darwin" ? { microphone: "granted", "system-audio": "granted" } : {},
);

const started = await send(3, "start", {
apiUrl: "https://example.test",
Expand Down Expand Up @@ -147,13 +154,18 @@ test("the sidecar trusts an ID-only meeting detection over whole-desktop capture
exports.init = async () => null;
exports.requestPermission = async (permission) => {
listeners.get("permission-status")?.({ permission, status: "granted" });
if (permission === "accessibility") {
// No title, no URL, no platform — exactly the event that used to
// be discarded, silently downgrading a live Meet.
listeners.get("meeting-detected")?.({ window: { id: "google-meet-window" } });
}
};
exports.prepareDesktopAudioRecording = async () => "desktop-audio-fallback";
exports.prepareDesktopAudioRecording = async () => {
// No title, no URL, no platform — exactly the event that used to be
// discarded, silently downgrading a live Meet.
//
// Fired from HERE, not from requestPermission("accessibility"): the
// sidecar only requests permissions on darwin, so hanging the event
// off that call made this test — which is about platform-independent
// detection logic — silently macOS-only. prepare runs everywhere.
listeners.get("meeting-detected")?.({ window: { id: "google-meet-window" } });
return "desktop-audio-fallback";
};
exports.startRecording = async () => null;
exports.pauseRecording = async () => null;
exports.resumeRecording = async () => {
Expand Down Expand Up @@ -254,7 +266,13 @@ test("SDK chatter never reaches stdout", async () => {
});
await send(3, "stop");

for (const noise of ["init noise", "permission noise", "prepare noise", "start noise"]) {
// "permission noise" only exists where a permission is requested, which is
// darwin (see requestPermissions in the sidecar). The rule under test — SDK
// chatter goes to stderr, never stdout — is checked on every platform by the
// three calls that do run everywhere.
const noises = ["init noise", "prepare noise", "start noise"];
if (process.platform === "darwin") noises.push("permission noise");
for (const noise of noises) {
assert.ok(stderr.includes(noise), `"${noise}" is missing from stderr:\n${stderr}`);
}
await close();
Expand Down
6 changes: 4 additions & 2 deletions scripts/eval/specs/14-recording.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,16 @@ checks:
- name: sidecar spawn/handshake/kill-group against a fake binary
type: cmd
run: cargo test -p brains-recording --test fake_sidecar
output_matches: "4 passed"
# Count-agnostic. A pinned count makes ADDING A TEST a spec failure, which
# teaches the wrong lesson; "FAILED" below is what actually guards this.
output_matches: "[1-9][0-9]* passed"
output_not_matches: "FAILED"
timeout_ms: 300000

- name: the Node sidecar against a fake Recall SDK
type: cmd
run: npm run test:recall
output_matches: "pass 4"
output_matches: "pass [1-9][0-9]*"
output_not_matches: "fail [1-9]"
timeout_ms: 300000

Expand Down
4 changes: 4 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ chrono = { workspace = true }
url = { workspace = true }
sha1 = "0.10"
base64 = { workspace = true }
# One job: strip the `\\?\` that Tauri puts on `resource_dir()`, before the
# path reaches a Node child that cannot parse it (src/resources.rs). Already in
# the lockfile via the Tauri tree — this only makes it a direct dependency.
dunce = "1"
# Permissions granted in capabilities/default.json must have their plugin
# present here, or ACL resolution fails at build time.
tauri-plugin-single-instance = "2"
Expand Down
10 changes: 10 additions & 0 deletions src-tauri/src/commands/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ pub struct StopResult {
pub path: Option<String>,
pub peak: i32,
pub duration_sec: f64,
/// The capture was above the silence floor but its level never moved.
/// The client gates the transcript fetch on it — a dead input bills a
/// job that comes back empty.
pub flat_input: bool,
pub detail: String,
}

Expand Down Expand Up @@ -140,6 +144,7 @@ pub async fn recording_stop(recorder: State<'_, RecordingState>) -> CmdResult<St
path: Some(stopped.path),
peak: stopped.peak,
duration_sec: stopped.duration_sec,
flat_input: stopped.flat_input,
}),
// Nothing was running, or nothing could be: both mean "no recording
// to stop", which is the state the caller wanted anyway.
Expand All @@ -148,6 +153,7 @@ pub async fn recording_stop(recorder: State<'_, RecordingState>) -> CmdResult<St
path: None,
peak: 0,
duration_sec: 0.0,
flat_input: false,
detail: error.to_string(),
}),
Err(error) => Err(CmdError::new(error)),
Expand Down Expand Up @@ -436,9 +442,13 @@ mod tests {
path: Some("/x.wav".into()),
peak: 2400,
duration_sec: 1.5,
flat_input: true,
detail: String::new(),
})
.unwrap();
assert!(json.contains(r#""durationSec":1.5"#), "{json}");
// The client gates the transcript fetch on this, so it has to arrive
// under the name the client reads.
assert!(json.contains(r#""flatInput":true"#), "{json}");
}
}
Loading
Loading