Files
mokosh/.planning/phases/01-stabilize-video-pipeline/01-05-PLAN.md

24 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, requirements_addressed, must_haves
phase plan type wave depends_on files_modified autonomous requirements requirements_addressed must_haves
01-stabilize-video-pipeline 05 execute 4
03
04
src/background/index.ts
true
REQ-video-ring-buffer
REQ-video-ring-buffer
truths artifacts key_links
`src/background/index.ts` no longer contains `addVideoChunkFromBlob`, `cleanupVideoBuffer`, `setupKeepalive`, `loadChunkFromIndexedDB`, `openIndexedDB`, or any `chrome.alarms` reference — buffer ownership moves to offscreen per D-16/D-19, and the alarms-driven keepalive is DELETED per D-18 (it never actually reset the SW idle timer; the long-lived port does)
`src/background/index.ts` no longer calls `chrome.tabCapture.getMediaStreamId` (D-01 amendment); video acquisition is now `getDisplayMedia` invoked from the offscreen module
`src/background/index.ts` no longer handles `VIDEO_CHUNK` or `VIDEO_CHUNK_SAVED` (deleted Message types in Plan 03)
`src/background/index.ts` no longer contains any `chrome.tabs.onActivated` handler tied to the recording lifecycle (D-14: tab-switch re-attach is non-applicable under `getDisplayMedia`; D-15: operator tab-switching no longer interrupts recording, the buffer keeps filling regardless of active tab)
SW has an `onConnect` listener that filters `port.name === 'video-keepalive'` and validates `port.sender?.id === chrome.runtime.id` (T-1-04 mitigation; this is the SW-side counterparty of the long-lived port keepalive per D-17)
SW has an `onMessage` `OFFSCREEN_READY` case that resolves a pending readiness Promise (Pattern 4 SW side)
SW's `SAVE_ARCHIVE` and `GET_VIDEO_BUFFER` handlers fetch the buffer via the port (`REQUEST_BUFFER` → wait for `BUFFER`) instead of holding their own `videoBuffer` array
SW's `ensureOffscreen` uses `chrome.offscreen.Reason.DISPLAY_MEDIA` (not `USER_MEDIA`) per D-02
SW's `onInstalled` listener calls `indexedDB.deleteDatabase('VideoRecorderDB')` once as a cleanup pass (RESEARCH.md Runtime State Inventory)
`npx tsc --noEmit` exits 0
path provides contains
src/background/index.ts Shrunk SW coordinator: lifecycle + port host + export buffer-fetch only; no buffer state, no alarms, no IndexedDB video-keepalive
from to via pattern
src/background/index.ts (onConnect) src/offscreen/recorder.ts (connectPort) shared port name 'video-keepalive' video-keepalive
from to via pattern
src/background/index.ts (SAVE_ARCHIVE handler) src/background/index.ts (getVideoBufferFromOffscreen) port REQUEST_BUFFER round-trip REQUEST_BUFFER
from to via pattern
src/background/index.ts (ensureOffscreen) src/offscreen/index.html chrome.offscreen.createDocument url src/offscreen/index.html
Shrink `src/background/index.ts` to its new responsibilities: offscreen lifecycle, port host, and export-time buffer fetch. DELETE the SW-side ring-buffer state and helpers (now owned by offscreen per D-16), DELETE the chrome.alarms keepalive (D-18), DELETE the IndexedDB code path (D-19), DELETE the `chrome.tabCapture.getMediaStreamId` call (D-01 amendment), DELETE the `VIDEO_CHUNK` / `VIDEO_CHUNK_SAVED` message handlers (their message types were removed in Plan 03), DELETE any `chrome.tabs.onActivated` re-attach plumbing (D-14: not applicable under the new capture API; D-15: operator tab switches no longer interrupt the recording), and WIRE the SW-side `onConnect` handler against the `'video-keepalive'` port that Plan 04 opens from the offscreen.

Purpose: REQ-video-ring-buffer's data flow on export is popup → SAVE_ARCHIVE → SW → REQUEST_BUFFER (via port) → offscreen → BUFFER (via port) → SW → assemble ZIP → download. The SW NEVER holds a chunk locally — which is the only way the buffer survives SW idle unloads (D-16).

Output: A heavily-shrunk src/background/index.ts that compiles cleanly, holds zero video-buffer state, talks to the offscreen exclusively via runtime messaging + the long-lived port, and tolerates the IndexedDB remnant by deleting it on first install.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md @.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md @.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md @src/background/index.ts @src/shared/types.ts @src/offscreen/recorder.ts Port contract (already implemented in offscreen by Plan 04): - Port name: `'video-keepalive'` - Offscreen → SW outbound: `{ type: 'PING' }` every 25 s (informational; SW just receives it as keepalive traffic) - Offscreen → SW outbound on REQUEST_BUFFER: `{ type: 'BUFFER', chunks: VideoChunk[] }` - SW → offscreen inbound: `{ type: 'REQUEST_BUFFER' }`

SW side must:

  1. Register chrome.runtime.onConnect.addListener that filters by port name AND validates sender ID
  2. Store the connected port in a module-level let videoPort: chrome.runtime.Port | null
  3. Clear the reference on disconnect (offscreen will reconnect; SW gets a new onConnect call)
  4. Expose getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> that:
    • Returns {chunks: []} early if no port is connected
    • Otherwise sends {type: 'REQUEST_BUFFER'} over the port
    • Resolves when a {type: 'BUFFER', chunks: ...} reply arrives
    • Times out at 2 s with {chunks: []}

Existing SW behaviors to PRESERVE:

  • mergeVideoChunks(chunks: VideoChunk[]): Blob (unchanged)
  • createArchive(...) (unchanged signature; calls mergeVideoChunks and zips with JSZip)
  • downloadArchive(blob) (unchanged)
  • captureScreenshot() (unchanged — Phase 3 owns popup-side rework)
  • chrome.runtime.onInstalled listener (existing, gets an indexedDB cleanup line added)
  • onMessage cases: REQUEST_PERMISSIONS, GET_VIDEO_BUFFER, SAVE_ARCHIVE — KEPT but their bodies change to talk to offscreen via port

Removed message types (from Plan 03's edit to src/shared/types.ts):

  • 'VIDEO_CHUNK' — handler block deleted
  • 'VIDEO_CHUNK_SAVED' — handler block deleted

chrome.offscreen.Reason.DISPLAY_MEDIA: try reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA] first. If the current @types/chrome (0.0.268) does NOT expose DISPLAY_MEDIA and tsc --noEmit fails, fall back to a narrowing cast (no as any): reasons: ['DISPLAY_MEDIA' as chrome.offscreen.Reason]. The executor verifies which form compiles by running npx tsc --noEmit after the edit.

<threat_model>

Trust Boundaries

Boundary Description
extension contexts → SW onConnect Any extension context can attempt to open a port to the SW; only the offscreen has a legitimate reason for the 'video-keepalive' port
popup / content script → SW onMessage Existing trust boundary; this plan adds T-1-04 sender-id check on the SW-side onMessage too

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-1-04 Spoofing — port-hijack on SW side chrome.runtime.onConnect handler mitigate The SW onConnect listener filters port.name === 'video-keepalive' AND port.sender?.id === chrome.runtime.id. A non-extension caller cannot open a runtime port at all (Chrome enforces); the sender-id check is defense-in-depth for the within-extension case. Grep gate: grep -v '^#' src/background/index.ts | grep -c "port.sender?.id !== chrome.runtime.id" returns at least 1.
T-1-NEW-05-01 Information Disclosure — buffer pulled by unauthorized message onMessage handlers (SAVE_ARCHIVE, GET_VIDEO_BUFFER) mitigate The SW-side onMessage listener already exists; this plan adds a sender.id === chrome.runtime.id guard at the top of the listener (per RESEARCH.md Security Domain table).
T-1-NEW-05-02 Tampering — stale IndexedDB orphan indexedDB.deleteDatabase('VideoRecorderDB') in onInstalled mitigate After this phase deletes the inline plugin's IndexedDB code, browser profiles that previously ran the old build still have a VideoRecorderDB database. The SW onInstalled listener calls indexedDB.deleteDatabase('VideoRecorderDB') idempotently to clear it. The call is harmless if the DB never existed. Grep gate: grep -c "indexedDB.deleteDatabase('VideoRecorderDB')" src/background/index.ts returns 1.
</threat_model>
Task 1: DELETE — drop legacy buffer + alarms + IndexedDB + tabCapture paths from SW src/background/index.ts - src/background/index.ts (the full current file: 536 lines) - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`src/background/index.ts` (lines 276-396) — the verified delete-target table - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Files to DELETE in this phase" The current `src/background/index.ts` has been verified at 536 lines. Apply the following deletions using the Edit tool. Re-verify each line range with `grep -n` BEFORE editing (line numbers below are from the on-disk state captured 2026-05-15; do NOT trust them blindly — re-run grep first):

(1) Delete addVideoChunkFromBlob function — currently lines 26-45 inclusive (the entire // Кольцевой буфер видео block through the closing }). Also remove the preceding // Кольцевой буфер видео comment.

(2) Delete cleanupVideoBuffer function — currently lines 47-75 inclusive.

(3) Delete firstChunkSaved from module state — line 17 (let firstChunkSaved = false; // Флаг что первый чанк уже сохранен).

(4) Delete videoBuffer from module state — line 16 (let videoBuffer: VideoChunk[] = [];).

(5) Replace the chrome.tabCapture call inside startVideoCapture — currently lines 126-144. The replacement is VERBATIM:

    // Создаём offscreen документ (с reason DISPLAY_MEDIA per D-02)
    await ensureOffscreen();

    // Просим offscreen запустить запись — getDisplayMedia вызывается там
    // (D-01: больше нет chrome.tabCapture.getMediaStreamId).
    logger.log('Sending START_RECORDING to offscreen...');
    try {
      await chrome.runtime.sendMessage({
        type: 'START_RECORDING'
      });
      logger.log('START_RECORDING sent successfully');
    } catch (msgError) {
      logger.error('Failed to send START_RECORDING:', msgError);
      throw msgError;
    }

The existing const [tab] = ... line at the top of startVideoCapture and the if (!tab.id || !tab.url) throw ... check can be retained — they don't harm anything. Phase 1 keeps them.

(6) Replace ensureOffscreen reason — currently line 90 reads reasons: ['USER_MEDIA'] as any,. Replace VERBATIM:

      reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA],

If chrome.offscreen.Reason.DISPLAY_MEDIA is NOT in the current @types/chrome (0.0.268) and tsc --noEmit fails, fall back to (no as any):

      reasons: ['DISPLAY_MEDIA' as chrome.offscreen.Reason],

(7) Update the justification text on line 91 to match RESEARCH.md Example C:

      justification: 'Continuous screen recording for operator session diagnostics'

(8) Delete setupKeepalive function — currently lines 156-165.

(9) Delete the setupKeepalive() call inside initialize — currently line 525.

(10) Delete the VIDEO_CHUNK case from the onMessage switch — currently lines 457-466.

(11) Delete the VIDEO_CHUNK_SAVED case — currently lines 468-473.

(12) Delete loadChunkFromIndexedDB function — currently lines 482-505.

(13) Delete openIndexedDB function — currently lines 507-520.

(14) Verify no chrome.tabs.onActivated listener exists in the file (D-14 / D-15: tab-switch handling is non-applicable under the new capture API). Run grep -n "chrome.tabs.onActivated" src/background/index.ts. If the grep returns any hits, DELETE those lines (the entire listener callback block). If the grep returns nothing, log "D-14/D-15 satisfied: no tab-switch handler found in SW" in the task summary.

After ALL these deletions, run npx tsc --noEmit. It MUST exit 0. If VideoChunk is reported as unused after the deletes, that indicates a function that needs it was inadvertently lost; STOP and audit. npx tsc --noEmit && [ $(grep -cE "addVideoChunkFromBlob|cleanupVideoBuffer|setupKeepalive|loadChunkFromIndexedDB|openIndexedDB|getMediaStreamId|chrome.alarms|chrome.tabs.onActivated" src/background/index.ts) -eq 0 ] <acceptance_criteria> - npx tsc --noEmit exits 0 - grep -v '^#' src/background/index.ts | grep -c "addVideoChunkFromBlob" returns 0 - grep -v '^#' src/background/index.ts | grep -c "cleanupVideoBuffer" returns 0 - grep -v '^#' src/background/index.ts | grep -c "setupKeepalive" returns 0 - grep -v '^#' src/background/index.ts | grep -c "loadChunkFromIndexedDB" returns 0 - grep -v '^#' src/background/index.ts | grep -c "openIndexedDB" returns 0 - grep -v '^#' src/background/index.ts | grep -c "getMediaStreamId" returns 0 - grep -v '^#' src/background/index.ts | grep -c "VIDEO_CHUNK_SAVED" returns 0 - grep -v '^#' src/background/index.ts | grep -c "chrome.alarms" returns 0 - grep -v '^#' src/background/index.ts | grep -c "chrome.tabs.onActivated" returns 0 (D-14/D-15 mitigation) - grep -c "DISPLAY_MEDIA" src/background/index.ts returns 1 - grep -c "as any" src/background/index.ts returns 0 (CLAUDE.md rule) - File line count reduced from 536 to roughly 380-400 lines (allow ±40) </acceptance_criteria> SW shed every legacy path. tsc clean. File ~30% smaller. Ready for the port-host wiring in Task 2.

Task 2: ADD — wire SW-side port host, sender check, onInstalled IndexedDB cleanup, and port-based buffer fetch src/background/index.ts - src/background/index.ts (post-Task-1 state) - src/offscreen/recorder.ts (read-only reference for the offscreen-side port contract) - .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Pattern 5: SW-side port handling" (lines 590-628) - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`src/background/index.ts` ADD blocks (lines 372-396) Seven targeted additions using the Edit tool (NOT a full rewrite).

(1) Module-level state additions — after the existing let cachedScreenshot: Blob | null = null; line, ADD VERBATIM:

// Port from offscreen (D-17). Re-assigned on every (re)connect.
let videoPort: chrome.runtime.Port | null = null;
// Offscreen readiness Promise — set up at module load, resolved on first
// OFFSCREEN_READY message (Pattern 4). startVideoCapture awaits this before
// sending START_RECORDING, so we never lose the popup's transient activation
// to a race with the offscreen bootstrap.
let offscreenReadyResolve: (() => void) | null = null;
const offscreenReady: Promise<void> = new Promise((res) => {
  offscreenReadyResolve = res;
});

(2) onConnect handler — add VERBATIM AFTER the ensureOffscreen function block. Place it BEFORE startVideoCapture so the listener is registered before any code might trigger a port open:

// SW-side port host (D-17, RESEARCH.md Pattern 5). The offscreen opens this
// port on bootstrap and reconnects on disconnect. We use it for: (a)
// keepalive traffic (PING) — Chrome 110+ resets the SW idle timer on every
// port message; (b) on-demand REQUEST_BUFFER round-trip during SAVE_ARCHIVE.
chrome.runtime.onConnect.addListener((port) => {
  // T-1-04 mitigation: only accept ports from this extension
  if (port.name !== 'video-keepalive') {
    return;
  }
  if (port.sender?.id !== chrome.runtime.id) {
    logger.warn('Rejecting port with mismatched sender:', port.sender?.id);
    port.disconnect();
    return;
  }
  logger.log('Offscreen port connected');
  videoPort = port;
  port.onDisconnect.addListener(() => {
    logger.log('Offscreen port disconnected; offscreen will reconnect');
    videoPort = null;
  });
  // Inbound traffic is mostly PING (ignored) and BUFFER (handled by the
  // per-request listener installed in getVideoBufferFromOffscreen).
});

(3) Buffer-fetch function — add VERBATIM AFTER the onConnect block above:

const BUFFER_FETCH_TIMEOUT_MS = 2_000;

async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
  if (videoPort === null) {
    logger.warn('No offscreen port available; returning empty buffer');
    return { chunks: [] };
  }
  const port = videoPort;
  return new Promise<VideoBufferResponse>((resolve) => {
    const timer = setTimeout(() => {
      port.onMessage.removeListener(handler);
      logger.warn(`Buffer fetch timed out after ${BUFFER_FETCH_TIMEOUT_MS} ms`);
      resolve({ chunks: [] });
    }, BUFFER_FETCH_TIMEOUT_MS);
    const handler = (msg: unknown) => {
      if (
        typeof msg === 'object' &&
        msg !== null &&
        (msg as { type?: unknown }).type === 'BUFFER'
      ) {
        clearTimeout(timer);
        port.onMessage.removeListener(handler);
        const chunks = (msg as { chunks?: VideoChunk[] }).chunks ?? [];
        resolve({ chunks });
      }
    };
    port.onMessage.addListener(handler);
    port.postMessage({ type: 'REQUEST_BUFFER' });
  });
}

(4) Delete getVideoBuffer (the synchronous version that returned the local array) — find and delete the function currently around lines ~168-172 post-deletes (function getVideoBuffer(): VideoBufferResponse { return { chunks: videoBuffer }; }). It no longer compiles after Task 1 deletes videoBuffer.

(5) Replace the GET_VIDEO_BUFFER case — in the onMessage switch, find case 'GET_VIDEO_BUFFER': and replace its body VERBATIM:

    case 'GET_VIDEO_BUFFER':
      getVideoBufferFromOffscreen().then((resp) => sendResponse(resp));
      return true;

(6) Update saveArchive to use the port-based fetch — find the const videoBuffer = getVideoBuffer(); line (currently around line 332 in the post-Task-1 file). Replace VERBATIM:

    const videoBufferResp = await getVideoBufferFromOffscreen();
    logger.log(`Video buffer: ${videoBufferResp.chunks.length} chunks`);

And update the subsequent createArchive(videoBuffer, ...) call to use videoBufferResp instead. Specifically:

    const archiveBlob = await createArchive(
      videoBufferResp,
      rrwebEvents,
      userEvents,
      screenshot
    );

(7) Add OFFSCREEN_READY case + await in startVideoCapture — two sub-edits:

(7a) Add a new case to the onMessage switch, placed AFTER case 'SAVE_ARCHIVE': and BEFORE the default: block. VERBATIM:

    case 'OFFSCREEN_READY':
      logger.log('OFFSCREEN_READY received');
      offscreenReadyResolve?.();
      offscreenReadyResolve = null;
      return false;

(7b) In startVideoCapture, ADD await offscreenReady; AFTER await ensureOffscreen();. The relevant excerpt becomes:

    await ensureOffscreen();
    await offscreenReady;
    logger.log('Sending START_RECORDING to offscreen...');
    // ... rest unchanged

