Skip to content

feat(timeline): Phase 0 — the op-aware, undoable composition engine (v0.2.0)#34

Merged
yultyyev merged 19 commits into
mainfrom
feat/timeline-engine-phase0
Jun 18, 2026
Merged

feat(timeline): Phase 0 — the op-aware, undoable composition engine (v0.2.0)#34
yultyyev merged 19 commits into
mainfrom
feat/timeline-engine-phase0

Conversation

@yultyyev

Copy link
Copy Markdown
Collaborator

Cuts v0.2.0 (minor). Merging this to main triggers semantic-release: it writes the CHANGELOG, creates the GitHub release, and runs npm publish --provenance. Please merge with a merge commit (not squash) so the individual feat:/fix: commits are preserved on main — semantic-release derives the version + notes from them, and a squash with a non-feat: subject would mask the minor bump.

What v0.2.0 delivers — Phase 0 (Foundations)

The timeline becomes a real op-aware, co-editable document. The wedge: one CompositionDoc mutated only through applyOp, every edit a replayable, invertible diff.

  • 0.1 — op-log hardening (feat(timeline) ×4): a shared revisioned store with rev CAS; exact per-op inverses (invertOp) + canonical doc order; the op-log data layer (op schema, batch inverse, undo-stack algebra); persisted op-log with working undo/redo.
  • 0.2 — read-only introspection (feat(timeline)): timeline show / at <T> / composited frame through the real export path — the agent's "eyes".
  • 0.3 — unified mutation (feat(timeline)): CLI, browser UI, and the chat agent all emit CompositionOp through one verb layer, so human-drag == agent-edit on the same undoable document.

