Skip to content

feat(qa): record an agent walking a web or iOS app, then open it - #54

Open
time-attack wants to merge 4 commits into
mainfrom
cursor/qa-recording-d3d5
Open

feat(qa): record an agent walking a web or iOS app, then open it#54
time-attack wants to merge 4 commits into
mainfrom
cursor/qa-recording-d3d5

Conversation

@time-attack

@time-attack time-attack commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Why (in your own words)

If you have a web app or a physical iOS app, you should be able to ask for a screen recording of an agent actually using it, plus a full QA pass, and have that recording open when it is done. Today QA can screenshot and write a report, but there is no walkthrough capture and nothing opens a video at the end. This is that affordance: a QA specialist (recording.md), not a sixth public skill. /recording is an opt-in alias for $qa --mode Report --module recording. Web capture is $B record; iOS capture polls the existing DebugBridge screenshot endpoint. Mutation stays report-only unless the user explicitly asked to fix.

demos/booked is a calm SFO-origin flight booker (published 2026 schedules, simulated card-on-file payment) plus record-mac.sh so the walkthrough can be run on a local Mac. A cloud agent cannot open a headed browser on the user's machine; $B record is also blocked on pair-agent tunnel tokens.

Live evidence

$B record start → stop writes a RECORDING: artifact. On the cloud VM (Linux Chromium, not the user's Mac) a Booked walkthrough produced:

RECORDING: /workspace/.gstack/qa-reports/recordings/booked-sfo-jfk.mp4
kind=mp4 frames=94 elapsed=10.9s ffmpeg=true

Delta DL 405 SFO→JFK Wed Aug 19 2026, $357, confirmation BKVBFEKH. Viewer open failed on the headless/cloud host (expected).

Browse unit tests:

bun test browse/test/screencast.test.ts
 10 pass
 0 fail

bun test test/compat-alias-routes.test.ts
(pass) /recording routes to a dispatcher and module that exist

bun test test/gstack2-skills.test.ts
 3 pass
 0 fail

Scope

  • Changed: $B record start|stop|status|open, iOS record-session.ts, QA recording.md + /recording alias, demos/booked fixture + Mac runner.
  • Verified live by: cloud-VM Playwright/Chromium start/stop + Booked booking walkthrough.
  • Did NOT test: the user's local Mac (this agent has no access to that display). record-mac.sh is the path for that.

Liveness proof (required)

This PR is from a cloud agent. There is no human desktop here to type GSTACK PR into a live UI.

Checklist

  • Liveness screenshot attached — cloud agent cannot satisfy this
  • Commits are signed off (git commit -s) — DCO
  • This is not a generated-file-only diff
  • No ETHOS.md edits
  • New public command / external service / host adapter — N/A (not a sixth public skill; no cloud browser)
  • Linked issue or reproduction: #

How to use it

/recording
# or on a Mac, from this branch:
cd demos/booked && ./record-mac.sh
Open in Web Open in Cursor 

Summary by cubic

Record agent walkthroughs for web and physical iOS QA and open the result when finished. Previously QA produced screenshots and a report only; now $B record (web) and an iOS poller capture sessions to mp4 or an HTML player, and stop --open launches it; recording remains blocked over the pair‑agent tunnel.

  • Adds $B record start|stop|status|open using CDP Page.startScreencast (JPEG frames), encodes via ffmpeg when present (HTML player fallback), opens via the platform handler, and flushes in‑progress recordings on server shutdown.

  • Introduces browse/src/screencast.ts and browse/src/screencast-encode.ts; validates output paths; caps frames and duration; keeps record read‑scoped and off the tunnel allowlist.

  • Adds ios-qa/scripts/record-session.ts to poll the daemon’s /screenshot for PNG frames, then encode/open; independent of the web runtime.

  • Routes /recording to $qa --mode Report --module recording; the specialist starts capture, then runs web qa-only or device ios-qa, and finally opens the artifact.

  • Demo: demos/booked plus record-mac.sh serves a local app on a Mac, walks a booking in a headed browser, records, and opens the file (cloud hosts cannot open a viewer on the user’s machine).

  • Tests cover encode fallback (JPEG/PNG), command validation and gating, and a live Chromium start/stop producing a RECORDING: artifact.

  • Usage:

    • Web: $B record start [path] [--fps N] [--quality Q] then $B record stop [--open]. If ffmpeg is missing, an HTML player is written and opened instead.
    • iOS: bun ios-qa/scripts/record-session.ts start --daemon <URL> [--fps N] then stop --open. No migration required.

Written for commit f71a10c. Summary will update on new commits.

Review in cubic

cursoragent and others added 3 commits August 18, 2026 16:58
Capture CDP JPEG frames while the agent drives the page, encode to mp4
(ffmpeg) or an HTML player, and optionally open the file. Keep record
off the pair-agent tunnel allowlist.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Sina Matian <time-attack@users.noreply.github.com>
Add a self-contained record-session helper that captures GET /screenshot
frames from the physical-device daemon, encodes them, and can open the
result. No new device backend.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Sina Matian <time-attack@users.noreply.github.com>
Route /recording to $qa --mode Report --module recording. The module
starts capture, runs full qa-only or ios-qa, then stops and opens the
file. Not a sixth public skill.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Sina Matian <time-attack@users.noreply.github.com>
@time-attack
time-attack marked this pull request as ready for review August 18, 2026 17:00

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

16 issues found across 26 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="browse/src/token-registry.ts">

<violation number="1" location="browse/src/token-registry.ts:42">
P2: A read-only token can now start, stop, or open the global recording, consuming disk and interrupting another agent's recording. Remove `record` from `SCOPE_READ` and grant it through a write-capable scope instead.</violation>
</file>

<file name="browse/src/screencast-encode.ts">

<violation number="1" location="browse/src/screencast-encode.ts:142">
P2: When `record start` receives an extensionless path inside a dot-named directory, this regex treats the directory as the extension and writes the MP4 elsewhere. Derive the extension from `path.parse(outputPath)` or the basename instead.</violation>

<violation number="2" location="browse/src/screencast-encode.ts:175">
P2: `record start` without a path calls `defaultRecordingPath()`, which returns a `/tmp` file. The recording module documents `.gstack/qa-reports/recordings/qa-<stamp>.mp4` as the default, so the default workflow bypasses the QA report artifact directory. Return the documented report path or update the module contract.</violation>
</file>

<file name="browse/test/screencast.test.ts">

<violation number="1" location="browse/test/screencast.test.ts:136">
P3: After `record stop`, `recordingStatus().framesDir` is always undefined: `stopRecording` sets `active = null`, and `recordingStatus()` then returns only `{ active: false, lastArtifact }`. So this frames-dir cleanup never executes. On the HTML-player fallback (the CI path, since ffmpeg-present mp4 is untested), `stopRecording` deliberately keeps the frame directory, so this test leaks `gstack-recording-frames-*` dirs into TEMP_DIR on every run. Capture the frames dir before calling `record stop`, or drop the dead lines.</violation>
</file>

<file name="ios-qa/scripts/record-session.ts">

<violation number="1" location="ios-qa/scripts/record-session.ts:52">
P1: When `--token` is used, this state file exposes the bearer with default permissions, commonly 0644, and can authenticate to the daemon. Write the state through an owner-only temporary file, rename it atomically, and preserve 0600 permissions.</violation>

<violation number="2" location="ios-qa/scripts/record-session.ts:102">
P2: When `--out` ends in `.webm`, this command selects `libx264`, so ffmpeg cannot create the requested WebM artifact and `stop` silently falls back to HTML. Select `libvpx-vp9` for WebM or reject that extension.</violation>

<violation number="3" location="ios-qa/scripts/record-session.ts:145">
P1: When a recording reaches the 15-minute or 3,600-frame limit, the poller exits without producing a `RECORDING:` artifact or clearing state. Finalize the bounded recording or clear the state and report that the limit was reached.</violation>

<violation number="4" location="ios-qa/scripts/record-session.ts:159">
P2: When two `start` commands overlap, both can pass this check and spawn pollers, leaving one orphaned and causing conflicting recordings. Claim the state path with an exclusive lock before spawning.</violation>

<violation number="5" location="ios-qa/scripts/record-session.ts:196">
P1: When the detached child runs before `writeState()` completes, the poller sees no state and exits without capturing any frames. Make the `poll` action briefly retry the state read before returning.</violation>

<violation number="6" location="ios-qa/scripts/record-session.ts:199">
P1: When the poller has already exited and its PID is reused, `stop` can terminate an unrelated process. Clear stale state on poller exit and validate process ownership before signaling the PID.</violation>
</file>

<file name="browse/src/server.ts">

<violation number="1" location="browse/src/server.ts:1416">
P2: When the recording CDP session stops responding, shutdown can remain stuck indefinitely in `flushRecordingOnShutdown()` after deleting the state-file credential. Bound the recording flush before continuing teardown, or add a timeout to `stopRecording()`.</violation>
</file>

<file name="browse/src/screencast.ts">

<violation number="1" location="browse/src/screencast.ts:147">
P2: When CDP emits no frames, the 15-minute limit never runs and the recorder remains active indefinitely. Enforce the duration with a timer started with the recording and clear it during stop.</violation>

<violation number="2" location="browse/src/screencast.ts:169">
P2: If `Page.startScreencast` rejects, this listener remains attached and contaminates later retries. Remove the listener in a catch path before rethrowing the start error.</violation>

<violation number="3" location="browse/src/screencast.ts:186">
P2: When finalization fails, `active = null` discards the recording state before an artifact exists. Clear the state only after successful encoding, or retain a pending recording so `record stop` can retry.</violation>

<violation number="4" location="browse/src/screencast.ts:209">
P2: `record stop` encodes with synchronous `spawnSync` calls: `encodeFrameDirectory` first runs `ffmpegAvailable()` (spawnSync probe, up to 4s) then `runFfmpeg` (spawnSync during which `ffmpeg` is given up to 120s per attempt, two attempts). Because this runs on the server's single-threaded event loop inside the command handler, a long recording blocks the entire browse server (all other page/CDP/token requests and streamed output) for the full encode duration. Use async `spawn`/`execFile` with a promise so the encode doesn't stall the server, or at least yield between frames/probes.</violation>

<violation number="5" location="browse/src/screencast.ts:212">
P2: On the HTML-player fallback (hosts without ffmpeg, the documented default path on this host), `stopRecording` never removes the frame directory: the cleanup guard at this line is skipped whenever `encoded.kind === 'html'`, and `flushRecordingOnShutdown` doesn't clean it either. Every recording without ffmpeg permanently leaks a `TEMP_DIR/gstack-recording-frames-<stamp>` directory holding up to MAX_FRAMES (7200) JPEGs, accumulating on long-lived servers. The frames are only needed to serve the html artifact, but there is no lifecycle that removes them once the player is delivered. Clean up the frame directory after the html artifact is produced (e.g. treat the html player as consumable, or track and reap abandoned frame dirs on start/shutdown).</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

let n = 0;
const maxFrames = 3600;
const deadline = state.startedAt + 15 * 60 * 1000;
while (Date.now() < deadline && n < maxFrames) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a recording reaches the 15-minute or 3,600-frame limit, the poller exits without producing a RECORDING: artifact or clearing state. Finalize the bounded recording or clear the state and report that the limit was reached.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/scripts/record-session.ts, line 145:

<comment>When a recording reaches the 15-minute or 3,600-frame limit, the poller exits without producing a `RECORDING:` artifact or clearing state. Finalize the bounded recording or clear the state and report that the limit was reached.</comment>

<file context>
@@ -0,0 +1,262 @@
+  let n = 0;
+  const maxFrames = 3600;
+  const deadline = state.startedAt + 15 * 60 * 1000;
+  while (Date.now() < deadline && n < maxFrames) {
+    if (!fs.existsSync(statePath())) break;
+    try {
</file context>


function writeState(state: RecordingState): void {
fs.mkdirSync(homeDir(), { recursive: true });
fs.writeFileSync(statePath(), JSON.stringify(state, null, 2));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When --token is used, this state file exposes the bearer with default permissions, commonly 0644, and can authenticate to the daemon. Write the state through an owner-only temporary file, rename it atomically, and preserve 0600 permissions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/scripts/record-session.ts, line 52:

<comment>When `--token` is used, this state file exposes the bearer with default permissions, commonly 0644, and can authenticate to the daemon. Write the state through an owner-only temporary file, rename it atomically, and preserve 0600 permissions.</comment>

<file context>
@@ -0,0 +1,262 @@
+
+function writeState(state: RecordingState): void {
+  fs.mkdirSync(homeDir(), { recursive: true });
+  fs.writeFileSync(statePath(), JSON.stringify(state, null, 2));
+}
+
</file context>
Suggested change
fs.writeFileSync(statePath(), JSON.stringify(state, null, 2));
const p = statePath();
const tmp = `${p}.tmp.${process.pid}`;
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { encoding: 'utf-8', mode: 0o600 });
fs.renameSync(tmp, p);
fs.chmodSync(p, 0o600);

const state = readState();
if (!state) throw new Error('Not recording. Start with: record-session.ts start --daemon URL');
const flags = parseNamed(args);
try { process.kill(state.pid, 'SIGTERM'); } catch (err: any) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the poller has already exited and its PID is reused, stop can terminate an unrelated process. Clear stale state on poller exit and validate process ownership before signaling the PID.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/scripts/record-session.ts, line 199:

<comment>When the poller has already exited and its PID is reused, `stop` can terminate an unrelated process. Clear stale state on poller exit and validate process ownership before signaling the PID.</comment>

<file context>
@@ -0,0 +1,262 @@
+  const state = readState();
+  if (!state) throw new Error('Not recording. Start with: record-session.ts start --daemon URL');
+  const flags = parseNamed(args);
+  try { process.kill(state.pid, 'SIGTERM'); } catch (err: any) {
+    if (err?.code !== 'ESRCH') throw err;
+  }
</file context>

Comment on lines +196 to +198
const state = readState();
if (!state) throw new Error('Not recording. Start with: record-session.ts start --daemon URL');
const flags = parseNamed(args);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the detached child runs before writeState() completes, the poller sees no state and exits without capturing any frames. Make the poll action briefly retry the state read before returning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios-qa/scripts/record-session.ts, line 196:

<comment>When the detached child runs before `writeState()` completes, the poller sees no state and exits without capturing any frames. Make the `poll` action briefly retry the state read before returning.</comment>

<file context>
@@ -0,0 +1,262 @@
+}
+
+function stop(args: string[]): string {
+  const state = readState();
+  if (!state) throw new Error('Not recording. Start with: record-session.ts start --daemon URL');
+  const flags = parseNamed(args);
</file context>
Suggested change
const state = readState();
if (!state) throw new Error('Not recording. Start with: record-session.ts start --daemon URL');
const flags = parseNamed(args);
let state = readState();
for (let i = 0; !state && i < 20; i++) {
await Bun.sleep(50);
state = readState();
}
if (!state) return;
await pollLoop(state);

'snapshot', 'text', 'html', 'links', 'forms', 'accessibility',
'console', 'network', 'perf', 'dialog', 'is', 'inspect',
'url', 'tabs', 'status', 'screenshot', 'pdf', 'css', 'attrs',
'url', 'tabs', 'status', 'screenshot', 'pdf', 'record', 'css', 'attrs',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A read-only token can now start, stop, or open the global recording, consuming disk and interrupting another agent's recording. Remove record from SCOPE_READ and grant it through a write-capable scope instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/src/token-registry.ts, line 42:

<comment>A read-only token can now start, stop, or open the global recording, consuming disk and interrupting another agent's recording. Remove `record` from `SCOPE_READ` and grant it through a write-capable scope instead.</comment>

<file context>
@@ -39,7 +39,7 @@ import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
   'snapshot', 'text', 'html', 'links', 'forms', 'accessibility',
   'console', 'network', 'perf', 'dialog', 'is', 'inspect',
-  'url', 'tabs', 'status', 'screenshot', 'pdf', 'css', 'attrs',
+  'url', 'tabs', 'status', 'screenshot', 'pdf', 'record', 'css', 'attrs',
   'media', 'data',
 ]);
</file context>

Comment thread browse/src/screencast.ts
const encoded: EncodeResult = encodeFrameDirectory(rec.framesDir, rec.outputPath, rec.fps);
lastArtifact = encoded.artifactPath;

if (!opts.keepFrames && encoded.kind !== 'html') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: On the HTML-player fallback (hosts without ffmpeg, the documented default path on this host), stopRecording never removes the frame directory: the cleanup guard at this line is skipped whenever encoded.kind === 'html', and flushRecordingOnShutdown doesn't clean it either. Every recording without ffmpeg permanently leaks a TEMP_DIR/gstack-recording-frames-<stamp> directory holding up to MAX_FRAMES (7200) JPEGs, accumulating on long-lived servers. The frames are only needed to serve the html artifact, but there is no lifecycle that removes them once the player is delivered. Clean up the frame directory after the html artifact is produced (e.g. treat the html player as consumable, or track and reap abandoned frame dirs on start/shutdown).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/src/screencast.ts, line 212:

<comment>On the HTML-player fallback (hosts without ffmpeg, the documented default path on this host), `stopRecording` never removes the frame directory: the cleanup guard at this line is skipped whenever `encoded.kind === 'html'`, and `flushRecordingOnShutdown` doesn't clean it either. Every recording without ffmpeg permanently leaks a `TEMP_DIR/gstack-recording-frames-<stamp>` directory holding up to MAX_FRAMES (7200) JPEGs, accumulating on long-lived servers. The frames are only needed to serve the html artifact, but there is no lifecycle that removes them once the player is delivered. Clean up the frame directory after the html artifact is produced (e.g. treat the html player as consumable, or track and reap abandoned frame dirs on start/shutdown).</comment>

<file context>
@@ -0,0 +1,274 @@
+  const encoded: EncodeResult = encodeFrameDirectory(rec.framesDir, rec.outputPath, rec.fps);
+  lastArtifact = encoded.artifactPath;
+
+  if (!opts.keepFrames && encoded.kind !== 'html') {
+    try {
+      fs.rmSync(rec.framesDir, { recursive: true, force: true });
</file context>

Comment thread browse/src/screencast.ts
? `Not recording. Last recording: ${lastArtifact}`
: 'Not recording. Start with: record start [path]');
}
active = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When finalization fails, active = null discards the recording state before an artifact exists. Clear the state only after successful encoding, or retain a pending recording so record stop can retry.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/src/screencast.ts, line 186:

<comment>When finalization fails, `active = null` discards the recording state before an artifact exists. Clear the state only after successful encoding, or retain a pending recording so `record stop` can retry.</comment>

<file context>
@@ -0,0 +1,274 @@
+      ? `Not recording. Last recording: ${lastArtifact}`
+      : 'Not recording. Start with: record start [path]');
+  }
+  active = null;
+
+  try {
</file context>

Comment thread browse/src/screencast.ts
};

session.on('Page.screencastFrame', rec.onFrame);
await session.send('Page.startScreencast', {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: If Page.startScreencast rejects, this listener remains attached and contaminates later retries. Remove the listener in a catch path before rethrowing the start error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/src/screencast.ts, line 169:

<comment>If `Page.startScreencast` rejects, this listener remains attached and contaminates later retries. Remove the listener in a catch path before rethrowing the start error.</comment>

<file context>
@@ -0,0 +1,274 @@
+  };
+
+  session.on('Page.screencastFrame', rec.onFrame);
+  await session.send('Page.startScreencast', {
+    format: 'jpeg',
+    quality,
</file context>

Comment thread browse/src/screencast.ts
await ackFrame(session, params.sessionId);
if (active !== rec) return;
const now = Date.now();
if (now - rec.startedAt > MAX_DURATION_MS) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When CDP emits no frames, the 15-minute limit never runs and the recorder remains active indefinitely. Enforce the duration with a timer started with the recording and clear it during stop.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/src/screencast.ts, line 147:

<comment>When CDP emits no frames, the 15-minute limit never runs and the recorder remains active indefinitely. Enforce the duration with a timer started with the recording and clear it during stop.</comment>

<file context>
@@ -0,0 +1,274 @@
+      await ackFrame(session, params.sessionId);
+      if (active !== rec) return;
+      const now = Date.now();
+      if (now - rec.startedAt > MAX_DURATION_MS) {
+        void stopRecording({ open: false }).catch(() => {});
+        return;
</file context>

expect(fs.existsSync(artifact)).toBe(true);
expect(fs.statSync(artifact).size).toBeGreaterThan(20);
fs.rmSync(dir, { recursive: true, force: true });
const framesDir = recordingStatus().framesDir;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: After record stop, recordingStatus().framesDir is always undefined: stopRecording sets active = null, and recordingStatus() then returns only { active: false, lastArtifact }. So this frames-dir cleanup never executes. On the HTML-player fallback (the CI path, since ffmpeg-present mp4 is untested), stopRecording deliberately keeps the frame directory, so this test leaks gstack-recording-frames-* dirs into TEMP_DIR on every run. Capture the frames dir before calling record stop, or drop the dead lines.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At browse/test/screencast.test.ts, line 136:

<comment>After `record stop`, `recordingStatus().framesDir` is always undefined: `stopRecording` sets `active = null`, and `recordingStatus()` then returns only `{ active: false, lastArtifact }`. So this frames-dir cleanup never executes. On the HTML-player fallback (the CI path, since ffmpeg-present mp4 is untested), `stopRecording` deliberately keeps the frame directory, so this test leaks `gstack-recording-frames-*` dirs into TEMP_DIR on every run. Capture the frames dir before calling `record stop`, or drop the dead lines.</comment>

<file context>
@@ -0,0 +1,156 @@
+    expect(fs.existsSync(artifact)).toBe(true);
+    expect(fs.statSync(artifact).size).toBeGreaterThan(20);
+    fs.rmSync(dir, { recursive: true, force: true });
+    const framesDir = recordingStatus().framesDir;
+    if (framesDir && fs.existsSync(framesDir)) fs.rmSync(framesDir, { recursive: true, force: true });
+  });
</file context>

A calm SFO-origin flight booker with published 2026 schedules, plus a
Mac script that serves it, walks a booking in headed browse, records,
and opens the file. Cloud agents cannot reach a user's Mac display.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Sina Matian <time-attack@users.noreply.github.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="demos/booked/index.html">

<violation number="1" location="demos/booked/index.html:378">
P3: On initial load the plane `<g id="plane">` keeps its hardcoded `translate(80 260)` while `drawMap()` moves the origin dot to the projected SFO coordinates (~104,186); `animatePlane` only runs after a destination is selected. The plane briefly appears offset from the origin dot. Position it with the projected origin in the no-destination branch as well.</violation>

<violation number="2" location="demos/booked/index.html:382">
P3: In timezones at UTC+13/UTC+14 (e.g. Pacific/Tongatapu, Pacific/Kiritimati), clicking a date button stores the previous day: `new Date(iso + "T12:00:00")` is parsed as local noon, while the selected-date comparison in `renderDates` uses `ymd()`/`toISOString()` (UTC), so the highlight never matches and the booked date is off by one. Append `Z` so the reconstruction uses UTC and stays consistent with the `toISOString`-based calendar in the rendered buttons.</violation>
</file>

<file name="demos/booked/record-mac.sh">

<violation number="1" location="demos/booked/record-mac.sh:10">
P2: `record start "$OUT"` fails: the script writes the recording to `<repo>/.gstack/qa-reports/recordings/`, which is outside the browse server's write scope (TEMP_DIR `TMP`/`/tmp` plus `process.cwd()` = `demos/booked` for `cd demos/booked && ./record-mac.sh`). `validateOutputPath` rejects it with "Path must be within". Write the output under the current directory (e.g. `$ROOT/recordings/`) or under the OS temp dir so the documented Mac flow actually records.</violation>

<violation number="2" location="demos/booked/record-mac.sh:61">
P3: The script `sleep 0.4`s then runs `curl -fsS`; with `set -e` a slow server startup makes curl fail and aborts the whole recording. Retry the health check for a few seconds (e.g. a small loop with timeout) instead of a single fixed sleep.</violation>

<violation number="3" location="demos/booked/record-mac.sh:64">
P2: `run()` expands unquoted `$BROWSE`, so any space in the browse path or checkout path is split into separate arguments and the command fails. On a Mac this is common (username with a space in `/Users/...`). Store the browse command as an array and expand it with `"${BROWSE[@]}"` instead of relying on word-splitting.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

PORT="${PORT:-8765}"
URL="http://127.0.0.1:${PORT}/"
STAMP="$(date +%Y%m%d-%H%M%S)"
OUT="${ROOT}/../../.gstack/qa-reports/recordings/booked-mac-${STAMP}.mp4"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: record start "$OUT" fails: the script writes the recording to <repo>/.gstack/qa-reports/recordings/, which is outside the browse server's write scope (TEMP_DIR TMP//tmp plus process.cwd() = demos/booked for cd demos/booked && ./record-mac.sh). validateOutputPath rejects it with "Path must be within". Write the output under the current directory (e.g. $ROOT/recordings/) or under the OS temp dir so the documented Mac flow actually records.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At demos/booked/record-mac.sh, line 10:

<comment>`record start "$OUT"` fails: the script writes the recording to `<repo>/.gstack/qa-reports/recordings/`, which is outside the browse server's write scope (TEMP_DIR `TMP`/`/tmp` plus `process.cwd()` = `demos/booked` for `cd demos/booked && ./record-mac.sh`). `validateOutputPath` rejects it with "Path must be within". Write the output under the current directory (e.g. `$ROOT/recordings/`) or under the OS temp dir so the documented Mac flow actually records.</comment>

<file context>
@@ -0,0 +1,85 @@
+PORT="${PORT:-8765}"
+URL="http://127.0.0.1:${PORT}/"
+STAMP="$(date +%Y%m%d-%H%M%S)"
+OUT="${ROOT}/../../.gstack/qa-reports/recordings/booked-mac-${STAMP}.mp4"
+mkdir -p "$(dirname "$OUT")"
+OUT="$(cd "$(dirname "$OUT")" && pwd)/$(basename "$OUT")"
</file context>

curl -fsS -o /dev/null "$URL"

# shellcheck disable=SC2086
run() { $BROWSE --headed "$@"; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: run() expands unquoted $BROWSE, so any space in the browse path or checkout path is split into separate arguments and the command fails. On a Mac this is common (username with a space in /Users/...). Store the browse command as an array and expand it with "${BROWSE[@]}" instead of relying on word-splitting.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At demos/booked/record-mac.sh, line 64:

<comment>`run()` expands unquoted `$BROWSE`, so any space in the browse path or checkout path is split into separate arguments and the command fails. On a Mac this is common (username with a space in `/Users/...`). Store the browse command as an array and expand it with `"${BROWSE[@]}"` instead of relying on word-splitting.</comment>

<file context>
@@ -0,0 +1,85 @@
+curl -fsS -o /dev/null "$URL"
+
+# shellcheck disable=SC2086
+run() { $BROWSE --headed "$@"; }
+
+run disconnect >/dev/null 2>&1 || true
</file context>

Comment thread demos/booked/index.html
renderCities(document.getElementById("search").value);
renderDates();
renderRides();
drawMap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: On initial load the plane <g id="plane"> keeps its hardcoded translate(80 260) while drawMap() moves the origin dot to the projected SFO coordinates (~104,186); animatePlane only runs after a destination is selected. The plane briefly appears offset from the origin dot. Position it with the projected origin in the no-destination branch as well.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At demos/booked/index.html, line 378:

<comment>On initial load the plane `<g id="plane">` keeps its hardcoded `translate(80 260)` while `drawMap()` moves the origin dot to the projected SFO coordinates (~104,186); `animatePlane` only runs after a destination is selected. The plane briefly appears offset from the origin dot. Position it with the projected origin in the no-destination branch as well.</comment>

<file context>
@@ -0,0 +1,438 @@
+  renderCities(document.getElementById("search").value);
+  renderDates();
+  renderRides();
+  drawMap();
+}
+
</file context>

Comment thread demos/booked/index.html
}

function selectDate(iso) {
state.date = new Date(iso + "T12:00:00");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: In timezones at UTC+13/UTC+14 (e.g. Pacific/Tongatapu, Pacific/Kiritimati), clicking a date button stores the previous day: new Date(iso + "T12:00:00") is parsed as local noon, while the selected-date comparison in renderDates uses ymd()/toISOString() (UTC), so the highlight never matches and the booked date is off by one. Append Z so the reconstruction uses UTC and stays consistent with the toISOString-based calendar in the rendered buttons.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At demos/booked/index.html, line 382:

<comment>In timezones at UTC+13/UTC+14 (e.g. Pacific/Tongatapu, Pacific/Kiritimati), clicking a date button stores the previous day: `new Date(iso + "T12:00:00")` is parsed as local noon, while the selected-date comparison in `renderDates` uses `ymd()`/`toISOString()` (UTC), so the highlight never matches and the booked date is off by one. Append `Z` so the reconstruction uses UTC and stays consistent with the `toISOString`-based calendar in the rendered buttons.</comment>

<file context>
@@ -0,0 +1,438 @@
+}
+
+function selectDate(iso) {
+  state.date = new Date(iso + "T12:00:00");
+  state.flight = null;
+  renderDates();
</file context>

SERVER_PID=$!
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
sleep 0.4
curl -fsS -o /dev/null "$URL"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The script sleep 0.4s then runs curl -fsS; with set -e a slow server startup makes curl fail and aborts the whole recording. Retry the health check for a few seconds (e.g. a small loop with timeout) instead of a single fixed sleep.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At demos/booked/record-mac.sh, line 61:

<comment>The script `sleep 0.4`s then runs `curl -fsS`; with `set -e` a slow server startup makes curl fail and aborts the whole recording. Retry the health check for a few seconds (e.g. a small loop with timeout) instead of a single fixed sleep.</comment>

<file context>
@@ -0,0 +1,85 @@
+SERVER_PID=$!
+trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
+sleep 0.4
+curl -fsS -o /dev/null "$URL"
+
+# shellcheck disable=SC2086
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants