ushman-spector
v0.3.0
Published
Bounded WebGL buffer-prefix capture for ushman parity/handoff. Opt-in samples with attestation log.
Maintainers
Readme
ushman-spector
Bounded WebGL capture for the ushman parity/handoff workflow. Browser-side instrumentation hookup, opt-in bounded GPU buffer-prefix sampling, and a v4 writer/CLI flow that archives each run under
.lab/spector/<run-id>/.
Opt-in by default. Production captures with full pixel dumps require explicit operator opt-in plus an attestation log entry. Per the IMPLEMENTATION-PLAN: "Tier S2 buffer-content recording (production). Opt-in + policy/attestation remains v3.1 for shipping mainline. It is an explicit POC track (bounded samples / raw prefix in JSON) so handoff data matches proposal depth; legal review before prod defaults."
Graphics-only. This package is for WebGL donors. Non-graphical adapters (browser extensions, plain web apps) do not consume it.
Status
Greenfield POC — Some Spector-shaped artifact code exists today fused into ushman/src/core/parity/index.ts and adapters/scene-heavy/gpu-diagnostics.ts; this package extracts and consolidates that surface.
Runtime: browser (preboot WebGL hook) + Node/Bun (writer / orchestrator / CLI).
What this package is NOT
- Not a parity verdict producer. The orchestrator's
parity/consumes our captures. - Not a full pixel-dump tool. We capture bounded prefixes of GPU buffers (configurable, default off).
- Not a Three.js library.
Install
npm i ushman-spector
bun add ushman-spectorpuppeteer-core is an optional peer dependency for the bundled CLI. The library surface only needs a page-like object that supports evaluate(...) and, for pre-navigation install, evaluateOnNewDocument(...) or addInitScript(...).
Test
bun test
bun run test:e2ebun test includes the browser E2E file as well. The browser-backed tests require a real local Chrome-family browser through puppeteer-core, auto-detect /Applications/Google Chrome.app on macOS, and fall back to PUPPETEER_EXECUTABLE_PATH=/path/to/chrome when Chrome lives elsewhere.
Public API
// Browser-side (page-script source)
export const SPECTOR_HOOK_SOURCE: string;
export const installSpectorHook: (page: PageLike) => Promise<void>;
export type PageLike = {
evaluate: <TResult>(pageFunction: ((...args: unknown[]) => TResult | Promise<TResult>) | string, ...args: unknown[]) => Promise<TResult>;
};
// Node-side capture orchestration
export type SpectorCaptureOptions = {
readonly captureBuffers?: boolean; // default false; opt-in
readonly bufferPrefixBytes?: number; // default 256, hard max 4096
readonly captureShaders?: boolean; // default true
readonly attestationLogPath?: string; // required if captureBuffers === true
readonly bundleHash?: string; // required if captureBuffers === true, format: sha256:<64-lowercase-hex>
readonly operatorName?: string;
readonly maxBuffers?: number; // default 64
readonly maxDrawCalls?: number; // default 256, hard max 1024
readonly maxTotalJsonBytes?: number; // default 262144
};
export type SpectorCapture = {
readonly createdAt: string;
readonly schemaName: 'ushman.spector-capture';
readonly schemaVersion: '1.0.0';
readonly ushmanVersion: string;
readonly state: string;
readonly durationMs: number;
readonly frameCount: number;
readonly glState: unknown;
readonly commands: readonly { readonly name: string; readonly argsPreview?: readonly string[] }[];
readonly shaders: readonly { readonly id: string; readonly type: 'vertex' | 'fragment'; readonly source: string }[];
readonly buffers: readonly unknown[];
readonly textures: readonly unknown[];
};
export type BufferSamplesDocument = {
readonly createdAt: string;
readonly schemaName: 'ushman.buffer-samples';
readonly schemaVersion: '1.0.0';
readonly ushmanVersion: string;
readonly state: string;
readonly captureBuffers: true;
readonly bufferPrefixBytes: number;
readonly buffers: readonly {
readonly id: string;
readonly target: string;
readonly usage?: string;
readonly byteLength: number;
readonly samplePrefix?: string;
readonly samplePrefixEncoding?: 'base64';
readonly sampleByteCount: number;
readonly sha256Prefix?: string;
readonly sha256Full?: string;
readonly notes?: readonly string[];
}[];
readonly truncation: {
readonly truncated: boolean;
readonly reasons: readonly string[];
readonly reasonDetails: readonly string[];
readonly droppedBuffers: number;
};
};
// Buffer sample semantics:
// - `sampleByteCount` is the actual captured prefix length (bounded by `bufferPrefixBytes`)
// - `byteLength` is the full GPU buffer size observed on the page
export type SpectorCaptureArtifacts = {
readonly capture: SpectorCapture;
readonly bufferSamples: BufferSamplesDocument | null;
};
export const runSpectorCapture: (opts: {
readonly createdAt?: string;
readonly page: PageLike;
readonly state: string;
readonly options?: SpectorCaptureOptions;
}) => Promise<SpectorCaptureArtifacts>;
export type LedgerPhase =
| 'capture'
| 'intake'
| 'seed'
| 'vendor-extract'
| 'cleanup'
| 'parity'
| 'characterize'
| 'equiv'
| 'analyze'
| 'recover'
| 'ship'
| 'migration';
export type LedgerValidator =
| 'spector'
| 'parity'
| 'characterize'
| 'equiv'
| 'verify'
| 'doctor';
export const writeSpectorCapture: (opts: {
readonly artifacts: SpectorCaptureArtifacts;
readonly outputPath: string;
}) => Promise<void>;
export const assertV4Workspace: (workspaceRoot: string) => Promise<void>;
export const resolveSpectorRunPaths: (opts: {
readonly workspaceRoot: string;
readonly state: string;
readonly timestamp: string;
readonly runId?: string;
}) => Promise<{
readonly runId: string;
readonly runDir: string;
readonly capturePath: string;
readonly bufferSamplesPath: string;
readonly manifestPath: string;
readonly ledgerPhase: string;
}>;
export const writeWorkspaceSpectorCapture: (opts: {
readonly artifacts: SpectorCaptureArtifacts;
readonly workspaceRoot: string;
readonly state: string;
readonly timestamp?: string;
readonly runId?: string;
readonly phase?: LedgerPhase;
readonly validator?: LedgerValidator;
readonly toolInvocation?:
| false
| {
readonly summary?: string;
readonly cwd?: string;
readonly args?: readonly string[];
readonly exitCode?: number;
};
}) => Promise<{
readonly runId: string;
readonly runDir: string;
readonly capturePath: string;
readonly manifestPath: string;
readonly ledgerEntryIds: readonly string[];
readonly phase: LedgerPhase;
}>;CLI
ushman-spector capture <workspace> \
--state=<state-id> \
--url=<page-url> \
[--capture-buffers] \
[--buffer-prefix-bytes=256] \
[--no-shaders] \
[--attestation-log=<path>] \
[--bundle-hash=<sha256:...>] \
[--max-buffers=<count>] \
[--max-draw-calls=<count>] \
[--max-total-json-bytes=<bytes>] \
[--operator-name=<name>] \
[--browser-ws-endpoint=<ws-url> | --executable-path=/path/to/chrome] \
[--wait-until=load|domcontentloaded|networkidle0|networkidle2] \
[--timeout-ms=<milliseconds>]If --capture-buffers is enabled, the CLI refuses to run unless both --attestation-log and --bundle-hash are supplied. USHMAN_BUFFER_CAPTURE=1 enables --capture-buffers by default for the CLI. If you pass an out-of-range integer such as --buffer-prefix-bytes=8192, the CLI fails fast instead of silently clamping it.
The CLI is v4-only. It requires <workspace>/.lab/lab.json with schemaVersion ushman-lab/v4.0 and refuses legacy v3 workspaces with a migration hint.
When the CLI launches its own browser, it currently uses the fixed Chromium flags --enable-webgl, --ignore-gpu-blocklist, and --use-angle=swiftshader. Those flags are not configurable yet.
Artifact flow
- Install the hook before navigation with
installSpectorHook(page)so buffer uploads are observed from the start of app boot. - Run
runSpectorCapture(...)after the page settles. - Persist the results with
writeSpectorCapture(...)or archive a full v4 run withwriteWorkspaceSpectorCapture(...).
runSpectorCapture(...) also attempts a best-effort install on the current page, but that fallback cannot reconstruct WebGL calls that already happened before the hook was present. For reliable buffer evidence, preinstall the hook before the app requests a context.
For v4 workspaces, the CLI and writeWorkspaceSpectorCapture(...) write:
.lab/spector/<run-id>/spector-capture.json— legacy spector-shaped telemetry (ushman.spector-capture).lab/spector/<run-id>/buffer-samples.json— bounded GPU buffer evidence with explicit truncation metadata.lab/spector/<run-id>/manifest.json— run metadata plus the emitted ledger entry ids.lab/ledger/<current-phase>/—tool-invocationandvalidator-resultentries for the capture
writeWorkspaceSpectorCapture(...) emits:
- a default archive-scoped
tool-invocationentry for library callers - a
validator-resultentry whose validator defaults to the active phase when it maps cleanly (parity,characterize,equiv) and otherwise falls back tospector
The CLI emits its own start/finish tool-invocation entries and disables the library default so the ledger does not duplicate those boundaries.
Pass toolInvocation: false when the caller already records its own tool-invocation boundaries around the archive step. The bundled CLI does this so the ledger has one start/finish pair instead of a nested extra archive entry.
writeSpectorCapture(...) treats the run directory as the atomic unit. Fresh runs are written into a sibling staging directory and promoted with a single directory rename. Overwrites rename the previous run directory into a backup, promote the staged directory, and restore the backup if promotion fails. If interrupted temp directories remain on disk, the next write recovers the newest backup or cleans stale staging directories before committing.
writeSpectorCapture(...) assumes path.dirname(outputPath) is a dedicated run directory. Existing directories may only contain capture artifacts managed by this package (spector-capture.json, buffer-samples.json, and an optional manifest.json). Shared directories with unrelated files are rejected instead of being renamed as a unit.
Ledger Shape
tool-invocation entries include:
kind,phase,summary,ts,id,prevEntryId- optional
cwd,args,exitCode
validator-result entries include:
kind,phase,summary,ts,id,prevEntryIdvalidator- optional
metrics,resultPath verdict
Verdict semantics:
greenmeans the capture archive was written with complete bounded evidence for the configured caps.yellowmeans the archive succeeded but buffer evidence was truncated bymaxBuffersormaxTotalJsonBytes.redis reserved for failed validator-style writes and is not emitted by the happy-path archive flow.
The ledger manifest at .lab/ledger/manifest.json is a bounded hot index with schemaVersion ushman-ledger-manifest/v2. It stores only entryCount, perPhaseCounts, and perPhaseLatest, so common read/write cost stays constant as historical entry files accumulate. Older v1 manifests that still contain entryLocations are migrated in place on the next ledger write. Readers that depended on entryLocations should enumerate .lab/ledger/<phase>/ directly instead of expecting a full global path map in the hot manifest.
The hot manifest is a cache, not the source of truth. The append-only entry files under .lab/ledger/<phase>/ remain authoritative. If a process crashes after writing an entry file but before updating the hot manifest, consumers should rescan the phase directory rather than assuming the manifest is complete.
Capture Notes
samplePrefixEncodingis currently always Base64.frameCountis snapshot-oriented. The package captures one evidence point, not a full frame timeline.glStateis a hook summary object, not a full Spector.js state export.- If you need operator-facing guidance on what is recorded, limits, and privacy implications, see docs/operator-guide.md.
V4 Usage
Boot the workspace from the Vite root, not a legacy stages/03-clean/ subtree:
cd /path/to/workspace
bunx vite build
bunx vite preview --host 127.0.0.1 --port 4173Then capture against the preview URL:
ushman-spector capture /path/to/workspace \
--state=0-lobby \
--url=http://127.0.0.1:4173 \
--bundle-hash=sha256:<64-hex>Cleanup Policy
Run archives under .lab/spector/ are retained. This package does not prune old runs automatically. Cleanup is expected to be orchestrator-managed or operator-managed so audit trails are not removed implicitly.
Why this exists separate from ushman
- Different release cadence. POC-only, opt-in, with explicit legal review notes.
- Different audience. Operators evaluating shader-level parity vs operators running normal characterization.
- Different runtime. Needs a browser context with WebGL + the preboot hook attached.
- Different threat model. Buffer captures may contain user-facing pixels — that's why we ship attestation hooks.
Where this fits in the family
| | |
|---|---|
| Depends on | Browser page adapter; puppeteer-core only if you use the bundled CLI |
| Consumed by | ushman operator/parity flows, which read archived runs under .lab/spector/<run-id>/ |
| Default safety | captureBuffers: false. Production captures require operator opt-in + attestation log |
