docs(01): create phase 1 plans for video pipeline stabilization .planning/phases/01-stabilize-video-pipeline/01-01-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-02-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-04-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-05-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-06-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-07-PLAN.md .planning/ROADMAP.md
This commit is contained in:
676
.planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md
Normal file
676
.planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md
Normal file
@@ -0,0 +1,676 @@
|
||||
---
|
||||
phase: 01-stabilize-video-pipeline
|
||||
plan: 03
|
||||
type: tdd
|
||||
wave: 1
|
||||
depends_on: ["02"]
|
||||
files_modified:
|
||||
- src/offscreen/recorder.ts
|
||||
- src/offscreen/index.html
|
||||
- src/shared/logger.ts
|
||||
- src/shared/types.ts
|
||||
autonomous: true
|
||||
requirements:
|
||||
- REQ-video-ring-buffer
|
||||
requirements_addressed:
|
||||
- REQ-video-ring-buffer
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "`src/offscreen/recorder.ts` exists and 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)"
|
||||
- "`MediaRecorder.start(2000)` is called on session start (timeslice = 2000 ms per SPEC §4.1)"
|
||||
- "On `MediaStreamTrack.onended`, the buffer is cleared and a `RECORDING_ERROR` of `'user-stopped-sharing'` is emitted to SW"
|
||||
- "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
|
||||
wave-1 dependency graph (Plans 03 and 04 run in parallel; Plan 02 wrote both
|
||||
sets of RED tests in advance).
|
||||
|
||||
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 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: any[]) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console[level](`[OS:${this.context}] ${timestamp}`, ...args);
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
this.logWithLevel('log', ...args);
|
||||
}
|
||||
|
||||
warn(...args: any[]) {
|
||||
this.logWithLevel('warn', ...args);
|
||||
}
|
||||
|
||||
error(...args: any[]) {
|
||||
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>
|
||||
Reference in New Issue
Block a user