Security + audited polish (fix)

  • Confine agent/UI media paths to the workspace (AGENTS.md non-negotiable feat(tools): add ingest — probe media and return MediaRef #3): resolveInWorkspace + a 403 boundary across every path-bearing registry tool and the add_media verb; symlink-following hardened (incl. dangling links), fail-closed on access errors. The CLI stays unconfined (a user-typed path is consent).
  • applyVerbs retry widened to the commit-attempt cap so a burst of concurrent edits can't drop its tail.
  • Per-clip fades suppressed only on real transition boundaries (no double-darken; no silently-dropped fade-to-black on a dangling last-clip transition).

Verification

A pre-release audit (5 dimensions: release-notes policy, npm publish surface, doc accuracy, residual/security, cross-slice integration) returned go, 0 blockers; an adversarial review of the polish slice found 3 real defects (one a major correctness regression) that were fixed before this PR. Every slice landed green and was adversarially reviewed.

Gate: pnpm type-check, pnpm test (539 pass), pnpm lint, pnpm build; npm tarball verified clean (only dist + README/SKILL/LICENSE, no brand strings, no secrets). Release notes generate from brand-clean commit subjects.

🤖 Generated with Claude Code

yultyyev and others added 19 commits June 17, 2026 14:53
…-store

Extract the session store's in-process write serialization + rev
compare-and-swap into a generic `createRevisionedStore` factory, and have
both the session log and the CompositionDoc consume it so the two stores
can't drift. The doc store previously did a lock-free read-apply-write,
which silently loses updates the moment the agent and `clip ui` co-edit
composition.json — the same bug class the session store already fixed.

- src/storage/revisioned-store.ts: shared serialization + CAS retry + CAS
  write + parse-free overwrite (independent write-chain per store).
- session/store.ts: refactored onto the factory; public API + behavior
  byte-identical (tests/session.test.ts unchanged and green).
- timeline/composition.ts: add `rev` (default 0, back-compat) to CompositionSchema.
- timeline/document-store.ts: mutateComposition now serialized + CAS; add
  writeCompositionIfUnchanged, overwriteComposition, CompositionConflictError.

applyOp's return type is unchanged — the reversible op-log / {doc, opsApplied}
change is the next PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(timeline): rev CAS for CompositionDoc via a shared revisioned-store
…order

Foundation for reversible editing (the op-log/undo lands next): a pure
`invertOp(comp, op): CompositionOp[]` that, given the pre-state, returns the
op(s) that undo `op` exactly. `applyOp`/`applyOps` keep their
`(comp) => Composition` signature, so no existing caller changes.

Two op-vocabulary additions are required for exact reversibility:
- `addTrack` gains an optional `index` (insert at z-order position) so a
  `removeTrack` can be undone at the original index.
- a new `clearTransform` op — the inverse of a `setTransform` that created a
  transform on a clip that had none.

`applyOp` now also canonicalizes track order on every output — clips by
(startSec, id), transitions by afterClipId — so two documents with identical
content are deep-equal regardless of the op history that built them. Array
order is semantically irrelevant (the compiler keys transitions by afterClipId
and rejects overlapping clips), and canonical order is what lets undo land on a
byte-identical document.

Verified by a round-trip property — applyOps(applyOp(pre, op), invertOp(pre, op))
deep-equals pre — across every op kind plus a LIFO sequence-undo test, including
clips carrying effects+transforms, multi-transition tracks, and equal-startSec
siblings (41 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(timeline): invertOp — exact per-op inverses + canonical doc order
…ack algebra

The pure data layer for undo/redo (the I/O wiring into mutateComposition + the
CLI lands next). No behavioural change yet — nothing reads or writes the log.

- `CompositionOpSchema` (ops.ts): a runtime validator for a CompositionOp, used
  to validate ops read back from the persisted op-log. An exhaustive
  `Record<CompositionOpKind, ...>` sample test parses every kind byte-stably so
  the schema can't drift from the hand-written union. Uses a defaults-free
  `PartialTransformSchema` for setTransform — `TransformSchema.partial()` would
  re-inflate omitted fields to defaults, turning a stored partial `{ scale: 2 }`
  into a full transform that overwrites x/y/opacity on redo.
- `applyOpsTracked` (ops.ts): applies a batch, threading state so each op's
  inverse is captured at the pre-state it saw, returning the new doc plus a
  single inverse op-list that undoes the whole batch.
- `doc-op-log.ts` (new): the undo-stack value type + pure algebra
  (record/undo/redo/canUndo/canRedo), `reconcile` (drops history on a doc/log
  rev mismatch — the crash-between-writes case — rather than risk a stale undo),
  and parse/serialize for the persisted `composition-ops.json`.

Verified: applyOpsTracked round-trips (apply batch then inverse = identity); the
stack algebra (truncate-redo-tail, cursor bounds); reconcile both ways;
parse/serialize round-trip + malformed-op rejection. 488 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(timeline): op-log data layer — op schema, batch inverse, undo-stack algebra
Wires the op-log data layer into document-store so timeline edits are
undoable end-to-end. The undo stack lives in composition-ops.json, separate
from the composition.json render doc.

- mutateComposition now records each batch (with its computed inverse) to the
  op-log and returns the new doc. Empty batches are a true no-op.
- undoLastDocOp / redoDocOp apply an entry's inverse / forward ops and move the
  log cursor; clip timeline undo|redo|log expose them.
- commitDocAndLog couples the doc + log writes in one serialized section,
  log-first so a crash leaves the log "ahead" (reconcile discards it) rather
  than the doc ahead (which could replay a stale inverse). doc.rev and log.rev
  advance together so reconcile can spot the crash window.

Hardening from an adversarial review of the diff (all latent — no live caller
yet, but fixed before the UI builds on these primitives):
- overwriteComposition / resetComposition now reset the op-log inside one
  exclusive section and advance rev past the last readable one, so a recovery
  that lands on a colliding rev can't match a stale log and replay an inverse
  that throws; resetComposition no longer regresses rev to 0.
- writeCompositionIfUnchanged resets undo history explicitly (a raw whole-doc
  CAS write has no recorded ops); undoable co-editing goes through
  mutateComposition.
- readDocOpLog reads both files inside the exclusive section so an interleaved
  in-process commit can't make a consistent log momentarily read as empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(timeline): persist the op-log — working undo/redo for the timeline
…t's eyes)

Three non-mutating views so an agent can see the document it edits:

- clip timeline show (enriched): rev, exportability (dry-runs the compiler and
  reports exportable + blockers), and structured tracks/clips (id, kind, start,
  end, duration, label) + transitions — not just a clip count.
- clip timeline at <atSec>: every clip live at a timeline time, across all
  tracks, with the offset into each (clipsAtTime, half-open [startSec, end)).
- clip timeline frame <atSec> [output]: render ONE composited frame, through the
  REAL segment path (buildSegmentStep) so the preview can't diverge from export.
  Maps doc-local time to the post-speed segment timebase so speed clips land on
  the right source frame.

- compile.ts: buildFrameAtPlan; the abutting check is extracted into a shared
  assertAbutting() used by BOTH export and frame, so a frame on an unexportable
  (overlapping/gapped) doc fails the same way export does instead of silently
  guessing a clip — an introspection tool must tell the truth about the doc.

Verified: arg-array unit tests (offsets, speed timebase, boundary/gap/overlap
throws) plus an end-to-end CLI render of a real 1920x1080 JPEG. The empty-string
positional now errors instead of coercing to time 0. 511 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(timeline): read-only introspection — show / at / frame (the agent's eyes)
… path

Both the chat agent and `clip ui` edited via legacy file-tools that bypassed
the CompositionDoc (file-in/file-out, no undo). This routes them through the
same non-destructive, undoable document the CLI already mutates, via a verb
layer.

- src/timeline/verbs.ts: CompositionVerbSchema (add_media/add_text/add_color/
  trim/move/split/remove/transition/set_transform, each with .describe() for the
  agent) + lowerVerb/lowerVerbs, which isolate the impure parts (ingest, id
  minting, append-point) outside the pure reducer.
- document-store.applyVerbs: lower → mutateComposition, applied + recorded as one
  undoable edit.
- src/ui/timeline-tools.ts: op-aware agent tools (timeline_edit/show/undo/redo/
  history); buildSystemPrompt rewritten to lead with them (legacy file-tools kept
  as a fallback for operations the engine doesn't cover yet).
- server.ts: GET /api/timeline, POST /api/timeline/verbs.

Hardening from an adversarial review of the diff:
- intent-free trim/move/set_transform verbs lower to NOTHING (no more do-nothing
  ops polluting the undo stack / clobbering redo).
- applyVerbs re-lowers under an expectedBaseRev CAS, so a concurrent in-process
  edit can't bake a stale append point into overlapping clips.
- the verbs route maps conflicts/stale clip refs to 409/422, not 500.
- add_* take an optional `id` so a batch can reference a clip it just created.
- trackEnd/ensureTrack are now shared with the CLI (one definition).

A pre-existing media-ingest path-confinement gap (orthogonal, would break CLI
editing of files outside the workspace if naively fixed) is tracked separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(timeline): unify the agent + UI onto the op-aware, undoable edit path
The chat agent and the localhost `clip ui` routes dispatch model/HTTP-
supplied file paths straight into FFmpeg — via every tool in the registry
(POST /api/tools/:name + the agent's tool calls) and via the add_media
verb (POST /api/timeline/verbs). resolveInput returns absolute paths as-is,
so any of these untrusted surfaces could read an arbitrary file off disk:
POST /api/tools/render {"input":"/etc/passwd"} re-encodes it into a
workspace file the UI then serves back — an exfiltration channel. Violates
AGENTS.md non-negotiable #3.

Add resolveInWorkspace (relative resolves against the workspace; an absolute
path must already sit inside it; ..-traversal and out-of-tree absolutes throw
WorkspaceBoundaryError) and route EVERY path-bearing registry tool plus the
add_media verb through it — not just ingest. Both server routes map the error
to HTTP 403. The trusted CLI keeps resolveInput (a user-typed path is consent)
and never dispatches through the registry, so editing files anywhere on disk
still works.

Containment is by resolved-path prefix and does not yet follow symlinks
(documented residual: an in-workspace symlink can still point out — hardening
via realpath is a follow-up). The boundary message is audience-neutral so it
reads sensibly both as agent tool-result data and in the 403 body.
fix(ui): confine agent/UI media paths to the workspace
…orkspace

resolveInWorkspace checked containment on the lexically-resolved path, so an
in-workspace symlink pointing out of the tree was followed at FFmpeg-open time —
the boundary it exists to enforce (AGENTS.md non-negotiable #3) leaked.

Canonicalize both the workspace and the resolved path before comparing: walk the
existing prefix with lstat, follow symlinks explicitly (so a DANGLING in-workspace
link resolves toward its real, possibly out-of-tree, target instead of looking
like an in-workspace leaf), and treat only a genuine ENOENT as a not-yet-created
tail — EACCES / symlink loops fail closed. A symlinked workspace root (macOS
/var -> /private/var) is collapsed too, so legitimate paths aren't rejected. The
lexical resolved path is still returned, so an in-workspace symlink to an
in-workspace target keeps working.

Residual (documented): an open-time TOCTOU race needs O_NOFOLLOW at the open
site, out of scope here.
applyVerbs re-lowers and retries on a CompositionConflictError (the optimistic
expectedBaseRev guard) but capped the loop at a hardcoded 4, while the inner
commit loop allows MAX_COMMIT_ATTEMPTS (8). A burst of >4 concurrent edits against
the same base rev therefore dropped its tail: the 5th+ writer exhausted its
retries and lost the edit. Align the cap to MAX_COMMIT_ATTEMPTS so each concurrent
edit re-lowers against fresh state and lands.

Latent today (the shipping UI has no concurrent caller of the verbs path), but
closed before the API gains one. expectedBaseRev stays load-bearing — the guard
is what forces the re-lower. Adds a deterministic regression test that races a
burst through a barrier so each writer needs an increasing attempt count.
A per-clip fadeIn/fadeOut on the same cut as a transition double-darkened (the
xfade already blends that window). Suppress the fade on a transition boundary so
the xfade owns the blend — but only when the fold actually emits an xfade there.
A transition keyed to the LAST clip (e.g. left dangling after the next clip was
removed) blends nothing, so gate fadeOut suppression on a following clip existing;
otherwise a fade-to-black at the timeline end was silently dropped to a hard cut.
Outer-boundary fades and fades on plain cuts are untouched.

Adds regression tests for the matched-boundary case and the dangling-transition
(last-clip) case.
- Document the `clip timeline` composition workflow (new → add-media →
  transition → show → export, with undo/redo and read-only show/at/frame) in
  README, SKILL.md, and the top-level `clip --help` (it was reachable but unlisted).
- Reframe the source-of-truth narrative: the Composition document is canonical
  for assembled edits; session.json is the append-only op log of tool calls
  (was "the session is the source of truth", which contradicted the code and
  AGENTS.md non-negotiable #2).
- Refresh the stale "356 tests" claim to 539 (README badge + FAQ, llms.txt).
- Remove competitor brand names from the TransitionKind JSDoc (it ships in
  dist/index.d.ts); the README/llms comparison prose keeps its intentional naming.
- Drop the hand-maintained, drifting SKILL.md frontmatter version field.
chore: fold the v0.2.0 release-audit follow-ups into the release
@yultyyev yultyyev merged commit 077f0f4 into main Jun 18, 2026
1 check passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.2.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant