Commit Graph

191 Commits

Author SHA1 Message Date
41e94d5daa feat(01-08): implement remuxSegments — single-EBML WebM remux via ts-ebml + webm-muxer
Drives all 5 RED tests in tests/background/webm-remux.test.ts to GREEN.

- Parses each VideoSegment via ts-ebml Decoder; tracks current Cluster
  Timestamp; extracts each SimpleBlock's VP9 frame(s) + keyframe flag
  + segment-local timestamp via tools.ebmlBlock.
- Re-emits all frames through a single webm-muxer Muxer<ArrayBufferTarget>
  configured with type:'webm', codec:'V_VP9', and adjusted monotonic
  timestamps (segmentBaseMs + cluster.Timestamp + block.timecode,
  microseconds for the muxer).
- Picks track info (PixelWidth, PixelHeight, optional CodecPrivate)
  from first segment that exposes them; falls back to 1024x768 with
  a logged warning per Task 5's failure-mode (e).
- Defensive: empty input -> empty Blob (Test 5); sort by timestamp
  ascending (mirrors retired mergeVideoSegments order discipline).
- 434 LOC including extensive JSDoc per project style; 8 small named
  helpers, no nested mega-functions.
- Empirically: 3-segment fixture -> 912 frames in 29.954 s,
  1_643_057 bytes (single-EBML); ffprobe duration=29.94s, count_frames=912.
- Logging via new Logger('Remux'); no console.* anywhere; no as any;
  no @ts-ignore.

Full suite: 13 files / 60 GREEN + 2 RED (webm-playback duration assertions
still failing against the stale fixture — Task 4 swaps the call site,
Task 5 regenerates the fixture). tsc exit 0.
2026-05-17 09:26:09 +02:00
407e683e9b test(01-08): RED unit tests for remuxSegments — single-EBML + monotonic + frame-count + size + empty
- 5 RED tests pinning the contract for src/background/webm-remux.ts
  (created in Task 3). All fail with "module missing" today — the
  Task 3 GREEN gate.
- Test 1: exactly 1 EBML header + 1 Segment magic in output.
- Test 2: output size within [0.7x, 1.3x] of input sum.
- Test 3: ffprobe format=duration >= 25_000 ms (skip-if-no-ffprobe).
- Test 4: ffprobe -count_frames in [905, 912] (per-seg sum 912 ± 3 boundary
  partial-frame drops, I-01 tightening).
- Test 5: empty input -> empty Blob (defense-in-depth).
- Fixture sliced at d13-confirmed byte offsets (0 / 509038 / 970967);
  verified against committed last_30sec.webm at Task 2 land time.
- Baseline counts: 13 files / 62 tests / 7 failed (2 webm-playback +
  5 new webm-remux) | 55 passed. tsc exit 0.
2026-05-17 09:23:53 +02:00
503531485c feat(01-08): install ts-ebml + webm-muxer; pin SW-compat via deps test
- Add ts-ebml ^3.0.2 (parse half) and webm-muxer ^5.1.4 (write half) per
  CONTEXT.md amendment D-14-remux; both MIT, both verified SW-compatible
  in the d13 debug-session library survey.
- tests/background/webm-remux-deps.test.ts pins two contracts:
  (a) named exports surface (Muxer + ArrayBufferTarget + Decoder).
  (b) both libraries import cleanly when window/document are absent on
      globalThis — guards the published dist against accidentally
      acquiring DOM globals on the hot path that would crash the
      Chrome service-worker runtime.
- Note: webm-muxer 5.1.4 upstream-deprecated in favor of Mediabunny; the
  pinned version still meets the d13 architectural requirement
  (single-EBML output via addVideoChunkRaw). Migration to Mediabunny is
  out of scope for Plan 01-08 and would require a new ADR.
- Baseline 53 GREEN + 2 new GREEN; tsc clean; 2 webm-playback duration
  RED still pending (drive to GREEN in Tasks 3-5).
2026-05-17 09:22:46 +02:00
2e499d7387 docs(01): add Plans 01-08 / 01-09 / 01-10 (amended Phase 1 charter)
Plans cover the post-D-13 architecture and the auto-start UX charter
expansion that landed during 2026-05-16 UAT:

- Plan 01-08 — WebM remux via ts-ebml@3.0.2 + webm-muxer@5.1.4. Replaces
  the broken file-concat in mergeVideoSegments with a real single-EBML
  remux. Drives the 2 RED tests in tests/offscreen/webm-playback.test.ts
  to GREEN. Regenerates the canonical fixture against the remuxer.
  5 tasks (4 TDD + 1 operator empirical checkpoint), wave 1.

- Plan 01-09 — Whole-desktop constraint (displaySurface:'monitor',
  cursor:'always') + post-grant validation, chrome.action.onClicked
  direct toolbar invocation, chrome.action badge state machine
  (REC/OFF/ERROR), chrome.runtime.onStartup notification + recovery
  notification on onUserStoppedSharing, popup scoped to SAVE-only.
  17 new test assertions across 4 test files. smoke.sh updated to
  auto-select an entire screen. 5 tasks (4 TDD + 1 operator empirical
  checkpoint), wave 2 (depends on 01-08).

- Plan 01-10 — chrome.runtime.onInstalled welcome tab on first install
  via chrome.storage.local guard; vanilla welcome.html/ts/css bundle
  with single "Начать запись" button consuming install-time activation.
  Uses centralized Logger pattern. 4 tasks (3 TDD + 1 operator empirical
  checkpoint), wave 3 (depends on 01-09).

CONTEXT.md amendment block appended with 4 disambiguated decisions:
- D-14-remux: WebM remux supersedes D-13 file-concat
- D-15-display-surface: whole-desktop + cursor visibility lifted from
  Phase 5 deferral
- D-16-toolbar: toolbar onClicked + popup SAVE-only + badge state
  machine + onStartup/recovery notifications
- D-17-onboarding: welcome tab on first install (distinct from
  D-17-port-lifecycle from Option C)
The earlier D-17 port-lifecycle heading also renamed to hyphenated
form for cross-ref consistency.

Plan-check loop: 3 iterations (initial + 2 revisions). Iteration 1
surfaced 11 findings (2 BLOCKER + 6 WARNING + 3 INFO); all addressed
via revision iter 1 with checker-recommended fixes. Iteration 2
surfaced 3 derivative regressions (literal-string grep anchors from
the iter-1 fixes did not match live CONTEXT.md); all addressed in iter
2 with empirically-validated literals. Iteration 3 PASSED clean.

Validation: gsd-sdk frontmatter.validate + verify.plan-structure both
return valid=True for all 3 plans. Plan 01-08 Task 4 verify-chain
grep tested end-to-end against live CONTEXT.md (exit 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:19:22 +02:00
bc310d98cf revert(01): reopen Phase 1 — D-13 multi-EBML-concat is unplayable
REQ-video-ring-buffer flipped from [x] back to [ ]. ROADMAP.md Phase 1
row reverted from [x] Closed 2026-05-15 to [ ] reopened 2026-05-16.
STATE.md status flipped phase_complete → phase_reopened with full
historical narrative preserved.

Root cause (confirmed at byte level by gsd-debugger 2026-05-16):
D-13's concat-of-self-contained-WebM-segments architecture produces a
3-EBML-header WebM that standards-compliant Matroska parsers
(mpv, ffmpeg, Chrome HTMLMediaElement) play only as the first segment
(~9.94 s) and silently drop the remaining 2 segments. Confirmed via
operator mpv drag-drop test of BOTH the canonical 2026-05-15 closure
fixture and the 2026-05-16 UAT-produced fixture — both exhibit the
same broken playback.

The 2026-05-15 "operator-confirmed clean Chrome playback" assessment
was insufficient: it verified the file plays without freezing but did
not measure total duration. Phase 1's primary deliverable
(REQ-video-ring-buffer / SPEC §10 #7) is therefore NOT satisfied.

Fix path chosen by user: ts-ebml (parse) + webm-muxer (write) to
replace mergeVideoSegments file-concat with real single-EBML remux.
Will land as Plan 01-08 via fresh /gsd-plan-phase ceremony.

RED test landed in tests/offscreen/webm-playback.test.ts (2 new
assertions on container-format-duration + ffmpeg-full-decode-duration).
2 failures, 53 baseline tests still GREEN.

Option C port-lifecycle refactor (debug session
empty-archive-port-race, commits 674c415..f0871c0) DID land cleanly
and is retained — that fix was orthogonal and correctly resolved the
silent-empty-archive symptom that previously masked this deeper bug.

Debug session: .planning/debug/d13-multi-ebml-concat-unplayable.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:47:47 +02:00
f1026954fc test(01): UAT BLOCKER #2 — D-13 multi-EBML-concat plays only ~9 s; both committed fixture and UAT output exhibit same broken playback. Phase 1 architecture finding. 2026-05-16 18:57:48 +02:00
f0871c0237 docs(option-c): archive empty-archive-port-race + amend CONTEXT.md D-17 port lifecycle
Two doc updates closing the debug session per the resolved pattern this
phase has established (cf. resolved/d12-blob-port-transfer-fails.md and
resolved/webm-playback-freeze.md):

1. **Move debug session to resolved/** with the Resolution section
   filled in (root_cause, fix, verification, files_changed). Status
   flipped tdd_red_confirmed -> resolved. Original investigation
   notes + bisect results + Option C strategy spec all preserved
   in-place — the file is the full provenance trail.

2. **Amend 01-CONTEXT.md D-17** with the new port lifecycle commitments.
   Append-only (D-17 itself untouched) per the doc cascade rule
   established earlier this phase ("amendments append, do not replace,
   to preserve SPEC provenance"). The amendment narrates:
   - What was Claude's-discretion at Phase 1 plan time has been
     specified by Option C.
   - The 290 s pre-emptive setTimeout reconnect (Pitfall 4) is RETIRED.
   - The architectural commitments added: PING/PONG health probe,
     request-id'd REQUEST_BUFFER/BUFFER, SW retry on port replacement,
     outer 10 s hard-timeout, operator-visible EmptyVideoBufferError
     surface.
   - The 4 pinning contracts added (port-health-probe,
     request-id-protocol, port-lifecycle-continuous, plus the
     refactored port-reconnect-race).

Suite remains 11 files / 53 tests, all GREEN. Quality gates intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:40:02 +02:00
246eadb2ef test(option-c): continuous 600 s port lifecycle pinning contract
Implements Option C step 3 per .planning/debug/empty-archive-port-race.md:

  "Continuous end-to-end vitest covering 600 s of port lifecycle
   (2 reconnects + simulated REQUEST_BUFFER round-trips). Becomes the
   new pinning contract for the port lifecycle."

The UAT Test 3 BLOCKER surfaced because no test exercised the full
operator timeline — 5+ minute recording with port-replacement windows
crossing real SAVE_ARCHIVE round-trips. This file pins that contract
end-to-end at the unit-test level.

What's exercised:
  - Both SW (src/background/index.ts) and offscreen recorder
    (src/offscreen/recorder.ts) loaded into the SAME chrome stub, with
    paired port-pair factory (one connect() yields offPort + swPort
    that talk to each other through captured listeners).
  - 12 ping/pong cycles (~300 s simulated wall-clock).
  - 3 SAVE_ARCHIVE round-trips (one before reconnect, two after each
    of the two forced reconnects).
  - 2 EXTERNAL port disconnects (port._disconnected=true) — simulates
    the SW eviction / port glitch path that the H1.b test pins.
  - JSZip mocked at file scope (vi.mock) because Node 22+ JSZip can't
    read native Blobs — preserves integration shape (size accounting)
    without depending on JSZip's Node compatibility.

Final assertions:
  1. All 3 saveArchive calls return success:true.
  2. EVERY BUFFER message that crossed the wire carried segments (no
     silent-loss path was reachable).
  3. PONGs round-tripped (proves health-probe loop closes).

Suite: 53 GREEN / 53 tests. tsc --noEmit exit 0; type-safety grep clean;
npm run build exit 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:53:47 +02:00
ffd383d2a6 feat(option-c-error-surface): createArchive throws on empty video; saveArchive surfaces to popup
Retires the upstream silent-skip defect (bisected to commit 555eb05 —
imported broken from before Phase 1, never on the original 22-defect
audit because it needed a real failure mode to surface). Per
.planning/debug/empty-archive-port-race.md Option C step 4: even with
the architectural port lifecycle now bullet-proof, a hard outer timeout
on the BUFFER fetch (10 s after every retry) must result in an
operator-VISIBLE failure — not a silent video-less archive.

1. **EmptyVideoBufferError** — typed error class with a stable `code`
   field ('empty-video-buffer') matching the offscreen-side
   CaptureErrorCode union vocabulary. Lets saveArchive's catch
   distinguish "no segments" failure from JSZip/manifest failures.

2. **createArchive throws** — when videoBufferResponse.segments.length
   === 0 OR when the merged blob is zero bytes, throw the typed error
   with a detail string for diagnostics. Replaces the silent-skip
   branch that was the bisect-confirmed transport for the H2 class.

3. **saveArchive broadcast** — on EmptyVideoBufferError, emit
   {type:'RECORDING_ERROR', error:'empty-video-buffer'} via
   chrome.runtime.sendMessage. The popup's existing RECORDING_ERROR
   handler surfaces the failure to the operator (same channel as
   codec-unsupported, user-cancelled, etc.). saveArchive still returns
   {success:false, error} so the popup's direct-response path also
   sees the failure (defense-in-depth via two channels).

Status: 52 GREEN / 52 tests passing. All 12 RED tests from the Option C
gate (3 in port-reconnect-race + 4 in port-health-probe + 5 in
request-id-protocol) are now GREEN. Build clean (npm run build exit 0).
Pinning contracts intact:
  - D-12 port-serialization (base64 wire format): GREEN
  - D-13 segment-rotation (3 x 10 s restart-segments ring): GREEN
  - A3 webm-playback (ffmpeg dry-run on fixture): GREEN

tsc --noEmit exit 0; type-safety grep clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:46:28 +02:00
6ffa242cb9 feat(option-c-sw): request-id'd BUFFER routing + retry on port replacement + PONG echo
Implements the SW-side architectural refactor per
.planning/debug/empty-archive-port-race.md "Fix Strategy: Option C":

1. **Request-id'd protocol** — getVideoBufferFromOffscreen generates a
   uuid (crypto.randomUUID with Math.random fallback) and sends
   {type:'REQUEST_BUFFER', requestId} on the live videoPort. The
   per-request listener pattern is GONE; replaced by a module-level
   pendingBufferRequests Map<requestId, PendingBufferRequest>. The
   onConnect-level message sink routes BUFFER -> resolve by id.

2. **Stale BUFFER routing** — BUFFER messages without a matching
   requestId in the Map are silently dropped (no cross-talk). BUFFER
   without a valid requestId at all is rejected with a warn (Option C
   protocol requires the id).

3. **Retry on port replacement** — every onConnect (post-bootstrap)
   scans pendingBufferRequests and re-issues REQUEST_BUFFER on the
   fresh port with the SAME requestId. The offscreen posts BUFFER on
   the current keepalivePort (see prior offscreen commit), the sink
   matches by id, and the request resolves. This retires the H2
   silent-drop class architecturally — the BUFFER reaches the SW
   regardless of port-replacement timing.

4. **PING -> PONG echo** — the sink replies to every PING with PONG.
   Closes the offscreen's health-probe loop (it counts missed PONGs
   and reconnects when MAX_MISSED_PONGS exceeded — see prior offscreen
   commit). The PONG post is wrapped in try/catch to absorb the same
   port-closed-mid-response race the offscreen ping path handles.

5. **Outer hard-timeout bumped 2s -> 10s** — the legacy per-port
   BUFFER_FETCH_TIMEOUT_MS = 2000 was too tight to retry across a
   reconnect. The new outer budget covers EVERY retry across port
   replacements; the inner round-trip is still ~100-200 ms.

6. **decodeBufferSegments extracted** — pulled out of the legacy
   inline handler so the new onConnect sink can decode wire segments
   without duplicating the logic. Preserves WR-07 (empty wire segment
   filter) and base64ToBlob defensive catch behaviour. Closes the
   pre-existing implicit-undefined-return path the legacy flatMap
   catch had (tsc happy but semantically ambiguous).

Status: 51 GREEN, 1 RED. The remaining RED (createArchive must throw
on empty video, surfacing to operator) is addressed in the next commit.

Pinning contracts (D-12 port-serialization, D-13 segment-rotation,
A3 webm-playback) untouched. tsc --noEmit exit 0; type-safety grep clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:43:12 +02:00
1fb3e978cb feat(option-c-offscreen): port health probe + request-id'd BUFFER + H1 try/catch
Implements the offscreen-side architectural refactor per
.planning/debug/empty-archive-port-race.md "Fix Strategy: Option C":

1. **Retired** the 290_000 ms pre-emptive reconnect setTimeout. Its race
   window between the synchronous .disconnect() and the onDisconnect
   handler running was the bisect-confirmed proximate cause of the H1
   "Attempting to use a disconnected port object" Uncaught Errors.

2. **Added** PONG-based health probe: each ping increments missedPongs;
   if MAX_MISSED_PONGS (3) consecutive PINGs go without echo, reconnect
   via the same clean teardown path the onDisconnect handler uses.
   PONG receipt resets the counter. Liveness-based replacement for the
   time-based pre-emptive rotation.

3. **H1 fix** — wrap PING postMessage in try/catch. The port object can
   transition to disconnected synchronously (SW eviction, port glitch)
   between the interval-callback being queued and it running. The catch
   absorbs the throw and routes through reconnectPort() — no more
   uncaught throws bubble out to the offscreen console.

4. **Request-id'd protocol** — REQUEST_BUFFER carries the SW-generated
   requestId; BUFFER response echoes it. The offscreen now posts on the
   CURRENT keepalivePort (no more portAtRequest stale-port refuse-to-
   post). The SW matches BUFFER → request by id, so port replacement
   mid-encode no longer drops the response — the SW retries on the new
   port and the matching BUFFER routes correctly.

5. **reconnectPort(reason)** — new helper consolidating the
   teardown+disconnect+reconnect dance used by both the missed-PONG
   path and the synchronous-throw path. Idempotent w.r.t. the chained
   onDisconnect callback.

Test updates:
  - H2 now sends REQUEST_BUFFER with a requestId (Option C contract).
  - H1.b refactored to test the externally-disconnected path (since the
    pre-emptive timeout path is gone): port._disconnected=true, fire
    ping, assert no throw + a fresh port appears.
  - Top-level snapshots of timer globals + afterEach restoration so a
    failing test doesn't leak overridden globals into the next test.

Status: 48 GREEN, 4 RED (the remaining RED is all SW-side — addressed
in next commit). All H1 + H1.b + H2 contracts now GREEN. Pinning
contracts (D-12 port-serialization, D-13 segment-rotation, A3 webm-
playback) untouched. tsc --noEmit exit 0; type-safety grep clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:37:22 +02:00
c6e8101860 feat(option-c-types): extend PortMessage with requestId + PONG
Pure type-extension step for the Option C architectural refactor of the
offscreen↔SW port lifecycle (.planning/debug/empty-archive-port-race.md).

PortMessage.requestId is optional so PING/PONG keep their no-payload
shape. REQUEST_BUFFER and BUFFER will populate it once the recorder
(offscreen) and getVideoBufferFromOffscreen (SW) are wired up in the
next commits. The narrowing remains structural — every consumer must
still validate `type` before accessing `requestId`.

PONG joins PING as a no-payload liveness signal. The SW echoes PONG on
PING, closing the health-probe loop the offscreen uses to detect a dead
port (replacing the 290 s pre-emptive setTimeout that was the proximate
cause of the H1 race window per the bisect).

No runtime change. 52 tests, 10 RED still RED (waiting for impl).
tsc --noEmit exit 0; type-safety grep clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:29:12 +02:00
4306d59dfd test(option-c): RED gate for request-id'd port protocol + health probe + error surface
Per .planning/debug/empty-archive-port-race.md "Fix Strategy: Option C
(Architectural)", land RED tests that pin the 4 sub-behaviours the
refactor must satisfy at the unit level. These complement the operator-
facing contract already pinned by port-reconnect-race.test.ts (H1+H2).

Offscreen side (tests/offscreen/port-health-probe.test.ts):
  A. Bootstrap installs no 290_000 ms pre-emptive reconnect timer
     (the timing-based race window from b064a21 is gone).
  B. Missed PONGs (5 PINGs without echo) trigger a clean reconnect via
     the same path the onDisconnect handler uses.
  C. PONG echoes within timeout keep the port alive indefinitely
     (counter-test for over-eager probe — already GREEN today).
  D. REQUEST_BUFFER with requestId → BUFFER response echoes the same id
     (the architectural mechanism that retires cross-talk).

SW side (tests/background/request-id-protocol.test.ts):
  1. getVideoBufferFromOffscreen sends REQUEST_BUFFER with a generated
     uuid requestId on the live videoPort.
  2. Stale BUFFER (mismatched requestId) is ignored — no resolution.
  3. Port replacement mid-request → SW re-issues REQUEST_BUFFER on the
     new port with the SAME requestId. Retires the H2 silent-drop class.
  4. Empty video segments → saveArchive returns {success:false, error}
     (operator-visible) instead of {success:true} with no-video archive.
  5. SW echoes PONG on PING, closing the health-probe loop.

Suite status: 10 files / 52 tests (42 GREEN, 10 RED).
  - 40 baseline + 2 new GREEN (port-health-probe C; request-id 2 & 4
    accidentally pass due to test-stub side effects — they will continue
    to pass after fix for the right reasons).
  - 3 RED in port-reconnect-race + 3 RED in port-health-probe + 4 RED
    in request-id-protocol.

Quality gates: tsc --noEmit exit 0; type-safety grep clean.
No production code touched in this commit — fix lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:27:17 +02:00
674c415945 test(debug-empty-archive): RED gate for empty-archive-port-race (H1 + H1.b + H2)
Phase 1 UAT Test 3 surfaced a two-headed BLOCKER:
(a) silent empty-video archive when save crosses a port-reconnect window,
(b) 3x "Attempting to use a disconnected port object" Uncaught Errors
starting at the 290 s pre-emptive reconnect mark.

Bisect confirmed: H1 (port lifecycle race) was introduced by Plan 01-04
(b064a21); H2 (createArchive silent-skip on empty segments) is an upstream
defect (555eb05) that became fatal once CR-01 + sweep #5 guaranteed the
silent-skip branch would fire on every save during a reconnect window.

This commit lands the 3 RED tests at the unit-test level — they match the
UAT error string byte-for-byte for H1/H1.b and pin the silent-drop
contract for H2. They will flip GREEN as the Option C architectural
refactor (request-id'd port protocol + port-health probe + retry +
operator-visible error surface) lands across the next commits.

Baseline: 8 files / 43 tests (40 GREEN, 3 RED). tsc --noEmit exit 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:17:45 +02:00
13094d4248 test(01): UAT partial — 2 pass, 1 BLOCKER (silent empty-video archive + port-reconnect race), 1 blocked 2026-05-16 13:57:24 +02:00
0cf964cb93 docs(01): goal-backward verification (10/10 PASS, 2 advisory, human_needed for operator residue) 2026-05-16 11:12:52 +02:00
75b88c6219 chore(session): consume HANDOFF.json + refresh STATE session continuity after resume
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:07:42 +02:00
92810a0f34 docs(01): add code review fix report (16/18 + 8 sweep, status: all_fixed) 2026-05-16 11:06:48 +02:00
034155bc4e fix(01-review): sweep #5 surface port-replaced-during-fetch diagnostic on buffer timeout 2026-05-16 11:00:55 +02:00
7c91f526d8 fix(01-review): sweep #2+#3+#4 recorder lifecycle hardening (re-entrance + start throw + dual-track teardown) 2026-05-16 10:59:17 +02:00
08a79a61ac fix(01-review): sweep #1 stopRecording nulls mediaStream first to prevent rotation race 2026-05-16 10:52:59 +02:00
a6e2d09de8 fix(01-review): IN-05 Message<T = unknown> + sweep any[] in RrwebEventsResponse/UserEvent.meta/popup log 2026-05-16 10:51:00 +02:00
680eee3cc7 fix(01-review): IN-04 delete decodeDryRun helper, retain only spawnSync-based decodeDryRunStrict 2026-05-16 10:34:03 +02:00
cb23143ccf fix(01-review): IN-03 delete vestigial ring-buffer.test.ts breadcrumb 2026-05-16 10:32:43 +02:00
b0631a4289 fix(01-review): IN-02 migrate Logger and ContentLogger to unknown[] args 2026-05-16 10:31:49 +02:00
6286957f53 fix(01-review): IN-01 read extensionVersion from chrome.runtime.getManifest() 2026-05-16 10:29:28 +02:00
f8a9c10758 fix(01-review): WR-08 downloadArchive use shared blobToBase64 helper 2026-05-16 10:25:34 +02:00
e9aae09f6d fix(01-review): WR-07 base64ToBlob empty-input shortcut + SW-side empty-segment filter 2026-05-16 10:24:38 +02:00
349ae88a8e fix(01-review): WR-04+WR-05 smoke.sh require python3 + identity-based zip detection 2026-05-16 10:19:46 +02:00
650c546a6e fix(01-review): WR-01+WR-02 stable capture error codes + pure assertCodecSupported 2026-05-16 09:49:01 +02:00
7bc2ef8c38 wip: phase-01 paused at review-fix 5/18; 13 findings + 8 sweep targets remain .planning/phases/01-stabilize-video-pipeline/.continue-here.md .planning/HANDOFF.json 2026-05-16 09:30:16 +02:00
2e3f5248ce fix(01-review): CR-01+CR-02+CR-03+WR-03+WR-09 critical port + handshake race fixes
What was wrong:
- CR-01 (recorder.ts): encodeAndSendBuffer captured no port identity before
  awaiting Promise.all(blobToBase64). If the port disconnected mid-encode
  and onDisconnect synchronously reconnected (re-assigning keepalivePort
  to a fresh instance), the post-await null-check evaluated false and
  the BUFFER was posted on the NEW port — but the SW's per-request
  onMessage listener was still bound to the OLD port (captured at
  getVideoBufferFromOffscreen line 110). Result: SW timed out after
  2 s, SAVE_ARCHIVE produced an empty-segments zip, data-loss path
  masquerading as a benign timeout.
- CR-02 (background/index.ts): SW's onConnect handler attached
  ONLY onDisconnect — no permanent onMessage sink. PING traffic
  had no listener when getVideoBufferFromOffscreen wasn't running
  (the normal idle state of the port), and field reports note Chrome's
  SW idle-timer reset behaves inconsistently when no listener is
  attached. Risk: PINGs silently dropped, SW evicted ~30 s into
  recording, port torn down, next SAVE_ARCHIVE fails entirely.
- CR-03 (background/index.ts): offscreenReady is a one-shot Promise
  resolved on the FIRST OFFSCREEN_READY message. If the SW is evicted
  while the offscreen document persists, the next SW lifetime creates
  a fresh Promise and waits on it forever — the offscreen never
  re-emits OFFSCREEN_READY. startVideoCapture() hangs at
  `await offscreenReady` until Chrome restarts.
- WR-03 (recorder.ts): `baseTimestamp + idx` (Date.now() + idx) used
  millisecond resolution + array offset. Two REQUEST_BUFFER calls
  within the same millisecond would collide, breaking the sort-by-
  timestamp contract in SW-side mergeVideoSegments.
- WR-09 (recorder.ts): encodeAndSendBuffer always appended the
  unfinalized in-flight segment to the BUFFER. That segment lacks
  the Matroska SegmentSize and Cues that MediaRecorder.stop()
  writes — re-introducing the "File ended prematurely" symptom
  documented in debug session webm-playback-freeze.

What changed:
- recorder.ts encodeAndSendBuffer:
  - Capture `portAtRequest = keepalivePort` BEFORE the encode.
  - After the await, refuse to post if `keepalivePort !== portAtRequest`
    (port was replaced by reconnect). SW already times out cleanly
    after BUFFER_FETCH_TIMEOUT_MS = 2 s; the next SAVE_ARCHIVE
    re-issues REQUEST_BUFFER on the fresh port. Stale data
    NEVER reaches a stranger port.
  - Include the in-flight segment ONLY when finalized.length === 0
    (preserve the SAVE-within-first-10-s UX trade-off documented at
    the original comment) — otherwise drop the unfinalized tail.
  - Replace `baseTimestamp + idx` with module-level monotonic
    `++segmentSeq` counter (zero wall-clock dependency).
  - Switch from Promise.all/map+filter to a sequential for-loop
    because each iteration now mutates the shared `segmentSeq`;
    Promise.all timing would interleave assignments. Throughput
    impact negligible (3 segments × ~50 ms base64 each ≈ 150 ms
    vs ~50 ms parallel — still well under the 2 s SW budget).

- background/index.ts onConnect:
  - Install a permanent `port.onMessage.addListener` that
    explicitly drains PING and silently drops unknown traffic.
    Per-request BUFFER listener still wins because it's attached
    LATER in the listener chain when getVideoBufferFromOffscreen
    fires; this sink only catches the idle PING stream and
    guarantees the SW idle-timer reset is consumed by a real
    handler.

- background/index.ts initialize():
  - When `chrome.offscreen.hasDocument()` returns true on SW init,
    immediately resolve `offscreenReady` AND null
    `offscreenReadyResolve`. The offscreen MUST have completed its
    bootstrap before it was observable via hasDocument(); waiting
    for an OFFSCREEN_READY that will never come is a deadlock.

Why these fixes vs alternatives:
- CR-01: alternatives considered: (a) cancel encoding when port
  disconnects (requires AbortController plumbing into blobToBase64);
  (b) re-route the BUFFER through the new port via a per-port
  request-id correlation. Both add machinery for a case the SW
  already handles correctly (2 s timeout → retry). The capture-
  identity check is the minimum-mechanism fix and matches REVIEW.md
  CR-01 fix guidance exactly.
- CR-02: alternative considered: documenting "rely on kernel-level
  port-message side effect for idle-timer reset" — REJECTED, this
  is what the existing comment did and the field evidence shows
  it's unreliable. Explicit listener is the safe default.
- CR-03: alternative considered: (a) have offscreen re-emit
  OFFSCREEN_READY on every inbound SW message — adds noise to
  the message bus and races with the original-bootstrap-emit.
  Option (b) (resolve-on-hasDocument-true) is simpler, narrower,
  and was explicitly recommended by REVIEW.md.
- WR-03: alternative considered: keeping Date.now() and adding a
  microsecond-resolution offset via performance.now() — fragile
  across SW respawns where performance.now() resets. Module-level
  monotonic counter has zero wall-clock dependency.
- WR-09: alternative considered: forcing a synchronous rotation
  at REQUEST_BUFFER time. Rejected — adds ~50–200 ms latency to
  every save AND races with scheduleRotation()'s timer. The
  "exclude unless empty" trade-off matches REVIEW.md option (a)
  exactly and preserves the documented first-10-s UX path.

Validation evidence:
- npx tsc --noEmit: exit 0 (no type errors).
- npx vitest run --reporter=dot: 30/30 tests pass in 2.67 s
  (8 test files, including port.test.ts which pins the reconnect
  invariant and port-serialization.test.ts which pins the wire format).
- grep "as any\|@ts-ignore" src/offscreen/ src/background/index.ts
  src/shared/: no matches (type-safety gate stays clean).
2026-05-16 09:21:34 +02:00
bf00bf65aa docs(01): add code review report (3 critical, 9 warning, 6 info) .planning/phases/01-stabilize-video-pipeline/01-REVIEW.md 2026-05-15 22:35:00 +02:00
1d06d9d70b docs(01-07): write Plan 07 SUMMARY and close phase 1 artifacts
Plan 07 SUMMARY — manual smoke + ffprobe D-12 gate + A3 empirical-playback
gate, three-attempt closure narrative with two pre-staged fallback
activations (base64 wire-format from CONTEXT.md Doc Cascade; D-13
restart-segments from CONTEXT.md), and the process-retro candidate flag.

Structure:

- Frontmatter: dependency graph (requires Plans 01..06; provides D-12/A3
  gates + fixture + REQ closure + D-09..D-11/CON-webm-header-retention
  retirement); patterns established (pre-staged contingency activation;
  three-attempt acceptance); requirements-completed [REQ-video-ring-buffer]
- Accomplishments: D-12 + A3 gates green; 30/30 tests; tsc clean; REQ
  marked Complete; cursor-visibility deferred to Phase 5
- Task Commits: this closure cycle (cd61cbc fixture + 7df72aa REQ/STATE/
  ROADMAP flip + this SUMMARY), plus the prior D-12 fix cycle (5 commits
  c0d9166..bf07619) and A3 fix cycle (6 commits 5530292..872f25d)
- Deviations from ORIGINAL Plan 07: two architectural escalations, both
  resolved by pre-staged fallbacks without re-planning; the closure
  ceremony itself split into 3 atomic commits (spec'd as 1) for
  reviewability
- Issues Encountered: three-attempt journey documented (attempt 1 75-byte
  text → D-12 fix; attempt 2 ffprobe-valid freezes → A3 fix; attempt 3
  clean)
- Process Observation: candidate retro for /gsd-plan-phase to auto-inject
  empirical-acceptance gates BEFORE merging a phase when RESEARCH.md flags
  HIGH-risk assumptions
- Next Phase Readiness: Phase 2 constraints (no competing keepalives;
  T-1-04 sender-check respected) + workflow next steps (code-review +
  verifier per user settings)
- Self-Check: PASSED — all closure artifacts present and verified

Phase 1 is complete; next workflow steps are /gsd-code-review +
/gsd-verify-work, then /gsd-plan-phase 2.
2026-05-15 22:02:11 +02:00
7df72aaa60 feat(01-07): close Phase 1 — REQ-video-ring-buffer complete, SPEC §10 #7 satisfied
Phase 1 closure 2026-05-15 — both acceptance gates green:

- D-12 ffprobe structural gate: `ffprobe -v error -f matroska -i
  tests/fixtures/last_30sec.webm` exit 0 (cd61cbc)
- A3 empirical-playback gate: operator confirmed clean end-to-end Chrome
  playback (no ~1 s freeze); ffmpeg `-v warning -i fixture -f null -`
  exit 0 with zero decoder errors (only expected muxer DTS-monotonicity
  warnings at segment join boundaries — documented D-13 trade-off for
  multi-EBML-header WebM concat; Chrome's MSE pipeline handles this
  natively, satisfying SPEC §10 #7)

Changes:

- .planning/REQUIREMENTS.md
  * REQ-video-ring-buffer checkbox [ ] -> [x]; description AMENDED to
    document the D-13 restart-segments lifecycle replacing D-09..D-11;
    SPEC §10 #2, #3, #7 noted as green 2026-05-15
  * Traceability table row: REQ-video-ring-buffer | Phase 1 | Complete
    (was Pending)

- .planning/ROADMAP.md
  * Phase 1 list-item flipped [ ] -> [x] with closure date + summary
  * Phase 1 Success Criteria 1, 2, 3 individually checked off; criterion 2
    re-worded to reflect D-13 segment-cycling (replacing the original
    single-continuous-recorder wording from D-09..D-11)
  * Plan list: 01-07-PLAN.md flipped [ ] -> [x] with closure note
  * Progress table row: 7/7 Complete 2026-05-15 (was 6/7 In Progress)
  * Phase 5 P1/P2 list: appended `getDisplayMedia` cursor visibility
    constraint (`video: { cursor: 'always' }`) — surfaced as user
    observation during Phase 1 smoke 2026-05-15

- .planning/STATE.md
  * Frontmatter: status -> phase_complete, completed_phases 0 -> 1,
    completed_plans 6 -> 7, percent 86 -> 100; stopped_at + last_activity
    rewritten for closure narrative
  * Current Position: Phase 1 COMPLETE, next Phase 2 of 5, Plan 7/7
    complete, progress bar [██████████] 100% Phase 1
  * Performance Metrics: Phase 1 row populated (7 plans, ~50 min); added
    Plan 07 row with closure narrative incl. the two debug sessions
  * Decisions log: appended [Phase 01-07-closure] decision and
    [Phase 01-07-deferred-to-5] note for the cursor-visibility refinement
  * Session Continuity: rewritten for closure; resume file points to the
    Plan 07 SUMMARY (commit 3)
  * Added "Phase 1 Closure Notes" block with ffprobe + ffmpeg gates,
    fixture metadata, test counts, criteria green-status, and a process
    retro candidate (auto-injection of empirical-acceptance gates when
    RESEARCH.md flags HIGH-risk assumptions)

Refs: .planning/debug/resolved/d12-blob-port-transfer-fails.md,
.planning/debug/resolved/webm-playback-freeze.md
2026-05-15 21:58:38 +02:00
cd61cbccb6 test(01-07): commit regenerated last_30sec.webm fixture against D-13 recorder
Replaces the stale fixture committed in 87909d9 (test(fix-a3): commit
debug-session test artifacts + stale fixture) — that one was captured
under the D-09..D-11 single-continuous-recorder lifecycle and froze
~1 s into playback per the resolved A3 cluster-alignment session.

This fresh fixture (1.6 MB, VP9 1142×1038) was produced by ./smoke.sh
against the D-13 restart-segments recorder (commits 5530292..872f25d):
three self-contained 10 s WebM segments concatenated by the SW into a
multi-EBML-header file the operator confirmed plays cleanly in Chrome.

Acceptance evidence:
- ffprobe -v error -f matroska -i tests/fixtures/last_30sec.webm → exit 0
- ffmpeg -v warning -i tests/fixtures/last_30sec.webm -f null - → exit 0
  with zero decoder errors; only the expected muxer DTS-monotonicity
  warnings at segment join boundaries (non-blocking — multi-EBML-header
  concat is the documented D-13 trade-off)
- Operator confirmed end-to-end Chrome playback clean (no ~1 s freeze)
- All 30/30 unit tests green incl. empirical webm-playback.test.ts gate

This closes the D-12 + A3 acceptance gates referenced in:
- .planning/debug/resolved/d12-blob-port-transfer-fails.md
- .planning/debug/resolved/webm-playback-freeze.md
2026-05-15 21:42:13 +02:00
872f25d649 docs(fix-a3): resolve webm-playback-freeze debug session, update STATE
Closes the second debug session in Phase 1's life (after d12). Both
sessions resolved fast — ~30 min for d12, ~15 min for the RED-test
landing in this one — because the planner had explicitly pre-staged
contingencies (D-12 ffprobe gate + D-13 restart-segments skeleton)
for the assumptions RESEARCH.md flagged HIGH-risk. Neither was a
planning oversight; both were the documented HIGH-risk assumption
activating as expected.

Changes:
- Moved .planning/debug/webm-playback-freeze.md →
  .planning/debug/resolved/webm-playback-freeze.md (status:
  root-cause-confirmed → resolved).
- Added the Resolution section: root-cause one-liner, applied-fix
  description, the 5 files-changed list, the 6 fix-a3 commit hashes,
  the in-tree verification matrix, and the explicit operator
  next-step (re-run ./smoke.sh, verify Chrome playback +
  ffmpeg-clean stderr + the 2 webm-playback.test.ts assertions
  flipping GREEN, then Phase 1 closes).
- Updated STATE.md frontmatter `stopped_at`, the Decisions log
  with a [Phase 01-07-debug-a3] entry summarising D-13 activation
  + the type renames + the retired old-API surface, and the
  Session Continuity block (timestamp, stopped_at narrative,
  resume-file pointer).

Phase 1 close is still pending operator regen of
tests/fixtures/last_30sec.webm. REQ-video-ring-buffer must not
be marked complete by this commit — Plan 07's §10 #7 acceptance
criterion owns that and only the in-Chrome playback + ffmpeg-clean
stderr (against a freshly regenerated fixture) can close it.
2026-05-15 21:18:36 +02:00
87909d976c test(fix-a3): commit debug-session test artifacts + stale fixture
Captures the RED contracts that the webm-playback-freeze debug
session landed (before this fix-a3 cycle started) plus the original
Plan 07 smoke fixture they run against. None of these files were
modified by this fix cycle — they are landed as-is from the debug
session to make the test history bisectable.

Files staged:
- tests/offscreen/segment-keyframes.test.ts
  Three describe blocks (~340 LOC):
    * documentation — pure-simulation tests that pin the D-09..D-11
      failure mode as executable evidence (regression guard against
      re-introducing single-continuous-recorder semantics)
    * GREEN-pinning — pure-simulation tests that pin the D-13
      segment-keyframe invariant
    * production-driven — imports src/offscreen/recorder.ts and
      asserts (i) `getSegments` exported as a function, (ii) it
      returns at most 3 Blobs. THIS BLOCK IS NOW GREEN after the
      D-13 activation in the prior commits — was the genuine TDD
      anchor for fix-a3.
- tests/offscreen/webm-playback.test.ts
  Two empirical-ffmpeg assertions on tests/fixtures/last_30sec.webm:
    * zero "Error submitting packet to decoder" lines from the
      VP9 decoder
    * no "File ended prematurely" container-finalization error
  Both STAY RED in this commit because the committed fixture is
  still the stale one from Plan 07's pre-fix smoke. They flip
  GREEN after the operator runs ./smoke.sh to regenerate the
  fixture against the D-13 recorder — see the closing message
  and the NEXT-STEP block of the resolved debug session.
- tests/fixtures/last_30sec.webm
  The 2.1 MB Plan 07 smoke artifact. Committed deliberately so
  the empirical RED test has something to run against. Will be
  overwritten by the next ./smoke.sh run (single-file rotation —
  the path is fixed by the smoke script + zip extraction step
  in the debug-session reproduction).

Verification:
- npx vitest run --reporter=dot → Tests 2 failed | 28 passed (30)
- The 2 fails are EXACTLY the two empirical-ffmpeg assertions in
  webm-playback.test.ts; the structural production-driven block
  in segment-keyframes.test.ts is fully GREEN.
- npx tsc --noEmit clean.
- npm run build succeeds.

Operator action required before Phase 1 close (Plan 07 still owns
REQ-video-ring-buffer): re-run ./smoke.sh per the documented
6-step reproduction in the debug session, then re-run
`npx vitest run tests/offscreen/webm-playback.test.ts` and
expect both assertions to flip GREEN. Plan 07 success criterion
§10 #7 (playback) lands at that point.
2026-05-15 21:16:02 +02:00
f81438d6c8 feat(fix-a3): rename TransferredVideoChunk → TransferredVideoSegment
Pure rename pass — zero behavioural change at any call site. The
prior "chunk" naming is a vestige of D-09..D-11's chunk-level
buffer; under D-13 the unit of transfer is a self-contained ~10 s
WebM segment, so the type name now matches the data shape.

Renames propagated atomically:
- src/shared/types.ts
  * TransferredVideoChunk → TransferredVideoSegment
    (also: the `isFirst?: boolean` field is dropped — D-13 segments
    are all implicitly their own header, so the flag is meaningless
    and only existed for the retired ring-buffer's pin semantics)
  * VideoChunk → VideoSegment (drops `isFirst?` for the same reason)
  * PortMessage.chunks? → PortMessage.segments?
  * VideoBufferResponse.chunks → VideoBufferResponse.segments
- src/offscreen/recorder.ts
  * import type rename
  * encodeAndSendBuffer's per-element accumulator + filter type
  * the port.postMessage payload field
- src/background/index.ts
  * import type rename
  * getVideoBufferFromOffscreen reads `(msg as {segments?}).segments`
    (matching the new wire field name)
  * empty-buffer returns `{ segments: [] }`
  * mergeVideoSegments signature takes VideoSegment[]
  * createArchive consumes videoBufferResponse.segments
  * saveArchive log line says "segments"

Verification:
- npx tsc --noEmit clean.
- npx vitest run → 28 passed / 2 failed (the 2 fixture-empirical
  ffmpeg dry-runs; unchanged — they're gated on ./smoke.sh regen).
- npm run build succeeds, all 60 modules transformed.
- grep predicates clean in src/:
  * no addChunk / trimAged / firstChunkSaved / isFirst
  * no TransferredVideoChunk / VideoChunk (old names)
  * 21 occurrences of new names propagated correctly
- Pre-existing port-serialization.test.ts still GREEN: its `isFirst`
  references are inside inline-test-scoped objects (not imports
  from production types), and its tests assert JSON-roundtrip
  behaviour rather than the production type shape.
2026-05-15 21:15:19 +02:00
670daa3fe8 feat(fix-a3): adapt SW receive path to segment semantics
Follow-up to the D-13 activation in src/offscreen/recorder.ts. Each
entry on the BUFFER port message is now a self-contained WebM
segment (not a partial chunk), so the SW-side concat is trivial:
sort by timestamp and Blob-concatenate. The resulting multi-EBML-
header file plays natively in Chrome (SPEC §10 #7 scope).

Changes in src/background/index.ts:
- mergeVideoChunks renamed to mergeVideoSegments; doc comment and
  log lines say "segment" throughout. No behavioural change beyond
  removing the now-stale per-chunk `isFirst` logging.
- getVideoBufferFromOffscreen no longer reads or carries the
  `isFirst` field when decoding wire payload into VideoChunk —
  D-13's segment lifecycle makes the flag meaningless (every
  segment is a fresh recorder boundary, every segment's first
  chunk is implicitly the header). The field stays optional on
  VideoChunk for one more commit; commit 4 sweeps the type
  rename and drops it.
- The single mergeVideoChunks call site in createArchive updated
  to the new name.

Verification:
- npx vitest run → 28 passed / 2 failed (same 2 empirical-ffmpeg
  REDs from webm-playback.test.ts; unchanged from prior commit —
  the fixture is still the stale single-continuous-recorder one).
- npx tsc --noEmit clean.
- src/background/index.ts now has zero references to addChunk /
  trimAged / firstChunkSaved / isFirst.
2026-05-15 21:12:46 +02:00
6a1a034030 feat(fix-a3): activate D-13 restart-segments in src/offscreen/recorder.ts
Replaces the single-continuous-MediaRecorder + age-trim lifecycle
(D-09..D-11, retired) with the restart-segments lifecycle pre-staged
in CONTEXT.md D-13 and RESEARCH.md Pattern 3. Debug session
webm-playback-freeze proved empirically that the prior approach
orphans VP9 P-frame keyframe references when middle chunks are
trimmed — ffmpeg dry-run on last_30sec.webm produced 8 "Error
submitting packet to decoder" lines and the playback froze ~1 s in
Chrome (root-cause-confirmed in the debug session).

Architecture change:
- MediaRecorder is rotated every SEGMENT_DURATION_MS = 10_000 ms on
  the SAME mediaStream (so no new getDisplayMedia user gesture).
- Each segment is started fresh, so the encoder seeds an EBML header
  + initial VP9 keyframe — segment is independently decodable.
- Up to MAX_SEGMENTS = 3 finalized segments are retained (3 × 10 s =
  30 s, matching the legacy operator-facing window).
- MediaRecorder.start() runs without a timeslice — exactly one
  ondataavailable per .stop() yields exactly one Blob per segment.
- onstop → onSegmentStopped finalizes currentChunks, evicts oldest
  if over the cap, and immediately starts the next segment.
- ~50–200 ms recording gap at each rotation boundary is the
  documented trade-off per CONTEXT.md D-13.

API surface delta:
- REMOVED: addChunk, trimAged, getBuffer, firstChunkSaved
  (entire D-09..D-11 ring-buffer module retired)
- ADDED:   getSegments, pushSegmentForTest (test seam),
           MAX_SEGMENTS, SEGMENT_DURATION_MS exports
- KEPT:    assertCodecSupported, resetBuffer (semantics updated to
           clear segments + currentChunks + rotation timer),
           VIDEO_BUFFER_DURATION_MS (now derived as
           MAX_SEGMENTS * SEGMENT_DURATION_MS = 30 s)
- bootstrap(), port lifecycle, OFFSCREEN_READY handshake, T-1-04
  sender-id check — all unchanged.

encodeAndSendBuffer iterates segments + the in-flight currentChunks
(if non-empty) so SAVE_ARCHIVE seconds after START_RECORDING still
returns the partial first segment instead of an empty buffer. Each
segment is base64-encoded per the D-12 wire-format contract.

Verification:
- npx vitest run → 28 passed / 2 failed; the 2 failures are exactly
  the empirical ffmpeg dry-runs in tests/offscreen/webm-playback.test.ts
  which stay RED until the operator regenerates the committed fixture
  via ./smoke.sh (expected and documented).
- tests/offscreen/segment-keyframes.test.ts production-driven RED
  block is now GREEN — getSegments exists and meets the cap contract.
- tests/offscreen/segment-rotation.test.ts (8 tests, added in the
  prior commit) all GREEN.
- npx tsc --noEmit clean.
- Zero new `as any` / `@ts-ignore` introduced.
2026-05-15 21:11:07 +02:00
5530292270 feat(fix-a3): retire ring-buffer first-chunk pin tests, add segment-rotation contract
Per debug session webm-playback-freeze "Activation Plan" step 4: the
D-09..D-11 ring-buffer semantics (first-chunk header pin + 30 s age trim)
are being replaced by D-13 restart-segments. The pinned-header assertions
were architecture-specific and become meaningless once each segment is
a self-contained WebM with its own EBML header and seed keyframe.

Changes:
- tests/offscreen/ring-buffer.test.ts: collapsed to a single breadcrumb
  test pointing at the successor file. Kept the path so git history /
  failure bisects land on the retirement commit cleanly.
- tests/offscreen/segment-rotation.test.ts (new): 8 tests pinning the
  D-13 invariants against the production recorder module:
    * MAX_SEGMENTS = 3, SEGMENT_DURATION_MS = 10_000 (= legacy 30 s window)
    * empty-by-default, ordered, oldest-evicted-at-cap
    * resetBuffer clears
    * getSegments returns a defensive snapshot (no internal aliasing)
  Uses a `pushSegmentForTest` seam so vitest can drive rotation
  deterministically without instantiating a real MediaRecorder.

RED today by design (TDD discipline) — the segment-rotation suite
imports `getSegments`, `pushSegmentForTest`, `MAX_SEGMENTS`,
`SEGMENT_DURATION_MS` from src/offscreen/recorder.ts. Those exports
land in the next commit. tsconfig.include is "src" only so tsc stays
clean during the RED window.
2026-05-15 20:59:01 +02:00
bf076199b4 docs(fix-d12): resolve debug session and update STATE
- Mark .planning/debug/d12-blob-port-transfer-fails.md as
  status: resolved; fill in the Resolution section with the
  applied fix (5 commit hashes, files changed), verification
  output (15/15 tests, tsc clean, vite build green, zero
  as-any/ts-ignore in fix-touched files), and inline answers
  to the specialist-review questions raised by the planner.
  Move the file to .planning/debug/resolved/.
- Update STATE.md frontmatter (stopped_at) + Decisions log
  + Session Continuity to record the D-12 fix landing and
  the open Plan 07 ffprobe gate (still requires operator
  smoke.sh + ffprobe re-run before Phase 1 can close).
- Land smoke.sh — the operator's D-12 acceptance-gate harness
  that surfaced the original failure. Self-contained: dedicated
  /tmp/mokosh-smoke-profile, auto-accept desktop-capture picker,
  Downloads polling, ffprobe gate, fixture staging.

REQ-video-ring-buffer remains NOT-complete — Plan 07 owns it,
operator must re-run ./smoke.sh to verify the fix end-to-end
in Chrome.

Refs: debug session d12-blob-port-transfer-fails (resolved).
2026-05-15 20:23:29 +02:00
d5bb948d95 feat(fix-d12): decode chunks from base64 in SW BUFFER receive
- Read incoming port.chunks as TransferredVideoChunk[] (was
  VideoChunk[] — but that was a lie because Blob doesn't survive
  JSON serialization across the port boundary).
- Decode each wire chunk via base64ToBlob(wire.data, wire.type) and
  resolve VideoBufferResponse with the resulting VideoChunk[]. The
  existing mergeVideoChunks downstream sees real Blobs and produces
  a real WebM-prefixed merged blob.
- Defensive per-chunk decode: log + skip individual decode failures
  rather than blowing up the whole fetch. Falls back to
  video/webm;codecs=vp9 if the wire chunk somehow omits the type
  (defense-in-depth — the offscreen always populates it).
- Document the 2 s BUFFER_FETCH_TIMEOUT_MS budget: covers worst-case
  encode + post-message + JSON parse with > 1.5 s of headroom for
  the current 15-chunk × 100 KB sizing.

Refs: debug session d12-blob-port-transfer-fails, D-17 port lifecycle.
2026-05-15 20:18:31 +02:00
283184978f feat(fix-d12): encode chunks to base64 in offscreen REQUEST_BUFFER handler
- Convert each VideoChunk's Blob to a TransferredVideoChunk via
  blobToBase64 before keepalivePort.postMessage. JSON.stringify(blob)
  === "{}" across extension contexts, so the previous direct send of
  VideoChunk[] was silently corrupting binary content on the wire.
- Move the work into encodeAndSendBuffer() to keep onPortMessage
  synchronous-typed (chrome.runtime.Port.onMessage ignores return
  values; the listener stays void-returning, the async work is
  fire-and-forget).
- Defensive per-chunk encode: log + skip individual encoding failures
  rather than crashing the whole BUFFER response. Operators get partial
  video > no video.
- Re-check keepalivePort !== null AFTER the await: the port may have
  disconnected during encoding (~100 ms for 15 chunks of ~100 KB each
  per the d12 sizing estimate).

Refs: debug session d12-blob-port-transfer-fails, D-17 port lifecycle.
2026-05-15 20:16:41 +02:00
d653283bc4 feat(fix-d12): add TransferredVideoChunk wire-format type in src/shared/types.ts
- Add TransferredVideoChunk { data: string (base64); type: string
  (MIME); timestamp: number; isFirst?: boolean } as the
  JSON-serializable shape for chunks traversing the offscreen↔SW
  chrome.runtime.Port boundary.
- Retarget PortMessage.chunks?: TransferredVideoChunk[] (was
  VideoChunk[]). The in-memory VideoChunk type is unchanged — both
  the offscreen ring buffer and the SW-side merge step keep using
  it after decoding the wire payload.
- No behavior change yet; this commit only lands the type. The
  encode/decode call sites land in the next two commits.

Refs: debug session d12-blob-port-transfer-fails.
2026-05-15 20:07:40 +02:00
c0d9166a1d feat(fix-d12): add binary encode/decode helpers in src/shared/binary.ts
- Add blobToBase64 / base64ToBlob in src/shared/binary.ts:
  portable Blob↔base64 round-trip for the chrome.runtime.Port
  wire-format. JSON.stringify(blob) returns "{}" across extension
  contexts, so binary payloads must travel as base64 strings.
- Mirror the GREEN-block helper signatures from
  tests/offscreen/port-serialization.test.ts so the same test pins
  both the standalone helpers and the production wire format.
- Land tests/offscreen/port-serialization.test.ts as the RED+GREEN
  executable contract for the D-12 fix: the RED block reproduces
  the 75-byte "[object Object]" failure mode byte-for-byte; the
  GREEN block pins the base64 wire-format the fix must implement.
- Uses arrayBuffer() + btoa(String.fromCharCode...) rather than
  FileReader: FileReader is browser-only; the chosen approach
  works in both Chrome extension contexts and the Node-based
  vitest environment.

Refs: debug session d12-blob-port-transfer-fails.
2026-05-15 20:06:51 +02:00
1ebfb42b30 docs(01-06): complete vite.config.ts collapse plan
- 01-06-SUMMARY.md: detailed write-up — 226 → 21 lines, Outcome A
  reconciliation (dist/src/offscreen/index.html), full dist layout
  for Plan 07's smoke test, T-1-NEW-06-01 / T-1-NEW-06-02 grep gates
- STATE.md: completed_plans 5 → 6, percent 71 → 86, current plan
  advanced 6 → 7, two new decisions logged, session stopped_at updated
- ROADMAP.md: Phase 1 plan progress row 4/7 → 6/7; 01-06-PLAN.md
  checked off

REQ-video-ring-buffer remains unchecked — Plan 07 owns the ffprobe gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:17:43 +02:00
6aeeda495c fix(01-06): align ensureOffscreen URL with crxjs emit path
After collapsing vite.config.ts to use rollupOptions.input.offscreen =
'src/offscreen/index.html', crxjs preserves the 'src/' prefix in the
bundled output (Outcome A per RESEARCH.md Pitfall 5 dichotomy):
  dist/src/offscreen/index.html  (NOT dist/offscreen/index.html)

The pre-amendment leftover string 'offscreen/index.html' at
src/background/index.ts:45 would have produced
ERR_FILE_NOT_FOUND in chrome.offscreen.createDocument and broken
Plan 07's manual smoke load. Updated to match the actual emit path.

- npm run build exits 0; 7 dist/assets/*.js bundles produced
- dist/manifest.json permissions: desktopCapture present, tabCapture absent
- tsc --noEmit clean; 9/9 vitest tests still green
- ensureOffscreen URL string now matches dist/src/offscreen/index.html

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:11:05 +02:00
23e69d0b77 refactor(01-06): delete inline copy-offscreen plugin and orphan offscreen/ directory
- Delete vite.config.ts inline copy-offscreen plugin (lines 13-216):
  the 174-line plugin that emitFile'd both offscreen HTML and a stringified
  JS module wired to tabCapture-era chromeMediaSource + IndexedDB pipeline
  (audit P0 #1 root cause; D-08 deletion target)
- Delete vite.config.ts misplaced publicDir/copyPublicDir (no public/ dir
  exists; audit P2 #17) and the manualChunks=undefined shape
- Rewrite vite.config.ts to RESEARCH.md Example B form: crx() + a single
  rollupOptions.input.offscreen pointing at src/offscreen/index.html
  (the crxjs-managed entry Plan 03 created); 21 lines total
- Delete orphan offscreen/index.ts (audit P2 #18 dead-code, D-08)
- Delete orphan offscreen/index.html (replaced by src/offscreen/index.html
  per D-07; runtime URL semantics preserved through crxjs entry binding)
- T-1-NEW-06-01 grep gate green (this.emitFile = 0)
- T-1-NEW-06-02 grep gate green (offscreen/ directory absent)
- tsc --noEmit clean; 9/9 vitest tests still green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:10:00 +02:00