(8) Sender check on onMessage — at the very top of the existing chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { callback, rename _sender to sender and add a guard. Replace the listener header VERBATIM:

chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
  if (sender.id !== chrome.runtime.id) {
    logger.warn('Rejecting message with mismatched sender:', sender.id);
    return false;
  }
  logger.log('Received message:', message.type, message);

(9) onInstalled IndexedDB cleanup — find the existing chrome.runtime.onInstalled.addListener block and REPLACE entirely VERBATIM:

chrome.runtime.onInstalled.addListener((details) => {
  logger.log('Extension installed/updated:', details.reason);
  // RESEARCH.md Runtime State Inventory — clean up orphaned IndexedDB from
  // pre-Phase-01 builds. Idempotent: no-op if DB never existed.
  try {
    indexedDB.deleteDatabase('VideoRecorderDB');
    logger.log('Cleaned up orphaned VideoRecorderDB (if present)');
  } catch (e) {
    logger.warn('indexedDB.deleteDatabase failed:', e);
  }
  initialize();
});

After ALL these edits, run:

npx tsc --noEmit
npx vitest run

Both MUST exit 0. npx tsc --noEmit && npx vitest run && grep -c "video-keepalive" src/background/index.ts && grep -c "VideoRecorderDB" src/background/index.ts <acceptance_criteria> - npx tsc --noEmit exits 0 - npx vitest run exits 0 (all 8 offscreen tests still pass — Plan 05 only touches SW, but the offscreen tests should not regress) - grep -c "chrome.runtime.onConnect.addListener" src/background/index.ts returns 1 - grep -c "'video-keepalive'" src/background/index.ts returns at least 1 - grep -c "port.sender?.id !== chrome.runtime.id" src/background/index.ts returns 1 (T-1-04 mitigation) - grep -c "sender.id !== chrome.runtime.id" src/background/index.ts returns 1 (onMessage sender check) - grep -c "indexedDB.deleteDatabase('VideoRecorderDB')" src/background/index.ts returns 1 - grep -c "function getVideoBufferFromOffscreen" src/background/index.ts returns 1 - grep -c "REQUEST_BUFFER" src/background/index.ts returns at least 1 - grep -c "offscreenReady" src/background/index.ts returns at least 2 (the Promise variable + the await) - grep -c "OFFSCREEN_READY" src/background/index.ts returns at least 1 (the case label) - grep -c "as any" src/background/index.ts returns 0 - grep -c "@ts-ignore" src/background/index.ts returns 0 </acceptance_criteria> SW side fully wired against the offscreen's port. Sender checks in place. IndexedDB cleanup landed on onInstalled. The SW is now a pure coordinator — it holds no buffer state of its own.

After both tasks land:
  1. npx tsc --noEmit — exits 0.
  2. npx vitest run — exits 0 (8 tests passing across 4 files in tests/offscreen/).
  3. The grep gates listed in Task 2's acceptance_criteria all return their expected values.
  4. wc -l src/background/index.ts — line count between 380 and 440 (the file shrunk from 536 by ~100-150 lines as the legacy paths went away and ~30-40 lines were added for the port host).

Commit cadence: TWO commits.

  • Task 1: ONE commit (refactor(01-05): delete legacy SW buffer + alarms + IndexedDB + tabCapture paths).
  • Task 2: ONE commit (feat(01-05): wire SW-side port host and port-based buffer fetch).

<success_criteria>

  • src/background/index.ts carries no buffer state, no alarms, no IndexedDB plumbing, no tabCapture calls, no chrome.tabs.onActivated re-attach plumbing
  • SW has onConnect handler matching the offscreen's port (Plan 04 counterparty)
  • SW has OFFSCREEN_READY handshake handler resolving a readiness Promise
  • T-1-04 mitigations in place on BOTH onConnect (sender + port name) and onMessage (sender)
  • IndexedDB orphan cleanup runs on onInstalled
  • tsc --noEmit clean; no as any, no @ts-ignore </success_criteria>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-05-SUMMARY.md` with: - Before/after line count for src/background/index.ts (was 536, now N) - List of every deleted symbol from Task 1 (so future audits can grep) - The final shape of the onMessage switch (which cases survived) - A snippet showing the exact `chrome.offscreen.createDocument` call as committed (so Plan 06 / 07 can grep against it) - Confirmation that `npx vitest run` shows all 8 tests passing (port + handshake stay green because Plan 04's offscreen-side stayed unchanged) - Two commit SHAs