Skip to content

Send the per-file role on episode upload - #1845

Open
martien-wdy wants to merge 6 commits into
split/7-tools-and-examplefrom
feat/episode-file-role-wire
Open

Send the per-file role on episode upload#1845
martien-wdy wants to merge 6 commits into
split/7-tools-and-examplefrom
feat/episode-file-role-wire

Conversation

@martien-wdy

@martien-wdy martien-wdy commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Problem

#1842 seals each camera source with a browser-playable cameras/<source>/playable.mp4 and marks it in the device manifest with Role = FileRoleDerived. The device therefore knows the file is an artifact computed from capture payload rather than capture payload itself.

buildEpisodeManifest in go/internal/agent/services/data_transfer_worker.go never sent that. It projected path, size, checksum, media type, format and source id, because EpisodeFileManifest had no role field. The remux reached the cloud carrying the camera's source_id and media type video/mp4, which on the catalog side is indistinguishable from a second camera capture on that source. A Business Intelligence query summing bytes per source_id roughly doubles every camera source. Only the verbatim manifest in attributes_json still held the truth, and nothing queries into it.

Cause

The upload manifest is a projection of the device manifest, and the role was added to the device manifest after the projection was written. The wire had no field to project onto.

Solution

Vendor the EpisodeFileRole addition into Proto/cloud/data_ingest.proto (matching wendylabsinc/service-protos#49 byte for byte, as this repository's copy already did), regenerate cloudpb, and map data.File.Role onto the enum in buildEpisodeManifest.

The mapping keeps one vocabulary across the three layers:

  • empty (capture payload, capture metadata) maps to EPISODE_FILE_ROLE_CAPTURED
  • data.FileRoleDerived maps to EPISODE_FILE_ROLE_DERIVED
  • anything else maps to EPISODE_FILE_ROLE_UNSPECIFIED

Captured is sent explicitly rather than left at the default, so a manifest that went through this mapper is distinguishable on the wire from one written by an agent built before the field existed. The unrecognised case only arises when an older agent resumes an upload for an episode a newer build sealed; UNSPECIFIED is defined by the wire contract to mean captured, which is the pre-existing meaning, and the enum deliberately has no "unknown" member because no reader could act on one.

The protoc version-stamp trap

The committed stubs under go/proto/gen were generated with a mix of protoc versions: 37 files stamped v7.34.0, 6 stamped v7.35.1, plus one protoc-gen-go v1.36.11-devel among 42 v1.36.11. Running go/scripts/generate-proto.sh therefore rewrites the stamp in 80 files and buries the real change.

Each regenerated file's own committed stamp is restored, and any file whose only difference was the stamp is reverted outright. The normalizer was verified as a true no-op: regenerating with no proto change leaves 80 files dirty, and after normalization the tree is clean. This pull request touches exactly one generated file, go/proto/gen/cloudpb/data_ingest.pb.go, and it contains no stamp lines in its diff.

One unrelated pre-existing drift surfaced during that check and is not included here: go/proto/gen/appspb/v1/sensor_service.pb.go is stale against its own .proto, whose comment was edited from "sensors and camera" to "sensor-read and camera" without regenerating. That belongs in its own change.

Verification

  • CC=/usr/bin/clang go build ./go/... passes
  • gofmt -l go/ is empty
  • go vet ./go/internal/agent/services/... ./go/internal/agent/data/... is clean
  • go test ./go/internal/agent/services/... ./go/internal/agent/data/... passes (ok for both packages)

Two new tests:

  • TestBuildEpisodeManifestCarriesFileRole builds a manifest with an index, a raw segment and the derived remux, and asserts the wire carries CAPTURED, CAPTURED, DERIVED respectively.
  • TestEpisodeFileRoleUnknownDegradesToUnspecified pins the three mapper cases including the unrecognised one.

Dependency order

This branch is cut from feat/episode-playable-at-seal, so its commits appear in this diff. It must merge after both:

  1. Carry the per-file role on the episode upload wire service-protos#49 (defines the enum)
  2. Seal episodes with a browser-playable MP4 per camera source #1842 (defines data.File.Role)

and after https://github.com/wendylabsinc/cloud/pull/463, which persists the role in the catalog. The cloud service should be deployed before devices carrying #1842 start uploading: a derived file that reaches a service without that change is recorded as captured and stays that way.

Convert keeps writing playable MP4s into a separate directory for
downloaded episodes. ConvertSourceInPlace is the sanctioned exception:
it writes cameras/<source>/playable.mp4 inside the episode itself, for
the agent to call while sealing, before the manifest's file list is
finalized. The raw segments and index.jsonl are still only ever read,
and the output goes through a temporary file plus rename so a crash
mid-mux leaves nothing half-written (sealing ignores *.tmp files).

The result judgement stays with the caller: BFrames,
UndecodedSliceHeaders and SyncSamples report the conditions under which
the clip's timing or seekability cannot be vouched for, and the package
deliberately does not decide policy for its callers.
The seal now remuxes every camera source into
cameras/<source>/playable.mp4 before sealFiles walks the episode, so the
derived clip gets a size and SHA-256 entry in the manifest and rides the
existing pipeline unchanged: the transfer worker uploads it because it
is listed, and commit-time verification covers it for the same reason.
The manifest entry carries role "derived" (File.Role, FileRoleDerived)
so nothing ever counts it as capture payload; model-input and
payload-retention accounting resolve payload bytes through index.jsonl
into the raw segments, which are kept byte-for-byte as before.

Sealing never fails or waits on the mux. A source whose stream cannot
become an honestly timed, seekable clip (B slices, slice headers the
muxer cannot parse, no random-access frame, or an outright mux failure)
seals without its playable.mp4 and the manifest's playable_notes names
the reason; a clip that omitted frames whose bytes were missing gets a
note as well. The gates mirror the warnings the episode-playable command
prints, hardened, because a manifest vouches for everything it lists.

Recovery of interrupted .partial episodes derives the same clips, since
it is the other path that seals; the truncated index tails are cut
first and the muxer counts a partial trailing line as unusable.

Cost: the remux is a copy, not a transcode, so the seal gains one more
pass over the camera bytes beside the checksum pass it already makes,
and the clip adds roughly the raw stream's size to the episode. That
size counts against the local quota through both the manifest total and
the disk-usage walk enforceQuota performs.
episode-playable now notices an episode whose camera sources all carry
a seal-time cameras/<source>/playable.mp4, says so, and converts
nothing, since reconverting would only duplicate bytes the manifest
already vouches for. The command remains the path for episodes sealed
by older agents and for sources whose seal-time mux was refused.

The wendy data command documentation explains the sealed-episode MP4,
its manifest listing with role "derived", the playable_notes failure
account, and the roughly-raw-stream-sized disk overhead counted against
the episode's quota. The data platform demo runbook now points at the
sealed clip instead of prescribing the laptop-side conversion.
Problem: a camera producer restart mid-episode splices a new Sequence
Parameter Set (SPS) and Picture Parameter Set (PPS) into the raw
stream, most likely at a different resolution. The seal-time remux kept
only the first SPS/PPS as the MP4's single decoder configuration and
silently dropped the changed ones, so every frame after the restart
would be decoded against the wrong parameters, while no existing gate
(B slices, unparseable slice headers, missing sync samples) fired: the
clip sealed into the manifest as a faithful rendering it is not.

Cause: muxAnnexBToMP4 treated every repeated parameter set as the
identical re-inline an encoder emits before each Instantaneous Decoder
Refresh (IDR) frame, without comparing bytes.

Solution: count byte-different SPS/PPS units as ParameterSetChanges on
the ClipResult (identical repeats stay uncounted), refuse the clip at
seal time with a playable_notes entry naming the producer restart, and
warn from the episode-playable command for laptop-side conversions.

Also adds regression tests for two seal contracts that held under
attack: a mux I/O failure never fails the seal and leaves nothing
half-written listed or behind, and recovery remuxes from the truncated
index instead of reusing a stale pre-crash clip, cleaning up any stray
temporary file.
The device manifest marks the seal-time cameras/<source>/playable.mp4 remux
with role "derived" and leaves the role empty for capture payload, but
buildEpisodeManifest projected only path, size, checksum, media type, format
and source id. The distinction died at the wire, so the cloud catalog stored
the remux with the camera's source id and video/mp4 media type and it read
back as a second capture on that source.

Vendor the EpisodeFileRole addition to cloud/data_ingest.proto, regenerate
cloudpb, and map data.File.Role onto the enum. Empty maps to CAPTURED,
FileRoleDerived to DERIVED. Any other string maps to UNSPECIFIED, which the
wire contract defines as captured; the enum has no unknown member and
inventing one would force readers to handle a state they cannot act on.

Only go/proto/gen/cloudpb/data_ingest.pb.go is regenerated. The committed
stubs carry a mix of protoc v7.34.0 and v7.35.1 stamps, so a plain
regeneration rewrites 80 files; each file's own committed stamp is restored
so the diff holds the intended change alone.
Follows the service-protos review on pull request #38. EpisodeManifest no
longer carries org_id or asset_id, so the transfer worker stops sending them.
The cloud reads both from the mutual Transport Layer Security (mTLS) client
certificate presented on the connection, which it already did; the manifest
copy was only ever compared against that certificate and then discarded.

Because the identity now travels solely on the certificate, the whole
org/asset thread through the upload path is dead and goes with it:
ingestClientFactory no longer returns them, and processEpisode, uploadEpisode
and buildEpisodeManifest no longer take them. dialFactory still reads
ProvisioningInfo, which is what builds the certificate identity header.

Byte offsets are unsigned on the wire now. The device keeps working in the
signed offsets its file interfaces use and converts at the boundary. A
server-reported committed offset above the signed maximum is treated as
"nothing committed", so the file restarts from zero rather than wrapping to a
negative offset.

Regenerated go/proto/gen/cloudpb from the updated contract. Every other
generated file's protoc version stamp was restored to its committed value, so
the diff carries only real changes.
@github-actions

Copy link
Copy Markdown
Contributor

Swift E2E Review

No Swift E2E review issues were generated for this run.


Report artifact: swift-e2e-tests.gh33393310203.run.0001

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant