feat(timeline): Phase 0 — the op-aware, undoable composition engine (v0.2.0)#34
Merged
Conversation
…-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
|
🎉 This PR is included in version 0.2.0 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cuts v0.2.0 (minor). Merging this to
maintriggers semantic-release: it writes the CHANGELOG, creates the GitHub release, and runsnpm publish --provenance. Please merge with a merge commit (not squash) so the individualfeat:/fix:commits are preserved onmain— 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
CompositionDocmutated only throughapplyOp, every edit a replayable, invertible diff.feat(timeline)×4): a shared revisioned store withrevCAS; 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.feat(timeline)):timeline show/at <T>/ compositedframethrough the real export path — the agent's "eyes".feat(timeline)): CLI, browser UI, and the chat agent all emitCompositionOpthrough one verb layer, so human-drag == agent-edit on the same undoable document.Security + audited polish (
fix)resolveInWorkspace+ a 403 boundary across every path-bearing registry tool and theadd_mediaverb; symlink-following hardened (incl. dangling links), fail-closed on access errors. The CLI stays unconfined (a user-typed path is consent).applyVerbsretry widened to the commit-attempt cap so a burst of concurrent edits can't drop its tail.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 (onlydist+ README/SKILL/LICENSE, no brand strings, no secrets). Release notes generate from brand-clean commit subjects.🤖 Generated with Claude Code