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

682 lines
32 KiB
Markdown

---
phase: 01-stabilize-video-pipeline
plan: 03
type: tdd
wave: 2
depends_on: ["02"]
files_modified:
- src/offscreen/recorder.ts
- src/offscreen/index.html
- src/shared/logger.ts
- src/shared/types.ts
autonomous: true
style_divergence_note: "OffscreenLogger uses `...args: unknown[]` (strict-mode hygiene) where existing Logger / ContentLogger use `...args: any[]` (project convention). Refactoring the existing two for consistency is OUT OF SCOPE for Phase 1; they remain on the legacy pattern."
requirements:
- REQ-video-ring-buffer
requirements_addressed:
- REQ-video-ring-buffer
must_haves:
truths:
- "`src/offscreen/recorder.ts` exists at the canonical source path as a real TypeScript module — strict type-check, source maps, IDE support (D-06)"
- "`src/offscreen/recorder.ts` exports the symbols Plan 02 tests against: addChunk, trimAged, getBuffer, resetBuffer, assertCodecSupported, VIDEO_BUFFER_DURATION_MS"
- "Running `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` exits 0 with all tests green"
- "Buffer holds at most: 1 header chunk + every chunk with arrival timestamp newer than now-30_000ms"
- "Codec strictly bound to `video/webm;codecs=vp9` at 400 000 bps with `MediaRecorder.isTypeSupported` gate; no fallback chain (D-20)"
- "Capture is `navigator.mediaDevices.getDisplayMedia()` invoked from inside the offscreen document (D-01); SW-side `chrome.tabCapture.getMediaStreamId` is removed in Plan 05"
- "Single continuous `MediaRecorder` runs for the whole session with `mediaRecorder.start(2000)` so chunks land on cluster boundaries per SPEC §4.1 (D-09)"
- "One-time source picker fires on session start (operator picks screen/window once); on `MediaStreamTrack.onended` the buffer is cleared and a `RECORDING_ERROR` of `'user-stopped-sharing'` is emitted to SW so the popup can re-prompt next interaction (D-03)"
- "Restart-segments fallback (D-13) is pre-staged as a commented-out skeleton at the bottom of recorder.ts so Plan 07's fallback path doesn't require a re-plan"
- "`src/offscreen/index.html` exists at the source path and references `./recorder.ts`"
- "`src/shared/logger.ts` has an `OffscreenLogger` class with `[OS:...]` prefix"
- "`src/shared/types.ts` carries the new `PortMessageType` and `PortMessage` types; `VIDEO_CHUNK` and `VIDEO_CHUNK_SAVED` removed"
artifacts:
- path: "src/offscreen/recorder.ts"
provides: "Ring buffer + getDisplayMedia + MediaRecorder + codec strict-mode + track-ended handler"
min_lines: 150
exports: ["addChunk", "trimAged", "getBuffer", "resetBuffer", "assertCodecSupported", "VIDEO_BUFFER_DURATION_MS"]
- path: "src/offscreen/index.html"
provides: "crxjs-managed offscreen entry point"
contains: "./recorder.ts"
- path: "src/shared/logger.ts"
provides: "OffscreenLogger added alongside existing Logger / ContentLogger"
contains: "export class OffscreenLogger"
- path: "src/shared/types.ts"
provides: "Port message types added; deleted VIDEO_CHUNK / VIDEO_CHUNK_SAVED variants"
contains: "PortMessage"
key_links:
- from: "src/offscreen/recorder.ts"
to: "src/shared/types.ts"
via: "import type { Message, VideoChunk, PortMessage } from '../shared/types'"
pattern: "from '../shared/types'"
- from: "src/offscreen/recorder.ts"
to: "src/shared/logger.ts"
via: "import { OffscreenLogger } from '../shared/logger'"
pattern: "from '../shared/logger'"
- from: "src/offscreen/index.html"
to: "src/offscreen/recorder.ts"
via: "<script type=\"module\" src=\"./recorder.ts\">"
pattern: "./recorder.ts"
---
<objective>
GREEN-implement the offscreen recorder module that owns:
- `getDisplayMedia` capture (CONTEXT.md D-01..D-03)
- single continuous `MediaRecorder` at timeslice 2000 ms (D-09)
- in-memory ring buffer with header retention + 30 s age trim (D-10, D-11)
- codec strict-mode (D-20 / RESEARCH.md Pattern 6)
- `MediaStreamTrack.onended` cleanup (RESEARCH.md Example F)
- restart-segments fallback pre-staged as a documented skeleton (D-13)
This plan flips the 2 ring-buffer + codec test files (created RED in Plan 02)
to GREEN. It does NOT yet wire up the port-keepalive or the OFFSCREEN_READY
handshake — that is Plan 04. To keep this plan inside its context budget, the
port-side code in `recorder.ts` is left as a small import-time placeholder
that Plan 04 fills in. The Plan-02 port + handshake tests therefore remain
RED until Plan 04 lands, which is the intended choreography per the
sequential wave structure (Plan 03 lands in wave 2; Plan 04 follows in wave
3 and refactors the bootstrap section of the same file). Plan 02 wrote both
sets of RED tests in advance so Plan 03 and Plan 04 each have a discrete
RED→GREEN cycle to flip.
Purpose: REQ-video-ring-buffer's load-bearing logic lives here. The ring
buffer is a pure function — exactly the TDD sweet spot. The remaining
non-pure side (getDisplayMedia + MediaRecorder construction + track.onended)
is wired in the same module because the buffer is private state owned by it.
Both test files attached to this plan run in pure Node and don't need a
browser.
Output: `src/offscreen/recorder.ts` (the new authoritative source-of-truth),
`src/offscreen/index.html` (crxjs entry point), `src/shared/logger.ts`
extended with `OffscreenLogger`, `src/shared/types.ts` cleaned up.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
@.planning/phases/01-stabilize-video-pipeline/01-VALIDATION.md
@tests/offscreen/ring-buffer.test.ts
@tests/offscreen/codec-check.test.ts
@src/background/index.ts
@src/shared/types.ts
@src/shared/logger.ts
@offscreen/index.ts
</context>
<interfaces>
<!-- Plan 02 already wrote tests that pin this module's contracts.
This plan implements the module to satisfy those tests. -->
Required exports from `src/offscreen/recorder.ts` (verified against the RED tests):
```typescript
export const VIDEO_BUFFER_DURATION_MS: number;
export function addChunk(blob: Blob, timestamp: number): void;
export function trimAged(now: number): void;
export function getBuffer(): VideoChunk[];
export function resetBuffer(): void;
export function assertCodecSupported(): void; // throws Error on unsupported vp9
```
Required side-effects of importing the module (pinned by Plan 02 tests):
- `chrome.runtime.onMessage.addListener(...)` called exactly once
- `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY', ... })` called exactly once
- `chrome.runtime.connect({ name: 'video-keepalive' })` called exactly once
The OFFSCREEN_READY send AND the port connect are owned by Plan 04 — Plan 03
adds a small bootstrap stub that calls them in the right order so that
the Plan-02 ring-buffer + codec tests can stub `chrome.runtime` minimally
without crashing on the bootstrap. The full port reconnect + ping logic
lands in Plan 04.
VideoChunk type (from src/shared/types.ts:27-31, unchanged):
```typescript
export interface VideoChunk {
data: Blob;
timestamp: number;
isFirst?: boolean;
}
```
</interfaces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser getDisplayMedia picker → offscreen `MediaStream` | The operator selects what to share; offscreen receives a track they chose |
| `MediaRecorder.isTypeSupported` → recorder.start path | A spoofed `MediaRecorder` could attempt to influence codec selection (only matters in test fixtures, but the strict-mode prevents production codec downgrade) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-01 | Tampering — codec downgrade | `MediaRecorder` constructor | mitigate | `assertCodecSupported()` calls `MediaRecorder.isTypeSupported('video/webm;codecs=vp9')` BEFORE constructing the recorder; if it returns false, throws an Error and emits `RECORDING_ERROR` to SW. No vp8 / h264 / default fallback chain. The strict mode covers `MediaRecorder` itself being absent (the codec-check test mocks both cases). Grep gate: `grep -v '^#' src/offscreen/recorder.ts \| grep -cE 'codecs=(vp8\|h264)'` returns 0 (no fallback codec strings in the module). |
| T-1-03 | Information Disclosure — captured stream contains other apps' content | `getDisplayMedia` stream | accept | This is the documented residual risk per CONTEXT.md D-04 — the operator opted into the "Sharing your screen" indicator as the cost of the broader capture coverage. The Chrome "Sharing your screen" indicator is the user-facing signal; the operator chose to share. No code-level mitigation is possible (the API is supposed to capture the screen). Documented as accepted risk in 01-RESEARCH.md §"Security Domain". |
| T-1-NEW-03-01 | DoS — unbounded buffer growth on a stuck timestamp | `addChunk` + `trimAged` | mitigate | The trim function uses arrival timestamp; if a clock anomaly produces a negative or stuck `now`, the buffer is bounded above by SPEC §10 #9 (50 MB RAM ceiling) anyway. Defensive belt: the recorder also exposes `getBuffer().length` to SW so a healthchecker can observe growth. No active rate-limit needed for Phase 1. |
</threat_model>
<feature>
<name>Offscreen recorder module — ring buffer + getDisplayMedia + codec strict-mode + track-ended cleanup</name>
<files>src/offscreen/recorder.ts (new), src/offscreen/index.html (new), src/shared/logger.ts (modify), src/shared/types.ts (modify)</files>
<behavior>
RED already exists (Plan 02). GREEN must satisfy:
Ring buffer (from `tests/offscreen/ring-buffer.test.ts`):
- After `resetBuffer()`, `getBuffer()` returns `[]`
- First `addChunk` produces an entry with `isFirst: true`; the first-chunk flag is sticky for the FIRST add only and survives subsequent trims
- Second and later `addChunk` calls produce entries with `isFirst: false` (or undefined — `falsy`)
- `trimAged(now)` removes every entry whose `timestamp < now - VIDEO_BUFFER_DURATION_MS` AND whose `isFirst` is not `true`
- `VIDEO_BUFFER_DURATION_MS === 30_000`
- `trimAged` on empty buffer does not throw
Codec strict-mode (from `tests/offscreen/codec-check.test.ts`):
- `assertCodecSupported()` calls `MediaRecorder.isTypeSupported('video/webm;codecs=vp9')`
- If the return is falsy: throw a new `Error` whose message starts with `'vp9 unsupported'` AND call `chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: <message> })` BEFORE throwing
- If the return is truthy: do not throw and do not emit RECORDING_ERROR
Bootstrap side-effects (pinned by Plan 02 handshake + port tests but Plan 03 only needs to STUB them — Plan 04 fills in):
- At module import time, register `chrome.runtime.onMessage.addListener(...)` BEFORE calling `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' })` (handshake ordering — Plan 04 elaborates)
- Call `chrome.runtime.connect({ name: 'video-keepalive' })` once (Plan 04 wires the ping loop and reconnect)
</behavior>
<implementation>
See task list — three tasks: RED-verify, GREEN-implement, then types + logger ergonomics.
</implementation>
</feature>
<tasks>
<task type="auto">
<name>Task 1: RED-verify — confirm Plan 02 tests fail at module resolution</name>
<files>(none modified)</files>
<read_first>
- tests/offscreen/ring-buffer.test.ts (Plan 02 output)
- tests/offscreen/codec-check.test.ts (Plan 02 output)
</read_first>
<action>
Run the two test files this plan owns and CAPTURE the failure mode:
```bash
npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts 2>&1 | tee /tmp/01-03-red.log
```
The output MUST contain "Cannot find module '../../src/offscreen/recorder'" (or "Failed to resolve" — Vitest's wording varies by version). The exit code MUST be non-zero. Both of these confirm the RED gate is in place from Plan 02.
If either condition is NOT met:
- If the test passes: Plan 02 wrote a degenerate test — STOP and escalate (a passing test before the implementation exists means the test is wrong).
- If the test fails for a different reason (e.g., syntax error in the test file): STOP and escalate to Plan 02 owner.
Otherwise, RED is confirmed; proceed to Task 2.
This is a verify-only step; nothing is committed.
</action>
<verify>
<automated>grep -qE "Cannot find module|Failed to resolve" /tmp/01-03-red.log && ! npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts 2>&1 | tail -1 | grep -q "passed"</automated>
</verify>
<acceptance_criteria>
- `/tmp/01-03-red.log` exists and is non-empty
- Contains "Cannot find module" or "Failed to resolve"
- Exit code of the vitest invocation was non-zero
</acceptance_criteria>
<done>RED gate confirmed. Plan 02 test files import a module that does not yet exist. Proceed to GREEN.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: GREEN — write src/offscreen/recorder.ts and src/offscreen/index.html</name>
<files>src/offscreen/recorder.ts, src/offscreen/index.html</files>
<read_first>
- src/background/index.ts (lines 26-75 — ring-buffer logic being relocated)
- src/shared/types.ts (the VideoChunk interface to import)
- src/shared/logger.ts (Logger class shape; OffscreenLogger lands in Task 3)
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md (lines 35-225 — composed pattern for the new module)
- .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Example A" + §"Example E" + §"Example F" (lines 882-893, 949-971, 973-989)
- tests/offscreen/ring-buffer.test.ts (the export surface to match)
- tests/offscreen/codec-check.test.ts (the export surface to match)
</read_first>
<behavior>
After Task 2 ships, the following MUST hold:
```bash
npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts --reporter=dot
# exit 0 — all 6 tests pass (4 in ring-buffer, 2 in codec-check)
```
</behavior>
<action>
Write the new `src/offscreen/recorder.ts`. Use the VERBATIM module below — line-by-line, comments and all (Russian section headers preserved per CONTEXT.md "Established patterns"):
```typescript
// src/offscreen/recorder.ts — Phase 01 source of truth (replaces dead
// offscreen/index.ts and the inline string in vite.config.ts:13-216).
// Owns: getDisplayMedia capture, MediaRecorder lifecycle, in-memory ring
// buffer with WebM-header retention + 30 s age trim, codec strict-mode,
// track.onended cleanup. Port keepalive + OFFSCREEN_READY handshake are
// wired by Plan 04 in the matching wave-1 lane.
import { OffscreenLogger } from '../shared/logger';
import type { Message, VideoChunk } from '../shared/types';
// ─── Константы (per CON-video-codec, CON-video-window) ──────────────────
export const VIDEO_BUFFER_DURATION_MS = 30_000; // 30 секунд
const VIDEO_MIME = 'video/webm;codecs=vp9'; // D-20 strict — no fallback
const VIDEO_BITRATE = 400_000; // CON-video-codec
const TIMESLICE_MS = 2_000; // SPEC §4.1
const PORT_NAME = 'video-keepalive'; // Plan 04 owns the ping loop
const logger = new OffscreenLogger('Recorder');
// ─── Состояние модуля (module-level, NOT inside startRecording — fixes audit P0 #1 shadow) ───
let videoRecorder: MediaRecorder | null = null; // renamed from 'mediaRecorder' to prevent shadowing
let mediaStream: MediaStream | null = null;
let videoBuffer: VideoChunk[] = [];
let firstChunkSaved = false;
let keepalivePort: chrome.runtime.Port | null = null; // Plan 04 fills the lifecycle
// ─── Кольцевой буфер (pure functions — testable in Node) ────────────────
export function addChunk(blob: Blob, timestamp: number): void {
const chunk: VideoChunk = {
data: blob,
timestamp,
isFirst: !firstChunkSaved,
};
if (!firstChunkSaved) {
firstChunkSaved = true;
logger.log('First chunk (WebM header) pinned, size:', blob.size);
}
videoBuffer.push(chunk);
trimAged(timestamp);
}
export function trimAged(now: number): void {
const cutoff = now - VIDEO_BUFFER_DURATION_MS;
videoBuffer = videoBuffer.filter((chunk) => {
if (chunk.isFirst) {
return true;
}
return chunk.timestamp >= cutoff;
});
}
export function getBuffer(): VideoChunk[] {
return videoBuffer;
}
export function resetBuffer(): void {
videoBuffer = [];
firstChunkSaved = false;
}
// ─── Проверка кодека (D-20 strict-mode — no fallback chain) ─────────────
export function assertCodecSupported(): void {
const supported =
typeof MediaRecorder !== 'undefined' &&
typeof MediaRecorder.isTypeSupported === 'function' &&
MediaRecorder.isTypeSupported(VIDEO_MIME);
if (!supported) {
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '<unknown>';
const errMessage = `vp9 unsupported. UA=${ua}`;
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: errMessage });
throw new Error(errMessage);
}
}
// ─── Захват экрана (getDisplayMedia inside offscreen — D-01) ────────────
async function startRecording(): Promise<void> {
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
logger.warn('Recording already active; ignoring duplicate START_RECORDING');
return;
}
try {
assertCodecSupported(); // throws if vp9 missing — no fallback
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false, // SPEC §9 — Phase 2 / CAP-01 territory
});
mediaStream = stream;
videoRecorder = new MediaRecorder(stream, {
mimeType: VIDEO_MIME,
videoBitsPerSecond: VIDEO_BITRATE,
});
videoRecorder.ondataavailable = onDataAvailable;
videoRecorder.onerror = (event) => logger.error('MediaRecorder error:', event);
// Track end detection — RESEARCH.md Example F. Attach to ALL tracks
// (Pitfall 6) so an audio-track edge case won't silently desync.
stream.getTracks().forEach((track) => {
track.addEventListener('ended', onUserStoppedSharing, { once: true });
});
videoRecorder.start(TIMESLICE_MS);
logger.log('Recording started, mime:', VIDEO_MIME, 'timeslice:', TIMESLICE_MS, 'ms');
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
logger.error('startRecording failed:', msg);
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: msg });
throw error;
}
}
function onDataAvailable(event: BlobEvent): void {
if (!event.data || event.data.size === 0) {
return;
}
addChunk(event.data, Date.now());
}
function onUserStoppedSharing(): void {
logger.log('Operator stopped sharing — clearing buffer');
resetBuffer();
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
videoRecorder.stop();
}
if (mediaStream !== null) {
mediaStream.getTracks().forEach((t) => t.stop());
mediaStream = null;
}
videoRecorder = null;
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'user-stopped-sharing' });
}
function stopRecording(): void {
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
videoRecorder.stop();
logger.log('Recording stopped manually');
}
}
// ─── Bootstrap (Plan 04 wires the full port + handshake) ────────────────
// Plan 03 commits this minimal bootstrap so that Plan 02's ring-buffer +
// codec tests can mock `chrome.runtime` without crashing on module import.
// Plan 04 elaborates the OFFSCREEN_READY handshake and the port reconnect
// loop without rewriting this module's exports.
function isFromOwnExtension(sender: chrome.runtime.MessageSender | undefined): boolean {
return sender?.id === chrome.runtime.id;
}
chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
if (!isFromOwnExtension(sender)) {
return false;
}
switch (message.type) {
case 'START_RECORDING':
startRecording().then(() => sendResponse({ ok: true })).catch((err) => sendResponse({ ok: false, error: String(err) }));
return true;
case 'STOP_RECORDING':
stopRecording();
sendResponse({ ok: true });
return false;
default:
return false;
}
});
// Plan 04 will replace this stub with the full reconnect + ping loop.
keepalivePort = chrome.runtime.connect({ name: PORT_NAME });
chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' });
// Touch the keepalive var so noUnusedLocals doesn't complain — Plan 04
// uses it. Once Plan 04 lands, this line is removed in its refactor pass.
void keepalivePort;
// ─── D-13 fallback: restart-segments skeleton (pre-staged per CONTEXT.md) ──
// Activated only if the Phase 07 ffprobe gate fails on the simpler
// continuous-recorder + age-trim approach. See RESEARCH.md Pattern 3.
//
// const SEGMENT_MS = 10_000;
// const MAX_SEGMENTS = 3;
// let segments: Blob[] = [];
// let currentChunks: Blob[] = [];
// function rotateSegment(): void { videoRecorder?.stop(); /* onstop assembles a segment */ }
// function onSegmentStopped(): void {
// segments.push(new Blob(currentChunks, { type: 'video/webm' }));
// if (segments.length > MAX_SEGMENTS) segments.shift();
// currentChunks = [];
// if (mediaStream) {
// videoRecorder = new MediaRecorder(mediaStream, { mimeType: VIDEO_MIME, videoBitsPerSecond: VIDEO_BITRATE });
// videoRecorder.ondataavailable = (e) => { if (e.data.size > 0) currentChunks.push(e.data); };
// videoRecorder.onstop = onSegmentStopped;
// videoRecorder.start();
// setTimeout(rotateSegment, SEGMENT_MS);
// }
// }
```
Then write `src/offscreen/index.html` with VERBATIM content:
```html
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Mokosh Recorder</title>
</head>
<body>
<script type="module" src="./recorder.ts"></script>
</body>
</html>
```
Notes:
- The exported pure functions (`addChunk`, `trimAged`, `getBuffer`, `resetBuffer`, `assertCodecSupported`) are deliberately decoupled from the impure `getDisplayMedia` path so they can be exercised in Node — exactly what Plan 02's ring-buffer + codec tests do.
- The handshake `OFFSCREEN_READY` send + the `chrome.runtime.connect` call are minimal stubs that satisfy the import-time test side-effects. Plan 04 elaborates them (adds ping loop, adds onDisconnect reconnect, adds REQUEST_BUFFER handler) — the changes Plan 04 makes are ADDITIVE within the same module, so they do NOT break Plan 03's tests.
- The `as any` count in this module is zero, the `@ts-ignore` count is zero (per CLAUDE.md naming rule + CONTEXT.md "Established patterns").
After writing both files, run:
```bash
npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts --reporter=dot
npx tsc --noEmit
```
Both MUST exit 0.
</action>
<verify>
<automated>npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `test -f src/offscreen/recorder.ts && test -f src/offscreen/index.html` exits 0
- `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` exits 0 with 6 passing tests
- `npx tsc --noEmit` exits 0
- `grep -c "export function addChunk" src/offscreen/recorder.ts` returns 1
- `grep -c "export function trimAged" src/offscreen/recorder.ts` returns 1
- `grep -c "export function getBuffer" src/offscreen/recorder.ts` returns 1
- `grep -c "export function resetBuffer" src/offscreen/recorder.ts` returns 1
- `grep -c "export function assertCodecSupported" src/offscreen/recorder.ts` returns 1
- `grep -c "VIDEO_BUFFER_DURATION_MS = 30_000" src/offscreen/recorder.ts` returns 1
- `grep -v '^#' src/offscreen/recorder.ts | grep -cE "codecs=(vp8|h264)"` returns 0 (T-1-01 mitigation — no fallback codec)
- `grep -c "as any" src/offscreen/recorder.ts` returns 0 (CLAUDE.md rule)
- `grep -c "@ts-ignore" src/offscreen/recorder.ts` returns 0
- `grep -c "start(2000)" src/offscreen/recorder.ts` returns 0 BUT `grep -c "TIMESLICE_MS" src/offscreen/recorder.ts` returns at least 2 (start uses the constant)
- `grep -c "./recorder.ts" src/offscreen/index.html` returns 1
</acceptance_criteria>
<done>Two tests files green; ring buffer and codec strict-mode behavior implemented; offscreen HTML entry exists; tsc clean.</done>
</task>
<task type="auto">
<name>Task 3: Logger + types ergonomics — add OffscreenLogger, clean up shared/types.ts</name>
<files>src/shared/logger.ts, src/shared/types.ts</files>
<read_first>
- src/shared/logger.ts (lines 1-51 — Logger and ContentLogger shapes)
- src/shared/types.ts (lines 1-68 — full current state)
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §"Shared Patterns / Logging" (lines 716-759)
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`src/shared/types.ts` (lines 480-530)
</read_first>
<action>
Two edits.
(1) `src/shared/logger.ts` — append a new `OffscreenLogger` class at the END of the file, mirroring the shape of `ContentLogger`:
```typescript
// Логгер для Offscreen Document
export class OffscreenLogger {
private context: string;
constructor(context: string) {
this.context = context;
}
private logWithLevel(level: 'log' | 'warn' | 'error', ...args: unknown[]) {
const timestamp = new Date().toISOString();
console[level](`[OS:${this.context}] ${timestamp}`, ...args);
}
log(...args: unknown[]) {
this.logWithLevel('log', ...args);
}
warn(...args: unknown[]) {
this.logWithLevel('warn', ...args);
}
error(...args: unknown[]) {
this.logWithLevel('error', ...args);
}
}
```
Do not modify the existing `Logger` or `ContentLogger` classes.
(2) `src/shared/types.ts` — three edits:
(a) Remove `'VIDEO_CHUNK'` and `'VIDEO_CHUNK_SAVED'` from the `MessageType` union. The `MessageType` becomes:
```typescript
export type MessageType =
| 'REQUEST_PERMISSIONS'
| 'PERMISSIONS_GRANTED'
| 'PERMISSIONS_DENIED'
| 'GET_VIDEO_BUFFER'
| 'VIDEO_BUFFER_RESPONSE'
| 'GET_RRWEB_EVENTS'
| 'RRWEB_EVENTS_RESPONSE'
| 'SAVE_ARCHIVE'
| 'ARCHIVE_SAVED'
| 'START_RECORDING'
| 'STOP_RECORDING'
| 'RECORDING_ERROR'
| 'OFFSCREEN_READY';
```
(b) After the existing `Message<T>` interface (after line 24), add the port message types VERBATIM:
```typescript
// Типы сообщений в long-lived port (offscreen ↔ SW; D-17 / Plan 04)
export type PortMessageType =
| 'PING'
| 'REQUEST_BUFFER'
| 'BUFFER';
export interface PortMessage {
type: PortMessageType;
chunks?: VideoChunk[];
}
```
(c) Leave the `VideoChunk`, `UserEvent`, `SessionMetadata`, `PopupState`, `VideoBufferResponse`, `RrwebEventsResponse` interfaces unchanged.
After both edits, run:
```bash
npx tsc --noEmit
npx vitest run
```
Both MUST exit 0 (the 2 ring-buffer + codec tests should still pass; the 2 handshake + port tests remain RED — they get GREEN in Plan 04).
</action>
<verify>
<automated>npx tsc --noEmit && grep -c "OffscreenLogger" src/shared/logger.ts && grep -cE "VIDEO_CHUNK[^_S]|VIDEO_CHUNK_SAVED" src/shared/types.ts</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- `grep -c "export class OffscreenLogger" src/shared/logger.ts` returns 1
- `grep -c "'VIDEO_CHUNK'" src/shared/types.ts` returns 0 (removed)
- `grep -c "'VIDEO_CHUNK_SAVED'" src/shared/types.ts` returns 0 (removed)
- `grep -c "PortMessage" src/shared/types.ts` returns at least 2 (one for the type alias, one for the interface)
- `grep -c "OFFSCREEN_READY" src/shared/types.ts` returns 1 (still present)
- `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` exits 0
</acceptance_criteria>
<done>OffscreenLogger available; types cleaned up; ring-buffer + codec tests still green; the deleted message types prevent Plan 05 from accidentally re-introducing the broken sendMessage-Blob path.</done>
</task>
<task type="auto">
<name>Task 4: REFACTOR — optional cleanup pass (only if obvious improvements exist)</name>
<files>src/offscreen/recorder.ts (only if changed)</files>
<read_first>
- src/offscreen/recorder.ts (Task 2 output)
</read_first>
<action>
Per the TDD REFACTOR phase rules in `$HOME/.claude/get-shit-done/references/tdd.md`:
1. Re-read `src/offscreen/recorder.ts` (from Task 2).
2. Look ONLY for obvious improvements:
- Constant duplication
- Unused imports
- Mis-placed comments
3. If no obvious improvements: SKIP THIS TASK (do not commit). Note in the summary: "Refactor: no changes needed."
4. If improvements found: make them; run `npx vitest run && npx tsc --noEmit`; commit ONLY if both exit 0.
Do NOT use this task to add features. Do NOT rewrite the bootstrap section (that's Plan 04's territory). Do NOT extract the ring buffer into a separate file in this phase (audit-driven decision: one source of truth in `src/offscreen/recorder.ts`).
</action>
<verify>
<automated>npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- All Plan-03-owned tests still pass after the refactor pass (or after the no-op decision)
- `npx tsc --noEmit` still exits 0
- The SUMMARY.md documents whether refactor happened or was skipped
</acceptance_criteria>
<done>REFACTOR phase complete (or explicitly skipped with rationale).</done>
</task>
</tasks>
<verification>
After all four tasks land:
1. `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` — exits 0, 6 tests passing.
2. `npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts` — exits NON-ZERO (these stay RED until Plan 04). The failure mode is now NOT a module-resolution error (since the module exists), but rather an assertion mismatch — the stub bootstrap calls `chrome.runtime.sendMessage({type: 'OFFSCREEN_READY'})` exactly once and `chrome.runtime.connect({name: 'video-keepalive'})` exactly once, so the handshake test MAY actually pass after Plan 03 lands. **This is acceptable**: the reconnect test is what definitively pins Plan 04.
3. `npx tsc --noEmit` — exits 0.
4. `grep -c "as any\|@ts-ignore" src/offscreen/recorder.ts src/offscreen/index.html` — returns 0.
5. `grep -v '^#' src/offscreen/recorder.ts | grep -cE "codecs=(vp8|h264)"` — returns 0 (T-1-01 mitigation grep gate).
6. `wc -l src/offscreen/recorder.ts` — at least 150 lines.
Commit cadence: per the TDD reference, this is the GREEN phase of a TDD plan:
- Task 1: no commit (verify-only).
- Task 2: ONE commit (`feat(01-03): implement offscreen recorder ring buffer and codec strict-mode`).
- Task 3: ONE commit (`feat(01-03): add OffscreenLogger and clean up shared types`).
- Task 4: zero or one commit (`refactor(01-03): {what was cleaned}` only if changes were made).
Total: 2-3 commits.
</verification>
<success_criteria>
- `src/offscreen/recorder.ts` exists, exports all 6 symbols Plan 02 tests expect
- 6 ring-buffer + codec tests passing (Plan 02 → Plan 03 GREEN handoff complete for the ring-buffer + codec slice)
- `src/offscreen/index.html` exists at source path, references `./recorder.ts`
- `OffscreenLogger` class exists in `src/shared/logger.ts`
- `PortMessage` type exists in `src/shared/types.ts`; `VIDEO_CHUNK` / `VIDEO_CHUNK_SAVED` removed
- D-13 fallback skeleton present as comment block in recorder.ts (no re-plan needed if Plan 07 ffprobe fails)
- `npx tsc --noEmit` clean; no `as any`, no `@ts-ignore`
- The grep gates in `<verification>` all return their expected values
</success_criteria>
<output>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-03-SUMMARY.md` with:
- RED: log excerpt from Task 1 showing the module-resolution failure
- GREEN: list of 6 tests now passing, with the test names
- REFACTOR: what changed (if anything) in Task 4
- Final list of files modified (4 files) and line counts
- The exact export surface of recorder.ts (so Plan 04 / 05 can grep against it without re-reading)
- Note: "Plan 04 needs to: (a) replace the import-time stub `chrome.runtime.connect({name:'video-keepalive'})` with the full ping-loop + reconnect-on-disconnect from RESEARCH.md Pattern 5; (b) wire the `REQUEST_BUFFER` handler so the SW can pull chunks on export; (c) confirm the existing `OFFSCREEN_READY` send is still emitted exactly once. Plan 05 needs to update SW side accordingly."
- Commit list (2-3 commits)
</output>